diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100755
index 0000000..d1fbdf7
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,63 @@
+# Contributing to OTW-Archive
+
+## Reporting bugs
+
+We maintain a [Jira issue tracker](https://otwarchive.atlassian.net/projects/AO3/issues) for developers,
+and a [list of Known Issues](https://archiveofourown.org/known_issues) for
+[Archive of Our Own](https://archiveofourown.org) users, neither of which are
+publicly editable.
+
+If you need help using the site, or want to report an issue you have found,
+please [contact the AO3 Support team](https://archiveofourown.org/support). Our Support team is staffed by volunteers, so please wait for a response before submitting another ticket. Duplicate submissions will not make things happen faster.
+
+
+## Reporting security issues
+
+Please refer to [SECURITY.md](https://github.com/otwcode/otwarchive/blob/master/SECURITY.md).
+
+
+## Updating documentation
+
+Our [development wiki](https://github.com/otwcode/otwarchive/wiki) is publicly
+editable. Unless a page says at the top that it should only be edited by
+official OTW volunteers, please feel free to make changes!
+
+
+## Suggesting new features
+
+Please [contact the AO3 Support team](https://archiveofourown.org/support). Our Support team is staffed by volunteers, so please wait for a response before submitting another ticket. Duplicate submissions will not make things happen faster.
+
+
+## Contributing code
+
+**We only accept pull requests for issues we have already added to [Jira](https://otwarchive.atlassian.net)**,
+with the exception of spelling corrections and documentation improvements
+(e.g. any Markdown files). We also do not accept code generated by AI tools; for more information,
+please refer to [our commit policy](https://github.com/otwcode/otwarchive/wiki/Commit-Policy#scary-legal-stuff).
+
+Please check out our development wiki for more information on:
+
+- [how to set up a development environment](https://github.com/otwcode/otwarchive/wiki)
+- [code conventions](https://github.com/otwcode/otwarchive/wiki/Commit-policy)
+
+### Workflow
+
+1. If you're a new contributor, start with an [issue reserved for first timers](https://otwarchive.atlassian.net/issues/?filter=13119). Otherwise, or if you're up for a challenge, choose any [open and unassigned issue](https://otwarchive.atlassian.net/issues/?filter=10800) available. (If you're a new contributor, don't worry about claiming the issue for now. If you make a Jira account, you'll get permissions for claiming issues in step 5.)
+2. Write code to address the issue. If you already have Jira permissions, be sure to set the issue status to ["In Progress"](https://github.com/otwcode/otwarchive/wiki/Issue-Tracking-with-Jira#status-guide).
+3. Optional: Create a Jira account if you'd like the ability to comment on, assign, and transition issues. Please make sure the Full Name on your Jira account either closely matches the name you'd like us to credit in the release notes or includes it in parentheses, e.g. "Nickname (CREDIT NAME)."
+4. Submit the code with a pull request following the checklist on [our template](https://github.com/otwcode/otwarchive/blob/master/.github/PULL_REQUEST_TEMPLATE.md).
+5. Once you've submitted a pull request, we'll review your code and give you permissions on Jira. (Please be patient with us! Due to our workload, it may take some time before we can review and eventually merge your pull request.)
+6. Once your pull request is merged, we will deploy it to our internal testing site and our QA team will check that everything is working as intended.
+7. If something is not working as intended, we may set the issue to ["Broken on Test"](https://github.com/otwcode/otwarchive/wiki/Issue-Tracking-with-Jira#status-guide) and ask you to make further changes in new pull requests.
+8. If all is well, your contribution will be deployed to the [Archive of Our Own](https://archiveofourown.org) and you will be credited in the [release notes](https://archiveofourown.org/admin_posts?tag=1)!
+
+
+## Volunteering for the OTW
+
+If you would like to donate more of your time and expertise in a multi-national,
+inclusive, fandom-oriented team, you might enjoy [becoming an official OTW volunteer](http://transformativeworks.org/how-you-can-help/volunteer).
+
+
+## Questions?
+
+[Drop us an email](mailto:otw-coders@transformativeworks.org) if you have any questions.
diff --git a/Capfile b/Capfile
new file mode 100755
index 0000000..e04728e
--- /dev/null
+++ b/Capfile
@@ -0,0 +1,4 @@
+load 'deploy' if respond_to?(:namespace) # cap2 differentiator
+Dir['vendor/plugins/*/recipes/*.rb'].each { |plugin| load(plugin) }
+
+load 'config/deploy' # remove this line to skip loading any of the default tasks
\ No newline at end of file
diff --git a/Gemfile b/Gemfile
new file mode 100755
index 0000000..ec061e3
--- /dev/null
+++ b/Gemfile
@@ -0,0 +1,186 @@
+source 'https://rubygems.org'
+
+ruby "~> 3.2.7"
+
+gem 'test-unit', '~> 3.2'
+
+gem 'bundler'
+
+gem "rails", "~> 7.2"
+gem "rails-i18n"
+gem "rack", "~> 2.2"
+gem "sprockets", "< 4"
+
+gem 'rails-observers', git: 'https://github.com/rails/rails-observers'
+gem 'actionpack-page_caching'
+gem 'rails-controller-testing'
+
+# Database
+# gem 'sqlite3-ruby', require: 'sqlite3'
+gem "mysql2"
+
+gem 'rack-attack'
+
+# Version of redis-rb gem
+# We are currently running Redis 3.2.1 (7/2018)
+gem "redis", "~> 3.3.5"
+gem 'redis-namespace'
+
+# Here are all our application-specific gems
+
+# Used to convert strings to ascii
+gem 'unicode'
+gem 'unidecoder'
+gem 'unicode_utils', '>=1.4.0'
+
+# Lograge is opinionated, very opinionated.
+gem "lograge" # https://github.com/roidrage/lograge
+
+gem 'will_paginate', '>=3.0.2'
+gem "pagy", "~> 9.3"
+gem 'acts_as_list', '~> 0.9.7'
+gem 'akismetor'
+
+gem 'httparty'
+gem 'htmlentities'
+gem 'whenever', '~>0.6.2', require: false
+gem 'nokogiri', '>= 1.8.5'
+gem 'mechanize'
+gem 'sanitize', '>= 4.6.5'
+gem "rest-client", "~> 2.1.0", require: "rest_client"
+gem 'resque', '>=1.14.0'
+gem 'resque-scheduler'
+gem 'after_commit_everywhere'
+#gem 'daemon-spawn', require: 'daemon_spawn'
+gem "elasticsearch", "8.18.0"
+gem "aws-sdk-s3"
+gem 'css_parser'
+
+gem "terrapin"
+
+# for looking up image dimensions quickly
+gem 'fastimage'
+
+# Gems for authentication
+gem "devise"
+gem "devise-async" # To mails through queues
+gem "bcrypt"
+gem "devise-pwned_password"
+
+# Needed for modern ssh
+gem "ed25519", ">= 1.2", "< 2.0"
+gem "bcrypt_pbkdf", ">= 1.0", "< 2.0"
+
+# A highly updated version of the authorization plugin
+gem 'permit_yo'
+gem "pundit"
+
+# fix for annoying UTF-8 error messages as per this:
+# http://openhood.com/rack/ruby/2010/07/15/rack-test-warning/
+gem 'escape_utils', '1.2.1'
+
+gem 'timeliness'
+
+# for generating graphs
+gem 'google_visualr', git: 'https://github.com/winston/google_visualr'
+
+# Globalize for translations
+gem "globalize", "~> 7.0"
+
+# Add a clean notifier that shows we are on dev or test
+gem 'rack-dev-mark', '>=0.7.8'
+
+#Phrase-app
+gem 'phraseapp-in-context-editor-ruby', '>=1.0.6'
+
+# For URL mangling
+gem 'addressable'
+gem 'audited', '~> 5.3'
+
+# For controlling application behavour dynamically
+gem 'rollout'
+
+# Use update memcached client with kinder, gentler I/O for Ruby
+gem 'connection_pool'
+gem 'dalli'
+gem 'kgio', '2.10.0'
+
+gem "marcel", "1.0.2"
+
+# Library for helping run pt-online-schema-change commands:
+gem "departure", "~> 6.8"
+
+gem "rack-timeout"
+gem "puma_worker_killer"
+
+group :test do
+ gem "rspec-rails", "~> 6.0"
+ gem 'pickle'
+ gem 'shoulda'
+ gem "capybara"
+ gem "cucumber"
+ gem 'database_cleaner'
+ gem "selenium-webdriver"
+ gem 'capybara-screenshot'
+ gem 'cucumber-rails', require: false
+ gem 'launchy' # So you can do Then show me the page
+
+ # Record and replay data from external URLs
+ gem "vcr", "~> 6.2"
+ gem "webmock"
+ gem 'timecop'
+ gem 'cucumber-timecop', require: false
+ # Code coverage
+ gem "simplecov"
+ gem "simplecov-cobertura", require: false
+ gem 'email_spec', '1.6.0'
+ gem "n_plus_one_control"
+end
+
+group :test, :development do
+ gem 'awesome_print'
+ gem 'brakeman'
+ gem 'pry-byebug'
+ gem 'whiny_validation'
+ gem "factory_bot_rails"
+ gem 'minitest'
+ gem "i18n-tasks", require: false
+end
+
+group :development do
+ gem 'bundler-audit'
+ gem 'active_record_query_trace', '~> 1.6', '>= 1.6.1'
+end
+
+group :linters do
+ gem "erb_lint", "0.4.0"
+ gem "rubocop", "1.22.3"
+ gem "rubocop-rails", "2.12.4"
+ gem "rubocop-rspec", "2.6.0"
+end
+
+group :test, :development, :staging do
+ gem 'bullet', '>= 5.7.3'
+ gem "factory_bot", require: false
+ gem "faker", require: false
+end
+
+# Deploy with Capistrano
+gem 'capistrano-gitflow_version', '>=0.0.3', require: false
+gem 'rvm-capistrano'
+
+# Use unicorn as the web server
+gem 'unicorn', '~> 5.5', require: false
+# Install puma so we can migrate to it
+gem "puma", "~> 6.5.0"
+# Use god as the monitor
+gem 'god', '~> 0.13.7'
+
+group :staging, :production do
+ gem "stackprof"
+ gem "sentry-ruby"
+ gem "sentry-rails"
+ gem "sentry-resque"
+end
+
+gem "image_processing", "~> 1.12"
diff --git a/Gemfile.lock b/Gemfile.lock
new file mode 100755
index 0000000..c1d7682
--- /dev/null
+++ b/Gemfile.lock
@@ -0,0 +1,781 @@
+GIT
+ remote: https://github.com/rails/rails-observers
+ revision: 389b577322d8b17336730b2dc4e179060a23c8e7
+ specs:
+ rails-observers (0.2.0)
+ activemodel (>= 4.2)
+
+GIT
+ remote: https://github.com/winston/google_visualr
+ revision: 17b97114a345baadd011e7b442b9a6c91a2b7ab5
+ specs:
+ google_visualr (2.5.1)
+
+GEM
+ remote: https://rubygems.org/
+ specs:
+ aaronh-chronic (0.3.9)
+ actioncable (7.2.2.2)
+ actionpack (= 7.2.2.2)
+ activesupport (= 7.2.2.2)
+ nio4r (~> 2.0)
+ websocket-driver (>= 0.6.1)
+ zeitwerk (~> 2.6)
+ actionmailbox (7.2.2.2)
+ actionpack (= 7.2.2.2)
+ activejob (= 7.2.2.2)
+ activerecord (= 7.2.2.2)
+ activestorage (= 7.2.2.2)
+ activesupport (= 7.2.2.2)
+ mail (>= 2.8.0)
+ actionmailer (7.2.2.2)
+ actionpack (= 7.2.2.2)
+ actionview (= 7.2.2.2)
+ activejob (= 7.2.2.2)
+ activesupport (= 7.2.2.2)
+ mail (>= 2.8.0)
+ rails-dom-testing (~> 2.2)
+ actionpack (7.2.2.2)
+ actionview (= 7.2.2.2)
+ activesupport (= 7.2.2.2)
+ nokogiri (>= 1.8.5)
+ racc
+ rack (>= 2.2.4, < 3.2)
+ rack-session (>= 1.0.1)
+ rack-test (>= 0.6.3)
+ rails-dom-testing (~> 2.2)
+ rails-html-sanitizer (~> 1.6)
+ useragent (~> 0.16)
+ actionpack-page_caching (1.2.4)
+ actionpack (>= 4.0.0)
+ actiontext (7.2.2.2)
+ actionpack (= 7.2.2.2)
+ activerecord (= 7.2.2.2)
+ activestorage (= 7.2.2.2)
+ activesupport (= 7.2.2.2)
+ globalid (>= 0.6.0)
+ nokogiri (>= 1.8.5)
+ actionview (7.2.2.2)
+ activesupport (= 7.2.2.2)
+ builder (~> 3.1)
+ erubi (~> 1.11)
+ rails-dom-testing (~> 2.2)
+ rails-html-sanitizer (~> 1.6)
+ active_record_query_trace (1.8.2)
+ activerecord (>= 6.0.0)
+ activejob (7.2.2.2)
+ activesupport (= 7.2.2.2)
+ globalid (>= 0.3.6)
+ activemodel (7.2.2.2)
+ activesupport (= 7.2.2.2)
+ activerecord (7.2.2.2)
+ activemodel (= 7.2.2.2)
+ activesupport (= 7.2.2.2)
+ timeout (>= 0.4.0)
+ activestorage (7.2.2.2)
+ actionpack (= 7.2.2.2)
+ activejob (= 7.2.2.2)
+ activerecord (= 7.2.2.2)
+ activesupport (= 7.2.2.2)
+ marcel (~> 1.0)
+ activesupport (7.2.2.2)
+ base64
+ benchmark (>= 0.3)
+ bigdecimal
+ concurrent-ruby (~> 1.0, >= 1.3.1)
+ connection_pool (>= 2.2.5)
+ drb
+ i18n (>= 1.6, < 2)
+ logger (>= 1.4.2)
+ minitest (>= 5.1)
+ securerandom (>= 0.3)
+ tzinfo (~> 2.0, >= 2.0.5)
+ acts_as_list (0.9.19)
+ activerecord (>= 3.0)
+ addressable (2.8.7)
+ public_suffix (>= 2.0.2, < 7.0)
+ after_commit_everywhere (1.6.0)
+ activerecord (>= 4.2)
+ activesupport
+ akismetor (1.0.0)
+ ast (2.4.3)
+ audited (5.8.0)
+ activerecord (>= 5.2, < 8.2)
+ activesupport (>= 5.2, < 8.2)
+ awesome_print (1.9.2)
+ aws-eventstream (1.3.0)
+ aws-partitions (1.895.0)
+ aws-sdk-core (3.191.3)
+ aws-eventstream (~> 1, >= 1.3.0)
+ aws-partitions (~> 1, >= 1.651.0)
+ aws-sigv4 (~> 1.8)
+ jmespath (~> 1, >= 1.6.1)
+ aws-sdk-kms (1.77.0)
+ aws-sdk-core (~> 3, >= 3.191.0)
+ aws-sigv4 (~> 1.1)
+ aws-sdk-s3 (1.143.0)
+ aws-sdk-core (~> 3, >= 3.191.0)
+ aws-sdk-kms (~> 1)
+ aws-sigv4 (~> 1.8)
+ aws-sigv4 (1.8.0)
+ aws-eventstream (~> 1, >= 1.0.2)
+ base64 (0.3.0)
+ bcrypt (3.1.20)
+ bcrypt_pbkdf (1.1.0)
+ benchmark (0.4.1)
+ better_html (2.0.2)
+ actionview (>= 6.0)
+ activesupport (>= 6.0)
+ ast (~> 2.0)
+ erubi (~> 1.4)
+ parser (>= 2.4)
+ smart_properties
+ bigdecimal (3.2.2)
+ brakeman (7.0.2)
+ racc
+ builder (3.3.0)
+ bullet (8.0.8)
+ activesupport (>= 3.0.0)
+ uniform_notifier (~> 1.11)
+ bundler-audit (0.9.1)
+ bundler (>= 1.2.0, < 3)
+ thor (~> 1.0)
+ byebug (12.0.0)
+ capistrano (2.15.11)
+ highline
+ net-scp (>= 1.0.0)
+ net-sftp (>= 2.0.0)
+ net-ssh (>= 2.0.14)
+ net-ssh-gateway (>= 1.1.0)
+ capistrano-ext (1.2.1)
+ capistrano (>= 1.0.0)
+ capistrano-gitflow_version (0.0.3.1)
+ capistrano-ext (>= 1.2.1)
+ stringex
+ 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)
+ capybara-screenshot (1.0.26)
+ capybara (>= 1.0, < 4)
+ launchy
+ chronic (0.10.2)
+ climate_control (1.2.0)
+ coderay (1.1.3)
+ concurrent-ruby (1.3.5)
+ connection_pool (2.5.3)
+ crack (1.0.0)
+ bigdecimal
+ rexml
+ crass (1.0.6)
+ css_parser (1.16.0)
+ addressable
+ cucumber (9.2.1)
+ builder (~> 3.2)
+ cucumber-ci-environment (> 9, < 11)
+ cucumber-core (> 13, < 14)
+ cucumber-cucumber-expressions (~> 17.0)
+ cucumber-gherkin (> 24, < 28)
+ cucumber-html-formatter (> 20.3, < 22)
+ cucumber-messages (> 19, < 25)
+ diff-lcs (~> 1.5)
+ mini_mime (~> 1.1)
+ multi_test (~> 1.1)
+ sys-uname (~> 1.2)
+ cucumber-ci-environment (10.0.1)
+ cucumber-core (13.0.3)
+ cucumber-gherkin (>= 27, < 28)
+ cucumber-messages (>= 20, < 23)
+ cucumber-tag-expressions (> 5, < 7)
+ cucumber-cucumber-expressions (17.1.0)
+ bigdecimal
+ cucumber-gherkin (27.0.0)
+ cucumber-messages (>= 19.1.4, < 23)
+ cucumber-html-formatter (21.13.0)
+ cucumber-messages (> 19, < 28)
+ cucumber-messages (22.0.0)
+ cucumber-rails (3.1.1)
+ capybara (>= 3.11, < 4)
+ cucumber (>= 5, < 10)
+ railties (>= 5.2, < 9)
+ cucumber-tag-expressions (6.1.2)
+ cucumber-timecop (0.0.6)
+ chronic
+ cucumber
+ timecop
+ dalli (3.2.8)
+ database_cleaner (2.1.0)
+ database_cleaner-active_record (>= 2, < 3)
+ database_cleaner-active_record (2.2.1)
+ activerecord (>= 5.a)
+ database_cleaner-core (~> 2.0.0)
+ database_cleaner-core (2.0.1)
+ date (3.4.1)
+ departure (6.8.0)
+ activerecord (>= 6.0.0, < 7.3.0, != 7.0.0)
+ mysql2 (>= 0.4.0, < 0.6.0)
+ railties (>= 6.0.0, < 7.3.0, != 7.0.0)
+ devise (4.9.3)
+ bcrypt (~> 3.0)
+ orm_adapter (~> 0.1)
+ railties (>= 4.1.0)
+ responders
+ warden (~> 1.2.3)
+ devise-async (1.0.0)
+ activejob (>= 5.0)
+ devise (>= 4.0)
+ devise-pwned_password (0.1.12)
+ devise (~> 4)
+ pwned (~> 2.4)
+ diff-lcs (1.6.2)
+ docile (1.4.1)
+ domain_name (0.6.20240107)
+ drb (2.2.3)
+ ed25519 (1.3.0)
+ elastic-transport (8.3.5)
+ faraday (< 3)
+ multi_json
+ elasticsearch (8.18.0)
+ elastic-transport (~> 8.3)
+ elasticsearch-api (= 8.18.0)
+ elasticsearch-api (8.18.0)
+ multi_json
+ email_spec (1.6.0)
+ launchy (~> 2.1)
+ mail (~> 2.2)
+ erb (5.0.2)
+ erb_lint (0.4.0)
+ activesupport
+ better_html (>= 2.0.1)
+ parser (>= 2.7.1.4)
+ rainbow
+ rubocop
+ smart_properties
+ erubi (1.13.1)
+ escape_utils (1.2.1)
+ et-orbi (1.2.11)
+ tzinfo
+ factory_bot (6.5.4)
+ activesupport (>= 6.1.0)
+ factory_bot_rails (6.5.0)
+ factory_bot (~> 6.5)
+ railties (>= 6.1.0)
+ faker (3.5.2)
+ i18n (>= 1.8.11, < 2)
+ faraday (2.13.1)
+ faraday-net_http (>= 2.0, < 3.5)
+ json
+ logger
+ faraday-net_http (3.4.0)
+ net-http (>= 0.5.0)
+ fastimage (2.3.0)
+ ffi (1.17.2)
+ fugit (1.11.1)
+ et-orbi (~> 1, >= 1.2.11)
+ raabro (~> 1.4)
+ get_process_mem (1.0.0)
+ bigdecimal (>= 2.0)
+ ffi (~> 1.0)
+ globalid (1.2.1)
+ activesupport (>= 6.1)
+ globalize (7.0.0)
+ activemodel (>= 7.0, < 8.1)
+ activerecord (>= 7.0, < 8.1)
+ activesupport (>= 7.0, < 8.1)
+ request_store (~> 1.0)
+ god (0.13.7)
+ hashdiff (1.2.0)
+ highline (3.1.2)
+ reline
+ htmlentities (4.3.4)
+ http-accept (1.7.0)
+ http-cookie (1.0.5)
+ domain_name (~> 0.5)
+ httparty (0.21.0)
+ mini_mime (>= 1.0.0)
+ multi_xml (>= 0.5.2)
+ i18n (1.14.7)
+ concurrent-ruby (~> 1.0)
+ i18n-tasks (1.0.15)
+ activesupport (>= 4.0.2)
+ ast (>= 2.1.0)
+ erubi
+ highline (>= 2.0.0)
+ i18n
+ parser (>= 3.2.2.1)
+ rails-i18n
+ rainbow (>= 2.2.2, < 4.0)
+ ruby-progressbar (~> 1.8, >= 1.8.1)
+ terminal-table (>= 1.5.1)
+ image_processing (1.12.2)
+ mini_magick (>= 4.9.5, < 5)
+ ruby-vips (>= 2.0.17, < 3)
+ io-console (0.8.1)
+ irb (1.15.2)
+ pp (>= 0.6.0)
+ rdoc (>= 4.0.0)
+ reline (>= 0.4.2)
+ jmespath (1.6.2)
+ json (2.12.2)
+ kgio (2.10.0)
+ launchy (2.5.2)
+ addressable (~> 2.8)
+ logger (1.7.0)
+ lograge (0.14.0)
+ actionpack (>= 4)
+ activesupport (>= 4)
+ railties (>= 4)
+ request_store (~> 1.0)
+ loofah (2.24.1)
+ 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.3)
+ mechanize (2.12.2)
+ addressable (~> 2.8)
+ base64
+ domain_name (~> 0.5, >= 0.5.20190701)
+ http-cookie (~> 1.0, >= 1.0.3)
+ mime-types (~> 3.3)
+ net-http-digest_auth (~> 1.4, >= 1.4.1)
+ net-http-persistent (>= 2.5.2, < 5.0.dev)
+ nkf
+ nokogiri (~> 1.11, >= 1.11.2)
+ rubyntlm (~> 0.6, >= 0.6.3)
+ webrick (~> 1.7)
+ webrobots (~> 0.1.2)
+ method_source (1.1.0)
+ mime-types (3.5.2)
+ mime-types-data (~> 3.2015)
+ mime-types-data (3.2024.0206)
+ mini_magick (4.12.0)
+ mini_mime (1.1.5)
+ mini_portile2 (2.8.9)
+ minitest (5.25.5)
+ mono_logger (1.1.2)
+ multi_json (1.15.0)
+ multi_test (1.1.0)
+ multi_xml (0.6.0)
+ mustermann (3.0.0)
+ ruby2_keywords (~> 0.0.1)
+ mysql2 (0.5.6)
+ n_plus_one_control (0.7.2)
+ net-http (0.6.0)
+ uri
+ net-http-digest_auth (1.4.1)
+ net-http-persistent (4.0.2)
+ connection_pool (~> 2.2)
+ net-imap (0.5.9)
+ date
+ net-protocol
+ net-pop (0.1.2)
+ net-protocol
+ net-protocol (0.2.2)
+ timeout
+ net-scp (4.0.0)
+ net-ssh (>= 2.6.5, < 8.0.0)
+ net-sftp (4.0.0)
+ net-ssh (>= 5.0.0, < 8.0.0)
+ net-smtp (0.5.1)
+ net-protocol
+ net-ssh (7.2.1)
+ net-ssh-gateway (2.0.0)
+ net-ssh (>= 4.0.0)
+ netrc (0.11.0)
+ nio4r (2.7.4)
+ nkf (0.2.0)
+ nokogiri (1.18.9)
+ mini_portile2 (~> 2.8.2)
+ racc (~> 1.4)
+ orm_adapter (0.5.0)
+ pagy (9.3.3)
+ parallel (1.25.1)
+ parser (3.3.8.0)
+ ast (~> 2.4.1)
+ racc
+ permit_yo (2.1.3)
+ phraseapp-in-context-editor-ruby (1.4.0)
+ i18n (>= 0.6)
+ json (>= 1.8, < 3)
+ phraseapp-ruby (~> 1.3)
+ request_store (~> 1.3)
+ phraseapp-ruby (1.6.0)
+ pickle (0.9.0)
+ cucumber (>= 3.0, < 10.0)
+ rake
+ power_assert (2.0.3)
+ pp (0.6.2)
+ prettyprint
+ prettyprint (0.2.0)
+ pry (0.15.2)
+ coderay (~> 1.1)
+ method_source (~> 1.0)
+ pry-byebug (3.11.0)
+ byebug (~> 12.0)
+ pry (>= 0.13, < 0.16)
+ psych (5.2.6)
+ date
+ stringio
+ public_suffix (6.0.2)
+ puma (6.5.0)
+ nio4r (~> 2.0)
+ puma_worker_killer (1.0.0)
+ bigdecimal (>= 2.0)
+ get_process_mem (>= 0.2)
+ puma (>= 2.7)
+ pundit (2.3.1)
+ activesupport (>= 3.0.0)
+ pwned (2.4.1)
+ raabro (1.4.0)
+ racc (1.8.1)
+ rack (2.2.17)
+ rack-attack (6.7.0)
+ rack (>= 1.0, < 4)
+ rack-dev-mark (0.8.0)
+ rack (>= 1.1, < 4.0)
+ rack-protection (3.2.0)
+ base64 (>= 0.1.0)
+ rack (~> 2.2, >= 2.2.4)
+ rack-session (1.0.2)
+ rack (< 3)
+ rack-test (2.2.0)
+ rack (>= 1.3)
+ rack-timeout (0.7.0)
+ rackup (1.0.1)
+ rack (< 3)
+ webrick
+ rails (7.2.2.2)
+ actioncable (= 7.2.2.2)
+ actionmailbox (= 7.2.2.2)
+ actionmailer (= 7.2.2.2)
+ actionpack (= 7.2.2.2)
+ actiontext (= 7.2.2.2)
+ actionview (= 7.2.2.2)
+ activejob (= 7.2.2.2)
+ activemodel (= 7.2.2.2)
+ activerecord (= 7.2.2.2)
+ activestorage (= 7.2.2.2)
+ activesupport (= 7.2.2.2)
+ bundler (>= 1.15.0)
+ railties (= 7.2.2.2)
+ rails-controller-testing (1.0.5)
+ actionpack (>= 5.0.1.rc1)
+ actionview (>= 5.0.1.rc1)
+ activesupport (>= 5.0.1.rc1)
+ rails-dom-testing (2.3.0)
+ activesupport (>= 5.0.0)
+ minitest
+ nokogiri (>= 1.6)
+ rails-html-sanitizer (1.6.2)
+ loofah (~> 2.21)
+ nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
+ rails-i18n (7.0.9)
+ i18n (>= 0.7, < 2)
+ railties (>= 6.0.0, < 8)
+ railties (7.2.2.2)
+ actionpack (= 7.2.2.2)
+ activesupport (= 7.2.2.2)
+ irb (~> 1.13)
+ rackup (>= 1.0.0)
+ rake (>= 12.2)
+ thor (~> 1.0, >= 1.2.2)
+ zeitwerk (~> 2.6)
+ rainbow (3.1.1)
+ raindrops (0.20.1)
+ rake (13.3.0)
+ rdoc (6.14.2)
+ erb
+ psych (>= 4.0.0)
+ redis (3.3.5)
+ redis-namespace (1.8.2)
+ redis (>= 3.0.4)
+ regexp_parser (2.10.0)
+ reline (0.6.2)
+ io-console (~> 0.5)
+ request_store (1.6.0)
+ rack (>= 1.4)
+ responders (3.1.1)
+ actionpack (>= 5.2)
+ railties (>= 5.2)
+ resque (2.6.0)
+ mono_logger (~> 1.0)
+ multi_json (~> 1.0)
+ redis-namespace (~> 1.6)
+ sinatra (>= 0.9.2)
+ resque-scheduler (4.10.2)
+ mono_logger (~> 1.0)
+ redis (>= 3.3)
+ resque (>= 1.27)
+ rufus-scheduler (~> 3.2, != 3.3)
+ rest-client (2.1.0)
+ http-accept (>= 1.7.0, < 2.0)
+ http-cookie (>= 1.0.2, < 2.0)
+ mime-types (>= 1.16, < 4.0)
+ netrc (~> 0.8)
+ rexml (3.4.1)
+ rollout (2.4.3)
+ rspec-core (3.13.5)
+ rspec-support (~> 3.13.0)
+ rspec-expectations (3.13.5)
+ diff-lcs (>= 1.2.0, < 2.0)
+ rspec-support (~> 3.13.0)
+ rspec-mocks (3.13.5)
+ diff-lcs (>= 1.2.0, < 2.0)
+ rspec-support (~> 3.13.0)
+ rspec-rails (6.1.5)
+ actionpack (>= 6.1)
+ activesupport (>= 6.1)
+ railties (>= 6.1)
+ rspec-core (~> 3.13)
+ rspec-expectations (~> 3.13)
+ rspec-mocks (~> 3.13)
+ rspec-support (~> 3.13)
+ rspec-support (3.13.4)
+ rubocop (1.22.3)
+ parallel (~> 1.10)
+ parser (>= 3.0.0.0)
+ rainbow (>= 2.2.2, < 4.0)
+ regexp_parser (>= 1.8, < 3.0)
+ rexml
+ rubocop-ast (>= 1.12.0, < 2.0)
+ ruby-progressbar (~> 1.7)
+ unicode-display_width (>= 1.4.0, < 3.0)
+ rubocop-ast (1.31.2)
+ parser (>= 3.3.0.4)
+ rubocop-rails (2.12.4)
+ activesupport (>= 4.2.0)
+ rack (>= 1.1)
+ rubocop (>= 1.7.0, < 2.0)
+ rubocop-rspec (2.6.0)
+ rubocop (~> 1.19)
+ ruby-progressbar (1.13.0)
+ ruby-vips (2.2.1)
+ ffi (~> 1.12)
+ ruby2_keywords (0.0.5)
+ rubyntlm (0.6.3)
+ rubyzip (2.4.1)
+ rufus-scheduler (3.9.1)
+ fugit (~> 1.1, >= 1.1.6)
+ rvm-capistrano (1.5.6)
+ capistrano (~> 2.15.4)
+ sanitize (6.1.0)
+ crass (~> 1.0.2)
+ nokogiri (>= 1.12.0)
+ securerandom (0.4.1)
+ selenium-webdriver (4.34.0)
+ base64 (~> 0.2)
+ logger (~> 1.4)
+ rexml (~> 3.2, >= 3.2.5)
+ rubyzip (>= 1.2.2, < 3.0)
+ websocket (~> 1.0)
+ sentry-rails (5.18.0)
+ railties (>= 5.0)
+ sentry-ruby (~> 5.18.0)
+ sentry-resque (5.18.0)
+ resque (>= 1.24)
+ sentry-ruby (~> 5.18.0)
+ sentry-ruby (5.18.0)
+ bigdecimal
+ concurrent-ruby (~> 1.0, >= 1.0.2)
+ shoulda (4.0.0)
+ shoulda-context (~> 2.0)
+ shoulda-matchers (~> 4.0)
+ shoulda-context (2.0.0)
+ shoulda-matchers (4.5.1)
+ activesupport (>= 4.2.0)
+ simplecov (0.22.0)
+ docile (~> 1.1)
+ simplecov-html (~> 0.11)
+ simplecov_json_formatter (~> 0.1)
+ simplecov-cobertura (3.0.0)
+ rexml
+ simplecov (~> 0.19)
+ simplecov-html (0.13.2)
+ simplecov_json_formatter (0.1.4)
+ sinatra (3.2.0)
+ mustermann (~> 3.0)
+ rack (~> 2.2, >= 2.2.4)
+ rack-protection (= 3.2.0)
+ tilt (~> 2.0)
+ smart_properties (1.17.0)
+ sprockets (3.7.2)
+ concurrent-ruby (~> 1.0)
+ rack (> 1, < 3)
+ stackprof (0.2.26)
+ stringex (2.8.6)
+ stringio (3.1.7)
+ sys-uname (1.3.1)
+ ffi (~> 1.1)
+ terminal-table (4.0.0)
+ unicode-display_width (>= 1.1.1, < 4)
+ terrapin (1.0.1)
+ climate_control
+ test-unit (3.6.2)
+ power_assert
+ thor (1.4.0)
+ tilt (2.3.0)
+ timecop (0.9.10)
+ timeliness (0.4.5)
+ timeout (0.4.3)
+ tzinfo (2.0.6)
+ concurrent-ruby (~> 1.0)
+ unicode (0.4.4.5)
+ unicode-display_width (2.6.0)
+ unicode_utils (1.4.0)
+ unicorn (5.8.0)
+ kgio (~> 2.6)
+ raindrops (~> 0.7)
+ unidecoder (1.1.2)
+ uniform_notifier (1.17.0)
+ uri (1.0.3)
+ useragent (0.16.11)
+ vcr (6.3.1)
+ base64
+ warden (1.2.9)
+ rack (>= 2.0.9)
+ webmock (3.25.1)
+ addressable (>= 2.8.0)
+ crack (>= 0.3.2)
+ hashdiff (>= 0.4.0, < 2.0.0)
+ webrick (1.9.1)
+ webrobots (0.1.2)
+ websocket (1.2.11)
+ websocket-driver (0.8.0)
+ base64
+ websocket-extensions (>= 0.1.0)
+ websocket-extensions (0.1.5)
+ whenever (0.6.8)
+ aaronh-chronic (>= 0.3.9)
+ activesupport (>= 2.3.4)
+ whiny_validation (1.1)
+ activemodel
+ activesupport
+ will_paginate (4.0.0)
+ xpath (3.2.0)
+ nokogiri (~> 1.8)
+ zeitwerk (2.7.3)
+
+PLATFORMS
+ ruby
+
+DEPENDENCIES
+ actionpack-page_caching
+ active_record_query_trace (~> 1.6, >= 1.6.1)
+ acts_as_list (~> 0.9.7)
+ addressable
+ after_commit_everywhere
+ akismetor
+ audited (~> 5.3)
+ awesome_print
+ aws-sdk-s3
+ bcrypt
+ bcrypt_pbkdf (>= 1.0, < 2.0)
+ brakeman
+ bullet (>= 5.7.3)
+ bundler
+ bundler-audit
+ capistrano-gitflow_version (>= 0.0.3)
+ capybara
+ capybara-screenshot
+ connection_pool
+ css_parser
+ cucumber
+ cucumber-rails
+ cucumber-timecop
+ dalli
+ database_cleaner
+ departure (~> 6.8)
+ devise
+ devise-async
+ devise-pwned_password
+ ed25519 (>= 1.2, < 2.0)
+ elasticsearch (= 8.18.0)
+ email_spec (= 1.6.0)
+ erb_lint (= 0.4.0)
+ escape_utils (= 1.2.1)
+ factory_bot
+ factory_bot_rails
+ faker
+ fastimage
+ globalize (~> 7.0)
+ god (~> 0.13.7)
+ google_visualr!
+ htmlentities
+ httparty
+ i18n-tasks
+ image_processing (~> 1.12)
+ kgio (= 2.10.0)
+ launchy
+ lograge
+ marcel (= 1.0.2)
+ mechanize
+ minitest
+ mysql2
+ n_plus_one_control
+ nokogiri (>= 1.8.5)
+ pagy (~> 9.3)
+ permit_yo
+ phraseapp-in-context-editor-ruby (>= 1.0.6)
+ pickle
+ pry-byebug
+ puma (~> 6.5.0)
+ puma_worker_killer
+ pundit
+ rack (~> 2.2)
+ rack-attack
+ rack-dev-mark (>= 0.7.8)
+ rack-timeout
+ rails (~> 7.2)
+ rails-controller-testing
+ rails-i18n
+ rails-observers!
+ redis (~> 3.3.5)
+ redis-namespace
+ resque (>= 1.14.0)
+ resque-scheduler
+ rest-client (~> 2.1.0)
+ rollout
+ rspec-rails (~> 6.0)
+ rubocop (= 1.22.3)
+ rubocop-rails (= 2.12.4)
+ rubocop-rspec (= 2.6.0)
+ rvm-capistrano
+ sanitize (>= 4.6.5)
+ selenium-webdriver
+ sentry-rails
+ sentry-resque
+ sentry-ruby
+ shoulda
+ simplecov
+ simplecov-cobertura
+ sprockets (< 4)
+ stackprof
+ terrapin
+ test-unit (~> 3.2)
+ timecop
+ timeliness
+ unicode
+ unicode_utils (>= 1.4.0)
+ unicorn (~> 5.5)
+ unidecoder
+ vcr (~> 6.2)
+ webmock
+ whenever (~> 0.6.2)
+ whiny_validation
+ will_paginate (>= 3.0.2)
+
+RUBY VERSION
+ ruby 3.2.7p253
+
+BUNDLED WITH
+ 2.6.3
diff --git a/LICENSE b/LICENSE
new file mode 100755
index 0000000..23cb790
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,339 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 2, June 1991
+
+ Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The licenses for most software are designed to take away your
+freedom to share and change it. By contrast, the GNU General Public
+License is intended to guarantee your freedom to share and change free
+software--to make sure the software is free for all its users. This
+General Public License applies to most of the Free Software
+Foundation's software and to any other program whose authors commit to
+using it. (Some other Free Software Foundation software is covered by
+the GNU Lesser General Public License instead.) You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+this service if you wish), that you receive source code or can get it
+if you want it, that you can change the software or use pieces of it
+in new free programs; and that you know you can do these things.
+
+ To protect your rights, we need to make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if you
+distribute copies of the software, or if you modify it.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must give the recipients all the rights that
+you have. You must make sure that they, too, receive or can get the
+source code. And you must show them these terms so they know their
+rights.
+
+ We protect your rights with two steps: (1) copyright the software, and
+(2) offer you this license which gives you legal permission to copy,
+distribute and/or modify the software.
+
+ Also, for each author's protection and ours, we want to make certain
+that everyone understands that there is no warranty for this free
+software. If the software is modified by someone else and passed on, we
+want its recipients to know that what they have is not the original, so
+that any problems introduced by others will not reflect on the original
+authors' reputations.
+
+ Finally, any free program is threatened constantly by software
+patents. We wish to avoid the danger that redistributors of a free
+program will individually obtain patent licenses, in effect making the
+program proprietary. To prevent this, we have made it clear that any
+patent must be licensed for everyone's free use or not licensed at all.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ GNU GENERAL PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+ 0. This License applies to any program or other work which contains
+a notice placed by the copyright holder saying it may be distributed
+under the terms of this General Public License. The "Program", below,
+refers to any such program or work, and a "work based on the Program"
+means either the Program or any derivative work under copyright law:
+that is to say, a work containing the Program or a portion of it,
+either verbatim or with modifications and/or translated into another
+language. (Hereinafter, translation is included without limitation in
+the term "modification".) Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope. The act of
+running the Program is not restricted, and the output from the Program
+is covered only if its contents constitute a work based on the
+Program (independent of having been made by running the Program).
+Whether that is true depends on what the Program does.
+
+ 1. You may copy and distribute verbatim copies of the Program's
+source code as you receive it, in any medium, provided that you
+conspicuously and appropriately publish on each copy an appropriate
+copyright notice and disclaimer of warranty; keep intact all the
+notices that refer to this License and to the absence of any warranty;
+and give any other recipients of the Program a copy of this License
+along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and
+you may at your option offer warranty protection in exchange for a fee.
+
+ 2. You may modify your copy or copies of the Program or any portion
+of it, thus forming a work based on the Program, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+ a) You must cause the modified files to carry prominent notices
+ stating that you changed the files and the date of any change.
+
+ b) You must cause any work that you distribute or publish, that in
+ whole or in part contains or is derived from the Program or any
+ part thereof, to be licensed as a whole at no charge to all third
+ parties under the terms of this License.
+
+ c) If the modified program normally reads commands interactively
+ when run, you must cause it, when started running for such
+ interactive use in the most ordinary way, to print or display an
+ announcement including an appropriate copyright notice and a
+ notice that there is no warranty (or else, saying that you provide
+ a warranty) and that users may redistribute the program under
+ these conditions, and telling the user how to view a copy of this
+ License. (Exception: if the Program itself is interactive but
+ does not normally print such an announcement, your work based on
+ the Program is not required to print an announcement.)
+
+These requirements apply to the modified work as a whole. If
+identifiable sections of that work are not derived from the Program,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works. But when you
+distribute the same sections as part of a whole which is a work based
+on the Program, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+ 3. You may copy and distribute the Program (or a work based on it,
+under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you also do one of the following:
+
+ a) Accompany it with the complete corresponding machine-readable
+ source code, which must be distributed under the terms of Sections
+ 1 and 2 above on a medium customarily used for software interchange; or,
+
+ b) Accompany it with a written offer, valid for at least three
+ years, to give any third party, for a charge no more than your
+ cost of physically performing source distribution, a complete
+ machine-readable copy of the corresponding source code, to be
+ distributed under the terms of Sections 1 and 2 above on a medium
+ customarily used for software interchange; or,
+
+ c) Accompany it with the information you received as to the offer
+ to distribute corresponding source code. (This alternative is
+ allowed only for noncommercial distribution and only if you
+ received the program in object code or executable form with such
+ an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it. For an executable work, complete source
+code means all the source code for all modules it contains, plus any
+associated interface definition files, plus the scripts used to
+control compilation and installation of the executable. However, as a
+special exception, the source code distributed need not include
+anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component
+itself accompanies the executable.
+
+If distribution of executable or object code is made by offering
+access to copy from a designated place, then offering equivalent
+access to copy the source code from the same place counts as
+distribution of the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+ 4. You may not copy, modify, sublicense, or distribute the Program
+except as expressly provided under this License. Any attempt
+otherwise to copy, modify, sublicense or distribute the Program is
+void, and will automatically terminate your rights under this License.
+However, parties who have received copies, or rights, from you under
+this License will not have their licenses terminated so long as such
+parties remain in full compliance.
+
+ 5. You are not required to accept this License, since you have not
+signed it. However, nothing else grants you permission to modify or
+distribute the Program or its derivative works. These actions are
+prohibited by law if you do not accept this License. Therefore, by
+modifying or distributing the Program (or any work based on the
+Program), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+ 6. Each time you redistribute the Program (or any work based on the
+Program), the recipient automatically receives a license from the
+original licensor to copy, distribute or modify the Program subject to
+these terms and conditions. You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+ 7. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Program at all. For example, if a patent
+license would not permit royalty-free redistribution of the Program by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under
+any particular circumstance, the balance of the section is intended to
+apply and the section as a whole is intended to apply in other
+circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system, which is
+implemented by public license practices. Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+ 8. If the distribution and/or use of the Program is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Program under this License
+may add an explicit geographical distribution limitation excluding
+those countries, so that distribution is permitted only in or among
+countries not thus excluded. In such case, this License incorporates
+the limitation as if written in the body of this License.
+
+ 9. The Free Software Foundation may publish revised and/or new versions
+of the General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Program
+specifies a version number of this License which applies to it and "any
+later version", you have the option of following the terms and conditions
+either of that version or of any later version published by the Free
+Software Foundation. If the Program does not specify a version number of
+this License, you may choose any version ever published by the Free Software
+Foundation.
+
+ 10. If you wish to incorporate parts of the Program into other free
+programs whose distribution conditions are different, write to the author
+to ask for permission. For software which is copyrighted by the Free
+Software Foundation, write to the Free Software Foundation; we sometimes
+make exceptions for this. Our decision will be guided by the two goals
+of preserving the free status of all derivatives of our free software and
+of promoting the sharing and reuse of software generally.
+
+ NO WARRANTY
+
+ 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
+FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
+OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
+PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
+OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
+TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
+PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
+REPAIR OR CORRECTION.
+
+ 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
+REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
+OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
+TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
+YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
+PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ {description}
+ Copyright (C) {year} {fullname}
+
+ This program is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation; either version 2 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along
+ with this program; if not, write to the Free Software Foundation, Inc.,
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program is interactive, make it output a short notice like this
+when it starts in an interactive mode:
+
+ Gnomovision version 69, Copyright (C) year name of author
+ Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, the commands you use may
+be called something other than `show w' and `show c'; they could even be
+mouse-clicks or menu items--whatever suits your program.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the program, if
+necessary. Here is a sample; alter the names:
+
+ Yoyodyne, Inc., hereby disclaims all copyright interest in the program
+ `Gnomovision' (which makes passes at compilers) written by James Hacker.
+
+ {signature of Ty Coon}, 1 April 1989
+ Ty Coon, President of Vice
+
+This General Public License does not permit incorporating your program into
+proprietary programs. If your program is a subroutine library, you may
+consider it more useful to permit linking proprietary applications with the
+library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.
diff --git a/README.md b/README.md
new file mode 100755
index 0000000..8724d77
--- /dev/null
+++ b/README.md
@@ -0,0 +1,46 @@
+OTW-Archive
+=========
+[](https://github.com/otwcode/otwarchive/actions/workflows/automated-tests.yml?query=branch%3Amaster) [](https://www.codeship.io/projects/14476) [](https://app.codecov.io/gh/otwcode/otwarchive)
+
+The OTW-Archive software is an open-source web application intended for hosting archives of fanworks, including fanfic, fanart, and fan vids.
+
+Its development is sponsored and managed by the [Organization for Transformative Works](https://www.transformativeworks.org/), a nonprofit organization by and for fans.
+
+Release Status
+---------
+Development of the OTW-Archive software is an ongoing labor of love. You can see it in action on the [Archive of Our Own](https://archiveofourown.org/), aka AO3, a multifandom archive also run by the OTW.
+
+You can find more information about the [history and future of the AO3 project on the OTW website](https://www.transformativeworks.org/archive_of_our_own/).
+
+If you wish to use this software, SquidgeWorld has generously provided [setup notes](https://squidgeworld.org/works/34491).
+
+How to Contribute
+----------
+We welcome pull requests for bugs described in our issue tracker. Please see our [Contributing Guidelines](https://github.com/otwcode/otwarchive/blob/master/CONTRIBUTING.md) for further information!
+
+* [Bug Tracker](https://otwarchive.atlassian.net/projects/AO3/issues)
+* [Developer Documentation](https://github.com/otwcode/otwarchive/wiki)
+* [Commit Policy](https://github.com/otwcode/otwarchive/wiki/Commit-policy)
+
+We do not have a public chat, but you are welcome to contact us at otw-coders@transformativeworks.org if you have any questions.
+
+We grant your Jira account permissions for commenting on, assigning, and transitioning issues [after you create your first pull request](https://github.com/otwcode/otwarchive/blob/master/CONTRIBUTING.md#workflow).
+
+API
+----------
+There is currently no API for the OTW-Archive software. While it is something we're considering for the future, we ask that contributors instead focus on issues already in our [Jira issue tracker](https://otwarchive.atlassian.net/).
+
+License and Acknowledgments
+----------
+The Archive code is licensed under [GPL-2.0-or-later](https://www.gnu.org/licenses/gpl-2.0.html) by the [Organization for Transformative Works](https://www.transformativeworks.org/).
+
+We benefit from software and services that are free to use for Open Source projects, including:
+
+* [RubyMine IDE](https://www.jetbrains.com/ruby/) by JetBrains
+* [Codeship](https://codeship.com/)
+* [Hound](https://houndci.com/) by [thoughtbot](https://thoughtbot.com/)
+* [BrowserStack](https://www.browserstack.com)
+* [Sentry](https://sentry.io)
+* [Full list of acknowledgments](ACKNOWLEDGMENTS.md)
+
+Thank you kindly!
diff --git a/Rakefile b/Rakefile
new file mode 100755
index 0000000..303c321
--- /dev/null
+++ b/Rakefile
@@ -0,0 +1,9 @@
+# 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 File.expand_path('../config/application', __FILE__)
+require 'rake'
+require 'resque/tasks'
+
+include Rake::DSL
+Otwarchive::Application.load_tasks
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100755
index 0000000..1a64647
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,14 @@
+# Reporting a security issue
+
+Please email us directly at [otw-coders@transformativeworks.org](mailto:otw-coders@transformativeworks.org)
+with details and reproduction steps. We will get back to you as soon as possible,
+and we may ask for additional information or guidance.
+
+Please avoid testing for security issues on the Archive of Our Own itself,
+as you risk disrupting other users and violating the [Terms of Service](https://archiveofourown.org/content#II.K.1).
+
+Please give us a reasonable amount of time to address the issue before any
+disclosure to the public or a third-party.
+
+We thank you for your effort and responsible disclosure! We will make sure you
+receive due credit on [our public release notes](https://archiveofourown.org/admin_posts?tag=1).
diff --git a/app/controllers/abuse_reports_controller.rb b/app/controllers/abuse_reports_controller.rb
new file mode 100755
index 0000000..bc13198
--- /dev/null
+++ b/app/controllers/abuse_reports_controller.rb
@@ -0,0 +1,38 @@
+class AbuseReportsController < ApplicationController
+ skip_before_action :store_location
+ before_action :load_abuse_languages
+
+ def new
+ @abuse_report = AbuseReport.new
+ reporter = current_admin || current_user
+ if reporter.present?
+ @abuse_report.email = reporter.email
+ @abuse_report.username = reporter.login
+ end
+ @abuse_report.url = params[:url] || request.env["HTTP_REFERER"]
+ end
+
+ def create
+ @abuse_report = AbuseReport.new(abuse_report_params)
+ @abuse_report.ip_address = request.remote_ip
+ if @abuse_report.save
+ @abuse_report.email_and_send
+ flash[:notice] = ts("Your report was submitted to the Policy & Abuse team. A confirmation message has been sent to the email address you provided.")
+ redirect_to root_path
+ else
+ render action: "new"
+ end
+ end
+
+ private
+
+ def load_abuse_languages
+ @abuse_languages = Language.where(abuse_support_available: true).default_order
+ end
+
+ def abuse_report_params
+ params.require(:abuse_report).permit(
+ :username, :email, :language, :summary, :url, :comment
+ )
+ end
+end
diff --git a/app/controllers/admin/activities_controller.rb b/app/controllers/admin/activities_controller.rb
new file mode 100755
index 0000000..18837da
--- /dev/null
+++ b/app/controllers/admin/activities_controller.rb
@@ -0,0 +1,11 @@
+class Admin::ActivitiesController < Admin::BaseController
+ def index
+ @activities = AdminActivity.order("created_at DESC").page(params[:page])
+ authorize @activities
+ end
+
+ def show
+ @activity = AdminActivity.find(params[:id])
+ authorize @activity
+ end
+end
diff --git a/app/controllers/admin/admin_invitations_controller.rb b/app/controllers/admin/admin_invitations_controller.rb
new file mode 100755
index 0000000..70d6614
--- /dev/null
+++ b/app/controllers/admin/admin_invitations_controller.rb
@@ -0,0 +1,61 @@
+class Admin::AdminInvitationsController < Admin::BaseController
+
+ def index
+ end
+
+ def create
+ @invitation = current_admin.invitations.new(invitation_params)
+
+ if @invitation.invitee_email.blank?
+ flash[:error] = t('no_email', default: "Please enter an email address.")
+ render action: 'index'
+ elsif @invitation.save
+ flash[:notice] = t('sent', default: "An invitation was sent to %{email_address}", email_address: @invitation.invitee_email)
+ redirect_to admin_invitations_path
+ else
+ render action: 'index'
+ end
+ end
+
+ def invite_from_queue
+ count = invitation_params[:invite_from_queue].to_i
+ InviteFromQueueJob.perform_later(count: count, creator: current_admin)
+
+ flash[:notice] = t(".success", count: count)
+ redirect_to admin_invitations_path
+ end
+
+ def grant_invites_to_users
+ if invitation_params[:user_group] == "All"
+ Invitation.grant_all(invitation_params[:number_of_invites].to_i)
+ else
+ Invitation.grant_empty(invitation_params[:number_of_invites].to_i)
+ end
+ flash[:notice] = t('invites_created', default: 'Invitations successfully created.')
+ redirect_to admin_invitations_path
+ end
+
+ def find
+ unless invitation_params[:user_name].blank?
+ @user = User.find_by(login: invitation_params[:user_name])
+ @hide_dashboard = true
+ @invitations = @user.invitations if @user
+ end
+ if !invitation_params[:token].blank?
+ @invitations = Invitation.where(token: invitation_params[:token])
+ elsif invitation_params[:invitee_email].present?
+ @invitations = Invitation.where("invitee_email LIKE ?", "%#{invitation_params[:invitee_email]}%")
+ end
+
+ return if @user || @invitations.present?
+
+ flash.now[:error] = t(".user_not_found")
+ end
+
+ private
+
+ def invitation_params
+ params.require(:invitation).permit(:invitee_email, :invite_from_queue,
+ :user_group, :number_of_invites, :user_name, :token)
+ end
+end
diff --git a/app/controllers/admin/admin_users_controller.rb b/app/controllers/admin/admin_users_controller.rb
new file mode 100755
index 0000000..8a0bce2
--- /dev/null
+++ b/app/controllers/admin/admin_users_controller.rb
@@ -0,0 +1,237 @@
+class Admin::AdminUsersController < Admin::BaseController
+ include ExportsHelper
+
+ before_action :set_roles, only: [:index, :bulk_search]
+ before_action :load_user, only: [:show, :update, :confirm_delete_user_creations, :destroy_user_creations, :troubleshoot, :activate, :creations]
+ before_action :user_is_banned, only: [:confirm_delete_user_creations, :destroy_user_creations]
+ before_action :load_user_creations, only: [:confirm_delete_user_creations, :creations]
+
+ def set_roles
+ @roles = Role.assignable.distinct
+ end
+
+ def load_user
+ @user = User.find_by!(login: params[:id])
+ end
+
+ def user_is_banned
+ return if @user&.banned?
+
+ flash[:error] = ts("That user is not banned!")
+ redirect_to admin_users_path
+ end
+
+ def load_user_creations
+ @works = @user.works.paginate(page: params[:works_page])
+ @comments = @user.comments.paginate(page: params[:comments_page])
+ end
+
+ def index
+ authorize User
+
+ # Values for the role dropdown:
+ @role_values = @roles.map { |role| [role.name.humanize.titlecase, role.id] }
+
+ return if search_params.empty?
+
+ @query = UserQuery.new(search_params)
+ @users = @query.search_results.scope(:with_includes_for_admin_index)
+ end
+
+ def bulk_search
+ authorize User
+ @emails = params[:emails].split if params[:emails]
+ if @emails.present?
+ found_users, not_found_emails, duplicates = User.search_multiple_by_email(@emails)
+ @users = found_users.paginate(page: params[:page] || 1)
+
+ if params[:download_button]
+ header = [%w(Email Username)]
+ found = found_users.map { |u| [u.email, u.login] }
+ not_found = not_found_emails.map { |email| [email, ""] }
+ send_csv_data(header + found + not_found, "bulk_user_search_#{Time.now.strftime("%Y-%m-%d-%H%M")}.csv")
+ flash.now[:notice] = ts("Downloaded CSV")
+ end
+ @results = {
+ total: @emails.size,
+ searched: found_users.size + not_found_emails.size,
+ found_users: found_users,
+ not_found_emails: not_found_emails,
+ duplicates: duplicates
+ }
+ else
+ @results = {}
+ end
+ end
+
+ # GET admin/users/1
+ def show
+ authorize @user
+ @page_subtitle = t(".page_title", login: @user.login)
+ log_items
+ end
+
+ # POST admin/users/update
+ def update
+ authorize @user
+
+ attributes = permitted_attributes(@user)
+ if attributes[:email].present?
+ @user.skip_reconfirmation!
+ @user.email = attributes[:email]
+ end
+ if attributes[:roles].present?
+ # Roles that the current admin can add or remove
+ allowed_roles = UserPolicy::ALLOWED_USER_ROLES_BY_ADMIN_ROLES
+ .values_at(*current_admin.roles)
+ .compact
+ .flatten
+
+ # Other roles the current user has
+ out_of_scope_roles = @user.roles.to_a.reject { |role| allowed_roles.include?(role.name) }
+
+ request_roles = Role.where(
+ id: attributes[:roles],
+ name: [allowed_roles]
+ )
+
+ @user.roles = out_of_scope_roles + request_roles
+ end
+
+ if @user.save
+ flash[:notice] = ts("User was successfully updated.")
+ else
+ flash[:error] = ts("The user %{name} could not be updated: %{errors}", name: params[:id], errors: @user.errors.full_messages.join(" "))
+ end
+ redirect_to request.referer || root_path
+ end
+
+ def update_next_of_kin
+ @user = authorize User.find_by!(login: params[:user_login])
+ kin = User.find_by(login: params[:next_of_kin_name])
+ kin_email = params[:next_of_kin_email]
+
+ fnok = @user.fannish_next_of_kin
+ previous_kin = fnok&.kin
+ fnok ||= @user.build_fannish_next_of_kin
+ fnok.assign_attributes(kin: kin, kin_email: kin_email)
+
+ unless fnok.changed?
+ flash[:notice] = ts("No change to fannish next of kin.")
+ redirect_to admin_user_path(@user) and return
+ end
+
+ # Remove FNOK that already exists.
+ if fnok.persisted? && kin.blank? && kin_email.blank?
+ fnok.destroy
+ @user.log_removal_of_next_of_kin(previous_kin, admin: current_admin)
+ flash[:notice] = ts("Fannish next of kin was removed.")
+ redirect_to admin_user_path(@user) and return
+ end
+
+ if fnok.save
+ @user.log_removal_of_next_of_kin(previous_kin, admin: current_admin)
+ @user.log_assignment_of_next_of_kin(kin, admin: current_admin)
+ flash[:notice] = ts("Fannish next of kin was updated.")
+ redirect_to admin_user_path(@user)
+ else
+ @hide_dashboard = true
+ log_items
+ render :show
+ end
+ end
+
+ def update_status
+ @user = User.find_by!(login: params[:user_login])
+
+ # Authorize on the manager, as we need to check which specific actions the admin can do.
+ @user_manager = authorize UserManager.new(current_admin, @user, params)
+ if @user_manager.save
+ flash[:notice] = @user_manager.success_message
+ if @user_manager.admin_action == "spamban"
+ redirect_to confirm_delete_user_creations_admin_user_path(@user)
+ else
+ redirect_to admin_user_path(@user)
+ end
+ else
+ flash[:error] = @user_manager.error_message
+ redirect_to admin_user_path(@user)
+ end
+ end
+
+ def confirm_delete_user_creations
+ authorize @user
+ @bookmarks = @user.bookmarks
+ @collections = @user.sole_owned_collections
+ @series = @user.series
+ @page_subtitle = t(".page_title", login: @user.login)
+ end
+
+ def destroy_user_creations
+ authorize @user
+
+ creations = @user.works + @user.bookmarks + @user.sole_owned_collections
+ creations.each do |creation|
+ AdminActivity.log_action(current_admin, creation, action: "destroy spam", summary: creation.inspect)
+ creation.mark_as_spam! if creation.respond_to?(:mark_as_spam!)
+ creation.destroy
+ end
+
+ # comments are special and needs to be handled separately
+ @user.comments.not_deleted.each do |comment|
+ AdminActivity.log_action(current_admin, comment, action: "destroy spam", summary: comment.inspect)
+ comment.submit_spam
+ comment.destroy_or_mark_deleted # comments with replies cannot be destroyed, mark deleted instead
+ end
+
+ flash[:notice] = t(".success", login: @user.login)
+ redirect_to(admin_users_path)
+ end
+
+ def troubleshoot
+ authorize @user
+
+ @user.fix_user_subscriptions
+ @user.set_user_work_dates
+ @user.reindex_user_creations
+ @user.update_works_index_timestamp!
+ @user.create_log_item(options = { action: ArchiveConfig.ACTION_TROUBLESHOOT, admin_id: current_admin.id })
+ flash[:notice] = ts("User account troubleshooting complete.")
+ redirect_to(request.env["HTTP_REFERER"] || root_path) && return
+ end
+
+ def activate
+ authorize @user
+
+ @user.activate
+ if @user.active?
+ @user.create_log_item( options = { action: ArchiveConfig.ACTION_ACTIVATE, note: "Manually Activated", admin_id: current_admin.id })
+ flash[:notice] = ts("User Account Activated")
+ redirect_to action: :show
+ else
+ flash[:error] = ts("Attempt to activate account failed.")
+ redirect_to action: :show
+ end
+ end
+
+ def creations
+ authorize @user
+ @page_subtitle = t(".page_title", login: @user.login)
+ end
+
+ private
+
+ def search_params
+ allowed_params = if policy(User).can_view_past?
+ %i[name email role_id user_id inactive page commit search_past]
+ else
+ %i[name email role_id user_id inactive page commit]
+ end
+
+ params.permit(*allowed_params)
+ end
+
+ def log_items
+ @log_items ||= @user.log_items.sort_by(&:created_at).reverse
+ end
+end
diff --git a/app/controllers/admin/api_controller.rb b/app/controllers/admin/api_controller.rb
new file mode 100755
index 0000000..e6efb99
--- /dev/null
+++ b/app/controllers/admin/api_controller.rb
@@ -0,0 +1,71 @@
+class Admin::ApiController < Admin::BaseController
+ before_action :check_for_cancel, only: [:create, :update]
+
+ def index
+ @page_subtitle = t(".page_title")
+ @api_keys = if params[:query]
+ sql_query = "%" + params[:query] + "%"
+ ApiKey.where("name LIKE ?", sql_query).order("name").paginate(page: params[:page])
+ else
+ ApiKey.order("name").paginate(page: params[:page])
+ end
+ authorize @api_keys
+ end
+
+ def show
+ redirect_to action: "index"
+ end
+
+ def new
+ @page_subtitle = t(".page_title")
+ @api_key = authorize ApiKey.new
+ end
+
+ def create
+ authorize ApiKey
+ # Use provided api key params if available otherwise fallback to empty
+ # ApiKey object
+ @api_key = params[:api_key].nil? ? ApiKey.new : ApiKey.new(api_key_params)
+ if @api_key.save
+ flash[:notice] = ts("New token successfully created")
+ redirect_to action: "index"
+ else
+ render "new"
+ end
+ end
+
+ def edit
+ @page_subtitle = t(".page_title")
+ @api_key = authorize ApiKey.find(params[:id])
+ end
+
+ def update
+ @api_key = authorize ApiKey.find(params[:id])
+ if @api_key.update(api_key_params)
+ flash[:notice] = ts("Access token was successfully updated")
+ redirect_to action: "index"
+ else
+ render "edit"
+ end
+ end
+
+ def destroy
+ @api_key = authorize ApiKey.find(params[:id])
+ @api_key.destroy
+ redirect_to(admin_api_path)
+ end
+
+ private
+
+ def api_key_params
+ params.require(:api_key).permit(
+ :name, :access_token, :banned
+ )
+ end
+
+ def check_for_cancel
+ if params[:cancel_button]
+ redirect_to action: "index"
+ end
+ end
+end
diff --git a/app/controllers/admin/banners_controller.rb b/app/controllers/admin/banners_controller.rb
new file mode 100755
index 0000000..1a91130
--- /dev/null
+++ b/app/controllers/admin/banners_controller.rb
@@ -0,0 +1,82 @@
+class Admin::BannersController < Admin::BaseController
+
+ # GET /admin/banners
+ def index
+ authorize(AdminBanner)
+
+ @admin_banners = AdminBanner.order("id DESC").paginate(page: params[:page])
+ end
+
+ # GET /admin/banners/1
+ def show
+ @admin_banner = authorize AdminBanner.find(params[:id])
+ end
+
+ # GET /admin/banners/new
+ def new
+ @admin_banner = authorize AdminBanner.new
+ end
+
+ # GET /admin/banners/1/edit
+ def edit
+ @admin_banner = authorize AdminBanner.find(params[:id])
+ end
+
+ # POST /admin/banners
+ def create
+ @admin_banner = authorize AdminBanner.new(admin_banner_params)
+
+ if @admin_banner.save
+ if @admin_banner.active?
+ AdminBanner.banner_on
+ flash[:notice] = ts('Setting banner back on for all users. This may take some time.')
+ else
+ flash[:notice] = ts('Banner successfully created.')
+ end
+ redirect_to @admin_banner
+ else
+ render action: 'new'
+ end
+ end
+
+ # PUT /admin/banners/1
+ def update
+ @admin_banner = authorize AdminBanner.find(params[:id])
+
+ if !@admin_banner.update(admin_banner_params)
+ render action: 'edit'
+ elsif params[:admin_banner_minor_edit]
+ flash[:notice] = ts('Updating banner for users who have not already dismissed it. This may take some time.')
+ redirect_to @admin_banner
+ else
+ if @admin_banner.active?
+ AdminBanner.banner_on
+ flash[:notice] = ts('Setting banner back on for all users. This may take some time.')
+ else
+ flash[:notice] = ts('Banner successfully updated.')
+ end
+ redirect_to @admin_banner
+ end
+ end
+
+ # GET /admin/banners/1/confirm_delete
+ def confirm_delete
+ @admin_banner = authorize AdminBanner.find(params[:id])
+ end
+
+ # DELETE /admin/banners/1
+ def destroy
+ @admin_banner = authorize AdminBanner.find(params[:id])
+ @admin_banner.destroy
+
+ flash[:notice] = ts('Banner successfully deleted.')
+ redirect_to admin_banners_path
+ end
+
+ private
+
+ def admin_banner_params
+ params.require(:admin_banner).permit(:content, :banner_type, :active)
+ end
+
+end
diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb
new file mode 100755
index 0000000..b70fd16
--- /dev/null
+++ b/app/controllers/admin/base_controller.rb
@@ -0,0 +1,3 @@
+class Admin::BaseController < ApplicationController
+ before_action :admin_only
+end
diff --git a/app/controllers/admin/blacklisted_emails_controller.rb b/app/controllers/admin/blacklisted_emails_controller.rb
new file mode 100755
index 0000000..1348905
--- /dev/null
+++ b/app/controllers/admin/blacklisted_emails_controller.rb
@@ -0,0 +1,40 @@
+class Admin::BlacklistedEmailsController < Admin::BaseController
+
+ def index
+ authorize AdminBlacklistedEmail
+ @admin_blacklisted_email = AdminBlacklistedEmail.new
+ if params[:query]
+ @admin_blacklisted_emails = AdminBlacklistedEmail.where(["email LIKE ?", '%' + params[:query] + '%'])
+ @admin_blacklisted_emails = @admin_blacklisted_emails.paginate(page: params[:page], per_page: ArchiveConfig.ITEMS_PER_PAGE)
+ end
+ @page_subtitle = t(".browser_title")
+ end
+
+ def create
+ @admin_blacklisted_email = authorize AdminBlacklistedEmail.new(admin_blacklisted_email_params)
+ @page_subtitle = t(".browser_title")
+
+ if @admin_blacklisted_email.save
+ flash[:notice] = ts("Email address %{email} banned.", email: @admin_blacklisted_email.email)
+ redirect_to admin_blacklisted_emails_path
+ else
+ render action: "index"
+ end
+ end
+
+ def destroy
+ @admin_blacklisted_email = authorize AdminBlacklistedEmail.find(params[:id])
+ @admin_blacklisted_email.destroy
+
+ flash[:notice] = ts("Email address %{email} removed from banned emails list.", email: @admin_blacklisted_email.email)
+ redirect_to admin_blacklisted_emails_path
+ end
+
+ private
+
+ def admin_blacklisted_email_params
+ params.require(:admin_blacklisted_email).permit(
+ :email
+ )
+ end
+end
diff --git a/app/controllers/admin/passwords_controller.rb b/app/controllers/admin/passwords_controller.rb
new file mode 100755
index 0000000..dd092f4
--- /dev/null
+++ b/app/controllers/admin/passwords_controller.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class Admin::PasswordsController < Devise::PasswordsController
+ before_action :user_logout_required
+ skip_before_action :store_location
+ layout "session"
+end
diff --git a/app/controllers/admin/sessions_controller.rb b/app/controllers/admin/sessions_controller.rb
new file mode 100755
index 0000000..d7ea1b4
--- /dev/null
+++ b/app/controllers/admin/sessions_controller.rb
@@ -0,0 +1,14 @@
+# Namespaced Admin class
+class Admin
+ # Handle admin session authentication
+ class SessionsController < Devise::SessionsController
+ before_action :user_logout_required, except: :destroy
+ skip_before_action :store_location, raise: false
+
+ # GET /admin/logout
+ def confirm_logout
+ # If the user is already logged out, we just redirect to the front page.
+ redirect_to root_path unless admin_signed_in?
+ end
+ end
+end
diff --git a/app/controllers/admin/settings_controller.rb b/app/controllers/admin/settings_controller.rb
new file mode 100755
index 0000000..9099629
--- /dev/null
+++ b/app/controllers/admin/settings_controller.rb
@@ -0,0 +1,24 @@
+class Admin::SettingsController < Admin::BaseController
+ before_action :load_admin_setting
+
+ def index
+ authorize @admin_setting
+ end
+
+ # PUT /admin_settings/1
+ def update
+ authorize @admin_setting
+ if @admin_setting.update(permitted_attributes(@admin_setting).merge(last_updated: current_admin))
+ flash[:notice] = t(".success")
+ redirect_to admin_settings_path
+ else
+ render :index
+ end
+ end
+
+ private
+
+ def load_admin_setting
+ @admin_setting = AdminSetting.first || AdminSetting.create(last_updated_by: Admin.first)
+ end
+end
diff --git a/app/controllers/admin/skins_controller.rb b/app/controllers/admin/skins_controller.rb
new file mode 100755
index 0000000..5bb5604
--- /dev/null
+++ b/app/controllers/admin/skins_controller.rb
@@ -0,0 +1,98 @@
+class Admin::SkinsController < Admin::BaseController
+
+ def index
+ authorize Skin
+ @unapproved_skins = Skin.unapproved_skins.sort_by_recent
+ end
+
+ def index_rejected
+ authorize Skin
+ @rejected_skins = Skin.rejected_skins.sort_by_recent
+ end
+
+ def index_approved
+ authorize Skin
+ @approved_skins = Skin.approved_skins.usable.sort_by_recent
+ end
+
+ def update
+ authorize Skin, :index?
+
+ flash[:notice] = []
+ modified_skin_titles = []
+ %w(official rejected cached featured in_chooser).each do |action|
+ skins_to_set = params["make_#{action}"] ? Skin.where(id: params["make_#{action}"].map {|id| id.to_i}) : []
+ skins_to_unset = params["make_un#{action}"] ? Skin.where(id: params["make_un#{action}"].map {|id| id.to_i}) : []
+ skins_to_set.each do |skin|
+ # Silently fail if the user doesn't have permission to update:
+ next unless policy(skin).update?
+
+ case action
+ when "official"
+ skin.update_attribute(:official, true)
+ when "rejected"
+ skin.update_attribute(:rejected, true)
+ when "cached"
+ next unless skin.official? && !skin.is_a?(WorkSkin)
+ skin.cache!
+ when "featured"
+ next unless skin.official? && !skin.is_a?(WorkSkin)
+ skin.cache! unless skin.cached?
+ skin.update_attribute(:featured, true)
+ when "in_chooser"
+ next unless skin.official? && !skin.is_a?(WorkSkin)
+ skin.cache! unless skin.cached?
+ skin.update_attribute(:in_chooser, true)
+ end
+ skin.update_attribute(:admin_note, params[:skin_admin_note]["#{skin.id}"]) if params[:skin_admin_note] && params[:skin_admin_note]["#{skin.id}"]
+ modified_skin_titles << skin.title
+ end
+
+ skins_to_unset.each do |skin|
+ # Silently fail if the user doesn't have permission to update:
+ next unless policy(skin).update?
+
+ case action
+ when "official"
+ skin.clear_cache! # no cache for unofficial skins
+ skin.update_attribute(:official, false)
+ skin.remove_me_from_preferences
+ when "rejected"
+ skin.update_attribute(:rejected, false)
+ when "cached"
+ next unless skin.official? && !skin.is_a?(WorkSkin)
+ skin.clear_cache! if skin.cached?
+ when "featured"
+ next unless skin.official? && !skin.is_a?(WorkSkin)
+ skin.update_attribute(:featured, false)
+ when "in_chooser"
+ next unless skin.official? && !skin.is_a?(WorkSkin)
+ skin.update_attribute(:in_chooser, false)
+ end
+ modified_skin_titles << skin.title
+ end
+ end
+
+ flash[:notice] << ts("The following skins were updated: %{titles}", titles: modified_skin_titles.join(', '))
+
+ # set default
+ if params[:set_default].present? && params[:set_default] != AdminSetting.default_skin&.title
+ authorize Skin, :set_default?
+
+ skin = Skin.find_by(title: params[:set_default], official: true)
+ @admin_setting = AdminSetting.first
+ if skin && @admin_setting
+ @admin_setting.default_skin = skin
+ @admin_setting.last_updated_by = params[:last_updated_by]
+ if @admin_setting.save
+ flash[:notice] << ts("Default skin changed to %{title}", title: skin.title)
+ else
+ flash[:error] = ts("We couldn't save the default skin change.")
+ end
+ end
+ end
+
+ redirect_to admin_skins_path
+ end
+
+end
diff --git a/app/controllers/admin/spam_controller.rb b/app/controllers/admin/spam_controller.rb
new file mode 100755
index 0000000..8b65278
--- /dev/null
+++ b/app/controllers/admin/spam_controller.rb
@@ -0,0 +1,36 @@
+class Admin::SpamController < Admin::BaseController
+ def index
+ authorize ModeratedWork
+
+ conditions = case params[:show]
+ when "reviewed"
+ { reviewed: true, approved: false }
+ when "approved"
+ { approved: true }
+ else
+ { reviewed: false, approved: false }
+ end
+ @works = ModeratedWork.where(conditions)
+ .joins(:work)
+ .includes(:work)
+ .order(:created_at)
+ .page(params[:page])
+ end
+
+ def bulk_update
+ authorize ModeratedWork
+
+ if ModeratedWork.bulk_update(spam_params)
+ flash[:notice] = "Works were successfully updated"
+ else
+ flash[:error] = "Sorry, please try again"
+ end
+ redirect_to admin_spam_index_path
+ end
+
+ private
+
+ def spam_params
+ params.slice(:spam, :ham)
+ end
+end
diff --git a/app/controllers/admin/user_creations_controller.rb b/app/controllers/admin/user_creations_controller.rb
new file mode 100755
index 0000000..995efe0
--- /dev/null
+++ b/app/controllers/admin/user_creations_controller.rb
@@ -0,0 +1,101 @@
+class Admin::UserCreationsController < Admin::BaseController
+ before_action :get_creation, only: [:hide, :set_spam, :destroy]
+ before_action :can_be_marked_as_spam, only: [:set_spam]
+
+ def get_creation
+ raise "Redshirt: Attempted to constantize invalid class initialize #{params[:creation_type]}" unless %w(Bookmark ExternalWork Series Work).include?(params[:creation_type])
+ @creation_class = params[:creation_type].constantize
+ @creation = @creation_class.find(params[:id])
+ end
+
+ def can_be_marked_as_spam
+ unless @creation_class && @creation_class == Work
+ flash[:error] = ts("You can only mark works as spam currently.")
+ redirect_to polymorphic_path(@creation) and return
+ end
+ end
+
+ # Removes an object from public view
+ def hide
+ authorize @creation
+ @creation.hidden_by_admin = (params[:hidden] == "true")
+ @creation.save(validate: false)
+ action = @creation.hidden_by_admin? ? "hide" : "unhide"
+ AdminActivity.log_action(current_admin, @creation, action: action)
+ flash[:notice] = @creation.hidden_by_admin? ?
+ ts("Item has been hidden.") :
+ ts("Item is no longer hidden.")
+ if @creation_class == ExternalWork || @creation_class == Bookmark
+ redirect_to(request.env["HTTP_REFERER"] || root_path)
+ else
+ redirect_to polymorphic_path(@creation)
+ end
+ end
+
+ def set_spam
+ authorize @creation
+ action = "mark as " + (params[:spam] == "true" ? "spam" : "not spam")
+ AdminActivity.log_action(current_admin, @creation, action: action, summary: @creation.inspect)
+ if params[:spam] == "true"
+ unless @creation.hidden_by_admin
+ @creation.notify_of_hiding_for_spam if @creation_class == Work
+ @creation.hidden_by_admin = true
+ end
+ @creation.mark_as_spam!
+ flash[:notice] = ts("Work was marked as spam and hidden.")
+ else
+ @creation.mark_as_ham!
+ @creation.update_attribute(:hidden_by_admin, false)
+ flash[:notice] = ts("Work was marked not spam and unhidden.")
+ end
+ redirect_to polymorphic_path(@creation)
+ end
+
+ def destroy
+ authorize @creation
+ AdminActivity.log_action(current_admin, @creation, action: "destroy", summary: @creation.inspect)
+ @creation.destroy
+ flash[:notice] = ts("Item was successfully deleted.")
+ if @creation_class == Bookmark || @creation_class == ExternalWork
+ redirect_to bookmarks_path
+ else
+ redirect_to works_path
+ end
+ end
+
+ def confirm_remove_pseud
+ @work = authorize Work.find(params[:id])
+
+ @orphan_pseuds = @work.orphan_pseuds
+ return unless @orphan_pseuds.empty?
+
+ flash[:error] = t(".must_have_orphan_pseuds")
+ redirect_to work_path(@work) and return
+ end
+
+ def remove_pseud
+ @work = authorize Work.find(params[:id])
+
+ pseuds = params[:pseuds]
+ orphan_account = User.orphan_account
+ if pseuds.blank?
+ pseuds = @work.orphan_pseuds
+ if pseuds.length > 1
+ flash[:error] = t(".must_select_pseud")
+ redirect_to work_path(@work) and return
+ end
+ else
+ pseuds = Pseud.find(pseuds).select { |p| p.user_id == orphan_account.id }
+ end
+
+ orphan_pseud = orphan_account.default_pseud
+ pseuds.each do |pseud|
+ pseud.change_ownership(@work, orphan_pseud)
+ end
+ unless pseuds.empty?
+ AdminActivity.log_action(current_admin, @work, action: "remove orphan_account pseuds")
+ flash[:notice] = t(".success", pseuds: pseuds.map(&:byline).to_sentence, count: pseuds.length)
+ end
+ redirect_to work_path(@work)
+ end
+end
diff --git a/app/controllers/admin_posts_controller.rb b/app/controllers/admin_posts_controller.rb
new file mode 100755
index 0000000..426c71b
--- /dev/null
+++ b/app/controllers/admin_posts_controller.rb
@@ -0,0 +1,101 @@
+class AdminPostsController < Admin::BaseController
+
+ before_action :admin_only, except: [:index, :show]
+ before_action :load_languages, except: [:show, :destroy]
+
+ # GET /admin_posts
+ def index
+ if params[:tag]
+ @tag = AdminPostTag.find_by(id: params[:tag])
+ if @tag
+ @admin_posts = @tag.admin_posts
+ end
+ end
+ @admin_posts ||= AdminPost
+ if params[:language_id].present? && (@language = Language.find_by(short: params[:language_id]))
+ @admin_posts = @admin_posts.where(language_id: @language.id)
+ @tags = AdminPostTag.distinct.joins(:admin_posts).where(admin_posts: { language_id: @language.id }).order(:name)
+ else
+ @admin_posts = @admin_posts.non_translated
+ @tags = AdminPostTag.order(:name)
+ end
+ @admin_posts = @admin_posts.order('created_at DESC').page(params[:page])
+ end
+
+ # GET /admin_posts/1
+ def show
+ admin_posts = AdminPost.non_translated
+ @admin_post = AdminPost.find_by(id: params[:id])
+ unless @admin_post
+ raise ActiveRecord::RecordNotFound, "Couldn't find admin post '#{params[:id]}'"
+ end
+ @admin_posts = admin_posts.order('created_at DESC').limit(8)
+ @previous_admin_post = admin_posts.order('created_at DESC').where('created_at < ?', @admin_post.created_at).first
+ @next_admin_post = admin_posts.order('created_at ASC').where('created_at > ?', @admin_post.created_at).first
+ @page_subtitle = @admin_post.title.html_safe
+ respond_to do |format|
+ format.html # show.html.erb
+ format.js
+ end
+ end
+
+ # GET /admin_posts/new
+ # GET /admin_posts/new.xml
+ def new
+ @admin_post = AdminPost.new
+ authorize @admin_post
+ end
+
+ # GET /admin_posts/1/edit
+ def edit
+ @admin_post = AdminPost.find(params[:id])
+ authorize @admin_post
+ end
+
+ # POST /admin_posts
+ def create
+ @admin_post = AdminPost.new(admin_post_params)
+ authorize @admin_post
+ if @admin_post.save
+ flash[:notice] = ts("Admin Post was successfully created.")
+ redirect_to(@admin_post)
+ else
+ render action: "new"
+ end
+ end
+
+ # PUT /admin_posts/1
+ def update
+ @admin_post = AdminPost.find(params[:id])
+ authorize @admin_post
+ if @admin_post.update(admin_post_params)
+ flash[:notice] = ts("Admin Post was successfully updated.")
+ redirect_to(@admin_post)
+ else
+ render action: "edit"
+ end
+ end
+
+ # DELETE /admin_posts/1
+ def destroy
+ @admin_post = AdminPost.find(params[:id])
+ authorize @admin_post
+ @admin_post.destroy
+ redirect_to(admin_posts_path)
+ end
+
+ protected
+
+ def load_languages
+ @news_languages = Language.where(id: Locale.all.map(&:language_id)).default_order
+ end
+
+ private
+
+ def admin_post_params
+ params.require(:admin_post).permit(
+ :admin_id, :title, :content, :translated_post_id, :language_id, :tag_list,
+ :comment_permissions, :moderated_commenting_enabled
+ )
+ end
+end
diff --git a/app/controllers/admins_controller.rb b/app/controllers/admins_controller.rb
new file mode 100755
index 0000000..aed27dd
--- /dev/null
+++ b/app/controllers/admins_controller.rb
@@ -0,0 +1,4 @@
+class AdminsController < Admin::BaseController
+ def index
+ end
+end
diff --git a/app/controllers/api/v2/base_controller.rb b/app/controllers/api/v2/base_controller.rb
new file mode 100755
index 0000000..54eb47b
--- /dev/null
+++ b/app/controllers/api/v2/base_controller.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+module Api
+ # Version the API explicitly in the URL to allow different versions with breaking changes to co-exist if necessary.
+ # The roll over to the next number should happen when code written against the old version will not work
+ # with the new version.
+ module V2
+ class BaseController < ApplicationController
+ skip_before_action :verify_authenticity_token
+ before_action :restrict_access
+
+ # Prevent unhandled errors from returning the normal HTML page
+ rescue_from StandardError, with: :render_standard_error_response
+
+ private
+
+ # Look for a token in the Authorization header only and check that the token isn't currently banned
+ def restrict_access
+ authenticate_or_request_with_http_token do |token, _|
+ ApiKey.exists?(access_token: token) && !ApiKey.find_by(access_token: token).banned?
+ end
+ end
+
+ # Top-level error handling: returns a 403 forbidden if a valid archivist isn't supplied and a 400
+ # if no works are supplied. If there is neither a valid archivist nor valid works, a 400 is returned
+ # with both errors as a message
+ def batch_errors(archivist, import_items)
+ errors = []
+ status = ""
+
+ if archivist&.is_archivist?
+ if import_items.nil? || import_items.empty?
+ status = :empty_request
+ errors << "No items to import were provided."
+ elsif import_items.size >= ArchiveConfig.IMPORT_MAX_WORKS_BY_ARCHIVIST
+ status = :too_many_request
+ errors << "This request contains too many items to import. A maximum of #{ArchiveConfig.IMPORT_MAX_WORKS_BY_ARCHIVIST} " \
+ "items can be imported at one time by an archivist."
+ end
+ else
+ status = :forbidden
+ errors << "The \"archivist\" field must specify the name of an Archive user with archivist privileges."
+ end
+
+ status = :ok if errors.empty?
+ [status, errors]
+ end
+
+ # Return a standard HTTP + Json envelope for all API responses
+ def render_api_response(status, messages, response = {})
+ # It's a bad request unless it's ok or an authorisation error
+ http_status = %i[forbidden ok].include?(status) ? status : :bad_request
+ render status: http_status, json: { status: status, messages: messages }.merge(response)
+ end
+
+ # Return a standard HTTP + Json envelope for errors that drop through other handling
+ def render_standard_error_response(exception)
+ message = "An error occurred in the Archive code: #{exception.message}"
+ render status: :internal_server_error, json: { status: :internal_server_error, messages: [message] }
+ end
+ end
+ end
+end
diff --git a/app/controllers/api/v2/bookmarks_controller.rb b/app/controllers/api/v2/bookmarks_controller.rb
new file mode 100755
index 0000000..8af657b
--- /dev/null
+++ b/app/controllers/api/v2/bookmarks_controller.rb
@@ -0,0 +1,229 @@
+class Api::V2::BookmarksController < Api::V2::BaseController
+ respond_to :json
+
+ # POST - search for bookmarks for this archivist
+ def search
+ archivist = User.find_by(login: params[:archivist])
+ bookmarks = params[:bookmarks]
+ # check for top-level errors (not an archivist, no bookmarks...)
+ status, messages = batch_errors(archivist, bookmarks)
+ results = []
+
+ if status == :ok
+ archivist_bookmarks = Bookmark.where(pseud_id: archivist.default_pseud.id)
+ results = bookmarks.map do |bookmark|
+ found_result = {}
+ found_result = check_archivist_bookmark(archivist, bookmark[:url], archivist_bookmarks) unless archivist_bookmarks.empty?
+
+ bookmark_response(
+ status: found_result[:bookmark_status] || :not_found,
+ bookmark_url: found_result[:bookmark_url] || "",
+ bookmark_id: bookmark[:id],
+ original_url: bookmark[:url],
+ messages: found_result[:bookmark_messages] || ["No bookmark found for archivist \"#{archivist.login}\" and URL \"#{bookmark[:url]}\""]
+ )
+ end
+ messages = ["Successfully searched bookmarks for archivist '#{archivist.login}'"]
+ end
+
+ render_api_response(status, messages, bookmarks: results)
+ end
+
+ # POST - create a bookmark for this archivist
+ def create
+ archivist = User.find_by(login: params[:archivist])
+ bookmarks = params[:bookmarks]
+ bookmarks_responses = []
+ @bookmarks = []
+
+ # check for top-level errors (not an archivist, no bookmarks...)
+ status, messages = batch_errors(archivist, bookmarks)
+
+ if status == :ok
+ # Flag error and successes
+ @some_errors = @some_success = false
+
+ # Process the bookmarks
+ archivist_bookmarks = Bookmark.where(pseud_id: archivist.default_pseud_id, bookmarkable_type: "ExternalWork")
+ bookmarks.each do |bookmark|
+ bookmarks_responses << create_bookmark(archivist, bookmark, archivist_bookmarks)
+ end
+
+ # set final response code and message depending on the flags
+ status = :bad_request if bookmarks_responses.any? { |r| [:ok, :created, :found].exclude?(r[:status]) }
+ messages = response_message(messages)
+ end
+
+ render_api_response(status, messages, bookmarks: bookmarks_responses)
+ end
+
+ private
+
+ # Find bookmarks for this archivist
+ def check_archivist_bookmark(archivist, current_bookmark_url, archivist_bookmarks)
+ archivist_bookmarks = archivist_bookmarks
+ .select { |b| b&.bookmarkable.is_a?(ExternalWork) ? b&.bookmarkable&.url == current_bookmark_url : false }
+ .map { |b| [b, b.bookmarkable] }
+
+ if archivist_bookmarks.present?
+ archivist_bookmark, archivist_bookmarkable = archivist_bookmarks.first
+ find_bookmark_response(
+ bookmarkable: archivist_bookmarkable,
+ bookmark_status: :found,
+ bookmark_message: "There is already a bookmark for #{archivist.login} and the URL #{current_bookmark_url}",
+ bookmark_url: bookmark_url(archivist_bookmark)
+ )
+ else
+ find_bookmark_response(
+ bookmarkable: nil,
+ bookmark_status: :not_found,
+ bookmark_message: "There is no bookmark for #{archivist.login} and the URL #{current_bookmark_url}",
+ bookmark_url: ""
+ )
+ end
+ end
+
+ # Create a bookmark for this archivist using the Bookmark model
+ def create_bookmark(archivist, params, archivist_bookmarks)
+ found_result = {}
+ bookmark_attributes = bookmark_attributes(archivist, params)
+ external_work_attributes = external_work_attributes(params)
+ bookmark_status, bookmark_messages = external_work_errors(external_work_attributes)
+ bookmark_url = nil
+ original_url = nil
+ bookmarkable = nil
+ @some_errors = true
+ if bookmark_status == :ok
+ begin
+
+ # Check if this bookmark is already imported by filtering the archivist's bookmarks
+ unless archivist_bookmarks.empty?
+ found_result = check_archivist_bookmark(archivist, external_work_attributes[:url], archivist_bookmarks)
+ bookmarkable = found_result[:bookmarkable]
+ end
+
+ if found_result[:bookmark_status] == :found
+ found_result[:bookmark_status] = :already_imported
+ else
+ bookmarkable = ExternalWork.new(external_work_attributes)
+ bookmark = bookmarkable.bookmarks.build(bookmark_attributes)
+ if bookmarkable.save && bookmark.save
+ @bookmarks << bookmark
+ @some_success = true
+ @some_errors = false
+ bookmark_status = :created
+ bookmark_url = bookmark_url(bookmark)
+ bookmark_messages << "Successfully created bookmark for \"" + bookmarkable.title + "\"."
+ else
+ bookmark_status = :unprocessable_entity
+ bookmark_messages << bookmarkable.errors.full_messages + bookmark.errors.full_messages
+ end
+ end
+ rescue StandardError => exception
+ bookmark_status = :unprocessable_entity
+ bookmark_messages << exception.message
+ end
+ original_url = bookmarkable.url if bookmarkable
+ end
+
+ bookmark_response(
+ status: bookmark_status || found_result[:bookmark_status],
+ bookmark_url: bookmark_url || found_result[:bookmark_url],
+ bookmark_id: params[:id],
+ original_url: original_url,
+ messages: bookmark_messages.flatten || found_result[:bookmark_messages]
+ )
+ end
+
+ # Error handling
+
+ # Set messages based on success and error flags
+ def response_message(messages)
+ messages << if @some_success && @some_errors
+ "At least one bookmark was not created. Please check the individual bookmark results for further information."
+ elsif !@some_success && @some_errors
+ "None of the bookmarks were created. Please check the individual bookmark results for further information."
+ else
+ "All bookmarks were successfully created."
+ end
+ messages
+ end
+
+ # Handling for incomplete requests
+ def external_work_errors(external_work_attributes)
+ status = :bad_request
+ errors = []
+
+ # Perform basic validation which the ExternalWork model doesn't do or returns strange messages for
+ # (title is validated correctly in the model and so isn't checked here)
+ url = external_work_attributes[:url]
+ author = external_work_attributes[:author]
+ fandom = external_work_attributes[:fandom_string]
+
+ if url.nil?
+ # Unreachable and AO3 URLs are handled in the ExternalWork model
+ errors << "This bookmark does not contain a URL to an external site. Please specify a valid, non-AO3 URL."
+ end
+
+ if author.nil? || author == ""
+ errors << "This bookmark does not contain an external author name. Please specify an author."
+ end
+
+ if fandom.nil? || fandom == ""
+ errors << "This bookmark does not contain a fandom. Please specify a fandom."
+ end
+
+ status = :ok if errors.empty?
+ [status, errors]
+ end
+
+ # Request and response hashes
+
+ # Map JSON request to attributes for bookmark
+ def bookmark_attributes(archivist, params)
+ {
+ pseud_id: archivist.default_pseud_id,
+ bookmarker_notes: params[:bookmarker_notes],
+ tag_string: params[:tag_string] || "",
+ collection_names: params[:collection_names],
+ private: params[:private].blank? ? false : params[:private],
+ rec: params[:recommendation].blank? ? false : params[:recommendation]
+ }
+ end
+
+ # Map JSON request to attributes for external work
+ def external_work_attributes(params)
+ {
+ url: params[:url],
+ author: params[:author],
+ title: params[:title],
+ summary: params[:summary],
+ fandom_string: params[:fandom_string] || "",
+ rating_string: params[:rating_string] || "",
+ category_string: params[:category_string] ? params[:category_string].to_s.split(",") : [], # category is actually an array on bookmarks
+ relationship_string: params[:relationship_string] || "",
+ character_string: params[:character_string] || ""
+ }
+ end
+
+ def bookmark_response(status:, bookmark_url:, bookmark_id:, original_url:, messages:)
+ messages = [messages] unless messages.respond_to?('each')
+ {
+ status: status,
+ archive_url: bookmark_url,
+ original_id: bookmark_id,
+ original_url: original_url,
+ messages: messages
+ }
+ end
+
+ def find_bookmark_response(bookmarkable:, bookmark_status:, bookmark_message:, bookmark_url:)
+ bookmark_status = :not_found unless [:found, :not_found].include?(bookmark_status)
+ {
+ bookmarkable: bookmarkable,
+ bookmark_status: bookmark_status,
+ bookmark_messages: bookmark_message,
+ bookmark_url: bookmark_url
+ }
+ end
+end
diff --git a/app/controllers/api/v2/works_controller.rb b/app/controllers/api/v2/works_controller.rb
new file mode 100755
index 0000000..32bc213
--- /dev/null
+++ b/app/controllers/api/v2/works_controller.rb
@@ -0,0 +1,225 @@
+class Api::V2::WorksController < Api::V2::BaseController
+ respond_to :json
+
+ # POST - search for works based on imported url
+ def search
+ works = params[:works]
+ original_urls = works.map { |w| w[:original_urls] }.flatten
+
+ results = []
+ messages = []
+ if original_urls.nil? || original_urls.blank? || original_urls.empty?
+ status = :empty_request
+ messages << "Please provide a list of URLs to find."
+ elsif original_urls.size >= ArchiveConfig.IMPORT_MAX_CHAPTERS
+ status = :too_many_request
+ messages << "Please provide no more than #{ArchiveConfig.IMPORT_MAX_CHAPTERS} URLs to find."
+ else
+ status = :ok
+ results = find_existing_works(original_urls)
+ messages << "Successfully searched all provided URLs."
+ end
+ render_api_response(status, messages, works: results)
+ end
+
+ # POST - create a work and invite authors to claim
+ def create
+ archivist = User.find_by(login: params[:archivist])
+ external_works = params[:items] || params[:works]
+ works_responses = []
+
+ # check for top-level errors (not an archivist, no works...)
+ status, messages = batch_errors(archivist, external_works)
+
+ if status == :ok
+ # Process the works, updating the flags
+ works_responses = external_works.map { |external_work| import_work(archivist, external_work.merge(params.permit!)) }
+ success_responses, error_responses = works_responses.partition { |r| [:ok, :created, :found].include?(r[:status]) }
+
+ # Send claim notification emails for successful works
+ if params[:send_claim_emails] && success_responses.present?
+ notified_authors = notify_and_return_authors(success_responses.map { |r| r[:work] }, archivist)
+ messages << "Claim emails sent to #{notified_authors.to_sentence}."
+ end
+
+ # set final status code and message depending on the flags
+ status = :bad_request if error_responses.present?
+ messages = response_message(messages, success_responses.present?, error_responses.present?)
+ end
+ render_api_response(status, messages, works: works_responses.map { |r| r.except!(:work) })
+ end
+
+ private
+
+ # Set messages based on success and error flags
+ def response_message(messages, any_success, any_errors)
+ messages << if any_success && any_errors
+ "At least one work was not imported. Please check individual work responses for further information."
+ elsif !any_success && any_errors
+ "None of the works were imported. Please check individual work responses for further information."
+ else
+ "All works were successfully imported."
+ end
+ messages
+ end
+
+ # Work-level error handling for requests that are incomplete or too large
+ def work_errors(work)
+ status = :bad_request
+ errors = []
+ urls = work[:chapter_urls]
+ if urls.nil? || urls.empty?
+ status = :empty_request
+ errors << "This work doesn't contain any chapter URLs. " +
+ "Works can only be imported from publicly-accessible chapter URLs."
+ elsif urls.length >= ArchiveConfig.IMPORT_MAX_CHAPTERS
+ status = :too_many_requests
+ errors << "This work contains too many chapter URLs. A maximum of #{ArchiveConfig.IMPORT_MAX_CHAPTERS} " \
+ "chapters can be imported per work."
+ end
+ status = :ok if errors.empty?
+ [status, errors]
+ end
+
+ # Search for works imported from the provided URLs
+ def find_existing_works(original_urls)
+ results = []
+ messages = []
+ original_urls.each do |original|
+ original_id = ""
+ if original.class == String
+ original_url = original
+ else
+ original_id = original[:id]
+ original_url = original[:url]
+ end
+
+ # Search for works - there may be duplicates
+ search_results = find_work_by_import_url(original_url)
+ if search_results[:works].empty?
+ results << { status: :not_found,
+ original_id: original_id,
+ original_url: original_url,
+ messages: [search_results[:message]] }
+ else
+ work_results = search_results[:works].map do |work|
+ archive_url = work_url(work)
+ message = "Work \"#{work.title}\", created on #{work.created_at.to_date.to_fs(:iso_date)} was found at \"#{archive_url}\"."
+ messages << message
+ { archive_url: archive_url,
+ created: work.created_at,
+ message: message }
+ end
+ results << { status: :found,
+ original_id: original_id,
+ original_url: original_url,
+ search_results: work_results,
+ messages: messages
+ }
+ end
+ end
+ results
+ end
+
+ def find_work_by_import_url(original_url)
+ works = nil
+ if original_url.blank?
+ message = "Please provide the original URL for the work."
+ else
+ # We know the url will be identical no need for a call to find_by_url
+ works = Work.where(imported_from_url: original_url)
+ message = if works.empty?
+ "No work has been imported from \"" + original_url + "\"."
+ else
+ "Found #{works.size} work(s) imported from \"" + original_url + "\"."
+ end
+ end
+ {
+ original_url: original_url,
+ works: works,
+ message: message
+ }
+ end
+
+ # Use the story parser to scrape works from the chapter URLs
+ def import_work(archivist, external_work)
+ work_status, work_messages = work_errors(external_work)
+ work_url = ""
+ original_url = ""
+ if work_status == :ok
+ urls = external_work[:chapter_urls]
+ original_url = urls.first
+ storyparser = StoryParser.new
+ options = story_parser_options(archivist, external_work)
+ begin
+ response = storyparser.import_chapters_into_story(urls, options)
+ work = response[:work]
+ work_status = response[:status]
+
+ work.save if work_status == :created
+ work_url = work_url(work)
+ work_messages << response[:message]
+ rescue => exception
+ Rails.logger.error("------- API v2: error: #{exception.inspect}")
+ work_status = :unprocessable_entity
+ work_messages << "Unable to import this work."
+ work_messages << exception.message
+ end
+ end
+
+ {
+ status: work_status,
+ archive_url: work_url,
+ original_id: external_work[:id],
+ original_url: original_url,
+ messages: work_messages,
+ work: work
+ }
+ end
+
+ # Send invitations to external authors for a given set of works
+ def notify_and_return_authors(success_works, archivist)
+ notified_authors = []
+ external_authors = success_works.map(&:external_authors).flatten.uniq
+ external_authors&.each do |external_author|
+ external_author.find_or_invite(archivist)
+ # One of the external author pseuds is its email address so filter that one out
+ author_names = external_author.names.reject { |a| a.name == external_author.email }.map(&:name).flatten.join(", ")
+ notified_authors << author_names
+ end
+ notified_authors
+ end
+
+ # Request and response hashes
+
+ # Create options map for StoryParser
+ def story_parser_options(archivist, work_params)
+ {
+ archivist: archivist,
+ import_multiple: "chapters",
+ importing_for_others: true,
+ do_not_set_current_author: true,
+ post_without_preview: work_params[:post_without_preview].blank? ? true : work_params[:post_without_preview],
+ restricted: work_params[:restricted],
+ override_tags: work_params[:override_tags].nil? ? true : work_params[:override_tags],
+ detect_tags: work_params[:detect_tags].nil? ? true : work_params[:detect_tags],
+ collection_names: work_params[:collection_names],
+ title: work_params[:title],
+ fandom: work_params[:fandoms],
+ archive_warning: work_params[:warnings],
+ character: work_params[:characters],
+ rating: work_params[:rating],
+ relationship: work_params[:relationships],
+ category: work_params[:categories],
+ freeform: work_params[:additional_tags],
+ language_id: Language.find_by(short: work_params[:language_code])&.id,
+ summary: work_params[:summary],
+ notes: work_params[:notes],
+ encoding: work_params[:encoding],
+ external_author_name: work_params[:external_author_name],
+ external_author_email: work_params[:external_author_email],
+ external_coauthor_name: work_params[:external_coauthor_name],
+ external_coauthor_email: work_params[:external_coauthor_email]
+ }
+ end
+end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
new file mode 100755
index 0000000..f24368b
--- /dev/null
+++ b/app/controllers/application_controller.rb
@@ -0,0 +1,564 @@
+class ApplicationController < ActionController::Base
+ include ActiveStorage::SetCurrent
+ include Pundit::Authorization
+ protect_from_forgery with: :exception, prepend: true
+ rescue_from ActionController::InvalidAuthenticityToken, with: :display_auth_error
+
+ rescue_from Pundit::NotAuthorizedError do
+ admin_only_access_denied
+ end
+
+ # sets admin user for pundit policies
+ def pundit_user
+ current_admin
+ end
+
+ rescue_from ActionController::UnknownFormat, with: :raise_not_found
+ rescue_from Elastic::Transport::Transport::Errors::ServiceUnavailable do
+ # Non-standard code to distinguish Elasticsearch errors from standard 503s.
+ # We can't use 444 because nginx will close connections without sending
+ # response headers.
+ head 445
+ end
+
+ def raise_not_found
+ redirect_to '/404'
+ end
+
+ rescue_from Rack::Timeout::RequestTimeoutException, with: :raise_timeout
+
+ def raise_timeout
+ redirect_to timeout_error_path
+ end
+
+ helper :all # include all helpers, all the time
+
+ include HtmlCleaner
+ before_action :sanitize_ac_params
+
+ # sanitize_params works best with a hash, and will convert
+ # ActionController::Parameters to a hash in order to work with them anyway.
+ #
+ # Controllers need to deal with ActionController::Parameters, not hashes.
+ # These methods hand the params as a hash to sanitize_params, and then
+ # transforms the results back into ActionController::Parameters.
+ def sanitize_ac_params
+ sanitize_params(params.to_unsafe_h).each do |key, value|
+ params[key] = transform_sanitized_hash_to_ac_params(key, value)
+ end
+ end
+
+ include Pagy::Backend
+ def pagy(collection, **vars)
+ pagy_overflow_handler do
+ super
+ end
+ end
+
+ def pagy_query_result(query_result, **vars)
+ pagy_overflow_handler do
+ Pagy.new(
+ count: query_result.total_entries,
+ page: query_result.current_page,
+ limit: query_result.per_page,
+ **vars
+ )
+ end
+ end
+
+ def pagy_overflow_handler(*)
+ yield
+ rescue Pagy::OverflowError
+ nil
+ end
+
+ def display_auth_error
+ respond_to do |format|
+ format.html do
+ redirect_to auth_error_path
+ end
+ format.any(:js, :json) do
+ render json: {
+ errors: {
+ auth_error: "Your current session has expired and we can't authenticate your request. Try logging in again, refreshing the page, or clearing your cache if you continue to experience problems.".html_safe
+ }
+ }, status: :unprocessable_entity
+ end
+ end
+ end
+
+ def transform_sanitized_hash_to_ac_params(key, value)
+ if value.is_a?(Hash)
+ ActionController::Parameters.new(value)
+ elsif value.is_a?(Array)
+ value.map.with_index do |val, index|
+ value[index] = transform_sanitized_hash_to_ac_params(key, val)
+ end
+ else
+ value
+ end
+ end
+
+ helper_method :current_user
+ helper_method :current_admin
+ helper_method :logged_in?
+ helper_method :logged_in_as_admin?
+ helper_method :guest?
+
+ # Title helpers
+ helper_method :process_title
+
+ # clear out the flash-being-set
+ before_action :clear_flash_cookie
+ def clear_flash_cookie
+ cookies.delete(:flash_is_set)
+ end
+
+ after_action :check_for_flash
+ def check_for_flash
+ cookies[:flash_is_set] = 1 unless flash.empty?
+ end
+
+ # Override redirect_to so that if it's called in a before_action hook, it'll
+ # still call check_for_flash after it runs.
+ def redirect_to(*args, **kwargs)
+ super.tap do
+ check_for_flash
+ end
+ end
+
+ after_action :ensure_admin_credentials
+ def ensure_admin_credentials
+ if logged_in_as_admin?
+ # if we are logged in as an admin and we don't have the admin_credentials
+ # set then set that cookie
+ cookies[:admin_credentials] = { value: 1, expires: 1.year.from_now } unless cookies[:admin_credentials]
+ else
+ # if we are NOT logged in as an admin and we have the admin_credentials
+ # set then delete that cookie
+ cookies.delete :admin_credentials unless cookies[:admin_credentials].nil?
+ end
+ end
+
+ # If there is no user_credentials cookie and the user appears to be logged in,
+ # redirect to the lost cookie page. Needs to be before the code to fix
+ # the user_credentials cookie or it won't fire.
+ before_action :logout_if_not_user_credentials
+ def logout_if_not_user_credentials
+ if logged_in? && cookies[:user_credentials].nil? && controller_name != "sessions"
+ logger.error "Forcing logout"
+ sign_out
+ redirect_to '/lost_cookie' and return
+ end
+ end
+
+ # The user_credentials cookie is used by nginx to figure out whether or not
+ # to cache the page, so we want to make sure that it's set when the user is
+ # logged in, and cleared when the user is logged out.
+ after_action :ensure_user_credentials
+ def ensure_user_credentials
+ if logged_in?
+ cookies[:user_credentials] = { value: 1, expires: 1.year.from_now } unless cookies[:user_credentials]
+ else
+ cookies.delete :user_credentials unless cookies[:user_credentials].nil?
+ end
+ end
+
+protected
+
+ def logged_in?
+ user_signed_in?
+ end
+
+ def logged_in_as_admin?
+ admin_signed_in?
+ end
+
+ def guest?
+ !(logged_in? || logged_in_as_admin?)
+ end
+
+ def process_title(string)
+ string = string.humanize.titleize
+
+ string = string.sub("Faq", "FAQ")
+ string = string.sub("Tos", "TOS")
+ string = string.sub("Dmca", "DMCA")
+ return string
+ end
+
+public
+
+ before_action :load_admin_banner
+ def load_admin_banner
+ if Rails.env.development?
+ @admin_banner = AdminBanner.where(active: true).last
+ else
+ # http://stackoverflow.com/questions/12891790/will-returning-a-nil-value-from-a-block-passed-to-rails-cache-fetch-clear-it
+ # Basically we need to store a nil separately.
+ @admin_banner = Rails.cache.fetch("admin_banner") do
+ banner = AdminBanner.where(active: true).last
+ banner.nil? ? "" : banner
+ end
+ @admin_banner = nil if @admin_banner == ""
+ end
+ end
+
+ before_action :load_tos_popup
+ def load_tos_popup
+ # Integers only, YYYY-MM-DD format of date Board approved TOS
+ @current_tos_version = 2024_11_19 # rubocop:disable Style/NumericLiterals
+ end
+
+ # store previous page in session to make redirecting back possible
+ # if already redirected once, don't redirect again.
+ before_action :store_location
+ def store_location
+ if session[:return_to] == "redirected"
+ session.delete(:return_to)
+ elsif request.fullpath.length > 200
+ # Sessions are stored in cookies, which has a 4KB size limit.
+ # Don't store paths that are too long (e.g. filters with lots of exclusions).
+ # Also remove the previous stored path.
+ session.delete(:return_to)
+ else
+ session[:return_to] = request.fullpath
+ end
+ end
+
+ # Redirect to the URI stored by the most recent store_location call or
+ # to the passed default.
+ def redirect_back_or_default(default = root_path)
+ back = session[:return_to]
+ session.delete(:return_to)
+ if back
+ session[:return_to] = "redirected"
+ redirect_to(back) and return
+ else
+ redirect_to(default) and return
+ end
+ end
+
+ def after_sign_in_path_for(resource)
+ if resource.is_a?(Admin)
+ admins_path
+ else
+ back = session[:return_to]
+ session.delete(:return_to)
+
+ back || user_path(current_user)
+ end
+ end
+
+ def authenticate_admin!
+ if admin_signed_in?
+ super
+ else
+ redirect_to root_path, notice: "I'm sorry, only an admin can look at that area"
+ ## if you want render 404 page
+ ## render file: File.join(Rails.root, 'public/404'), formats: [:html], status: 404, layout: false
+ end
+ end
+
+ # Filter method - keeps users out of admin areas
+ def admin_only
+ authenticate_admin! || admin_only_access_denied
+ end
+
+ # Filter method to prevent admin users from accessing certain actions
+ def users_only
+ logged_in? || access_denied
+ end
+
+ # Filter method - requires user to have opendoors privs
+ def opendoors_only
+ (logged_in? && permit?("opendoors")) || access_denied
+ end
+
+ # Redirect as appropriate when an access request fails.
+ #
+ # The default action is to redirect to the login screen.
+ #
+ # Override this method in your controllers if you want to have special
+ # behavior in case the user is not authorized
+ # to access the requested action. For example, a popup window might
+ # simply close itself.
+ def access_denied(options ={})
+ store_location
+ if logged_in?
+ destination = options[:redirect].blank? ? user_path(current_user) : options[:redirect]
+ # i18n-tasks-use t('users.reconfirm_email.access_denied.logged_in')
+ flash[:error] = t(".access_denied.logged_in", default: t("application.access_denied.access_denied.logged_in")) # rubocop:disable I18n/DefaultTranslation
+ redirect_to destination
+ else
+ destination = options[:redirect].blank? ? new_user_session_path : options[:redirect]
+ flash[:error] = ts "Sorry, you don't have permission to access the page you were trying to reach. Please log in."
+ redirect_to destination
+ end
+ false
+ end
+
+ def admin_only_access_denied
+ respond_to do |format|
+ format.html do
+ flash[:error] = t("admin.access.page_access_denied")
+ redirect_to root_path
+ end
+ format.json do
+ errors = [t("admin.access.action_access_denied")]
+ render json: { errors: errors }, status: :forbidden
+ end
+ format.js do
+ flash[:error] = t("admin.access.page_access_denied")
+ render js: "window.location.href = '#{root_path}';"
+ end
+ end
+ end
+
+ # Filter method - prevents users from logging in as admin
+ def user_logout_required
+ if logged_in?
+ flash[:notice] = 'Please log out of your user account first!'
+ redirect_to root_path
+ end
+ end
+
+ # Prevents admin from logging in as users
+ def admin_logout_required
+ if logged_in_as_admin?
+ flash[:notice] = 'Please log out of your admin account first!'
+ redirect_to root_path
+ end
+ end
+
+ # Hide admin banner via cookies
+ before_action :hide_banner
+ def hide_banner
+ if params[:hide_banner]
+ session[:hide_banner] = true
+ end
+ end
+
+ # Store the current user as a class variable in the User class,
+ # so other models can access it with "User.current_user"
+ around_action :set_current_user
+ def set_current_user
+ User.current_user = logged_in_as_admin? ? current_admin : current_user
+ @current_user = current_user
+ unless current_user.nil?
+ @current_user_subscriptions_count, @current_user_visible_work_count, @current_user_bookmarks_count, @current_user_owned_collections_count, @current_user_challenge_signups_count, @current_user_offer_assignments, @current_user_unposted_works_size=
+ Rails.cache.fetch("user_menu_counts_#{current_user.id}",
+ expires_in: 2.hours,
+ race_condition_ttl: 5) { "#{current_user.subscriptions.count}, #{current_user.visible_work_count}, #{current_user.bookmarks.count}, #{current_user.owned_collections.count}, #{current_user.challenge_signups.count}, #{current_user.offer_assignments.undefaulted.count + current_user.pinch_hit_assignments.undefaulted.count}, #{current_user.unposted_works.size}" }.split(",").map(&:to_i)
+ end
+
+ yield
+
+ User.current_user = nil
+ @current_user = nil
+ end
+
+ def load_collection
+ @collection = Collection.find_by(name: params[:collection_id]) if params[:collection_id]
+ end
+
+ def collection_maintainers_only
+ logged_in? && @collection && @collection.user_is_maintainer?(current_user) || access_denied
+ end
+
+ def collection_owners_only
+ logged_in? && @collection && @collection.user_is_owner?(current_user) || access_denied
+ end
+
+ def not_allowed(fallback=nil)
+ flash[:error] = ts("Sorry, you're not allowed to do that.")
+ redirect_to (fallback || root_path) rescue redirect_to '/'
+ end
+
+
+ def get_page_title(fandom, author, title, options = {})
+ # truncate any piece that is over 15 chars long to the nearest word
+ if options[:truncate]
+ fandom = fandom.gsub(/^(.{15}[\w.]*)(.*)/) {$2.empty? ? $1 : $1 + '...'}
+ author = author.gsub(/^(.{15}[\w.]*)(.*)/) {$2.empty? ? $1 : $1 + '...'}
+ title = title.gsub(/^(.{15}[\w.]*)(.*)/) {$2.empty? ? $1 : $1 + '...'}
+ end
+
+ @page_title = ""
+ if logged_in? && !current_user.preference.try(:work_title_format).blank?
+ @page_title = current_user.preference.work_title_format.dup
+ @page_title.gsub!(/FANDOM/, fandom)
+ @page_title.gsub!(/AUTHOR/, author)
+ @page_title.gsub!(/TITLE/, title)
+ else
+ @page_title = title + " - " + author + " - " + fandom
+ end
+
+ @page_title += " [#{ArchiveConfig.APP_NAME}]" unless options[:omit_archive_name]
+ @page_title.html_safe
+ end
+
+ public
+
+ #### -- AUTHORIZATION -- ####
+
+ # It is just much easier to do this here than to try to stuff variable values into a constant in environment.rb
+
+ def is_registered_user?
+ logged_in? || logged_in_as_admin?
+ end
+
+ def is_admin?
+ logged_in_as_admin?
+ end
+
+ def see_adult?
+ params[:anchor] = "comments" if (params[:show_comments] && params[:anchor].blank?)
+ return true if cookies[:view_adult] || logged_in_as_admin?
+ return false unless current_user
+ return true if current_user.is_author_of?(@work)
+ return true if current_user.preference && current_user.preference.adult
+ return false
+ end
+
+ def use_caching?
+ %w(staging production test).include?(Rails.env) && AdminSetting.current.enable_test_caching?
+ end
+
+ protected
+
+ # Prevents banned and suspended users from adding/editing content
+ def check_user_status
+ if current_user.is_a?(User) && (current_user.suspended? || current_user.banned?)
+ if current_user.suspended?
+ suspension_end = current_user.suspended_until
+
+ # Unban threshold is 6:51pm, 12 hours after the unsuspend_users rake task located in schedule.rb is run at 6:51am
+ unban_theshold = DateTime.new(suspension_end.year, suspension_end.month, suspension_end.day, 18, 51, 0, "+00:00")
+
+ # If the stated suspension end date is after the unban threshold we need to advance a day
+ suspension_end = suspension_end.next_day(1) if suspension_end > unban_theshold
+ localized_suspension_end = view_context.date_in_zone(suspension_end)
+ flash[:error] = t("users.status.suspension_notice_html", suspended_until: localized_suspension_end, contact_abuse_link: view_context.link_to(t("users.contact_abuse"), new_abuse_report_path))
+
+ else
+ flash[:error] = t("users.status.ban_notice_html", contact_abuse_link: view_context.link_to(t("users.contact_abuse"), new_abuse_report_path))
+ end
+ redirect_to current_user
+ end
+ end
+
+ # Prevents temporarily suspended users from deleting content
+ def check_user_not_suspended
+ return unless current_user.is_a?(User) && current_user.suspended?
+
+ suspension_end = current_user.suspended_until
+
+ # Unban threshold is 6:51pm, 12 hours after the unsuspend_users rake task located in schedule.rb is run at 6:51am
+ unban_theshold = DateTime.new(suspension_end.year, suspension_end.month, suspension_end.day, 18, 51, 0, "+00:00")
+
+ # If the stated suspension end date is after the unban threshold we need to advance a day
+ suspension_end = suspension_end.next_day(1) if suspension_end > unban_theshold
+ localized_suspension_end = view_context.date_in_zone(suspension_end)
+
+ flash[:error] = t("users.status.suspension_notice_html", suspended_until: localized_suspension_end, contact_abuse_link: view_context.link_to(t("users.contact_abuse"), new_abuse_report_path))
+
+ redirect_to current_user
+ end
+
+ # Does the current user own a specific object?
+ def current_user_owns?(item)
+ !item.nil? && current_user.is_a?(User) && (item.is_a?(User) ? current_user == item : current_user.is_author_of?(item))
+ end
+
+ # Make sure a specific object belongs to the current user and that they have permission
+ # to view, edit or delete it
+ def check_ownership
+ access_denied(redirect: @check_ownership_of) unless current_user_owns?(@check_ownership_of)
+ end
+ def check_ownership_or_admin
+ return true if logged_in_as_admin?
+ access_denied(redirect: @check_ownership_of) unless current_user_owns?(@check_ownership_of)
+ end
+
+ # Make sure the user is allowed to see a specific page
+ # includes a special case for restricted works and series, since we want to encourage people to sign up to read them
+ def check_visibility
+ if @check_visibility_of.respond_to?(:restricted) && @check_visibility_of.restricted && User.current_user.nil?
+ redirect_to new_user_session_path(restricted: true)
+ elsif @check_visibility_of.is_a? Skin
+ access_denied unless logged_in_as_admin? || current_user_owns?(@check_visibility_of) || @check_visibility_of.official?
+ else
+ is_hidden = (@check_visibility_of.respond_to?(:visible) && !@check_visibility_of.visible) ||
+ (@check_visibility_of.respond_to?(:visible?) && !@check_visibility_of.visible?) ||
+ (@check_visibility_of.respond_to?(:hidden_by_admin?) && @check_visibility_of.hidden_by_admin?)
+ can_view_hidden = logged_in_as_admin? || current_user_owns?(@check_visibility_of)
+ access_denied if (is_hidden && !can_view_hidden)
+ end
+ end
+
+ # Make sure user is allowed to access tag wrangling pages
+ def check_permission_to_wrangle
+ if AdminSetting.current.tag_wrangling_off? && !logged_in_as_admin?
+ flash[:error] = "Wrangling is disabled at the moment. Please check back later."
+ redirect_to root_path
+ else
+ logged_in_as_admin? || permit?("tag_wrangler") || access_denied
+ end
+ end
+
+ # Checks if user is allowed to see related page if parent item is hidden or in unrevealed collection
+ # Checks if user is logged in if parent item is restricted
+ def check_visibility_for(parent)
+ return if logged_in_as_admin? || current_user_owns?(parent) # Admins and the owner can see all related pages
+
+ access_denied(redirect: root_path) if parent.try(:hidden_by_admin) || parent.try(:in_unrevealed_collection) || (parent.respond_to?(:visible?) && !parent.visible?)
+ end
+
+ public
+
+ def valid_sort_column(param, model='work')
+ allowed = []
+ if model.to_s.downcase == 'work'
+ allowed = %w(author title date created_at word_count hit_count)
+ elsif model.to_s.downcase == 'tag'
+ allowed = %w[name created_at taggings_count_cache uses]
+ elsif model.to_s.downcase == 'collection'
+ allowed = %w(collections.title collections.created_at)
+ elsif model.to_s.downcase == 'prompt'
+ allowed = %w(fandom created_at prompter)
+ elsif model.to_s.downcase == 'claim'
+ allowed = %w(created_at claimer)
+ end
+ !param.blank? && allowed.include?(param.to_s.downcase)
+ end
+
+ def set_sort_order
+ # sorting
+ @sort_column = (valid_sort_column(params[:sort_column],"prompt") ? params[:sort_column] : 'id')
+ @sort_direction = (valid_sort_direction(params[:sort_direction]) ? params[:sort_direction] : 'DESC')
+ if !params[:sort_direction].blank? && !valid_sort_direction(params[:sort_direction])
+ params[:sort_direction] = 'DESC'
+ end
+ @sort_order = @sort_column + " " + @sort_direction
+ end
+
+ def valid_sort_direction(param)
+ !param.blank? && %w(asc desc).include?(param.to_s.downcase)
+ end
+
+ def flash_search_warnings(result)
+ if result.respond_to?(:error) && result.error
+ flash.now[:error] = result.error
+ elsif result.respond_to?(:notice) && result.notice
+ flash.now[:notice] = result.notice
+ end
+ end
+
+ # Don't get unnecessary data for json requests
+
+ skip_before_action :load_admin_banner,
+ :store_location,
+ if: proc { %w(js json).include?(request.format) }
+
+end
diff --git a/app/controllers/archive_faqs_controller.rb b/app/controllers/archive_faqs_controller.rb
new file mode 100755
index 0000000..8a8a881
--- /dev/null
+++ b/app/controllers/archive_faqs_controller.rb
@@ -0,0 +1,195 @@
+class ArchiveFaqsController < ApplicationController
+ before_action :admin_only, except: [:index, :show]
+ before_action :set_locale
+ before_action :validate_locale, if: :logged_in_as_admin?
+ before_action :require_language_id
+ before_action :default_locale_only, only: [:new, :create, :manage, :update_positions, :confirm_delete, :destroy]
+ around_action :with_locale
+
+ # GET /archive_faqs
+ def index
+ @archive_faqs = ArchiveFaq.order("position ASC")
+ unless logged_in_as_admin?
+ @archive_faqs = @archive_faqs.with_translations(I18n.locale)
+ end
+ respond_to do |format|
+ format.html # index.html.erb
+ end
+ end
+
+ # GET /archive_faqs/1
+ def show
+ @questions = []
+ @archive_faq = ArchiveFaq.find_by!(slug: params[:id])
+ if params[:language_id] == "en"
+ @questions = @archive_faq.questions
+ else
+ @archive_faq.questions.each do |question|
+ question.translations.each do |translation|
+ if translation.is_translated == "1" && params[:language_id].to_s == translation.locale.to_s
+ @questions << question
+ end
+ end
+ end
+ end
+ @page_subtitle = @archive_faq.title + ts(" FAQ")
+
+ respond_to do |format|
+ format.html # show.html.erb
+ end
+ end
+
+ protected
+
+ def build_questions
+ notice = ""
+ num_to_build = params["num_questions"] ? params["num_questions"].to_i : @archive_faq.questions.count
+ if num_to_build < @archive_faq.questions.count
+ notice += ts("There are currently %{num} questions. You can only submit a number equal to or greater than %{num}. ", num: @archive_faq.questions.count)
+ num_to_build = @archive_faq.questions.count
+ elsif params["num_questions"]
+ notice += ts("Set up %{num} questions. ", num: num_to_build)
+ end
+ num_existing = @archive_faq.questions.count
+ num_existing.upto(num_to_build-1) do
+ @archive_faq.questions.build
+ end
+ unless notice.blank?
+ flash[:notice] = notice
+ end
+ end
+
+ public
+
+ # GET /archive_faqs/new
+ def new
+ @archive_faq = authorize ArchiveFaq.new
+ 1.times { @archive_faq.questions.build(attributes: { question: "This is a temporary question", content: "This is temporary content", anchor: "ThisIsATemporaryAnchor"})}
+ respond_to do |format|
+ format.html # new.html.erb
+ end
+ end
+
+ # GET /archive_faqs/1/edit
+ def edit
+ @archive_faq = authorize ArchiveFaq.find_by(slug: params[:id])
+ authorize :archive_faq, :full_access? if default_locale?
+ build_questions
+ end
+
+ # GET /archive_faqs/manage
+ def manage
+ @archive_faqs = authorize ArchiveFaq.order("position ASC")
+ end
+
+ # POST /archive_faqs
+ def create
+ @archive_faq = authorize ArchiveFaq.new(archive_faq_params)
+ if @archive_faq.save
+ flash[:notice] = t(".success")
+ redirect_to(@archive_faq)
+ else
+ render action: "new"
+ end
+ end
+
+ # PUT /archive_faqs/1
+ def update
+ @archive_faq = authorize ArchiveFaq.find_by(slug: params[:id])
+ authorize :archive_faq, :full_access? if default_locale?
+
+ if @archive_faq.update(archive_faq_params)
+ flash[:notice] = t(".success")
+ redirect_to(@archive_faq)
+ else
+ render action: "edit"
+ end
+ end
+
+ # reorder FAQs
+ def update_positions
+ authorize :archive_faq
+ if params[:archive_faqs]
+ @archive_faqs = ArchiveFaq.reorder_list(params[:archive_faqs])
+ flash[:notice] = t(".success")
+ elsif params[:archive_faq]
+ params[:archive_faq].each_with_index do |id, position|
+ ArchiveFaq.update(id, position: position + 1)
+ (@archive_faqs ||= []) << ArchiveFaq.find(id)
+ end
+ end
+ respond_to do |format|
+ format.html { redirect_to(archive_faqs_path) }
+ format.js { render nothing: true }
+ end
+ end
+
+ # The ?language_id=somelanguage needs to persist throughout URL changes
+ # Get the value from set_locale to make sure there's no problem with order
+ def default_url_options
+ { language_id: set_locale.to_s }
+ end
+
+ # Set the locale as an instance variable first
+ def set_locale
+ session[:language_id] = params[:language_id] if Locale.exists?(iso: params[:language_id])
+
+ if current_user.present?
+ @i18n_locale = session[:language_id].presence || current_user.preference.locale.iso
+ else
+ @i18n_locale = session[:language_id].presence || I18n.default_locale
+ end
+ end
+
+ def validate_locale
+ return if params[:language_id].blank? || Locale.exists?(iso: params[:language_id])
+
+ flash[:error] = "The specified locale does not exist."
+ redirect_to url_for(request.query_parameters.merge(language_id: I18n.default_locale))
+ end
+
+ def require_language_id
+ return if params[:language_id].present? && Locale.exists?(iso: params[:language_id])
+
+ redirect_to url_for(request.query_parameters.merge(language_id: @i18n_locale.to_s))
+ end
+
+ def default_locale_only
+ return if default_locale?
+
+ flash[:error] = t("archive_faqs.default_locale_only")
+ redirect_to archive_faqs_path
+ end
+
+ # Setting I18n.locale directly is not thread safe
+ def with_locale
+ I18n.with_locale(@i18n_locale) { yield }
+ end
+
+ # GET /archive_faqs/1/confirm_delete
+ def confirm_delete
+ @archive_faq = authorize ArchiveFaq.find_by(slug: params[:id])
+ end
+
+ # DELETE /archive_faqs/1
+ def destroy
+ @archive_faq = authorize ArchiveFaq.find_by(slug: params[:id])
+ @archive_faq.destroy
+ redirect_to(archive_faqs_path)
+ end
+
+ private
+
+ def default_locale?
+ @i18n_locale.to_s == I18n.default_locale.to_s
+ end
+
+ def archive_faq_params
+ params.require(:archive_faq).permit(
+ :title,
+ questions_attributes: [
+ :id, :question, :anchor, :content, :screencast, :_destroy, :is_translated
+ ]
+ )
+ end
+end
diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb
new file mode 100755
index 0000000..620d53d
--- /dev/null
+++ b/app/controllers/autocomplete_controller.rb
@@ -0,0 +1,224 @@
+class AutocompleteController < ApplicationController
+ respond_to :json
+
+ skip_before_action :store_location
+ skip_around_action :set_current_user, except: [:collection_parent_name, :owned_tag_sets, :site_skins]
+ skip_before_action :sanitize_ac_params # can we dare!
+
+ #### DO WE NEED THIS AT ALL? IF IT FIRES WITHOUT A TERM AND 500s BECAUSE USER DID SOMETHING WACKY SO WHAT
+ # # If you have an autocomplete that should fire without a term add it here
+ # before_action :require_term, except: [:tag_in_fandom, :relationship_in_fandom, :character_in_fandom, :nominated_parents]
+ #
+ # def require_term
+ # if params[:term].blank?
+ # flash[:error] = ts("What were you trying to autocomplete?")
+ # redirect_to(request.env["HTTP_REFERER"] || root_path) and return
+ # end
+ # end
+ #
+ #########################################
+ ############# LOOKUP ACTIONS GO HERE
+
+ # PSEUDS
+ def pseud
+ return if params[:term].blank?
+
+ render_output(Pseud.autocomplete_lookup(search_param: params[:term], autocomplete_prefix: "autocomplete_pseud").map { |res| Pseud.fullname_from_autocomplete(res) })
+ end
+
+ ## TAGS
+ private
+ def tag_output(search_param, tag_type)
+ tags = Tag.autocomplete_lookup(search_param: search_param, autocomplete_prefix: "autocomplete_tag_#{tag_type}")
+ render_output tags.map {|r| Tag.name_from_autocomplete(r)}
+ end
+ public
+ # these are all basically duplicates but make our calls to autocomplete more readable
+ def tag; tag_output(params[:term], params[:type] || "all"); end
+ def fandom; tag_output(params[:term], "fandom"); end
+ def character; tag_output(params[:term], "character"); end
+ def relationship; tag_output(params[:term], "relationship"); end
+ def freeform; tag_output(params[:term], "freeform"); end
+
+
+ ## TAGS IN FANDOMS
+ private
+ def tag_in_fandom_output(params)
+ render_output(Tag.autocomplete_fandom_lookup(params).map {|r| Tag.name_from_autocomplete(r)})
+ end
+ public
+ def character_in_fandom; tag_in_fandom_output(params.merge({tag_type: "character"})); end
+ def relationship_in_fandom; tag_in_fandom_output(params.merge({tag_type: "relationship"})); end
+
+
+ ## TAGS IN SETS
+ #
+ # Note that only tagsets in OwnedTagSets are in autocomplete
+ #
+
+ # expects the following params:
+ # :tag_set - tag set ids comma-separated
+ # :tag_type - tag type as a string unless "all" desired
+ # :in_any - set to false if only want tags in ALL specified sets
+ # :term - the search term
+ def tags_in_sets
+ results = TagSet.autocomplete_lookup(params)
+ render_output(results.map {|r| Tag.name_from_autocomplete(r)})
+ end
+
+ # expects the following params:
+ # :fandom - fandom name(s) as an array or a comma-separated string
+ # :tag_set - tag set id(s) as an array or a comma-separated string
+ # :tag_type - tag type as a string unless "all" desired
+ # :include_wrangled - set to false if you only want tags from the set associations and NOT tags wrangled into the fandom
+ # :fallback - set to false to NOT do
+ # :term - the search term
+ def associated_tags
+ if params[:fandom].blank?
+ render_output([ts("Please select a fandom first!")])
+ else
+ results = TagSetAssociation.autocomplete_lookup(params)
+ render_output(results.map {|r| Tag.name_from_autocomplete(r)})
+ end
+ end
+
+ ## NONCANONICAL TAGS
+ def noncanonical_tag
+ search_param = Query.new.escape_reserved_characters(params[:term])
+ raise "Redshirt: Attempted to constantize invalid class initialize noncanonical_tag #{params[:type].classify}" unless Tag::TYPES.include?(params[:type].classify)
+
+ tag_class = params[:type].classify.constantize
+ one_tag = tag_class.find_by(canonical: false, name: params[:term]) if params[:term].present?
+ # If there is an exact match in the database, ensure it is the first thing suggested.
+ match = if one_tag
+ [one_tag.name]
+ else
+ []
+ end
+
+ # As explained in https://stackoverflow.com/a/54080114, the Elasticsearch suggestion suggester does not support
+ # matches in the middle of a series of words. Therefore, we break the autocomplete query into its individual
+ # words – based on whitespace – except for the last word, which could be incomplete, so a prefix match is
+ # appropriate. This emulates the behavior of SQL `LIKE '%text%'.
+
+ word_list = search_param.split
+ last_word = word_list.pop
+ search_list = word_list.map { |w| { term: { name: { value: w, case_insensitive: true } } } } + [{ prefix: { name: { value: last_word, case_insensitive: true } } }]
+ begin
+ # Size is chosen so we get enough search results from each shard.
+ search_results = $elasticsearch.search(
+ index: TagIndexer.index_name,
+ body: { size: "100", query: { bool: { filter: [{ match: { tag_type: params[:type].capitalize } }, { match: { canonical: false } }], must: search_list } } }
+ )
+ render_output((match + search_results["hits"]["hits"].first(10).map { |t| t["_source"]["name"] }).uniq)
+ rescue Elastic::Transport::Transport::Errors::BadRequest
+ render_output(match)
+ end
+ end
+
+ # more-specific autocompletes should be added below here when they can't be avoided
+
+ # look up collections ranked by number of items they contain
+
+ def collection_fullname
+ results = Collection.autocomplete_lookup(search_param: params[:term], autocomplete_prefix: "autocomplete_collection_all").map {|res| Collection.fullname_from_autocomplete(res)}
+ render_output(results)
+ end
+
+ # return collection names
+
+ def open_collection_names
+ # in this case we want different ids from names so we can display the title but only put in the name
+ results = Collection.autocomplete_lookup(search_param: params[:term], autocomplete_prefix: "autocomplete_collection_open").map do |str|
+ {id: (whole_name = Collection.name_from_autocomplete(str)), name: Collection.title_from_autocomplete(str) + " (#{whole_name})" }
+ end
+ respond_with(results)
+ end
+
+ # For creating collections, autocomplete the name of a parent collection owned by the user only
+ def collection_parent_name
+ render_output(current_user.maintained_collections.top_level.with_name_like(params[:term]).pluck(:name).sort)
+ end
+
+ # for looking up existing urls for external works to avoid duplication
+ def external_work
+ render_output(ExternalWork.where(["url LIKE ?", '%' + params[:term] + '%']).limit(10).order(:url).pluck(:url))
+ end
+
+ # the pseuds of the potential matches who could fulfill the requests in the given signup
+ def potential_offers
+ potential_matches(false)
+ end
+
+ # the pseuds of the potential matches who want the offers in the given signup
+ def potential_requests
+ potential_matches(true)
+ end
+
+ # Return matching potential requests or offers
+ def potential_matches(return_requests=true)
+ search_param = params[:term]
+ signup_id = params[:signup_id]
+ signup = ChallengeSignup.find(signup_id)
+ pmatches = return_requests ?
+ signup.offer_potential_matches.sort.reverse.map {|pm| pm.request_signup.pseud.byline} :
+ signup.request_potential_matches.sort.reverse.map {|pm| pm.offer_signup.pseud.byline}
+ pmatches.select! { |pm| pm.match(/#{Regexp.escape(search_param)}/) } if search_param.present?
+ render_output(pmatches)
+ end
+
+ # owned tag sets that are usable by all
+ def owned_tag_sets
+ if params[:term].length > 0
+ search_param = '%' + params[:term] + '%'
+ render_output(OwnedTagSet.limit(10).order(:title).usable.where("owned_tag_sets.title LIKE ?", search_param).collect(&:title))
+ end
+ end
+
+ # skins for parenting
+ def site_skins
+ if params[:term].present?
+ search_param = '%' + params[:term] + '%'
+ query = Skin.site_skins.where("title LIKE ?", search_param).limit(15).sort_by_recent
+ if logged_in?
+ query = query.approved_or_owned_by(current_user)
+ else
+ query = query.approved_skins
+ end
+ render_output(query.pluck(:title))
+ end
+ end
+
+ # admin posts for translations, formatted as Admin Post Title (Post #id)
+ def admin_posts
+ if params[:term].present?
+ search_param = '%' + params[:term] + '%'
+ results = AdminPost.non_translated.where("title LIKE ?", search_param).limit(ArchiveConfig.MAX_RECENT).map do |result|
+ {id: (post_id = result.id),
+ name: result.title + " (Post ##{post_id})" }
+ end
+ respond_with(results)
+ end
+ end
+
+ def admin_post_tags
+ if params[:term].present?
+ search_param = '%' + params[:term].strip + '%'
+ query = AdminPostTag.where("name LIKE ?", search_param).limit(ArchiveConfig.MAX_RECENT)
+ render_output(query.pluck(:name))
+ end
+ end
+
+private
+
+ # Because of the respond_to :json at the top of the controller, this will return a JSON-encoded
+ # response which the autocomplete javascript on the other end should be able to handle :)
+ def render_output(result_strings)
+ if result_strings.first.is_a?(String)
+ respond_with(result_strings.map {|str| {id: str, name: str}})
+ else
+ respond_with(result_strings)
+ end
+ end
+
+end
diff --git a/app/controllers/blocked/users_controller.rb b/app/controllers/blocked/users_controller.rb
new file mode 100755
index 0000000..827e8b1
--- /dev/null
+++ b/app/controllers/blocked/users_controller.rb
@@ -0,0 +1,89 @@
+module Blocked
+ class UsersController < ApplicationController
+ before_action :set_user
+
+ before_action :check_ownership, except: :index
+ before_action :check_ownership_or_admin, only: :index
+ before_action :check_admin_permissions, only: :index
+
+ before_action :build_block, only: [:confirm_block, :create]
+ before_action :set_block, only: [:confirm_unblock, :destroy]
+
+ # GET /users/:user_id/blocked/users
+ def index
+ @blocks = @user.blocks_as_blocker
+ .joins(:blocked)
+ .includes(blocked: [:pseuds, { default_pseud: { icon_attachment: { blob: {
+ variant_records: { image_attachment: :blob },
+ preview_image_attachment: { blob: { variant_records: { image_attachment: :blob } } }
+ } } } }])
+ .order(created_at: :desc).order(id: :desc).page(params[:page])
+
+ @pseuds = @blocks.map { |b| b.blocked.default_pseud }
+ @rec_counts = Pseud.rec_counts_for_pseuds(@pseuds)
+ @work_counts = Pseud.work_counts_for_pseuds(@pseuds)
+
+ @page_subtitle = "Blocked Users"
+ end
+
+ # GET /users/:user_id/blocked/users/confirm_block
+ def confirm_block
+ @hide_dashboard = true
+
+ return if @block.valid?
+
+ # We can't block this user for whatever reason
+ flash[:error] = @block.errors.full_messages.first
+ redirect_to user_blocked_users_path(@user)
+ end
+
+ # GET /users/:user_id/blocked/users/confirm_unblock
+ def confirm_unblock
+ @hide_dashboard = true
+
+ @blocked = @block.blocked
+ end
+
+ # POST /users/:user_id/blocked/users
+ def create
+ if @block.save
+ flash[:notice] = t(".blocked", name: @block.blocked.login)
+ else
+ # We can't block this user for whatever reason
+ flash[:error] = @block.errors.full_messages.first
+ end
+
+ redirect_to user_blocked_users_path(@user)
+ end
+
+ # DELETE /users/:user_id/blocked/users/:id
+ def destroy
+ @block.destroy
+ flash[:notice] = t(".unblocked", name: @block.blocked.login)
+ redirect_to user_blocked_users_path(@user)
+ end
+
+ private
+
+ # Sets the user whose blocks we're viewing/modifying.
+ def set_user
+ @user = User.find_by!(login: params[:user_id])
+ @check_ownership_of = @user
+ end
+
+ # Builds (but doesn't save) a block matching the desired params:
+ def build_block
+ blocked_byline = params.fetch(:blocked_id, "")
+ @block = @user.blocks_as_blocker.build(blocked_byline: blocked_byline)
+ @blocked = @block.blocked
+ end
+
+ def set_block
+ @block = @user.blocks_as_blocker.find(params[:id])
+ end
+
+ def check_admin_permissions
+ authorize Block if logged_in_as_admin?
+ end
+ end
+end
diff --git a/app/controllers/bookmarks_controller.rb b/app/controllers/bookmarks_controller.rb
new file mode 100755
index 0000000..050a1ad
--- /dev/null
+++ b/app/controllers/bookmarks_controller.rb
@@ -0,0 +1,397 @@
+class BookmarksController < ApplicationController
+ before_action :load_collection
+ before_action :load_owner, only: [:index]
+ before_action :load_bookmarkable, only: [:index, :new, :create]
+ before_action :users_only, only: [:new, :create, :edit, :update]
+ before_action :check_user_status, only: [:new, :create, :edit, :update]
+ before_action :check_parent_visible, only: [:index, :new]
+ before_action :load_bookmark, only: [:show, :edit, :update, :destroy, :confirm_delete, :share]
+ before_action :check_visibility, only: [:show, :share]
+ before_action :check_ownership, only: [:edit, :update, :destroy, :confirm_delete, :share]
+
+ before_action :check_pseud_ownership, only: [:create, :update]
+
+ skip_before_action :store_location, only: [:share]
+
+ def check_pseud_ownership
+ if params[:bookmark][:pseud_id]
+ pseud = Pseud.find(bookmark_params[:pseud_id])
+ unless pseud && current_user && current_user.pseuds.include?(pseud)
+ flash[:error] = ts("You can't bookmark with that pseud.")
+ redirect_to root_path and return
+ end
+ end
+ end
+
+ def check_parent_visible
+ check_visibility_for(@bookmarkable)
+ end
+
+ # get the parent
+ def load_bookmarkable
+ if params[:work_id]
+ @bookmarkable = Work.find(params[:work_id])
+ elsif params[:external_work_id]
+ @bookmarkable = ExternalWork.find(params[:external_work_id])
+ elsif params[:series_id]
+ @bookmarkable = Series.find(params[:series_id])
+ end
+ end
+
+ def load_bookmark
+ @bookmark = Bookmark.find(params[:id])
+ @check_ownership_of = @bookmark
+ @check_visibility_of = @bookmark
+ end
+
+ def search
+ @languages = Language.default_order
+ options = params[:bookmark_search].present? ? bookmark_search_params : {}
+ options.merge!(page: params[:page]) if params[:page].present?
+ options[:show_private] = false
+ options[:show_restricted] = logged_in? || logged_in_as_admin?
+ @search = BookmarkSearchForm.new(options)
+ @page_subtitle = ts("Search Bookmarks")
+ if params[:bookmark_search].present? && params[:edit_search].blank?
+ if @search.query.present?
+ @page_subtitle = ts("Bookmarks Matching '%{query}'", query: @search.query)
+ end
+ @bookmarks = @search.search_results.scope(:for_blurb)
+ flash_search_warnings(@bookmarks)
+ set_own_bookmarks
+ render 'search_results'
+ end
+ end
+
+ def index
+ if @bookmarkable
+ @bookmarks = @bookmarkable.bookmarks.not_private.order_by_created_at.paginate(page: params[:page], per_page: ArchiveConfig.ITEMS_PER_PAGE)
+ @bookmarks = @bookmarks.where(hidden_by_admin: false) unless logged_in_as_admin?
+ else
+ base_options = {
+ show_private: (@user.present? && @user == current_user),
+ show_restricted: logged_in? || logged_in_as_admin?,
+ page: params[:page]
+ }
+
+ options = params[:bookmark_search].present? ? bookmark_search_params : {}
+
+ if params[:include_bookmark_search].present?
+ params[:include_bookmark_search].keys.each do |key|
+ options[key] ||= []
+ options[key] << params[:include_bookmark_search][key]
+ options[key].flatten!
+ end
+ end
+
+ if params[:exclude_bookmark_search].present?
+ params[:exclude_bookmark_search].keys.each do |key|
+ # Keep bookmarker tags separate, so we can search for them on bookmarks
+ # and search for the rest on bookmarkables
+ options_key = key == "tag_ids" ? :excluded_bookmark_tag_ids : :excluded_tag_ids
+ options[options_key] ||= []
+ options[options_key] << params[:exclude_bookmark_search][key]
+ options[options_key].flatten!
+ end
+ end
+
+ options.merge!(base_options)
+ @page_subtitle = index_page_title
+
+ if @owner.present?
+ @search = BookmarkSearchForm.new(options.merge(faceted: true, parent: @owner))
+
+ if @user.blank?
+ # When it's not a particular user's bookmarks, we want
+ # to list *bookmarkable* items to avoid duplication
+ @bookmarkable_items = @search.bookmarkable_search_results.scope(:for_blurb)
+ flash_search_warnings(@bookmarkable_items)
+ @facets = @bookmarkable_items.facets
+ else
+ # We're looking at a particular user's bookmarks, so
+ # just retrieve the standard search results and their facets.
+ @bookmarks = @search.search_results.scope(:for_blurb)
+ flash_search_warnings(@bookmarks)
+ @facets = @bookmarks.facets
+ end
+
+ if @search.options[:excluded_tag_ids].present? || @search.options[:excluded_bookmark_tag_ids].present?
+ # Excluded tags do not appear in search results, so we need to generate empty facets
+ # to keep them as checkboxes on the filters.
+ excluded_tag_ids = @search.options[:excluded_tag_ids] || []
+ excluded_bookmark_tag_ids = @search.options[:excluded_bookmark_tag_ids] || []
+
+ # It's possible to determine the tag types by looking at
+ # the original parameters params[:exclude_bookmark_search],
+ # but we need the tag names too, so a database query is unavoidable.
+ tags = Tag.where(id: excluded_tag_ids + excluded_bookmark_tag_ids)
+ tags.each do |tag|
+ if excluded_tag_ids.include?(tag.id.to_s)
+ key = tag.class.to_s.underscore
+ @facets[key] ||= []
+ @facets[key] << QueryFacet.new(tag.id, tag.name, 0)
+ end
+ if excluded_bookmark_tag_ids.include?(tag.id.to_s)
+ key = 'tag'
+ @facets[key] ||= []
+ @facets[key] << QueryFacet.new(tag.id, tag.name, 0)
+ end
+ end
+ end
+ elsif use_caching?
+ @bookmarks = Rails.cache.fetch("bookmarks/index/latest/v2_true", expires_in: ArchiveConfig.SECONDS_UNTIL_BOOKMARK_INDEX_EXPIRE.seconds) do
+ search = BookmarkSearchForm.new(show_private: false, show_restricted: false, sort_column: 'created_at')
+ results = search.search_results.scope(:for_blurb)
+ flash_search_warnings(results)
+ results.to_a
+ end
+ else
+ @bookmarks = Bookmark.latest.for_blurb.to_a
+ end
+ end
+ set_own_bookmarks
+
+ @pagy =
+ if @bookmarks.respond_to?(:total_pages)
+ pagy_query_result(@bookmarks)
+ elsif @bookmarkable_items.respond_to?(:total_pages)
+ pagy_query_result(@bookmarkable_items)
+ end
+ end
+
+ # GET /:locale/bookmark/:id
+ # GET /:locale/users/:user_id/bookmarks/:id
+ # GET /:locale/works/:work_id/bookmark/:id
+ # GET /:locale/external_works/:external_work_id/bookmark/:id
+ def show
+ end
+
+ # GET /bookmarks/new
+ # GET /bookmarks/new.xml
+ def new
+ @bookmark = Bookmark.new
+ respond_to do |format|
+ format.html
+ format.js {
+ @button_name = ts("Create")
+ @action = :create
+ render action: "bookmark_form_dynamic"
+ }
+ end
+ end
+
+ # GET /bookmarks/1/edit
+ def edit
+ @bookmarkable = @bookmark.bookmarkable
+ respond_to do |format|
+ format.html
+ format.js {
+ @button_name = ts("Update")
+ @action = :update
+ render action: "bookmark_form_dynamic"
+ }
+ end
+ end
+
+ # POST /bookmarks
+ # POST /bookmarks.xml
+ def create
+ @bookmarkable ||= ExternalWork.new(external_work_params)
+ @bookmark = Bookmark.new(bookmark_params.merge(bookmarkable: @bookmarkable))
+ if @bookmark.errors.empty? && @bookmark.save
+ flash[:notice] = ts("Bookmark was successfully created. It should appear in bookmark listings within the next few minutes.")
+ redirect_to(bookmark_path(@bookmark))
+ else
+ render :new
+ end
+ end
+
+ # PUT /bookmarks/1
+ # PUT /bookmarks/1.xml
+ def update
+ new_collections = []
+ unapproved_collections = []
+ errors = []
+ bookmark_params[:collection_names]&.split(",")&.map(&:strip)&.uniq&.each do |collection_name|
+ collection = Collection.find_by(name: collection_name)
+ if collection.nil?
+ errors << ts("%{name} does not exist.", name: collection_name)
+ else
+ if @bookmark.collections.include?(collection)
+ next
+ elsif collection.closed? && !collection.user_is_maintainer?(User.current_user)
+ errors << ts("%{title} is closed to new submissions.", title: collection.title)
+ elsif @bookmark.add_to_collection(collection) && @bookmark.save
+ if @bookmark.approved_collections.include?(collection)
+ new_collections << collection
+ else
+ unapproved_collections << collection
+ end
+ else
+ errors << ts("Something went wrong trying to add collection %{title}, sorry!", title: collection.title)
+ end
+ end
+ end
+
+ # messages to the user
+ unless errors.empty?
+ flash[:error] = ts("We couldn't add your submission to the following collections: ") + errors.join(" ")
+ end
+
+ unless new_collections.empty?
+ flash[:notice] = ts("Added to collection(s): %{collections}.",
+ collections: new_collections.collect(&:title).join(", "))
+ end
+ unless unapproved_collections.empty?
+ flash[:notice] = flash[:notice] ? flash[:notice] + " " : ""
+ flash[:notice] += if unapproved_collections.size > 1
+ ts("You have submitted your bookmark to moderated collections (%{all_collections}). It will not become a part of those collections until it has been approved by a moderator.", all_collections: unapproved_collections.map(&:title).join(", "))
+ else
+ ts("You have submitted your bookmark to the moderated collection '%{collection}'. It will not become a part of the collection until it has been approved by a moderator.", collection: unapproved_collections.first.title)
+ end
+ end
+
+ flash[:notice] = (flash[:notice]).html_safe unless flash[:notice].blank?
+ flash[:error] = (flash[:error]).html_safe unless flash[:error].blank?
+
+ if @bookmark.update(bookmark_params) && errors.empty?
+ flash[:notice] = flash[:notice] ? " " + flash[:notice] : ""
+ flash[:notice] = ts("Bookmark was successfully updated.").html_safe + flash[:notice]
+ flash[:notice] = flash[:notice].html_safe
+ redirect_to(@bookmark)
+ else
+ @bookmarkable = @bookmark.bookmarkable
+ render :edit and return
+ end
+ end
+
+ # GET /bookmarks/1/share
+ def share
+ if request.xhr?
+ if @bookmark.bookmarkable.is_a?(Work) && @bookmark.bookmarkable.unrevealed?
+ render template: "errors/404", status: :not_found
+ else
+ render layout: false
+ end
+ else
+ # Avoid getting an unstyled page if JavaScript is disabled
+ flash[:error] = ts("Sorry, you need to have JavaScript enabled for this.")
+ redirect_back(fallback_location: root_path)
+ end
+ end
+
+ def confirm_delete
+ end
+
+ # DELETE /bookmarks/1
+ # DELETE /bookmarks/1.xml
+ def destroy
+ @bookmark.destroy
+ flash[:notice] = ts("Bookmark was successfully deleted.")
+ redirect_to user_bookmarks_path(current_user)
+ end
+
+ protected
+
+ def load_owner
+ if params[:user_id].present?
+ @user = User.find_by(login: params[:user_id])
+ unless @user
+ raise ActiveRecord::RecordNotFound, "Couldn't find user named '#{params[:user_id]}'"
+ end
+ if params[:pseud_id].present?
+ @pseud = @user.pseuds.find_by(name: params[:pseud_id])
+ unless @pseud
+ raise ActiveRecord::RecordNotFound, "Couldn't find pseud named '#{params[:pseud_id]}'"
+ end
+ end
+ end
+ if params[:tag_id]
+ @tag = Tag.find_by_name(params[:tag_id])
+ unless @tag
+ raise ActiveRecord::RecordNotFound, "Couldn't find tag named '#{params[:tag_id]}'"
+ end
+ unless @tag.canonical?
+ if @tag.merger.present?
+ redirect_to tag_bookmarks_path(@tag.merger) and return
+ else
+ redirect_to tag_path(@tag) and return
+ end
+ end
+ end
+ @owner = @bookmarkable || @pseud || @user || @collection || @tag
+ end
+
+ def index_page_title
+ if @owner.present?
+ owner_name = case @owner.class.to_s
+ when 'Pseud'
+ @owner.name
+ when 'User'
+ @owner.login
+ when 'Collection'
+ @owner.title
+ else
+ @owner.try(:name)
+ end
+ "#{owner_name} - Bookmarks".html_safe
+ else
+ "Latest Bookmarks"
+ end
+ end
+
+ def set_own_bookmarks
+ return unless @bookmarks
+ @own_bookmarks = []
+ if current_user.is_a?(User)
+ pseud_ids = current_user.pseuds.pluck(:id)
+ @own_bookmarks = @bookmarks.select do |b|
+ pseud_ids.include?(b.pseud_id)
+ end
+ end
+ end
+
+ private
+
+ def bookmark_params
+ params.require(:bookmark).permit(
+ :pseud_id, :bookmarker_notes, :tag_string, :collection_names, :private, :rec
+ )
+ end
+
+ def external_work_params
+ params.require(:external_work).permit(
+ :url, :author, :title, :fandom_string, :rating_string, :relationship_string,
+ :character_string, :summary, category_strings: []
+ )
+ end
+
+ def bookmark_search_params
+ params.require(:bookmark_search).permit(
+ :bookmark_query,
+ :bookmarkable_query,
+ :bookmarker,
+ :bookmark_notes,
+ :rec,
+ :with_notes,
+ :bookmarkable_type,
+ :language_id,
+ :date,
+ :bookmarkable_date,
+ :sort_column,
+ :other_tag_names,
+ :excluded_tag_names,
+ :other_bookmark_tag_names,
+ :excluded_bookmark_tag_names,
+ rating_ids: [],
+ warning_ids: [], # backwards compatibility
+ archive_warning_ids: [],
+ category_ids: [],
+ fandom_ids: [],
+ character_ids: [],
+ relationship_ids: [],
+ freeform_ids: [],
+ tag_ids: [],
+ )
+ end
+end
diff --git a/app/controllers/challenge/gift_exchange_controller.rb b/app/controllers/challenge/gift_exchange_controller.rb
new file mode 100755
index 0000000..dccf8e0
--- /dev/null
+++ b/app/controllers/challenge/gift_exchange_controller.rb
@@ -0,0 +1,109 @@
+class Challenge::GiftExchangeController < ChallengesController
+
+ before_action :users_only
+ before_action :load_collection
+ before_action :load_challenge, except: [:new, :create]
+ before_action :collection_owners_only, only: [:new, :create, :edit, :update, :destroy]
+
+ # ACTIONS
+
+ def show
+ end
+
+ def new
+ if (@collection.challenge)
+ flash[:notice] = ts("There is already a challenge set up for this collection.")
+ # TODO this will break if the challenge isn't a gift exchange
+ redirect_to edit_collection_gift_exchange_path(@collection)
+ else
+ @challenge = GiftExchange.new
+ end
+ end
+
+ def edit
+ end
+
+ def create
+ @challenge = GiftExchange.new(gift_exchange_params)
+ if @challenge.save
+ @collection.challenge = @challenge
+ @collection.save
+ flash[:notice] = ts('Challenge was successfully created.')
+ redirect_to collection_profile_path(@collection)
+ else
+ render action: :new
+ end
+ end
+
+ def update
+ if @challenge.update(gift_exchange_params)
+ flash[:notice] = ts('Challenge was successfully updated.')
+
+ # expire the cache on the signup form
+ ActionController::Base.new.expire_fragment('challenge_signups/new')
+
+ # see if we initialized the tag set
+ redirect_to collection_profile_path(@collection)
+ else
+ render action: :edit
+ end
+ end
+
+ def destroy
+ @challenge.destroy
+ flash[:notice] = 'Challenge settings were deleted.'
+ redirect_to @collection
+ end
+
+ private
+
+ def gift_exchange_params
+ params.require(:gift_exchange).permit(
+ :signup_open, :time_zone, :signups_open_at_string, :signups_close_at_string,
+ :assignments_due_at_string, :requests_summary_visible, :requests_num_required,
+ :requests_num_allowed, :offers_num_required, :offers_num_allowed,
+ :signup_instructions_general, :signup_instructions_requests,
+ :signup_instructions_offers, :request_url_label, :offer_url_label,
+ :offer_description_label, :request_description_label, :works_reveal_at_string,
+ :authors_reveal_at_string,
+ request_restriction_attributes: [
+ :id, :optional_tags_allowed, :title_required, :title_allowed, :description_required,
+ :description_allowed, :url_required, :url_allowed, :fandom_num_required,
+ :fandom_num_allowed, :allow_any_fandom, :require_unique_fandom,
+ :character_num_required, :character_num_allowed, :allow_any_character,
+ :require_unique_character, :relationship_num_required, :relationship_num_allowed,
+ :allow_any_relationship, :require_unique_relationship, :rating_num_required,
+ :rating_num_allowed, :allow_any_rating, :require_unique_rating,
+ :category_num_required, :category_num_allowed, :allow_any_category,
+ :require_unique_category, :freeform_num_required, :freeform_num_allowed,
+ :allow_any_freeform, :require_unique_freeform, :archive_warning_num_required,
+ :archive_warning_num_allowed, :allow_any_archive_warning, :require_unique_archive_warning
+ ],
+ offer_restriction_attributes: [
+ :id, :optional_tags_allowed, :title_required, :title_allowed,
+ :description_required, :description_allowed, :url_required, :url_allowed,
+ :fandom_num_required, :fandom_num_allowed, :allow_any_fandom,
+ :require_unique_fandom, :character_num_required, :character_num_allowed,
+ :allow_any_character, :require_unique_character, :relationship_num_required,
+ :relationship_num_allowed, :allow_any_relationship, :require_unique_relationship,
+ :rating_num_required, :rating_num_allowed, :rating_num_required, :allow_any_rating, :require_unique_rating,
+ :category_num_required, :category_num_allowed, :allow_any_category,
+ :require_unique_category, :freeform_num_required, :freeform_num_allowed,
+ :allow_any_freeform, :require_unique_freeform, :archive_warning_num_required,
+ :archive_warning_num_allowed, :allow_any_archive_warning, :require_unique_archive_warning,
+ :tag_sets_to_add, :character_restrict_to_fandom,
+ :character_restrict_to_tag_set, :relationship_restrict_to_fandom,
+ :relationship_restrict_to_tag_set,
+ tag_sets_to_remove: []
+ ],
+ potential_match_settings_attributes: [
+ :id, :num_required_prompts, :num_required_fandoms, :num_required_characters,
+ :num_required_relationships, :num_required_freeforms, :num_required_categories,
+ :num_required_ratings, :num_required_archive_warnings, :include_optional_fandoms,
+ :include_optional_characters, :include_optional_relationships,
+ :include_optional_freeforms, :include_optional_categories, :include_optional_ratings,
+ :include_optional_archive_warnings
+ ]
+ )
+ end
+end
diff --git a/app/controllers/challenge/prompt_meme_controller.rb b/app/controllers/challenge/prompt_meme_controller.rb
new file mode 100755
index 0000000..4415cd4
--- /dev/null
+++ b/app/controllers/challenge/prompt_meme_controller.rb
@@ -0,0 +1,80 @@
+class Challenge::PromptMemeController < ChallengesController
+
+ before_action :users_only
+ before_action :load_collection
+ before_action :load_challenge, except: [:new, :create]
+ before_action :collection_owners_only, only: [:new, :create, :edit, :update, :destroy]
+
+ # ACTIONS
+
+ # is actually a blank page - should it be redirected to collection profile?
+ def show
+ end
+
+ # The new form for prompt memes is actually the challenge settings page because challenges are always created in the context of a collection.
+ def new
+ if (@collection.challenge)
+ flash[:notice] = ts("There is already a challenge set up for this collection.")
+ redirect_to edit_collection_prompt_meme_path(@collection)
+ else
+ @challenge = PromptMeme.new
+ end
+ end
+
+ def edit
+ end
+
+ def create
+ @challenge = PromptMeme.new(prompt_meme_params)
+ if @challenge.save
+ @collection.challenge = @challenge
+ @collection.save
+ flash[:notice] = ts('Challenge was successfully created.')
+ redirect_to collection_profile_path(@collection)
+ else
+ render action: :new
+ end
+ end
+
+ def update
+ if @challenge.update(prompt_meme_params)
+ flash[:notice] = 'Challenge was successfully updated.'
+ # expire the cache on the signup form
+ ActionController::Base.new.expire_fragment('challenge_signups/new')
+ redirect_to @collection
+ else
+ render action: :edit
+ end
+ end
+
+ def destroy
+ @challenge.destroy
+ flash[:notice] = 'Challenge settings were deleted.'
+ redirect_to @collection
+ end
+
+ private
+
+ def prompt_meme_params
+ params.require(:prompt_meme).permit(
+ :signup_open, :time_zone, :signups_open_at_string, :signups_close_at_string,
+ :assignments_due_at_string, :anonymous, :requests_num_required, :requests_num_allowed,
+ :signup_instructions_general, :signup_instructions_requests, :request_url_label,
+ :request_description_label, :works_reveal_at_string, :authors_reveal_at_string,
+ request_restriction_attributes: [ :id, :optional_tags_allowed, :title_required,
+ :title_allowed, :description_required, :description_allowed, :url_required,
+ :url_allowed, :fandom_num_required, :fandom_num_allowed, :require_unique_fandom,
+ :character_num_required, :character_num_allowed, :category_num_required,
+ :category_num_allowed, :require_unique_category, :require_unique_character,
+ :relationship_num_required, :relationship_num_allowed, :require_unique_relationship,
+ :rating_num_required, :rating_num_allowed, :require_unique_rating,
+ :freeform_num_required, :freeform_num_allowed, :require_unique_freeform,
+ :archive_warning_num_required, :archive_warning_num_allowed, :require_unique_archive_warning,
+ :tag_sets_to_remove, :tag_sets_to_add, :character_restrict_to_fandom,
+ :character_restrict_to_tag_set, :relationship_restrict_to_fandom,
+ :relationship_restrict_to_tag_set, tag_sets_to_remove: []
+ ]
+ )
+ end
+
+end
diff --git a/app/controllers/challenge_assignments_controller.rb b/app/controllers/challenge_assignments_controller.rb
new file mode 100755
index 0000000..39e6b84
--- /dev/null
+++ b/app/controllers/challenge_assignments_controller.rb
@@ -0,0 +1,243 @@
+class ChallengeAssignmentsController < ApplicationController
+ before_action :users_only
+
+ before_action :load_collection, except: [:index]
+ before_action :load_challenge, except: [:index]
+ before_action :collection_owners_only, except: [:index, :show, :default]
+
+ before_action :load_assignment_from_id, only: [:show, :default]
+ before_action :owner_only, only: [:default]
+
+ before_action :check_signup_closed, except: [:index]
+ before_action :check_assignments_not_sent, only: [:generate, :set, :send_out]
+ before_action :check_assignments_sent, only: [:default, :purge, :confirm_purge]
+
+
+ # PERMISSIONS AND STATUS CHECKING
+
+ def load_challenge
+ @challenge = @collection.challenge if @collection
+ no_challenge unless @challenge
+ end
+
+ def no_challenge
+ flash[:error] = t('challenge_assignments.no_challenge', default: "What challenge did you want to work with?")
+ redirect_to collection_path(@collection) rescue redirect_to '/'
+ false
+ end
+
+ def load_assignment_from_id
+ @challenge_assignment = @collection.assignments.find(params[:id])
+ end
+
+ def owner_only
+ return if current_user == @challenge_assignment.offering_pseud.user
+
+ flash[:error] = t("challenge_assignments.validation.not_owner")
+ redirect_to root_path
+ end
+
+ def check_signup_closed
+ signup_open and return unless !@challenge.signup_open
+ end
+
+ def signup_open
+ flash[:error] = t('challenge_assignments.signup_open', default: "Signup is currently open, you cannot make assignments now.")
+ redirect_to @collection rescue redirect_to '/'
+ false
+ end
+
+ def check_assignments_not_sent
+ assignments_sent and return unless @challenge.assignments_sent_at.nil?
+ end
+
+ def assignments_sent
+ flash[:error] = t('challenge_assignments.assignments_sent', default: "Assignments have already been sent! If necessary, you can purge them.")
+ redirect_to collection_assignments_path(@collection) rescue redirect_to '/'
+ false
+ end
+
+ def check_assignments_sent
+ assignments_not_sent and return unless @challenge.assignments_sent_at
+ end
+
+ def assignments_not_sent
+ flash[:error] = t('challenge_assignments.assignments_not_sent', default: "Assignments have not been sent! You might want matching instead.")
+ redirect_to collection_path(@collection) rescue redirect_to '/'
+ false
+ end
+
+
+
+ # ACTIONS
+
+ def index
+ if params[:user_id] && (@user = User.find_by(login: params[:user_id]))
+ if current_user == @user
+ if params[:collection_id] && (@collection = Collection.find_by(name: params[:collection_id]))
+ @challenge_assignments = @user.offer_assignments.in_collection(@collection).undefaulted + @user.pinch_hit_assignments.in_collection(@collection).undefaulted
+ else
+ @challenge_assignments = @user.offer_assignments.undefaulted + @user.pinch_hit_assignments.undefaulted
+ end
+ else
+ flash[:error] = ts("You aren't allowed to see that user's assignments.")
+ redirect_to '/' and return
+ end
+ else
+ # do error-checking for the collection case
+ return unless load_collection
+ @challenge = @collection.challenge if @collection
+ signup_open and return unless !@challenge.signup_open
+ access_denied and return unless @challenge.user_allowed_to_see_assignments?(current_user)
+
+ # we temporarily are ordering by requesting pseud to avoid left join
+ @assignments = case
+ when params[:pinch_hit]
+ # order by pinch hitter name
+ ChallengeAssignment.unfulfilled_in_collection(@collection).undefaulted.with_pinch_hitter.joins("INNER JOIN pseuds ON (challenge_assignments.pinch_hitter_id = pseuds.id)").order("pseuds.name")
+ when params[:fulfilled]
+ @collection.assignments.fulfilled.order_by_requesting_pseud
+ when params[:unfulfilled]
+ ChallengeAssignment.unfulfilled_in_collection(@collection).undefaulted.order_by_requesting_pseud
+ else
+ @collection.assignments.defaulted.uncovered.order_by_requesting_pseud
+ end
+ @assignments = @assignments.page(params[:page])
+ end
+ end
+
+ def show
+ unless @challenge.user_allowed_to_see_assignments?(current_user) || @challenge_assignment.offering_pseud.user == current_user
+ flash[:error] = ts("You aren't allowed to see that assignment!")
+ redirect_to "/" and return
+ end
+ if @challenge_assignment.defaulted?
+ flash[:notice] = ts("This assignment has been defaulted-on.")
+ end
+ end
+
+ def generate
+ # regenerate assignments using the current potential matches
+ ChallengeAssignment.generate(@collection)
+ flash[:notice] = ts("Beginning regeneration of assignments. This may take some time, especially if your challenge is large.")
+ redirect_to collection_potential_matches_path(@collection)
+ end
+
+ def send_out
+ no_giver_count = @collection.assignments.with_request.with_no_offer.count
+ no_recip_count = @collection.assignments.with_offer.with_no_request.count
+ if (no_giver_count + no_recip_count) > 0
+ flash[:error] = ts("Some participants still aren't assigned. Please either delete them or match them to a placeholder before sending out assignments.")
+ redirect_to collection_potential_matches_path(@collection)
+ else
+ ChallengeAssignment.send_out(@collection)
+ flash[:notice] = "Assignments are now being sent out."
+ redirect_to collection_assignments_path(@collection)
+ end
+ end
+
+ def set
+ # update all the assignments
+ all_assignment_params = challenge_assignment_params
+
+ @assignments = []
+
+ @collection.assignments.where(id: all_assignment_params.keys).each do |assignment|
+ assignment_params = all_assignment_params[assignment.id.to_s]
+ @assignments << assignment unless assignment.update(assignment_params)
+ end
+
+ ChallengeAssignment.update_placeholder_assignments!(@collection)
+ if @assignments.empty?
+ flash[:notice] = "Assignments updated"
+ redirect_to collection_potential_matches_path(@collection)
+ else
+ flash[:error] = ts("These assignments could not be saved because the two participants do not match. Did you mean to write in a giver?")
+ render template: "potential_matches/index"
+ end
+ end
+
+ def confirm_purge
+ end
+
+ def purge
+ ChallengeAssignment.clear!(@collection)
+ @challenge.assignments_sent_at = nil
+ @challenge.save
+ flash[:notice] = ts("Assignments purged!")
+ redirect_to collection_path(@collection)
+ end
+
+ def update_multiple
+ @errors = []
+ params.each_pair do |key, val|
+ action, id = key.split(/_/)
+ next unless %w(approve default undefault cover).include?(action)
+ assignment = @collection.assignments.find_by(id: id)
+ unless assignment
+ @errors << ts("Couldn't find assignment with id #{id}!")
+ next
+ end
+ case action
+ when "default"
+ # default_assignment_id = y/n
+ assignment.default || (@errors << ts("We couldn't default the assignment for %{offer}", offer: assignment.offer_byline))
+ when "undefault"
+ # undefault_[assignment_id] = y/n - if set, undefault
+ assignment.defaulted_at = nil
+ assignment.save || (@errors << ts("We couldn't undefault the assignment covering %{request}.", request: assignment.request_byline))
+ when "approve"
+ assignment.get_collection_item.approve_by_collection if assignment.get_collection_item
+ when "cover"
+ # cover_[assignment_id] = pinch hitter pseud
+ next if val.blank? || assignment.pinch_hitter.try(:byline) == val
+ pseud = Pseud.parse_byline(val)
+ if pseud.nil?
+ @errors << ts("We couldn't find the user %{val} to assign that to.", val: val)
+ else
+ assignment.cover(pseud) || (@errors << ts("We couldn't assign %{val} to cover %{request}.", val: val, request: assignment.request_byline))
+ end
+ end
+ end
+ if @errors.empty?
+ flash[:notice] = "Assignment updates complete!"
+ redirect_to collection_assignments_path(@collection)
+ else
+ flash[:error] = @errors
+ redirect_to collection_assignments_path(@collection)
+ end
+ end
+
+ def default_all
+ # mark all unfulfilled assignments as defaulted
+ unfulfilled_assignments = ChallengeAssignment.unfulfilled_in_collection(@collection).readonly(false)
+ unfulfilled_assignments.update_all defaulted_at: Time.now
+ flash[:notice] = "All unfulfilled assignments marked as defaulting."
+ redirect_to collection_assignments_path(@collection)
+ end
+
+ def default
+ @challenge_assignment.defaulted_at = Time.now
+ @challenge_assignment.save
+
+ assignments_page_url = collection_assignments_url(@challenge_assignment.collection)
+
+ @challenge_assignment.collection.notify_maintainers_challenge_default(@challenge_assignment, assignments_page_url)
+
+ flash[:notice] = "We have notified the collection maintainers that you had to default on your assignment."
+ redirect_to user_assignments_path(current_user)
+ end
+
+ private
+
+ def challenge_assignment_params
+ params.slice(:challenge_assignments).permit(
+ challenge_assignments: [
+ :request_signup_pseud,
+ :offer_signup_pseud,
+ :pinch_hitter_byline
+ ]
+ ).require(:challenge_assignments)
+ end
+
+end
diff --git a/app/controllers/challenge_claims_controller.rb b/app/controllers/challenge_claims_controller.rb
new file mode 100755
index 0000000..a29fd2e
--- /dev/null
+++ b/app/controllers/challenge_claims_controller.rb
@@ -0,0 +1,148 @@
+class ChallengeClaimsController < ApplicationController
+
+ before_action :users_only
+ before_action :load_collection, except: [:index]
+ before_action :collection_owners_only, except: [:index, :show, :create, :destroy]
+ before_action :load_claim_from_id, only: [:show, :destroy]
+
+ before_action :load_challenge, except: [:index]
+
+ before_action :allowed_to_destroy, only: [:destroy]
+
+
+ # PERMISSIONS AND STATUS CHECKING
+
+ def load_challenge
+ if @collection
+ @challenge = @collection.challenge
+ elsif @challenge_claim
+ @challenge = @challenge_claim.collection.challenge
+ end
+ no_challenge and return unless @challenge
+ end
+
+ def no_challenge
+ flash[:error] = ts("What challenge did you want to work with?")
+ redirect_to collection_path(@collection) rescue redirect_to '/'
+ false
+ end
+
+ def load_claim_from_id
+ @challenge_claim = ChallengeClaim.find(params[:id])
+ no_claim and return unless @challenge_claim
+ end
+
+ def no_claim
+ flash[:error] = ts("What claim did you want to work on?")
+ if @collection
+ redirect_to collection_path(@collection) rescue redirect_to '/'
+ else
+ redirect_to user_path(@user) rescue redirect_to '/'
+ end
+ false
+ end
+
+ def load_user
+ @user = User.find_by(login: params[:user_id]) if params[:user_id]
+ no_user and return unless @user
+ end
+
+ def no_user
+ flash[:error] = ts("What user were you trying to work with?")
+ redirect_to "/" and return
+ false
+ end
+
+ def owner_only
+ unless @user == @challenge_claim.claiming_user
+ flash[:error] = ts("You aren't the claimer of that prompt.")
+ redirect_to "/" and return false
+ end
+ end
+
+ def allowed_to_destroy
+ @challenge_claim.user_allowed_to_destroy?(current_user) || not_allowed(@collection)
+ end
+
+
+ # ACTIONS
+
+ def index
+ if !(@collection = Collection.find_by(name: params[:collection_id])).nil? && @collection.closed? && !@collection.user_is_maintainer?(current_user)
+ flash[:notice] = ts("This challenge is currently closed to new posts.")
+ end
+ if params[:collection_id]
+ return unless load_collection
+
+ @challenge = @collection.challenge
+ not_allowed(@collection) unless user_scoped? || @challenge.user_allowed_to_see_assignments?(current_user)
+
+ @claims = ChallengeClaim.unposted_in_collection(@collection)
+ @claims = @claims.where(claiming_user_id: current_user.id) if user_scoped?
+
+ # sorting
+ set_sort_order
+
+ if params[:sort] == "claimer"
+ @claims = @claims.order_by_offering_pseud(@sort_direction)
+ else
+ @claims = @claims.order(@sort_order)
+ end
+ elsif params[:user_id] && (@user = User.find_by(login: params[:user_id]))
+ if current_user == @user
+ @claims = @user.request_claims.order_by_date.unposted
+ if params[:posted]
+ @claims = @user.request_claims.order_by_date.posted
+ end
+ if params[:collection_id] && (@collection = Collection.find_by(name: params[:collection_id]))
+ @claims = @claims.in_collection(@collection)
+ end
+ else
+ flash[:error] = ts("You aren't allowed to see that user's claims.")
+ redirect_to '/' and return
+ end
+ end
+ @claims = @claims.paginate page: params[:page], per_page: ArchiveConfig.ITEMS_PER_PAGE
+ end
+
+ def show
+ # this is here just as a failsafe, this path should not be used
+ redirect_to collection_prompt_path(@collection, @challenge_claim.request_prompt)
+ end
+
+ def create
+ # create a new claim
+ prompt = @collection.prompts.find(params[:prompt_id])
+ claim = prompt.request_claims.build(claiming_user: current_user)
+ if claim.save
+ flash[:notice] = "New claim made."
+ else
+ flash[:error] = "We couldn't save the new claim."
+ end
+ redirect_to collection_claims_path(@collection, for_user: true)
+ end
+
+ def destroy
+ redirect_path = collection_claims_path(@collection)
+ flash[:notice] = ts("The claim was deleted.")
+
+ if @challenge_claim.claiming_user == current_user
+ redirect_path = collection_claims_path(@collection, for_user: true)
+ flash[:notice] = ts("Your claim was deleted.")
+ end
+
+ begin
+ @challenge_claim.destroy
+ rescue
+ flash.delete(:notice)
+ flash[:error] = ts("We couldn't delete that right now, sorry! Please try again later.")
+ end
+ redirect_to redirect_path
+ end
+
+ private
+
+ def user_scoped?
+ params[:for_user].to_s.casecmp?("true")
+ end
+end
diff --git a/app/controllers/challenge_requests_controller.rb b/app/controllers/challenge_requests_controller.rb
new file mode 100755
index 0000000..cb0072b
--- /dev/null
+++ b/app/controllers/challenge_requests_controller.rb
@@ -0,0 +1,41 @@
+class ChallengeRequestsController < ApplicationController
+
+ before_action :load_collection
+ before_action :check_visibility
+
+ def check_visibility
+ unless @collection
+ flash.now[:notice] = ts("Collection could not be found")
+ redirect_to '/' and return
+ end
+ unless @collection.challenge_type == "PromptMeme" || (@collection.challenge_type == "GiftExchange" && @collection.challenge.user_allowed_to_see_requests_summary?(current_user))
+ flash.now[:notice] = ts("You are not allowed to view the requests summary!")
+ redirect_to collection_path(@collection) and return
+ end
+ end
+
+ def index
+ @show_request_fandom_tags = @collection.challenge.request_restriction.allowed("fandom").positive?
+
+ # sorting
+ set_sort_order
+ direction = (@sort_direction.casecmp("ASC").zero? ? "ASC" : "DESC")
+
+ # actual content, do the efficient method unless we need the full query
+
+ if @sort_column == "fandom"
+ query = "SELECT prompts.*, GROUP_CONCAT(tags.name) AS tagnames FROM prompts INNER JOIN set_taggings ON prompts.tag_set_id = set_taggings.tag_set_id
+ INNER JOIN tags ON tags.id = set_taggings.tag_id
+ WHERE prompts.type = 'Request' AND tags.type = 'Fandom' AND prompts.collection_id = " + @collection.id.to_s + " GROUP BY prompts.id ORDER BY tagnames " + @sort_direction
+ @requests = Prompt.paginate_by_sql(query, page: params[:page], per_page: ArchiveConfig.ITEMS_PER_PAGE)
+ elsif @sort_column == "prompter" && !@collection.prompts.where(anonymous: true).exists?
+ @requests = @collection.prompts.where("type = 'Request'").
+ joins(challenge_signup: :pseud).
+ order("pseuds.name #{direction}").
+ paginate(page: params[:page])
+ else
+ @requests = @collection.prompts.where("type = 'Request'").order(@sort_order).paginate(page: params[:page], per_page: ArchiveConfig.ITEMS_PER_PAGE)
+ end
+ end
+
+end
diff --git a/app/controllers/challenge_signups_controller.rb b/app/controllers/challenge_signups_controller.rb
new file mode 100755
index 0000000..d81a23d
--- /dev/null
+++ b/app/controllers/challenge_signups_controller.rb
@@ -0,0 +1,389 @@
+# For exporting to Excel CSV format
+require 'csv'
+
+class ChallengeSignupsController < ApplicationController
+ include ExportsHelper
+
+ before_action :users_only, except: [:summary]
+ before_action :load_collection, except: [:index]
+ before_action :load_challenge, except: [:index]
+ before_action :load_signup_from_id, only: [:show, :edit, :update, :destroy, :confirm_delete]
+ before_action :allowed_to_destroy, only: [:destroy, :confirm_delete]
+ before_action :signup_owner_only, only: [:edit, :update]
+ before_action :maintainer_or_signup_owner_only, only: [:show]
+ before_action :check_signup_open, only: [:new, :create, :edit, :update]
+ before_action :check_pseud_ownership, only: [:create, :update]
+ before_action :check_signup_in_collection, only: [:show, :edit, :update, :destroy, :confirm_delete]
+
+ def load_challenge
+ @challenge = @collection.challenge
+ no_challenge and return unless @challenge
+ end
+
+ def no_challenge
+ flash[:error] = ts("What challenge did you want to sign up for?")
+ redirect_to collection_path(@collection) rescue redirect_to '/'
+ false
+ end
+
+ def check_signup_open
+ signup_closed and return unless (@challenge.signup_open || @collection.user_is_maintainer?(current_user))
+ end
+
+ def signup_closed
+ flash[:error] = ts("Sign-up is currently closed: please contact a moderator for help.")
+ redirect_to @collection rescue redirect_to '/'
+ false
+ end
+
+ def signup_closed_owner?
+ @collection.challenge_type == "GiftExchange" && !@challenge.signup_open && @collection.user_is_owner?(current_user)
+ end
+
+ def signup_owner_only
+ not_signup_owner and return unless @challenge_signup.pseud.user == current_user || signup_closed_owner?
+ end
+
+ def maintainer_or_signup_owner_only
+ not_allowed(@collection) and return unless (@challenge_signup.pseud.user == current_user || @collection.user_is_maintainer?(current_user))
+ end
+
+ def not_signup_owner
+ flash[:error] = ts("You can't edit someone else's sign-up!")
+ redirect_to @collection
+ false
+ end
+
+ def allowed_to_destroy
+ @challenge_signup.user_allowed_to_destroy?(current_user) || not_allowed(@collection)
+ end
+
+ def load_signup_from_id
+ @challenge_signup = ChallengeSignup.find(params[:id])
+ end
+
+ def check_pseud_ownership
+ if params[:challenge_signup][:pseud_id] && (pseud = Pseud.find(params[:challenge_signup][:pseud_id]))
+ # either you have to own the pseud, OR you have to be a mod editing after signups are closed and NOT changing the pseud
+ unless current_user.pseuds.include?(pseud) || (@challenge_signup && @challenge_signup.pseud == pseud && signup_closed_owner?)
+ flash[:error] = ts("You can't sign up with that pseud.")
+ redirect_to root_path and return
+ end
+ end
+ end
+
+ def check_signup_in_collection
+ unless @challenge_signup.collection_id == @collection.id
+ flash[:error] = ts("Sorry, that sign-up isn't associated with that collection.")
+ redirect_to @collection
+ end
+ end
+
+ #### ACTIONS
+
+ def index
+ if params[:user_id] && (@user = User.find_by(login: params[:user_id]))
+ if current_user == @user
+ @challenge_signups = @user.challenge_signups.order_by_date
+ render action: :index and return
+ else
+ flash[:error] = ts("You aren't allowed to see that user's sign-ups.")
+ redirect_to '/' and return
+ end
+ else
+ load_collection
+ load_challenge if @collection
+ return false unless @challenge
+ end
+
+ # using respond_to in order to provide Excel output
+ # see ExportsHelper for export_csv method
+ respond_to do |format|
+ format.html {
+ if @challenge.user_allowed_to_see_signups?(current_user)
+ @challenge_signups = @collection.signups.joins(:pseud)
+ if params[:query]
+ @query = params[:query]
+ @challenge_signups = @challenge_signups.where("pseuds.name LIKE ?", '%' + params[:query] + '%')
+ end
+ @challenge_signups = @challenge_signups.order("pseuds.name").paginate(page: params[:page], per_page: ArchiveConfig.ITEMS_PER_PAGE)
+ elsif params[:user_id] && (@user = User.find_by(login: params[:user_id]))
+ @challenge_signups = @collection.signups.by_user(current_user)
+ else
+ not_allowed(@collection)
+ end
+ }
+ format.csv {
+ if (@collection.gift_exchange? && @challenge.user_allowed_to_see_signups?(current_user)) ||
+ (@collection.prompt_meme? && @collection.user_is_maintainer?(current_user))
+ csv_data = self.send("#{@challenge.class.name.underscore}_to_csv")
+ filename = "#{@collection.name}_signups_#{Time.now.strftime('%Y-%m-%d-%H%M')}.csv"
+ send_csv_data(csv_data, filename)
+ else
+ flash[:error] = ts("You aren't allowed to see the CSV summary.")
+ redirect_to collection_path(@collection) rescue redirect_to '/' and return
+ end
+ }
+ end
+ end
+
+ def summary
+ @summary = ChallengeSignupSummary.new(@collection)
+
+ if @collection.signups.count < (ArchiveConfig.ANONYMOUS_THRESHOLD_COUNT/2)
+ flash.now[:notice] = ts("Summary does not appear until at least %{count} sign-ups have been made!", count: ((ArchiveConfig.ANONYMOUS_THRESHOLD_COUNT/2)))
+ elsif @collection.signups.count > ArchiveConfig.MAX_SIGNUPS_FOR_LIVE_SUMMARY
+ # too many signups in this collection to show the summary page "live"
+ modification_time = @summary.cached_time
+
+ # The time is always written alongside the cache, so if the time is
+ # missing, then the cache must be missing as well -- and we want to
+ # generate it. We also want to generate it if signups are open and it was
+ # last generated more than an hour ago.
+ if modification_time.nil? ||
+ (@collection.challenge.signup_open? && modification_time < 1.hour.ago)
+
+ # Touch the cache so that we don't try to generate the summary a second
+ # time on subsequent page loads.
+ @summary.touch_cache
+
+ # Generate the cache of the summary in the background.
+ @summary.enqueue_for_generation
+ end
+ else
+ # generate it on the fly
+ @tag_type = @summary.tag_type
+ @summary_tags = @summary.summary
+ @generated_live = true
+ end
+ end
+
+ def show
+ unless @challenge_signup.valid?
+ flash[:error] = ts("This sign-up is invalid. Please check your sign-ups for a duplicate or edit to fix any other problems.")
+ end
+ end
+
+ protected
+ def build_prompts
+ notice = ""
+ @challenge.class::PROMPT_TYPES.each do |prompt_type|
+ num_to_build = params["num_#{prompt_type}"] ? params["num_#{prompt_type}"].to_i : @challenge.required(prompt_type)
+ if num_to_build < @challenge.required(prompt_type)
+ notice += ts("You must submit at least %{required} #{prompt_type}. ", required: @challenge.required(prompt_type))
+ num_to_build = @challenge.required(prompt_type)
+ elsif num_to_build > @challenge.allowed(prompt_type)
+ notice += ts("You can only submit up to %{allowed} #{prompt_type}. ", allowed: @challenge.allowed(prompt_type))
+ num_to_build = @challenge.allowed(prompt_type)
+ elsif params["num_#{prompt_type}"]
+ notice += ts("Set up %{num} #{prompt_type.pluralize}. ", num: num_to_build)
+ end
+ num_existing = @challenge_signup.send(prompt_type).count
+ num_existing.upto(num_to_build-1) do
+ @challenge_signup.send(prompt_type).build
+ end
+ end
+ unless notice.blank?
+ flash[:notice] = notice
+ end
+ end
+
+ public
+ def new
+ if (@challenge_signup = ChallengeSignup.in_collection(@collection).by_user(current_user).first)
+ flash[:notice] = ts("You are already signed up for this challenge. You can edit your sign-up below.")
+ redirect_to edit_collection_signup_path(@collection, @challenge_signup)
+ else
+ @challenge_signup = ChallengeSignup.new
+ build_prompts
+ end
+ end
+
+ def edit
+ build_prompts
+ end
+
+ def create
+ @challenge_signup = ChallengeSignup.new(challenge_signup_params)
+
+ @challenge_signup.pseud = current_user.default_pseud unless @challenge_signup.pseud
+ @challenge_signup.collection = @collection
+ # we check validity first to prevent saving tag sets if invalid
+ if @challenge_signup.valid? && @challenge_signup.save
+ flash[:notice] = ts('Sign-up was successfully created.')
+ redirect_to collection_signup_path(@collection, @challenge_signup)
+ else
+ render action: :new
+ end
+ end
+
+ def update
+ if @challenge_signup.update(challenge_signup_params)
+ flash[:notice] = ts('Sign-up was successfully updated.')
+ redirect_to collection_signup_path(@collection, @challenge_signup)
+ else
+ render action: :edit
+ end
+ end
+
+ def confirm_delete
+ end
+
+ def destroy
+ unless @challenge.signup_open || @collection.user_is_maintainer?(current_user)
+ flash[:error] = ts("You cannot delete your sign-up after sign-ups are closed. Please contact a moderator for help.")
+ else
+ @challenge_signup.destroy
+ flash[:notice] = ts("Challenge sign-up was deleted.")
+ end
+ if @collection.user_is_maintainer?(current_user) && !@collection.prompt_meme?
+ redirect_to collection_signups_path(@collection)
+ elsif @collection.prompt_meme?
+ redirect_to collection_requests_path(@collection)
+ else
+ redirect_to @collection
+ end
+ end
+
+
+protected
+
+ def request_to_array(type, request)
+ any_types = TagSet::TAG_TYPES.select {|type| request && request.send("any_#{type}")}
+ any_types.map! { |type| ts("Any %{type}", type: type.capitalize) }
+ tags = request.nil? ? [] : request.tag_set.tags.map {|tag| tag.name}
+ rarray = [(tags + any_types).join(", ")]
+
+ if @challenge.send("#{type}_restriction").optional_tags_allowed
+ rarray << (request.nil? ? "" : request.optional_tag_set.tags.map {|tag| tag.name}.join(", "))
+ end
+
+ if @challenge.send("#{type}_restriction").title_allowed
+ rarray << (request.nil? ? "" : sanitize_field(request, :title))
+ end
+
+ if @challenge.send("#{type}_restriction").description_allowed
+ description = (request.nil? ? "" : sanitize_field(request, :description))
+ # Didn't find a way to get Excel 2007 to accept line breaks
+ # withing a field; not even when the row delimiter is set to
+ # \r\n and linebreaks within the field are only \n. :-(
+ #
+ # Thus stripping linebreaks.
+ rarray << description.gsub(/[\n\r]/, " ")
+ end
+
+ rarray << (request.nil? ? "" : request.url) if
+ @challenge.send("#{type}_restriction").url_allowed
+
+ return rarray
+ end
+
+
+ def gift_exchange_to_csv
+ header = ["Pseud", "Email", "Sign-up URL"]
+
+ %w(request offer).each do |type|
+ @challenge.send("#{type.pluralize}_num_allowed").times do |i|
+ header << "#{type.capitalize} #{i+1} Tags"
+ header << "#{type.capitalize} #{i+1} Optional Tags" if
+ @challenge.send("#{type}_restriction").optional_tags_allowed
+ header << "#{type.capitalize} #{i+1} Title" if
+ @challenge.send("#{type}_restriction").title_allowed
+ header << "#{type.capitalize} #{i+1} Description" if
+ @challenge.send("#{type}_restriction").description_allowed
+ header << "#{type.capitalize} #{i+1} URL" if
+ @challenge.send("#{type}_restriction").url_allowed
+ end
+ end
+
+ csv_array = []
+ csv_array << header
+
+ @collection.signups.each do |signup|
+ row = [signup.pseud.name, signup.pseud.user.email,
+ collection_signup_url(@collection, signup)]
+
+ %w(request offer).each do |type|
+ @challenge.send("#{type.pluralize}_num_allowed").times do |i|
+ row += request_to_array(type, signup.send(type.pluralize)[i])
+ end
+ end
+ csv_array << row
+ end
+
+ csv_array
+ end
+
+
+ def prompt_meme_to_csv
+ header = ["Pseud", "Sign-up URL", "Tags"]
+ header << "Optional Tags" if @challenge.request_restriction.optional_tags_allowed
+ header << "Title" if @challenge.request_restriction.title_allowed
+ header << "Description" if @challenge.request_restriction.description_allowed
+ header << "URL" if @challenge.request_restriction.url_allowed
+
+ csv_array = []
+ csv_array << header
+ @collection.prompts.where(type: "Request").each do |request|
+ row =
+ if request.anonymous?
+ ["(Anonymous)", ""]
+ else
+ [request.challenge_signup.pseud.name,
+ collection_signup_url(@collection, request.challenge_signup)]
+ end
+ csv_array << (row + request_to_array("request", request))
+ end
+
+ csv_array
+ end
+
+ private
+
+ def challenge_signup_params
+ params.require(:challenge_signup).permit(
+ :pseud_id,
+ requests_attributes: nested_prompt_params,
+ offers_attributes: nested_prompt_params
+ )
+ end
+
+ def nested_prompt_params
+ [
+ :id,
+ :title,
+ :url,
+ :any_fandom,
+ :any_character,
+ :any_relationship,
+ :any_freeform,
+ :any_category,
+ :any_rating,
+ :any_archive_warning,
+ :anonymous,
+ :description,
+ :_destroy,
+ tag_set_attributes: [
+ :id,
+ :updated_at,
+ :character_tagnames,
+ :relationship_tagnames,
+ :freeform_tagnames,
+ :category_tagnames,
+ :rating_tagnames,
+ :archive_warning_tagnames,
+ :fandom_tagnames,
+ character_tagnames: [],
+ relationship_tagnames: [],
+ freeform_tagnames: [],
+ category_tagnames: [],
+ rating_tagnames: [],
+ archive_warning_tagnames: [],
+ fandom_tagnames: [],
+ ],
+ optional_tag_set_attributes: [
+ :tagnames
+ ]
+ ]
+ end
+end
diff --git a/app/controllers/challenges_controller.rb b/app/controllers/challenges_controller.rb
new file mode 100755
index 0000000..cfe8754
--- /dev/null
+++ b/app/controllers/challenges_controller.rb
@@ -0,0 +1,68 @@
+class ChallengesController < ApplicationController
+ # This is the PARENT controller for all the challenge controller classes
+ #
+ # Here's how challenges work:
+ #
+ # For each TYPE of challenge, you use the challenge generator:
+ # script/generate challenge WhatEver -- eg, script/generate challenge
+ # Flashfic, script/generate challenge BigBang, etc
+ # This creates:
+ # - a model called Challenge::WhatEver -- eg, Challenge::Flashfic,
+ # Challenge::BigBang, etc which actually implements a challenge
+ # - a controller Challenge::WhatEverController --
+ # Challenge::FlashficController which goes in
+ # controllers/challenge/flashfic_controller.rb
+ # - a views subfolder called views/challenge/whatever --
+ # eg, views/challenge/flashfic/, views/challenge/bigbang/
+ #
+ # Example:
+ # script/generate challenge Flashfic creates:
+ # - controllers/challenge/flashfics_controller.rb
+ # - models/challenge/flashfic.rb
+ # - views/challenges/flashfics/
+ # - db/migrate/create_challenge_flashfics to create challenge_flashfics table
+ #
+ # Setting up an instance:
+ # - create the collection SGA_Flashfic
+ # - mark it as a challenge collection
+ # - choose "Regular Single Prompt (flashfic-style)" as the challenge type
+ #
+ # This feeds us into challenges/new and creates a new empty Challenge object
+ # with collection -> SGA_Flashfic
+ # and implementation -> Challenge::Flashfic.new
+ # We are then redirected to Challenge::FlashficController new ->
+ # views/challenges/flashfic/new.html.erb
+ #
+ # These can have lots of options specific to flashfics, including eg how often
+ # prompts are posted, if they should automatically
+ # be closed, whether we take user suggestions for prompts, etc.
+ #
+
+ before_action :load_collection
+
+ def no_collection
+ flash[:error] = t('challenge.no_collection',
+ default: 'What collection did you want to work with?')
+ redirect_to(request.env['HTTP_REFERER'] || root_path)
+ false
+ end
+
+ def no_challenge
+ flash[:error] = t('challenges.no_challenge',
+ default: 'What challenge did you want to work on?')
+ redirect_to collection_path(@collection) rescue redirect_to '/'
+ false
+ end
+
+ def load_collection
+ @collection ||= Collection.find_by(name: params[:collection_id]) if
+ params[:collection_id]
+ no_collection && return unless @collection
+ end
+
+ def load_challenge
+ @challenge = @collection.challenge
+ no_challenge and return unless @challenge
+ end
+
+end
diff --git a/app/controllers/chapters_controller.rb b/app/controllers/chapters_controller.rb
new file mode 100755
index 0000000..2c52d12
--- /dev/null
+++ b/app/controllers/chapters_controller.rb
@@ -0,0 +1,295 @@
+class ChaptersController < ApplicationController
+ # only registered users and NOT admin should be able to create new chapters
+ before_action :users_only, except: [:index, :show, :destroy, :confirm_delete]
+ before_action :check_user_status, only: [:new, :create, :update, :update_positions]
+ before_action :check_user_not_suspended, only: [:edit, :remove_user_creatorship, :confirm_delete, :destroy]
+ before_action :load_work
+ # only authors of a work should be able to edit its chapters
+ before_action :check_ownership, except: [:index, :show]
+ before_action :check_visibility, only: [:show]
+ before_action :load_chapter, only: [:show, :edit, :remove_user_creatorship, :update, :preview, :post, :confirm_delete, :destroy]
+
+ cache_sweeper :feed_sweeper
+
+ # GET /work/:work_id/chapters
+ # GET /work/:work_id/chapters.xml
+ def index
+ # this route is never used
+ redirect_to work_path(params[:work_id])
+ end
+
+ # GET /work/:work_id/chapters/manage
+ def manage
+ @chapters = @work.chapters_in_order(include_content: false,
+ include_drafts: true)
+ end
+
+ # GET /work/:work_id/chapters/:id
+ # GET /work/:work_id/chapters/:id.xml
+ def show
+ @tag_groups = @work.tag_groups
+
+ redirect_to url_for(controller: :chapters, action: :show, work_id: @work.id, id: params[:selected_id]) and return if params[:selected_id]
+
+ @chapters = @work.chapters_in_order(
+ include_content: false,
+ include_drafts: (logged_in_as_admin? ||
+ @work.user_is_owner_or_invited?(current_user))
+ )
+
+ unless @chapters.include?(@chapter)
+ access_denied
+ return
+ end
+
+ chapter_position = @chapters.index(@chapter)
+ if @chapters.length > 1
+ @previous_chapter = @chapters[chapter_position - 1] unless chapter_position.zero?
+ @next_chapter = @chapters[chapter_position + 1]
+ end
+
+ if @work.unrevealed?
+ @page_title = t(".unrevealed") + t(".chapter_position", position: @chapter.position.to_s)
+ else
+ fandoms = @tag_groups["Fandom"]
+ fandom = fandoms.empty? ? t(".unspecified_fandom") : fandoms[0].name
+ title_fandom = fandoms.size > 3 ? t(".multifandom") : fandom
+ author = @work.anonymous? ? t(".anonymous") : @work.pseuds.sort.collect(&:byline).join(", ")
+ @page_title = get_page_title(title_fandom, author, @work.title + t(".chapter_position", position: @chapter.position.to_s))
+ end
+
+ if params[:view_adult]
+ cookies[:view_adult] = "true"
+ elsif @work.adult? && !see_adult?
+ render "works/_adult", layout: "application" and return
+ end
+
+ @kudos = @work.kudos.with_user.includes(:user)
+
+ if current_user.respond_to?(:subscriptions)
+ @subscription = current_user.subscriptions.where(subscribable_id: @work.id,
+ subscribable_type: "Work").first ||
+ current_user.subscriptions.build(subscribable: @work)
+ end
+ # update the history.
+ Reading.update_or_create(@work, current_user) if current_user
+
+ respond_to do |format|
+ format.html
+ format.js
+ end
+ end
+
+ # GET /work/:work_id/chapters/new
+ # GET /work/:work_id/chapters/new.xml
+ def new
+ @chapter = @work.chapters.build(position: @work.number_of_chapters + 1)
+ end
+
+ # GET /work/:work_id/chapters/1/edit
+ def edit
+ end
+
+ def remove_user_creatorship
+ @chapter.creatorships.for_user(current_user).destroy_all
+ if @work.chapters.any? { |c| current_user.is_author_of?(c) }
+ flash[:notice] = ts("You have been removed as a creator from the chapter.")
+ redirect_to @work and return
+ end
+
+ # remove from work if no longer co-creator on any chapter
+ pseuds_with_author_removed = @work.pseuds - current_user.pseuds
+
+ if pseuds_with_author_removed.empty?
+ redirect_to controller: "orphans", action: "new", work_id: @work.id
+ else
+ @work.remove_author(current_user)
+ flash[:notice] = ts("You have been removed as a creator from the work.")
+ redirect_to current_user
+ end
+ end
+
+ def draft_flash_message(work)
+ flash[:notice] = work.posted ? t("chapters.draft_flash.posted_work") : t("chapters.draft_flash.unposted_work_html", deletion_date: view_context.date_in_zone(work.created_at + 29.days)).html_safe
+ end
+
+ # POST /work/:work_id/chapters
+ # POST /work/:work_id/chapters.xml
+ def create
+ if params[:cancel_button]
+ redirect_back_or_default(root_path)
+ return
+ end
+
+ @chapter = @work.chapters.build(chapter_params)
+ @work.wip_length = params[:chapter][:wip_length]
+
+ if params[:edit_button] || chapter_cannot_be_saved?
+ render :new
+ else # :post_without_preview or :preview
+ @chapter.posted = true if params[:post_without_preview_button]
+ @work.set_revised_at_by_chapter(@chapter)
+ if @chapter.save && @work.save
+ if @chapter.posted
+ post_chapter
+ redirect_to [@work, @chapter]
+ else
+ draft_flash_message(@work)
+ redirect_to preview_work_chapter_path(@work, @chapter)
+ end
+ else
+ render :new
+ end
+ end
+ end
+
+ # PUT /work/:work_id/chapters/1
+ # PUT /work/:work_id/chapters/1.xml
+ def update
+ if params[:cancel_button]
+ # Not quite working yet - should send the user back to wherever they were before they hit edit
+ redirect_back_or_default(root_path)
+ return
+ end
+
+ @chapter.attributes = chapter_params
+ @work.wip_length = params[:chapter][:wip_length]
+
+ if params[:edit_button] || chapter_cannot_be_saved?
+ render :edit
+ elsif params[:preview_button]
+ @preview_mode = true
+ if @chapter.posted?
+ flash[:notice] = ts("This is a preview of what this chapter will look like after your changes have been applied. You should probably read the whole thing to check for problems before posting.")
+ else
+ draft_flash_message(@work)
+ end
+ render :preview
+ else
+ @chapter.posted = true if params[:post_button] || params[:post_without_preview_button]
+ posted_changed = @chapter.posted_changed?
+ @work.set_revised_at_by_chapter(@chapter)
+ if @chapter.save && @work.save
+ flash[:notice] = ts("Chapter was successfully #{posted_changed ? 'posted' : 'updated'}.")
+ redirect_to work_chapter_path(@work, @chapter)
+ else
+ render :edit
+ end
+ end
+ end
+
+ def update_positions
+ if params[:chapters]
+ @work.reorder_list(params[:chapters])
+ flash[:notice] = ts("Chapter order has been successfully updated.")
+ elsif params[:chapter]
+ params[:chapter].each_with_index do |id, position|
+ @work.chapters.update(id, position: position + 1)
+ (@chapters ||= []) << Chapter.find(id)
+ end
+ end
+ respond_to do |format|
+ format.html { redirect_to(@work) and return }
+ format.js { head :ok }
+ end
+ end
+
+ # GET /chapters/1/preview
+ def preview
+ @preview_mode = true
+ end
+
+ # POST /chapters/1/post
+ def post
+ if params[:cancel_button]
+ redirect_to @work
+ elsif params[:edit_button]
+ redirect_to [:edit, @work, @chapter]
+ else
+ @chapter.posted = true
+ @work.set_revised_at_by_chapter(@chapter)
+ if @chapter.save && @work.save
+ post_chapter
+ redirect_to(@work)
+ else
+ render :preview
+ end
+ end
+ end
+
+ # GET /work/:work_id/chapters/1/confirm_delete
+ def confirm_delete
+ end
+
+ # DELETE /work/:work_id/chapters/1
+ # DELETE /work/:work_id/chapters/1.xml
+ def destroy
+ if @chapter.is_only_chapter? || @chapter.only_non_draft_chapter?
+ flash[:error] = t(".only_chapter")
+ redirect_to(edit_work_path(@work))
+ return
+ end
+
+ was_draft = !@chapter.posted?
+ if @chapter.destroy
+ @work.minor_version = @work.minor_version + 1 unless was_draft
+ @work.set_revised_at
+ @work.save
+ flash[:notice] = ts("The chapter #{was_draft ? 'draft ' : ''}was successfully deleted.")
+ else
+ flash[:error] = ts("Something went wrong. Please try again.")
+ end
+ redirect_to controller: "works", action: "show", id: @work
+ end
+
+ private
+
+ # Check whether we should display :new or :edit instead of previewing or
+ # saving the user's changes.
+ def chapter_cannot_be_saved?
+ # The chapter can only be saved if the work can be saved:
+ if @work.invalid?
+ @work.errors.full_messages.each do |message|
+ @chapter.errors.add(:base, message)
+ end
+ end
+
+ @chapter.errors.any? || @chapter.invalid?
+ end
+
+ # fetch work these chapters belong to from db
+ def load_work
+ @work = params[:work_id] ? Work.find_by(id: params[:work_id]) : Chapter.find_by(id: params[:id]).try(:work)
+ if @work.blank?
+ flash[:error] = ts("Sorry, we couldn't find the work you were looking for.")
+ redirect_to root_path and return
+ end
+ @check_ownership_of = @work
+ @check_visibility_of = @work
+ end
+
+ # Loads the specified chapter from the database. Redirects to the work if no
+ # chapter is specified, or if the specified chapter doesn't exist.
+ def load_chapter
+ @chapter = @work.chapters.find_by(id: params[:id])
+
+ return if @chapter
+
+ flash[:error] = ts("Sorry, we couldn't find the chapter you were looking for.")
+ redirect_to work_path(@work)
+ end
+
+ def post_chapter
+ @work.update_attribute(:posted, true) unless @work.posted
+ flash[:notice] = ts("Chapter has been posted!")
+ end
+
+ private
+
+ def chapter_params
+ params.require(:chapter).permit(:title, :position, :wip_length, :"published_at(3i)",
+ :"published_at(2i)", :"published_at(1i)", :summary,
+ :notes, :endnotes, :content, :published_at,
+ author_attributes: [:byline, ids: [], coauthors: []])
+ end
+end
diff --git a/app/controllers/collection_items_controller.rb b/app/controllers/collection_items_controller.rb
new file mode 100755
index 0000000..1dbafd8
--- /dev/null
+++ b/app/controllers/collection_items_controller.rb
@@ -0,0 +1,229 @@
+class CollectionItemsController < ApplicationController
+ before_action :load_collection
+ before_action :load_user, only: [:update_multiple]
+ before_action :load_collectible_item, only: [:new, :create]
+ before_action :check_parent_visible, only: [:new]
+ before_action :users_only, only: [:new]
+
+ cache_sweeper :collection_sweeper
+
+ def index
+
+ # TODO: AO3-6507 Refactor to use send instead of case statements.
+ if @collection && @collection.user_is_maintainer?(current_user)
+ @collection_items = @collection.collection_items.include_for_works
+ @collection_items = case params[:status]
+ when "approved"
+ @collection_items.approved_by_both
+ when "rejected_by_collection"
+ @collection_items.rejected_by_collection
+ when "rejected_by_user"
+ @collection_items.rejected_by_user
+ when "unreviewed_by_user"
+ @collection_items.invited_by_collection
+ else
+ @collection_items.unreviewed_by_collection
+ end
+ elsif params[:user_id] && (@user = User.find_by(login: params[:user_id])) && @user == current_user
+ @collection_items = CollectionItem.for_user(@user).includes(:collection).merge(Collection.with_attached_icon)
+ @collection_items = case params[:status]
+ when "approved"
+ @collection_items.approved_by_both
+ when "rejected_by_collection"
+ @collection_items.rejected_by_collection
+ when "rejected_by_user"
+ @collection_items.rejected_by_user
+ when "unreviewed_by_collection"
+ @collection_items.approved_by_user.unreviewed_by_collection
+ else
+ @collection_items.unreviewed_by_user
+ end
+ else
+ flash[:error] = ts("You don't have permission to see that, sorry!")
+ redirect_to collections_path and return
+ end
+
+ sort = "created_at DESC"
+ @collection_items = @collection_items.order(sort).paginate page: params[:page], per_page: ArchiveConfig.ITEMS_PER_PAGE
+ end
+
+ def load_collectible_item
+ if params[:work_id]
+ @item = Work.find(params[:work_id])
+ elsif params[:bookmark_id]
+ @item = Bookmark.find(params[:bookmark_id])
+ end
+ end
+
+ def check_parent_visible
+ check_visibility_for(@item)
+ end
+
+ def load_user
+ unless @collection
+ @user = User.find_by(login: params[:user_id])
+ end
+ end
+
+ def new
+ end
+
+ def create
+ unless params[:collection_names]
+ flash[:error] = ts("What collections did you want to add?")
+ redirect_to(request.env["HTTP_REFERER"] || root_path) and return
+ end
+ unless @item
+ flash[:error] = ts("What did you want to add to a collection?")
+ redirect_to(request.env["HTTP_REFERER"] || root_path) and return
+ end
+ if !current_user.archivist && @item.respond_to?(:allow_collection_invitation?) && !@item.allow_collection_invitation?
+ flash[:error] = t(".invitation_not_sent", default: "This item could not be invited.")
+ redirect_to(@item) and return
+ end
+ # for each collection name
+ # see if it exists, is open, and isn't already one of this item's collections
+ # add the collection and save
+ # if there are errors, add them to errors
+ new_collections = []
+ invited_collections = []
+ unapproved_collections = []
+ errors = []
+ params[:collection_names].split(',').map {|name| name.strip}.uniq.each do |collection_name|
+ collection = Collection.find_by(name: collection_name)
+ if !collection
+ errors << ts("%{name}, because we couldn't find a collection with that name. Make sure you are using the one-word name, and not the title.", name: collection_name)
+ elsif @item.collections.include?(collection)
+ if @item.rejected_collections.include?(collection)
+ errors << ts("%{collection_title}, because the %{object_type}'s owner has rejected the invitation.", collection_title: collection.title, object_type: @item.class.name.humanize.downcase)
+ else
+ errors << ts("%{collection_title}, because this item has already been submitted.", collection_title: collection.title)
+ end
+ elsif collection.closed? && !collection.user_is_maintainer?(User.current_user)
+ errors << ts("%{collection_title} is closed to new submissions.", collection_title: collection.title)
+ elsif (collection.anonymous? || collection.unrevealed?) && !current_user.is_author_of?(@item)
+ errors << ts("%{collection_title}, because you don't own this item and the collection is anonymous or unrevealed.", collection_title: collection.title)
+ elsif !current_user.is_author_of?(@item) && !collection.user_is_maintainer?(current_user)
+ errors << ts("%{collection_title}, either you don't own this item or are not a moderator of the collection.", collection_title: collection.title)
+ elsif @item.is_a?(Work) && @item.anonymous? && !current_user.is_author_of?(@item)
+ errors << ts("%{collection_title}, because you don't own this item and the item is anonymous.", collection_title: collection.title)
+ # add the work to a collection, and try to save it
+ elsif @item.add_to_collection(collection) && @item.save(validate: false)
+ # approved_by_user? and approved_by_collection? are both true.
+ # This is will be true for archivists adding works to collections they maintain
+ # or creators adding their works to a collection with auto-approval.
+ if @item.approved_collections.include?(collection)
+ new_collections << collection
+ # if the current_user is a maintainer of the collection then approved_by_user must have been false (which means
+ # the current_user isn't the owner of the item), then the maintainer is attempting to invite this work to
+ # their collection
+ elsif collection.user_is_maintainer?(current_user)
+ invited_collections << collection
+ # otherwise the current_user is the owner of the item and approved_by_COLLECTION was false (which means the
+ # current_user isn't a collection_maintainer), so the item owner is attempting to add their work to a moderated
+ # collection
+ else
+ unapproved_collections << collection
+ end
+ else
+ errors << ts("Something went wrong trying to add collection %{name}, sorry!", name: collection_name)
+ end
+ end
+
+ # messages to the user
+ unless errors.empty?
+ flash[:error] = ts("We couldn't add your submission to the following collection(s): ") + "
" + errors.join("") + "
"
+ end
+ flash[:notice] = "" unless new_collections.empty? && unapproved_collections.empty?
+ unless new_collections.empty?
+ flash[:notice] = ts("Added to collection(s): %{collections}.",
+ collections: new_collections.collect(&:title).join(", "))
+ end
+ unless invited_collections.empty?
+ invited_collections.each do |needs_user_approval|
+ flash[:notice] ||= ""
+ flash[:notice] = t(".invited_to_collections_html",
+ invited_link: view_context.link_to(t(".invited"),
+ collection_items_path(needs_user_approval, status: :unreviewed_by_user)),
+ collection_title: needs_user_approval.title)
+ end
+ end
+ unless unapproved_collections.empty?
+ flash[:notice] ||= ""
+ flash[:notice] += ts(" You have submitted your work to #{unapproved_collections.size > 1 ? "moderated collections (%{all_collections}). It will not become a part of those collections" : "the moderated collection '%{all_collections}'. It will not become a part of the collection"} until it has been approved by a moderator.", all_collections: unapproved_collections.map { |f| f.title }.join(', '))
+ end
+
+ flash[:notice] = (flash[:notice]).html_safe unless flash[:notice].blank?
+ flash[:error] = (flash[:error]).html_safe unless flash[:error].blank?
+
+ redirect_to(@item)
+ end
+
+ def update_multiple
+ if @collection&.user_is_maintainer?(current_user)
+ update_multiple_with_params(
+ allowed_items: @collection.collection_items,
+ update_params: collection_update_multiple_params,
+ success_path: collection_items_path(@collection)
+ )
+ elsif @user && @user == current_user
+ update_multiple_with_params(
+ allowed_items: CollectionItem.for_user(@user),
+ update_params: user_update_multiple_params,
+ success_path: user_collection_items_path(@user)
+ )
+ else
+ flash[:error] = ts("You don't have permission to do that, sorry!")
+ redirect_to(@collection || @user)
+ end
+ end
+
+ # The main work performed by update_multiple. Uses the passed-in parameters
+ # to update, and only updates items that can be found in allowed_items (which
+ # should be a relation on CollectionItems). When all items are successfully
+ # updated, redirects to success_path.
+ def update_multiple_with_params(allowed_items:, update_params:, success_path:)
+ # Collect any failures so that we can display errors:
+ @collection_items = []
+
+ # Make sure that the keys are integers so that we can look up the
+ # parameters by ID.
+ update_params.transform_keys!(&:to_i)
+
+ # By using where() here and updating each item individually, instead of
+ # using allowed_items.update(update_params.keys, update_params.values) --
+ # which uses find() under the hood -- we ensure that we'll fail silently if
+ # the user tries to update an item they're not allowed to.
+ allowed_items.where(id: update_params.keys).each do |item|
+ item_data = update_params[item.id]
+ if item_data[:remove] == "1"
+ next unless item.user_allowed_to_destroy?(current_user)
+
+ @collection_items << item unless item.destroy
+ else
+ @collection_items << item unless item.update(item_data)
+ end
+ end
+
+ if @collection_items.empty?
+ flash[:notice] = ts("Collection status updated!")
+ redirect_to success_path
+ else
+ render action: "index"
+ end
+ end
+
+ private
+
+ def user_update_multiple_params
+ allowed = %i[user_approval_status remove]
+ params.slice(:collection_items).permit(collection_items: allowed).
+ require(:collection_items)
+ end
+
+ def collection_update_multiple_params
+ allowed = %i[collection_approval_status unrevealed anonymous remove]
+ params.slice(:collection_items).permit(collection_items: allowed).
+ require(:collection_items)
+ end
+end
diff --git a/app/controllers/collection_participants_controller.rb b/app/controllers/collection_participants_controller.rb
new file mode 100755
index 0000000..ed336ee
--- /dev/null
+++ b/app/controllers/collection_participants_controller.rb
@@ -0,0 +1,135 @@
+class CollectionParticipantsController < ApplicationController
+ before_action :users_only
+ before_action :load_collection
+ before_action :load_participant, only: [:update, :destroy]
+ before_action :allowed_to_promote, only: [:update]
+ before_action :allowed_to_destroy, only: [:destroy]
+ before_action :has_other_owners, only: [:update, :destroy]
+ before_action :collection_maintainers_only, only: [:index, :add, :update]
+
+ cache_sweeper :collection_sweeper
+
+ def owners_required
+ flash[:error] = t("collection_participants.validation.owners_required")
+ redirect_to collection_participants_path(@collection)
+ false
+ end
+
+ def load_collection
+ @collection = Collection.find_by!(name: params[:collection_id])
+ end
+
+ def load_participant
+ @participant = @collection.collection_participants.find(params[:id])
+ end
+
+ def allowed_to_promote
+ @new_role = collection_participant_params[:participant_role]
+ @participant.user_allowed_to_promote?(current_user, @new_role) || not_allowed(@collection)
+ end
+
+ def allowed_to_destroy
+ @participant.user_allowed_to_destroy?(current_user) || not_allowed(@collection)
+ end
+
+ def has_other_owners
+ !@participant.is_owner? || (@collection.owners != [@participant.pseud]) || owners_required
+ end
+
+ ## ACTIONS
+
+ def join
+ unless @collection
+ flash[:error] = t('no_collection', default: "Which collection did you want to join?")
+ redirect_to(request.env["HTTP_REFERER"] || root_path) and return
+ end
+ participants = CollectionParticipant.in_collection(@collection).for_user(current_user) unless current_user.nil?
+ if participants.empty?
+ @participant = CollectionParticipant.new(
+ collection: @collection,
+ pseud: current_user.default_pseud,
+ participant_role: CollectionParticipant::NONE
+ )
+ @participant.save
+ flash[:notice] = t('applied_to_join_collection', default: "You have applied to join %{collection}.", collection: @collection.title)
+ else
+ participants.each do |participant|
+ if participant.is_invited?
+ participant.approve_membership!
+ flash[:notice] = t('collection_participants.accepted_invite', default: "You are now a member of %{collection}.", collection: @collection.title)
+ redirect_to(request.env["HTTP_REFERER"] || root_path) and return
+ end
+ end
+
+ flash[:notice] = t('collection_participants.no_invitation', default: "You have already joined (or applied to) this collection.")
+ end
+
+ redirect_to(request.env["HTTP_REFERER"] || root_path)
+ end
+
+ def index
+ @collection_participants = @collection.collection_participants.reject {|p| p.pseud.nil?}.sort_by {|participant| participant.pseud.name.downcase }
+ end
+
+ def update
+ if @participant.update(collection_participant_params)
+ flash[:notice] = t('collection_participants.update_success', default: "Updated %{participant}.", participant: @participant.pseud.name)
+ else
+ flash[:error] = t(".failure", participant: @participant.pseud.name)
+ end
+ redirect_to collection_participants_path(@collection)
+ end
+
+ def destroy
+ @participant.destroy
+ flash[:notice] = t('collection_participants.destroy', default: "Removed %{participant} from collection.", participant: @participant.pseud.name)
+ redirect_to(request.env["HTTP_REFERER"] || root_path)
+ end
+
+ def add
+ @participants_added = []
+ @participants_invited = []
+ pseud_results = Pseud.parse_bylines(params[:participants_to_invite])
+ pseud_results[:pseuds].each do |pseud|
+ if @collection.participants.include?(pseud)
+ participant = CollectionParticipant.where(collection_id: @collection.id, pseud_id: pseud.id).first
+ if participant && participant.is_none?
+ @participants_added << participant if participant.approve_membership!
+ end
+ else
+ participant = CollectionParticipant.new(collection: @collection, pseud: pseud, participant_role: CollectionParticipant::MEMBER)
+ @participants_invited << participant if participant.save
+ end
+ end
+
+ if @participants_invited.empty? && @participants_added.empty?
+ if pseud_results[:banned_pseuds].present?
+ flash[:error] = ts("%{name} cannot participate in challenges.",
+ name: pseud_results[:banned_pseuds].to_sentence
+ )
+ else
+ flash[:error] = ts("We couldn't find anyone new by that name to add.")
+ end
+ else
+ flash[:notice] = ""
+ end
+
+ unless @participants_invited.empty?
+ @participants_invited = @participants_invited.sort_by {|participant| participant.pseud.name.downcase }
+ flash[:notice] += ts("New members invited: ") + @participants_invited.collect(&:pseud).collect(&:byline).join(', ')
+ end
+
+ unless @participants_added.empty?
+ @participants_added = @participants_added.sort_by {|participant| participant.pseud.name.downcase }
+ flash[:notice] += ts("Members added: ") + @participants_added.collect(&:pseud).collect(&:byline).join(', ')
+ end
+
+ redirect_to collection_participants_path(@collection)
+ end
+
+ private
+
+ def collection_participant_params
+ params.require(:collection_participant).permit(:participant_role)
+ end
+end
diff --git a/app/controllers/collection_profile_controller.rb b/app/controllers/collection_profile_controller.rb
new file mode 100755
index 0000000..2f50993
--- /dev/null
+++ b/app/controllers/collection_profile_controller.rb
@@ -0,0 +1,13 @@
+class CollectionProfileController < ApplicationController
+
+ before_action :load_collection
+
+ def show
+ unless @collection
+ flash[:error] = "What collection did you want to look at?"
+ redirect_to collections_path and return
+ end
+ @page_subtitle = t(".page_title", collection_title: @collection.title)
+ end
+
+end
diff --git a/app/controllers/collections_controller.rb b/app/controllers/collections_controller.rb
new file mode 100755
index 0000000..51d75d7
--- /dev/null
+++ b/app/controllers/collections_controller.rb
@@ -0,0 +1,214 @@
+class CollectionsController < ApplicationController
+ before_action :users_only, only: [:new, :edit, :create, :update]
+ before_action :load_collection_from_id, only: [:show, :edit, :update, :destroy, :confirm_delete]
+ before_action :collection_owners_only, only: [:edit, :update, :destroy, :confirm_delete]
+ before_action :check_user_status, only: [:new, :create, :edit, :update, :destroy]
+ before_action :validate_challenge_type
+ before_action :check_parent_visible, only: [:index]
+ cache_sweeper :collection_sweeper
+
+ # Lazy fix to prevent passing unsafe values to eval via challenge_type
+ # In both CollectionsController#create and CollectionsController#update there are a vulnerable usages of eval
+ # For now just make sure the values passed to it are safe
+ def validate_challenge_type
+ if params[:challenge_type] and not ["", "GiftExchange", "PromptMeme"].include?(params[:challenge_type])
+ return render status: :bad_request, text: "invalid challenge_type"
+ end
+ end
+
+ def load_collection_from_id
+ @collection = Collection.find_by(name: params[:id])
+ unless @collection
+ raise ActiveRecord::RecordNotFound, "Couldn't find collection named '#{params[:id]}'"
+ end
+ end
+
+ def check_parent_visible
+ return unless params[:work_id] && (@work = Work.find_by(id: params[:work_id]))
+
+ check_visibility_for(@work)
+ end
+
+ def index
+ if params[:work_id]
+ @work = Work.find(params[:work_id])
+ @collections = @work.approved_collections
+ .by_title
+ .for_blurb
+ .paginate(page: params[:page])
+ elsif params[:collection_id]
+ @collection = Collection.find_by!(name: params[:collection_id])
+ @collections = @collection.children
+ .by_title
+ .for_blurb
+ .paginate(page: params[:page])
+ @page_subtitle = t(".subcollections_page_title", collection_title: @collection.title)
+ elsif params[:user_id]
+ @user = User.find_by!(login: params[:user_id])
+ @collections = @user.maintained_collections
+ .by_title
+ .for_blurb
+ .paginate(page: params[:page])
+ @page_subtitle = ts("%{username} - Collections", username: @user.login)
+ else
+ @sort_and_filter = true
+ params[:collection_filters] ||= {}
+ params[:sort_column] = "collections.created_at" if !valid_sort_column(params[:sort_column], 'collection')
+ params[:sort_direction] = 'DESC' if !valid_sort_direction(params[:sort_direction])
+ sort = params[:sort_column] + " " + params[:sort_direction]
+ @collections = Collection.sorted_and_filtered(sort, params[:collection_filters], params[:page])
+ end
+ end
+
+ # display challenges that are currently taking signups
+ def list_challenges
+ @page_subtitle = "Open Challenges"
+ @hide_dashboard = true
+ @challenge_collections = (Collection.signup_open("GiftExchange").limit(15) + Collection.signup_open("PromptMeme").limit(15))
+ end
+
+ def list_ge_challenges
+ @page_subtitle = "Open Gift Exchange Challenges"
+ @challenge_collections = Collection.signup_open("GiftExchange").limit(15)
+ end
+
+ def list_pm_challenges
+ @page_subtitle = "Open Prompt Meme Challenges"
+ @challenge_collections = Collection.signup_open("PromptMeme").limit(15)
+ end
+
+ def show
+ @page_subtitle = @collection.title
+
+ if @collection.collection_preference.show_random? || params[:show_random]
+ # show a random selection of works/bookmarks
+ @works = WorkQuery.new(
+ collection_ids: [@collection.id], show_restricted: is_registered_user?
+ ).sample(count: ArchiveConfig.NUMBER_OF_ITEMS_VISIBLE_IN_DASHBOARD)
+
+ @bookmarks = BookmarkQuery.new(
+ collection_ids: [@collection.id], show_restricted: is_registered_user?
+ ).sample(count: ArchiveConfig.NUMBER_OF_ITEMS_VISIBLE_IN_DASHBOARD)
+ else
+ # show recent
+ @works = WorkQuery.new(
+ collection_ids: [@collection.id], show_restricted: is_registered_user?,
+ sort_column: "revised_at",
+ per_page: ArchiveConfig.NUMBER_OF_ITEMS_VISIBLE_IN_DASHBOARD
+ ).search_results
+
+ @bookmarks = BookmarkQuery.new(
+ collection_ids: [@collection.id], show_restricted: is_registered_user?,
+ sort_column: "created_at",
+ per_page: ArchiveConfig.NUMBER_OF_ITEMS_VISIBLE_IN_DASHBOARD
+ ).search_results
+ end
+ end
+
+ def new
+ @hide_dashboard = true
+ @collection = Collection.new
+ if params[:collection_id] && (@collection_parent = Collection.find_by(name: params[:collection_id]))
+ @collection.parent_name = @collection_parent.name
+ end
+ end
+
+ def edit
+ end
+
+ def create
+ @hide_dashboard = true
+ @collection = Collection.new(collection_params)
+
+ # add the owner
+ owner_attributes = []
+ (params[:owner_pseuds] || [current_user.default_pseud_id]).each do |pseud_id|
+ pseud = Pseud.find(pseud_id)
+ owner_attributes << {pseud: pseud, participant_role: CollectionParticipant::OWNER} if pseud
+ end
+ @collection.collection_participants.build(owner_attributes)
+
+ if @collection.save
+ flash[:notice] = ts('Collection was successfully created.')
+ unless params[:challenge_type].blank?
+ if params[:challenge_type] == "PromptMeme"
+ redirect_to new_collection_prompt_meme_path(@collection) and return
+ elsif params[:challenge_type] == "GiftExchange"
+ redirect_to new_collection_gift_exchange_path(@collection) and return
+ end
+ else
+ redirect_to collection_path(@collection)
+ end
+ else
+ @challenge_type = params[:challenge_type]
+ render action: "new"
+ end
+ end
+
+ def update
+ if @collection.update(collection_params)
+ flash[:notice] = ts('Collection was successfully updated.')
+ if params[:challenge_type].blank?
+ if @collection.challenge
+ # trying to destroy an existing challenge
+ flash[:error] = ts("Note: if you want to delete an existing challenge, please do so on the challenge page.")
+ end
+ else
+ if @collection.challenge
+ if @collection.challenge.class.name != params[:challenge_type]
+ flash[:error] = ts("Note: if you want to change the type of challenge, first please delete the existing challenge on the challenge page.")
+ else
+ if params[:challenge_type] == "PromptMeme"
+ redirect_to edit_collection_prompt_meme_path(@collection) and return
+ elsif params[:challenge_type] == "GiftExchange"
+ redirect_to edit_collection_gift_exchange_path(@collection) and return
+ end
+ end
+ else
+ if params[:challenge_type] == "PromptMeme"
+ redirect_to new_collection_prompt_meme_path(@collection) and return
+ elsif params[:challenge_type] == "GiftExchange"
+ redirect_to new_collection_gift_exchange_path(@collection) and return
+ end
+ end
+ end
+ redirect_to collection_path(@collection)
+ else
+ render action: "edit"
+ end
+ end
+
+ def confirm_delete
+ end
+
+ def destroy
+ @hide_dashboard = true
+ @collection = Collection.find_by(name: params[:id])
+ begin
+ @collection.destroy
+ flash[:notice] = ts("Collection was successfully deleted.")
+ rescue
+ flash[:error] = ts("We couldn't delete that right now, sorry! Please try again later.")
+ end
+ redirect_to(collections_path)
+ end
+
+ private
+
+ def collection_params
+ params.require(:collection).permit(
+ :name, :title, :email, :header_image_url, :description,
+ :parent_name, :challenge_type, :icon, :delete_icon,
+ :icon_alt_text, :icon_comment_text,
+ collection_profile_attributes: [
+ :id, :intro, :faq, :rules,
+ :gift_notification, :assignment_notification
+ ],
+ collection_preference_attributes: [
+ :id, :moderated, :closed, :unrevealed, :anonymous,
+ :gift_exchange, :show_random, :prompt_meme, :email_notify
+ ]
+ )
+ end
+
+end
diff --git a/app/controllers/comments_controller.rb b/app/controllers/comments_controller.rb
new file mode 100755
index 0000000..d863da4
--- /dev/null
+++ b/app/controllers/comments_controller.rb
@@ -0,0 +1,700 @@
+class CommentsController < ApplicationController
+ skip_before_action :store_location, except: [:show, :index, :new]
+ before_action :load_commentable,
+ only: [:index, :new, :create, :edit, :update, :show_comments,
+ :hide_comments, :add_comment_reply,
+ :cancel_comment_reply, :delete_comment,
+ :cancel_comment_delete, :unreviewed, :review_all]
+ before_action :check_user_status, only: [:new, :create, :edit, :update, :destroy]
+ before_action :load_comment, only: [:show, :edit, :update, :delete_comment, :destroy, :cancel_comment_edit, :cancel_comment_delete, :review, :approve, :reject, :freeze, :unfreeze, :hide, :unhide]
+ before_action :check_visibility, only: [:show]
+ before_action :check_if_restricted
+ before_action :check_tag_wrangler_access
+ before_action :check_parent_visible
+ before_action :check_modify_parent,
+ only: [:new, :create, :edit, :update, :add_comment_reply,
+ :cancel_comment_reply, :cancel_comment_edit]
+ before_action :check_pseud_ownership, only: [:create, :update]
+ before_action :check_ownership, only: [:edit, :update, :cancel_comment_edit]
+ before_action :check_permission_to_edit, only: [:edit, :update ]
+ before_action :check_permission_to_delete, only: [:delete_comment, :destroy]
+ before_action :check_guest_comment_admin_setting, only: [:new, :create, :add_comment_reply]
+ before_action :check_parent_comment_permissions, only: [:new, :create, :add_comment_reply]
+ before_action :check_unreviewed, only: [:add_comment_reply]
+ before_action :check_frozen, only: [:new, :create, :add_comment_reply]
+ before_action :check_hidden_by_admin, only: [:new, :create, :add_comment_reply]
+ before_action :check_not_replying_to_spam, only: [:new, :create, :add_comment_reply]
+ before_action :check_guest_replies_preference, only: [:new, :create, :add_comment_reply]
+ before_action :check_permission_to_review, only: [:unreviewed]
+ before_action :check_permission_to_access_single_unreviewed, only: [:show]
+ before_action :check_permission_to_moderate, only: [:approve, :reject]
+ before_action :check_permission_to_modify_frozen_status, only: [:freeze, :unfreeze]
+ before_action :check_permission_to_modify_hidden_status, only: [:hide, :unhide]
+ before_action :admin_logout_required, only: [:new, :create, :add_comment_reply]
+
+ include BlockHelper
+
+ before_action :check_blocked, only: [:new, :create, :add_comment_reply, :edit, :update]
+ def check_blocked
+ parent = find_parent
+
+ if blocked_by?(parent)
+ flash[:comment_error] = t("comments.check_blocked.parent")
+ redirect_to_all_comments(parent, show_comments: true)
+ elsif @comment && blocked_by_comment?(@comment.commentable)
+ # edit and update set @comment to the comment being edited
+ flash[:comment_error] = t("comments.check_blocked.reply")
+ redirect_to_all_comments(parent, show_comments: true)
+ elsif @comment.nil? && blocked_by_comment?(@commentable)
+ # new, create, and add_comment_reply don't set @comment, but do set @commentable
+ flash[:comment_error] = t("comments.check_blocked.reply")
+ redirect_to_all_comments(parent, show_comments: true)
+ end
+ end
+
+ def check_pseud_ownership
+ return unless params[:comment][:pseud_id]
+ pseud = Pseud.find(params[:comment][:pseud_id])
+ return if pseud && current_user && current_user.pseuds.include?(pseud)
+ flash[:error] = ts("You can't comment with that pseud.")
+ redirect_to root_path
+ end
+
+ def load_comment
+ @comment = Comment.find(params[:id])
+ @check_ownership_of = @comment
+ @check_visibility_of = @comment
+ end
+
+ def check_parent_visible
+ check_visibility_for(find_parent)
+ end
+
+ def check_modify_parent
+ parent = find_parent
+ # No one can create or update comments on something hidden by an admin.
+ if parent.respond_to?(:hidden_by_admin) && parent.hidden_by_admin
+ flash[:error] = ts("Sorry, you can't add or edit comments on a hidden work.")
+ redirect_to work_path(parent)
+ end
+ # No one can create or update comments on unrevealed works.
+ if parent.respond_to?(:in_unrevealed_collection) && parent.in_unrevealed_collection
+ flash[:error] = ts("Sorry, you can't add or edit comments on an unrevealed work.")
+ redirect_to work_path(parent)
+ end
+ end
+
+ def find_parent
+ if @comment.present?
+ @comment.ultimate_parent
+ elsif @commentable.is_a?(Comment)
+ @commentable.ultimate_parent
+ elsif @commentable.present? && @commentable.respond_to?(:work)
+ @commentable.work
+ else
+ @commentable
+ end
+ end
+
+ # Check to see if the ultimate_parent is a Work, and if so, if it's restricted
+ def check_if_restricted
+ parent = find_parent
+
+ return unless parent.respond_to?(:restricted) && parent.restricted? && !(logged_in? || logged_in_as_admin?)
+ redirect_to new_user_session_path(restricted_commenting: true)
+ end
+
+ # Check to see if the ultimate_parent is a Work or AdminPost, and if so, if it allows
+ # comments for the current user.
+ def check_parent_comment_permissions
+ parent = find_parent
+ if parent.is_a?(Work)
+ translation_key = "work"
+ elsif parent.is_a?(AdminPost)
+ translation_key = "admin_post"
+ else
+ return
+ end
+
+ if parent.disable_all_comments?
+ flash[:error] = t("comments.commentable.permissions.#{translation_key}.disable_all")
+ redirect_to parent
+ elsif parent.disable_anon_comments? && !logged_in?
+ flash[:error] = t("comments.commentable.permissions.#{translation_key}.disable_anon")
+ redirect_to parent
+ end
+ end
+
+ def check_guest_comment_admin_setting
+ admin_settings = AdminSetting.current
+
+ return unless admin_settings.guest_comments_off? && guest?
+
+ flash[:error] = t("comments.commentable.guest_comments_disabled")
+ redirect_back(fallback_location: root_path)
+ end
+
+ def check_guest_replies_preference
+ return unless guest? && @commentable.respond_to?(:guest_replies_disallowed?) && @commentable.guest_replies_disallowed?
+
+ flash[:error] = t("comments.check_guest_replies_preference.error")
+ redirect_back(fallback_location: root_path)
+ end
+
+ def check_unreviewed
+ return unless @commentable.respond_to?(:unreviewed?) && @commentable.unreviewed?
+
+ flash[:error] = ts("Sorry, you cannot reply to an unapproved comment.")
+ redirect_to logged_in? ? root_path : new_user_session_path
+ end
+
+ def check_frozen
+ return unless @commentable.respond_to?(:iced?) && @commentable.iced?
+
+ flash[:error] = t("comments.check_frozen.error")
+ redirect_back(fallback_location: root_path)
+ end
+
+ def check_hidden_by_admin
+ return unless @commentable.respond_to?(:hidden_by_admin?) && @commentable.hidden_by_admin?
+
+ flash[:error] = t("comments.check_hidden_by_admin.error")
+ redirect_back(fallback_location: root_path)
+ end
+
+ def check_not_replying_to_spam
+ return unless @commentable.respond_to?(:approved?) && !@commentable.approved?
+
+ flash[:error] = t("comments.check_not_replying_to_spam.error")
+ redirect_back(fallback_location: root_path)
+ end
+
+ def check_permission_to_review
+ parent = find_parent
+ return if logged_in_as_admin? || current_user_owns?(parent)
+ flash[:error] = ts("Sorry, you don't have permission to see those unreviewed comments.")
+ redirect_to logged_in? ? root_path : new_user_session_path
+ end
+
+ def check_permission_to_access_single_unreviewed
+ return unless @comment.unreviewed?
+ parent = find_parent
+ return if logged_in_as_admin? || current_user_owns?(parent) || current_user_owns?(@comment)
+ flash[:error] = ts("Sorry, that comment is currently in moderation.")
+ redirect_to logged_in? ? root_path : new_user_session_path
+ end
+
+ def check_permission_to_moderate
+ parent = find_parent
+ unless logged_in_as_admin? || current_user_owns?(parent)
+ flash[:error] = ts("Sorry, you don't have permission to moderate that comment.")
+ redirect_to(logged_in? ? root_path : new_user_session_path)
+ end
+ end
+
+ def check_tag_wrangler_access
+ if @commentable.is_a?(Tag) || (@comment&.parent&.is_a?(Tag))
+ logged_in_as_admin? || permit?("tag_wrangler") || access_denied
+ end
+ end
+
+ # Must be able to delete other people's comments on owned works, not just owned comments!
+ def check_permission_to_delete
+ access_denied(redirect: @comment) unless logged_in_as_admin? || current_user_owns?(@comment) || current_user_owns?(@comment.ultimate_parent)
+ end
+
+ # Comments cannot be edited after they've been replied to or if they are frozen.
+ def check_permission_to_edit
+ if @comment&.iced?
+ flash[:error] = t("comments.check_permission_to_edit.error.frozen")
+ redirect_back(fallback_location: root_path)
+ elsif !@comment&.count_all_comments&.zero?
+ flash[:error] = ts("Comments with replies cannot be edited")
+ redirect_back(fallback_location: root_path)
+ end
+ end
+
+ # Comments on works can be frozen or unfrozen by admins with proper
+ # authorization or the work creator.
+ # Comments on tags can be frozen or unfrozen by admins with proper
+ # authorization.
+ # Comments on admin posts can be frozen or unfrozen by any admin.
+ def check_permission_to_modify_frozen_status
+ return if permission_to_modify_frozen_status
+
+ # i18n-tasks-use t('comments.freeze.permission_denied')
+ # i18n-tasks-use t('comments.unfreeze.permission_denied')
+ flash[:error] = t("comments.#{action_name}.permission_denied")
+ redirect_back(fallback_location: root_path)
+ end
+
+ def check_permission_to_modify_hidden_status
+ return if policy(@comment).can_hide_comment?
+
+ # i18n-tasks-use t('comments.hide.permission_denied')
+ # i18n-tasks-use t('comments.unhide.permission_denied')
+ flash[:error] = t("comments.#{action_name}.permission_denied")
+ redirect_back(fallback_location: root_path)
+ end
+
+ # Get the thing the user is trying to comment on
+ def load_commentable
+ @thread_view = false
+ if params[:comment_id]
+ @thread_view = true
+ if params[:id]
+ @commentable = Comment.find(params[:id])
+ @thread_root = Comment.find(params[:comment_id])
+ else
+ @commentable = Comment.find(params[:comment_id])
+ @thread_root = @commentable
+ end
+ elsif params[:chapter_id]
+ @commentable = Chapter.find(params[:chapter_id])
+ elsif params[:work_id]
+ @commentable = Work.find(params[:work_id])
+ elsif params[:admin_post_id]
+ @commentable = AdminPost.find(params[:admin_post_id])
+ elsif params[:tag_id]
+ @commentable = Tag.find_by_name(params[:tag_id])
+ @page_subtitle = @commentable.try(:name)
+ end
+ end
+
+ def index
+ return raise_not_found if @commentable.blank?
+
+ return unless @commentable.class == Comment
+
+ # we link to the parent object at the top
+ @commentable = @commentable.ultimate_parent
+ end
+
+ def unreviewed
+ @comments = @commentable.find_all_comments
+ .unreviewed_only
+ .for_display
+ .page(params[:page])
+ end
+
+ # GET /comments/1
+ # GET /comments/1.xml
+ def show
+ @comments = CommentDecorator.wrap_comments([@comment])
+ @thread_view = true
+ @thread_root = @comment
+ params[:comment_id] = params[:id]
+ end
+
+ # GET /comments/new
+ def new
+ if @commentable.nil?
+ flash[:error] = ts("What did you want to comment on?")
+ redirect_back_or_default(root_path)
+ else
+ @comment = Comment.new
+ @controller_name = params[:controller_name] if params[:controller_name]
+ @name =
+ case @commentable.class.name
+ when /Work/
+ @commentable.title
+ when /Chapter/
+ @commentable.work.title
+ when /Tag/
+ @commentable.name
+ when /AdminPost/
+ @commentable.title
+ when /Comment/
+ ts("Previous Comment")
+ else
+ @commentable.class.name
+ end
+ end
+ end
+
+ # GET /comments/1/edit
+ def edit
+ respond_to do |format|
+ format.html
+ format.js
+ end
+ end
+
+ # POST /comments
+ # POST /comments.xml
+ def create
+ if @commentable.nil?
+ flash[:error] = ts("What did you want to comment on?")
+ redirect_back_or_default(root_path)
+ else
+ @comment = Comment.new(comment_params)
+ @comment.ip_address = request.remote_ip
+ @comment.user_agent = request.env["HTTP_USER_AGENT"]&.to(499)
+ @comment.commentable = Comment.commentable_object(@commentable)
+ @controller_name = params[:controller_name]
+
+ # First, try saving the comment
+ if @comment.save
+ flash[:comment_notice] = if @comment.unreviewed?
+ # i18n-tasks-use t("comments.create.success.moderated.admin_post")
+ # i18n-tasks-use t("comments.create.success.moderated.work")
+ t("comments.create.success.moderated.#{@comment.ultimate_parent.model_name.i18n_key}")
+ else
+ t("comments.create.success.not_moderated")
+ end
+ respond_to do |format|
+ format.html do
+ if request.referer&.match(/inbox/)
+ redirect_to user_inbox_path(current_user, filters: filter_params[:filters], page: params[:page])
+ elsif request.referer&.match(/new/) || (@comment.unreviewed? && current_user)
+ # If the referer is the new comment page, go to the comment's page
+ # instead of reloading the full work.
+ # If the comment is unreviewed and commenter is logged in, take
+ # them to the comment's page so they can access the edit and
+ # delete options for the comment, since unreviewed comments don't
+ # appear on the commentable.
+ redirect_to comment_path(@comment)
+ elsif request.referer == root_url
+ # replying on the homepage
+ redirect_to root_path
+ elsif @comment.unreviewed?
+ redirect_to_all_comments(@commentable)
+ else
+ redirect_to_comment(@comment, { view_full_work: (params[:view_full_work] == "true"), page: params[:page] })
+ end
+ end
+ end
+ else
+ flash[:error] = ts("Couldn't save comment!")
+ render action: "new"
+ end
+ end
+ end
+
+ # PUT /comments/1
+ # PUT /comments/1.xml
+ def update
+ updated_comment_params = comment_params.merge(edited_at: Time.current)
+ if @comment.update(updated_comment_params)
+ flash[:comment_notice] = ts('Comment was successfully updated.')
+ respond_to do |format|
+ format.html do
+ redirect_to comment_path(@comment) and return if @comment.unreviewed?
+ redirect_to_comment(@comment)
+ end
+ format.js # updating the comment in place
+ end
+ else
+ render action: "edit"
+ end
+ end
+
+ # DELETE /comments/1
+ # DELETE /comments/1.xml
+ def destroy
+ authorize @comment if logged_in_as_admin?
+
+ parent = @comment.ultimate_parent
+ parent_comment = @comment.reply_comment? ? @comment.commentable : nil
+ unreviewed = @comment.unreviewed?
+
+ if !@comment.destroy_or_mark_deleted
+ # something went wrong?
+ flash[:comment_error] = ts("We couldn't delete that comment.")
+ redirect_to_comment(@comment)
+ elsif unreviewed
+ # go back to the rest of the unreviewed comments
+ flash[:notice] = ts("Comment deleted.")
+ redirect_back(fallback_location: unreviewed_work_comments_path(@comment.commentable))
+ elsif parent_comment
+ flash[:comment_notice] = ts("Comment deleted.")
+ redirect_to_comment(parent_comment)
+ else
+ flash[:comment_notice] = ts("Comment deleted.")
+ redirect_to_all_comments(parent, {show_comments: true})
+ end
+ end
+
+ def review
+ if logged_in_as_admin?
+ authorize @comment
+ else
+ return unless current_user_owns?(@comment.ultimate_parent)
+ end
+
+ return unless @comment&.unreviewed?
+
+ @comment.toggle!(:unreviewed)
+ # mark associated inbox comments as read
+ InboxComment.where(user_id: current_user.id, feedback_comment_id: @comment.id).update_all(read: true) unless logged_in_as_admin?
+ flash[:notice] = ts("Comment approved.")
+ respond_to do |format|
+ format.html do
+ if params[:approved_from] == "inbox"
+ redirect_to user_inbox_path(current_user, page: params[:page], filters: filter_params[:filters])
+ elsif params[:approved_from] == "home"
+ redirect_to root_path
+ elsif @comment.ultimate_parent.is_a?(AdminPost)
+ redirect_to unreviewed_admin_post_comments_path(@comment.ultimate_parent)
+ else
+ redirect_to unreviewed_work_comments_path(@comment.ultimate_parent)
+ end
+ return
+ end
+ format.js
+ end
+ end
+
+ def review_all
+ authorize @commentable, policy_class: CommentPolicy if logged_in_as_admin?
+ unless (@commentable && current_user_owns?(@commentable)) || (@commentable && logged_in_as_admin? && @commentable.is_a?(AdminPost))
+ flash[:error] = ts("What did you want to review comments on?")
+ redirect_back_or_default(root_path)
+ return
+ end
+
+ @comments = @commentable.find_all_comments.unreviewed_only
+ @comments.each { |c| c.toggle!(:unreviewed) }
+ flash[:notice] = ts("All moderated comments approved.")
+ redirect_to @commentable
+ end
+
+ def approve
+ authorize @comment
+ @comment.mark_as_ham!
+ redirect_to_all_comments(@comment.ultimate_parent, show_comments: true)
+ end
+
+ def reject
+ authorize @comment if logged_in_as_admin?
+ @comment.mark_as_spam!
+ redirect_to_all_comments(@comment.ultimate_parent, show_comments: true)
+ end
+
+ # PUT /comments/1/freeze
+ def freeze
+ # TODO: When AO3-5939 is fixed, we can use
+ # comments = @comment.full_set
+ if @comment.iced?
+ flash[:comment_error] = t(".error")
+ else
+ comments = @comment.set_to_freeze_or_unfreeze
+ Comment.mark_all_frozen!(comments)
+ flash[:comment_notice] = t(".success")
+ end
+
+ redirect_to_all_comments(@comment.ultimate_parent, show_comments: true)
+ rescue StandardError
+ flash[:comment_error] = t(".error")
+ redirect_to_all_comments(@comment.ultimate_parent, show_comments: true)
+ end
+
+ # PUT /comments/1/unfreeze
+ def unfreeze
+ # TODO: When AO3-5939 is fixed, we can use
+ # comments = @comment.full_set
+ if @comment.iced?
+ comments = @comment.set_to_freeze_or_unfreeze
+ Comment.mark_all_unfrozen!(comments)
+ flash[:comment_notice] = t(".success")
+ else
+ flash[:comment_error] = t(".error")
+ end
+
+ redirect_to_all_comments(@comment.ultimate_parent, show_comments: true)
+ rescue StandardError
+ flash[:comment_error] = t(".error")
+ redirect_to_all_comments(@comment.ultimate_parent, show_comments: true)
+ end
+
+ # PUT /comments/1/hide
+ def hide
+ if !@comment.hidden_by_admin?
+ @comment.mark_hidden!
+ AdminActivity.log_action(current_admin, @comment, action: "hide comment")
+ flash[:comment_notice] = t(".success")
+ else
+ flash[:comment_error] = t(".error")
+ end
+ redirect_to_all_comments(@comment.ultimate_parent, show_comments: true)
+ end
+
+ # PUT /comments/1/unhide
+ def unhide
+ if @comment.hidden_by_admin?
+ @comment.mark_unhidden!
+ AdminActivity.log_action(current_admin, @comment, action: "unhide comment")
+ flash[:comment_notice] = t(".success")
+ else
+ flash[:comment_error] = t(".error")
+ end
+ redirect_to_all_comments(@comment.ultimate_parent, show_comments: true)
+ end
+
+ def show_comments
+ respond_to do |format|
+ format.html do
+ # if non-ajax it could mean sudden javascript failure OR being redirected from login
+ # so we're being extra-nice and preserving any intention to comment along with the show comments option
+ options = {show_comments: true}
+ options[:add_comment_reply_id] = params[:add_comment_reply_id] if params[:add_comment_reply_id]
+ options[:view_full_work] = params[:view_full_work] if params[:view_full_work]
+ options[:page] = params[:page]
+ redirect_to_all_comments(@commentable, options)
+ end
+
+ format.js do
+ @comments = CommentDecorator.for_commentable(@commentable, page: params[:page])
+ end
+ end
+ end
+
+ def hide_comments
+ respond_to do |format|
+ format.html do
+ redirect_to_all_comments(@commentable)
+ end
+ format.js
+ end
+ end
+
+ # If JavaScript is enabled, use add_comment_reply.js to load the reply form
+ # Otherwise, redirect to a comment view with the form already loaded
+ def add_comment_reply
+ @comment = Comment.new
+ respond_to do |format|
+ format.html do
+ options = {show_comments: true}
+ options[:controller] = @commentable.class.to_s.underscore.pluralize
+ options[:anchor] = "comment_#{params[:id]}"
+ options[:page] = params[:page]
+ options[:view_full_work] = params[:view_full_work]
+ if @thread_view
+ options[:id] = @thread_root
+ options[:add_comment_reply_id] = params[:id]
+ redirect_to_comment(@commentable, options)
+ else
+ options[:id] = @commentable.id # work, chapter or other stuff that is not a comment
+ options[:add_comment_reply_id] = params[:id]
+ redirect_to_all_comments(@commentable, options)
+ end
+ end
+ format.js { @commentable = Comment.find(params[:id]) }
+ end
+ end
+
+ def cancel_comment_reply
+ respond_to do |format|
+ format.html do
+ options = {}
+ options[:show_comments] = params[:show_comments] if params[:show_comments]
+ redirect_to_all_comments(@commentable, options)
+ end
+ format.js { @commentable = Comment.find(params[:id]) }
+ end
+ end
+
+ def cancel_comment_edit
+ respond_to do |format|
+ format.html { redirect_to_comment(@comment) }
+ format.js
+ end
+ end
+
+ def delete_comment
+ respond_to do |format|
+ format.html do
+ options = {}
+ options[:show_comments] = params[:show_comments] if params[:show_comments]
+ options[:delete_comment_id] = params[:id] if params[:id]
+ redirect_to_comment(@comment, options) # TO DO: deleting without javascript doesn't work and it never has!
+ end
+ format.js
+ end
+ end
+
+ def cancel_comment_delete
+ respond_to do |format|
+ format.html do
+ options = {}
+ options[:show_comments] = params[:show_comments] if params[:show_comments]
+ redirect_to_comment(@comment, options)
+ end
+ format.js
+ end
+ end
+
+ protected
+
+ # redirect to a particular comment in a thread, going into the thread
+ # if necessary to display it
+ def redirect_to_comment(comment, options = {})
+ if comment.depth > ArchiveConfig.COMMENT_THREAD_MAX_DEPTH
+ if comment.ultimate_parent.is_a?(Tag)
+ default_options = {
+ controller: :comments,
+ action: :show,
+ id: comment.commentable.id,
+ tag_id: comment.ultimate_parent.to_param,
+ anchor: "comment_#{comment.id}"
+ }
+ else
+ default_options = {
+ controller: comment.commentable.class.to_s.underscore.pluralize,
+ action: :show,
+ id: (comment.commentable.is_a?(Tag) ? comment.commentable.to_param : comment.commentable.id),
+ anchor: "comment_#{comment.id}"
+ }
+ end
+ # display the comment's direct parent (and its associated thread)
+ redirect_to(url_for(default_options.merge(options)))
+ else
+ # need to redirect to the specific chapter; redirect_to_all will then retrieve full work view if applicable
+ redirect_to_all_comments(comment.parent, options.merge({show_comments: true, anchor: "comment_#{comment.id}"}))
+ end
+ end
+
+ def redirect_to_all_comments(commentable, options = {})
+ default_options = {anchor: "comments"}
+ options = default_options.merge(options)
+
+ if commentable.is_a?(Tag)
+ redirect_to comments_path(tag_id: commentable.to_param,
+ add_comment_reply_id: options[:add_comment_reply_id],
+ delete_comment_id: options[:delete_comment_id],
+ page: options[:page],
+ anchor: options[:anchor])
+ else
+ if commentable.is_a?(Chapter) && (options[:view_full_work] || current_user.try(:preference).try(:view_full_works))
+ commentable = commentable.work
+ end
+ redirect_to polymorphic_path(commentable,
+ options.slice(:show_comments,
+ :add_comment_reply_id,
+ :delete_comment_id,
+ :view_full_work,
+ :anchor,
+ :page))
+ end
+ end
+
+ def permission_to_modify_frozen_status
+ parent = find_parent
+ return true if policy(@comment).can_freeze_comment?
+ return true if parent.is_a?(Work) && current_user_owns?(parent)
+
+ false
+ end
+
+ private
+
+ def comment_params
+ params.require(:comment).permit(
+ :pseud_id, :comment_content, :name, :email, :edited_at
+ )
+ end
+
+ def filter_params
+ params.permit!
+ end
+end
diff --git a/app/controllers/concerns/tag_wrangling.rb b/app/controllers/concerns/tag_wrangling.rb
new file mode 100755
index 0000000..b0e9f1b
--- /dev/null
+++ b/app/controllers/concerns/tag_wrangling.rb
@@ -0,0 +1,14 @@
+module TagWrangling
+ extend ActiveSupport::Concern
+
+ # When used an around_action, record_wrangling_activity will
+ # mark that some wrangling activity has occurred. If a Wrangleable
+ # is saved during the action, a LastWranglingActivity will be
+ # recorded. Otherwise no additional steps are taken.
+ def record_wrangling_activity
+ User.should_update_wrangling_activity = true
+ yield
+ ensure
+ User.should_update_wrangling_activity = false
+ end
+end
diff --git a/app/controllers/creatorships_controller.rb b/app/controllers/creatorships_controller.rb
new file mode 100755
index 0000000..e5ef053
--- /dev/null
+++ b/app/controllers/creatorships_controller.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+# A controller for viewing co-creator requests -- that is, creatorships where
+# the creator hasn't yet approved it.
+class CreatorshipsController < ApplicationController
+ before_action :load_user
+ before_action :check_ownership
+
+ # Show all of the creatorships associated with the current user. Displays a
+ # form where the user can select multiple creatorships and perform actions
+ # (accept, reject) in bulk.
+ def show
+ @page_subtitle = ts("Co-Creator Requests")
+ @creatorships = @creatorships.unapproved.order(id: :desc).
+ paginate(page: params[:page])
+ end
+
+ # Update the selected creatorships.
+ def update
+ @creatorships = @creatorships.where(id: params[:selected])
+
+ if params[:accept]
+ accept_update
+ elsif params[:reject]
+ reject_update
+ end
+
+ redirect_to user_creatorships_path(@user, page: params[:page])
+ end
+
+ private
+
+ # When the user presses "Accept" on the co-creator request listing, this is
+ # the code that runs.
+ def accept_update
+ flash[:notice] = []
+
+ @creatorships.each do |creatorship|
+ creatorship.accept!
+ link = view_context.link_to(title_for_creation(creatorship.creation),
+ creatorship.creation)
+ flash[:notice] << ts("You are now listed as a co-creator on %{link}.",
+ link: link).html_safe
+ end
+ end
+
+ # When the user presses "Reject" on the co-creator request listing, this is
+ # the code that runs. Note that rejection is equivalent to destroying the
+ # request.
+ def reject_update
+ @creatorships.each(&:destroy)
+ flash[:notice] = ts("Requests rejected.")
+ end
+
+ # A helper method used to display a nicely formatted title for a creation.
+ helper_method :title_for_creation
+ def title_for_creation(creation)
+ if creation.is_a?(Chapter)
+ "Chapter #{creation.position} of #{creation.work.title}"
+ else
+ creation.title
+ end
+ end
+
+ # Load the user, and set @creatorships equal to all co-creator requests for
+ # that user.
+ def load_user
+ @user = User.find_by!(login: params[:user_id])
+ @check_ownership_of = @user
+ @creatorships = Creatorship.unapproved.for_user(@user)
+ end
+end
diff --git a/app/controllers/downloads_controller.rb b/app/controllers/downloads_controller.rb
new file mode 100755
index 0000000..4fe5aa9
--- /dev/null
+++ b/app/controllers/downloads_controller.rb
@@ -0,0 +1,72 @@
+class DownloadsController < ApplicationController
+
+ skip_before_action :store_location, only: :show
+ before_action :load_work, only: :show
+ before_action :check_download_posted_status, only: :show
+ before_action :check_download_visibility, only: :show
+ around_action :remove_downloads, only: :show
+
+ def show
+ respond_to :html, :pdf, :mobi, :epub, :azw3
+ @download = Download.new(@work, mime_type: request.format)
+ @download.generate
+
+ # Make sure we were able to generate the download.
+ unless @download.exists?
+ flash[:error] = ts("We were not able to render this work. Please try again in a little while or try another format.")
+ redirect_to work_path(@work)
+ return
+ end
+
+ # Send file synchronously so we don't delete it before we have finished
+ # sending it
+ File.open(@download.file_path, 'r') do |f|
+ send_data f.read, filename: "#{@download.file_name}.#{@download.file_type}", type: @download.mime_type
+ end
+ end
+
+protected
+
+ # Set up the work and check revealed status
+ # Once a format has been created, we want nginx to be able to serve
+ # it directly, without going through Rails again (until the work changes).
+ # This means no processing per user. Consider this the "published" version.
+ # It can't contain unposted chapters, nor unrevealed creators, even
+ # if the creator is the one requesting the download.
+ def load_work
+ unless AdminSetting.current.downloads_enabled?
+ flash[:error] = ts("Sorry, downloads are currently disabled.")
+ redirect_back_or_default works_path
+ return
+ end
+
+ @work = Work.find(params[:id])
+ end
+
+ # We're currently just writing everything to tmp and feeding them through
+ # nginx so we don't want to keep the files around.
+ def remove_downloads
+ yield
+ ensure
+ @download.remove
+ end
+
+ # We can't use check_visibility because this controller doesn't have access to
+ # cookies on production or staging.
+ def check_download_visibility
+ return unless @work.hidden_by_admin || @work.in_unrevealed_collection?
+ message = if @work.hidden_by_admin
+ ts("Sorry, you can't download a work that has been hidden by an admin.")
+ else
+ ts("Sorry, you can't download an unrevealed work.")
+ end
+ flash[:error] = message
+ redirect_to work_path(@work)
+ end
+
+ def check_download_posted_status
+ return if @work.posted
+ flash[:error] = ts("Sorry, you can't download a draft.")
+ redirect_to work_path(@work)
+ end
+end
diff --git a/app/controllers/errors_controller.rb b/app/controllers/errors_controller.rb
new file mode 100755
index 0000000..c78a0df
--- /dev/null
+++ b/app/controllers/errors_controller.rb
@@ -0,0 +1,16 @@
+class ErrorsController < ApplicationController
+ %w[403 404 422 500].each do |error_code|
+ define_method error_code.to_sym do
+ render error_code, status: error_code.to_i, formats: :html
+ end
+ end
+
+ def auth_error
+ @page_subtitle = t(".browser_title")
+ end
+
+ def timeout_error
+ @page_subtitle = t(".browser_title")
+ render "timeout_error", status: :gateway_timeout
+ end
+end
diff --git a/app/controllers/external_authors_controller.rb b/app/controllers/external_authors_controller.rb
new file mode 100755
index 0000000..584f4e1
--- /dev/null
+++ b/app/controllers/external_authors_controller.rb
@@ -0,0 +1,104 @@
+class ExternalAuthorsController < ApplicationController
+ before_action :load_user
+ before_action :check_ownership, only: [:edit]
+ before_action :check_user_status, only: [:edit]
+ before_action :get_external_author_from_invitation, only: [:claim, :complete_claim]
+ before_action :users_only, only: [:complete_claim]
+
+ def load_user
+ @user = User.find_by(login: params[:user_id])
+ @check_ownership_of = @user
+ end
+
+ def index
+ if @user && current_user == @user
+ @external_authors = @user.external_authors
+ elsif logged_in? && current_user.archivist
+ @external_authors = ExternalCreatorship.where(archivist_id: current_user).collect(&:external_author).uniq
+ elsif logged_in?
+ redirect_to user_external_authors_path(current_user)
+ else
+ flash[:notice] = "You can't see that information."
+ redirect_to root_path
+ end
+ end
+
+ def edit
+ @external_author = ExternalAuthor.find(params[:id])
+ end
+
+ def get_external_author_from_invitation
+ token = params[:invitation_token] || (params[:user] && params[:user][:invitation_token])
+ @invitation = Invitation.find_by(token: token)
+ unless @invitation
+ flash[:error] = ts("You need an invitation to do that.")
+ redirect_to root_path
+ return
+ end
+
+ @external_author = @invitation.external_author
+ return if @external_author
+ flash[:error] = ts("There are no stories to claim on this invitation. Did you want to sign up instead?")
+ redirect_to signup_path(@invitation.token)
+ end
+
+ def claim
+ end
+
+ def complete_claim
+ # go ahead and give the user the works
+ @external_author.claim!(current_user)
+ @invitation.mark_as_redeemed(current_user) if @invitation
+ flash[:notice] = t('external_author_claimed', default: "We have added the stories imported under %{email} to your account.", email: @external_author.email)
+ redirect_to user_external_authors_path(current_user)
+ end
+
+ def update
+ @invitation = Invitation.find_by(token: params[:invitation_token])
+ @external_author = ExternalAuthor.find(params[:id])
+ unless (@invitation && @invitation.external_author == @external_author) || @external_author.user == current_user
+ flash[:error] = "You don't have permission to do that."
+ redirect_to root_path
+ return
+ end
+
+ flash[:notice] = ""
+ if params[:imported_stories] == "nothing"
+ flash[:notice] += "Okay, we'll leave things the way they are! You can use the email link any time if you change your mind. "
+ elsif params[:imported_stories] == "orphan"
+ # orphan the works
+ @external_author.orphan(params[:remove_pseud])
+ flash[:notice] += "Your imported stories have been orphaned. Thank you for leaving them in the archive! "
+ elsif params[:imported_stories] == "delete"
+ # delete the works
+ @external_author.delete_works
+ flash[:notice] += "Your imported stories have been deleted. "
+ end
+
+ if @invitation &&
+ params[:imported_stories].present? &&
+ params[:imported_stories] != "nothing"
+ @invitation.mark_as_redeemed
+ end
+
+ if @external_author.update(external_author_params[:external_author] || {})
+ flash[:notice] += "Your preferences have been saved."
+ redirect_to @user ? user_external_authors_path(@user) : root_path
+ else
+ flash[:error] = "There were problems saving your preferences."
+ render action: "edit"
+ end
+ end
+
+ private
+
+ def external_author_params
+ params.permit(
+ :id, :user_id, :utf8, :_method, :authenticity_token, :invitation_token,
+ :imported_stories, :commit, :remove_pseud,
+ external_author: [
+ :email, :do_not_email, :do_not_import
+ ]
+ )
+ end
+end
diff --git a/app/controllers/external_works_controller.rb b/app/controllers/external_works_controller.rb
new file mode 100755
index 0000000..d40b6db
--- /dev/null
+++ b/app/controllers/external_works_controller.rb
@@ -0,0 +1,69 @@
+class ExternalWorksController < ApplicationController
+ before_action :admin_only, only: [:edit, :update]
+ before_action :users_only, only: [:new]
+ before_action :check_user_status, only: [:new]
+
+ def new
+ @bookmarkable = ExternalWork.new
+ @bookmark = Bookmark.new
+ end
+
+ # Used with bookmark form to get an existing external work and return it via ajax
+ def fetch
+ if params[:external_work_url]
+ url = Addressable::URI.heuristic_parse(params[:external_work_url]).to_str
+ @external_work = ExternalWork.where(url: url).first
+ end
+ respond_to do |format|
+ format.js
+ end
+ end
+
+ def index
+ if params[:show] == "duplicates"
+ unless logged_in_as_admin?
+ access_denied
+ return
+ end
+
+ @external_works = ExternalWork.duplicate.order("created_at DESC").paginate(page: params[:page])
+ else
+ @external_works = ExternalWork.order("created_at DESC").paginate(page: params[:page])
+ end
+ end
+
+ def show
+ @external_work = ExternalWork.find(params[:id])
+ end
+
+ def edit
+ @external_work = authorize ExternalWork.find(params[:id])
+ @work = @external_work
+ end
+
+ def update
+ @external_work = authorize ExternalWork.find(params[:id])
+ @external_work.attributes = work_params
+ if @external_work.update(external_work_params)
+ flash[:notice] = t(".successfully_updated")
+ redirect_to(@external_work)
+ else
+ render action: "edit"
+ end
+ end
+
+ private
+
+ def external_work_params
+ params.require(:external_work).permit(
+ :url, :author, :title, :summary, :language_id
+ )
+ end
+
+ def work_params
+ params.require(:work).permit(
+ :rating_string, :fandom_string, :relationship_string, :character_string,
+ :freeform_string, category_strings: [], archive_warning_strings: []
+ )
+ end
+end
diff --git a/app/controllers/fandoms_controller.rb b/app/controllers/fandoms_controller.rb
new file mode 100755
index 0000000..9440ec2
--- /dev/null
+++ b/app/controllers/fandoms_controller.rb
@@ -0,0 +1,56 @@
+class FandomsController < ApplicationController
+ before_action :load_collection
+
+ def index
+ if @collection
+ @media = Media.canonical.by_name - [Media.find_by(name: ArchiveConfig.MEDIA_NO_TAG_NAME)] - [Media.find_by(name: ArchiveConfig.MEDIA_UNCATEGORIZED_NAME)]
+ @page_subtitle = t(".collection_page_title", collection_title: @collection.title)
+ @medium = Media.find_by_name(params[:media_id]) if params[:media_id]
+ @counts = SearchCounts.fandom_ids_for_collection(@collection)
+ @fandoms = (@medium ? @medium.fandoms : Fandom.all).where(id: @counts.keys).by_name
+ elsif params[:media_id]
+ @medium = Media.find_by_name!(params[:media_id])
+ @page_subtitle = @medium.name
+ @fandoms = if @medium == Media.uncategorized
+ @medium.fandoms.in_use.by_name
+ else
+ @medium.fandoms.canonical.by_name.with_count
+ end
+ else
+ flash[:notice] = t(".choose_media")
+ redirect_to media_index_path and return
+ end
+ @fandoms_by_letter = @fandoms.group_by { |f| f.sortable_name[0].upcase }
+ end
+
+ def show
+ @fandom = Fandom.find_by_name(params[:id])
+ if @fandom.nil?
+ flash[:error] = ts("Could not find fandom named %{fandom_name}", fandom_name: params[:id])
+ redirect_to media_index_path and return
+ end
+ @characters = @fandom.characters.canonical.by_name
+ end
+
+ def unassigned
+ join_string = "LEFT JOIN wrangling_assignments
+ ON (wrangling_assignments.fandom_id = tags.id)
+ LEFT JOIN users
+ ON (users.id = wrangling_assignments.user_id)"
+ conditions = "canonical = 1 AND users.id IS NULL"
+ unless params[:media_id].blank?
+ @media = Media.find_by_name(params[:media_id])
+ if @media
+ join_string << " INNER JOIN common_taggings
+ ON (tags.id = common_taggings.common_tag_id)"
+ conditions << " AND common_taggings.filterable_id = #{@media.id}
+ AND common_taggings.filterable_type = 'Tag'"
+ end
+ end
+ @fandoms = Fandom.joins(join_string).
+ where(conditions).
+ order(params[:sort] == 'count' ? "count DESC" : "sortable_name ASC").
+ with_count.
+ paginate(page: params[:page], per_page: 250)
+ end
+end
diff --git a/app/controllers/favorite_tags_controller.rb b/app/controllers/favorite_tags_controller.rb
new file mode 100755
index 0000000..c77ed12
--- /dev/null
+++ b/app/controllers/favorite_tags_controller.rb
@@ -0,0 +1,52 @@
+class FavoriteTagsController < ApplicationController
+ skip_before_action :store_location, only: [:create, :destroy]
+ before_action :users_only
+ before_action :load_user
+ before_action :check_ownership
+
+ respond_to :html, :json
+
+ # POST /favorites_tags
+ def create
+ @favorite_tag = current_user.favorite_tags.build(favorite_tag_params)
+ success_message = ts("You have successfully added %{tag_name} to your favorite tags. You can find them on the Archive homepage.", tag_name: @favorite_tag.tag_name)
+ if @favorite_tag.save
+ respond_to do |format|
+ format.html { redirect_to tag_works_path(tag_id: @favorite_tag.tag.to_param), notice: success_message.html_safe }
+ format.json { render json: { item_id: @favorite_tag.id, item_success_message: success_message }, status: :created }
+ end
+ else
+ respond_to do |format|
+ format.html do
+ flash.keep
+ redirect_to tag_works_path(tag_id: @favorite_tag.tag.to_param), flash: { error: @favorite_tag.errors.full_messages }
+ end
+ format.json { render json: { errors: @favorite_tag.errors.full_messages }, status: :unprocessable_entity }
+ end
+ end
+ end
+
+ # DELETE /favorite_tags/1
+ def destroy
+ @favorite_tag = FavoriteTag.find(params[:id])
+ @favorite_tag.destroy
+ success_message = ts('You have successfully removed %{tag_name} from your favorite tags.', tag_name: @favorite_tag.tag_name)
+ respond_to do |format|
+ format.html { redirect_to tag_works_path(tag_id: @favorite_tag.tag.to_param), notice: success_message }
+ format.json { render json: { item_success_message: success_message }, status: :ok }
+ end
+ end
+
+ private
+
+ def load_user
+ @user = User.find_by(login: params[:user_id])
+ @check_ownership_of = @user
+ end
+
+ def favorite_tag_params
+ params.require(:favorite_tag).permit(
+ :tag_id
+ )
+ end
+end
diff --git a/app/controllers/feedbacks_controller.rb b/app/controllers/feedbacks_controller.rb
new file mode 100755
index 0000000..1ce99c0
--- /dev/null
+++ b/app/controllers/feedbacks_controller.rb
@@ -0,0 +1,48 @@
+class FeedbacksController < ApplicationController
+ skip_before_action :store_location
+ before_action :load_support_languages
+
+ def new
+ @admin_setting = AdminSetting.current
+ @feedback = Feedback.new
+ @feedback.referer = request.referer
+ if logged_in_as_admin?
+ @feedback.email = current_admin.email
+ elsif is_registered_user?
+ @feedback.email = current_user.email
+ @feedback.username = current_user.login
+ end
+ end
+
+ def create
+ @admin_setting = AdminSetting.current
+ @feedback = Feedback.new(feedback_params)
+ @feedback.rollout = @feedback.rollout_string
+ @feedback.user_agent = request.env["HTTP_USER_AGENT"]&.to(499)
+ @feedback.ip_address = request.remote_ip
+ @feedback.referer = nil unless @feedback.referer && ArchiveConfig.PERMITTED_HOSTS.include?(URI(@feedback.referer).host)
+ @feedback.site_skin = helpers.current_skin
+ if @feedback.save
+ @feedback.email_and_send
+ flash[:notice] = t("successfully_sent",
+ default: "Your message was sent to the Archive team - thank you!")
+ redirect_back_or_default(root_path)
+ else
+ flash[:error] = t("failure_send",
+ default: "Sorry, your message could not be saved - please try again!")
+ render action: "new"
+ end
+ end
+
+ private
+
+ def load_support_languages
+ @support_languages = Language.where(support_available: true).default_order
+ end
+
+ def feedback_params
+ params.require(:feedback).permit(
+ :comment, :email, :summary, :username, :language, :referer
+ )
+ end
+end
diff --git a/app/controllers/gifts_controller.rb b/app/controllers/gifts_controller.rb
new file mode 100755
index 0000000..87a11a9
--- /dev/null
+++ b/app/controllers/gifts_controller.rb
@@ -0,0 +1,61 @@
+class GiftsController < ApplicationController
+
+ before_action :load_collection
+
+ def index
+ @user = User.find_by!(login: params[:user_id]) if params[:user_id]
+ @recipient_name = params[:recipient]
+ @page_subtitle = ts("Gifts for %{name}", name: (@user ? @user.login : @recipient_name))
+ unless @user || @recipient_name
+ flash[:error] = ts("Whose gifts did you want to see?")
+ redirect_to(@collection || root_path) and return
+ end
+ if @user
+ if current_user.nil?
+ @works = @user.gift_works.visible_to_all
+ else
+ if @user == current_user && params[:refused]
+ @works = @user.rejected_gift_works.visible_to_registered_user
+ else
+ @works = @user.gift_works.visible_to_registered_user
+ end
+ end
+ else
+ pseud = Pseud.parse_byline(@recipient_name)
+ if pseud
+ if current_user.nil?
+ @works = pseud.gift_works.visible_to_all
+ else
+ @works = pseud.gift_works.visible_to_registered_user
+ end
+ else
+ if current_user.nil?
+ @works = Work.giftworks_for_recipient_name(@recipient_name).visible_to_all
+ else
+ @works = Work.giftworks_for_recipient_name(@recipient_name).visible_to_registered_user
+ end
+ end
+ end
+ @works = @works.in_collection(@collection) if @collection
+ @works = @works.order('revised_at DESC').paginate(page: params[:page], per_page: ArchiveConfig.ITEMS_PER_PAGE)
+ end
+
+ def toggle_rejected
+ @gift = Gift.find(params[:id])
+ # have to have the gift, be logged in, and the owner of the gift
+ if @gift && current_user && @gift.user == current_user
+ @gift.rejected = !@gift.rejected?
+ @gift.save!
+ if @gift.rejected?
+ flash[:notice] = ts("This work will no longer be listed among your gifts.")
+ else
+ flash[:notice] = ts("This work will now be listed among your gifts.")
+ end
+ else
+ # user doesn't have permission
+ access_denied
+ return
+ end
+ redirect_to user_gifts_path(current_user) and return
+ end
+end
diff --git a/app/controllers/hit_count_controller.rb b/app/controllers/hit_count_controller.rb
new file mode 100755
index 0000000..976cc09
--- /dev/null
+++ b/app/controllers/hit_count_controller.rb
@@ -0,0 +1,31 @@
+# This controller is exclusively used to track hit counts on works.
+#
+# Note that we deliberately only accept JSON requests because JSON requests
+# bypass a lot of the usual before_action hooks, many of which can trigger
+# database queries. We want this action to avoid hitting the database if at
+# all possible.
+class HitCountController < ApplicationController
+ skip_around_action :set_current_user
+
+ skip_before_action :logout_if_not_user_credentials
+
+ skip_after_action :ensure_user_credentials,
+ :ensure_admin_credentials
+
+ def create
+ respond_to do |format|
+ format.json do
+ if ENV["REQUEST_FROM_BOT"]
+ head :forbidden
+ else
+ RedisHitCounter.add(
+ params[:work_id].to_i,
+ request.remote_ip
+ )
+
+ head :ok
+ end
+ end
+ end
+ end
+end
diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb
new file mode 100755
index 0000000..6b82518
--- /dev/null
+++ b/app/controllers/home_controller.rb
@@ -0,0 +1,87 @@
+class HomeController < ApplicationController
+
+ before_action :users_only, only: [:first_login_help]
+ skip_before_action :store_location, only: [:first_login_help, :token_dispenser]
+
+ # unicorn_test
+ def unicorn_test
+ end
+
+ def content
+ @page_subtitle = t(".page_title")
+ render action: "content", layout: "application"
+ end
+
+ def privacy
+ @page_subtitle = t(".page_title")
+ render action: "privacy", layout: "application"
+ end
+
+ # terms of service
+ def tos
+ @page_subtitle = t(".page_title")
+ render action: "tos", layout: "application"
+ end
+
+ # terms of service faq
+ def tos_faq
+ @page_subtitle = t(".page_title")
+ render action: "tos_faq", layout: "application"
+ end
+
+ # dmca policy
+ def dmca
+ render action: "dmca", layout: "application"
+ end
+
+ # lost cookie
+ def lost_cookie
+ render action: 'lost_cookie', layout: 'application'
+ end
+
+ # for updating form tokens on cached pages
+ def token_dispenser
+ respond_to do |format|
+ format.json { render json: { token: form_authenticity_token } }
+ end
+ end
+
+ # diversity statement
+ def diversity
+ render action: "diversity_statement", layout: "application"
+ end
+
+ # site map
+ def site_map
+ render action: "site_map", layout: "application"
+ end
+
+ # donate
+ def donate
+ @page_subtitle = t(".page_title")
+ render action: "donate", layout: "application"
+ end
+
+ # about
+ def about
+ @page_subtitle = t(".page_title")
+ render action: "about", layout: "application"
+ end
+
+ def first_login_help
+ render action: "first_login_help", layout: false
+ end
+
+ # home page itself
+ def index
+ @homepage = Homepage.new(@current_user)
+ @random_user = User.unscoped.order(Arel.sql("RAND()")).first
+ unless @homepage.logged_in?
+ @user_count, @work_count, @fandom_count = @homepage.rounded_counts
+ end
+
+ @hide_dashboard = true
+ render action: 'index', layout: 'application'
+end
+end
+
diff --git a/app/controllers/inbox_controller.rb b/app/controllers/inbox_controller.rb
new file mode 100755
index 0000000..26f71b2
--- /dev/null
+++ b/app/controllers/inbox_controller.rb
@@ -0,0 +1,75 @@
+class InboxController < ApplicationController
+ include BlockHelper
+
+ before_action :load_user
+ before_action :check_ownership_or_admin
+
+ before_action :load_commentable, only: :reply
+ before_action :check_blocked, only: :reply
+
+ def load_user
+ @user = User.find_by(login: params[:user_id])
+ @check_ownership_of = @user
+ end
+
+ def show
+ authorize InboxComment if logged_in_as_admin?
+ @page_subtitle = t(".page_title", user: @user.login)
+ @inbox_total = @user.inbox_comments.with_bad_comments_removed.count
+ @unread = @user.inbox_comments.with_bad_comments_removed.count_unread
+ @filters = filter_params[:filters] || {}
+ @inbox_comments = @user.inbox_comments.with_bad_comments_removed.find_by_filters(@filters).page(params[:page])
+ end
+
+ def reply
+ @comment = Comment.new
+ respond_to do |format|
+ format.html do
+ redirect_to comment_path(@commentable, add_comment_reply_id: @commentable.id, anchor: 'comment_' + @commentable.id.to_s)
+ end
+ format.js
+ end
+ end
+
+ def update
+ authorize InboxComment if logged_in_as_admin?
+ begin
+ @inbox_comments = InboxComment.find(params[:inbox_comments])
+ if params[:read]
+ @inbox_comments.each { |i| i.update_attribute(:read, true) }
+ elsif params[:unread]
+ @inbox_comments.each { |i| i.update_attribute(:read, false) }
+ elsif params[:delete]
+ @inbox_comments.each { |i| i.destroy }
+ end
+ success_message = t(".success")
+ rescue
+ flash[:caution] = t(".must_select_item")
+ end
+ respond_to do |format|
+ format.html { redirect_to request.referer || user_inbox_path(@user, page: params[:page], filters: params[:filters]), notice: success_message }
+ format.json { render json: { item_success_message: success_message }, status: :ok }
+ end
+ end
+
+ private
+
+ # Allow flexible params through, since we're not posting any data
+ def filter_params
+ params.permit!
+ end
+
+ def load_commentable
+ @commentable = Comment.find(params[:comment_id])
+ end
+
+ def check_blocked
+ if blocked_by?(@commentable.ultimate_parent)
+ flash[:error] = t("comments.check_blocked.parent")
+ redirect_back(fallback_location: user_inbox_path(@user))
+ elsif blocked_by_comment?(@commentable)
+ flash[:error] = t("comments.check_blocked.reply")
+ redirect_back(fallback_location: user_inbox_path(@user))
+ end
+ end
+end
diff --git a/app/controllers/invitations_controller.rb b/app/controllers/invitations_controller.rb
new file mode 100755
index 0000000..42df0c2
--- /dev/null
+++ b/app/controllers/invitations_controller.rb
@@ -0,0 +1,93 @@
+class InvitationsController < ApplicationController
+ before_action :check_permission
+ before_action :admin_only, only: [:create, :destroy]
+ before_action :check_user_status, only: [:index, :manage, :invite_friend, :update]
+ before_action :load_invitation, only: [:show, :invite_friend, :update, :destroy]
+ before_action :check_ownership_or_admin, only: [:show, :invite_friend, :update]
+
+ def load_invitation
+ @invitation = Invitation.find(params[:id] || invitation_params[:id])
+ @check_ownership_of = @invitation
+ end
+
+ def check_permission
+ @user = User.find_by(login: params[:user_id])
+ access_denied unless policy(User).can_manage_users? || @user.present? && @user == current_user
+ end
+
+ def index
+ @unsent_invitations = @user.invitations.unsent.limit(5)
+ end
+
+ def manage
+ status = params[:status]
+ @invitations = @user.invitations
+ if %w(unsent unredeemed redeemed).include?(status)
+ @invitations = @invitations.send(status)
+ end
+ end
+
+ def show
+ end
+
+ def invite_friend
+ if !invitation_params[:invitee_email].blank?
+ @invitation.invitee_email = invitation_params[:invitee_email]
+ if @invitation.save
+ flash[:notice] = 'Invitation was successfully sent.'
+ redirect_to([@user, @invitation])
+ else
+ render action: "show"
+ end
+ else
+ flash[:error] = "Please enter an email address."
+ render action: "show"
+ end
+ end
+
+ def create
+ if invitation_params[:number_of_invites].to_i > 0
+ invitation_params[:number_of_invites].to_i.times do
+ @user.invitations.create
+ end
+ end
+ flash[:notice] = "Invitations were successfully created."
+ redirect_to user_invitations_path(@user)
+ end
+
+ def update
+ @invitation.attributes = invitation_params
+
+ if @invitation.invitee_email_changed? && @invitation.update(invitation_params)
+ flash[:notice] = 'Invitation was successfully sent.'
+ if logged_in_as_admin?
+ redirect_to find_admin_invitations_path("invitation[token]" => @invitation.token)
+ else
+ redirect_to([@user, @invitation])
+ end
+ else
+ flash[:error] = "Please enter an email address." if @invitation.invitee_email.blank?
+ render action: "show"
+ end
+ end
+
+ def destroy
+ @user = @invitation.creator
+ if @invitation.destroy
+ flash[:notice] = "Invitation successfully destroyed"
+ else
+ flash[:error] = "Invitation was not destroyed."
+ end
+ if @user.is_a?(User)
+ redirect_to user_invitations_path(@user)
+ else
+ redirect_to admin_invitations_path
+ end
+ end
+
+ private
+
+ def invitation_params
+ params.require(:invitation).permit(:id, :invitee_email, :number_of_invites)
+ end
+end
diff --git a/app/controllers/invite_requests_controller.rb b/app/controllers/invite_requests_controller.rb
new file mode 100755
index 0000000..9789f59
--- /dev/null
+++ b/app/controllers/invite_requests_controller.rb
@@ -0,0 +1,124 @@
+class InviteRequestsController < ApplicationController
+ before_action :admin_only, only: [:manage, :destroy]
+
+ # GET /invite_requests
+ # Set browser page title to Invitation Requests
+ def index
+ @invite_request = InviteRequest.new
+ @page_subtitle = t(".page_title")
+ end
+
+ # GET /invite_requests/1
+ def show
+ @invite_request = InviteRequest.find_by(email: params[:email])
+
+ if @invite_request.present?
+ @position_in_queue = @invite_request.position
+ else
+ @invitation = Invitation.unredeemed.from_queue.find_by(invitee_email: params[:email])
+ end
+
+ respond_to do |format|
+ format.html
+ format.js
+ end
+ end
+
+ def resend
+ @invitation = Invitation.unredeemed.from_queue.find_by(invitee_email: params[:email])
+
+ if @invitation.nil?
+ flash[:error] = t("invite_requests.resend.not_found")
+ elsif !@invitation.can_resend?
+ flash[:error] = t("invite_requests.resend.not_yet",
+ count: ArchiveConfig.HOURS_BEFORE_RESEND_INVITATION)
+ else
+ @invitation.send_and_set_date(resend: true)
+
+ if @invitation.errors.any?
+ flash[:error] = @invitation.errors.full_messages.first
+ else
+ flash[:notice] = t("invite_requests.resend.success", email: @invitation.invitee_email)
+ end
+ end
+
+ redirect_to status_invite_requests_path
+ end
+
+ # POST /invite_requests
+ def create
+ unless AdminSetting.current.invite_from_queue_enabled?
+ flash[:error] = t(".queue_disabled.html",
+ closed_bold: helpers.tag.strong(t("invite_requests.create.queue_disabled.closed")),
+ news_link: helpers.link_to(t("invite_requests.create.queue_disabled.news"), admin_posts_path(tag: 143)))
+ redirect_to invite_requests_path
+ return
+ end
+
+ @invite_request = InviteRequest.new(invite_request_params)
+ @invite_request.ip_address = request.remote_ip
+ if @invite_request.save
+ flash[:notice] = t(".success",
+ date: l(@invite_request.proposed_fill_time.to_date, format: :long),
+ return_address: ArchiveConfig.RETURN_ADDRESS)
+ redirect_to invite_requests_path
+ else
+ render action: :index
+ end
+ end
+
+ def manage
+ authorize(InviteRequest)
+
+ @invite_requests = InviteRequest.all
+
+ if params[:query].present?
+ query = "%#{params[:query]}%"
+ @invite_requests = InviteRequest.where(
+ "simplified_email LIKE ? OR ip_address LIKE ?",
+ query, query
+ )
+
+ # Keep track of the fact that this has been filtered, so the position
+ # will not cleanly correspond to the page that we're on and the index of
+ # the request on the page:
+ @filtered = true
+ end
+
+ @invite_requests = @invite_requests.order(:id).page(params[:page])
+ end
+
+ def destroy
+ @invite_request = InviteRequest.find(params[:id])
+ authorize @invite_request
+
+ if @invite_request.destroy
+ success_message = ts("Request for %{email} was removed from the queue.", email: @invite_request.email)
+ respond_to do |format|
+ format.html { redirect_to manage_invite_requests_path(page: params[:page], query: params[:query]), notice: success_message }
+ format.json { render json: { item_success_message: success_message }, status: :ok }
+ end
+ else
+ error_message = ts("Request could not be removed. Please try again.")
+ respond_to do |format|
+ format.html do
+ flash.keep
+ redirect_to manage_invite_requests_path(page: params[:page], query: params[:query]), flash: { error: error_message }
+ end
+ format.json { render json: { errors: error_message }, status: :unprocessable_entity }
+ end
+ end
+ end
+
+ def status
+ @page_subtitle = t(".browser_title")
+ end
+
+ private
+
+ def invite_request_params
+ params.require(:invite_request).permit(
+ :email, :note, :query
+ )
+ end
+end
diff --git a/app/controllers/known_issues_controller.rb b/app/controllers/known_issues_controller.rb
new file mode 100755
index 0000000..793daff
--- /dev/null
+++ b/app/controllers/known_issues_controller.rb
@@ -0,0 +1,58 @@
+class KnownIssuesController < ApplicationController
+ before_action :admin_only, except: [:index]
+
+ # GET /known_issues
+ def index
+ @known_issues = KnownIssue.all
+ end
+
+ # GET /known_issues/1
+ def show
+ @known_issue = authorize KnownIssue.find(params[:id])
+ end
+
+ # GET /known_issues/new
+ def new
+ @known_issue = authorize KnownIssue.new
+ end
+
+ # GET /known_issues/1/edit
+ def edit
+ @known_issue = authorize KnownIssue.find(params[:id])
+ end
+
+ # POST /known_issues
+ def create
+ @known_issue = authorize KnownIssue.new(known_issue_params)
+ if @known_issue.save
+ flash[:notice] = "Known issue was successfully created."
+ redirect_to(@known_issue)
+ else
+ render action: "new"
+ end
+ end
+
+ # PUT /known_issues/1
+ def update
+ @known_issue = authorize KnownIssue.find(params[:id])
+ if @known_issue.update(known_issue_params)
+ flash[:notice] = "Known issue was successfully updated."
+ redirect_to(@known_issue)
+ else
+ render action: "edit"
+ end
+ end
+
+ # DELETE /known_issues/1
+ def destroy
+ @known_issue = authorize KnownIssue.find(params[:id])
+ @known_issue.destroy
+ redirect_to(known_issues_path)
+ end
+
+ private
+
+ def known_issue_params
+ params.require(:known_issue).permit(:title, :content)
+ end
+end
diff --git a/app/controllers/kudos_controller.rb b/app/controllers/kudos_controller.rb
new file mode 100755
index 0000000..636296a
--- /dev/null
+++ b/app/controllers/kudos_controller.rb
@@ -0,0 +1,94 @@
+class KudosController < ApplicationController
+ skip_before_action :store_location
+ before_action :load_parent, only: [:index]
+ before_action :check_parent_visible, only: [:index]
+ before_action :admin_logout_required, only: [:create]
+
+ def load_parent
+ @work = Work.find(params[:work_id])
+ end
+
+ def check_parent_visible
+ check_visibility_for(@work)
+ end
+
+ def index
+ @kudos = @work.kudos.includes(:user).with_user
+ @guest_kudos_count = @work.kudos.by_guest.count
+
+ respond_to do |format|
+ format.html do
+ @kudos = @kudos.order(id: :desc).paginate(
+ page: params[:page],
+ per_page: ArchiveConfig.MAX_KUDOS_TO_SHOW
+ )
+ end
+
+ format.js do
+ @kudos = @kudos.where("id < ?", params[:before].to_i) if params[:before]
+ end
+ end
+ end
+
+ def create
+ @kudo = Kudo.new(kudo_params)
+ if current_user.present?
+ @kudo.user = current_user
+ else
+ @kudo.ip_address = request.remote_ip
+ end
+
+ if @kudo.save
+ respond_to do |format|
+ format.html do
+ flash[:kudos_notice] = t(".success")
+ redirect_to request.referer and return
+ end
+
+ format.js do
+ @commentable = @kudo.commentable
+ @kudos = @commentable.kudos.with_user.includes(:user)
+ render :create, status: :created
+ end
+ end
+ else
+ error_message = @kudo.errors.full_messages.first
+ respond_to do |format|
+ format.html do
+ # If user is suspended or banned, JavaScript disabled, redirect user to dashboard with message instead.
+ return if check_user_status
+
+ flash[:kudos_error] = error_message
+ redirect_to request.referer and return
+ end
+
+ format.js do
+ render json: { error_message: error_message }, status: :unprocessable_entity
+ end
+ end
+ end
+ rescue ActiveRecord::RecordNotUnique
+ # Uniqueness checks at application level (Rails validations) are inherently
+ # prone to race conditions. If we pass Rails validations but get rejected
+ # by database unique indices, use the usual duplicate error message.
+ #
+ # https://api.rubyonrails.org/v5.1/classes/ActiveRecord/Validations/ClassMethods.html#method-i-validates_uniqueness_of-label-Concurrency+and+integrity
+ error_message = t("activerecord.errors.models.kudo.taken")
+ respond_to do |format|
+ format.html do
+ flash[:kudos_error] = error_message
+ redirect_to request.referer
+ end
+
+ format.js do
+ render json: { error_message: error_message }, status: :unprocessable_entity
+ end
+ end
+ end
+
+ private
+
+ def kudo_params
+ params.require(:kudo).permit(:commentable_id, :commentable_type)
+ end
+end
diff --git a/app/controllers/languages_controller.rb b/app/controllers/languages_controller.rb
new file mode 100755
index 0000000..2a42320
--- /dev/null
+++ b/app/controllers/languages_controller.rb
@@ -0,0 +1,53 @@
+class LanguagesController < ApplicationController
+ def index
+ @languages = Language.default_order
+ @works_counts = Rails.cache.fetch("/v1/languages/work_counts/#{current_user.present?}", expires_in: 1.day) do
+ WorkQuery.new.works_per_language(@languages.count)
+ end
+ end
+
+ def new
+ @language = Language.new
+ authorize @language
+ end
+
+ def create
+ @language = Language.new(language_params)
+ authorize @language
+ if @language.save
+ flash[:notice] = t("languages.successfully_added")
+ redirect_to languages_path
+ else
+ render action: "new"
+ end
+ end
+
+ def edit
+ @language = Language.find_by(short: params[:id])
+ authorize @language
+ return unless @language == Language.default
+
+ flash[:error] = t("languages.cannot_edit_default")
+ redirect_to languages_path
+ end
+
+ def update
+ @language = Language.find_by(short: params[:id])
+ authorize @language
+
+ if @language.update(permitted_attributes(@language))
+ flash[:notice] = t("languages.successfully_updated")
+ redirect_to languages_path
+ else
+ render action: "new"
+ end
+ end
+
+ private
+
+ def language_params
+ params.require(:language).permit(
+ :name, :short, :support_available, :abuse_support_available, :sortable_name
+ )
+ end
+end
diff --git a/app/controllers/locales_controller.rb b/app/controllers/locales_controller.rb
new file mode 100755
index 0000000..b36bec3
--- /dev/null
+++ b/app/controllers/locales_controller.rb
@@ -0,0 +1,55 @@
+class LocalesController < ApplicationController
+
+ def index
+ authorize Locale
+
+ @locales = Locale.default_order
+ end
+
+ def new
+ @locale = Locale.new
+ authorize @locale
+ @languages = Language.default_order
+ end
+
+ # GET /locales/en/edit
+ def edit
+ @locale = Locale.find_by(iso: params[:id])
+ authorize @locale
+ @languages = Language.default_order
+ end
+
+ def update
+ @locale = Locale.find_by(iso: params[:id])
+ @locale.attributes = locale_params
+ authorize @locale
+ if @locale.save
+ flash[:notice] = ts('Your locale was successfully updated.')
+ redirect_to action: 'index', status: 303
+ else
+ @languages = Language.default_order
+ render action: "edit"
+ end
+ end
+
+
+ def create
+ @locale = Locale.new(locale_params)
+ authorize @locale
+ if @locale.save
+ flash[:notice] = t('successfully_added', default: 'Locale was successfully added.')
+ redirect_to locales_path
+ else
+ @languages = Language.default_order
+ render action: "new"
+ end
+ end
+
+ private
+
+ def locale_params
+ params.require(:locale).permit(
+ :name, :iso, :language_id, :email_enabled, :interface_enabled
+ )
+ end
+end
diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb
new file mode 100755
index 0000000..579d3c6
--- /dev/null
+++ b/app/controllers/media_controller.rb
@@ -0,0 +1,25 @@
+class MediaController < ApplicationController
+ before_action :load_collection
+ skip_before_action :store_location, only: [:show]
+
+ def index
+ uncategorized = Media.uncategorized
+ @media = Media.canonical.by_name.where.not(name: [ArchiveConfig.MEDIA_UNCATEGORIZED_NAME, ArchiveConfig.MEDIA_NO_TAG_NAME]) + [uncategorized]
+ @fandom_listing = {}
+ @media.each do |medium|
+ if medium == uncategorized
+ @fandom_listing[medium] = medium.children.in_use.by_type('Fandom').order('created_at DESC').limit(5)
+ else
+ @fandom_listing[medium] = (logged_in? || logged_in_as_admin?) ?
+ # was losing the select trying to do this through the parents association
+ Fandom.unhidden_top(5).joins(:common_taggings).where(canonical: true, common_taggings: {filterable_id: medium.id, filterable_type: 'Tag'}) :
+ Fandom.public_top(5).joins(:common_taggings).where(canonical: true, common_taggings: {filterable_id: medium.id, filterable_type: 'Tag'})
+ end
+ end
+ @page_subtitle = t(".browser_title")
+ end
+
+ def show
+ redirect_to media_fandoms_path(media_id: params[:id])
+ end
+end
diff --git a/app/controllers/menu_controller.rb b/app/controllers/menu_controller.rb
new file mode 100755
index 0000000..86ae0da
--- /dev/null
+++ b/app/controllers/menu_controller.rb
@@ -0,0 +1,23 @@
+class MenuController < ApplicationController
+
+ # about menu
+ def about
+ render action: "about", layout: "application"
+ end
+
+ # browse menu
+ def browse
+ render action: "browse", layout: "application"
+ end
+
+ # fandoms menu
+ def fandoms
+ render action: "fandoms", layout: "application"
+ end
+
+ # search menu
+ def search
+ render action: "search", layout: "application"
+ end
+
+end
\ No newline at end of file
diff --git a/app/controllers/muted/users_controller.rb b/app/controllers/muted/users_controller.rb
new file mode 100755
index 0000000..86b36fe
--- /dev/null
+++ b/app/controllers/muted/users_controller.rb
@@ -0,0 +1,89 @@
+module Muted
+ class UsersController < ApplicationController
+ before_action :set_user
+
+ before_action :check_ownership, except: :index
+ before_action :check_ownership_or_admin, only: :index
+ before_action :check_admin_permissions, only: :index
+
+ before_action :build_mute, only: [:confirm_mute, :create]
+ before_action :set_mute, only: [:confirm_unmute, :destroy]
+
+ # GET /users/:user_id/muted/users
+ def index
+ @mutes = @user.mutes_as_muter
+ .joins(:muted)
+ .includes(muted: [:pseuds, { default_pseud: { icon_attachment: { blob: {
+ variant_records: { image_attachment: :blob },
+ preview_image_attachment: { blob: { variant_records: { image_attachment: :blob } } }
+ } } } }])
+ .order(created_at: :desc).order(id: :desc).page(params[:page])
+
+ @pseuds = @mutes.map { |b| b.muted.default_pseud }
+ @rec_counts = Pseud.rec_counts_for_pseuds(@pseuds)
+ @work_counts = Pseud.work_counts_for_pseuds(@pseuds)
+
+ @page_subtitle = t(".title")
+ end
+
+ # GET /users/:user_id/muted/users/confirm_mute
+ def confirm_mute
+ @hide_dashboard = true
+
+ return if @mute.valid?
+
+ # We can't mute this user for whatever reason
+ flash[:error] = @mute.errors.full_messages.first
+ redirect_to user_muted_users_path(@user)
+ end
+
+ # GET /users/:user_id/muted/users/confirm_unmute
+ def confirm_unmute
+ @hide_dashboard = true
+
+ @muted = @mute.muted
+ end
+
+ # POST /users/:user_id/muted/users
+ def create
+ if @mute.save
+ flash[:notice] = t(".muted", name: @mute.muted.login)
+ else
+ # We can't mute this user for whatever reason
+ flash[:error] = @mute.errors.full_messages.first
+ end
+
+ redirect_to user_muted_users_path(@user)
+ end
+
+ # DELETE /users/:user_id/muted/users/:id
+ def destroy
+ @mute.destroy
+ flash[:notice] = t(".unmuted", name: @mute.muted.login)
+ redirect_to user_muted_users_path(@user)
+ end
+
+ private
+
+ # Sets the user whose mutes we're viewing/modifying.
+ def set_user
+ @user = User.find_by!(login: params[:user_id])
+ @check_ownership_of = @user
+ end
+
+ # Builds (but doesn't save) a mute matching the desired params:
+ def build_mute
+ muted_byline = params.fetch(:muted_id, "")
+ @mute = @user.mutes_as_muter.build(muted_byline: muted_byline)
+ @muted = @mute.muted
+ end
+
+ def set_mute
+ @mute = @user.mutes_as_muter.find(params[:id])
+ end
+
+ def check_admin_permissions
+ authorize Mute if logged_in_as_admin?
+ end
+ end
+end
diff --git a/app/controllers/opendoors/external_authors_controller.rb b/app/controllers/opendoors/external_authors_controller.rb
new file mode 100755
index 0000000..5dd6eb3
--- /dev/null
+++ b/app/controllers/opendoors/external_authors_controller.rb
@@ -0,0 +1,78 @@
+class Opendoors::ExternalAuthorsController < ApplicationController
+
+ before_action :users_only
+ before_action :opendoors_only
+ before_action :load_external_author, only: [:show, :forward]
+
+ def load_external_author
+ @external_author = ExternalAuthor.find(params[:id])
+ end
+
+ def index
+ if params[:query]
+ @query = params[:query]
+ sql_query = '%' + @query +'%'
+ @external_authors = ExternalAuthor.where("external_authors.email LIKE ?", sql_query)
+ else
+ @external_authors = ExternalAuthor.unclaimed
+ end
+ # list in reverse order
+ @external_authors = @external_authors.order("created_at DESC").paginate(page: params[:page])
+ end
+
+ def show
+ end
+
+ def new
+ @external_author = ExternalAuthor.new
+ end
+
+ # create an external author identity (and pre-emptively block it)
+ def create
+ @external_author = ExternalAuthor.new(external_author_params)
+ unless @external_author.save
+ flash[:error] = ts("We couldn't save that address.")
+ else
+ flash[:notice] = ts("We have saved and blocked the email address %{email}", email: @external_author.email)
+ end
+
+ redirect_to opendoors_tools_path
+ end
+
+ def forward
+ if @external_author.is_claimed
+ flash[:error] = ts("This external author has already been claimed!")
+ redirect_to opendoors_external_author_path(@external_author) and return
+ end
+
+ # get the invitation
+ @invitation = Invitation.where(external_author_id: @external_author.id).first
+
+ unless @invitation
+ # if there is no invite we create one
+ @invitation = Invitation.new(external_author: @external_author)
+ end
+
+ # send the invitation to specified address
+ @email = params[:email]
+ @invitation.invitee_email = @email
+ @invitation.creator = User.find_by(login: "open_doors") || current_user
+ if @invitation.save
+ flash[:notice] = ts("Claim invitation for %{author_email} has been forwarded to %{invitee_email}!", author_email: @external_author.email, invitee_email: @invitation.invitee_email)
+ else
+ flash[:error] = ts("We couldn't forward the claim for %{author_email} to that email address.", author_email: @external_author.email) + @invitation.errors.full_messages.join(", ")
+ end
+
+ # redirect to external author listing for that user
+ redirect_to opendoors_external_authors_path(query: @external_author.email)
+ end
+
+ private
+
+ def external_author_params
+ params.require(:external_author).permit(
+ :email, :do_not_email, :do_not_import
+ )
+ end
+
+end
diff --git a/app/controllers/opendoors/tools_controller.rb b/app/controllers/opendoors/tools_controller.rb
new file mode 100755
index 0000000..1f3cb80
--- /dev/null
+++ b/app/controllers/opendoors/tools_controller.rb
@@ -0,0 +1,61 @@
+class Opendoors::ToolsController < ApplicationController
+
+ before_action :users_only
+ before_action :opendoors_only
+
+ def index
+ @imported_from_url = params[:imported_from_url]
+ @external_author = ExternalAuthor.new
+ end
+
+ # Update the imported_from_url value on an existing AO3 work
+ # This is not RESTful but is IMO a better idea than setting up a works controller under the opendoors namespace,
+ # since the functionality we want to provide is so limited.
+ def url_update
+
+ # extract the work id and find the work
+ if params[:work_url] && params[:work_url].match(/works\/([0-9]+)\/?$/)
+ work_id = $1
+ @work = Work.find_by_id(work_id)
+ end
+ unless @work
+ flash[:error] = ts("We couldn't find that work on the Archive. Have you put in the full URL?")
+ redirect_to action: :index and return
+ end
+
+ # check validity of the new redirecting url
+ unless params[:imported_from_url].blank?
+ # try to parse the original entered url
+ begin
+ URI.parse(params[:imported_from_url])
+ @imported_from_url = params[:imported_from_url]
+ rescue
+ end
+
+ # if that didn't work, try to encode the URL and then parse it
+ if @imported_from_url.blank?
+ begin
+ URI.parse(URI::Parser.new.escape(params[:imported_from_url]))
+ @imported_from_url = URI::Parser.new.escape(params[:imported_from_url])
+ rescue
+ end
+ end
+ end
+
+ if @imported_from_url.blank?
+ flash[:error] = ts("The imported-from url you are trying to set doesn't seem valid.")
+ else
+ # check for any other works
+ works = Work.where(imported_from_url: @imported_from_url)
+ if works.count > 0
+ flash[:error] = ts("There is already a work imported from the url %{url}.", url: @imported_from_url)
+ else
+ # ok let's try to update
+ @work.update_attribute(:imported_from_url, @imported_from_url)
+ flash[:notice] = "Updated imported-from url for #{@work.title} to #{@imported_from_url}"
+ end
+ end
+ redirect_to action: :index, imported_from_url: @imported_from_url and return
+ end
+
+end
diff --git a/app/controllers/orphans_controller.rb b/app/controllers/orphans_controller.rb
new file mode 100755
index 0000000..b5edab1
--- /dev/null
+++ b/app/controllers/orphans_controller.rb
@@ -0,0 +1,90 @@
+class OrphansController < ApplicationController
+ # You must be logged in to orphan works - relies on current_user data
+ before_action :users_only, except: [:index]
+
+ before_action :check_user_not_suspended, except: [:index]
+ before_action :load_pseuds, only: [:create]
+ before_action :load_orphans, only: [:create]
+
+ def index
+ @user = User.orphan_account
+ @works = @user.works
+ end
+
+ def new
+ if params[:work_id]
+ @to_be_orphaned = Work.find(params[:work_id])
+ check_one_owned(@to_be_orphaned, current_user.works)
+ elsif params[:work_ids]
+ @to_be_orphaned = Work.where(id: params[:work_ids]).to_a
+ check_all_owned(@to_be_orphaned, current_user.works)
+ elsif params[:series_id]
+ @to_be_orphaned = Series.find(params[:series_id])
+ check_one_owned(@to_be_orphaned, current_user.series)
+ elsif params[:pseud_id]
+ @to_be_orphaned = Pseud.find(params[:pseud_id])
+ check_one_owned(@to_be_orphaned, current_user.pseuds)
+ else
+ @to_be_orphaned = current_user
+ end
+ end
+
+ def create
+ use_default = params[:use_default] == "true"
+ Creatorship.orphan(@pseuds, @orphans, use_default)
+ flash[:notice] = ts("Orphaning was successful.")
+ redirect_to user_path(current_user)
+ end
+
+ protected
+
+ def show_orphan_permission_error
+ flash[:error] = ts("You don't have permission to orphan that!")
+ redirect_to root_path
+ end
+
+ # Given an ActiveRecord item and an ActiveRecord relation, check whether the
+ # item is in the relation. If not, show a flash error.
+ def check_one_owned(chosen_item, all_owned_items)
+ show_orphan_permission_error unless all_owned_items.exists?(chosen_item.id)
+ end
+
+ # Given a collection of ActiveRecords and an ActiveRecord relation, check
+ # whether all items in the collection are contained in the relation. If not,
+ # show a flash error.
+ def check_all_owned(chosen_items, all_owned_items)
+ chosen_ids = chosen_items.map(&:id)
+ owned_ids = all_owned_items.where(id: chosen_ids).pluck(:id)
+ unowned_ids = chosen_ids - owned_ids
+ show_orphan_permission_error if unowned_ids.any?
+ end
+
+ # Load the list of works or series into the @orphans variable, and verify
+ # that the current user owns the works/series in question.
+ def load_orphans
+ if params[:work_ids]
+ @orphans = Work.where(id: params[:work_ids]).to_a
+ check_all_owned(@orphans, current_user.works)
+ elsif params[:series_id]
+ @orphans = Series.where(id: params[:series_id]).to_a
+ check_all_owned(@orphans, current_user.series)
+ else
+ flash[:error] = ts("What did you want to orphan?")
+ redirect_to current_user
+ end
+ end
+
+ # If a pseud_id is specified, load it and check that it belongs to the
+ # current user. Otherwise, assume that the user wants to orphan with all of
+ # their pseuds.
+ def load_pseuds
+ if params[:pseud_id]
+ @pseuds = Pseud.where(id: params[:pseud_id]).to_a
+ check_all_owned(@pseuds, current_user.pseuds)
+ else
+ @pseuds = current_user.pseuds
+ # We don't need to check ownership here because these pseuds are
+ # guaranteed to be owned by the current user.
+ end
+ end
+end
diff --git a/app/controllers/owned_tag_sets_controller.rb b/app/controllers/owned_tag_sets_controller.rb
new file mode 100755
index 0000000..f157775
--- /dev/null
+++ b/app/controllers/owned_tag_sets_controller.rb
@@ -0,0 +1,239 @@
+class OwnedTagSetsController < ApplicationController
+ cache_sweeper :tag_set_sweeper
+
+ before_action :load_tag_set, except: [:index, :new, :create, :show_options]
+ before_action :users_only, only: [:new, :create]
+ before_action :moderators_only, except: [:index, :new, :create, :show, :show_options]
+ before_action :owners_only, only: [:destroy]
+
+ def load_tag_set
+ @tag_set = OwnedTagSet.find_by(id: params[:id])
+ unless @tag_set
+ flash[:error] = ts("What Tag Set did you want to look at?")
+ redirect_to tag_sets_path and return
+ end
+ end
+
+ def moderators_only
+ @tag_set.user_is_moderator?(current_user) || access_denied
+ end
+
+ def owners_only
+ @tag_set.user_is_owner?(current_user) || access_denied
+ end
+
+ def nominated_only
+ @tag_set.nominated || access_denied
+ end
+
+ ### ACTIONS
+
+ def index
+ if params[:user_id]
+ @user = User.find_by login: params[:user_id]
+ @tag_sets = OwnedTagSet.owned_by(@user)
+ elsif params[:restriction]
+ @restriction = PromptRestriction.find(params[:restriction])
+ @tag_sets = OwnedTagSet.in_prompt_restriction(@restriction)
+ if @tag_sets.count == 1
+ redirect_to tag_set_path(@tag_sets.first, tag_type: (params[:tag_type] || "fandom")) and return
+ end
+ else
+ @tag_sets = OwnedTagSet
+ if params[:query]
+ @query = params[:query]
+ @tag_sets = @tag_sets.where("title LIKE ?", '%' + params[:query] + '%')
+ else
+ # show a random selection
+ @tag_sets = @tag_sets.order("created_at DESC")
+ end
+ end
+ @tag_sets = @tag_sets.paginate(per_page: (params[:per_page] || ArchiveConfig.ITEMS_PER_PAGE), page: (params[:page] || 1))
+ end
+
+ def show_options
+ @restriction = PromptRestriction.find_by(id: params[:restriction])
+ unless @restriction
+ flash[:error] = ts("Which Tag Set did you want to look at?")
+ redirect_to tag_sets_path and return
+ end
+ @tag_sets = OwnedTagSet.in_prompt_restriction(@restriction)
+ @tag_set_ids = @tag_sets.pluck(:tag_set_id)
+ @tag_type = params[:tag_type] && TagSet::TAG_TYPES.include?(params[:tag_type]) ? params[:tag_type] : "fandom"
+ # @tag_type is restricted by in_prompt_restriction and therefore safe to pass to constantize
+ @tags = @tag_type.classify.constantize.joins(:set_taggings).where("set_taggings.tag_set_id IN (?)", @tag_set_ids).by_name_without_articles
+ end
+
+ def show
+ # don't bother collecting tags unless the user gets to see them
+ if @tag_set.visible || @tag_set.user_is_moderator?(current_user)
+
+ # we use this to collect up fandom parents for characters and relationships
+ @fandom_keys_from_other_tags = []
+
+ # we use this to store the tag name results
+ @tag_hash = HashWithIndifferentAccess.new
+
+ %w[character relationship].each do |tag_type|
+ next unless @tag_set.has_type?(tag_type)
+
+ ## names_by_parent returns a hash of arrays like so:
+ ## hash[parent_name] => [child name, child name, child name]
+
+ # get the manually associated fandoms
+ assoc_hash = TagSetAssociation.names_by_parent(TagSetAssociation.for_tag_set(@tag_set), tag_type)
+
+ # get canonically associated fandoms
+ # Safe for constantize as tag_type restricted to character relationship
+ canonical_hash = Tag.names_by_parent(tag_type.classify.constantize.in_tag_set(@tag_set).canonical, "fandom")
+
+ # merge the values of the two hashes (each value is an array) as a set (ie remove duplicates)
+ @tag_hash[tag_type] = assoc_hash.merge(canonical_hash) { |_key, oldval, newval| (oldval | newval) }
+
+ # get any tags without a fandom
+ remaining = @tag_set.with_type(tag_type).where.not(name: @tag_hash[tag_type].values.flatten)
+ if remaining.any?
+ @tag_hash[tag_type]["(No linked fandom - might need association)"] ||= []
+ @tag_hash[tag_type]["(No linked fandom - might need association)"] += remaining.pluck(:name)
+ end
+
+ # store the parents
+ @fandom_keys_from_other_tags += @tag_hash[tag_type].keys
+ end
+
+ # get rid of duplicates and sort
+ @fandom_keys_from_other_tags = @fandom_keys_from_other_tags.compact.uniq.sort {|a,b| a.gsub(/^(the |an |a )/, '') <=> b.gsub(/^(the |an |a )/, '')}
+
+ # now handle fandoms
+ if @tag_set.has_type?("fandom")
+ # Get fandoms hashed by media -- just the canonical associations
+ @tag_hash[:fandom] = Tag.names_by_parent(Fandom.in_tag_set(@tag_set), "media")
+
+ # get any fandoms without a media
+ if @tag_set.with_type("fandom").with_no_parents.count > 0
+ @tag_hash[:fandom]["(No Media)"] ||= []
+ @tag_hash[:fandom]["(No Media)"] += @tag_set.with_type("fandom").with_no_parents.pluck(:name)
+ end
+
+ # we want to collect and warn about any chars or relationships not in the set's fandoms
+ @character_seen = {}
+ @relationship_seen = {}
+
+ # clear out the fandoms we're showing from the list of other tags
+ if @fandom_keys_from_other_tags && !@fandom_keys_from_other_tags.empty?
+ @fandom_keys_from_other_tags -= @tag_hash[:fandom].values.flatten
+ end
+
+ @unassociated_chars = []
+ @unassociated_rels = []
+ unless @fandom_keys_from_other_tags.empty?
+ if @tag_hash[:character]
+ @unassociated_chars = @tag_hash[:character].values_at(*@fandom_keys_from_other_tags).flatten.compact.uniq
+ end
+ if @tag_hash[:relationship]
+ @unassociated_rels = @tag_hash[:relationship].values_at(*@fandom_keys_from_other_tags).flatten.compact.uniq
+ end
+ end
+ end
+ end
+ end
+
+ def new
+ @tag_set = OwnedTagSet.new
+ end
+
+ def create
+ @tag_set = OwnedTagSet.new(owned_tag_set_params)
+ @tag_set.add_owner(current_user.default_pseud)
+ if @tag_set.save
+ flash[:notice] = ts('Tag Set was successfully created.')
+ redirect_to tag_set_path(@tag_set)
+ else
+ render action: "new"
+ end
+ end
+
+ def edit
+ get_parent_child_tags
+ end
+
+ def update
+ if @tag_set.update(owned_tag_set_params) && @tag_set.tag_set.save!
+ flash[:notice] = ts("Tag Set was successfully updated.")
+ redirect_to tag_set_path(@tag_set)
+ else
+ get_parent_child_tags
+ render action: :edit
+ end
+ end
+
+ def confirm_delete
+ end
+
+ def destroy
+ @tag_set = OwnedTagSet.find(params[:id])
+ begin
+ name = @tag_set.title
+ @tag_set.destroy
+ flash[:notice] = ts("Your Tag Set %{name} was deleted.", name: name)
+ rescue
+ flash[:error] = ts("We couldn't delete that right now, sorry! Please try again later.")
+ end
+ redirect_to tag_sets_path
+ end
+
+ def batch_load
+ end
+
+ def do_batch_load
+ if params[:batch_associations]
+ failed = @tag_set.load_batch_associations!(params[:batch_associations], do_relationships: (params[:batch_do_relationships] ? true : false))
+ if failed.empty?
+ flash[:notice] = ts("Tags and associations loaded!")
+ redirect_to tag_set_path(@tag_set) and return
+ else
+ flash.now[:notice] = ts("We couldn't add all the tags and associations you wanted -- the ones left below didn't work. See the help for suggestions!")
+ @failed_batch_associations = failed.join("\n")
+ render action: :batch_load and return
+ end
+ else
+ flash[:error] = ts("What did you want to load?")
+ redirect_to action: :batch_load and return
+ end
+ end
+
+
+
+
+ protected
+
+ # for manual associations
+ def get_parent_child_tags
+ @tags_in_set = Tag.joins(:set_taggings).where("set_taggings.tag_set_id = ?", @tag_set.tag_set_id).order("tags.name ASC")
+ @parent_tags_in_set = @tags_in_set.where(type: 'Fandom').pluck :name, :id
+ @child_tags_in_set = @tags_in_set.where("type IN ('Relationship', 'Character')").pluck :name, :id
+ end
+
+ private
+
+ def owned_tag_set_params
+ params.require(:owned_tag_set).permit(
+ :owner_changes, :moderator_changes, :title, :description, :visible,
+ :usable, :nominated, :fandom_nomination_limit, :character_nomination_limit,
+ :relationship_nomination_limit, :freeform_nomination_limit,
+ associations_to_remove: [],
+ tag_set_associations_attributes: [
+ :id, :create_association, :tag_id, :parent_tag_id, :_destroy
+ ],
+ tag_set_attributes: [
+ :id,
+ :from_owned_tag_set, :fandom_tagnames_to_add, :character_tagnames_to_add,
+ :relationship_tagnames_to_add, :freeform_tagnames_to_add,
+ character_tagnames: [], rating_tagnames: [], archive_warning_tagnames: [],
+ category_tagnames: [], fandom_tags_to_remove: [], character_tags_to_remove: [],
+ relationship_tags_to_remove: [], freeform_tags_to_remove: []
+ ]
+ )
+ end
+
+end
diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb
new file mode 100755
index 0000000..58be0b3
--- /dev/null
+++ b/app/controllers/people_controller.rb
@@ -0,0 +1,33 @@
+class PeopleController < ApplicationController
+
+ before_action :load_collection
+
+ def search
+ if people_search_params.blank?
+ @search = PseudSearchForm.new({})
+ else
+ options = people_search_params.merge(page: params[:page])
+ @search = PseudSearchForm.new(options)
+ @people = @search.search_results.scope(:for_search)
+ flash_search_warnings(@people)
+ end
+ end
+
+ def index
+ if @collection.present?
+ @people = @collection.participants.with_attached_icon.includes(:user).order(:name).page(params[:page])
+ @rec_counts = Pseud.rec_counts_for_pseuds(@people)
+ @work_counts = Pseud.work_counts_for_pseuds(@people)
+ @page_subtitle = t(".collection_page_title", collection_title: @collection.title)
+ else
+ redirect_to search_people_path
+ end
+ end
+
+ protected
+
+ def people_search_params
+ return {} unless params[:people_search].present?
+ params[:people_search].permit!
+ end
+end
diff --git a/app/controllers/potential_matches_controller.rb b/app/controllers/potential_matches_controller.rb
new file mode 100755
index 0000000..8b19c74
--- /dev/null
+++ b/app/controllers/potential_matches_controller.rb
@@ -0,0 +1,146 @@
+class PotentialMatchesController < ApplicationController
+
+ before_action :users_only
+ before_action :load_collection
+ before_action :collection_maintainers_only
+ before_action :load_challenge
+ before_action :check_assignments_not_sent
+ before_action :check_signup_closed, only: [:generate]
+ before_action :load_potential_match_from_id, only: [:show]
+
+
+ def load_challenge
+ @challenge = @collection.challenge
+ no_challenge and return unless @challenge
+ end
+
+ def no_challenge
+ flash[:error] = ts("What challenge did you want to sign up for?")
+ redirect_to collection_path(@collection) rescue redirect_to '/'
+ false
+ end
+
+ def load_potential_match_from_id
+ @potential_match = PotentialMatch.find(params[:id])
+ no_potential_match and return unless @potential_match
+ end
+
+ def no_assignment
+ flash[:error] = ts("What potential match did you want to work on?")
+ redirect_to collection_path(@collection) rescue redirect_to '/'
+ false
+ end
+
+ def check_signup_closed
+ signup_open and return unless !@challenge.signup_open
+ end
+
+ def signup_open
+ flash[:error] = ts("Sign-up is still open, you cannot determine potential matches now.")
+ redirect_to @collection rescue redirect_to '/'
+ false
+ end
+
+ def check_assignments_not_sent
+ assignments_sent and return unless @challenge.assignments_sent_at.nil?
+ end
+
+ def assignments_sent
+ flash[:error] = ts("Assignments have already been sent! If necessary, you can purge them.")
+ redirect_to collection_assignments_path(@collection) rescue redirect_to '/'
+ false
+ end
+
+ def index
+ @settings = @collection.challenge.potential_match_settings
+
+ if (invalid_ids = PotentialMatch.invalid_signups_for(@collection)).present?
+ # there are invalid signups
+ @invalid_signups = ChallengeSignup.where(id: invalid_ids)
+ elsif PotentialMatch.in_progress?(@collection)
+ # we're generating
+ @in_progress = true
+ @progress = PotentialMatch.progress(@collection)
+ elsif ChallengeAssignment.in_progress?(@collection)
+ @assignment_in_progress = true
+ elsif @collection.potential_matches.count > 0 && @collection.assignments.count == 0
+ flash[:error] = ts("There has been an error in the potential matching. Please first try regenerating assignments, and if that doesn't work, all potential matches. If the problem persists, please contact Support.")
+ elsif @collection.potential_matches.count > 0
+ # we have potential_matches and assignments
+
+ ### find assignments with no potential recipients
+ # first get signups with no offer potential matches
+ no_opms = ChallengeSignup.in_collection(@collection).no_potential_offers.pluck(:id)
+ @assignments_with_no_potential_recipients = @collection.assignments.where(offer_signup_id: no_opms)
+
+ ### find assignments with no potential giver
+ # first get signups with no request potential matches
+ no_rpms = ChallengeSignup.in_collection(@collection).no_potential_requests.pluck(:id)
+ @assignments_with_no_potential_givers = @collection.assignments.where(request_signup_id: no_rpms)
+
+ # list the assignments by requester
+ if params[:no_giver]
+ @assignments = @collection.assignments.with_request.with_no_offer.order_by_requesting_pseud
+ elsif params[:no_recipient]
+ # ordering causes this to hang on large challenge due to
+ # left join required to get offering pseuds
+ @assignments = @collection.assignments.with_offer.with_no_request # .order_by_offering_pseud
+ elsif params[:dup_giver]
+ @assignments = ChallengeAssignment.duplicate_givers(@collection).order_by_offering_pseud
+ elsif params[:dup_recipient]
+ @assignments = ChallengeAssignment.duplicate_recipients(@collection).order_by_requesting_pseud
+ else
+ @assignments = @collection.assignments.with_request.with_offer.order_by_requesting_pseud
+ end
+ @assignments = @assignments.paginate page: params[:page], per_page: ArchiveConfig.ITEMS_PER_PAGE
+ end
+ end
+
+ # Generate potential matches
+ def generate
+ if PotentialMatch.in_progress?(@collection)
+ flash[:error] = ts("Potential matches are already being generated for this collection!")
+ else
+ # delete all existing assignments and potential matches for this collection
+ ChallengeAssignment.clear!(@collection)
+ PotentialMatch.clear!(@collection)
+
+ flash[:notice] = ts("Beginning generation of potential matches. This may take some time, especially if your challenge is large.")
+ PotentialMatch.set_up_generating(@collection)
+ PotentialMatch.generate(@collection)
+ end
+
+ # redirect to index
+ redirect_to collection_potential_matches_path(@collection)
+ end
+
+ # Regenerate matches for one signup
+ def regenerate_for_signup
+ if params[:signup_id].blank? || (@signup = ChallengeSignup.where(id: params[:signup_id]).first).nil?
+ flash[:error] = ts("What sign-up did you want to regenerate matches for?")
+ else
+ PotentialMatch.regenerate_for_signup(@signup)
+ flash[:notice] = ts("Matches are being regenerated for ") + @signup.pseud.byline +
+ ts(". Please allow at least 5 minutes for this process to complete before refreshing the page.")
+ end
+ # redirect to index
+ redirect_to collection_potential_matches_path(@collection)
+ end
+
+ def cancel_generate
+ if !PotentialMatch.in_progress?(@collection)
+ flash[:error] = ts("Potential matches are not currently being generated for this challenge.")
+ elsif PotentialMatch.canceled?(@collection)
+ flash[:error] = ts("Potential match generation has already been canceled, please refresh again shortly.")
+ else
+ PotentialMatch.cancel_generation(@collection)
+ flash[:notice] = ts("Potential match generation cancellation requested. This may take a while, please refresh shortly.")
+ end
+
+ redirect_to collection_potential_matches_path(@collection)
+ end
+
+ def show
+ end
+
+end
diff --git a/app/controllers/preferences_controller.rb b/app/controllers/preferences_controller.rb
new file mode 100755
index 0000000..69f6592
--- /dev/null
+++ b/app/controllers/preferences_controller.rb
@@ -0,0 +1,72 @@
+class PreferencesController < ApplicationController
+ before_action :load_user
+ before_action :check_ownership
+ skip_before_action :store_location
+
+ # Ensure that the current user is authorized to view and change this information
+ def load_user
+ @user = User.find_by(login: params[:user_id])
+ @check_ownership_of = @user
+ end
+
+ def index
+ @user = User.find_by(login: params[:user_id])
+ @preference = @user.preference
+ @available_skins = (current_user.skins.site_skins + Skin.approved_skins.site_skins).uniq
+ @available_locales = Locale.where(email_enabled: true)
+ end
+
+ def update
+ @user = User.find_by(login: params[:user_id])
+ @preference = @user.preference
+ @user.preference.attributes = preference_params
+ @available_skins = (current_user.skins.site_skins + Skin.approved_skins.site_skins).uniq
+ @available_locales = Locale.where(email_enabled: true)
+
+ if params[:preference][:skin_id].present?
+ # unset session skin if user changed their skin
+ session[:site_skin] = nil
+ end
+
+ if @user.preference.save
+ flash[:notice] = ts('Your preferences were successfully updated.')
+ redirect_to user_path(@user)
+ else
+ flash[:error] = ts('Sorry, something went wrong. Please try that again.')
+ render action: :index
+ end
+ end
+
+ private
+
+ def preference_params
+ params.require(:preference).permit(
+ :minimize_search_engines,
+ :disable_share_links,
+ :adult,
+ :view_full_works,
+ :hide_warnings,
+ :hide_freeform,
+ :disable_work_skins,
+ :skin_id,
+ :time_zone,
+ :preferred_locale,
+ :work_title_format,
+ :comment_emails_off,
+ :comment_inbox_off,
+ :comment_copy_to_self_off,
+ :kudos_emails_off,
+ :admin_emails_off,
+ :allow_collection_invitation,
+ :collection_emails_off,
+ :collection_inbox_off,
+ :recipient_emails_off,
+ :history_enabled,
+ :first_login,
+ :banner_seen,
+ :allow_cocreator,
+ :allow_gifts,
+ :guest_replies_off
+ )
+ end
+end
diff --git a/app/controllers/profile_controller.rb b/app/controllers/profile_controller.rb
new file mode 100755
index 0000000..add7370
--- /dev/null
+++ b/app/controllers/profile_controller.rb
@@ -0,0 +1,45 @@
+class ProfileController < ApplicationController
+ before_action :load_user_and_pseuds
+
+ def show
+ @user = User.find_by(login: params[:user_id])
+ if @user.profile.nil?
+ Profile.create(user_id: @user.id)
+ @user.reload
+ end
+
+ @profile = @user.profile
+
+ # code the same as the stuff in users_controller
+ if current_user.respond_to?(:subscriptions)
+ @subscription = current_user.subscriptions.where(subscribable_id: @user.id,
+ subscribable_type: "User").first ||
+ current_user.subscriptions.build(subscribable: @user)
+ end
+ @page_subtitle = t(".page_title", username: @user.login)
+ end
+
+ def pseuds
+ respond_to do |format|
+ format.html do
+ redirect_to user_pseuds_path(@user)
+ end
+
+ format.js
+ end
+ end
+
+ private
+
+ def load_user_and_pseuds
+ @user = User.find_by(login: params[:user_id])
+
+ if @user.nil?
+ flash[:error] = ts("Sorry, there's no user by that name.")
+ redirect_to root_path
+ return
+ end
+
+ @pseuds = @user.pseuds.default_alphabetical.paginate(page: params[:page])
+ end
+end
diff --git a/app/controllers/prompts_controller.rb b/app/controllers/prompts_controller.rb
new file mode 100755
index 0000000..69a084e
--- /dev/null
+++ b/app/controllers/prompts_controller.rb
@@ -0,0 +1,211 @@
+class PromptsController < ApplicationController
+
+ before_action :users_only, except: [:show]
+ before_action :load_collection, except: [:index]
+ before_action :load_challenge, except: [:index]
+ before_action :load_prompt_from_id, only: [:show, :edit, :update, :destroy]
+ before_action :load_signup, except: [:index, :destroy, :show]
+ # before_action :promptmeme_only, except: [:index, :new]
+ before_action :allowed_to_destroy, only: [:destroy]
+ before_action :allowed_to_view, only: [:show]
+ before_action :signup_owner_only, only: [:edit, :update]
+ before_action :check_signup_open, only: [:new, :create, :edit, :update]
+ before_action :check_prompt_in_collection, only: [:show, :edit, :update, :destroy]
+
+ # def promptmeme_only
+ # unless @collection.challenge_type == "PromptMeme"
+ # flash[:error] = ts("Only available for prompt meme challenges, not gift exchanges")
+ # redirect_to collection_path(@collection) rescue redirect_to '/'
+ # end
+ # end
+
+ def load_challenge
+ @challenge = @collection.challenge
+ no_challenge and return unless @challenge
+ end
+
+ def no_challenge
+ flash[:error] = ts("What challenge did you want to sign up for?")
+ redirect_to collection_path(@collection) rescue redirect_to '/'
+ false
+ end
+
+ def load_signup
+ unless @challenge_signup
+ @challenge_signup = ChallengeSignup.in_collection(@collection).by_user(current_user).first
+ end
+ no_signup and return unless @challenge_signup
+ end
+
+ def no_signup
+ flash[:error] = ts("Please submit a basic sign-up with the required fields first.")
+ redirect_to new_collection_signup_path(@collection) rescue redirect_to '/'
+ false
+ end
+
+ def check_signup_open
+ signup_closed and return unless (@challenge.signup_open || @collection.user_is_owner?(current_user) || @collection.user_is_moderator?(current_user))
+ end
+
+ def signup_closed
+ flash[:error] = ts("Signup is currently closed: please contact a moderator for help.")
+ redirect_to @collection rescue redirect_to '/'
+ false
+ end
+
+ def signup_owner_only
+ not_signup_owner and return unless (@challenge_signup.pseud.user == current_user || (@collection.challenge_type == "GiftExchange" && !@challenge.signup_open && @collection.user_is_owner?(current_user)))
+ end
+
+ def maintainer_or_signup_owner_only
+ not_allowed(@collection) and return unless (@challenge_signup.pseud.user == current_user || @collection.user_is_maintainer?(current_user))
+ end
+
+ def not_signup_owner
+ flash[:error] = ts("You can't edit someone else's sign-up!")
+ redirect_to @collection
+ false
+ end
+
+ def allowed_to_destroy
+ @challenge_signup.user_allowed_to_destroy?(current_user) || not_allowed(@collection)
+ end
+
+ def load_prompt_from_id
+ @prompt = Prompt.find_by(id: params[:id])
+ if @prompt.nil?
+ no_prompt
+ return
+ end
+ @challenge_signup = @prompt.challenge_signup
+ end
+
+ def no_prompt
+ flash[:error] = ts("What prompt did you want to work on?")
+ redirect_to collection_path(@collection) rescue redirect_to '/'
+ false
+ end
+
+ def check_prompt_in_collection
+ unless @prompt.collection_id == @collection.id
+ flash[:error] = ts("Sorry, that prompt isn't associated with that collection.")
+ redirect_to @collection
+ end
+ end
+
+ def allowed_to_view
+ unless @challenge.user_allowed_to_see_prompt?(current_user, @prompt)
+ access_denied(redirect: @collection)
+ end
+ end
+
+ #### ACTIONS
+
+ def index
+ # this currently doesn't get called anywhere
+ # should probably list all the prompts in a given collection (instead of using challenge signup for that)
+ end
+
+ def show
+ end
+
+ def new
+ if params[:prompt_type] == "offer"
+ @index = @challenge_signup.offers.count
+ @prompt = @challenge_signup.offers.build
+ else
+ @index = @challenge_signup.requests.count
+ @prompt = @challenge_signup.requests.build
+ end
+ end
+
+ def edit
+ @index = @challenge_signup.send(@prompt.class.name.downcase.pluralize).index(@prompt)
+ end
+
+ def create
+ if params[:prompt_type] == "offer"
+ @prompt = @challenge_signup.offers.build(prompt_params)
+ else
+ @prompt = @challenge_signup.requests.build(prompt_params)
+ end
+
+ if @challenge_signup.save
+ flash[:notice] = ts("Prompt was successfully added.")
+ redirect_to collection_signup_path(@collection, @challenge_signup)
+ else
+ flash[:error] = ts("That prompt would make your overall sign-up invalid, sorry.")
+ redirect_to edit_collection_signup_path(@collection, @challenge_signup)
+ end
+ end
+
+ def update
+ if @prompt.update(prompt_params)
+ flash[:notice] = ts("Prompt was successfully updated.")
+ redirect_to collection_signup_path(@collection, @challenge_signup)
+ else
+ render action: :edit
+ end
+ end
+
+ def destroy
+ if !(@challenge.signup_open || @collection.user_is_maintainer?(current_user))
+ flash[:error] = ts("You cannot delete a prompt after sign-ups are closed. Please contact a moderator for help.")
+ else
+ if !@prompt.can_delete?
+ flash[:error] = ts("That would make your sign-up invalid, sorry! Please edit instead.")
+ else
+ @prompt.destroy
+ flash[:notice] = ts("Prompt was deleted.")
+ end
+ end
+ if @collection.user_is_maintainer?(current_user) && @collection.challenge_type == "PromptMeme"
+ redirect_to collection_requests_path(@collection)
+ elsif @prompt.challenge_signup
+ redirect_to collection_signup_path(@collection, @prompt.challenge_signup)
+ elsif @collection.user_is_maintainer?(current_user)
+ redirect_to collection_signups_path(@collection)
+ else
+ redirect_to @collection
+ end
+ end
+
+ private
+
+ def prompt_params
+ params.require(:prompt).permit(
+ :title,
+ :url,
+ :anonymous,
+ :description,
+ :any_fandom,
+ :any_character,
+ :any_relationship,
+ :any_freeform,
+ :any_category,
+ :any_rating,
+ :any_archive_warning,
+ tag_set_attributes: [
+ :fandom_tagnames,
+ :id,
+ :updated_at,
+ :character_tagnames,
+ :relationship_tagnames,
+ :freeform_tagnames,
+ :category_tagnames,
+ :rating_tagnames,
+ :archive_warning_tagnames,
+ fandom_tagnames: [],
+ character_tagnames: [],
+ relationship_tagnames: [],
+ freeform_tagnames: [],
+ category_tagnames: [],
+ rating_tagnames: [],
+ archive_warning_tagnames: []
+ ],
+ optional_tag_set_attributes: [
+ :tagnames
+ ]
+ )
+ end
+end
diff --git a/app/controllers/pseuds_controller.rb b/app/controllers/pseuds_controller.rb
new file mode 100755
index 0000000..b3f0428
--- /dev/null
+++ b/app/controllers/pseuds_controller.rb
@@ -0,0 +1,143 @@
+class PseudsController < ApplicationController
+ cache_sweeper :pseud_sweeper
+
+ before_action :load_user
+ before_action :check_ownership, only: [:create, :destroy, :new]
+ before_action :check_ownership_or_admin, only: [:edit, :update]
+ before_action :check_user_status, only: [:new, :create, :edit, :update]
+
+ def load_user
+ @user = User.find_by!(login: params[:user_id])
+ @check_ownership_of = @user
+ end
+
+ # GET /pseuds
+ # GET /pseuds.xml
+ def index
+ @pseuds = @user.pseuds.with_attached_icon.alphabetical.paginate(page: params[:page])
+ @rec_counts = Pseud.rec_counts_for_pseuds(@pseuds)
+ @work_counts = Pseud.work_counts_for_pseuds(@pseuds)
+ @page_subtitle = @user.login
+ end
+
+ # GET /users/:user_id/pseuds/:id
+ def show
+ @pseud = @user.pseuds.find_by!(name: params[:id])
+ @page_subtitle = @pseud.name
+
+ # very similar to show under users - if you change something here, change it there too
+ if logged_in? || logged_in_as_admin?
+ visible_works = @pseud.works.visible_to_registered_user
+ visible_series = @pseud.series.visible_to_registered_user
+ visible_bookmarks = @pseud.bookmarks.visible_to_registered_user
+ else
+ visible_works = @pseud.works.visible_to_all
+ visible_series = @pseud.series.visible_to_all
+ visible_bookmarks = @pseud.bookmarks.visible_to_all
+ end
+
+ visible_works = visible_works.revealed.non_anon
+ visible_series = visible_series.exclude_anonymous
+
+ @fandoms = \
+ Fandom.select("tags.*, count(DISTINCT works.id) as work_count")
+ .joins(:filtered_works).group("tags.id").merge(visible_works)
+ .where(filter_taggings: { inherited: false })
+ .order("work_count DESC").load
+
+ @works = visible_works.order("revised_at DESC").limit(ArchiveConfig.NUMBER_OF_ITEMS_VISIBLE_IN_DASHBOARD)
+ @series = visible_series.order("updated_at DESC").limit(ArchiveConfig.NUMBER_OF_ITEMS_VISIBLE_IN_DASHBOARD)
+ @bookmarks = visible_bookmarks.order("updated_at DESC").limit(ArchiveConfig.NUMBER_OF_ITEMS_VISIBLE_IN_DASHBOARD)
+
+ return unless current_user.respond_to?(:subscriptions)
+
+ @subscription = current_user.subscriptions.where(subscribable_id: @user.id,
+ subscribable_type: "User").first ||
+ current_user.subscriptions.build(subscribable: @user)
+ end
+
+ # GET /pseuds/new
+ # GET /pseuds/new.xml
+ def new
+ @pseud = @user.pseuds.build
+ end
+
+ # GET /pseuds/1/edit
+ def edit
+ @pseud = @user.pseuds.find_by!(name: params[:id])
+ authorize @pseud if logged_in_as_admin?
+ end
+
+ # POST /pseuds
+ # POST /pseuds.xml
+ def create
+ @pseud = Pseud.new(permitted_attributes(Pseud))
+ if @user.pseuds.where(name: @pseud.name).blank?
+ @pseud.user_id = @user.id
+ old_default = @user.default_pseud
+ if @pseud.save
+ flash[:notice] = t(".successfully_created")
+ if @pseud.is_default
+ # if setting this one as default, unset the attribute of the current default pseud
+ old_default.update_attribute(:is_default, false)
+ end
+ redirect_to polymorphic_path([@user, @pseud])
+ else
+ render action: "new"
+ end
+ else
+ # user tried to add pseud they already have
+ flash[:error] = t(".already_have_pseud_with_name")
+ render action: "new"
+ end
+ end
+
+ # PUT /pseuds/1
+ # PUT /pseuds/1.xml
+ def update
+ @pseud = @user.pseuds.find_by(name: params[:id])
+ authorize @pseud if logged_in_as_admin?
+ default = @user.default_pseud
+ if @pseud.update(permitted_attributes(@pseud))
+ if logged_in_as_admin? && @pseud.ticket_url.present?
+ link = view_context.link_to("Ticket ##{@pseud.ticket_number}", @pseud.ticket_url)
+ summary = "#{link} for User ##{@pseud.user_id}"
+ AdminActivity.log_action(current_admin, @pseud, action: "edit pseud", summary: summary)
+ end
+ # if setting this one as default, unset the attribute of the current default pseud
+ default.update_attribute(:is_default, false) if @pseud.is_default && default != @pseud
+ flash[:notice] = t(".successfully_updated")
+ redirect_to([@user, @pseud])
+ else
+ render action: "edit"
+ end
+ end
+
+ # DELETE /pseuds/1
+ # DELETE /pseuds/1.xml
+ def destroy
+ @hide_dashboard = true
+ if params[:cancel_button]
+ flash[:notice] = t(".not_deleted")
+ redirect_to(user_pseuds_path(@user)) && return
+ end
+
+ @pseud = @user.pseuds.find_by(name: params[:id])
+ if @pseud.is_default
+ flash[:error] = t(".cannot_delete_default")
+ elsif @pseud.name == @user.login
+ flash[:error] = t(".cannot_delete_matching_username")
+ elsif params[:bookmarks_action] == "transfer_bookmarks"
+ @pseud.change_bookmarks_ownership
+ @pseud.replace_me_with_default
+ flash[:notice] = t(".successfully_deleted")
+ elsif params[:bookmarks_action] == "delete_bookmarks" || @pseud.bookmarks.empty?
+ @pseud.replace_me_with_default
+ flash[:notice] = t(".successfully_deleted")
+ else
+ render "delete_preview" and return
+ end
+
+ redirect_to(user_pseuds_path(@user))
+ end
+end
diff --git a/app/controllers/questions_controller.rb b/app/controllers/questions_controller.rb
new file mode 100755
index 0000000..4c82934
--- /dev/null
+++ b/app/controllers/questions_controller.rb
@@ -0,0 +1,38 @@
+class QuestionsController < ApplicationController
+ before_action :load_archive_faq, except: :update_positions
+
+ # GET /archive_faq/:archive_faq_id/questions/manage
+ def manage
+ authorize :archive_faq, :full_access?
+ @questions = @archive_faq.questions.order("position")
+ end
+
+ # fetch archive_faq these questions belong to from db
+ def load_archive_faq
+ @archive_faq = ArchiveFaq.find_by(slug: params[:archive_faq_id])
+ unless @archive_faq.present?
+ flash[:error] = t("questions.not_found")
+ redirect_to root_path and return
+ end
+ end
+
+ # Update the position number of questions within a archive_faq
+ def update_positions
+ authorize :archive_faq, :full_access?
+ if params[:questions]
+ @archive_faq = ArchiveFaq.find_by(slug: params[:archive_faq_id])
+ @archive_faq.reorder_list(params[:questions])
+ flash[:notice] = t(".success")
+ elsif params[:question]
+ params[:question].each_with_index do |id, position|
+ Question.update(id, position: position + 1)
+ (@questions ||= []) << Question.find(id)
+ end
+ flash[:notice] = t(".success")
+ end
+ respond_to do |format|
+ format.html { redirect_to(@archive_faq) and return }
+ format.js { render nothing: true }
+ end
+ end
+end
diff --git a/app/controllers/readings_controller.rb b/app/controllers/readings_controller.rb
new file mode 100755
index 0000000..e68d001
--- /dev/null
+++ b/app/controllers/readings_controller.rb
@@ -0,0 +1,70 @@
+class ReadingsController < ApplicationController
+ before_action :users_only
+ before_action :load_user
+ before_action :check_ownership
+ before_action :check_history_enabled
+
+ def load_user
+ @user = User.find_by(login: params[:user_id])
+ @check_ownership_of = @user
+ end
+
+ def index
+ @readings = @user.readings.visible
+ @page_subtitle = t(".history_page_title")
+ if params[:show] == 'to-read'
+ @readings = @readings.where(toread: true)
+ @page_subtitle = t(".marked_for_later_page_title")
+ end
+ @readings = @readings.order("last_viewed DESC")
+ @pagy, @readings = pagy(@readings)
+ end
+
+ def destroy
+ @reading = @user.readings.find(params[:id])
+ if @reading.destroy
+ success_message = ts('Work successfully deleted from your history.')
+ respond_to do |format|
+ format.html { redirect_to request.referer || user_readings_path(current_user, page: params[:page]), notice: success_message }
+ format.json { render json: { item_success_message: success_message }, status: :ok }
+ end
+ else
+ respond_to do |format|
+ format.html do
+ flash.keep
+ redirect_to request.referer || user_readings_path(current_user, page: params[:page]), flash: { error: @reading.errors.full_messages }
+ end
+ format.json { render json: { errors: @reading.errors.full_messages }, status: :unprocessable_entity }
+ end
+ end
+ end
+
+ def clear
+ success = true
+
+ @user.readings.each do |reading|
+ reading.destroy!
+ rescue ActiveRecord::RecordNotDestroyed
+ success = false
+ end
+
+ if success
+ flash[:notice] = t(".success")
+ else
+ flash[:error] = t(".error")
+ end
+
+ redirect_to user_readings_path(current_user)
+ end
+
+ protected
+
+ # checks if user has history enabled and redirects to preferences if not, so they can potentially change it
+ def check_history_enabled
+ unless current_user.preference.history_enabled?
+ flash[:notice] = ts("You have reading history disabled in your preferences. Change it below if you'd like us to keep track of it.")
+ redirect_to user_preferences_path(current_user)
+ end
+ end
+
+end
diff --git a/app/controllers/redirect_controller.rb b/app/controllers/redirect_controller.rb
new file mode 100755
index 0000000..320cc5c
--- /dev/null
+++ b/app/controllers/redirect_controller.rb
@@ -0,0 +1,29 @@
+class RedirectController < ApplicationController
+
+ def index
+ do_redirect
+ end
+
+ def do_redirect
+ url = params[:original_url]
+ if url.blank?
+ flash[:error] = ts("What url did you want to look up?")
+ else
+ @work = Work.find_by_url(url)
+ if @work
+ flash[:notice] = ts("You have been redirected here from %{url}. Please update the original link if possible!", url: url)
+ redirect_to work_path(@work) and return
+ else
+ flash[:error] = ts("We could not find a work imported from that url in the Archive of Our Own, sorry! Try another url?")
+ end
+ end
+ redirect_to redirect_path
+ end
+
+ def show
+ if params[:original_url].present?
+ redirect_to action: :do_redirect, original_url: params[:original_url] and return
+ end
+ end
+
+end
diff --git a/app/controllers/related_works_controller.rb b/app/controllers/related_works_controller.rb
new file mode 100755
index 0000000..9c98f19
--- /dev/null
+++ b/app/controllers/related_works_controller.rb
@@ -0,0 +1,96 @@
+class RelatedWorksController < ApplicationController
+
+ before_action :load_user, only: [:index]
+ before_action :users_only, except: [:index]
+ before_action :get_instance_variables, except: [:index]
+
+ def index
+ @page_subtitle = t(".page_title", login: @user.login)
+ @translations_of_user = @user.related_works.posted.where(translation: true)
+ @remixes_of_user = @user.related_works.posted.where(translation: false)
+ @translations_by_user = @user.parent_work_relationships.posted.where(translation: true)
+ @remixes_by_user = @user.parent_work_relationships.posted.where(translation: false)
+
+ return if @user == current_user
+
+ # Extra constraints on what we display if someone else is viewing @user's
+ # related works page:
+ @translations_of_user = @translations_of_user.merge(Work.revealed.non_anon)
+ @remixes_of_user = @remixes_of_user.merge(Work.revealed.non_anon)
+ @translations_by_user = @translations_by_user.merge(Work.revealed.non_anon)
+ @remixes_by_user = @remixes_by_user.merge(Work.revealed.non_anon)
+ end
+
+ # GET /related_works/1
+ # GET /related_works/1.xml
+ def show
+ end
+
+ def update
+ # updates are done by the owner of the parent, to aprove or remove links on the parent work.
+ unless @user
+ if current_user_owns?(@child)
+ flash[:error] = ts("Sorry, but you don't have permission to do that. Try removing the link from your own work.")
+ redirect_back_or_default(user_related_works_path(current_user))
+ return
+ else
+ flash[:error] = ts("Sorry, but you don't have permission to do that.")
+ redirect_back_or_default(root_path)
+ return
+ end
+ end
+ # the assumption here is that any update is a toggle from what was before
+ @related_work.reciprocal = !@related_work.reciprocal?
+ if @related_work.update_attribute(:reciprocal, @related_work.reciprocal)
+ notice = @related_work.reciprocal? ? ts("Link was successfully approved") :
+ ts("Link was successfully removed")
+ flash[:notice] = notice
+ redirect_to(@related_work.parent)
+ else
+ flash[:error] = ts('Sorry, something went wrong.')
+ redirect_to(@related_work)
+ end
+ end
+
+ def destroy
+ # destroys are done by the owner of the child, to remove links to the parent work which also removes the link back if it exists.
+ unless current_user_owns?(@child)
+ if @user
+ flash[:error] = ts("Sorry, but you don't have permission to do that. You can only approve or remove the link from your own work.")
+ redirect_back_or_default(user_related_works_path(current_user))
+ return
+ else
+ flash[:error] = ts("Sorry, but you don't have permission to do that.")
+ redirect_back_or_default(root_path)
+ return
+ end
+ end
+ @related_work.destroy
+ redirect_back(fallback_location: user_related_works_path(current_user))
+ end
+
+ private
+
+ def load_user
+ if params[:user_id].blank?
+ flash[:error] = ts("Whose related works were you looking for?")
+ redirect_back_or_default(search_people_path)
+ else
+ @user = User.find_by(login: params[:user_id])
+ if @user.blank?
+ flash[:error] = ts("Sorry, we couldn't find that user")
+ redirect_back_or_default(root_path)
+ end
+ end
+ end
+
+ def get_instance_variables
+ @related_work = RelatedWork.find(params[:id])
+ @child = @related_work.work
+ if @related_work.parent.is_a? (Work)
+ @owners = @related_work.parent.pseuds.map(&:user)
+ @user = current_user if @owners.include?(current_user)
+ end
+ end
+
+end
diff --git a/app/controllers/serial_works_controller.rb b/app/controllers/serial_works_controller.rb
new file mode 100755
index 0000000..fbdec65
--- /dev/null
+++ b/app/controllers/serial_works_controller.rb
@@ -0,0 +1,25 @@
+# Controller for Serial Works
+class SerialWorksController < ApplicationController
+
+ before_action :load_serial_work
+ before_action :check_ownership
+
+ def load_serial_work
+ @serial_work = SerialWork.find(params[:id])
+ @check_ownership_of = @serial_work.series
+ end
+
+ # DELETE /related_works/1
+ # Updated so if last work in series is deleted redirects to current user works listing instead of throwing 404
+ def destroy
+ last_work = (@serial_work.series.works.count <= 1)
+
+ @serial_work.destroy
+
+ if last_work
+ redirect_to current_user
+ else
+ redirect_to series_path(@serial_work.series)
+ end
+ end
+end
diff --git a/app/controllers/series_controller.rb b/app/controllers/series_controller.rb
new file mode 100755
index 0000000..ca1757e
--- /dev/null
+++ b/app/controllers/series_controller.rb
@@ -0,0 +1,157 @@
+class SeriesController < ApplicationController
+ before_action :check_user_status, only: [:new, :create, :edit, :update]
+ before_action :load_series, only: [:show, :edit, :remove_user_creatorship, :update, :manage, :destroy, :confirm_delete]
+ before_action :check_ownership, only: [:edit, :update, :manage, :destroy, :confirm_delete]
+ before_action :check_visibility, only: [:show]
+
+ def load_series
+ @series = Series.find_by(id: params[:id])
+ unless @series
+ raise ActiveRecord::RecordNotFound, "Couldn't find series '#{params[:id]}'"
+ end
+ @check_ownership_of = @series
+ @check_visibility_of = @series
+ end
+
+ # GET /series
+ # GET /series.xml
+ def index
+ unless params[:user_id]
+ flash[:error] = ts("Whose series did you want to see?")
+ redirect_to(root_path) and return
+ end
+ @user = User.find_by!(login: params[:user_id])
+ @page_subtitle = t(".page_title", username: @user.login)
+
+ @series = if current_user.nil?
+ Series.visible_to_all
+ else
+ Series.visible_to_registered_user
+ end
+
+ if params[:pseud_id]
+ @pseud = @user.pseuds.find_by!(name: params[:pseud_id])
+ @page_subtitle = t(".page_title", username: @pseud.name)
+ @series = @series.exclude_anonymous.for_pseud(@pseud)
+ else
+ @series = @series.exclude_anonymous.for_user(@user)
+ end
+ @series = @series.paginate(page: params[:page])
+ end
+
+ # GET /series/1
+ # GET /series/1.xml
+ def show
+ @works = @series.works_in_order.posted.select(&:visible?).paginate(page: params[:page])
+
+ # sets the page title with the data for the series
+ if @series.unrevealed?
+ @page_subtitle = t(".unrevealed_series")
+ else
+ @page_title = get_page_title(@series.allfandoms.collect(&:name).join(t("support.array.words_connector")), @series.anonymous? ? t(".anonymous") : @series.allpseuds.collect(&:byline).join(t("support.array.words_connector")), @series.title)
+ end
+
+ if current_user.respond_to?(:subscriptions)
+ @subscription = current_user.subscriptions.where(subscribable_id: @series.id,
+ subscribable_type: 'Series').first ||
+ current_user.subscriptions.build(subscribable: @series)
+ end
+ end
+
+ # GET /series/new
+ # GET /series/new.xml
+ def new
+ @series = Series.new
+ end
+
+ # GET /series/1/edit
+ def edit
+ end
+
+ def remove_user_creatorship
+ pseuds_with_author_removed = @series.pseuds - current_user.pseuds
+ if pseuds_with_author_removed.empty?
+ redirect_to controller: "orphans", action: "new", series_id: @series.id
+ else
+ begin
+ @series.remove_author(current_user)
+ flash[:notice] = ts("You have been removed as a creator from the series and its works.")
+ redirect_to @series
+ rescue StandardError => e
+ flash[:error] = e.message
+ redirect_to @series
+ end
+ end
+ end
+
+ # GET /series/1/manage
+ def manage
+ @serial_works = @series.serial_works.includes(:work).order(:position)
+ end
+
+ # POST /series
+ # POST /series.xml
+ def create
+ @series = Series.new(series_params)
+ if @series.save
+ flash[:notice] = ts('Series was successfully created.')
+ redirect_to(@series)
+ else
+ render action: "new"
+ end
+ end
+
+ # PUT /series/1
+ # PUT /series/1.xml
+ def update
+ @series.attributes = series_params
+ if @series.errors.empty? && @series.save
+ flash[:notice] = ts('Series was successfully updated.')
+ redirect_to(@series)
+ else
+ render action: "edit"
+ end
+ end
+
+ def update_positions
+ if params[:serial_works]
+ @series = Series.find(params[:id])
+ @series.reorder_list(params[:serial_works])
+ flash[:notice] = ts("Series order has been successfully updated.")
+ elsif params[:serial]
+ params[:serial].each_with_index do |id, position|
+ SerialWork.update(id, position: position + 1)
+ (@serial_works ||= []) << SerialWork.find(id)
+ end
+ end
+ respond_to do |format|
+ format.html { redirect_to series_path(@series) and return }
+ format.json { head :ok }
+ end
+ end
+
+ # GET /series/1/confirm_delete
+ def confirm_delete
+ end
+
+ # DELETE /series/1
+ # DELETE /series/1.xml
+ def destroy
+ if @series.destroy
+ flash[:notice] = ts("Series was successfully deleted.")
+ redirect_to(current_user)
+ else
+ flash[:error] = ts("Sorry, we couldn't delete the series. Please try again.")
+ redirect_to(@series)
+ end
+ end
+
+ private
+
+ def series_params
+ params.require(:series).permit(
+ :title, :summary, :series_notes, :complete,
+ author_attributes: [:byline, ids: [], coauthors: []]
+ )
+ end
+end
diff --git a/app/controllers/skins_controller.rb b/app/controllers/skins_controller.rb
new file mode 100755
index 0000000..4351e88
--- /dev/null
+++ b/app/controllers/skins_controller.rb
@@ -0,0 +1,223 @@
+class SkinsController < ApplicationController
+ before_action :users_only, only: [:new, :create, :destroy]
+ before_action :load_skin, except: [:index, :new, :create, :unset]
+ before_action :check_ownership_or_admin, only: [:edit, :update]
+ before_action :check_ownership, only: [:confirm_delete, :destroy]
+ before_action :check_visibility, only: [:show]
+ before_action :check_editability, only: [:edit, :update, :confirm_delete, :destroy]
+
+ #### ACTIONS
+
+ # GET /skins
+ def index
+ is_work_skin = params[:skin_type] && params[:skin_type] == "WorkSkin"
+ if current_user && current_user.is_a?(User)
+ @preference = current_user.preference
+ end
+ if params[:user_id] && (@user = User.find_by(login: params[:user_id]))
+ redirect_to new_user_session_path and return unless logged_in?
+ if @user != current_user
+ flash[:error] = "You can only browse your own skins and approved public skins."
+ redirect_to skins_path and return
+ end
+ if is_work_skin
+ @skins = @user.work_skins.sort_by_recent.includes(:author).with_attached_icon
+ @title = ts('My Work Skins')
+ else
+ @skins = @user.skins.site_skins.sort_by_recent.includes(:author).with_attached_icon
+ @title = ts('My Site Skins')
+ end
+ else
+ if is_work_skin
+ @skins = WorkSkin.approved_skins.sort_by_recent_featured.includes(:author).with_attached_icon
+ @title = ts('Public Work Skins')
+ else
+ @skins = if logged_in?
+ Skin.approved_skins.usable.site_skins.sort_by_recent_featured.with_attached_icon
+ else
+ Skin.approved_skins.usable.site_skins.cached.sort_by_recent_featured.with_attached_icon
+ end
+ @title = ts('Public Site Skins')
+ end
+ end
+ end
+
+ # GET /skins/1
+ def show
+ @page_subtitle = @skin.title.html_safe
+ end
+
+ # GET /skins/new
+ def new
+ @skin = Skin.new
+ if params[:wizard]
+ render :new_wizard
+ else
+ render :new
+ end
+ end
+
+ # POST /skins
+ def create
+ unless params[:skin_type].nil? || params[:skin_type] && %w(Skin WorkSkin).include?(params[:skin_type])
+ flash[:error] = ts("What kind of skin did you want to create?")
+ redirect_to :new and return
+ end
+ loaded = load_archive_parents unless params[:skin_type] && params[:skin_type] == 'WorkSkin'
+ if params[:skin_type] == "WorkSkin"
+ @skin = WorkSkin.new(skin_params)
+ else
+ @skin = Skin.new(skin_params)
+ end
+ @skin.author = current_user
+ if @skin.save
+ flash[:notice] = ts("Skin was successfully created.")
+ if loaded
+ flash[:notice] += ts(" We've added all the archive skin components as parents. You probably want to remove some of them now!")
+ redirect_to edit_skin_path(@skin)
+ else
+ redirect_to skin_path(@skin)
+ end
+ else
+ if params[:wizard]
+ render :new_wizard
+ else
+ render :new
+ end
+ end
+ end
+
+ # GET /skins/1/edit
+ def edit
+ authorize @skin if logged_in_as_admin?
+ end
+
+ def update
+ authorize @skin if logged_in_as_admin?
+
+ loaded = load_archive_parents
+ if @skin.update(skin_params)
+ @skin.cache! if @skin.cached?
+ @skin.recache_children!
+ flash[:notice] = ts("Skin was successfully updated.")
+ if loaded
+ if flash[:error].present?
+ flash[:notice] = ts("Any other edits were saved.")
+ else
+ flash[:notice] += ts(" We've added all the archive skin components as parents. You probably want to remove some of them now!")
+ end
+ redirect_to edit_skin_path(@skin)
+ else
+ redirect_to @skin
+ end
+ else
+ render action: "edit"
+ end
+ end
+
+ # Get /skins/1/preview
+ def preview
+ flash[:notice] = []
+ flash[:notice] << ts("You are previewing the skin %{title}. This is a randomly chosen page.", title: @skin.title)
+ flash[:notice] << ts("Go back or click any link to remove the skin.")
+ flash[:notice] << ts("Tip: You can preview any archive page you want by tacking on '?site_skin=[skin_id]' like you can see in the url above.")
+ flash[:notice] << "".html_safe + ts("Return To Skin To Use") + "".html_safe
+ tag = FilterCount.where("public_works_count BETWEEN 10 AND 20").random_order.first.filter
+ redirect_to tag_works_path(tag, site_skin: @skin.id)
+ end
+
+ def set
+ if @skin.cached?
+ flash[:notice] = ts("The skin %{title} has been set. This will last for your current session.", title: @skin.title)
+ session[:site_skin] = @skin.id
+ else
+ flash[:error] = ts("Sorry, but only certain skins can be used this way (for performance reasons). Please drop a support request if you'd like %{title} to be added!", title: @skin.title)
+ end
+ redirect_to(request.referer || @skin)
+ end
+
+ def unset
+ session[:site_skin] = nil
+ if logged_in? && current_user.preference
+ current_user.preference.skin_id = AdminSetting.default_skin_id
+ current_user.preference.save
+ end
+ flash[:notice] = ts("You are now using the default Archive skin again!")
+ redirect_to(request.referer || root_path)
+ end
+
+ # GET /skins/1/confirm_delete
+ def confirm_delete
+ end
+
+ # DELETE /skins/1
+ def destroy
+ @skin = Skin.find_by(id: params[:id])
+ begin
+ @skin.destroy
+ flash[:notice] = ts("The skin was deleted.")
+ rescue
+ flash[:error] = ts("We couldn't delete that right now, sorry! Please try again later.")
+ end
+
+ if current_user && current_user.is_a?(User) && current_user.preference.skin_id == @skin.id
+ current_user.preference.skin_id = AdminSetting.default_skin_id
+ current_user.preference.save
+ end
+ redirect_to user_skins_path(current_user) rescue redirect_to skins_path
+ end
+
+ private
+
+ def skin_params
+ params.require(:skin).permit(
+ :title, :description, :public, :css, :role, :ie_condition, :unusable,
+ :font, :base_em, :margin, :paragraph_margin, :background_color,
+ :foreground_color, :headercolor, :accent_color, :icon,
+ media: [],
+ skin_parents_attributes: [
+ :id, :position, :parent_skin_id, :parent_skin_title, :_destroy
+ ]
+ )
+ end
+
+ def load_skin
+ @skin = Skin.find_by(id: params[:id])
+ unless @skin
+ flash[:error] = "Skin not found"
+ redirect_to skins_path and return
+ end
+ @check_ownership_of = @skin
+ @check_visibility_of = @skin
+ end
+
+ def check_editability
+ unless @skin.editable?
+ flash[:error] = ts("Sorry, you don't have permission to edit this skin")
+ redirect_to @skin
+ end
+ end
+
+ # if we've been asked to load the archive parents, we do so and add them to params
+ def load_archive_parents
+ if params[:add_site_parents]
+ params[:skin][:skin_parents_attributes] ||= ActionController::Parameters.new
+ archive_parents = Skin.get_current_site_skin.get_all_parents
+ skin_parent_titles = params[:skin][:skin_parents_attributes].values.map { |v| v[:parent_skin_title] }
+ skin_parents = skin_parent_titles.empty? ? [] : Skin.where(title: skin_parent_titles).pluck(:id)
+ skin_parents += @skin.get_all_parents.collect(&:id) if @skin
+ unless (skin_parents.uniq & archive_parents.map(&:id)).empty?
+ flash[:error] = ts("You already have some of the archive components as parents, so we couldn't load the others. Please remove the existing components first if you really want to do this!")
+ return true
+ end
+ last_position = params[:skin][:skin_parents_attributes]&.keys&.map(&:to_i)&.max || 0
+ archive_parents.each do |parent_skin|
+ last_position += 1
+ new_skin_parent_hash = ActionController::Parameters.new({ position: last_position, parent_skin_id: parent_skin.id })
+ params[:skin][:skin_parents_attributes].merge!({last_position.to_s => new_skin_parent_hash})
+ end
+ return true
+ end
+ false
+ end
+end
diff --git a/app/controllers/stats_controller.rb b/app/controllers/stats_controller.rb
new file mode 100755
index 0000000..8ebb530
--- /dev/null
+++ b/app/controllers/stats_controller.rb
@@ -0,0 +1,130 @@
+class StatsController < ApplicationController
+
+ before_action :users_only
+ before_action :load_user
+ before_action :check_ownership
+
+ # only the current user
+ def load_user
+ @user = current_user
+ @check_ownership_of = @user
+ end
+
+ # gather statistics for the user on all their works
+ def index
+ user_works = Work.joins(pseuds: :user).where(users: { id: @user.id }).where(posted: true)
+ user_chapters = Chapter.joins(pseuds: :user).where(users: { id: @user.id }).where(posted: true)
+ work_query = user_works
+ .joins(:taggings)
+ .joins("inner join tags on taggings.tagger_id = tags.id AND tags.type = 'Fandom'")
+ .select("distinct tags.name as fandom, works.id as id, works.title as title")
+
+ # sort
+
+ # NOTE: Because we are going to be eval'ing the @sort variable later we MUST make sure that its content is
+ # checked against the allowlist of valid options
+ sort_options = %w[hits date kudos.count comment_thread_count bookmarks.count subscriptions.count word_count]
+ @sort = sort_options.include?(params[:sort_column]) ? params[:sort_column] : "hits"
+
+ @dir = params[:sort_direction] == "ASC" ? "ASC" : "DESC"
+ params[:sort_column] = @sort
+ params[:sort_direction] = @dir
+
+ # gather works and sort by specified count
+ @years = ["All Years"] + user_chapters.pluck(:published_at).map { |date| date.year.to_s }
+ .uniq.sort
+ @current_year = @years.include?(params[:year]) ? params[:year] : "All Years"
+ if @current_year == "All Years"
+ work_query = work_query.select("works.revised_at as date, works.word_count as word_count")
+ else
+ next_year = @current_year.to_i + 1
+ start_date = DateTime.parse("01/01/#{@current_year}")
+ end_date = DateTime.parse("01/01/#{next_year}")
+ work_query = work_query
+ .joins(:chapters)
+ .where("chapters.posted = 1 AND chapters.published_at >= ? AND chapters.published_at < ?", start_date, end_date)
+ .select("CONVERT(MAX(chapters.published_at), datetime) as date, SUM(chapters.word_count) as word_count")
+ .group(:id, :fandom)
+ end
+ works = work_query.all.sort_by { |w| @dir == "ASC" ? (stat_element(w, @sort) || 0) : (0 - (stat_element(w, @sort) || 0).to_i) }
+
+ # on the off-chance a new user decides to look at their stats and have no works
+ render "no_stats" and return if works.blank?
+
+ # group by fandom or flat view
+ if params[:flat_view]
+ @works = {ts("All Fandoms") => works.uniq}
+ else
+ @works = works.group_by(&:fandom)
+ end
+
+ # gather totals for all works
+ @totals = {}
+ (sort_options - ["date"]).each do |value|
+ # the inject is used to collect the sum in the "result" variable as we iterate over all the works
+ @totals[value.split(".")[0].to_sym] = works.uniq.inject(0) { |result, work| result + (stat_element(work, value) || 0) } # sum the works
+ end
+ @totals[:user_subscriptions] = Subscription.where(subscribable_id: @user.id, subscribable_type: 'User').count
+
+ # graph top 5 works
+ @chart_data = GoogleVisualr::DataTable.new
+ @chart_data.new_column('string', 'Title')
+
+ chart_col = @sort == "date" ? "hits" : @sort
+ chart_col_title = chart_col.split(".")[0].titleize == "Comments" ? ts("Comment Threads") : chart_col.split(".")[0].titleize
+ if @sort == "date" && @dir == "ASC"
+ chart_title = ts("Oldest")
+ elsif @sort == "date" && @dir == "DESC"
+ chart_title = ts("Most Recent")
+ elsif @dir == "ASC"
+ chart_title = ts("Bottom Five By #{chart_col_title}")
+ else
+ chart_title = ts("Top Five By #{chart_col_title}")
+ end
+ @chart_data.new_column('number', chart_col_title)
+
+ # Add Rows and Values
+ @chart_data.add_rows(works.uniq[0..4].map { |w| [w.title, stat_element(w, chart_col)] })
+
+ # image version of bar chart
+ # opts from here: http://code.google.com/apis/chart/image/docs/gallery/bar_charts.html
+ @image_chart = GoogleVisualr::Image::BarChart.new(@chart_data, {isVertical: true}).uri({
+ chtt: chart_title,
+ chs: "800x350",
+ chbh: "a",
+ chxt: "x",
+ chm: "N,000000,0,-1,11"
+ })
+
+ options = {
+ colors: ["#993333"],
+ title: chart_title,
+ vAxis: {
+ viewWindow: { min: 0 }
+ }
+ }
+ @chart = GoogleVisualr::Interactive::ColumnChart.new(@chart_data, options)
+
+ end
+
+ private
+
+ def stat_element(work, element)
+ case element.downcase
+ when "date"
+ work.date
+ when "hits"
+ work.hits
+ when "kudos.count"
+ work.kudos.count
+ when "comment_thread_count"
+ work.comment_thread_count
+ when "bookmarks.count"
+ work.bookmarks.count
+ when "subscriptions.count"
+ work.subscriptions.count
+ when "word_count"
+ work.word_count
+ end
+ end
+end
diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb
new file mode 100644
index 0000000..bb5449b
--- /dev/null
+++ b/app/controllers/statuses_controller.rb
@@ -0,0 +1,112 @@
+class StatusesController < ApplicationController
+ before_action :users_only, except: [:index, :show, :timeline]
+ before_action :set_status, only: [:show, :edit, :update, :destroy]
+ before_action :set_user, except: [:timeline]
+ before_action :check_ownership, only: [:edit, :update, :destroy]
+
+
+ def redirect_to_current_user
+ if current_user
+ redirect_to user_status_path(current_user)
+ else
+ redirect_to new_user_session_path, alert: "Please log in to view statuses."
+ end
+ end
+ def check_ownership
+ unless @status && @status.user == current_user
+ redirect_to user_status_path(@user || current_user), alert: "You don't have permission to do that silly!"
+ end
+end
+
+ def edit
+ @user = current_user
+ end
+
+ def update
+ @user = current_user
+ if @status.update(status_params)
+ redirect_to user_status_path(@user, @status), notice: "Status updated!"
+ else
+ render :edit
+ end
+ end
+
+ def show
+ end
+ def destroy
+ @status.destroy
+ redirect_to user_statuses_path(@user), notice: "Status deleted."
+ end
+
+ def redirect_to_current_user_new
+ if current_user
+ redirect_to new_user_status_path(current_user)
+ else
+ redirect_to new_user_session_path, alert: "Please log in to create a status."
+ end
+ end
+ def index
+ if params[:user_id].present?
+ @user = User.find_by(id: params[:user_id]) || User.find_by(login: params[:user_id])
+ end
+ if @user
+ @statuses = @user.statuses.order(created_at: :desc)
+ else
+ @statuses = []
+ end
+end
+ def new
+ if @user != current_user
+ redirect_to user_statuses_path(current_user, @status), alert: "You can't do that silly!"
+ return
+ end
+ @status = current_user.statuses.new
+end
+ def create
+ if @user != current_user
+ redirect_to user_status_path(current_user), alert: "You can't do that silly!"
+ return
+ end
+ @status = current_user.statuses.new(status_params)
+ if @status.save
+ redirect_to user_statuses_path(current_user, @status), notice: "Status created!"
+ else
+ render :new
+ end
+ end
+ def timeline
+ @statuses = Status.includes(:user, :icon_attachment)
+ .order(created_at: :desc)
+ end
+ private
+
+def set_status
+ return unless params[:id].present?
+
+ @status = Status.find_by(id: params[:id])
+ unless @status
+ redirect_to user_statuses_path(current_user), alert: "Status not found."
+ return
+ end
+
+ @user = @status.user
+end
+
+def set_user
+ return if @user.present?
+ @user = if params[:user_id].present?
+ User.find_by(id: params[:user_id]) || User.find_by(login: params[:user_id])
+ else
+ current_user
+ end
+
+ unless @user
+ redirect_to root_path, alert: "User not found."
+ end
+end
+
+def status_params
+ params.require(:status).permit(:icon, :text, :mood, :music)
+ end
+end
+
diff --git a/app/controllers/subscriptions_controller.rb b/app/controllers/subscriptions_controller.rb
new file mode 100755
index 0000000..355a2ce
--- /dev/null
+++ b/app/controllers/subscriptions_controller.rb
@@ -0,0 +1,97 @@
+class SubscriptionsController < ApplicationController
+
+ skip_before_action :store_location, only: [:create, :destroy]
+
+ before_action :users_only
+ before_action :load_user
+ before_action :load_subscribable_type, only: [:index, :confirm_delete_all, :delete_all]
+ before_action :check_ownership
+
+ def load_user
+ @user = User.find_by(login: params[:user_id])
+ @check_ownership_of = @user
+ end
+
+ # GET /subscriptions
+ # GET /subscriptions.xml
+ def index
+ @subscriptions = @user.subscriptions.includes(:subscribable)
+ @subscriptions = @subscriptions.where(subscribable_type: @subscribable_type.classify) if @subscribable_type
+
+ @subscriptions = @subscriptions.to_a.sort { |a,b| a.name.downcase <=> b.name.downcase }
+ @subscriptions = @subscriptions.paginate page: params[:page], per_page: ArchiveConfig.ITEMS_PER_PAGE
+ @page_subtitle = @subscribable_type ? t(".subscription_type_page_title", username: @user.login, subscription_type: @subscribable_type.classify) : t(".page_title", username: @user.login)
+ end
+
+ # POST /subscriptions
+ # POST /subscriptions.xml
+ def create
+ @subscription = @user.subscriptions.build(subscription_params)
+
+ success_message = ts("You are now following %{name}. If you'd like to stop receiving email updates, you can unsubscribe from your Subscriptions page.", name: @subscription.name).html_safe
+ if @subscription.save
+ respond_to do |format|
+ format.html { redirect_to request.referer || @subscription.subscribable, notice: success_message }
+ format.json { render json: { item_id: @subscription.id, item_success_message: success_message }, status: :created }
+ end
+ else
+ respond_to do |format|
+ format.html {
+ flash.keep
+ redirect_to request.referer || @subscription.subscribable, flash: { error: @subscription.errors.full_messages }
+ }
+ format.json { render json: { errors: @subscription.errors.full_messages }, status: :unprocessable_entity }
+ end
+ end
+ end
+
+ # DELETE /subscriptions/1
+ # DELETE /subscriptions/1.xml
+ def destroy
+ @subscription = Subscription.find(params[:id])
+ @subscribable = @subscription.subscribable
+ @subscription.destroy
+
+ success_message = ts("You have successfully unsubscribed from %{name}.", name: @subscription.name).html_safe
+ respond_to do |format|
+ format.html { redirect_to request.referer || user_subscriptions_path(current_user), notice: success_message }
+ format.json { render json: { item_success_message: success_message }, status: :ok }
+ end
+ end
+
+ def confirm_delete_all
+ end
+
+ def delete_all
+ @subscriptions = @user.subscriptions
+ @subscriptions = @subscriptions.where(subscribable_type: @subscribable_type.classify) if @subscribable_type
+
+ success = true
+ @subscriptions.each do |subscription|
+ subscription.destroy!
+ rescue StandardError
+ success = false
+ end
+
+ if success
+ flash[:notice] = t(".success")
+ else
+ flash[:error] = t(".error")
+ end
+
+ redirect_to user_subscriptions_path(current_user, type: @subscribable_type)
+ end
+
+ private
+
+ def load_subscribable_type
+ @subscribable_type = params[:type].pluralize.downcase if params[:type] && Subscription::VALID_SUBSCRIBABLES.include?(params[:type].singularize.titleize)
+ end
+
+ def subscription_params
+ params.require(:subscription).permit(
+ :subscribable_id, :subscribable_type
+ )
+ end
+
+end
diff --git a/app/controllers/tag_set_associations_controller.rb b/app/controllers/tag_set_associations_controller.rb
new file mode 100755
index 0000000..4aa3508
--- /dev/null
+++ b/app/controllers/tag_set_associations_controller.rb
@@ -0,0 +1,77 @@
+class TagSetAssociationsController < ApplicationController
+ cache_sweeper :tag_set_sweeper
+
+ before_action :load_tag_set
+ before_action :users_only
+ before_action :moderators_only
+
+ def load_tag_set
+ @tag_set = OwnedTagSet.find_by(id: params[:tag_set_id])
+ unless @tag_set
+ flash[:error] = ts("What tag set did you want to look at?")
+ redirect_to tag_sets_path and return
+ end
+ end
+
+ def moderators_only
+ @tag_set.user_is_moderator?(current_user) || access_denied
+ end
+
+ def index
+ get_tags_to_associate
+ end
+
+ def update_multiple
+ # we get params like "create_association_[tag_id]_[parent_tagname]"
+ @errors = []
+ params.each_pair do |key, val|
+ next unless val.present?
+ if key.match(/^create_association_(\d+)_(.*?)$/)
+ tag_id = $1
+ parent_tagname = $2
+
+ # fix back the tagnames if they have [] brackets -- see _review_individual_nom for details
+ parent_tagname = parent_tagname.gsub('#LBRACKET', '[').gsub('#RBRACKET', ']')
+
+ assoc = @tag_set.tag_set_associations.build(tag_id: tag_id, parent_tagname: parent_tagname, create_association: true)
+ if assoc.valid?
+ assoc.save
+ else
+ @errors += assoc.errors.full_messages
+ end
+ end
+ end
+
+ if @errors.empty?
+ flash[:notice] = ts("Nominated associations were added.")
+ redirect_to tag_set_path(@tag_set)
+ else
+ flash[:error] = ts("We couldn't add all of your specified associations. See more detailed errors below!")
+ get_tags_to_associate
+ render action: :index
+ end
+ end
+
+
+
+ protected
+ def get_tags_to_associate
+ # get the tags for which we have a parent nomination which doesn't already exist in the database
+ @tags_to_associate = Tag.joins(:set_taggings).where("set_taggings.tag_set_id = ?", @tag_set.tag_set_id).
+ joins("INNER JOIN tag_nominations ON tag_nominations.tagname = tags.name").
+ joins("INNER JOIN tag_set_nominations ON tag_nominations.tag_set_nomination_id = tag_set_nominations.id").
+ where("tag_set_nominations.owned_tag_set_id = ?", @tag_set.id).
+ where("tag_nominations.parented = 0 AND tag_nominations.rejected != 1 AND EXISTS
+ (SELECT * from tags WHERE tags.name = tag_nominations.parent_tagname)")
+
+ # skip already associated tags
+ associated_tag_ids = TagSetAssociation.where(owned_tag_set_id: @tag_set.id).pluck :tag_id
+ @tags_to_associate = @tags_to_associate.where("tags.id NOT IN (?)", associated_tag_ids) unless associated_tag_ids.empty?
+
+ # now get out just the tags and nominated parent tagnames
+ @tags_to_associate = @tags_to_associate.select("DISTINCT tags.id, tags.name, tag_nominations.parent_tagname").
+ order("tag_nominations.parent_tagname ASC, tags.name ASC")
+
+ end
+
+end
diff --git a/app/controllers/tag_set_nominations_controller.rb b/app/controllers/tag_set_nominations_controller.rb
new file mode 100755
index 0000000..1b45bcd
--- /dev/null
+++ b/app/controllers/tag_set_nominations_controller.rb
@@ -0,0 +1,371 @@
+class TagSetNominationsController < ApplicationController
+ cache_sweeper :tag_set_sweeper
+
+ before_action :users_only
+ before_action :load_tag_set, except: [ :index ]
+ before_action :check_pseud_ownership, only: [:create, :update]
+ before_action :load_nomination, only: [:show, :edit, :update, :destroy, :confirm_delete]
+ before_action :set_limit, only: [:new, :edit, :show, :create, :update]
+
+ def check_pseud_ownership
+ if !tag_set_nomination_params[:pseud_id].blank?
+ pseud = Pseud.find(tag_set_nomination_params[:pseud_id])
+ unless pseud && current_user && current_user.pseuds.include?(pseud)
+ flash[:error] = ts("You can't nominate tags with that pseud.")
+ redirect_to root_path and return
+ end
+ end
+ end
+
+ def load_tag_set
+ @tag_set = OwnedTagSet.find_by_id(params[:tag_set_id])
+ unless @tag_set
+ flash[:error] = ts("What tag set did you want to nominate for?")
+ redirect_to tag_sets_path and return
+ end
+ end
+
+ def load_nomination
+ @tag_set_nomination = @tag_set.tag_set_nominations.find_by(id: params[:id])
+ unless @tag_set_nomination
+ flash[:error] = ts("Which nominations did you want to work with?")
+ redirect_to tag_set_path(@tag_set) and return
+ end
+ unless current_user.is_author_of?(@tag_set_nomination) || @tag_set.user_is_moderator?(current_user)
+ flash[:error] = ts("You can only see your own nominations or nominations for a set you moderate.")
+ redirect_to tag_set_path(@tag_set) and return
+ end
+ end
+
+ def set_limit
+ @limit = @tag_set.limits
+ end
+
+ # used in new/edit to build any nominations that don't already exist before we open the form
+ def build_nominations
+ @limit[:fandom].times do |i|
+ fandom_nom = @tag_set_nomination.fandom_nominations[i] || @tag_set_nomination.fandom_nominations.build
+ @limit[:character].times {|j| fandom_nom.character_nominations[j] || fandom_nom.character_nominations.build }
+ @limit[:relationship].times {|j| fandom_nom.relationship_nominations[j] || fandom_nom.relationship_nominations.build }
+ end
+
+ if @limit[:fandom] == 0
+ @limit[:character].times {|j| @tag_set_nomination.character_nominations[j] || @tag_set_nomination.character_nominations.build }
+ @limit[:relationship].times {|j| @tag_set_nomination.relationship_nominations[j] || @tag_set_nomination.relationship_nominations.build }
+ end
+
+ @limit[:freeform].times {|i| @tag_set_nomination.freeform_nominations[i] || @tag_set_nomination.freeform_nominations.build }
+ end
+
+
+ def index
+ if params[:user_id]
+ @user = User.find_by(login: params[:user_id])
+ if @user != current_user
+ flash[:error] = ts("You can only view your own nominations, sorry.")
+ redirect_to tag_sets_path and return
+ else
+ @tag_set_nominations = TagSetNomination.owned_by(@user)
+ end
+ elsif (@tag_set = OwnedTagSet.find_by_id(params[:tag_set_id]))
+ if @tag_set.user_is_moderator?(current_user)
+ # reviewing nominations
+ setup_for_review
+ else
+ flash[:error] = ts("You can't see those nominations, sorry.")
+ redirect_to tag_sets_path and return
+ end
+ else
+ flash[:error] = ts("What nominations did you want to work with?")
+ redirect_to tag_sets_path and return
+ end
+ end
+
+ def show
+ end
+
+ def new
+ if @tag_set_nomination = TagSetNomination.for_tag_set(@tag_set).owned_by(current_user).first
+ redirect_to edit_tag_set_nomination_path(@tag_set, @tag_set_nomination)
+ else
+ @tag_set_nomination = TagSetNomination.new(pseud: current_user.default_pseud, owned_tag_set: @tag_set)
+ build_nominations
+ end
+ end
+
+ def edit
+ # build up extra nominations if not all were used
+ build_nominations
+ end
+
+ def create
+ @tag_set_nomination = @tag_set.tag_set_nominations.build(tag_set_nomination_params)
+ if @tag_set_nomination.save
+ flash[:notice] = ts('Your nominations were successfully submitted.')
+ redirect_to tag_set_nomination_path(@tag_set, @tag_set_nomination)
+ else
+ build_nominations
+ render action: "new"
+ end
+ end
+
+
+ def update
+ if @tag_set_nomination.update(tag_set_nomination_params)
+ flash[:notice] = ts("Your nominations were successfully updated.")
+ redirect_to tag_set_nomination_path(@tag_set, @tag_set_nomination)
+ else
+ build_nominations
+ render action: "edit"
+ end
+ end
+
+ def destroy
+ unless @tag_set_nomination.unreviewed? || @tag_set.user_is_moderator?(current_user)
+ flash[:error] = ts("You cannot delete nominations after some of them have been reviewed, sorry!")
+ redirect_to tag_set_nomination_path(@tag_set, @tag_set_nomination)
+ else
+ @tag_set_nomination.destroy
+ flash[:notice] = ts("Your nominations were deleted.")
+ redirect_to tag_set_path(@tag_set)
+ end
+ end
+
+ def base_nom_query(tag_type)
+ TagNomination.where(type: (tag_type.is_a?(Array) ? tag_type.map {|t| "#{t.classify}Nomination"} : "#{tag_type.classify}Nomination")).
+ for_tag_set(@tag_set).unreviewed.limit(@nom_limit)
+ end
+
+ # set up various variables for reviewing nominations
+ def setup_for_review
+ set_limit
+
+ # Only this amount of tag nominations is shown on the review page.
+ # If there are more (more_noms == true), moderators have to approve/reject from the shown noms to see more noms.
+ # TODO: AO3-3764 Show all tag set nominations
+ @nom_limit = 30
+ @nominations = HashWithIndifferentAccess.new
+ @nominations_count = HashWithIndifferentAccess.new
+ more_noms = false
+
+ if @tag_set.includes_fandoms?
+ # all char and rel tags happen under fandom noms
+ @nominations_count[:fandom] = @tag_set.fandom_nominations.unreviewed.count
+ more_noms = true if @nominations_count[:fandom] > @nom_limit
+ # Show a random selection of nominations if there are more noms than can be shown at once
+ @nominations[:fandom] = more_noms ? base_nom_query("fandom").random_order : base_nom_query("fandom").order(:tagname)
+ if (@limit[:character] > 0 || @limit[:relationship] > 0)
+ @nominations[:cast] = base_nom_query(%w(character relationship)).
+ join_fandom_nomination.
+ where('fandom_nominations_tag_nominations.approved = 1').
+ order(:parent_tagname, :type, :tagname)
+ end
+ else
+ # if there are no fandoms we're going to assume this is a one or few fandom tagset
+ @nominations_count[:character] = @tag_set.character_nominations.unreviewed.count
+ @nominations_count[:relationship] = @tag_set.relationship_nominations.unreviewed.count
+ more_noms = true if (@tag_set.character_nominations.unreviewed.count > @nom_limit || @tag_set.relationship_nominations.unreviewed.count > @nom_limit)
+ @nominations[:character] = base_nom_query("character") if @limit[:character] > 0
+ @nominations[:relationship] = base_nom_query("relationship") if @limit[:relationship] > 0
+ if more_noms # Show a random selection of nominations if there are more noms than can be shown at once
+ parent_tagnames = TagNomination.for_tag_set(@tag_set).unreviewed.random_order.limit(100).pluck(:parent_tagname).uniq.first(30)
+ @nominations[:character] = @nominations[:character].where(parent_tagname: parent_tagnames) if @limit[:character] > 0
+ @nominations[:relationship] = @nominations[:relationship].where(parent_tagname: parent_tagnames) if @limit[:relationship] > 0
+ end
+ @nominations[:character] = @nominations[:character].order(:parent_tagname, :tagname) if @limit[:character] > 0
+ @nominations[:relationship] = @nominations[:relationship].order(:parent_tagname, :tagname) if @limit[:relationship] > 0
+ end
+ @nominations_count[:freeform] = @tag_set.freeform_nominations.unreviewed.count
+ more_noms = true if @nominations_count[:freeform] > @nom_limit
+ # Show a random selection of nominations if there are more noms than can be shown at once
+ @nominations[:freeform] = (more_noms ? base_nom_query("freeform").random_order : base_nom_query("freeform").order(:tagname)) unless @limit[:freeform].zero?
+
+ if more_noms
+ flash[:notice] = ts("There are too many nominations to show at once, so here's a randomized selection! Additional nominations will appear after you approve or reject some.")
+ end
+
+ if @tag_set.tag_nominations.unreviewed.empty?
+ flash[:notice] = ts("No nominations to review!")
+ end
+ end
+
+ def confirm_delete
+ end
+
+ def confirm_destroy_multiple
+ end
+
+ def destroy_multiple
+ unless @tag_set.user_is_owner?(current_user)
+ flash[:error] = ts("You don't have permission to do that.")
+ redirect_to tag_set_path(@tag_set) and return
+ end
+
+ @tag_set.clear_nominations!
+ flash[:notice] = ts("All nominations for this Tag Set have been cleared.")
+ redirect_to tag_set_path(@tag_set)
+ end
+
+ # update_multiple gets called from the index/review form.
+ # we expect params like "character_approve_My Awesome Tag" and "fandom_reject_My Lousy Tag"
+ def update_multiple
+ unless @tag_set.user_is_moderator?(current_user)
+ flash[:error] = ts("You don't have permission to do that.")
+ redirect_to tag_set_path(@tag_set) and return
+ end
+
+ # Collate the input into @approve, @reject, @synonym, @change, checking for:
+ # - invalid tag name changes
+ # - approve & reject both selected
+ # put errors in @errors, mark types to force to be expanded with @force_expand
+ @approve = HashWithIndifferentAccess.new; @synonym = HashWithIndifferentAccess.new
+ @reject = HashWithIndifferentAccess.new; @change = HashWithIndifferentAccess.new
+ @errors = []; @force_expand = {}
+ collect_update_multiple_results
+
+ # If we have errors don't move ahead
+ unless @errors.empty?
+ render_index_on_error and return
+ end
+
+ # OK, now we're going ahead and making piles of db changes! eep! D:
+ TagSet::TAG_TYPES_INITIALIZABLE.each do |tag_type|
+ # we're adding the approved tags and synonyms
+ @tagnames_to_add = @approve[tag_type] + @synonym[tag_type]
+ @tagnames_to_remove = @reject[tag_type]
+
+ # If we've approved a tag, change any other nominations that have this tag as a synonym to the synonym
+ if @tagnames_to_add.present?
+ tagnames_to_change = TagNomination.for_tag_set(@tag_set).where(type: "#{tag_type.classify}Nomination").where("synonym IN (?)", @tagnames_to_add).pluck(:tagname).uniq
+ tagnames_to_change.each do |oldname|
+ synonym = TagNomination.for_tag_set(@tag_set).where(type: "#{tag_type.classify}Nomination", tagname: oldname).pluck(:synonym).first
+ unless TagNomination.change_tagname!(@tag_set, oldname, synonym)
+ flash[:error] = ts("Oh no! We ran into a problem partway through saving your updates, changing %{oldname} to %{newname} -- please check over your tag set closely!",
+ oldname: oldname, newname: synonym)
+ render_index_on_error and return
+ end
+ end
+ end
+
+ # do the name changes
+ @change[tag_type].each do |oldname, newname|
+ if TagNomination.change_tagname!(@tag_set, oldname, newname)
+ @tagnames_to_add << newname
+ else
+ # ughhhh
+ flash[:error] = ts("Oh no! We ran into a problem partway through saving your updates, changing %{oldname} to %{newname} -- please check over your tag set closely!",
+ oldname: oldname, newname: newname)
+ render_index_on_error and return
+ end
+ end
+
+ # update the tag set
+ unless @tag_set.add_tagnames(tag_type, @tagnames_to_add) && @tag_set.remove_tagnames(tag_type, @tagnames_to_remove)
+ @errors = @tag_set.errors.full_messages
+ flash[:error] = ts("Oh no! We ran into a problem partway through saving your updates -- please check over your tag set closely!")
+ render_index_on_error and return
+ end
+
+ @notice ||= []
+ @notice << ts("Successfully added to set: %{approved}", approved: @tagnames_to_add.join(', ')) unless @tagnames_to_add.empty?
+ @notice << ts("Successfully rejected: %{rejected}", rejected: @tagnames_to_remove.join(', ')) unless @tagnames_to_remove.empty?
+ end
+
+ # If we got here we made it through, YAY
+ flash[:notice] = @notice
+ if @tag_set.tag_nominations.unreviewed.empty?
+ flash[:notice] << ts("All nominations reviewed, yay!")
+ redirect_to tag_set_path(@tag_set)
+ else
+ flash[:notice] << ts("Still some nominations left to review!")
+ redirect_to tag_set_nominations_path(@tag_set) and return
+ end
+ end
+
+ protected
+
+ def render_index_on_error
+ setup_for_review
+ render action: "index"
+ end
+
+ # gathers up the data for all the tag types
+ def collect_update_multiple_results
+ TagSet::TAG_TYPES_INITIALIZABLE.each do |tag_type|
+ @approve[tag_type] = []
+ @synonym[tag_type] = []
+ @reject[tag_type] = []
+ @change[tag_type] = []
+ end
+
+ params.each_pair do |key, val|
+ next unless val.present?
+ if key.match(/^([a-z]+)_(approve|reject|synonym|change)_(.*)$/)
+ type = $1
+ action = $2
+ name = $3
+ # fix back the tagname if it has [] brackets -- see _review_individual_nom for details
+ name = name.gsub('#LBRACKET', '[').gsub('#RBRACKET', ']')
+ if TagSet::TAG_TYPES_INITIALIZABLE.include?(type)
+ # we're safe
+ case action
+ when "reject"
+ @reject[type] << name
+ when "approve"
+ @approve[type] << name unless params["#{type}_change_#{name}"].present? && (params["#{type}_change_#{name}"] != name)
+ when "synonym", "change"
+ next if val == name
+ # this is the tricky one: make sure we can do this name change
+ tagnom = TagNomination.for_tag_set(@tag_set).where(type: "#{type.classify}Nomination", tagname: name).first
+ if !tagnom
+ @errors << ts("Couldn't find a #{type} nomination for %{name}", name: name)
+ @force_expand[type] = true
+ elsif !tagnom.change_tagname?(val)
+ @errors << ts("Invalid name change for %{name} to %{val}: %{msg}", name: name, val: val, msg: tagnom.errors.full_messages.join(", "))
+ @force_expand[type] = true
+ elsif action == "synonym"
+ @synonym[type] << val
+ else
+ @change[type] << [name, val]
+ end
+ end
+ end
+ end
+ end
+
+ TagSet::TAG_TYPES_INITIALIZABLE.each do |tag_type|
+ unless (intersect = @approve[tag_type] & @reject[tag_type]).empty?
+ @errors << ts("You have both approved and rejected the following %{type} tags: %{intersect}", type: tag_type, intersect: intersect.join(", "))
+ @force_expand[tag_type] = true
+ end
+ end
+ end
+
+ private
+
+ def tag_set_nomination_params
+ params.require(:tag_set_nomination).permit(
+ :pseud_id,
+ fandom_nominations_attributes: [
+ :id,
+ :tagname,
+ character_nominations_attributes: [
+ :id, :tagname, :from_fandom_nomination
+ ],
+ relationship_nominations_attributes: [
+ :id, :tagname, :from_fandom_nomination
+ ],
+ ],
+ freeform_nominations_attributes: [
+ :id, :tagname
+ ],
+ character_nominations_attributes: [
+ :id, :tagname, :parent_tagname
+ ],
+ relationship_nominations_attributes: [
+ :id, :tagname, :parent_tagname
+ ]
+ )
+ end
+
+end
diff --git a/app/controllers/tag_wranglers_controller.rb b/app/controllers/tag_wranglers_controller.rb
new file mode 100755
index 0000000..9722cbb
--- /dev/null
+++ b/app/controllers/tag_wranglers_controller.rb
@@ -0,0 +1,112 @@
+class TagWranglersController < ApplicationController
+ include ExportsHelper
+ include WranglingHelper
+
+ before_action :check_user_status
+ before_action :check_permission_to_wrangle, except: [:report_csv]
+
+ def index
+ authorize :wrangling, :full_access? if logged_in_as_admin?
+
+ @wranglers = Role.find_by(name: "tag_wrangler").users.alphabetical
+ conditions = ["canonical = 1"]
+ joins = "LEFT JOIN wrangling_assignments ON (wrangling_assignments.fandom_id = tags.id)
+ LEFT JOIN users ON (users.id = wrangling_assignments.user_id)"
+ unless params[:fandom_string].blank?
+ conditions.first << " AND name LIKE ?"
+ conditions << params[:fandom_string] + "%"
+ end
+ unless params[:wrangler_id].blank?
+ if params[:wrangler_id] == "No Wrangler"
+ conditions.first << " AND users.id IS NULL"
+ else
+ @wrangler = User.find_by(login: params[:wrangler_id])
+ if @wrangler
+ conditions.first << " AND users.id = #{@wrangler.id}"
+ end
+ end
+ end
+ unless params[:media_id].blank?
+ @media = Media.find_by_name(params[:media_id])
+ if @media
+ joins << " INNER JOIN common_taggings ON (tags.id = common_taggings.common_tag_id)"
+ conditions.first << " AND common_taggings.filterable_id = #{@media.id} AND common_taggings.filterable_type = 'Tag'"
+ end
+ end
+ @assignments = Fandom.in_use.joins(joins)
+ .select('tags.*, users.login AS wrangler')
+ .where(conditions)
+ .order(:name)
+ .paginate(page: params[:page], per_page: 50)
+ end
+
+ def show
+ authorize :wrangling if logged_in_as_admin?
+
+ @wrangler = User.find_by!(login: params[:id])
+ @page_subtitle = @wrangler.login
+ @fandoms = @wrangler.fandoms.by_name
+ @counts = tag_counts_per_category
+ end
+
+ def report_csv
+ authorize :wrangling
+
+ wrangler = User.find_by!(login: params[:id])
+ wrangled_tags = Tag
+ .where(last_wrangler: wrangler)
+ .limit(ArchiveConfig.WRANGLING_REPORT_LIMIT)
+ .includes(:merger, :parents)
+ results = [%w[Name Last\ Updated Type Merger Fandoms Unwrangleable]]
+ wrangled_tags.find_each(order: :desc) do |tag|
+ merger = tag.merger&.name || ""
+ fandoms = tag.parents.filter_map { |parent| parent.name if parent.is_a?(Fandom) }.join(", ")
+ results << [tag.name, tag.updated_at, tag.type, merger, fandoms, tag.unwrangleable]
+ end
+ filename = "wrangled_tags_#{wrangler.login}_#{Time.now.utc.strftime('%Y-%m-%d-%H%M')}.csv"
+ send_csv_data(results, filename)
+ end
+
+ def create
+ authorize :wrangling if logged_in_as_admin?
+
+ unless params[:tag_fandom_string].blank?
+ names = params[:tag_fandom_string].gsub(/$/, ',').split(',').map(&:strip)
+ fandoms = Fandom.where('name IN (?)', names)
+ unless fandoms.blank?
+ for fandom in fandoms
+ unless !current_user.respond_to?(:fandoms) || current_user.fandoms.include?(fandom)
+ assignment = current_user.wrangling_assignments.build(fandom_id: fandom.id)
+ assignment.save!
+ end
+ end
+ end
+ end
+ unless params[:assignments].blank?
+ params[:assignments].each_pair do |fandom_id, user_logins|
+ fandom = Fandom.find(fandom_id)
+ user_logins.uniq.each do |login|
+ unless login.blank?
+ user = User.find_by(login: login)
+ unless user.nil? || user.fandoms.include?(fandom)
+ assignment = user.wrangling_assignments.build(fandom_id: fandom.id)
+ assignment.save!
+ end
+ end
+ end
+ end
+ flash[:notice] = "Wranglers were successfully assigned!"
+ end
+ redirect_to tag_wranglers_path(media_id: params[:media_id], fandom_string: params[:fandom_string], wrangler_id: params[:wrangler_id])
+ end
+
+ def destroy
+ authorize :wrangling if logged_in_as_admin?
+
+ wrangler = User.find_by(login: params[:id])
+ assignment = WranglingAssignment.where(user_id: wrangler.id, fandom_id: params[:fandom_id]).first
+ assignment.destroy
+ flash[:notice] = "Wranglers were successfully unassigned!"
+ redirect_to tag_wranglers_path(media_id: params[:media_id], fandom_string: params[:fandom_string], wrangler_id: params[:wrangler_id])
+ end
+end
diff --git a/app/controllers/tag_wranglings_controller.rb b/app/controllers/tag_wranglings_controller.rb
new file mode 100755
index 0000000..c3068d9
--- /dev/null
+++ b/app/controllers/tag_wranglings_controller.rb
@@ -0,0 +1,108 @@
+class TagWranglingsController < ApplicationController
+ include TagWrangling
+ include WranglingHelper
+
+ before_action :check_user_status
+ before_action :check_permission_to_wrangle
+ around_action :record_wrangling_activity, only: [:wrangle]
+
+ def index
+ @counts = tag_counts_per_category
+ authorize :wrangling, :read_access? if logged_in_as_admin?
+ return if params[:show].blank?
+
+ raise "Redshirt: Attempted to constantize invalid class initialize tag_wranglings_controller_index #{params[:show].classify}" unless Tag::USER_DEFINED.include?(params[:show].classify)
+
+ params[:sort_column] = "created_at" unless valid_sort_column(params[:sort_column], "tag")
+ params[:sort_direction] = "ASC" unless valid_sort_direction(params[:sort_direction])
+
+ if params[:show] == "fandoms"
+ @media_names = Media.by_name.pluck(:name)
+ @page_subtitle = t(".page_subtitle")
+ end
+
+ type = params[:show].singularize.capitalize
+ @tags = TagQuery.new({
+ type: type,
+ in_use: true,
+ unwrangleable: false,
+ unwrangled: true,
+ has_posted_works: true,
+ sort_column: params[:sort_column],
+ sort_direction: params[:sort_direction],
+ page: params[:page],
+ per_page: ArchiveConfig.ITEMS_PER_PAGE
+ }).search_results
+ end
+
+ def wrangle
+ authorize :wrangling, :full_access? if logged_in_as_admin?
+
+ params[:page] = '1' if params[:page].blank?
+ params[:sort_column] = 'name' if !valid_sort_column(params[:sort_column], 'tag')
+ params[:sort_direction] = 'ASC' if !valid_sort_direction(params[:sort_direction])
+ options = {show: params[:show], page: params[:page], sort_column: params[:sort_column], sort_direction: params[:sort_direction]}
+
+ error_messages, notice_messages = [], []
+
+ # make tags canonical if allowed
+ if params[:canonicals].present? && params[:canonicals].is_a?(Array)
+ saved_canonicals, not_saved_canonicals = [], []
+ tags = Tag.where(id: params[:canonicals])
+
+ tags.each do |tag_to_canonicalize|
+ if tag_to_canonicalize.update(canonical: true)
+ saved_canonicals << tag_to_canonicalize
+ else
+ not_saved_canonicals << tag_to_canonicalize
+ end
+ end
+
+ error_messages << ts('The following tags couldn\'t be made canonical: %{tags_not_saved}', tags_not_saved: not_saved_canonicals.collect(&:name).join(', ')) unless not_saved_canonicals.empty?
+ notice_messages << ts('The following tags were successfully made canonical: %{tags_saved}', tags_saved: saved_canonicals.collect(&:name).join(', ')) unless saved_canonicals.empty?
+ end
+
+ if params[:media] && !params[:selected_tags].blank?
+ options.merge!(media: params[:media])
+ @media = Media.find_by_name(params[:media])
+ @fandoms = Fandom.find(params[:selected_tags])
+ @fandoms.each { |fandom| fandom.add_association(@media) }
+ elsif params[:fandom_string].blank? && params[:selected_tags].is_a?(Array) && !params[:selected_tags].empty?
+ error_messages << ts('There were no Fandom tags!')
+ elsif params[:fandom_string].present? && params[:selected_tags].is_a?(Array) && !params[:selected_tags].empty?
+ canonical_fandoms, noncanonical_fandom_names = [], []
+ fandom_names = params[:fandom_string].split(',').map(&:squish)
+
+ fandom_names.each do |fandom_name|
+ if (fandom = Fandom.find_by_name(fandom_name)).try(:canonical?)
+ canonical_fandoms << fandom
+ else
+ noncanonical_fandom_names << fandom_name
+ end
+ end
+
+ if canonical_fandoms.present?
+ saved_to_fandoms = Tag.where(id: params[:selected_tags])
+
+ saved_to_fandoms.each do |tag_to_wrangle|
+ canonical_fandoms.each do |fandom|
+ tag_to_wrangle.add_association(fandom)
+ end
+ end
+
+ canonical_fandom_names = canonical_fandoms.collect(&:name)
+ options.merge!(fandom_string: canonical_fandom_names.join(','))
+ notice_messages << ts('The following tags were successfully wrangled to %{canonical_fandoms}: %{tags_saved}', canonical_fandoms: canonical_fandom_names.join(', '), tags_saved: saved_to_fandoms.collect(&:name).join(', ')) unless saved_to_fandoms.empty?
+ end
+
+ if noncanonical_fandom_names.present?
+ error_messages << ts('The following names are not canonical fandoms: %{noncanonical_fandom_names}.', noncanonical_fandom_names: noncanonical_fandom_names.join(', '))
+ end
+ end
+
+ flash[:notice] = notice_messages.join(' ').html_safe unless notice_messages.empty?
+ flash[:error] = error_messages.join(' ').html_safe unless error_messages.empty?
+
+ redirect_to tag_wranglings_path(options)
+ end
+end
diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb
new file mode 100755
index 0000000..6d9c2fd
--- /dev/null
+++ b/app/controllers/tags_controller.rb
@@ -0,0 +1,427 @@
+class TagsController < ApplicationController
+ include TagWrangling
+
+ before_action :load_collection
+ before_action :check_user_status, except: [:show, :index, :show_hidden, :search, :feed]
+ before_action :check_permission_to_wrangle, except: [:show, :index, :show_hidden, :search, :feed]
+ before_action :load_tag, only: [:edit, :update, :wrangle, :mass_update]
+ before_action :load_tag_and_subtags, only: [:show]
+ around_action :record_wrangling_activity, only: [:create, :update, :mass_update]
+
+ caches_page :feed
+
+ def load_tag
+ @tag = Tag.find_by_name(params[:id])
+ unless @tag && @tag.is_a?(Tag)
+ raise ActiveRecord::RecordNotFound, "Couldn't find tag named '#{params[:id]}'"
+ end
+ end
+
+ # improved performance for show page
+ def load_tag_and_subtags
+ @tag = Tag.includes(:direct_sub_tags).find_by_name(params[:id])
+ unless @tag && @tag.is_a?(Tag)
+ raise ActiveRecord::RecordNotFound, "Couldn't find tag named '#{params[:id]}'"
+ end
+ end
+
+ # GET /tags
+ def index
+ if @collection
+ @tags = Freeform.canonical.for_collections_with_count([@collection] + @collection.children)
+ @page_subtitle = t(".collection_page_title", collection_title: @collection.title)
+ else
+ no_fandom = Fandom.find_by_name(ArchiveConfig.FANDOM_NO_TAG_NAME)
+ if no_fandom
+ @tags = no_fandom.children.by_type("Freeform").first_class.limit(ArchiveConfig.TAGS_IN_CLOUD)
+ # have to put canonical at the end so that it doesn't overwrite sort order for random and popular
+ # and then sort again at the very end to make it alphabetic
+ @tags = if params[:show] == "random"
+ @tags.random.canonical.sort
+ else
+ @tags.popular.canonical.sort
+ end
+ else
+ @tags = []
+ end
+ end
+ end
+
+ def search
+ options = params[:tag_search].present? ? tag_search_params : {}
+ options.merge!(page: params[:page]) if params[:page].present?
+ @search = TagSearchForm.new(options)
+ @page_subtitle = ts("Search Tags")
+
+ return if params[:tag_search].blank?
+
+ @page_subtitle = ts("Tags Matching '%{query}'", query: options[:name]) if options[:name].present?
+
+ @tags = @search.search_results
+ flash_search_warnings(@tags)
+ end
+
+ def show
+ @page_subtitle = @tag.name
+ if @tag.is_a?(Banned) && !logged_in_as_admin?
+ flash[:error] = t("admin.access.not_admin_denied")
+ redirect_to(tag_wranglings_path) && return
+ end
+ # if tag is NOT wrangled, prepare to show works and bookmarks that are using it
+ if !@tag.canonical && !@tag.merger
+ @works = if logged_in? # current_user.is_a?User
+ @tag.works.visible_to_registered_user.paginate(page: params[:page])
+ elsif logged_in_as_admin?
+ @tag.works.visible_to_admin.paginate(page: params[:page])
+ else
+ @tag.works.visible_to_all.paginate(page: params[:page])
+ end
+ @bookmarks = @tag.bookmarks.visible.paginate(page: params[:page])
+ end
+ # cache the children, since it's a possibly massive query
+ @tag_children = Rails.cache.fetch "views/tags/#{@tag.cache_key}/children" do
+ children = {}
+ (@tag.child_types - %w(SubTag)).each do |child_type|
+ tags = @tag.send(child_type.underscore.pluralize).order('taggings_count_cache DESC').limit(ArchiveConfig.TAG_LIST_LIMIT + 1)
+ children[child_type] = tags.to_a.uniq unless tags.blank?
+ end
+ children
+ end
+ end
+
+ def feed
+ begin
+ @tag = Tag.find(params[:id])
+ rescue ActiveRecord::RecordNotFound
+ raise ActiveRecord::RecordNotFound, "Couldn't find tag with id '#{params[:id]}'"
+ end
+ @tag = @tag.merger if !@tag.canonical? && @tag.merger
+ # Temp for testing
+ if %w(Fandom Character Relationship).include?(@tag.type.to_s) || @tag.name == 'F/F'
+ if @tag.canonical?
+ @works = @tag.filtered_works.visible_to_all.order('created_at DESC').limit(25)
+ else
+ @works = @tag.works.visible_to_all.order('created_at DESC').limit(25)
+ end
+ else
+ redirect_to(tag_works_path(tag_id: @tag.to_param)) && return
+ end
+
+ respond_to do |format|
+ format.html
+ format.atom
+ end
+ end
+
+ def show_hidden
+ unless params[:creation_id].blank? || params[:creation_type].blank? || params[:tag_type].blank?
+ model = case params[:creation_type].downcase
+ when "series"
+ Series
+ when "work"
+ Work
+ when "chapter"
+ Chapter
+ end
+ @display_creation = model.find(params[:creation_id]) if model.is_a? Class
+
+ # Tags aren't directly on series, so we need to handle them differently
+ if params[:creation_type] == 'Series'
+ if params[:tag_type] == 'warnings'
+ @display_tags = @display_creation.works.visible.collect(&:archive_warnings).flatten.compact.uniq.sort
+ else
+ @display_tags = @display_creation.works.visible.collect(&:freeforms).flatten.compact.uniq.sort
+ end
+ else
+ @display_tags = case params[:tag_type]
+ when 'warnings'
+ @display_creation.archive_warnings
+ when 'freeforms'
+ @display_creation.freeforms
+ end
+ end
+
+ # The string used in views/tags/show_hidden.js.erb
+ if params[:tag_type] == 'warnings'
+ @display_category = 'warnings'
+ else
+ @display_category = @display_tags.first.class.name.tableize
+ end
+ end
+
+ respond_to do |format|
+ format.html do
+ # This is just a quick fix to avoid script barf if JavaScript is disabled
+ flash[:error] = ts('Sorry, you need to have JavaScript enabled for this.')
+ if request.env['HTTP_REFERER']
+ redirect_to(request.env['HTTP_REFERER'] || root_path)
+ else
+ # else branch needed to deal with bots, which don't have a referer
+ redirect_to '/'
+ end
+ end
+ format.js
+ end
+ end
+
+ # GET /tags/new
+ def new
+ authorize :wrangling if logged_in_as_admin?
+
+ @tag = Tag.new
+
+ respond_to do |format|
+ format.html # new.html.erb
+ end
+ end
+
+ # POST /tags
+ def create
+ type = tag_params[:type] if params[:tag]
+
+ unless type
+ flash[:error] = ts("Please provide a category.")
+ @tag = Tag.new(name: tag_params[:name])
+ render(action: "new")
+ return
+ end
+
+ raise "Redshirt: Attempted to constantize invalid class initialize create #{type.classify}" unless Tag::TYPES.include?(type.classify)
+
+ model = begin
+ type.classify.constantize
+ rescue StandardError
+ nil
+ end
+ @tag = model.find_or_create_by_name(tag_params[:name]) if model.is_a? Class
+
+ unless @tag&.valid?
+ render(action: "new")
+ return
+ end
+
+ if @tag.id_previously_changed? # i.e. tag is new
+ @tag.update_attribute(:canonical, tag_params[:canonical])
+ flash[:notice] = ts("Tag was successfully created.")
+ else
+ flash[:notice] = ts("Tag already existed and was not modified.")
+ end
+
+ redirect_to edit_tag_path(@tag)
+ end
+
+ def edit
+ authorize :wrangling, :read_access? if logged_in_as_admin?
+
+ @page_subtitle = ts('%{tag_name} - Edit', tag_name: @tag.name)
+
+ if @tag.is_a?(Banned) && !logged_in_as_admin?
+ flash[:error] = ts('Please log in as admin')
+
+ redirect_to(tag_wranglings_path) && return
+ end
+
+ @counts = {}
+ @uses = ['Works', 'Drafts', 'Bookmarks', 'Private Bookmarks', 'External Works', 'Taggings Count']
+ @counts['Works'] = @tag.visible_works_count
+ @counts['Drafts'] = @tag.works.unposted.count
+ @counts['Bookmarks'] = @tag.visible_bookmarks_count
+ @counts['Private Bookmarks'] = @tag.bookmarks.not_public.count
+ @counts['External Works'] = @tag.visible_external_works_count
+ @counts['Taggings Count'] = @tag.taggings_count
+
+ @parents = @tag.parents.order(:name).group_by { |tag| tag[:type] }
+ @parents['MetaTag'] = @tag.direct_meta_tags.by_name
+ @children = @tag.children.order(:name).group_by { |tag| tag[:type] }
+ @children['SubTag'] = @tag.direct_sub_tags.by_name
+ @children['Merger'] = @tag.mergers.by_name
+
+ if @tag.respond_to?(:wranglers)
+ @wranglers = @tag.canonical ? @tag.wranglers : (@tag.merger ? @tag.merger.wranglers : [])
+ elsif @tag.respond_to?(:fandoms) && !@tag.fandoms.empty?
+ @wranglers = @tag.fandoms.collect(&:wranglers).flatten.uniq
+ end
+ @suggested_fandoms = @tag.suggested_parent_tags("Fandom") - @tag.fandoms if @tag.respond_to?(:fandoms)
+ end
+
+ def update
+ authorize :wrangling if logged_in_as_admin?
+
+ # update everything except for the synonym,
+ # so that the associations are there to move when the synonym is created
+ syn_string = params[:tag].delete(:syn_string)
+ new_tag_type = params[:tag].delete(:type)
+
+ # Limiting the conditions under which you can update the tag type
+ types = logged_in_as_admin? ? (Tag::USER_DEFINED + %w[Media]) : Tag::USER_DEFINED
+ @tag = @tag.recategorize(new_tag_type) if @tag.can_change_type? && (types + %w[UnsortedTag]).include?(new_tag_type)
+
+ unless params[:tag].empty?
+ @tag.attributes = tag_params
+ end
+
+ @tag.syn_string = syn_string if @tag.errors.empty? && @tag.save
+
+ if @tag.errors.empty? && @tag.save
+ flash[:notice] = ts('Tag was updated.')
+ redirect_to edit_tag_path(@tag)
+ else
+ @parents = @tag.parents.order(:name).group_by { |tag| tag[:type] }
+ @parents['MetaTag'] = @tag.direct_meta_tags.by_name
+ @children = @tag.children.order(:name).group_by { |tag| tag[:type] }
+ @children['SubTag'] = @tag.direct_sub_tags.by_name
+ @children['Merger'] = @tag.mergers.by_name
+
+ render :edit
+ end
+ end
+
+ def wrangle
+ authorize :wrangling, :read_access? if logged_in_as_admin?
+
+ @page_subtitle = ts('%{tag_name} - Wrangle', tag_name: @tag.name)
+ @counts = {}
+ @tag.child_types.map { |t| t.underscore.pluralize.to_sym }.each do |tag_type|
+ @counts[tag_type] = @tag.send(tag_type).count
+ end
+
+ show = params[:show]
+ if %w(fandoms characters relationships freeforms sub_tags mergers).include?(show)
+ params[:sort_column] = 'name' unless valid_sort_column(params[:sort_column], 'tag')
+ params[:sort_direction] = 'ASC' unless valid_sort_direction(params[:sort_direction])
+ sort = params[:sort_column] + ' ' + params[:sort_direction]
+ # add a secondary sorting key when the main one is not discerning enough
+ if sort.include?('suggested') || sort.include?('taggings_count_cache')
+ sort += ', name ASC'
+ end
+ # this makes sure params[:status] is safe
+ status = params[:status]
+ @tags = if %w[unfilterable canonical synonymous unwrangleable].include?(status)
+ @tag.send(show).reorder(sort).send(status).paginate(page: params[:page], per_page: ArchiveConfig.ITEMS_PER_PAGE)
+ elsif status == "unwrangled"
+ @tag.unwrangled_tags(
+ params[:show].singularize.camelize,
+ params.permit!.slice(:sort_column, :sort_direction, :page)
+ )
+ else
+ @tag.send(show).reorder(sort).paginate(page: params[:page], per_page: ArchiveConfig.ITEMS_PER_PAGE)
+ end
+ end
+ end
+
+ def mass_update
+ authorize :wrangling if logged_in_as_admin?
+
+ params[:page] = '1' if params[:page].blank?
+ params[:sort_column] = 'name' unless valid_sort_column(params[:sort_column], 'tag')
+ params[:sort_direction] = 'ASC' unless valid_sort_direction(params[:sort_direction])
+ options = { show: params[:show], page: params[:page], sort_column: params[:sort_column], sort_direction: params[:sort_direction], status: params[:status] }
+
+ error_messages = []
+ notice_messages = []
+
+ # make tags canonical if allowed
+ if params[:canonicals].present? && params[:canonicals].is_a?(Array)
+ saved_canonicals = []
+ not_saved_canonicals = []
+ tags = Tag.where(id: params[:canonicals])
+
+ tags.each do |tag_to_canonicalize|
+ if tag_to_canonicalize.update(canonical: true)
+ saved_canonicals << tag_to_canonicalize
+ else
+ not_saved_canonicals << tag_to_canonicalize
+ end
+ end
+
+ error_messages << ts('The following tags couldn\'t be made canonical: %{tags_not_saved}', tags_not_saved: not_saved_canonicals.collect(&:name).join(', ')) unless not_saved_canonicals.empty?
+ notice_messages << ts('The following tags were successfully made canonical: %{tags_saved}', tags_saved: saved_canonicals.collect(&:name).join(', ')) unless saved_canonicals.empty?
+ end
+
+ # remove associated tags
+ if params[:remove_associated].present? && params[:remove_associated].is_a?(Array)
+ saved_removed_associateds = []
+ not_saved_removed_associateds = []
+ tags = Tag.where(id: params[:remove_associated])
+
+ tags.each do |tag_to_remove|
+ if @tag.remove_association(tag_to_remove.id)
+ saved_removed_associateds << tag_to_remove
+ else
+ not_saved_removed_associateds << tag_to_remove
+ end
+ end
+
+ error_messages << ts('The following tags couldn\'t be removed: %{tags_not_saved}', tags_not_saved: not_saved_removed_associateds.collect(&:name).join(', ')) unless not_saved_removed_associateds.empty?
+ notice_messages << ts('The following tags were successfully removed: %{tags_saved}', tags_saved: saved_removed_associateds.collect(&:name).join(', ')) unless saved_removed_associateds.empty?
+ end
+
+ # wrangle to fandom(s)
+ if params[:fandom_string].blank? && params[:selected_tags].is_a?(Array) && !params[:selected_tags].empty?
+ error_messages << ts('There were no Fandom tags!')
+ end
+ if params[:fandom_string].present? && params[:selected_tags].is_a?(Array) && !params[:selected_tags].empty?
+ canonical_fandoms = []
+ noncanonical_fandom_names = []
+ fandom_names = params[:fandom_string].split(',').map(&:squish)
+
+ fandom_names.each do |fandom_name|
+ if (fandom = Fandom.find_by_name(fandom_name)).try(:canonical?)
+ canonical_fandoms << fandom
+ else
+ noncanonical_fandom_names << fandom_name
+ end
+ end
+
+ if canonical_fandoms.present?
+ saved_to_fandoms = Tag.where(id: params[:selected_tags])
+
+ saved_to_fandoms.each do |tag_to_wrangle|
+ canonical_fandoms.each do |fandom|
+ tag_to_wrangle.add_association(fandom)
+ end
+ end
+
+ canonical_fandom_names = canonical_fandoms.collect(&:name)
+ options[:fandom_string] = canonical_fandom_names.join(',')
+ notice_messages << ts('The following tags were successfully wrangled to %{canonical_fandoms}: %{tags_saved}', canonical_fandoms: canonical_fandom_names.join(', '), tags_saved: saved_to_fandoms.collect(&:name).join(', ')) unless saved_to_fandoms.empty?
+ end
+
+ if noncanonical_fandom_names.present?
+ error_messages << ts('The following names are not canonical fandoms: %{noncanonical_fandom_names}.', noncanonical_fandom_names: noncanonical_fandom_names.join(', '))
+ end
+ end
+
+ flash[:notice] = notice_messages.join(' ').html_safe unless notice_messages.empty?
+ flash[:error] = error_messages.join(' ').html_safe unless error_messages.empty?
+
+ redirect_to url_for({ controller: :tags, action: :wrangle, id: params[:id] }.merge(options))
+ end
+
+ private
+
+ def tag_params
+ params.require(:tag).permit(
+ :name, :type, :canonical, :unwrangleable, :adult, :sortable_name,
+ :meta_tag_string, :sub_tag_string, :merger_string, :syn_string,
+ :media_string, :fandom_string, :character_string, :relationship_string,
+ :freeform_string,
+ associations_to_remove: []
+ )
+ end
+
+ def tag_search_params
+ params.require(:tag_search).permit(
+ :query,
+ :name,
+ :fandoms,
+ :type,
+ :canonical,
+ :wrangling_status,
+ :created_at,
+ :uses,
+ :sort_column,
+ :sort_direction
+ )
+ end
+end
diff --git a/app/controllers/troubleshooting_controller.rb b/app/controllers/troubleshooting_controller.rb
new file mode 100755
index 0000000..f1eb0ff
--- /dev/null
+++ b/app/controllers/troubleshooting_controller.rb
@@ -0,0 +1,197 @@
+# frozen_string_literal: true
+
+# A controller used to let admins and tag wranglers perform some
+# troubleshooting on tags and works.
+class TroubleshootingController < ApplicationController
+ before_action :check_permission_to_wrangle
+ before_action :load_item
+ before_action :check_visibility
+
+ # Display options for troubleshooting.
+ def show
+ @item_type = @item.class.base_class.to_s.underscore
+ @page_subtitle = t(".page_title.#{@item_type}")
+ end
+
+ # Perform the desired troubleshooting actions.
+ def update
+ actions = params.fetch(:actions, []).map(&:to_s).reject(&:blank?)
+
+ disallowed = actions - @allowed_actions
+
+ if disallowed.any?
+ disallowed_names = disallowed.map do |action|
+ t("troubleshooting.show.#{action}.title", default: action.titleize)
+ end
+
+ flash[:error] =
+ ts("The following actions aren't allowed: %{actions}.",
+ actions: disallowed_names.to_sentence)
+
+ redirect_to troubleshooting_path
+ return
+ end
+
+ flash[:notice] = []
+ flash[:error] = []
+
+ (@allowed_actions & actions).each do |action|
+ send(action)
+ end
+
+ # Make sure that we don't show blank errors.
+ flash[:notice] = nil if flash[:notice].blank?
+ flash[:error] = nil if flash[:error].blank?
+
+ redirect_to item_path
+ end
+
+ protected
+
+ # Calculate the permitted actions based on the item type and whether the
+ # current user is an admin. This is used in the show action to figure out
+ # which options to display, and in the update action to figure out which
+ # actions to perform.
+ #
+ # In order to work properly, this needs to return a list of strings, and each
+ # string needs to be the name of an instance method in this class. To make
+ # the list of options display properly (with a name and a description), each
+ # of these names also needs a corresponding title and description entry in
+ # the i18n scope en.troubleshooting.show.
+ def allowed_actions
+ if @item.is_a?(Tag)
+ allowed_actions_for_tag
+ elsif @item.is_a?(Work)
+ allowed_actions_for_work
+ end
+ end
+
+ # Decide which options should be available when we're troubleshooting a tag.
+ def allowed_actions_for_tag
+ if logged_in_as_admin?
+ %w[fix_associations fix_counts fix_meta_tags update_tag_filters reindex_tag]
+ else
+ %w[fix_associations fix_counts fix_meta_tags]
+ end
+ end
+
+ # Decide which options should be available when we're troubleshooting a work.
+ def allowed_actions_for_work
+ if logged_in_as_admin?
+ %w[update_work_filters reindex_work]
+ else
+ %w[update_work_filters]
+ end
+ end
+
+ # Let the views use these two path methods.
+ helper_method :item_path, :troubleshooting_path
+
+ # The path to view the item. Ideally we could just do redirect_to @item, but
+ # unfortunately we're dealing with tags, which have numerous subclasses.
+ def item_path
+ if @item.is_a?(Tag)
+ tag_path(@item)
+ else
+ polymorphic_path(@item)
+ end
+ end
+
+ # The path to view the troubleshooting page for this item. Again, this is
+ # necessary because tags have a lot of subclasses and need special handling.
+ def troubleshooting_path
+ if @item.is_a?(Tag)
+ tag_troubleshooting_path(@item)
+ else
+ polymorphic_path([@item, :troubleshooting])
+ end
+ end
+
+ # Load the @item based on params. Results in a 404 error if the item in
+ # question can't be found, and a 500 error if there's an unknown type. Also
+ # sets the variable @allowed_actions.
+ def load_item
+ if params[:tag_id]
+ @item = Tag.find_by_name(params[:tag_id])
+
+ if @item.nil?
+ raise ActiveRecord::RecordNotFound,
+ ts("Could not find tag with name '%{name}'",
+ name: params[:tag_id])
+ end
+ elsif params[:work_id]
+ @item = Work.find(params[:work_id])
+ else
+ raise "Unknown item type!"
+ end
+
+ @check_visibility_of = @item
+ @allowed_actions = allowed_actions
+ end
+
+ ########################################
+ # AVAILABLE ACTIONS
+ ########################################
+
+ # An action allowing the user to reindex a work.
+ def reindex_work
+ @item.enqueue_to_index
+ flash[:notice] << ts("Work sent to be reindexed.")
+ end
+
+ # An action allowing the user to reindex a tag (and everything related to it).
+ def reindex_tag
+ @item.async(:reindex_associated, true)
+ flash[:notice] << ts("Tag reindex job added to queue.")
+ end
+
+ # An action allowing the user to try to fix the filters for this tag and all
+ # of its synonyms.
+ def update_tag_filters
+ @item.async(:update_filters_for_taggables)
+
+ @item.mergers.find_each do |syn|
+ syn.async(:update_filters_for_taggables)
+ end
+
+ flash[:notice] << ts("Tagged items enqueued for filter updates.")
+ end
+
+ # An action allowing the user to try to fix a single work's filters.
+ def update_work_filters
+ @item.update_filters
+ flash[:notice] << ts("Work filters updated.")
+ end
+
+ # An action allowing the user to try to fix the filter count and taggings
+ # count.
+ def fix_counts
+ @item.filter_count&.update_counts
+ @item.update(taggings_count: @item.taggings.count)
+ flash[:notice] << ts("Tag counts updated.")
+ end
+
+ # An action allowing the user to try to fix the inherited metatags.
+ def fix_meta_tags
+ modified = MetaTagging.transaction do
+ InheritedMetaTagUpdater.new(@item).update
+ end
+
+ if modified
+ # Fixing the meta taggings is all well and good, but unless the filters
+ # are adjusted too, this will have no immediate effects.
+ @item.async(:update_filters_for_filterables)
+ flash[:notice] << ts("Inherited metatags recalculated. This tag has " \
+ "also been enqueued to have its filters fixed.")
+ else
+ flash[:notice] << ts("Inherited metatags recalculated. No incorrect " \
+ "metatags found.")
+ end
+ end
+
+ # An action allowing the user to try to delete invalid associations.
+ def fix_associations
+ @item.async(:destroy_invalid_associations)
+ flash[:notice] << ts("Tag association job enqueued.")
+ end
+end
diff --git a/app/controllers/unsorted_tags_controller.rb b/app/controllers/unsorted_tags_controller.rb
new file mode 100755
index 0000000..cf8afdd
--- /dev/null
+++ b/app/controllers/unsorted_tags_controller.rb
@@ -0,0 +1,31 @@
+class UnsortedTagsController < ApplicationController
+ include WranglingHelper
+
+ before_action :check_user_status
+ before_action :check_permission_to_wrangle
+
+ def index
+ authorize :wrangling, :read_access? if logged_in_as_admin?
+
+ @tags = UnsortedTag.page(params[:page])
+ @counts = tag_counts_per_category
+ end
+
+ def mass_update
+ authorize :wrangling if logged_in_as_admin?
+
+ if params[:tags].present?
+ params[:tags].delete_if { |_, tag_type| tag_type.blank? }
+ tags = UnsortedTag.where(id: params[:tags].keys)
+ tags.each do |tag|
+ new_type = params[:tags][tag.id.to_s]
+ raise "#{new_type} is not a valid tag type" unless Tag::USER_DEFINED.include?(new_type)
+
+ tag.update_attribute(:type, new_type)
+ end
+ flash[:notice] = ts("Tags were successfully sorted.")
+ end
+ redirect_to unsorted_tags_path(page: params[:page])
+ end
+
+end
diff --git a/app/controllers/user_invite_requests_controller.rb b/app/controllers/user_invite_requests_controller.rb
new file mode 100755
index 0000000..d4f2695
--- /dev/null
+++ b/app/controllers/user_invite_requests_controller.rb
@@ -0,0 +1,86 @@
+class UserInviteRequestsController < ApplicationController
+ before_action :admin_only, except: [:new, :create]
+ before_action :check_user_status, only: [:new, :create]
+
+ # GET /user_invite_requests
+ # GET /user_invite_requests.xml
+ def index
+ @user_invite_requests = UserInviteRequest.not_handled.page(params[:page])
+ end
+
+ # GET /user_invite_requests/new
+ # GET /user_invite_requests/new.xml
+ def new
+ @page_title = ts('New User Invitation Request')
+ if AdminSetting.request_invite_enabled?
+ if logged_in?
+ @user = current_user
+ @user_invite_request = @user.user_invite_requests.build
+ else
+ flash[:error] = ts("Please log in.")
+ redirect_to new_user_session_path
+ end
+ else
+ flash[:error] = ts("Sorry, additional invitations are unavailable. Please use the queue! If you are the mod of a challenge currently being run on the Archive, please contact Support. If you are the maintainer of an at-risk archive, please contact Open Doors.".html_safe)
+ redirect_to root_path
+ end
+ end
+ # POST /user_invite_requests
+ # POST /user_invite_requests.xml
+ def create
+ if AdminSetting.request_invite_enabled?
+ if logged_in?
+ @user = current_user
+ @user_invite_request = @user.user_invite_requests.build(user_invite_request_params)
+ else
+ flash[:error] = "Please log in."
+ redirect_to new_user_session_path
+ end
+ if @user_invite_request.save
+ flash[:notice] = 'Request was successfully created.'
+ redirect_to(@user)
+ else
+ render action: "new"
+ end
+ else
+ flash[:error] = ts("Sorry, new invitations are temporarily unavailable. If you are the mod of a challenge currently being run on the Archive, please contact Support. If you are the maintainer of an at-risk archive, please contact Open Doors".html_safe)
+ redirect_to root_path
+ end
+ end
+
+ # PUT /user_invite_requests/1
+ # PUT /user_invite_requests/1.xml
+ def update
+ if params[:decline_all]
+ params[:requests].each_pair do |id, quantity|
+ unless quantity.blank?
+ request = UserInviteRequest.find(id)
+ user = User.find(request.user_id)
+ requested_total = request.quantity.to_i
+ request.quantity = 0
+ request.save!
+ I18n.with_locale(user.preference.locale_for_mails) do
+ UserMailer.invite_request_declined(request.user_id, requested_total, request.reason).deliver_later
+ end
+ end
+ end
+ flash[:notice] = 'All Requests were declined.'
+ redirect_to user_invite_requests_path and return
+ end
+ params[:requests].each_pair do |id, quantity|
+ unless quantity.blank?
+ request = UserInviteRequest.find(id)
+ request.quantity = quantity.to_i
+ request.save!
+ end
+ end
+ flash[:notice] = ts("Requests were successfully updated.")
+ redirect_to user_invite_requests_path
+ end
+
+ private
+
+ def user_invite_request_params
+ params.require(:user_invite_request).permit(:quantity, :reason)
+ end
+end
diff --git a/app/controllers/users/passwords_controller.rb b/app/controllers/users/passwords_controller.rb
new file mode 100755
index 0000000..6c05174
--- /dev/null
+++ b/app/controllers/users/passwords_controller.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+# Use for resetting lost passwords
+class Users::PasswordsController < Devise::PasswordsController
+ before_action :admin_logout_required
+ skip_before_action :store_location
+ layout "session"
+
+ def create
+ user = User.find_for_authentication(resource_params.permit(:login))
+ if user.nil? || user.new_record?
+ flash[:error] = t(".user_not_found")
+ redirect_to new_user_password_path and return
+ end
+
+ if user.prevent_password_resets?
+ flash[:error] = t(".reset_blocked_html", contact_abuse_link: view_context.link_to(t(".contact_abuse"), new_abuse_report_path))
+ redirect_to root_path and return
+ elsif user.password_resets_limit_reached?
+ available_time = ApplicationController.helpers.time_in_zone(
+ user.password_resets_available_time, nil, user
+ )
+
+ flash[:error] = t(".reset_cooldown_html", reset_available_time: available_time)
+ redirect_to root_path and return
+ end
+
+ user.update_password_resets_requested
+ user.save
+
+ super
+ end
+
+ protected
+
+ # We need to include information about the user (the remaining reset attempts)
+ # in addition to the configured reset cooldown in the success message.
+ # Otherwise, we would just override `devise_i18n_options` instead of this method.
+ def successfully_sent?(resource)
+ return super if Devise.paranoid
+ return unless resource.errors.empty?
+
+ flash[:notice] = t("users.passwords.create.send_instructions",
+ send_times_remaining: t("users.passwords.create.send_times_remaining",
+ count: resource.password_resets_remaining),
+ send_cooldown_period: t("users.passwords.create.send_cooldown_period",
+ count: ArchiveConfig.PASSWORD_RESET_COOLDOWN_HOURS))
+ end
+
+ def after_resetting_password_path_for(resource)
+ resource.create_log_item(action: ArchiveConfig.ACTION_PASSWORD_RESET)
+ super
+ end
+end
diff --git a/app/controllers/users/registrations_controller.rb b/app/controllers/users/registrations_controller.rb
new file mode 100755
index 0000000..4a0ca27
--- /dev/null
+++ b/app/controllers/users/registrations_controller.rb
@@ -0,0 +1,98 @@
+class Users::RegistrationsController < Devise::RegistrationsController
+ before_action :check_account_creation_status
+ before_action :configure_permitted_parameters
+
+ def new
+ @page_subtitle = t(".page_title")
+ super do |resource|
+ if params[:invitation_token]
+ @invitation = Invitation.find_by(token: params[:invitation_token])
+ resource.invitation_token = @invitation.token
+ resource.email = @invitation.invitee_email
+ end
+
+ @hide_dashboard = true
+ end
+ end
+
+ def create
+ @page_subtitle = t(".page_title")
+ @hide_dashboard = true
+ build_resource(sign_up_params)
+
+ resource.transaction do
+ # skip sending the Devise confirmation notification
+ resource.skip_confirmation_notification!
+ resource.invitation_token = params[:invitation_token]
+ if resource.save
+ notify_and_show_confirmation_screen
+ else
+ render action: "new"
+ end
+ end
+ end
+
+ private
+
+ def notify_and_show_confirmation_screen
+ # deliver synchronously to avoid getting caught in backed-up mail queue
+ UserMailer.signup_notification(resource.id).deliver_now
+
+ flash[:notice] = ts("During testing you can activate via your activation url.",
+ activation_url: activate_path(resource.confirmation_token)).html_safe if Rails.env.development?
+
+ render 'users/confirmation'
+ end
+
+ def configure_permitted_parameters
+ params[:user] = params[:user_registration]&.merge(
+ accepted_tos_version: @current_tos_version
+ )
+ devise_parameter_sanitizer.permit(
+ :sign_up,
+ keys: [
+ :password_confirmation, :email, :age_over_13, :data_processing, :terms_of_service, :accepted_tos_version
+ ]
+ )
+ end
+
+ def check_account_creation_status
+ if is_registered_user?
+ flash[:error] = ts('You are already logged in!')
+ redirect_to root_path and return
+ end
+
+ token = params[:invitation_token]
+
+ if !AdminSetting.current.account_creation_enabled?
+ flash[:error] = ts('Account creation is suspended at the moment. Please check back with us later.')
+ redirect_to root_path and return
+ else
+ check_account_creation_invite(token) if AdminSetting.current.creation_requires_invite?
+ end
+ end
+
+ def check_account_creation_invite(token)
+ unless token.blank?
+ invitation = Invitation.find_by(token: token)
+
+ if !invitation
+ flash[:error] = ts('There was an error with your invitation token, please contact support')
+ redirect_to new_feedback_report_path
+ elsif invitation.redeemed_at
+ flash[:error] = ts('This invitation has already been used to create an account, sorry!')
+ redirect_to root_path
+ end
+
+ return
+ end
+
+ if !AdminSetting.current.invite_from_queue_enabled?
+ flash[:error] = ts('Account creation currently requires an invitation. We are unable to give out additional invitations at present, but existing invitations can still be used to create an account.')
+ redirect_to root_path
+ else
+ flash[:error] = ts("To create an account, you'll need an invitation. One option is to add your name to the automatic queue below.")
+ redirect_to invite_requests_path
+ end
+ end
+end
diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb
new file mode 100755
index 0000000..d44bb62
--- /dev/null
+++ b/app/controllers/users/sessions_controller.rb
@@ -0,0 +1,36 @@
+class Users::SessionsController < Devise::SessionsController
+
+ layout "session"
+ before_action :admin_logout_required
+ skip_before_action :store_location
+
+ # POST /users/login
+ def create
+ super do |resource|
+ unless resource.remember_me
+ message = ts(" You'll stay logged in for %{number} weeks even if you close your browser, so make sure to log out if you're using a public or shared computer.", number: ArchiveConfig.DEFAULT_SESSION_LENGTH_IN_WEEKS)
+ end
+ flash[:notice] += message unless message.nil?
+ flash[:notice] = flash[:notice].html_safe
+ end
+ end
+
+ # GET /users/logout
+ def confirm_logout
+ # If the user is already logged out, we just redirect to the front page.
+ redirect_to root_path unless user_signed_in?
+ end
+
+ # DELETE /users/logout
+ def destroy
+ # signed_out clears the session
+ return_to = session[:return_to]
+
+ signed_out = (Devise.sign_out_all_scopes ? sign_out : sign_out(resource_name))
+ set_flash_message! :notice, :signed_out if signed_out
+
+ session[:return_to] = return_to
+
+ redirect_back_or_default root_path
+ end
+end
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
new file mode 100755
index 0000000..4cf17c4
--- /dev/null
+++ b/app/controllers/users_controller.rb
@@ -0,0 +1,418 @@
+class UsersController < ApplicationController
+ cache_sweeper :pseud_sweeper
+
+ before_action :check_user_status, only: [:edit, :update, :change_username, :changed_username]
+ before_action :load_user, except: [:activate, :delete_confirmation, :index]
+ before_action :check_ownership, except: [:activate, :change_username, :changed_username, :delete_confirmation, :edit, :index, :show, :update]
+ before_action :check_ownership_or_admin, only: [:change_username, :changed_username, :edit, :update]
+ skip_before_action :store_location, only: [:end_first_login]
+
+ def load_user
+ @user = User.find_by!(login: params[:id])
+ @check_ownership_of = @user
+ end
+
+ def index
+ flash.keep
+ redirect_to controller: :people, action: :index
+ end
+
+ # GET /users/1
+ def show
+ @page_subtitle = @user.login
+ @status = @user.statuses.last
+ visible = visible_items(current_user)
+
+ @works = visible[:works].order('revised_at DESC').limit(ArchiveConfig.NUMBER_OF_ITEMS_VISIBLE_IN_DASHBOARD)
+ @series = visible[:series].order('updated_at DESC').limit(ArchiveConfig.NUMBER_OF_ITEMS_VISIBLE_IN_DASHBOARD)
+ @bookmarks = visible[:bookmarks].order('updated_at DESC').limit(ArchiveConfig.NUMBER_OF_ITEMS_VISIBLE_IN_DASHBOARD)
+ if current_user.respond_to?(:subscriptions)
+ @subscription = current_user.subscriptions.where(subscribable_id: @user.id,
+ subscribable_type: 'User').first ||
+ current_user.subscriptions.build(subscribable: @user)
+ end
+ end
+
+ # GET /users/1/edit
+ def edit
+ @page_subtitle = t(".browser_title")
+ authorize @user.profile if logged_in_as_admin?
+ end
+
+ def change_email
+ @page_subtitle = t(".page_title")
+ end
+
+ def change_password
+ @page_subtitle = t(".page_title")
+ end
+
+ def change_username
+ authorize @user if logged_in_as_admin?
+ @page_subtitle = t(".page_title")
+ end
+
+ def changed_password
+ unless params[:password] && reauthenticate
+ render(:change_password) && return
+ end
+
+ @user.password = params[:password]
+ @user.password_confirmation = params[:password_confirmation]
+
+ if @user.save
+ flash[:notice] = ts("Your password has been changed. To protect your account, you have been logged out of all active sessions. Please log in with your new password.")
+ @user.create_log_item(action: ArchiveConfig.ACTION_PASSWORD_CHANGE)
+
+ redirect_to(user_profile_path(@user)) && return
+ else
+ render(:change_password) && return
+ end
+ end
+
+ def changed_username
+ authorize @user if logged_in_as_admin?
+ render(:change_username) && return if params[:new_login].blank?
+
+ @new_login = params[:new_login]
+
+ unless logged_in_as_admin? || @user.valid_password?(params[:password])
+ flash[:error] = t(".user.incorrect_password")
+ render(:change_username) && return
+ end
+
+ if @new_login == @user.login
+ flash.now[:error] = t(".new_username_must_be_different")
+ render :change_username and return
+ end
+
+ old_login = @user.login
+ @user.login = @new_login
+ @user.ticket_number = params[:ticket_number]
+
+ if @user.save
+ if logged_in_as_admin?
+ flash[:notice] = t(".admin.successfully_updated")
+ redirect_to admin_user_path(@user)
+ else
+ I18n.with_locale(@user.preference.locale_for_mails) do
+ UserMailer.change_username(@user, old_login).deliver_later
+ end
+
+ flash[:notice] = t(".user.successfully_updated")
+ redirect_to @user
+ end
+ else
+ @user.reload
+ render :change_username
+ end
+ end
+
+ def activate
+ if params[:id].blank?
+ flash[:error] = ts('Your activation key is missing.')
+ redirect_to root_path
+
+ return
+ end
+
+ @user = User.find_by(confirmation_token: params[:id])
+
+ unless @user
+ flash[:error] = ts("Your activation key is invalid. If you didn't activate within #{AdminSetting.current.days_to_purge_unactivated * 7} days, your account was deleted. Please sign up again, or contact support via the link in our footer for more help.").html_safe
+ redirect_to root_path
+
+ return
+ end
+
+ if @user.active?
+ flash[:error] = ts("Your account has already been activated.")
+ redirect_to @user
+
+ return
+ end
+
+ @user.activate
+
+ flash[:notice] = ts("Account activation complete! Please log in.")
+
+ @user.create_log_item(action: ArchiveConfig.ACTION_ACTIVATE)
+
+ # assign over any external authors that belong to this user
+ external_authors = []
+ external_authors << ExternalAuthor.find_by(email: @user.email)
+ @invitation = @user.invitation
+ external_authors << @invitation.external_author if @invitation
+ external_authors.compact!
+
+ unless external_authors.empty?
+ external_authors.each do |external_author|
+ external_author.claim!(@user)
+ end
+
+ flash[:notice] += ts(" We found some works already uploaded to the Archive of Our Own that we think belong to you! You'll see them on your homepage when you've logged in.")
+ end
+
+ redirect_to(new_user_session_path)
+ end
+
+ def update
+ authorize @user.profile if logged_in_as_admin?
+ if @user.profile.update(profile_params)
+ if logged_in_as_admin? && @user.profile.ticket_url.present?
+ link = view_context.link_to("Ticket ##{@user.profile.ticket_number}", @user.profile.ticket_url)
+ AdminActivity.log_action(current_admin, @user, action: "edit profile", summary: link)
+ end
+ flash[:notice] = ts('Your profile has been successfully updated')
+ redirect_to user_profile_path(@user)
+ else
+ render :edit
+ end
+ end
+
+ def confirm_change_email
+ @page_subtitle = t(".browser_title")
+
+ render :change_email and return unless reauthenticate
+
+ if params[:new_email].blank?
+ flash.now[:error] = t("users.confirm_change_email.blank_email")
+ render :change_email and return
+ end
+
+ @new_email = params[:new_email]
+
+ # Please note: This comparison is not technically correct. According to
+ # RFC 5321, the local part of an email address is case sensitive, while the
+ # domain is case insensitive. That said, all major email providers treat
+ # the local part as case insensitive, so it would probably cause more
+ # confusion if we did this correctly.
+ #
+ # Also, email addresses are validated on the client, and will only contain
+ # a limited subset of ASCII, so we don't need to do a unicode casefolding pass.
+ if @new_email.downcase == @user.email.downcase
+ flash.now[:error] = t("users.confirm_change_email.same_as_current")
+ render :change_email and return
+ end
+
+ if @new_email.downcase != params[:email_confirmation].downcase
+ flash.now[:error] = t("users.confirm_change_email.nonmatching_email")
+ render :change_email and return
+ end
+
+ old_email = @user.email
+ @user.email = @new_email
+ return if @user.valid?(:update)
+
+ # Make sure that on failure, the form doesn't show the new invalid email as the current one
+ @user.email = old_email
+ render :change_email
+ end
+
+ def changed_email
+ new_email = params[:new_email]
+
+ old_email = @user.email
+ @user.email = new_email
+
+ if @user.save
+ I18n.with_locale(@user.preference.locale_for_mails) do
+ UserMailer.change_email(@user.id, old_email, new_email).deliver_later
+ end
+ else
+ # Make sure that on failure, the form still shows the old email as the "current" one.
+ @user.email = old_email
+ end
+
+ render :change_email
+ end
+
+ # GET /users/1/reconfirm_email?confirmation_token=abcdef
+ def reconfirm_email
+ confirmed_user = User.confirm_by_token(params[:confirmation_token])
+
+ if confirmed_user.errors.empty?
+ flash[:notice] = t(".success")
+ else
+ flash[:error] = t(".invalid_token")
+ end
+
+ redirect_to change_email_user_path(@user)
+ end
+
+ # DELETE /users/1
+ # DELETE /users/1.xml
+ def destroy
+ @hide_dashboard = true
+ @works = @user.works.where(posted: true)
+ @sole_owned_collections = @user.sole_owned_collections
+
+ if @works.empty? && @sole_owned_collections.empty?
+ @user.wipeout_unposted_works
+ @user.destroy_empty_series
+
+ @user.destroy
+ flash[:notice] = ts('You have successfully deleted your account.')
+
+ redirect_to(delete_confirmation_path)
+ elsif params[:coauthor].blank? && params[:sole_author].blank?
+ @sole_authored_works = @user.sole_authored_works
+ @coauthored_works = @user.coauthored_works
+
+ render('delete_preview') && return
+ elsif params[:coauthor] || params[:sole_author]
+ destroy_author
+ end
+ end
+
+ def delete_confirmation
+ end
+
+ def end_first_login
+ @user.preference.update_attribute(:first_login, false)
+
+ respond_to do |format|
+ format.html { redirect_to(@user) && return }
+ format.js
+ end
+ end
+
+ def end_banner
+ @user.preference.update_attribute(:banner_seen, true)
+
+ respond_to do |format|
+ format.html { redirect_to(request.env['HTTP_REFERER'] || root_path) && return }
+ format.js
+ end
+ end
+
+ def end_tos_prompt
+ @user.update_attribute(:accepted_tos_version, @current_tos_version)
+ head :no_content
+ end
+
+ private
+
+ def reauthenticate
+ if params[:password_check].blank?
+ return wrong_password!(params[:new_email],
+ t("users.confirm_change_email.blank_password"),
+ t("users.changed_password.blank_password"))
+ end
+
+ if @user.valid_password?(params[:password_check])
+ true
+ else
+ wrong_password!(params[:new_email],
+ t("users.confirm_change_email.wrong_password_html", contact_support_link: helpers.link_to(t("users.confirm_change_email.contact_support"), new_feedback_report_path)),
+ t("users.changed_password.wrong_password"))
+ end
+ end
+
+ def wrong_password!(condition, if_true, if_false)
+ flash.now[:error] = condition ? if_true : if_false
+ @wrong_password = true
+
+ false
+ end
+
+ def visible_items(current_user)
+ # NOTE: When current_user is nil, we use .visible_to_all, otherwise we use
+ # .visible_to_registered_user.
+ visible_method = current_user.nil? && current_admin.nil? ? :visible_to_all : :visible_to_registered_user
+
+ visible_works = @user.works.send(visible_method)
+ visible_series = @user.series.send(visible_method)
+ visible_bookmarks = @user.bookmarks.send(visible_method)
+
+ visible_works = visible_works.revealed.non_anon
+ visible_series = visible_series.exclude_anonymous
+ @fandoms = if @user == User.orphan_account
+ []
+ else
+ Fandom.select("tags.*, count(DISTINCT works.id) as work_count").
+ joins(:filtered_works).group("tags.id").merge(visible_works).
+ where(filter_taggings: { inherited: false }).
+ order('work_count DESC').load
+ end
+
+ {
+ works: visible_works,
+ series: visible_series,
+ bookmarks: visible_bookmarks
+ }
+ end
+
+ def destroy_author
+ @sole_authored_works = @user.sole_authored_works
+ @coauthored_works = @user.coauthored_works
+
+ if params[:cancel_button]
+ flash[:notice] = ts('Account deletion canceled.')
+ redirect_to user_profile_path(@user)
+
+ return
+ end
+
+ if params[:coauthor] == 'keep_pseud' || params[:coauthor] == 'orphan_pseud'
+ # Orphans co-authored works.
+
+ pseuds = @user.pseuds
+ works = @coauthored_works
+
+ # We change the pseud to the default orphan pseud if use_default is true.
+ use_default = params[:use_default] == 'true' || params[:coauthor] == 'orphan_pseud'
+
+ Creatorship.orphan(pseuds, works, use_default)
+
+ elsif params[:coauthor] == 'remove'
+ # Removes user as an author from co-authored works
+
+ @coauthored_works.each do |w|
+ w.remove_author(@user)
+ end
+ end
+
+ if params[:sole_author] == 'keep_pseud' || params[:sole_author] == 'orphan_pseud'
+ # Orphans works where user is the sole author.
+
+ pseuds = @user.pseuds
+ works = @sole_authored_works
+
+ # We change the pseud to default orphan pseud if use_default is true.
+ use_default = params[:use_default] == 'true' || params[:sole_author] == 'orphan_pseud'
+
+ Creatorship.orphan(pseuds, works, use_default)
+ Collection.orphan(pseuds, @sole_owned_collections, default: use_default)
+ elsif params[:sole_author] == 'delete'
+ # Deletes works where user is sole author
+ @sole_authored_works.each(&:destroy)
+
+ # Deletes collections where user is sole author
+ @sole_owned_collections.each(&:destroy)
+ end
+
+ @works = @user.works.where(posted: true)
+
+ if @works.blank?
+ @user.wipeout_unposted_works
+ @user.destroy_empty_series
+
+ @user.destroy
+
+ flash[:notice] = ts('You have successfully deleted your account.')
+ redirect_to(delete_confirmation_path)
+ else
+ flash[:error] = ts('Sorry, something went wrong! Please try again.')
+ redirect_to(@user)
+ end
+ end
+
+ private
+
+ def profile_params
+ params.require(:profile_attributes).permit(
+ :title, :about_me, :ticket_number
+ )
+ end
+end
diff --git a/app/controllers/works_controller.rb b/app/controllers/works_controller.rb
new file mode 100755
index 0000000..ec65bdc
--- /dev/null
+++ b/app/controllers/works_controller.rb
@@ -0,0 +1,984 @@
+# encoding=utf-8
+
+class WorksController < ApplicationController
+ # only registered users and NOT admin should be able to create new works
+ before_action :load_collection
+ before_action :load_owner, only: [:index]
+ before_action :users_only, except: [:index, :show, :navigate, :search, :collected, :edit_tags, :update_tags, :drafts, :share]
+ before_action :check_user_status, except: [:index, :edit, :edit_multiple, :confirm_delete_multiple, :delete_multiple, :confirm_delete, :destroy, :show, :show_multiple, :navigate, :search, :collected, :share]
+ before_action :check_user_not_suspended, only: [:edit, :confirm_delete, :destroy, :show_multiple, :edit_multiple, :confirm_delete_multiple, :delete_multiple]
+ before_action :load_work, except: [:new, :create, :import, :index, :show_multiple, :edit_multiple, :update_multiple, :delete_multiple, :search, :drafts, :collected]
+ # this only works to check ownership of a SINGLE item and only if load_work has happened beforehand
+ before_action :check_ownership, except: [:index, :show, :navigate, :new, :create, :import, :show_multiple, :edit_multiple, :edit_tags, :update_tags, :update_multiple, :delete_multiple, :search, :mark_for_later, :mark_as_read, :drafts, :collected, :share]
+ # admins should have the ability to edit tags (:edit_tags, :update_tags) as per our ToS
+ before_action :check_ownership_or_admin, only: [:edit_tags, :update_tags]
+ before_action :log_admin_activity, only: [:update_tags]
+ before_action :check_parent_visible, only: [:navigate]
+ before_action :check_visibility, only: [:show, :navigate, :share, :mark_for_later, :mark_as_read]
+
+ before_action :load_first_chapter, only: [:show, :edit, :update, :preview]
+
+ cache_sweeper :collection_sweeper
+ cache_sweeper :feed_sweeper
+
+ skip_before_action :store_location, only: [:share]
+
+ # we want to extract the countable params from work_search and move them into their fields
+ def clean_work_search_params
+ QueryCleaner.new(work_search_params || {}).clean
+ end
+
+ def search
+ @languages = Language.default_order
+ options = params[:work_search].present? ? clean_work_search_params : {}
+ options[:page] = params[:page] if params[:page].present?
+ options[:show_restricted] = current_user.present? || logged_in_as_admin?
+ @search = WorkSearchForm.new(options)
+ @page_subtitle = ts("Search Works")
+
+ if params[:work_search].present? && params[:edit_search].blank?
+ if @search.query.present?
+ @page_subtitle = ts("Works Matching '%{query}'", query: @search.query)
+ end
+
+ @works = @search.search_results.scope(:for_blurb)
+ set_own_works
+ flash_search_warnings(@works)
+ render 'search_results'
+ end
+ end
+
+ # GET /works
+ def index
+ base_options = {
+ page: params[:page] || 1,
+ show_restricted: current_user.present? || logged_in_as_admin?
+ }
+
+ options = params[:work_search].present? ? clean_work_search_params : {}
+
+ if params[:fandom_id].present? || (@collection.present? && @tag.present?)
+ @fandom = Fandom.find(params[:fandom_id]) if params[:fandom_id]
+
+ tag = @fandom || @tag
+
+ options[:filter_ids] ||= []
+ options[:filter_ids] << tag.id
+ end
+
+ if params[:include_work_search].present?
+ params[:include_work_search].keys.each do |key|
+ options[key] ||= []
+ options[key] << params[:include_work_search][key]
+ options[key].flatten!
+ end
+ end
+
+ if params[:exclude_work_search].present?
+ params[:exclude_work_search].keys.each do |key|
+ options[:excluded_tag_ids] ||= []
+ options[:excluded_tag_ids] << params[:exclude_work_search][key]
+ options[:excluded_tag_ids].flatten!
+ end
+ end
+
+ options.merge!(base_options)
+ @page_subtitle = index_page_title
+
+ if logged_in? && @tag
+ @favorite_tag = @current_user.favorite_tags
+ .where(tag_id: @tag.id).first ||
+ FavoriteTag
+ .new(tag_id: @tag.id, user_id: @current_user.id)
+ end
+
+ if @owner.present?
+ @search = WorkSearchForm.new(options.merge(faceted: true, works_parent: @owner))
+ # If we're using caching we'll try to get the results from cache
+ # Note: we only cache some first initial number of pages since those are biggest bang for
+ # the buck -- users don't often go past them
+ if use_caching? && params[:work_search].blank? && params[:fandom_id].blank? &&
+ params[:include_work_search].blank? && params[:exclude_work_search].blank? &&
+ (params[:page].blank? || params[:page].to_i <= ArchiveConfig.PAGES_TO_CACHE)
+ # the subtag is for eg collections/COLL/tags/TAG
+ subtag = @tag.present? && @tag != @owner ? @tag : nil
+ user = logged_in? || logged_in_as_admin? ? 'logged_in' : 'logged_out'
+ @works = Rails.cache.fetch("#{@owner.works_index_cache_key(subtag)}_#{user}_page#{params[:page]}_true", expires_in: ArchiveConfig.SECONDS_UNTIL_WORK_INDEX_EXPIRE.seconds) do
+ results = @search.search_results.scope(:for_blurb)
+ # calling this here to avoid frozen object errors
+ results.items
+ results.facets
+ results
+ end
+ else
+ @works = @search.search_results.scope(:for_blurb)
+ end
+
+ flash_search_warnings(@works)
+
+ @facets = @works.facets
+ if @search.options[:excluded_tag_ids].present? && @facets
+ tags = Tag.where(id: @search.options[:excluded_tag_ids])
+ tags.each do |tag|
+ @facets[tag.class.to_s.underscore] ||= []
+ @facets[tag.class.to_s.underscore] << QueryFacet.new(tag.id, tag.name, 0)
+ end
+ end
+ elsif use_caching?
+ @works = Rails.cache.fetch('works/index/latest/v1', expires_in: ArchiveConfig.SECONDS_UNTIL_WORK_INDEX_EXPIRE.seconds) do
+ Work.latest.for_blurb.to_a
+ end
+ else
+ @works = Work.latest.for_blurb.to_a
+ end
+ set_own_works
+
+ @pagy = pagy_query_result(@works) if @works.respond_to?(:total_pages)
+ end
+
+ def collected
+ options = params[:work_search].present? ? clean_work_search_params : {}
+ options[:page] = params[:page] || 1
+ options[:show_restricted] = current_user.present? || logged_in_as_admin?
+
+ @user = User.find_by!(login: params[:user_id])
+ @search = WorkSearchForm.new(options.merge(works_parent: @user, collected: true))
+ @works = @search.search_results.scope(:for_blurb)
+ flash_search_warnings(@works)
+ @facets = @works.facets
+ set_own_works
+ @page_subtitle = ts('%{username} - Collected Works', username: @user.login)
+ end
+
+ def drafts
+ unless params[:user_id] && (@user = User.find_by(login: params[:user_id]))
+ flash[:error] = ts('Whose drafts did you want to look at?')
+ redirect_to users_path
+ return
+ end
+
+ unless current_user == @user || logged_in_as_admin?
+ flash[:error] = ts('You can only see your own drafts, sorry!')
+ redirect_to logged_in? ? user_path(current_user) : new_user_session_path
+ return
+ end
+
+ @page_subtitle = t(".page_title", username: @user.login)
+
+ if params[:pseud_id]
+ @pseud = @user.pseuds.find_by(name: params[:pseud_id])
+ @works = @pseud.unposted_works.for_blurb.paginate(page: params[:page])
+ else
+ @works = @user.unposted_works.for_blurb.paginate(page: params[:page])
+ end
+ end
+
+ # GET /works/1
+ # GET /works/1.xml
+ def show
+ @tag_groups = @work.tag_groups
+ if @work.unrevealed?
+ @page_subtitle = t(".page_title.unrevealed")
+ else
+ page_creator = if @work.anonymous?
+ ts("Anonymous")
+ else
+ @work.pseuds.map(&:byline).sort.join(", ")
+ end
+ fandoms = @tag_groups["Fandom"]
+ page_title_inner = if fandoms.size > 3
+ ts("Multifandom")
+ else
+ fandoms.empty? ? ts("No fandom specified") : fandoms[0].name
+ end
+ @page_title = get_page_title(page_title_inner, page_creator, @work.title)
+ end
+
+ # Users must explicitly okay viewing of adult content
+ if params[:view_adult]
+ cookies[:view_adult] = "true"
+ elsif @work.adult? && !see_adult?
+ render('_adult', layout: 'application') && return
+ end
+
+ # Users must explicitly okay viewing of entire work
+ if @work.chaptered?
+ if params[:view_full_work] || (logged_in? && current_user.preference.try(:view_full_works))
+ @chapters = @work.chapters_in_order(
+ include_drafts: (logged_in_as_admin? ||
+ @work.user_is_owner_or_invited?(current_user))
+ )
+ else
+ flash.keep
+ redirect_to([@work, @chapter, { only_path: true }]) && return
+ end
+ end
+
+ @tag_categories_limited = Tag::VISIBLE - ['ArchiveWarning']
+ @kudos = @work.kudos.with_user.includes(:user)
+
+ if current_user.respond_to?(:subscriptions)
+ @subscription = current_user.subscriptions.where(subscribable_id: @work.id,
+ subscribable_type: 'Work').first ||
+ current_user.subscriptions.build(subscribable: @work)
+ end
+
+ render :show
+ Reading.update_or_create(@work, current_user) if current_user
+ end
+
+ # GET /works/1/share
+ def share
+ if request.xhr?
+ if @work.unrevealed?
+ render template: "errors/404", status: :not_found
+ else
+ render layout: false
+ end
+ else
+ # Avoid getting an unstyled page if JavaScript is disabled
+ flash[:error] = ts("Sorry, you need to have JavaScript enabled for this.")
+ if request.env["HTTP_REFERER"]
+ redirect_to(request.env["HTTP_REFERER"] || root_path)
+ else
+ # else branch needed to deal with bots, which don't have a referer
+ redirect_to root_path
+ end
+ end
+ end
+
+ def navigate
+ @chapters = @work.chapters_in_order(
+ include_content: false,
+ include_drafts: (logged_in_as_admin? ||
+ @work.user_is_owner_or_invited?(current_user))
+ )
+ end
+
+ # GET /works/new
+ def new
+ @hide_dashboard = true
+ @unposted = current_user.unposted_work
+
+ if params[:load_unposted] && @unposted
+ @work = @unposted
+ @chapter = @work.first_chapter
+ else
+ @work = Work.new
+ @chapter = @work.chapters.build
+ end
+
+ # for clarity, add the collection and recipient
+ if params[:assignment_id] && (@challenge_assignment = ChallengeAssignment.find(params[:assignment_id])) && @challenge_assignment.offering_user == current_user
+ @work.challenge_assignments << @challenge_assignment
+ end
+
+ if params[:claim_id] && (@challenge_claim = ChallengeClaim.find(params[:claim_id])) && User.find(@challenge_claim.claiming_user_id) == current_user
+ @work.challenge_claims << @challenge_claim
+ end
+
+ if @collection
+ @work.add_to_collection(@collection)
+ end
+
+ @work.set_challenge_info
+ @work.set_challenge_claim_info
+ set_work_form_fields
+
+ if params[:import]
+ @page_subtitle = ts("Import New Work")
+ render(:new_import)
+ elsif @work.persisted?
+ render(:edit)
+ else
+ render(:new)
+ end
+ end
+
+ # POST /works
+ def create
+ if params[:cancel_button]
+ flash[:notice] = ts('New work posting canceled.')
+ redirect_to current_user
+ return
+ end
+
+ @work = Work.new(work_params)
+
+ @chapter = @work.first_chapter
+ @chapter.attributes = work_params[:chapter_attributes] if work_params[:chapter_attributes]
+ @work.ip_address = request.remote_ip
+
+ @work.set_challenge_info
+ @work.set_challenge_claim_info
+ set_work_form_fields
+
+ if work_cannot_be_saved?
+ render :new
+ else
+ @work.posted = @chapter.posted = true if params[:post_button]
+ @work.set_revised_at_by_chapter(@chapter)
+
+ if @work.save
+ if params[:preview_button]
+ flash[:notice] = ts("Draft was successfully created. It will be scheduled for deletion on %{deletion_date}.", deletion_date: view_context.date_in_zone(@work.created_at + 29.days)).html_safe
+ in_moderated_collection
+ redirect_to preview_work_path(@work)
+ else
+ # We check here to see if we are attempting to post to moderated collection
+ flash[:notice] = ts("Work was successfully posted. It should appear in work listings within the next few minutes.")
+ in_moderated_collection
+ redirect_to work_path(@work)
+ end
+ else
+ render :new
+ end
+ end
+ end
+
+ # GET /works/1/edit
+ def edit
+ @hide_dashboard = true
+ if @work.number_of_chapters > 1
+ @chapters = @work.chapters_in_order(include_content: false,
+ include_drafts: true)
+ end
+ set_work_form_fields
+ end
+
+ def remove_user_creatorship
+ pseuds_with_author_removed = @work.pseuds - current_user.pseuds
+
+ if pseuds_with_author_removed.empty?
+ redirect_to controller: 'orphans', action: 'new', work_id: @work.id
+ else
+ @work.remove_author(current_user)
+ flash[:notice] = ts("You have been removed as a creator from the work.")
+ redirect_to current_user
+ end
+ end
+
+ # GET /works/1/edit_tags
+ def edit_tags
+ authorize @work if logged_in_as_admin?
+ @page_subtitle = ts("Edit Work Tags")
+ end
+
+ # PUT /works/1
+ def update
+ if params[:cancel_button]
+ return cancel_posting_and_redirect
+ end
+
+ @work.preview_mode = !!(params[:preview_button] || params[:edit_button])
+ @work.attributes = work_params
+ @chapter.attributes = work_params[:chapter_attributes] if work_params[:chapter_attributes]
+ @work.ip_address = request.remote_ip
+ @work.set_word_count(@work.preview_mode)
+
+ @work.set_challenge_info
+ @work.set_challenge_claim_info
+ set_work_form_fields
+
+ if params[:edit_button] || work_cannot_be_saved?
+ render :edit
+ elsif params[:preview_button]
+ unless @work.posted?
+ flash[:notice] = ts("Your changes have not been saved. Please post your work or save as draft if you want to keep them.")
+ end
+
+ in_moderated_collection
+ @preview_mode = true
+ render :preview
+ else
+ @work.posted = @chapter.posted = true if params[:post_button]
+ @work.set_revised_at_by_chapter(@chapter)
+ posted_changed = @work.posted_changed?
+
+ if @chapter.save && @work.save
+ flash[:notice] = ts("Work was successfully #{posted_changed ? 'posted' : 'updated'}.")
+ if posted_changed
+ flash[:notice] << ts(" It should appear in work listings within the next few minutes.")
+ end
+ in_moderated_collection
+ redirect_to work_path(@work)
+ else
+ @chapter.errors.full_messages.each { |err| @work.errors.add(:base, err) }
+ render :edit
+ end
+ end
+ end
+
+ def update_tags
+ authorize @work if logged_in_as_admin?
+ if params[:cancel_button]
+ return cancel_posting_and_redirect
+ end
+
+ @work.preview_mode = !!(params[:preview_button] || params[:edit_button])
+ @work.attributes = work_tag_params
+
+ if params[:edit_button] || work_cannot_be_saved?
+ render :edit_tags
+ elsif params[:preview_button]
+ @preview_mode = true
+ render :preview_tags
+ elsif params[:save_button]
+ @work.save
+ flash[:notice] = ts('Tags were successfully updated.')
+ redirect_to(@work)
+ else # Save As Draft
+ @work.posted = true
+ @work.minor_version = @work.minor_version + 1
+ @work.save
+ flash[:notice] = ts('Work was successfully updated.')
+ redirect_to(@work)
+ end
+ end
+
+ # GET /works/1/preview
+ def preview
+ @preview_mode = true
+ end
+
+ def preview_tags
+ @preview_mode = true
+ end
+
+ def confirm_delete
+ end
+
+ # DELETE /works/1
+ def destroy
+ @work = Work.find(params[:id])
+
+ begin
+ was_draft = !@work.posted?
+ title = @work.title
+ @work.destroy
+ flash[:notice] = ts("Your work %{title} was deleted.", title: title).html_safe
+ rescue
+ flash[:error] = ts("We couldn't delete that right now, sorry! Please try again later.")
+ end
+
+ if was_draft
+ redirect_to drafts_user_works_path(current_user)
+ else
+ redirect_to user_works_path(current_user)
+ end
+ end
+
+ # POST /works/import
+ def import
+ # check to make sure we have some urls to work with
+ @urls = params[:urls].split
+ if @urls.empty?
+ flash.now[:error] = ts('Did you want to enter a URL?')
+ render(:new_import) && return
+ end
+
+ @language_id = params[:language_id]
+ if @language_id.empty?
+ flash.now[:error] = ts("Language cannot be blank.")
+ render(:new_import) && return
+ end
+
+ importing_for_others = params[:importing_for_others] != "false" && params[:importing_for_others]
+
+ # is external author information entered when import for others is not checked?
+ if (params[:external_author_name].present? || params[:external_author_email].present?) && !importing_for_others
+ flash.now[:error] = ts('You have entered an external author name or e-mail address but did not select "Import for others." Please select the "Import for others" option or remove the external author information to continue.')
+ render(:new_import) && return
+ end
+
+ # is this an archivist importing?
+ if importing_for_others && !current_user.archivist
+ flash.now[:error] = ts('You may not import stories by other users unless you are an approved archivist.')
+ render(:new_import) && return
+ end
+
+ # make sure we're not importing too many at once
+ if params[:import_multiple] == 'works' && (!current_user.archivist && @urls.length > ArchiveConfig.IMPORT_MAX_WORKS || @urls.length > ArchiveConfig.IMPORT_MAX_WORKS_BY_ARCHIVIST)
+ flash.now[:error] = ts('You cannot import more than %{max} works at a time.', max: current_user.archivist ? ArchiveConfig.IMPORT_MAX_WORKS_BY_ARCHIVIST : ArchiveConfig.IMPORT_MAX_WORKS)
+ render(:new_import) && return
+ elsif params[:import_multiple] == 'chapters' && @urls.length > ArchiveConfig.IMPORT_MAX_CHAPTERS
+ flash.now[:error] = ts('You cannot import more than %{max} chapters at a time.', max: ArchiveConfig.IMPORT_MAX_CHAPTERS)
+ render(:new_import) && return
+ end
+
+ options = build_options(params)
+ options[:ip_address] = request.remote_ip
+
+ # now let's do the import
+ if params[:import_multiple] == 'works' && @urls.length > 1
+ import_multiple(@urls, options)
+ else # a single work possibly with multiple chapters
+ import_single(@urls, options)
+ end
+ end
+
+ protected
+
+ # import a single work (possibly with multiple chapters)
+ def import_single(urls, options)
+ # try the import
+ storyparser = StoryParser.new
+
+ begin
+ @work = if urls.size == 1
+ storyparser.download_and_parse_story(urls.first, options)
+ else
+ storyparser.download_and_parse_chapters_into_story(urls, options)
+ end
+ rescue Timeout::Error
+ flash.now[:error] = ts('Import has timed out. This may be due to connectivity problems with the source site. Please try again in a few minutes, or check Known Issues to see if there are import problems with this site.')
+ render(:new_import) && return
+ rescue StoryParser::Error => exception
+ flash.now[:error] = ts("We couldn't successfully import that work, sorry: %{message}", message: exception.message)
+ render(:new_import) && return
+ end
+
+ unless @work && @work.save
+ flash.now[:error] = ts("We were only partially able to import this work and couldn't save it. Please review below!")
+ @chapter = @work.chapters.first
+ @series = current_user.series.distinct
+ render(:new) && return
+ end
+
+ # Otherwise, we have a saved work, go us
+ send_external_invites([@work])
+ @chapter = @work.first_chapter if @work
+ if @work.posted
+ redirect_to(work_path(@work)) && return
+ else
+ redirect_to(preview_work_path(@work)) && return
+ end
+ end
+
+ # import multiple works
+ def import_multiple(urls, options)
+ # try a multiple import
+ storyparser = StoryParser.new
+ @works, failed_urls, errors = storyparser.import_from_urls(urls, options)
+
+ # collect the errors neatly, matching each error to the failed url
+ unless failed_urls.empty?
+ error_msgs = 0.upto(failed_urls.length).map { |index| "
#{failed_urls[index]}
#{errors[index]}
" }.join("\n")
+ flash.now[:error] = "
#{ts('Failed Imports')}
#{error_msgs}
".html_safe
+ end
+
+ # if EVERYTHING failed, boo. :( Go back to the import form.
+ render(:new_import) && return if @works.empty?
+
+ # if we got here, we have at least some successfully imported works
+ flash[:notice] = ts('Importing completed successfully for the following works! (But please check the results over carefully!)')
+ send_external_invites(@works)
+
+ # fall through to import template
+ end
+
+ # if we are importing for others, we need to send invitations
+ def send_external_invites(works)
+ return unless params[:importing_for_others]
+
+ @external_authors = works.collect(&:external_authors).flatten.uniq
+ unless @external_authors.empty?
+ @external_authors.each do |external_author|
+ external_author.find_or_invite(current_user)
+ end
+ message = ' ' + ts('We have notified the author(s) you imported works for. If any were missed, you can also add co-authors manually.')
+ flash[:notice] ? flash[:notice] += message : flash[:notice] = message
+ end
+ end
+
+ # check to see if the work is being added / has been added to a moderated collection, then let user know that
+ def in_moderated_collection
+ moderated_collections = []
+ @work.collections.each do |collection|
+ next unless !collection.nil? && collection.moderated? && !collection.user_is_posting_participant?(current_user)
+ next unless @work.collection_items.present?
+ @work.collection_items.each do |collection_item|
+ next unless collection_item.collection == collection
+ if collection_item.approved_by_user? && collection_item.unreviewed_by_collection?
+ moderated_collections << collection
+ end
+ end
+ end
+ if moderated_collections.present?
+ flash[:notice] ||= ''
+ flash[:notice] += ts(" You have submitted your work to #{moderated_collections.size > 1 ? 'moderated collections (%{all_collections}). It will not become a part of those collections' : "the moderated collection '%{all_collections}'. It will not become a part of the collection"} until it has been approved by a moderator.", all_collections: moderated_collections.map(&:title).join(', '))
+ end
+ end
+
+ public
+
+ def post_draft
+ @user = current_user
+ @work = Work.find(params[:id])
+
+ unless @user.is_author_of?(@work)
+ flash[:error] = ts('You can only post your own works.')
+ redirect_to(current_user) && return
+ end
+
+ if @work.posted
+ flash[:error] = ts('That work is already posted. Do you want to edit it instead?')
+ redirect_to(edit_user_work_path(@user, @work)) && return
+ end
+
+ @work.posted = true
+ @work.minor_version = @work.minor_version + 1
+
+ unless @work.valid? && @work.save
+ flash[:error] = ts('There were problems posting your work.')
+ redirect_to(edit_user_work_path(@user, @work)) && return
+ end
+
+ # AO3-3498: since a work's word count is calculated in a before_save and the chapter is posted in an after_save,
+ # work's word count needs to be updated with the chapter's word count after the chapter is posted
+ # AO3-6273 Cannot rely on set_word_count here in a production environment, as it might query an older version of the database
+ # Instead, as the work in this context is reduced its first chapter, we copy the value directly
+ @work.word_count = @work.first_chapter.word_count
+ @work.save
+
+ if !@collection.nil? && @collection.moderated?
+ redirect_to work_path(@work), notice: ts('Work was submitted to a moderated collection. It will show up in the collection once approved.')
+ else
+ flash[:notice] = ts('Your work was successfully posted.')
+ redirect_to @work
+ end
+ end
+
+ # WORK ON MULTIPLE WORKS
+
+ def show_multiple
+ @page_subtitle = ts("Edit Multiple Works")
+ @user = current_user
+
+ @works = Work.joins(pseuds: :user).where(users: { id: @user.id })
+
+ @works = @works.where(id: params[:work_ids]) if params[:work_ids]
+
+ @works_by_fandom = @works.joins(:taggings)
+ .joins("inner join tags on taggings.tagger_id = tags.id AND tags.type = 'Fandom'")
+ .select('distinct tags.name as fandom, works.id, works.title, works.posted').group_by(&:fandom)
+ end
+
+ def edit_multiple
+ if params[:commit] == 'Orphan'
+ redirect_to(new_orphan_path(work_ids: params[:work_ids])) && return
+ end
+
+ @page_subtitle = ts("Edit Multiple Works")
+ @user = current_user
+ @works = Work.select('distinct works.*').joins(pseuds: :user).where('users.id = ?', @user.id).where(id: params[:work_ids])
+
+ render('confirm_delete_multiple') && return if params[:commit] == 'Delete'
+ end
+
+ def confirm_delete_multiple
+ @user = current_user
+ @works = Work.select('distinct works.*').joins(pseuds: :user).where('users.id = ?', @user.id).where(id: params[:work_ids])
+ end
+
+ def delete_multiple
+ @user = current_user
+ @works = Work.joins(pseuds: :user).where('users.id = ?', @user.id).where(id: params[:work_ids]).readonly(false)
+ titles = @works.collect(&:title)
+
+ @works.each(&:destroy)
+
+ flash[:notice] = ts('Your works %{titles} were deleted.', titles: titles.join(', '))
+ redirect_to show_multiple_user_works_path(@user)
+ end
+
+ def update_multiple
+ @user = current_user
+ @works = Work.joins(pseuds: :user).where('users.id = ?', @user.id).where(id: params[:work_ids]).readonly(false)
+ @errors = []
+
+ # To avoid overwriting, we entirely trash any blank fields.
+ updated_work_params = work_params.reject { |_key, value| value.blank? }
+
+ @works.each do |work|
+ # now we can just update each work independently, woo!
+ unless work.update(updated_work_params)
+ @errors << ts('The work %{title} could not be edited: %{error}', title: work.title, error: work.errors.full_messages.join(" ")).html_safe
+ end
+
+ if params[:remove_me]
+ if work.pseuds.where.not(user_id: current_user.id).exists?
+ work.remove_author(current_user)
+ else
+ @errors << ts("You cannot remove yourself as co-creator of the work %{title} because you are the only listed creator. If you have invited another co-creator, you must wait for them to accept before you can remove yourself.", title: work.title)
+ end
+ end
+ end
+
+ if @errors.empty?
+ flash[:notice] = ts('Your edits were put through! Please check over the works to make sure everything is right.')
+ else
+ flash[:error] = @errors
+ end
+
+ redirect_to show_multiple_user_works_path(@user, work_ids: @works.map(&:id))
+ end
+
+ # marks a work to read later
+ def mark_for_later
+ @work = Work.find(params[:id])
+ Reading.mark_to_read_later(@work, current_user, true)
+ read_later_path = user_readings_path(current_user, show: 'to-read')
+ if @work.marked_for_later?(current_user)
+ flash[:notice] = ts("This work was added to your #{view_context.link_to('Marked for Later list', read_later_path)}.").html_safe
+ end
+ redirect_to(request.env['HTTP_REFERER'] || root_path)
+ end
+
+ def mark_as_read
+ @work = Work.find(params[:id])
+ Reading.mark_to_read_later(@work, current_user, false)
+ read_later_path = user_readings_path(current_user, show: 'to-read')
+ unless @work.marked_for_later?(current_user)
+ flash[:notice] = ts("This work was removed from your #{view_context.link_to('Marked for Later list', read_later_path)}.").html_safe
+ end
+ redirect_to(request.env['HTTP_REFERER'] || root_path)
+ end
+
+ protected
+
+ def load_owner
+ if params[:user_id].present?
+ @user = User.find_by!(login: params[:user_id])
+ if params[:pseud_id].present?
+ @pseud = @user.pseuds.find_by(name: params[:pseud_id])
+ end
+ end
+ if params[:tag_id]
+ @tag = Tag.find_by_name(params[:tag_id])
+ unless @tag && @tag.is_a?(Tag)
+ raise ActiveRecord::RecordNotFound, "Couldn't find tag named '#{params[:tag_id]}'"
+ end
+ unless @tag.canonical?
+ if @tag.merger.present?
+ if @collection.present?
+ redirect_to(collection_tag_works_path(@collection, @tag.merger)) && return
+ else
+ redirect_to(tag_works_path(@tag.merger)) && return
+ end
+ else
+ redirect_to(tag_path(@tag)) && return
+ end
+ end
+ end
+ @language = Language.find_by(short: params[:language_id]) if params[:language_id].present?
+ @owner = @pseud || @user || @collection || @tag || @language
+ end
+
+ def load_work
+ @work = Work.find_by(id: params[:id])
+ unless @work
+ raise ActiveRecord::RecordNotFound, "Couldn't find work with id '#{params[:id]}'"
+ end
+ if @collection && !@work.collections.include?(@collection)
+ redirect_to(@work) && return
+ end
+
+ @check_ownership_of = @work
+ @check_visibility_of = @work
+ end
+
+ def check_parent_visible
+ check_visibility_for(@work)
+ end
+
+ def load_first_chapter
+ @chapter = if @work.user_is_owner_or_invited?(current_user) || logged_in_as_admin?
+ @work.first_chapter
+ else
+ @work.chapters.in_order.posted.first
+ end
+ end
+
+ # Check whether we should display :new or :edit instead of previewing or
+ # saving the user's changes.
+ def work_cannot_be_saved?
+ !(@work.errors.empty? && @work.valid?)
+ end
+
+ def set_work_form_fields
+ @work.reset_published_at(@chapter)
+ @series = current_user.series.distinct
+ @serial_works = @work.serial_works
+
+ if @collection.nil?
+ @collection = @work.approved_collections.first
+ end
+
+ if params[:claim_id]
+ @posting_claim = ChallengeClaim.find_by(id: params[:claim_id])
+ end
+ end
+
+ def set_own_works
+ return unless @works
+ @own_works = []
+ if current_user.is_a?(User)
+ pseud_ids = current_user.pseuds.pluck(:id)
+ @own_works = @works.select do |work|
+ (pseud_ids & work.pseuds.pluck(:id)).present?
+ end
+ end
+ end
+
+ def cancel_posting_and_redirect
+ if @work && @work.posted
+ flash[:notice] = ts('The work was not updated.')
+ redirect_to user_works_path(current_user)
+ else
+ flash[:notice] = ts('The work was not posted. It will be saved here in your drafts for one month, then deleted from the Archive.')
+ redirect_to drafts_user_works_path(current_user)
+ end
+ end
+
+ def index_page_title
+ if @owner.present?
+ owner_name =
+ case @owner.class.to_s
+ when 'Pseud'
+ @owner.name
+ when 'User'
+ @owner.login
+ when 'Collection'
+ @owner.title
+ else
+ @owner.try(:name)
+ end
+
+ "#{owner_name} - Works".html_safe
+ else
+ 'Latest Works'
+ end
+ end
+
+ def log_admin_activity
+ if logged_in_as_admin?
+ options = { action: params[:action] }
+
+ if params[:action] == 'update_tags'
+ summary = "Old tags: #{@work.tags.pluck(:name).join(', ')}"
+ end
+
+ AdminActivity.log_action(current_admin, @work, action: params[:action], summary: summary)
+ end
+ end
+
+ private
+
+ def build_options(params)
+ pseuds_to_apply =
+ (Pseud.find_by(name: params[:pseuds_to_apply]) if params[:pseuds_to_apply])
+
+ {
+ pseuds: pseuds_to_apply,
+ post_without_preview: params[:post_without_preview],
+ importing_for_others: params[:importing_for_others],
+ restricted: params[:restricted],
+ moderated_commenting_enabled: params[:moderated_commenting_enabled],
+ comment_permissions: params[:comment_permissions],
+ override_tags: params[:override_tags],
+ detect_tags: params[:detect_tags] == "true",
+ fandom: params[:work][:fandom_string],
+ archive_warning: params[:work][:archive_warning_strings],
+ character: params[:work][:character_string],
+ rating: params[:work][:rating_string],
+ relationship: params[:work][:relationship_string],
+ category: params[:work][:category_strings],
+ freeform: params[:work][:freeform_string],
+ notes: params[:notes],
+ encoding: params[:encoding],
+ external_author_name: params[:external_author_name],
+ external_author_email: params[:external_author_email],
+ external_coauthor_name: params[:external_coauthor_name],
+ external_coauthor_email: params[:external_coauthor_email],
+ language_id: params[:language_id]
+ }.compact_blank!
+ end
+
+ def work_params
+ params.require(:work).permit(
+ :rating_string, :fandom_string, :relationship_string, :character_string,
+ :archive_warning_string, :category_string,
+ :freeform_string, :summary, :notes, :endnotes, :collection_names, :recipients, :wip_length,
+ :backdate, :language_id, :work_skin_id, :restricted, :comment_permissions,
+ :moderated_commenting_enabled, :title, :pseuds_to_add, :collections_to_add,
+ current_user_pseud_ids: [],
+ collections_to_remove: [],
+ challenge_assignment_ids: [],
+ challenge_claim_ids: [],
+ category_strings: [],
+ archive_warning_strings: [],
+ author_attributes: [:byline, ids: [], coauthors: []],
+ series_attributes: [:id, :title],
+ parent_work_relationships_attributes: [
+ :url, :title, :author, :language_id, :translation
+ ],
+ chapter_attributes: [
+ :title, :"published_at(3i)", :"published_at(2i)", :"published_at(1i)",
+ :published_at, :content
+ ]
+ )
+ end
+
+ def work_tag_params
+ params.require(:work).permit(
+ :rating_string, :fandom_string, :relationship_string, :character_string,
+ :archive_warning_string, :category_string, :freeform_string, :language_id,
+ category_strings: [],
+ archive_warning_strings: []
+ )
+ end
+
+ def work_search_params
+ params.require(:work_search).permit(
+ :query,
+ :title,
+ :creators,
+ :revised_at,
+ :complete,
+ :single_chapter,
+ :word_count,
+ :language_id,
+ :fandom_names,
+ :rating_ids,
+ :character_names,
+ :relationship_names,
+ :freeform_names,
+ :hits,
+ :kudos_count,
+ :comments_count,
+ :bookmarks_count,
+ :sort_column,
+ :sort_direction,
+ :other_tag_names,
+ :excluded_tag_names,
+ :crossover,
+ :date_from,
+ :date_to,
+ :words_from,
+ :words_to,
+
+ archive_warning_ids: [],
+ warning_ids: [], # backwards compatibility
+ category_ids: [],
+ rating_ids: [],
+ fandom_ids: [],
+ character_ids: [],
+ relationship_ids: [],
+ freeform_ids: [],
+
+ collection_ids: []
+ )
+ end
+
+end
diff --git a/app/controllers/wrangling_guidelines_controller.rb b/app/controllers/wrangling_guidelines_controller.rb
new file mode 100755
index 0000000..f015a18
--- /dev/null
+++ b/app/controllers/wrangling_guidelines_controller.rb
@@ -0,0 +1,82 @@
+class WranglingGuidelinesController < ApplicationController
+ before_action :admin_only, except: [:index, :show]
+
+ # GET /wrangling_guidelines
+ def index
+ @wrangling_guidelines = WranglingGuideline.order("position ASC")
+ end
+
+ # GET /wrangling_guidelines/1
+ def show
+ @wrangling_guideline = WranglingGuideline.find(params[:id])
+ end
+
+ # GET /wrangling_guidelines/new
+ def new
+ authorize :wrangling
+ @wrangling_guideline = WranglingGuideline.new
+ end
+
+ # GET /wrangling_guidelines/1/edit
+ def edit
+ authorize :wrangling
+ @wrangling_guideline = WranglingGuideline.find(params[:id])
+ end
+
+ # GET /wrangling_guidelines/manage
+ def manage
+ authorize :wrangling
+ @wrangling_guidelines = WranglingGuideline.order("position ASC")
+ end
+
+ # POST /wrangling_guidelines
+ def create
+ authorize :wrangling
+ @wrangling_guideline = WranglingGuideline.new(wrangling_guideline_params)
+
+ if @wrangling_guideline.save
+ flash[:notice] = t("wrangling_guidelines.create")
+ redirect_to(@wrangling_guideline)
+ else
+ render action: "new"
+ end
+ end
+
+ # PUT /wrangling_guidelines/1
+ def update
+ authorize :wrangling
+ @wrangling_guideline = WranglingGuideline.find(params[:id])
+
+ if @wrangling_guideline.update(wrangling_guideline_params)
+ flash[:notice] = t("wrangling_guidelines.update")
+ redirect_to(@wrangling_guideline)
+ else
+ render action: "edit"
+ end
+ end
+
+ # reorder FAQs
+ def update_positions
+ authorize :wrangling
+ if params[:wrangling_guidelines]
+ @wrangling_guidelines = WranglingGuideline.reorder_list(params[:wrangling_guidelines])
+ flash[:notice] = t("wrangling_guidelines.reorder")
+ end
+ redirect_to(wrangling_guidelines_path)
+ end
+
+ # DELETE /wrangling_guidelines/1
+ def destroy
+ authorize :wrangling
+ @wrangling_guideline = WranglingGuideline.find(params[:id])
+ @wrangling_guideline.destroy
+ flash[:notice] = t("wrangling_guidelines.delete")
+ redirect_to(wrangling_guidelines_path)
+ end
+
+ private
+
+ def wrangling_guideline_params
+ params.require(:wrangling_guideline).permit(:title, :content)
+ end
+end
diff --git a/app/decorators/bookmarkable_decorator.rb b/app/decorators/bookmarkable_decorator.rb
new file mode 100755
index 0000000..9ac2961
--- /dev/null
+++ b/app/decorators/bookmarkable_decorator.rb
@@ -0,0 +1,75 @@
+class BookmarkableDecorator < SimpleDelegator
+ attr_accessor :inner_hits, :loaded_bookmarks
+
+ # Given a list of bookmarkable objects, with inner bookmark hits, load the
+ # objects and their bookmarks and wrap everything up in a
+ # BookmarkableDecorator object.
+ def self.load_from_elasticsearch(hits, **options)
+ bookmarks = load_bookmarks(hits, **options)
+ bookmarkables = load_bookmarkables(hits, **options)
+
+ hits.map do |hit|
+ id = hit["_id"]
+ next if bookmarkables[id].blank?
+ new_with_inner_hits(
+ bookmarkables[id],
+ hit.dig("inner_hits", "bookmark"),
+ bookmarks
+ )
+ end.compact
+ end
+
+ # Given search results for bookmarkables, with inner_hits for the matching
+ # bookmarks, load all of the referenced bookmarks and return a hash mapping
+ # from IDs to bookmarks.
+ def self.load_bookmarks(hits, **options)
+ all_bookmark_hits = hits.flat_map do |bookmarkable_item|
+ bookmarkable_item.dig("inner_hits", "bookmark", "hits", "hits")
+ end
+
+ Bookmark.load_from_elasticsearch(all_bookmark_hits, **options).group_by(&:id)
+ end
+
+ # Given search results for bookmarkables, return a hash mapping from IDs to
+ # Works, Series, or ExternalWorks (depending on which type the ID is marked
+ # with).
+ def self.load_bookmarkables(hits, **options)
+ hits_by_bookmarkable_type = hits.group_by do |item|
+ item.dig("_source", "bookmarkable_type")
+ end
+
+ bookmarkables = {}
+
+ [Work, Series, ExternalWork].each do |klass|
+ hits_for_klass = hits_by_bookmarkable_type[klass.to_s]
+ next if hits_for_klass.blank?
+ type = klass.to_s.underscore
+ klass.load_from_elasticsearch(hits_for_klass, **options).each do |item|
+ bookmarkables["#{item.id}-#{type}"] = item
+ end
+ end
+
+ bookmarkables
+ end
+
+ # Create a new bookmarkable decorator with information about this
+ # bookmarkable's inner hits, and a hash of pre-loaded bookmarks.
+ def self.new_with_inner_hits(bookmarkable, inner_hits, bookmarks)
+ new(bookmarkable).tap do |decorator|
+ decorator.inner_hits = inner_hits
+ decorator.loaded_bookmarks = bookmarks
+ end
+ end
+
+ # Return the number of inner bookmarks matching the query.
+ def matching_bookmark_count
+ @matching_bookmark_count ||= inner_hits.dig("hits", "total")
+ end
+
+ # Return a small sampling of inner bookmarks matching the query.
+ def matching_bookmarks
+ @matching_bookmarks = inner_hits.dig("hits", "hits").flat_map do |hit|
+ loaded_bookmarks[hit["_id"].to_i]
+ end.compact
+ end
+end
diff --git a/app/decorators/comment_decorator.rb b/app/decorators/comment_decorator.rb
new file mode 100755
index 0000000..5843112
--- /dev/null
+++ b/app/decorators/comment_decorator.rb
@@ -0,0 +1,68 @@
+class CommentDecorator < SimpleDelegator
+ # Given an array of thread IDs, loads all comments in those threads, wraps
+ # all of them in CommentDecorators, and sets up reviewed_replies for all
+ # comments. Returns a hash mapping from comment IDs to CommentDecorators.
+ def self.from_thread_ids(thread_ids)
+ wrapped_by_id = {}
+
+ # Order by [thread, id] so that the comments in a thread are processed in
+ # the order they were posted. This will ensure that when we process a
+ # reply, its commentable has already been processed, and will also ensure
+ # that the replies to a comment are displayed in the correct order:
+ Comment.for_display.where(thread: thread_ids).order(:thread, :id).each do |comment|
+ wrapped = CommentDecorator.new(comment)
+ wrapped_by_id[comment.id] = wrapped
+
+ next unless comment.reply_comment?
+
+ wrapped_by_id[comment.commentable_id].comments << wrapped
+ end
+
+ wrapped_by_id
+ end
+
+ # Given an array of thread IDs, loads them with from_thread_ids, and then
+ # replaces the contents of the array with the CommentDecorators for each
+ # thread root.
+ def self.wrap_thread_ids(thread_ids)
+ comments_by_id = from_thread_ids(thread_ids)
+
+ wrapped = thread_ids.map do |id|
+ comments_by_id[id]
+ end
+
+ thread_ids.replace(wrapped)
+ end
+
+ # Given an array of comments, loads the threads associated with those
+ # comments using from_thread_ids, and then replaces the contents of the array
+ # with the decorated version of those comments.
+ def self.wrap_comments(comments)
+ comments_by_id = from_thread_ids(comments.map(&:thread))
+
+ wrapped = comments.map do |comment|
+ comments_by_id[comment.id]
+ end
+
+ comments.replace(wrapped)
+ end
+
+ # Given a commentable, gets the desired page of threads for that commentable,
+ # and then loads all of the comments for those threads using wrap_thread_ids.
+ def self.for_commentable(commentable, page:)
+ thread_ids = commentable.comments.reviewed.pluck(:thread)
+ .paginate(page: page, per_page: Comment.per_page)
+
+ wrap_thread_ids(thread_ids)
+ end
+
+ # Override the comments association.
+ def comments
+ @comments ||= []
+ end
+
+ # Override the reviewed_replies association.
+ def reviewed_replies
+ @reviewed_replies ||= comments.reject(&:unreviewed?)
+ end
+end
diff --git a/app/decorators/homepage.rb b/app/decorators/homepage.rb
new file mode 100755
index 0000000..312ebfd
--- /dev/null
+++ b/app/decorators/homepage.rb
@@ -0,0 +1,70 @@
+class Homepage
+ include NumberHelper
+
+ def initialize(user)
+ @user = user
+ end
+
+ def rounded_counts
+ @user_count = Rails.cache.fetch("/v1/home/counts/user", expires_in: 40.minutes) do
+ estimate_number(User.count)
+ end
+ @work_count = Rails.cache.fetch("/v1/home/counts/works", expires_in: 40.minutes) do
+ estimate_number(Work.posted.count)
+ end
+ @fandom_count = Rails.cache.fetch("/v1/home/counts/fandom", expires_in: 40.minutes) do
+ estimate_number(Fandom.canonical.count)
+ end
+ [@user_count, @work_count, @fandom_count]
+ end
+
+ def logged_in?
+ @user.present?
+ end
+
+ def admin_posts
+ @admin_posts = if Rails.env.development?
+ AdminPost.non_translated.for_homepage.all
+ else
+ Rails.cache.fetch("home/index/home_admin_posts", expires_in: 20.minutes) do
+ AdminPost.non_translated.for_homepage.to_a
+ end
+ end
+ end
+
+ def favorite_tags
+ return unless logged_in?
+
+ @favorite_tags ||= if Rails.env.development?
+ @user.favorite_tags.to_a.sort_by { |favorite_tag| favorite_tag.tag.sortable_name.downcase }
+ else
+ Rails.cache.fetch("home/index/#{@user.id}/home_favorite_tags") do
+ @user.favorite_tags.to_a.sort_by { |favorite_tag| favorite_tag.tag.sortable_name.downcase }
+ end
+ end
+ end
+
+ def readings
+ return unless logged_in? && @user.preference.try(:history_enabled?)
+
+ @readings ||= if Rails.env.development?
+ @user.readings.visible.random_order
+ .limit(ArchiveConfig.NUMBER_OF_ITEMS_VISIBLE_ON_HOMEPAGE)
+ .where(toread: true)
+ .all
+ else
+ Rails.cache.fetch("home/index/#{@user.id}/home_marked_for_later") do
+ @user.readings.visible.random_order
+ .limit(ArchiveConfig.NUMBER_OF_ITEMS_VISIBLE_ON_HOMEPAGE)
+ .where(toread: true)
+ .to_a
+ end
+ end
+ end
+
+ def inbox_comments
+ return unless logged_in?
+
+ @inbox_comments ||= @user.inbox_comments.with_bad_comments_removed.for_homepage
+ end
+end
diff --git a/app/decorators/pseud_decorator.rb b/app/decorators/pseud_decorator.rb
new file mode 100755
index 0000000..a3867aa
--- /dev/null
+++ b/app/decorators/pseud_decorator.rb
@@ -0,0 +1,126 @@
+class PseudDecorator < SimpleDelegator
+
+ attr_reader :data
+
+ # Pseuds need to be decorated with various stats from the "_source" when
+ # viewing search results, so we first load the pseuds with the base search
+ # class, and then decorate them with the data.
+ def self.load_from_elasticsearch(hits, **options)
+ items = Pseud.load_from_elasticsearch(hits, **options)
+ decorate_from_search(items, hits)
+ end
+
+ # TODO: pull this out into a reusable module
+ def self.decorate_from_search(results, search_hits)
+ search_data = search_hits.group_by { |doc| doc["_id"] }
+ results.map do |result|
+ data = search_data[result.id.to_s].first&.dig('_source') || {}
+ new_with_data(result, data)
+ end
+ end
+
+ # TODO: Either eliminate this function or add definitions for work_counts and
+ # bookmark_counts (and possibly fandom information, as well?). The NameError
+ # that this causes isn't a problem at the moment because the function isn't
+ # being called from anywhere, but it needs to be fixed before it can be used.
+ def self.decorate(pseuds)
+ users = User.where(id: pseuds.map(&:user_id)).group_by(&:id)
+ work_counts
+ bookmark_counts
+ work_key = User.current_user.present? ? :general_works_count : :public_works_count
+ bookmark_key = User.current_user.present? ? :general_bookmarks_count : :public_bookmarks_count
+ pseuds.map do |pseud|
+ data = {
+ user_login: users[user_id].login,
+ bookmark_key => bookmark_counts[id],
+ work_key => work_counts[id]
+ }
+ new_with_data(pseud, data)
+ end
+ end
+
+ def self.new_with_data(pseud, data)
+ new(pseud).tap do |decorator|
+ decorator.data = data
+ end
+ end
+
+ def data=(info)
+ @data = HashWithIndifferentAccess.new(info)
+ end
+
+ def works_count
+ count = User.current_user.present? ? data[:general_works_count] : data[:public_works_count]
+ count || 0
+ end
+
+ def bookmarks_count
+ User.current_user.present? ? data[:general_bookmarks_count] : data[:public_bookmarks_count]
+ end
+
+ def byline
+ data[:byline] ||= constructed_byline
+ end
+
+ def user_login
+ data[:user_login]
+ end
+
+ def pseud_path
+ "/users/#{user_login}/pseuds/#{name}"
+ end
+
+ def works_path
+ "#{pseud_path}/works"
+ end
+
+ def works_link
+ return unless works_count > 0
+ text = ActionController::Base.helpers.pluralize(works_count, "works")
+ "#{text}"
+ end
+
+ def bookmarks_path
+ "#{pseud_path}/bookmarks"
+ end
+
+ def bookmarks_link
+ return unless bookmarks_count > 0
+ text = ActionController::Base.helpers.pluralize(bookmarks_count, "bookmarks")
+ "#{text}"
+ end
+
+ def fandom_path(id)
+ return unless id
+ "#{works_path}?fandom_id=#{id}"
+ end
+
+ def fandom_link(fandom_id)
+ fandom = fandom_stats(fandom_id)
+ return unless fandom.present?
+ text = ActionController::Base.helpers.pluralize(fandom[:count], "work") + " in #{fandom[:name]}"
+ "#{text}"
+ end
+
+ def authored_items_links(options = {})
+ general_links = [works_link, bookmarks_link].compact.join(", ")
+ if options[:fandom_id].present?
+ # This can potentially be an array
+ fandom_links = [options[:fandom_id]].flatten.map do |fandom_id|
+ fandom_link(fandom_id)
+ end
+ general_links + " | " + fandom_links.compact.join(", ")
+ else
+ general_links
+ end
+ end
+
+ def constructed_byline
+ name == user_login ? name : "#{name} (#{user_login})"
+ end
+
+ def fandom_stats(id)
+ key = User.current_user.present? ? "id" : "id_for_public"
+ data[:fandoms]&.detect { |fandom| fandom[key].to_s == id.to_s }
+ end
+end
diff --git a/app/helpers/admin_helper.rb b/app/helpers/admin_helper.rb
new file mode 100755
index 0000000..1b085c2
--- /dev/null
+++ b/app/helpers/admin_helper.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+module AdminHelper
+ def admin_activity_login_string(activity)
+ activity.admin.nil? ? ts("Admin deleted") : activity.admin_login
+ end
+
+ def admin_activity_target_link(activity)
+ url = if activity.target.is_a?(Pseud)
+ user_pseuds_path(activity.target.user)
+ else
+ activity.target
+ end
+ link_to(activity.target_name, url)
+ end
+
+ # Summaries for profile and pseud edits, which contain links, need to be
+ # handled differently from summaries that use item.inspect (and thus contain
+ # angle brackets).
+ def admin_activity_summary(activity)
+ if activity.action == "edit pseud" || activity.action == "edit profile"
+ raw sanitize_field(activity, :summary)
+ else
+ activity.summary
+ end
+ end
+
+ def admin_setting_disabled?(field)
+ return unless logged_in_as_admin?
+
+ !policy(AdminSetting).permitted_attributes.include?(field)
+ end
+
+ def admin_setting_checkbox(form, field_name)
+ form.check_box(field_name, disabled: admin_setting_disabled?(field_name))
+ end
+
+ def admin_setting_text_field(form, field_name, options = {})
+ options[:disabled] = admin_setting_disabled?(field_name)
+ form.text_field(field_name, options)
+ end
+
+ def admin_can_update_user_roles?
+ return unless logged_in_as_admin?
+
+ policy(User).permitted_attributes.include?(roles: [])
+ end
+
+ def admin_can_update_user_email?
+ return unless logged_in_as_admin?
+
+ policy(User).permitted_attributes.include?(:email)
+ end
+end
diff --git a/app/helpers/admin_post_helper.rb b/app/helpers/admin_post_helper.rb
new file mode 100755
index 0000000..e167de6
--- /dev/null
+++ b/app/helpers/admin_post_helper.rb
@@ -0,0 +1,8 @@
+module AdminPostHelper
+ def sorted_translations(admin_post)
+ admin_post.translations.sort_by do |translation|
+ language = translation.language
+ language.sortable_name.blank? ? language.short : language.sortable_name
+ end
+ end
+end
diff --git a/app/helpers/advanced_search_helper.rb b/app/helpers/advanced_search_helper.rb
new file mode 100755
index 0000000..5a36a10
--- /dev/null
+++ b/app/helpers/advanced_search_helper.rb
@@ -0,0 +1,16 @@
+module AdvancedSearchHelper
+
+ def filter_boolean_value(filter_action, tag_type, tag_id)
+ if filter_action == "include"
+ @search.send("#{tag_type}_ids").present? &&
+ @search.send("#{tag_type}_ids").include?(tag_id)
+ elsif tag_type == "tag" && @search.respond_to?(:excluded_bookmark_tag_ids)
+ # Bookmarker's tag exclude checkboxes on bookmark filters
+ @search.excluded_bookmark_tag_ids.present? && @search.excluded_bookmark_tag_ids.include?(tag_id)
+ else
+ # Work tag exclude checkboxes on bookmark filters,
+ # or exclude checkboxes on work filters
+ @search.excluded_tag_ids.present? && @search.excluded_tag_ids.include?(tag_id)
+ end
+ end
+end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
new file mode 100755
index 0000000..80a574a
--- /dev/null
+++ b/app/helpers/application_helper.rb
@@ -0,0 +1,654 @@
+# Methods added to this helper will be available to all templates in the application.
+module ApplicationHelper
+ include HtmlCleaner
+
+ # TODO: Official recommendation from Rails indicates we should switch to
+ # unobtrusive JavaScript instead of using anything like `link_to_function`
+ def link_to_function(name, *args, &block)
+ html_options = args.extract_options!.symbolize_keys
+
+ function = block_given? ? update_page(&block) : args[0] || ''
+
+ onclick = "#{"#{html_options[:onclick]}; " if html_options[:onclick]}#{function}; return false;"
+ href = html_options[:href] || 'javascript:void(0)'
+
+ content_tag(:a, name, html_options.merge(href: href, onclick: onclick))
+ end
+
+ # Generates class names for the main div in the application layout
+ def classes_for_main
+ class_names = controller.controller_name + '-' + controller.action_name
+
+ show_sidebar = ((@user || @admin_posts || @collection || show_wrangling_dashboard) && !@hide_dashboard)
+ class_names += " dashboard" if show_sidebar
+
+ class_names += " filtered" if page_has_filters?
+
+ case controller.controller_name
+ when "abuse_reports", "feedbacks", "known_issues"
+ class_names = "system support #{controller.controller_name} #{controller.action_name}"
+ when "archive_faqs"
+ class_names = "system docs support faq #{controller.action_name}"
+ when "wrangling_guidelines"
+ class_names = "system docs guideline #{controller.action_name}"
+ when "home"
+ class_names = if %w(content privacy).include?(controller.action_name)
+ "system docs tos tos-#{controller.action_name}"
+ else
+ "system docs #{controller.action_name}"
+ end
+ when "errors"
+ class_names = "system #{controller.controller_name} error-#{controller.action_name}"
+ end
+
+ class_names
+ end
+
+ def page_has_filters?
+ @facets.present? || (controller.action_name == 'index' && controller.controller_name == 'collections') || (controller.action_name == 'unassigned' && controller.controller_name == 'fandoms')
+ end
+
+ # This is used to make the current page we're on (determined by the path or by the specified condition) a span with class "current" and it allows us to add a title attribute to the link or the span
+ def span_if_current(link_to_default_text, path, condition=nil, title_attribute_default_text=nil)
+ is_current = condition.nil? ? current_page?(path) : condition
+ span_tag = title_attribute_default_text.nil? ? "#{link_to_default_text}" : "#{link_to_default_text}"
+ link_code = title_attribute_default_text.nil? ? link_to(link_to_default_text, path) : link_to(link_to_default_text, path, title: "#{title_attribute_default_text}")
+ is_current ? span_tag.html_safe : link_code
+ end
+
+ def link_to_rss(link_to_feed)
+ link_to content_tag(:span, ts("RSS Feed")), link_to_feed, title: ts("RSS Feed"), class: "rss"
+ end
+
+ # 1: default shows just the link to help
+ # 2: show_text = true: shows "plain text with limited html" and link to help
+ def allowed_html_instructions(show_text = true)
+ (show_text ? h(ts("Plain text with limited HTML")) : "".html_safe) +
+ link_to_help("html-help")
+ end
+
+ # Byline helpers
+ def byline(creation, options={})
+ if creation.respond_to?(:anonymous?) && creation.anonymous?
+ anon_byline = ts("Anonymous").html_safe
+ if options[:visibility] != "public" && (logged_in_as_admin? || is_author_of?(creation))
+ anon_byline += " [#{non_anonymous_byline(creation, options[:only_path])}]".html_safe
+ end
+ return anon_byline
+ end
+ non_anonymous_byline(creation, options[:only_path])
+ end
+
+ def non_anonymous_byline(creation, url_path = nil)
+ only_path = url_path.nil? ? true : url_path
+
+ if @preview_mode
+ # Skip cache in preview mode
+ return byline_text(creation, only_path)
+ end
+
+ Rails.cache.fetch("#{creation.cache_key}/byline-nonanon/#{only_path.to_s}") do
+ byline_text(creation, only_path)
+ end
+ end
+
+ def byline_text(creation, only_path, text_only = false)
+ if creation.respond_to?(:author)
+ creation.author
+ else
+ pseuds = @preview_mode ? creation.pseuds_after_saving : creation.pseuds.to_a
+ pseuds = pseuds.flatten.uniq.sort
+
+ archivists = Hash.new []
+ if creation.is_a?(Work)
+ external_creatorships = creation.external_creatorships.select { |ec| !ec.claimed? }
+ external_creatorships.each do |ec|
+ archivist_pseud = pseuds.select { |p| ec.archivist.pseuds.include?(p) }.first
+ archivists[archivist_pseud] += [ec.author_name]
+ end
+ end
+
+ pseuds.map { |pseud|
+ pseud_byline = text_only ? pseud.byline : pseud_link(pseud, only_path)
+ if archivists[pseud].empty?
+ pseud_byline
+ else
+ archivists[pseud].map { |ext_author|
+ ts("%{ext_author} [archived by %{name}]", ext_author: ext_author, name: pseud_byline)
+ }.join(', ')
+ end
+ }.join(', ').html_safe
+ end
+ end
+
+ def pseud_link(pseud, only_path = true)
+ if only_path
+ link_to(pseud.byline, user_pseud_path(pseud.user, pseud), rel: "author")
+ else
+ link_to(pseud.byline, user_pseud_url(pseud.user, pseud), rel: "author")
+ end
+ end
+
+ # A plain text version of the byline, for when we don't want to deliver a linkified version.
+ def text_byline(creation, options={})
+ if creation.respond_to?(:anonymous?) && creation.anonymous?
+ anon_byline = ts("Anonymous")
+ if (logged_in_as_admin? || is_author_of?(creation)) && options[:visibility] != 'public'
+ anon_byline += " [#{non_anonymous_byline(creation)}]".html_safe
+ end
+ anon_byline
+ else
+ only_path = false
+ text_only = true
+ byline_text(creation, only_path, text_only)
+ end
+ end
+
+ def link_to_modal(content = "", options = {})
+ options[:class] ||= ""
+ options[:for] ||= ""
+ options[:title] ||= options[:for]
+
+ html_options = { class: "#{options[:class]} modal", title: options[:title] }
+ link_to content, options[:for], html_options
+ end
+
+ # Currently, help files are static. We may eventually want to make these dynamic?
+ def link_to_help(help_entry, link = '?'.html_safe)
+ help_file = ""
+ #if Locale.active && Locale.active.language
+ # help_file = "#{ArchiveConfig.HELP_DIRECTORY}/#{Locale.active.language.code}/#{help_entry}.html"
+ #end
+
+ unless !help_file.blank? && File.exists?("#{Rails.root}/public/#{help_file}")
+ help_file = "#{ArchiveConfig.HELP_DIRECTORY}/#{help_entry}.html"
+ end
+
+ " ".html_safe + link_to_modal(link, for: help_file, title: help_entry.split('-').join(' ').capitalize, class: "help symbol question").html_safe
+ end
+
+ # Inserts the flash alert messages for flash[:key] wherever
+ # <%= flash_div :key %>
+ # is placed in the views. That is, if a controller or model sets
+ # flash[:error] = "OMG ERRORZ AIE"
+ # or
+ # flash.now[:error] = "OMG ERRORZ AIE"
+ #
+ # then that error will appear in the view where you have
+ # <%= flash_div :error %>
+ #
+ # The resulting HTML will look like this:
+ #
OMG ERRORZ AIE
+ #
+ # The CSS classes are specified in system-messages.css.
+ #
+ # You can also have multiple possible flash alerts in a single location with:
+ # <%= flash_div :error, :caution, :notice %>
+ # (These are the three varieties currently defined.)
+ #
+ def flash_div *keys
+ keys.collect { |key|
+ if flash[key]
+ if flash[key].is_a?(Array)
+ content_tag(:div,
+ content_tag(:ul,
+ safe_join(flash[key].map do |flash_item|
+ content_tag(:li, sanitize(flash_item))
+ end), "\n"),
+ class: "flash #{key}")
+ else
+ content_tag(:div, sanitize(flash[key]), class: "flash #{key}")
+ end
+ end
+ }.join.html_safe
+ end
+
+ # Generates sorting links for index pages, with column names and directions
+ def sort_link(title, column=nil, options = {})
+ condition = options[:unless] if options.has_key?(:unless)
+
+ unless column.nil?
+ current_column = (params[:sort_column] == column.to_s) || params[:sort_column].blank? && options[:sort_default]
+ css_class = current_column ? "current" : nil
+ if current_column # explicitly or implicitly doing the existing sorting, so we need to toggle
+ if params[:sort_direction]
+ direction = params[:sort_direction].to_s.upcase == 'ASC' ? 'DESC' : 'ASC'
+ else
+ direction = options[:desc_default] ? 'ASC' : 'DESC'
+ end
+ else
+ direction = options[:desc_default] ? 'DESC' : 'ASC'
+ end
+ link_to_unless condition, ((direction == 'ASC' ? '↑ ' : '↓ ') + title).html_safe,
+ current_path_with(sort_column: column, sort_direction: direction), {class: css_class, title: (direction == 'ASC' ? ts('sort up') : ts('sort down'))}
+ else
+ link_to_unless params[:sort_column].nil?, title, current_path_with(sort_column: nil, sort_direction: nil)
+ end
+ end
+
+ ## Allow use of tiny_mce WYSIWYG editor
+ def use_tinymce
+ @content_for_tinymce = ""
+ content_for :tinymce do
+ javascript_include_tag "tinymce/tinymce.min.js", skip_pipeline: true
+ end
+ @content_for_tinymce_init = ""
+ content_for :tinymce_init do
+ javascript_include_tag "mce_editor.min.js", skip_pipeline: true
+ end
+ end
+
+ # check for pages that allow tiny_mce before loading the massive javascript
+ def allow_tinymce?(controller)
+ %w(admin_posts archive_faqs known_issues chapters works wrangling_guidelines).include?(controller.controller_name) &&
+ %w(new create edit update).include?(controller.action_name)
+ end
+
+ # see: http://www.w3.org/TR/wai-aria/states_and_properties#aria-valuenow
+ def generate_countdown_html(field_id, max)
+ max = max.to_s
+ span = content_tag(:span, max, id: "#{field_id}_counter", class: "value", "data-maxlength" => max)
+ content_tag(:p, span + ts(' characters left'), class: "character_counter", "tabindex" => 0)
+ end
+
+ # expand/contracts all expand/contract targets inside its nearest parent with the target class (usually index or listbox etc)
+ def expand_contract_all(target = "listbox")
+ expand_all = button_tag(ts("Expand All"), class: "expand_all", data: { target_class: target })
+ contract_all = button_tag(ts("Contract All"), class: "contract_all", data: { target_class: target })
+
+ expand_all + contract_all
+ end
+
+ # Sets up expand/contract/shuffle buttons for any list whose id is passed in
+ # See the jquery code in application.js
+ # Note that these start hidden because if javascript is not available, we
+ # don't want to show the user the buttons at all.
+ def expand_contract_shuffle(list_id, shuffle: true)
+ target = "##{list_id}"
+ expander = button_tag("↓".html_safe, class: "expand hidden", title: "expand", data: { action_target: target })
+ contractor = button_tag("↑".html_safe, class: "contract hidden", title: "contract", data: { action_target: target })
+ shuffler = button_tag("⇆".html_safe, class: "shuffle hidden", title: "shuffle", data: { action_target: target }) if shuffle
+
+ expander + contractor + shuffler
+ end
+
+ # returns the default autocomplete attributes, all of which can be overridden
+ # note: we do this and put the message defaults here so we can use translation on them
+ def autocomplete_options(method, options={})
+ {
+ class: "autocomplete",
+ data: {
+ autocomplete_method: (method.is_a?(Array) ? method.to_json : "/autocomplete/#{method}"),
+ autocomplete_hint_text: ts("Start typing for suggestions!"),
+ autocomplete_no_results_text: ts("(No suggestions found)"),
+ autocomplete_min_chars: 1,
+ autocomplete_searching_text: ts("Searching...")
+ }
+ }.deep_merge(options)
+ end
+
+ # see http://asciicasts.com/episodes/197-nested-model-form-part-2
+ def link_to_add_section(linktext, form, nested_model_name, partial_to_render, locals = {})
+ new_nested_model = form.object.class.reflect_on_association(nested_model_name).klass.new
+ child_index = "new_#{nested_model_name}"
+ rendered_partial_to_add =
+ form.fields_for(nested_model_name, new_nested_model, child_index: child_index) {|child_form|
+ render(partial: partial_to_render, locals: {form: child_form, index: child_index}.merge(locals))
+ }
+ link_to_function(linktext, "add_section(this, \"#{nested_model_name}\", \"#{escape_javascript(rendered_partial_to_add)}\")", class: "hidden showme")
+ end
+
+ # see above
+ def link_to_remove_section(linktext, form, class_of_section_to_remove="removeme")
+ form.hidden_field(:_destroy) + "\n" +
+ link_to_function(linktext, "remove_section(this, \"#{class_of_section_to_remove}\")", class: "hidden showme")
+ end
+
+ # show time in the time zone specified by the first argument
+ # add the user's time when specified in preferences
+ def time_in_zone(time, zone = nil, user = User.current_user)
+ return ts("(no time specified)") if time.blank?
+
+ zone ||= (user&.is_a?(User) && user.preference.time_zone) ? user.preference.time_zone : Time.zone.name
+ time_in_zone = time.in_time_zone(zone)
+ time_in_zone_string = time_in_zone.strftime('%a %d
+ %b %Y
+ %I:%M%p') +
+ " #{time_in_zone.zone} "
+
+ user_time_string = ""
+ if user.is_a?(User) && user.preference.time_zone
+ if user.preference.time_zone != zone
+ user_time = time.in_time_zone(user.preference.time_zone)
+ user_time_string = "(" + user_time.strftime('%I:%M%p') +
+ " #{user_time.zone})"
+ elsif !user.preference.time_zone
+ user_time_string = link_to ts("(set timezone)"), user_preferences_path(user)
+ end
+ end
+
+ (time_in_zone_string + user_time_string).strip.html_safe
+ end
+
+ def mailto_link(user, options={})
+ "
+
+ ".html_safe
+ end
+
+ # these two handy methods will take a form object (eg from form_for) and an attribute (eg :title or '_destroy')
+ # and generate the id or name that Rails will output for that object
+ def field_attribute(attribute)
+ attribute.to_s.sub(/\?$/,"")
+ end
+
+ def name_to_id(name)
+ name.to_s.gsub(/\]\[|[^-a-zA-Z0-9:.]/, "_").sub(/_$/, "")
+ end
+
+ def field_id(form_or_object_name, attribute, index: nil, **_kwargs)
+ name_to_id(field_name(form_or_object_name, attribute, index: index))
+ end
+
+ # This is a partial re-implementation of ActionView::Helpers::FormTagHelper#field_name.
+ # The method contract changed in Rails 7.0, but we can't use the default because it sometimes
+ # includes other information that -- at a minimum -- wreaks havoc on the Cucumber feature tests.
+ # It is used in when constructing forms, like in app/views/tags/new.html.erb.
+ def field_name(form_or_object_name, attribute, *_method_names, multiple: false, index: nil)
+ object_name = if form_or_object_name.respond_to?(:object_name)
+ form_or_object_name.object_name
+ else
+ form_or_object_name
+ end
+ if object_name.blank?
+ "#{field_attribute(attribute)}#{multiple ? '[]' : ''}"
+ elsif index
+ "#{object_name}[#{index}][#{field_attribute(attribute)}]#{multiple ? '[]' : ''}"
+ else
+ "#{object_name}[#{field_attribute(attribute)}]#{multiple ? '[]' : ''}"
+ end
+ end
+
+ # toggle an checkboxes (scrollable checkboxes) section of a form to show all of the checkboxes
+ def checkbox_section_toggle(checkboxes_id, checkboxes_size, options = {})
+ toggle_show = content_tag(:a, ts("Expand %{checkboxes_size} Checkboxes", checkboxes_size: checkboxes_size),
+ class: "toggle #{checkboxes_id}_show") + "\n".html_safe
+
+ toggle_hide = content_tag(:a, ts("Collapse Checkboxes"),
+ style: "display: none;",
+ class: "toggle #{checkboxes_id}_hide",
+ href: "##{checkboxes_id}") + "\n".html_safe
+
+ css_class = checkbox_section_css_class(checkboxes_size)
+
+ javascript_bits = content_for(:footer_js) {
+ javascript_tag("$j(document).ready(function(){\n" +
+ "$j('##{checkboxes_id}').find('.actions').show();\n" +
+ "$j('.#{checkboxes_id}_show').click(function() {\n" +
+ "$j('##{checkboxes_id}').find('.index').attr('class', 'options index all');\n" +
+ "$j('.#{checkboxes_id}_hide').show();\n" +
+ "$j('.#{checkboxes_id}_show').hide();\n" +
+ "});" + "\n" +
+ "$j('.#{checkboxes_id}_hide').click(function() {\n" +
+ "$j('##{checkboxes_id}').find('.index').attr('class', '#{css_class}');\n" +
+ "$j('.#{checkboxes_id}_show').show();\n" +
+ "$j('.#{checkboxes_id}_hide').hide();\n" +
+ "});\n" +
+ "})")
+ }
+
+ toggle = content_tag(:p,
+ (options[:no_show] ? "".html_safe : toggle_show) +
+ toggle_hide +
+ (options[:no_js] ? "".html_safe : javascript_bits), class: "actions", style: "display: none;")
+ end
+
+ # create a scrollable checkboxes section for a form that can be toggled open/closed
+ # form: the form this is being created in
+ # attribute: the attribute being set
+ # choices: the array of options (which should be objects of some sort)
+ # checked_method: a method that can be run on the object of the form to get back a list
+ # of currently-set options
+ # name_method: a method that can be run on each individual option to get its pretty name for labelling (typically just "name")
+ # value_method: a value that can be run to get the value of each individual option
+ #
+ #
+ # See the prompt_form in challenge signups for example of usage
+ def checkbox_section(form, attribute, choices, options = {})
+ options = {
+ checked_method: nil,
+ name_method: "name",
+ name_helper_method: nil, # alternative: pass a helper method that gets passed the choice
+ extra_info_method: nil, # helper method that gets passed the choice, for any extra information that gets attached to the label
+ value_method: "id",
+ disabled: false,
+ include_toggle: true,
+ checkbox_side: "left",
+ include_blank: true,
+ concise: false # specify concise to invoke alternate formatting for skimmable lists (two-column in default layout)
+ }.merge(options)
+
+ field_name = options[:field_name] || field_name(form, attribute)
+ field_name += '[]'
+ base_id = options[:field_id] || field_id(form, attribute)
+ checkboxes_id = "#{base_id}_checkboxes"
+ opts = options[:disabled] ? {disabled: "true"} : {}
+ already_checked = case
+ when options[:checked_method].is_a?(Array)
+ options[:checked_method]
+ when options[:checked_method].nil?
+ []
+ else
+ form.object.send(options[:checked_method]) || []
+ end
+
+ checkboxes = choices.map do |choice|
+ is_checked = !options[:checked_method] || already_checked.empty? ? false : already_checked.include?(choice)
+ display_name = case
+ when options[:name_helper_method]
+ eval("#{options[:name_helper_method]}(choice)")
+ else
+ choice.send(options[:name_method]).html_safe
+ end
+ value = choice.send(options[:value_method])
+ checkbox_id = "#{base_id}_#{name_to_id(value)}"
+ checkbox = check_box_tag(field_name, value, is_checked, opts.merge({id: checkbox_id}))
+ checkbox_and_label = label_tag checkbox_id, class: "action" do
+ options[:checkbox_side] == "left" ? checkbox + display_name : display_name + checkbox
+ end
+ if options[:extra_info_method]
+ checkbox_and_label = options[:checkbox_side] == "left" ? checkbox_and_label + eval("#{options[:extra_info_method]}(choice)") : eval("#{options[:extra_info_method]}(choice)") + checkbox_and_label
+ end
+ content_tag(:li, checkbox_and_label)
+ end.join("\n").html_safe
+
+ # if there are only a few choices, don't show the scrolling and the toggle
+ size = choices.size
+ css_class = checkbox_section_css_class(size, options[:concise])
+ checkboxes_ul = content_tag(:ul, checkboxes, class: css_class)
+
+ toggle = "".html_safe
+ if options[:include_toggle] && !options[:concise] && size > (ArchiveConfig.OPTIONS_TO_SHOW * 6)
+ toggle = checkbox_section_toggle(checkboxes_id, size)
+ end
+
+ # We wrap the whole thing in a div
+ return content_tag(:div, checkboxes_ul + toggle + (options[:include_blank] ? hidden_field_tag(field_name, " ") : ''.html_safe), id: checkboxes_id)
+ end
+
+ def checkbox_section_css_class(size, concise=false)
+ css_class = "options index group"
+
+ if concise
+ css_class += " concise lots" if size > ArchiveConfig.OPTIONS_TO_SHOW
+ else
+ css_class += " many" if size > ArchiveConfig.OPTIONS_TO_SHOW
+ css_class += " lots" if size > (ArchiveConfig.OPTIONS_TO_SHOW * 6)
+ end
+
+ css_class
+ end
+
+ def check_all_none(all_text="All", none_text="None", id_filter=nil)
+ filter_attrib = (id_filter ? " data-checkbox-id-filter=\"#{id_filter}\"" : '')
+ ('
").html_safe
+ end
+
+ def submit_button(form=nil, button_text=nil)
+ button_text ||= (form.nil? || form.object.nil? || form.object.new_record?) ? ts("Submit") : ts("Update")
+ content_tag(:p, (form.nil? ? submit_tag(button_text) : form.submit(button_text)), class: "submit")
+ end
+
+ def submit_fieldset(form=nil, button_text=nil)
+ content_tag(:fieldset, content_tag(:legend, ts("Actions")) + submit_button(form, button_text))
+ end
+
+ def first_paragraph(full_text, placeholder_text = 'No preview available.')
+ # is there a paragraph that does not have a child image?
+ paragraph = Nokogiri::HTML5.parse(full_text).at_xpath("//p[not(img)]")
+ if paragraph.present?
+ # if so, get its text and put it in a fresh p tag
+ paragraph_text = paragraph.text
+ return content_tag(:p, paragraph_text)
+ else
+ # if not, put the placeholder text in a p tag with the placeholder class
+ return content_tag(:p, ts(placeholder_text), class: 'placeholder')
+ end
+ end
+
+ # spans for nesting a checkbox or radio button inside its label to make custom
+ # checkbox or radio designs
+ def label_indicator_and_text(text)
+ content_tag(:span, "", class: "indicator", "aria-hidden": "true") + content_tag(:span, text)
+ end
+
+ # Display a collection of radio buttons, wrapped in an unordered list.
+ #
+ # The parameter option_array should be a list of pairs, where the first
+ # element in each pair is the radio button's value, and the second element in
+ # each pair is the radio button's label.
+ def radio_button_list(form, field_name, option_array)
+ content_tag(:ul) do
+ form.collection_radio_buttons(field_name, option_array, :first, :second,
+ include_hidden: false) do |builder|
+ content_tag(:li, builder.label { builder.radio_button + builder.text })
+ end
+ end
+ end
+
+ # Identifier for creation, formatted external-work-12, series-12, work-12.
+ def creation_id_for_css_classes(creation)
+ return unless %w[ExternalWork Series Work].include?(creation.class.name)
+
+ "#{creation.class.name.underscore.dasherize}-#{creation.id}"
+ end
+
+ # Array of creator ids, formatted user-123, user-126.
+ # External works are not created by users, so we can skip this.
+ def creator_ids_for_css_classes(creation)
+ return [] unless %w[Series Work].include?(creation.class.name)
+ return [] if creation.anonymous?
+ # Although series.unrevealed? can be true, the creators are not concealed
+ # in the blurb. Therefore, we do not need special handling for unrevealed
+ # series.
+ return [] if creation.is_a?(Work) && creation.unrevealed?
+
+ creation.pseuds.pluck(:user_id).uniq.map { |id| "user-#{id}" }
+ end
+
+ def css_classes_for_creation_blurb(creation)
+ return if creation.nil?
+
+ Rails.cache.fetch("#{creation.cache_key_with_version}/blurb_css_classes-v2") do
+ creation_id = creation_id_for_css_classes(creation)
+ creator_ids = creator_ids_for_css_classes(creation).join(" ")
+ "blurb group #{creation_id} #{creator_ids}".strip
+ end
+ end
+
+ # Returns the current path, with some modified parameters. Modeled after
+ # WillPaginate::ActionView::LinkRenderer to try to prevent any additional
+ # security risks.
+ def current_path_with(**kwargs)
+ # Only throw in the query params if this is a GET request, because POST and
+ # such don't pass their params in the URL.
+ path_params = if request.get? || request.head?
+ permit_all_except(params, [:script_name, :original_script_name])
+ else
+ {}
+ end
+
+ path_params.deep_merge!(kwargs)
+ path_params[:only_path] = true # prevent shenanigans
+
+ url_for(path_params)
+ end
+
+ # Creates a new hash with all keys except those marked as blocked.
+ #
+ # This is a bit of a hack, but without this we'd have to either (a) make a
+ # list of all permitted params each time current_path_with is called, or (b)
+ # call params.permit! and effectively disable strong parameters for any code
+ # called after current_path_with.
+ def permit_all_except(params, blocked_keys)
+ if params.respond_to?(:each_pair)
+ {}.tap do |result|
+ params.each_pair do |key, value|
+ key = key.to_sym
+ next if blocked_keys.include?(key)
+
+ result[key] = permit_all_except(value, blocked_keys)
+ end
+ end
+ elsif params.respond_to?(:map)
+ params.map do |entry|
+ permit_all_except(entry, blocked_keys)
+ end
+ else # not a hash or an array, just a flat value
+ params
+ end
+ end
+
+ def disallow_robots?(item)
+ return unless item
+
+ if item.is_a?(User)
+ item.preference&.minimize_search_engines?
+ elsif item.respond_to?(:users)
+ item.users.all? { |u| u&.preference&.minimize_search_engines? }
+ end
+ end
+
+ # Determines if the page (controller and action combination) does not need
+ # to show the ToS (Terms of Service) popup.
+ def tos_exempt_page?
+ case params[:controller]
+ when "home"
+ %w[index content dmca privacy tos tos_faq].include?(params[:action])
+ when "abuse_reports", "feedbacks", "users/sessions"
+ %w[new create].include?(params[:action])
+ when "archive_faqs"
+ %w[index show].include?(params[:action])
+ end
+ end
+
+ def browser_page_title(page_title, page_subtitle)
+ return page_title if page_title
+
+ page = if page_subtitle
+ page_subtitle
+ elsif controller.action_name == "index"
+ process_title(controller.controller_name)
+ else
+ "#{process_title(controller.action_name)} #{process_title(controller.controller_name.singularize)}"
+ end
+ # page_subtitle sometimes contains user (including admin) content, so let's
+ # not html_safe the entire string. Let's require html_safe be called when
+ # we set @page_subtitle, so we're conscious of what we're doing.
+ page + " | #{ArchiveConfig.APP_NAME}"
+ end
+end
diff --git a/app/helpers/block_helper.rb b/app/helpers/block_helper.rb
new file mode 100755
index 0000000..21753c2
--- /dev/null
+++ b/app/helpers/block_helper.rb
@@ -0,0 +1,46 @@
+module BlockHelper
+ def block_link(user, block: nil)
+ if block.nil?
+ block = user.block_by_current_user
+ blocking_user = current_user
+ else
+ blocking_user = block.blocker
+ end
+
+ if block
+ link_to(t("blocked.unblock"), confirm_unblock_user_blocked_user_path(blocking_user, block))
+ else
+ link_to(t("blocked.block"), confirm_block_user_blocked_users_path(blocking_user, blocked_id: user))
+ end
+ end
+
+ def blocked_by?(object)
+ return false unless current_user
+
+ blocker = users_for(object)
+
+ # Users can't be blocked by their own creations, even if one of their
+ # co-creators has blocked them:
+ return false if blocker.include?(current_user)
+
+ blocker.any?(&:block_of_current_user)
+ end
+
+ def blocked_by_comment?(comment)
+ (comment.is_a?(Comment) || comment.is_a?(CommentDecorator)) &&
+ comment.parent_type != "Tag" &&
+ blocked_by?(comment)
+ end
+
+ def users_for(object)
+ if object.is_a?(User)
+ [object]
+ elsif object.respond_to?(:user)
+ [object.user].compact
+ elsif object.respond_to?(:users)
+ object.users
+ else
+ []
+ end
+ end
+end
diff --git a/app/helpers/bookmarks_helper.rb b/app/helpers/bookmarks_helper.rb
new file mode 100755
index 0000000..c16587a
--- /dev/null
+++ b/app/helpers/bookmarks_helper.rb
@@ -0,0 +1,130 @@
+module BookmarksHelper
+ # if the current user has the current object bookmarked return the existing bookmark
+ # since the user may have multiple bookmarks for different pseuds we prioritize by current default pseud if more than one bookmark exists
+ def bookmark_if_exists(bookmarkable)
+ return nil unless logged_in?
+
+ bookmarkable = bookmarkable.work if bookmarkable.class == Chapter
+
+ current_user.bookmarks.where(bookmarkable: bookmarkable)
+ .reorder("pseuds.is_default", "bookmarks.id").last
+ end
+
+ # returns just a url to the new bookmark form
+ def get_new_bookmark_path(bookmarkable)
+ return case bookmarkable.class.to_s
+ when "Chapter"
+ new_work_bookmark_path(bookmarkable.work)
+ when "Work"
+ new_work_bookmark_path(bookmarkable)
+ when "ExternalWork"
+ new_external_work_bookmark_path(bookmarkable)
+ when "Series"
+ new_series_bookmark_path(bookmarkable)
+ end
+ end
+
+ def link_to_bookmarkable_bookmarks(bookmarkable, link_text='')
+ if link_text.blank?
+ link_text = number_with_delimiter(Bookmark.count_visible_bookmarks(bookmarkable, current_user))
+ end
+ path = case bookmarkable.class.name
+ when "Work"
+ then work_bookmarks_path(bookmarkable)
+ when "ExternalWork"
+ then external_work_bookmarks_path(bookmarkable)
+ when "Series"
+ then series_bookmarks_path(bookmarkable)
+ end
+ link_to link_text, path
+ end
+
+ # returns the appropriate small single icon for a bookmark -- not hardcoded, these are in css so they are skinnable
+ def get_symbol_for_bookmark(bookmark)
+ if bookmark.private?
+ css_class = "private"
+ title_string = "Private Bookmark"
+ elsif bookmark.hidden_by_admin?
+ css_class = "hidden"
+ title_string = "Bookmark Hidden by Admin"
+ elsif bookmark.rec?
+ css_class = "rec"
+ title_string = "Rec"
+ else
+ css_class = "public"
+ title_string = "Public Bookmark"
+ end
+ link_to_help('bookmark-symbols-key', content_tag(:span, content_tag(:span, title_string, class: "text"), class: css_class, title: title_string))
+ end
+
+ def bookmark_form_path(bookmark, bookmarkable)
+ if bookmark.new_record?
+ if bookmarkable.new_record?
+ bookmarks_path
+ else
+ polymorphic_path([bookmarkable, bookmark])
+ end
+ else
+ bookmark_path(bookmark)
+ end
+ end
+
+ def get_count_for_bookmark_blurb(bookmarkable)
+ count = bookmarkable.public_bookmark_count
+ link = link_to (count < 100 ? count.to_s : "*"),
+ polymorphic_path([bookmarkable, Bookmark])
+ content_tag(:span, link, class: "count")
+ end
+
+ # Bookmark blurbs contain a single bookmark from a single user.
+ # bookmark blurb group creation-id [creator-ids bookmarker-id].uniq
+ def css_classes_for_bookmark_blurb(bookmark)
+ return if bookmark.nil?
+
+ creation = bookmark.bookmarkable
+ if creation.nil?
+ "bookmark blurb group #{bookmarker_id_for_css_classes(bookmark)}"
+ else
+ Rails.cache.fetch("#{creation.cache_key_with_version}_#{bookmark.cache_key}/blurb_css_classes") do
+ creation_id = creation_id_for_css_classes(creation)
+ user_ids = user_ids_for_bookmark_blurb(bookmark).join(" ")
+ "bookmark blurb group #{creation_id} #{user_ids}".squish
+ end
+ end
+ end
+
+ # Bookmarkable blurbs contain multiple short blurbs from different users.
+ # Bookmarker ids are applied to the individual short blurbs.
+ # Note that creation blurb classes are cached.
+ # bookmark blurb group creation-id creator-ids
+ def css_classes_for_bookmarkable_blurb(bookmarkable)
+ return "bookmark blurb group" if bookmarkable.nil?
+
+ creation_classes = css_classes_for_creation_blurb(bookmarkable)
+ "bookmark #{creation_classes}".strip
+ end
+
+ def css_classes_for_bookmark_blurb_short(bookmark)
+ return if bookmark.nil?
+
+ own = "own" if is_author_of?(bookmark)
+ bookmarker_id = bookmarker_id_for_css_classes(bookmark)
+ "#{own} user short blurb group #{bookmarker_id}".squish
+ end
+
+ private
+
+ def bookmarker_id_for_css_classes(bookmark)
+ return if bookmark.nil?
+
+ "user-#{bookmark.pseud.user_id}"
+ end
+
+ # Array of unique creator and bookmarker ids, formatted user-123, user-126.
+ # If the user has bookmarked their own work, we don't need their id twice.
+ def user_ids_for_bookmark_blurb(bookmark)
+ user_ids = creator_ids_for_css_classes(bookmark.bookmarkable)
+ user_ids << bookmarker_id_for_css_classes(bookmark)
+ user_ids.uniq
+ end
+end
diff --git a/app/helpers/challenge_helper.rb b/app/helpers/challenge_helper.rb
new file mode 100755
index 0000000..5ba2608
--- /dev/null
+++ b/app/helpers/challenge_helper.rb
@@ -0,0 +1,48 @@
+module ChallengeHelper
+ def prompt_tags(prompt)
+ details = content_tag(:h6, ts("Tags"), class: "landmark heading")
+ TagSet::TAG_TYPES.each do |type|
+ if prompt && prompt.tag_set && !prompt.tag_set.with_type(type).empty?
+ details += content_tag(:ul, tag_link_list(prompt.tag_set.with_type(type), link_to_works=true), class: "#{type} type tags commas")
+ end
+ end
+ details
+ end
+
+ # generate the display value for the claim
+ def claim_title(claim)
+ claim.title.html_safe + link_to(ts(" (Details)"), collection_prompt_path(claim.collection, claim.request_prompt), target: "_blank", class: "toggle")
+ end
+
+ # count the number of tag sets used in a challenge
+ def tag_set_count(collection)
+ if challenge_type_present?(collection)
+ tag_sets = determine_tag_sets(collection.challenge)
+
+ # use `blank?` instead of `empty?` since there is the possibility that
+ # `tag_sets` will be nil, and nil does not respond to `blank?`
+ tag_sets.size unless tag_sets.blank?
+ end
+ end
+
+ private
+
+ # Private: Determines whether a collection has a challenge type
+ #
+ # Returns a boolean
+ def challenge_type_present?(collection)
+ collection && collection.challenge_type.present?
+ end
+
+ # Private: Determines the collection of owned_tag_sets based on a given
+ # challenge's class
+ #
+ # Returns an ActiveRecord Collection object or nil
+ def determine_tag_sets(challenge)
+ if challenge.class.name == 'GiftExchange'
+ challenge.offer_restriction.owned_tag_sets
+ elsif challenge.class.name == 'PromptMeme'
+ challenge.request_restriction.owned_tag_sets
+ end
+ end
+end
diff --git a/app/helpers/collections_helper.rb b/app/helpers/collections_helper.rb
new file mode 100755
index 0000000..d1c8af1
--- /dev/null
+++ b/app/helpers/collections_helper.rb
@@ -0,0 +1,138 @@
+module CollectionsHelper
+
+ # Generates a draggable, pop-up div which contains the add-to-collection form
+ def collection_link(item)
+ if item.class == Chapter
+ item = item.work
+ end
+ if logged_in?
+ if item.class == Work
+ link_to ts("Add To Collection"), new_work_collection_item_path(item), remote: true
+ elsif item.class == Bookmark
+ link_to ts("Add To Collection"), new_bookmark_collection_item_path(item)
+ end
+ end
+ end
+
+ # show a section if it's not empty or if the parent collection has it
+ def show_collection_section(collection, section)
+ if ["intro", "faq", "rules"].include?(section) # just a check that we're not using a bogus section string
+ !collection.collection_profile.send(section).blank? || collection.parent && !collection.parent.collection_profile.send(section).blank?
+ end
+ end
+
+ # show collection preface if at least one section of the profile (or the parent collection's profile) is not empty
+ def show_collection_preface(collection)
+ show_collection_section(collection, "intro") || show_collection_section(collection, "faq") || show_collection_section(collection, "rules")
+ end
+
+ # show navigation to relevant sections of the profile if needed
+ def show_collection_profile_navigation(collection, section)
+ ["intro", "faq", "rules"].each do |s|
+ if show_collection_section(collection, s) && s != section
+ return true # if at least one other section than the current one is not blank, we need the navigation; break out of the each...do
+ end
+ end
+ return false # if it passed through all tests above and not found a match, then we don't need the navigation
+ end
+
+ def challenge_class_name(collection)
+ collection.challenge.class.name.demodulize.tableize.singularize
+ end
+
+ def show_collections_data(collections)
+ collections.collect { |coll| link_to coll.title, collection_path(coll) }.join(ArchiveConfig.DELIMITER_FOR_OUTPUT).html_safe
+ end
+
+ def challenge_assignment_byline(assignment)
+ if assignment.offer_signup && assignment.offer_signup.pseud
+ assignment.offer_signup.pseud.byline
+ elsif assignment.pinch_hitter
+ assignment.pinch_hitter.byline + "* (pinch hitter)"
+ else
+ ""
+ end
+ end
+
+ def challenge_assignment_email(assignment)
+ if assignment.offer_signup && assignment.offer_signup.pseud
+ user = assignment.offer_signup.pseud.user
+ elsif assignment.pinch_hitter
+ user = assignment.pinch_hitter.user
+ else
+ user = nil
+ end
+ if user
+ mailto_link user, subject: "[#{(@collection.title)}] Message from Collection Maintainer"
+ end
+ end
+
+ def collection_item_display_title(collection_item)
+ item = collection_item.item
+ item_type = collection_item.item_type
+ if item_type == 'Bookmark' && item.present? && item.bookmarkable.present?
+ # .html_safe is necessary for titles with ampersands etc when inside ts()
+ ts('Bookmark for %{title}', title: item.bookmarkable.title).html_safe
+ elsif item_type == 'Bookmark'
+ ts('Bookmark of deleted item')
+ elsif item_type == 'Work' && item.posted?
+ item.title
+ elsif item_type == 'Work' && !item.posted?
+ ts('%{title} (Draft)', title: item.title).html_safe
+ # Prevent 500 error if collection_item is not destroyed when collection_item.item is
+ else
+ ts('Deleted or unknown item')
+ end
+ end
+
+ def collection_item_approval_options_label(actor:, item_type:)
+ item_type = item_type.downcase
+ actor = actor.downcase
+
+ case actor
+ when "user"
+ t("collections_helper.collection_item_approval_options_label.user.#{item_type}")
+ when "collection"
+ t("collections_helper.collection_item_approval_options_label.collection")
+ end
+ end
+
+ # i18n-tasks-use t('collections_helper.collection_item_approval_options.collection.approved')
+ # i18n-tasks-use t('collections_helper.collection_item_approval_options.collection.rejected')
+ # i18n-tasks-use t('collections_helper.collection_item_approval_options.collection.unreviewed')
+ # i18n-tasks-use t('collections_helper.collection_item_approval_options.user.bookmark.approved')
+ # i18n-tasks-use t('collections_helper.collection_item_approval_options.user.bookmark.rejected')
+ # i18n-tasks-use t('collections_helper.collection_item_approval_options.user.bookmark.unreviewed')
+ # i18n-tasks-use t('collections_helper.collection_item_approval_options.user.work.approved')
+ # i18n-tasks-use t('collections_helper.collection_item_approval_options.user.work.rejected')
+ # i18n-tasks-use t('collections_helper.collection_item_approval_options.user.work.unreviewed')
+ def collection_item_approval_options(actor:, item_type:)
+ item_type = item_type.downcase
+ actor = actor.downcase
+
+ key = case actor
+ when "user"
+ "collections_helper.collection_item_approval_options.user.#{item_type}"
+ when "collection"
+ "collections_helper.collection_item_approval_options.collection"
+ end
+
+ [
+ [t("#{key}.unreviewed"), :unreviewed],
+ [t("#{key}.approved"), :approved],
+ [t("#{key}.rejected"), :rejected]
+ ]
+ end
+
+ # Fetches the icon URL for the given collection, using the standard (100x100) variant.
+ def standard_icon_url(collection)
+ return "/images/skins/iconsets/default/icon_collection.png" unless collection.icon.attached?
+
+ rails_blob_url(collection.icon.variant(:standard))
+ end
+
+ # Wraps the collection's standard_icon_url in an image tag
+ def collection_icon_display(collection)
+ image_tag(standard_icon_url(collection), size: "100x100", alt: collection.icon_alt_text, class: "icon", skip_pipeline: true)
+ end
+end
diff --git a/app/helpers/comments_helper.rb b/app/helpers/comments_helper.rb
new file mode 100755
index 0000000..3cba368
--- /dev/null
+++ b/app/helpers/comments_helper.rb
@@ -0,0 +1,404 @@
+module CommentsHelper
+ def value_for_comment_form(commentable, comment)
+ commentable.is_a?(Tag) ? comment : [commentable, comment]
+ end
+
+ def title_for_comment_page(commentable)
+ if commentable.commentable_name.blank?
+ title = ""
+ elsif commentable.is_a?(Tag)
+ title = link_to_tag(commentable)
+ else
+ title = link_to(commentable.commentable_name, commentable)
+ end
+ (ts('Reading Comments on ') + title).html_safe
+ end
+
+ def link_to_comment_ultimate_parent(comment)
+ ultimate = comment.ultimate_parent
+ case ultimate.class.to_s
+ when 'Work' then
+ link_to ultimate.title, ultimate
+ when 'Pseud' then
+ link_to ultimate.name, ultimate
+ when 'AdminPost' then
+ link_to ultimate.title, ultimate
+ else
+ if ultimate.is_a?(Tag)
+ link_to_tag(ultimate)
+ else
+ link_to 'Something Interesting', ultimate
+ end
+ end
+ end
+
+ def comment_link_with_commentable_name(comment)
+ ultimate_parent = comment.ultimate_parent
+ commentable_name = ultimate_parent&.commentable_name
+ text = case ultimate_parent.class.to_s
+ when "Work"
+ t("comments_helper.comment_link_with_commentable_name.on_work_html", title: commentable_name)
+ when "AdminPost"
+ t("comments_helper.comment_link_with_commentable_name.on_admin_post_html", title: commentable_name)
+ else
+ if ultimate_parent.is_a?(Tag)
+ t("comments_helper.comment_link_with_commentable_name.on_tag_html", name: commentable_name)
+ else
+ t("comments_helper.comment_link_with_commentable_name.on_unknown")
+ end
+ end
+ link_to(text, comment_path(comment))
+ end
+
+ # return pseudname or name for comment
+ def get_commenter_pseud_or_name(comment)
+ if comment.pseud_id
+ if comment.pseud.nil?
+ ts("Account Deleted")
+ elsif comment.pseud.user.official
+ (link_to comment.pseud.byline, [comment.pseud.user, comment.pseud]) + content_tag(:span, " " + ts("(Official)"), class: "role")
+ else
+ link_to comment.pseud.byline, [comment.pseud.user, comment.pseud]
+ end
+ else
+ content_tag(:span, comment.name) + content_tag(:span, " #{ts('(Guest)')}", class: "role")
+ end
+ end
+
+ def chapter_description_link(comment)
+ link_to t("comments_helper.chapter_link_html", position: comment.parent.position), work_chapter_path(comment.parent.work, comment.parent)
+ end
+
+ def image_safety_mode_cache_key(comment)
+ "image-safety-mode" if comment.use_image_safety_mode?
+ end
+
+ ####
+ ## Mar 4 2009 Enigel: the below shouldn't happen anymore, please test
+ ####
+ ## Note: there is a small but interesting bug here. If you first use javascript to open
+ ## up the comments, the various url_for(:overwrite_params) arguments used below as the
+ ## non-javascript fallbacks will end up with the wrong code, and so if you then turn
+ ## off Javascript and try to use the links, you will get weirdo results. I think this
+ ## is a bug we can live with for the moment; someone consistently browsing without
+ ## javascript shouldn't have problems.
+ ## -- Naomi, 9/2/2008
+ ####
+
+ #### Helpers for _commentable.html.erb ####
+
+ # return link to show or hide comments
+ def show_hide_comments_link(commentable, options={})
+ options[:link_type] ||= "show"
+ options[:show_count] ||= false
+
+ commentable_id = commentable.is_a?(Tag) ?
+ :tag_id :
+ "#{commentable.class.to_s.underscore}_id".to_sym
+ commentable_value = commentable.is_a?(Tag) ?
+ commentable.name :
+ commentable.id
+
+ comment_count = commentable.count_visible_comments.to_s
+
+ link_action = options[:link_type] == "hide" || params[:show_comments] ?
+ :hide_comments :
+ :show_comments
+
+ link_text = ts("%{words} %{count}",
+ words: options[:link_type] == "hide" || params[:show_comments] ?
+ "Hide Comments" :
+ "Comments",
+ count: options[:show_count] ?
+ "(" +comment_count+ ")" :
+ "")
+
+ link_to(
+ link_text,
+ url_for(controller: :comments,
+ action: link_action,
+ commentable_id => commentable_value,
+ view_full_work: params[:view_full_work]),
+ remote: true)
+ end
+
+ #### HELPERS FOR CHECKING WHICH BUTTONS/FORMS TO DISPLAY #####
+
+ def can_reply_to_comment?(comment)
+ admin_settings = AdminSetting.current
+
+ return false if comment.unreviewed?
+ return false if comment.iced?
+ return false if comment.hidden_by_admin?
+ return false if parent_disallows_comments?(comment)
+ return false if comment_parent_hidden?(comment)
+ return false if blocked_by_comment?(comment)
+ return false if blocked_by?(comment.ultimate_parent)
+ return false if logged_in_as_admin?
+
+ return true unless guest?
+
+ !(admin_settings.guest_comments_off? || comment.guest_replies_disallowed?)
+ end
+
+ def can_edit_comment?(comment)
+ is_author_of?(comment) &&
+ !comment.iced? &&
+ comment.count_all_comments.zero? &&
+ !comment_parent_hidden?(comment) &&
+ !blocked_by_comment?(comment.commentable) &&
+ !blocked_by?(comment.ultimate_parent)
+ end
+
+ # Only an admin with proper authorization can mark a spam comment ham.
+ def can_mark_comment_ham?(comment)
+ return unless comment.pseud.nil? && !comment.approved?
+
+ policy(comment).can_mark_comment_spam?
+ end
+
+ # An admin with proper authorization or a creator of the comment's ultimate
+ # parent (i.e. the work) can mark an approved comment as spam.
+ def can_mark_comment_spam?(comment)
+ return unless comment.pseud.nil? && comment.approved?
+
+ policy(comment).can_mark_comment_spam? || is_author_of?(comment.ultimate_parent)
+ end
+
+ # Comments can be deleted by admins with proper authorization, their creator
+ # (if the creator is a registered user), or the creator of the comment's
+ # ultimate parent.
+ def can_destroy_comment?(comment)
+ policy(comment).can_destroy_comment? ||
+ is_author_of?(comment) ||
+ is_author_of?(comment.ultimate_parent)
+ end
+
+ # Comments on works can be frozen by admins with proper authorization or the
+ # work creator.
+ # Comments on tags can be frozen by admins with proper authorization.
+ # Comments on admin posts can be frozen by any admin.
+ def can_freeze_comment?(comment)
+ policy(comment).can_freeze_comment? ||
+ comment.ultimate_parent.is_a?(Work) &&
+ is_author_of?(comment.ultimate_parent)
+ end
+
+ def can_hide_comment?(comment)
+ policy(comment).can_hide_comment?
+ end
+
+ def can_see_hidden_comment?(comment)
+ !comment.hidden_by_admin? ||
+ is_author_of?(comment) ||
+ can_hide_comment?(comment)
+ end
+
+ def comment_parent_hidden?(comment)
+ parent = comment.ultimate_parent
+ (parent.respond_to?(:hidden_by_admin) && parent.hidden_by_admin) ||
+ (parent.respond_to?(:in_unrevealed_collection) && parent.in_unrevealed_collection)
+ end
+
+ def parent_disallows_comments?(comment)
+ parent = comment.ultimate_parent
+ return false unless parent.is_a?(Work) || parent.is_a?(AdminPost)
+
+ parent.disable_all_comments? ||
+ parent.disable_anon_comments? && !logged_in?
+ end
+
+ def can_review_comment?(comment)
+ return false unless comment.unreviewed?
+
+ is_author_of?(comment.ultimate_parent) || policy(comment).can_review_comment?
+ end
+
+ def can_review_all_comments?(commentable)
+ commentable.is_a?(AdminPost) || is_author_of?(commentable)
+ end
+
+ #### HELPERS FOR REPLYING TO COMMENTS #####
+
+ # return link to add new reply to a comment
+ def add_comment_reply_link(comment)
+ commentable_id = comment.ultimate_parent.is_a?(Tag) ?
+ :tag_id :
+ comment.parent.class.name.foreign_key.to_sym # :chapter_id, :admin_post_id etc.
+ commentable_value = comment.ultimate_parent.is_a?(Tag) ?
+ comment.ultimate_parent.name :
+ comment.parent.id
+ link_to(
+ ts("Reply"),
+ url_for(controller: :comments,
+ action: :add_comment_reply,
+ id: comment.id,
+ comment_id: params[:comment_id],
+ commentable_id => commentable_value,
+ view_full_work: params[:view_full_work],
+ page: params[:page]),
+ remote: true)
+ end
+
+ # return link to cancel new reply to a comment
+ def cancel_comment_reply_link(comment)
+ commentable_id = comment.ultimate_parent.is_a?(Tag) ?
+ :tag_id :
+ comment.parent.class.name.foreign_key.to_sym
+ commentable_value = comment.ultimate_parent.is_a?(Tag) ?
+ comment.ultimate_parent.name :
+ comment.parent.id
+ link_to(
+ ts("Cancel"),
+ url_for(
+ controller: :comments,
+ action: :cancel_comment_reply,
+ id: comment.id,
+ comment_id: params[:comment_id],
+ commentable_id => commentable_value,
+ view_full_work: params[:view_full_work],
+ page: params[:page]
+ ),
+ remote: true
+ )
+ end
+
+ # canceling an edit
+ def cancel_edit_comment_link(comment)
+ link_to(ts("Cancel"),
+ url_for(controller: :comments,
+ action: :cancel_comment_edit,
+ id: comment.id,
+ comment_id: params[:comment_id]),
+ remote: true)
+ end
+
+ # return html link to edit comment
+ def edit_comment_link(comment)
+ link_to(ts("Edit"),
+ url_for(controller: :comments,
+ action: :edit,
+ id: comment,
+ comment_id: params[:comment_id]),
+ remote: true)
+ end
+
+ def do_cancel_delete_comment_link(comment)
+ if params[:delete_comment_id] && params[:delete_comment_id] == comment.id.to_s
+ cancel_delete_comment_link(comment)
+ else
+ delete_comment_link(comment)
+ end
+ end
+
+ def freeze_comment_button(comment)
+ if comment.iced?
+ button_to ts("Unfreeze Thread"), unfreeze_comment_path(comment), method: :put
+ else
+ button_to ts("Freeze Thread"), freeze_comment_path(comment), method: :put
+ end
+ end
+
+ def hide_comment_button(comment)
+ if comment.hidden_by_admin?
+ button_to ts("Make Comment Visible"), unhide_comment_path(comment), method: :put
+ else
+ button_to ts("Hide Comment"), hide_comment_path(comment), method: :put
+ end
+ end
+
+ # Not a link or button, but included with them.
+ def frozen_comment_indicator
+ content_tag(:span, ts("Frozen"), class: "frozen current")
+ end
+
+ # return html link to delete comments
+ def delete_comment_link(comment)
+ link_to(
+ ts("Delete"),
+ url_for(controller: :comments,
+ action: :delete_comment,
+ id: comment,
+ comment_id: params[:comment_id]),
+ remote: true)
+ end
+
+ # return link to cancel new reply to a comment
+ def cancel_delete_comment_link(comment)
+ link_to(
+ ts("Cancel"),
+ url_for(controller: :comments,
+ action: :cancel_comment_delete,
+ id: comment,
+ comment_id: params[:comment_id]),
+ remote: true)
+ end
+
+ # return html link to mark/unmark comment as spam
+ def tag_comment_as_spam_link(comment)
+ if comment.approved
+ link_to(ts("Spam"), reject_comment_path(comment), method: :put, data: { confirm: "Are you sure you want to mark this as spam?" })
+ else
+ link_to(ts("Not Spam"), approve_comment_path(comment), method: :put)
+ end
+ end
+
+ # gets the css user- class name for the comment
+ def commenter_id_for_css_classes(comment)
+ return if comment.pseud.nil?
+ return if comment.by_anonymous_creator?
+ return if comment.is_deleted
+ return if comment.hidden_by_admin
+
+ "user-#{comment.pseud.user_id}"
+ end
+
+ def css_classes_for_comment(comment)
+ return if comment.nil?
+
+ unavailable = "unavailable" if comment.hidden_by_admin
+ unreviewed = "unreviewed" if comment.unreviewed?
+ commenter = commenter_id_for_css_classes(comment)
+ official = "official" if commenter && comment&.pseud&.user&.official
+ guest = "guest" unless comment.pseud_id
+
+ "#{unavailable} #{official} #{guest} #{unreviewed} comment group #{commenter}".squish
+ end
+
+ # find the parent of the commentable
+ def find_parent(commentable)
+ if commentable.respond_to?(:ultimate_parent)
+ commentable.ultimate_parent
+ elsif commentable.respond_to?(:work)
+ commentable.work
+ else
+ commentable
+ end
+ end
+
+ # if parent commentable is a work, determine if current user created it
+ def current_user_is_work_creator(commentable)
+ if logged_in?
+ parent = find_parent(commentable)
+ parent.is_a?(Work) && current_user.is_author_of?(parent)
+ end
+ end
+
+ # if parent commentable is an anonymous work, determine if current user created it
+ def current_user_is_anonymous_creator(commentable)
+ if logged_in?
+ parent = find_parent(commentable)
+ parent.is_a?(Work) && parent.anonymous? && current_user.is_author_of?(parent)
+ end
+ end
+
+ # determine if the parent has its comments set to moderated
+ def comments_are_moderated(commentable)
+ parent = find_parent(commentable)
+ parent.respond_to?(:moderated_commenting_enabled) && parent.moderated_commenting_enabled?
+ end
+
+ def focused_on_comment(commentable)
+ params[:add_comment_reply_id] && params[:add_comment_reply_id] == commentable.id.to_s
+ end
+end
diff --git a/app/helpers/date_helper.rb b/app/helpers/date_helper.rb
new file mode 100755
index 0000000..ac6d502
--- /dev/null
+++ b/app/helpers/date_helper.rb
@@ -0,0 +1,36 @@
+module DateHelper
+
+ # Use time_ago_in_words if less than a month ago, otherwise display date
+ def set_format_for_date(datetime)
+ return "" unless datetime.is_a? Time
+ if datetime > 30.days.ago && !AdminSetting.enable_test_caching?
+ time_ago_in_words(datetime)
+ else
+ date_in_user_time_zone(datetime).to_date.to_formatted_s(:rfc822)
+ end
+ end
+
+ def date_in_user_time_zone(datetime)
+ if logged_in? && current_user.preference.time_zone
+ zone = current_user.preference.time_zone
+ begin
+ date_in_user_time_zone = datetime.in_time_zone(current_user.preference.time_zone)
+ rescue
+ datetime
+ end
+ else
+ date_in_user_time_zone = datetime
+ end
+ end
+
+ # show date in the time zone specified
+ # note: this does *not* append timezone and does *not* reflect user preferences
+ def date_in_zone(time, zone = nil)
+ zone ||= Time.zone.name
+ return nil if time.blank?
+
+ time_in_zone = time.in_time_zone(zone)
+ I18n.l(time_in_zone, format: :date_short_html).html_safe
+ # i18n-tasks-use t('time.formats.date_short_html')
+ end
+end
diff --git a/app/helpers/exports_helper.rb b/app/helpers/exports_helper.rb
new file mode 100755
index 0000000..08131f1
--- /dev/null
+++ b/app/helpers/exports_helper.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+module ExportsHelper
+
+ def send_csv_data(content_array, filename)
+ send_data(export_csv(content_array), filename: filename, type: :csv)
+ end
+
+ # Tab-separated CSV with utf-16le encoding (unicode) and byte order
+ # mark. This seems to be the only variant Excel can get
+ # automatically into proper table format. OpenOffice handles it
+ # well, too.
+ def export_csv(content_array)
+ csv_data = content_array.map { |x| x.to_csv(col_sep: "\t", encoding: "utf-8") }.join
+ byte_order_mark = "\uFEFF"
+ (byte_order_mark + csv_data).encode("utf-16le", "utf-8", invalid: :replace, undef: :replace, replace: "")
+ end
+end
diff --git a/app/helpers/external_authors_helper.rb b/app/helpers/external_authors_helper.rb
new file mode 100755
index 0000000..a4e8075
--- /dev/null
+++ b/app/helpers/external_authors_helper.rb
@@ -0,0 +1,26 @@
+module ExternalAuthorsHelper
+
+ def add_name_link(form_builder)
+ link_to_function 'add name' do |page|
+ form_builder.fields_for :external_author_names, ExternalAuthorName.new, child_index: 'NEW_RECORD' do |f|
+ html = render(partial: 'external_author_name', locals: { form: f })
+ page << "$('external_author_names').insert({ bottom: '#{escape_javascript(html)}'.replace(/NEW_RECORD/g, new Date().getTime()) });"
+ end
+ end
+ end
+
+
+ def remove_name_link(form_builder)
+ if form_builder.object.new_record?
+ # If the task is a new record, we can just remove the div from the dom
+ link_to_function("remove", "$(this).up('.external_author_name').remove();");
+ else
+ # However if it's a "real" record it has to be deleted from the database,
+ # for this reason the new fields_for, accept_nested_attributes helpers give us _delete,
+ # a virtual attribute that tells rails to delete the child record.
+ form_builder.hidden_field("_destroy") +
+ link_to_function("remove", "$(this).up('.external_author_name').hide(); $(this).previous().value = '1'")
+ end
+ end
+
+end
diff --git a/app/helpers/home_helper.rb b/app/helpers/home_helper.rb
new file mode 100755
index 0000000..55e91cd
--- /dev/null
+++ b/app/helpers/home_helper.rb
@@ -0,0 +1,114 @@
+module HomeHelper
+ def html_to_text(string)
+ string.gsub!(/ /, "\n")
+ string.gsub!(/<\/?p>/, "\n\n")
+ string = strip_tags(string)
+ string.gsub!(/^[ \t]*/, "")
+ while !string.gsub!(/\n\s*\n\s*\n/, "\n\n").nil?
+ # keep going
+ end
+ return string
+ end
+
+ # A TOC section has an h4 header, p with intro link, and ol of subsections.
+ def tos_table_of_contents_section(action)
+ return unless %w[content privacy tos].include?(action)
+
+ content = tos_section_header(action) + tos_section_intro_link(action) + tos_subsection_list(action)
+ # If we're on /tos, /content, or /privacy, use the details tag to make
+ # sections expandable and collapsable.
+ if controller.controller_name == "home"
+ # Use the open attribute to make the page's corresponding section expanded
+ # by default.
+ content_tag(:details, content, open: controller.action_name == action)
+ else
+ content
+ end
+ end
+
+ private
+
+ def tos_section_header(action)
+ # If we're on /tos, /content, or /privacy, the corresponding section header
+ # gets extra text indicating it is the current section.
+ text = if controller.controller_name == "home" && controller.action_name == action
+ t("home.tos_toc.#{action}.header_current")
+ else
+ t("home.tos_toc.#{action}.header")
+ end
+ heading = content_tag(:h4, text, class: "heading")
+ # If we're on /tos, /content, or /privacy, use a summary tag around the h4
+ # so it serves as the toggle to expand or collapse its section.
+ if controller.controller_name == "home"
+ content_tag(:summary, heading)
+ else
+ heading
+ end
+ end
+
+ def tos_section_intro_link(action)
+ content_tag(:p, link_to(t("home.tos_toc.#{action}.intro"), tos_anchor_url(action, action)))
+ end
+
+ def tos_subsection_list(action)
+ items = case action
+ when "content"
+ content_policy_subsection_items
+ when "privacy"
+ privacy_policy_subsection_items
+ when "tos"
+ tos_subsection_items
+ end
+ content_tag(:ol, items.html_safe, style: "list-style-type: upper-alpha;")
+ end
+
+ # When we are on the /signup page, the entire TOS is displayed. This lets us
+ # make sure that page only uses plain anchors in its TOC while the /tos,
+ # /content, nad /privacy pages (found in the home controller) sometimes
+ # point to other pages.
+ def tos_anchor_url(action, anchor)
+ if controller.controller_name == "home"
+ url_for(only_path: true, action: action, anchor: anchor)
+ else
+ "##{anchor}"
+ end
+ end
+
+ def content_policy_subsection_items
+ content_tag(:li, link_to(t("home.tos_toc.content.offensive_content"), tos_anchor_url("content", "II.A"))) +
+ content_tag(:li, link_to(t("home.tos_toc.content.fanworks"), tos_anchor_url("content", "II.B"))) +
+ content_tag(:li, link_to(t("home.tos_toc.content.commercial_promotion"), tos_anchor_url("content", "II.C"))) +
+ content_tag(:li, link_to(t("home.tos_toc.content.copyright_infringement"), tos_anchor_url("content", "II.D"))) +
+ content_tag(:li, link_to(t("home.tos_toc.content.plagiarism"), tos_anchor_url("content", "II.E"))) +
+ content_tag(:li, link_to(t("home.tos_toc.content.personal_information_and_fannish_identities"), tos_anchor_url("content", "II.F"))) +
+ content_tag(:li, link_to(t("home.tos_toc.content.impersonation"), tos_anchor_url("content", "II.G"))) +
+ content_tag(:li, link_to(t("home.tos_toc.content.harassment"), tos_anchor_url("content", "II.H"))) +
+ content_tag(:li, link_to(t("home.tos_toc.content.user_icons"), tos_anchor_url("content", "II.I"))) +
+ content_tag(:li, link_to(t("home.tos_toc.content.mandatory_tags"), tos_anchor_url("content", "II.J"))) +
+ content_tag(:li, link_to(t("home.tos_toc.content.illegal_and_inappropriate_content"), tos_anchor_url("content", "II.K")))
+ end
+
+ def privacy_policy_subsection_items
+ content_tag(:li, link_to(t("home.tos_toc.privacy.applicability"), tos_anchor_url("privacy", "III.A"))) +
+ content_tag(:li, link_to(t("home.tos_toc.privacy.scope_of_personal_information_we_process"), tos_anchor_url("privacy", "III.B"))) +
+ content_tag(:li, link_to(t("home.tos_toc.privacy.types_of_personal_information_we_collect_and_process"), tos_anchor_url("privacy", "III.C"))) +
+ content_tag(:li, link_to(t("home.tos_toc.privacy.aggregate_and_anonymous_information"), tos_anchor_url("privacy", "III.D"))) +
+ content_tag(:li, link_to(t("home.tos_toc.privacy.your_rights_under_applicable_data_privacy_laws"), tos_anchor_url("privacy", "III.E"))) +
+ content_tag(:li, link_to(t("home.tos_toc.privacy.information_shared_with_third_parties"), tos_anchor_url("privacy", "III.F"))) +
+ content_tag(:li, link_to(t("home.tos_toc.privacy.termination_of_account"), tos_anchor_url("privacy", "III.G"))) +
+ content_tag(:li, link_to(t("home.tos_toc.privacy.retention_of_personal_information"), tos_anchor_url("privacy", "III.H"))) +
+ content_tag(:li, link_to(t("home.tos_toc.privacy.contact_us"), tos_anchor_url("privacy", "III.I")))
+ end
+
+ def tos_subsection_items
+ content_tag(:li, link_to(t("home.tos_toc.tos.general_terms"), tos_anchor_url("tos", "I.A"))) +
+ content_tag(:li, link_to(t("home.tos_toc.tos.updates_to_the_tos"), tos_anchor_url("tos", "I.B"))) +
+ content_tag(:li, link_to(t("home.tos_toc.tos.potential_problems"), tos_anchor_url("tos", "I.C"))) +
+ content_tag(:li, link_to(t("home.tos_toc.tos.content_you_access"), tos_anchor_url("tos", "I.D"))) +
+ content_tag(:li, link_to(t("home.tos_toc.tos.what_we_do_with_content"), tos_anchor_url("tos", "I.E"))) +
+ content_tag(:li, link_to(t("home.tos_toc.tos.what_you_cant_do"), tos_anchor_url("tos", "I.F"))) +
+ content_tag(:li, link_to(t("home.tos_toc.tos.registration_and_email_addresses"), tos_anchor_url("tos", "I.G"))) +
+ content_tag(:li, link_to(t("home.tos_toc.tos.age_policy"), tos_anchor_url("tos", "I.H"))) +
+ content_tag(:li, link_to(t("home.tos_toc.tos.abuse_policy"), tos_anchor_url("tos", "I.I")))
+ end
+end
diff --git a/app/helpers/inbox_helper.rb b/app/helpers/inbox_helper.rb
new file mode 100755
index 0000000..d19915c
--- /dev/null
+++ b/app/helpers/inbox_helper.rb
@@ -0,0 +1,20 @@
+module InboxHelper
+ # Describes commentable - used on inbox show page
+ def commentable_description_link(comment)
+ commentable = comment.ultimate_parent
+ return ts("Deleted Object") if commentable.blank?
+
+ if commentable.is_a?(Tag)
+ link_to commentable.name, tag_comment_path(commentable, comment)
+ elsif commentable.is_a?(AdminPost)
+ link_to commentable.title, admin_post_comment_path(commentable, comment)
+ elsif commentable.chaptered?
+ link_to t("inbox_helper.comment_link_with_chapter_number", position: comment.parent.position, title: commentable.title), work_comment_path(commentable, comment)
+ else
+ link_to commentable.title, work_comment_path(commentable, comment)
+ end
+ end
+
+ # get_commenter_pseud_or_name can be found in comments_helper
+
+end
diff --git a/app/helpers/invitations_helper.rb b/app/helpers/invitations_helper.rb
new file mode 100755
index 0000000..3d30d9b
--- /dev/null
+++ b/app/helpers/invitations_helper.rb
@@ -0,0 +1,26 @@
+module InvitationsHelper
+ def creator_link(invitation)
+ case invitation.creator
+ when User
+ link_to(invitation.creator.login, invitation.creator)
+ when Admin
+ invitation.creator.login
+ else
+ t("invitations.invitation.queue")
+ end
+ end
+
+ def invitee_link(invitation)
+ return unless invitation.invitee_type == "User"
+
+ if User.current_user.is_a?(Admin) && policy(invitation).access_invitee_details?
+ return t("invitations.invitation.user_id_deleted", user_id: invitation.invitee_id) if invitation.invitee.blank?
+
+ return link_to(invitation.invitee.login, admin_user_path(invitation.invitee))
+ end
+
+ return t("invitations.invitation.deleted_user") if invitation.invitee.blank?
+
+ link_to(invitation.invitee.login, invitation.invitee) if invitation.invitee.present?
+ end
+end
diff --git a/app/helpers/kudos_helper.rb b/app/helpers/kudos_helper.rb
new file mode 100755
index 0000000..dd1c01b
--- /dev/null
+++ b/app/helpers/kudos_helper.rb
@@ -0,0 +1,52 @@
+module KudosHelper
+ # Returns a comma-separated list of kudos. Restricts the list to the first
+ # ArchiveConfig.MAX_KUDOS_TO_SHOW entries, with a link to view more.
+ #
+ # When showing_more is true, returns a list with a connector at the front,
+ # so that it can be appended to an existing list to make a longer list.
+ # Otherwise, returns a normal-looking list.
+ def kudos_user_links(commentable, kudos, showing_more: true)
+ kudos = kudos.order(id: :desc)
+
+ total_count = kudos.count
+ collapsed_count = total_count - ArchiveConfig.MAX_KUDOS_TO_SHOW
+ kudos_to_display = kudos.limit(ArchiveConfig.MAX_KUDOS_TO_SHOW).to_a
+
+ kudos_links = kudos_to_display.map do |kudo|
+ link_to kudo.user.login, kudo.user
+ end
+
+ # Make sure to duplicate the hash returned by I18n.translate, because
+ # otherwise I18n will start returning our modified version:
+ connectors = t("support.array").dup
+
+ if showing_more
+ # Make a connector appear at the front:
+ kudos_links.unshift("")
+
+ # Even if it looks like there are only two items, we're actually just
+ # showing the last part of a longer list, so we should always use the
+ # last_word_connector instead of the two_words_connector:
+ connectors[:two_words_connector] = connectors[:last_word_connector]
+ end
+
+ if collapsed_count.positive?
+ # Add the link to show more at the end of the list:
+ kudos_links << link_to(
+ t("kudos.user_links.more_link", count: collapsed_count),
+ work_kudos_path(commentable, before: kudos_to_display.last.id),
+ id: "kudos_more_link", remote: true
+ )
+
+ # Regardless of whether we're showing 2 or 3+, we need to wrap the last
+ # connector in a span with the id "kudos_more_connector" so that we can
+ # remove/alter it later:
+ %i[two_words_connector last_word_connector].each do |connector_type|
+ connectors[connector_type] = tag.span(connectors[connector_type],
+ id: "kudos_more_connector")
+ end
+ end
+
+ kudos_links.to_sentence(connectors).html_safe
+ end
+end
diff --git a/app/helpers/language_helper.rb b/app/helpers/language_helper.rb
new file mode 100755
index 0000000..d1d036b
--- /dev/null
+++ b/app/helpers/language_helper.rb
@@ -0,0 +1,39 @@
+RTL_LOCALES = %w[ar fa he].freeze
+
+module LanguageHelper
+ def available_faq_locales
+ ArchiveFaq.translated_locales.map { |code| Locale.find_by(iso: code) }
+ end
+
+ def rtl?
+ RTL_LOCALES.include?(Globalize.locale.to_s)
+ end
+
+ def rtl_language?(language)
+ RTL_LOCALES.include?(language.short)
+ end
+
+ def english?
+ params[:language_id] == "en"
+ end
+
+ def translated_questions(all_questions)
+ questions = []
+ all_questions.each do |question|
+ question.translations.each do |translation|
+ if translation.is_translated == "1" && params[:language_id].to_s == translation.locale.to_s
+ questions << question
+ end
+ end
+ end
+ questions
+ end
+
+ def language_options_for_select(languages, value_attribute)
+ languages.map { |language| [language.name, language[value_attribute], { lang: language.short }] }
+ end
+
+ def locale_options_for_select(locales, value_attribute)
+ locales.map { |locale| [locale.name, locale[value_attribute], { lang: locale.language.short }] }
+ end
+end
diff --git a/app/helpers/mailer_helper.rb b/app/helpers/mailer_helper.rb
new file mode 100755
index 0000000..c656a4e
--- /dev/null
+++ b/app/helpers/mailer_helper.rb
@@ -0,0 +1,311 @@
+module MailerHelper
+
+ def style_bold(text)
+ ("" + "#{text}".html_safe + "").html_safe
+ end
+
+ def style_link(body, url, html_options = {})
+ html_options[:style] = "color:#990000"
+ link_to(body.html_safe, url, html_options)
+ end
+
+ def style_role(text)
+ tag.em(tag.strong(text))
+ end
+
+ # For work, chapter, and series links
+ def style_creation_link(title, url, html_options = {})
+ html_options[:style] = "color:#990000"
+ ("" + link_to(title.html_safe, url, html_options) + "").html_safe
+ end
+
+ # For work, chapter, and series titles
+ def style_creation_title(title)
+ ("" + title.html_safe + "").html_safe
+ end
+
+ def style_footer_link(body, url, html_options = {})
+ html_options[:style] = "color:#FFFFFF"
+ link_to(body.html_safe, url, html_options)
+ end
+
+ def style_email(email, name = nil, html_options = {})
+ html_options[:style] = "color:#990000"
+ mail_to(email, name.nil? ? nil : name.html_safe, html_options)
+ end
+
+ def style_pseud_link(pseud)
+ style_link("" +
+ pseud.byline, user_pseud_url(pseud.user, pseud))
+ end
+
+ def text_pseud(pseud)
+ pseud.byline + " (#{user_pseud_url(pseud.user, pseud)})"
+ end
+
+ def style_quote(text)
+ ("
" + text + "
").html_safe
+ end
+
+ def support_link(text)
+ style_link(text, root_url + "support")
+ end
+
+ def abuse_link(text)
+ style_link(text, root_url + "abuse_reports/new")
+ end
+
+ def tos_link(text)
+ style_link(text, tos_url)
+ end
+
+ def opendoors_link(text)
+ style_link(text, "https://opendoors.transformativeworks.org/contact-open-doors/")
+ end
+
+ def styled_divider
+ ("
" +
+ " " +
+ "" +
+ "
").html_safe
+ end
+
+ def text_divider
+ "--------------------"
+ end
+
+ # strip opening paragraph tags, and line breaks or close-pargraphs at the end of the string
+ # all other close-paragraphs become double line breaks
+ # line break tags become single line breaks
+ # bold text is wrapped in *
+ # italic text is wrapped in /
+ # underlined text is wrapped in _
+ # all other html tags are stripped
+ def to_plain_text(html)
+ strip_tags(
+ html.gsub(/
|<\/p>\z| \z/, "")
+ .gsub(/<\/p>/, "\n\n")
+ .gsub(/ /, "\n")
+ .gsub(/<\/?(b|em|strong)>/, "*")
+ .gsub(/<\/?(i|cite)>/, "/")
+ .gsub(/<\/?u>/, "_")
+ )
+ end
+
+ # Reformat a string as HTML with tags instead of newlines, but with all
+ # other HTML escaped.
+ # This is used for collection.assignment_notification, which already strips
+ # HTML tags (when saving the collection settings, the params are sanitized),
+ # but that still leaves other HTML entities.
+ def escape_html_and_create_linebreaks(html)
+ # Escape each line with h(), then join with s and mark as html_safe to
+ # ensure that the s aren't escaped.
+ html.split("\n").map { |line_of_text| h(line_of_text) }.join(' ').html_safe
+ end
+
+ # The title used in creatorship_notification and creatorship_request
+ # emails.
+ def creation_title(creation)
+ if creation.is_a?(Chapter)
+ t("mailer.general.creation.title_with_chapter_number",
+ position: creation.position, title: creation.work.title)
+ else
+ creation.title
+ end
+ end
+
+ # e.g., Title (x words), where Title is a link
+ def creation_link_with_word_count(creation, creation_url)
+ title = if creation.is_a?(Chapter)
+ creation.full_chapter_title.html_safe
+ else
+ creation.title.html_safe
+ end
+ t("mailer.general.creation.link_with_word_count",
+ creation_link: style_creation_link(title, creation_url),
+ word_count: creation_word_count(creation)).html_safe
+ end
+
+ # e.g., "Title" (x words), where Title is not a link
+ def creation_title_with_word_count(creation)
+ title = if creation.is_a?(Chapter)
+ creation.full_chapter_title.html_safe
+ else
+ creation.title.html_safe
+ end
+ t("mailer.general.creation.title_with_word_count",
+ creation_title: title, word_count: creation_word_count(creation))
+ end
+
+ # The bylines used in subscription emails to prevent exposing the name(s) of
+ # anonymous creator(s).
+ def creator_links(work)
+ if work.anonymous?
+ "Anonymous"
+ else
+ work.pseuds.map { |p| style_pseud_link(p) }.to_sentence.html_safe
+ end
+ end
+
+ def creator_text(work)
+ if work.anonymous?
+ "Anonymous"
+ else
+ work.pseuds.map { |p| text_pseud(p) }.to_sentence.html_safe
+ end
+ end
+
+ def metadata_label(text)
+ text.html_safe + t("mailer.general.metadata_label_indicator")
+ end
+
+ # Spacing is dealt with in locale files, e.g. " : " for French.
+ def work_tag_metadata(tags)
+ return if tags.empty?
+
+ "#{work_tag_metadata_label(tags)}#{work_tag_metadata_list(tags)}"
+ end
+
+ def style_metadata_label(text)
+ style_bold(metadata_label(text))
+ end
+
+ # Spacing is dealt with in locale files, e.g. " : " for French.
+ def style_work_tag_metadata(tags)
+ return if tags.empty?
+
+ label = style_bold(work_tag_metadata_label(tags))
+ "#{label}#{style_work_tag_metadata_list(tags)}".html_safe
+ end
+
+ def commenter_pseud_or_name_link(comment)
+ return style_bold(t("roles.anonymous_creator")) if comment.by_anonymous_creator?
+
+ if comment.comment_owner.nil?
+ t("roles.commenter_name.html", name: style_bold(comment.comment_owner_name), role_with_parens: style_role(t("roles.guest_with_parens")))
+ else
+ role = comment.user.official ? t("roles.official_with_parens") : t("roles.registered_with_parens")
+ pseud_link = style_link(comment.pseud.byline, user_pseud_url(comment.user, comment.pseud))
+ t("roles.commenter_name.html", name: tag.strong(pseud_link), role_with_parens: style_role(role))
+ end
+ end
+
+ def commenter_pseud_or_name_text(comment)
+ return t("roles.anonymous_creator") if comment.by_anonymous_creator?
+
+ if comment.comment_owner.nil?
+ t("roles.commenter_name.text", name: comment.comment_owner_name, role_with_parens: t("roles.guest_with_parens"))
+ else
+ role = comment.user.official ? t("roles.official_with_parens") : t("roles.registered_with_parens")
+ t("roles.commenter_name.text", name: text_pseud(comment.pseud), role_with_parens: role)
+ end
+ end
+
+ def content_for_commentable_text(comment)
+ if comment.ultimate_parent.is_a?(Tag)
+ t(".content.tag.text",
+ pseud: commenter_pseud_or_name_text(comment),
+ tag: comment.ultimate_parent.commentable_name,
+ tag_url: tag_url(comment.ultimate_parent))
+ elsif comment.parent.is_a?(Chapter) && comment.ultimate_parent.chaptered?
+ if comment.parent.title.blank?
+ t(".content.chapter.untitled_text",
+ pseud: commenter_pseud_or_name_text(comment),
+ chapter_position: comment.parent.position,
+ work: comment.ultimate_parent.commentable_name,
+ chapter_url: work_chapter_url(comment.parent.work, comment.parent))
+ else
+ t(".content.chapter.titled_text",
+ pseud: commenter_pseud_or_name_text(comment),
+ chapter_position: comment.parent.position,
+ chapter_title: comment.parent.title,
+ work: comment.ultimate_parent.commentable_name,
+ chapter_url: work_chapter_url(comment.parent.work, comment.parent))
+ end
+ else
+ t(".content.other.text",
+ pseud: commenter_pseud_or_name_text(comment),
+ title: comment.ultimate_parent.commentable_name,
+ commentable_url: polymorphic_url(comment.ultimate_parent))
+ end
+ end
+
+ def content_for_commentable_html(comment)
+ if comment.ultimate_parent.is_a?(Tag)
+ t(".content.tag.html",
+ pseud_link: commenter_pseud_or_name_link(comment),
+ tag_link: style_link(comment.ultimate_parent.commentable_name, tag_url(comment.ultimate_parent)))
+ elsif comment.parent.is_a?(Chapter) && comment.ultimate_parent.chaptered?
+ t(".content.chapter.html",
+ pseud_link: commenter_pseud_or_name_link(comment),
+ chapter_link: style_link(comment.parent.title.blank? ? t(".chapter.untitled", position: comment.parent.position) : t(".chapter.titled", position: comment.parent.position, title: comment.parent.title), work_chapter_url(comment.parent.work, comment.parent)),
+ work_link: style_creation_link(comment.ultimate_parent.commentable_name, work_url(comment.parent.work)))
+ else
+ t(".content.other.html",
+ pseud_link: commenter_pseud_or_name_link(comment),
+ commentable_link: style_creation_link(comment.ultimate_parent.commentable_name, polymorphic_url(comment.ultimate_parent)))
+ end
+ end
+
+ def collection_footer_note_html(is_collection_email, collection)
+ if is_collection_email
+ t("mailer.collections.why_collection_email.html",
+ collection_link: style_footer_link(collection.title, collection_url(collection)))
+ else
+ t("mailer.collections.why_maintainer.html",
+ collection_link: style_footer_link(collection.title, collection_url(collection)))
+ end
+ end
+
+ def collection_footer_note_text(is_collection_email, collection)
+ if is_collection_email
+ t("mailer.collections.why_collection_email.text",
+ collection_title: collection.title,
+ collection_url: collection_url(collection))
+ else
+ t("mailer.collections.why_maintainer.text",
+ collection_title: collection.title,
+ collection_url: collection_url(collection))
+ end
+ end
+
+ private
+
+ # e.g., 1 word or 50 words
+ def creation_word_count(creation)
+ t("mailer.general.creation.word_count", count: creation.word_count)
+ end
+
+ def work_tag_metadata_label(tags)
+ return if tags.empty?
+
+ # i18n-tasks-use t('activerecord.models.archive_warning')
+ # i18n-tasks-use t('activerecord.models.character')
+ # i18n-tasks-use t('activerecord.models.fandom')
+ # i18n-tasks-use t('activerecord.models.freeform')
+ # i18n-tasks-use t('activerecord.models.rating')
+ # i18n-tasks-use t('activerecord.models.relationship')
+ type = tags.first.type
+ t("activerecord.models.#{type.underscore}", count: tags.count) + t("mailer.general.metadata_label_indicator")
+ end
+
+ # We don't use .to_sentence because these aren't links and we risk making any
+ # connector word (e.g., "and") look like part of the final tag.
+ def work_tag_metadata_list(tags)
+ return if tags.empty?
+
+ tags.pluck(:name).join(t("support.array.words_connector"))
+ end
+
+ def style_work_tag_metadata_list(tags)
+ return if tags.empty?
+
+ type = tags.first.type
+ # Fandom tags are linked and to_sentence'd.
+ if type == "Fandom"
+ tags.map { |f| style_link(f.name, fandom_url(f)) }.to_sentence.html_safe
+ else
+ work_tag_metadata_list(tags)
+ end
+ end
+end # end of MailerHelper
diff --git a/app/helpers/mute_helper.rb b/app/helpers/mute_helper.rb
new file mode 100755
index 0000000..688dc9f
--- /dev/null
+++ b/app/helpers/mute_helper.rb
@@ -0,0 +1,42 @@
+module MuteHelper
+ def mute_link(user, mute: nil)
+ if mute.nil?
+ mute = user.mute_by_current_user
+ muting_user = current_user
+ else
+ muting_user = mute.muter
+ end
+
+ if mute
+ link_to(t("muted.unmute"), confirm_unmute_user_muted_user_path(muting_user, mute))
+ else
+ link_to(t("muted.mute"), confirm_mute_user_muted_users_path(muting_user, muted_id: user))
+ end
+ end
+
+ def mute_css
+ return if current_user.nil?
+
+ Rails.cache.fetch(mute_css_key(current_user)) do
+ mute_css_uncached(current_user)
+ end
+ end
+
+ def mute_css_uncached(user)
+ user.reload
+
+ return if user.muted_users.empty?
+
+ css_classes = user.muted_users.map { |muted_user| ".user-#{muted_user.id}" }.join(", ")
+
+ "".html_safe
+ end
+
+ def mute_css_key(user)
+ "muted/#{user.id}/mute_css"
+ end
+
+ def user_has_muted_users?
+ !current_user.muted_users.empty? if current_user
+ end
+end
diff --git a/app/helpers/number_helper.rb b/app/helpers/number_helper.rb
new file mode 100755
index 0000000..76ab708
--- /dev/null
+++ b/app/helpers/number_helper.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module NumberHelper
+ # Converts a precise number to an approximate with no more than 4 digits.
+ #
+ # @example
+ # estimate_number(2) # 2
+ # estimate_number(25) # 25
+ # estimate_number(308) # 308
+ # estimate_number(1234) # 1234
+ # estimate_number(120345) # 120300
+ def estimate_number(number)
+ digits = [(Math.log10([number, 1].max).to_i - 3), 0].max
+ divide = 10**digits
+ divide * (number / divide).to_i
+ end
+end
diff --git a/app/helpers/orphans_helper.rb b/app/helpers/orphans_helper.rb
new file mode 100755
index 0000000..deb3175
--- /dev/null
+++ b/app/helpers/orphans_helper.rb
@@ -0,0 +1,15 @@
+module OrphansHelper
+
+ # Renders the appropriate partial based on the class of object to be orphaned
+ def render_orphan_partial(to_be_orphaned)
+ if to_be_orphaned.is_a?(Series)
+ render 'orphans/orphan_series', series: to_be_orphaned
+ elsif to_be_orphaned.is_a?(Pseud)
+ render 'orphans/orphan_pseud', pseud: to_be_orphaned
+ elsif to_be_orphaned.is_a?(User)
+ render 'orphans/orphan_user', user: to_be_orphaned
+ else # either a single work or an array of works
+ render 'orphans/orphan_work', works: [to_be_orphaned].flatten
+ end
+ end
+end
diff --git a/app/helpers/pagination_helper.rb b/app/helpers/pagination_helper.rb
new file mode 100755
index 0000000..cee1c0b
--- /dev/null
+++ b/app/helpers/pagination_helper.rb
@@ -0,0 +1,66 @@
+module PaginationHelper
+ include Pagy::Frontend
+
+ # change the default link renderer for will_paginate
+ def will_paginate(collection_or_options = nil, options = {})
+ if collection_or_options.is_a? Hash
+ options = collection_or_options
+ collection_or_options = nil
+ end
+ options = options.merge renderer: PaginationListLinkRenderer unless options[:renderer]
+ super(*[collection_or_options, options].compact)
+ end
+
+ # Cf https://github.com/ddnexus/pagy/blob/master/gem/lib/pagy/frontend.rb
+ # i18n-tasks-use t("pagy.prev")
+ # i18n-tasks-use t("pagy.next")
+ # i18n-tasks-use t("pagy.aria_label.nav")
+ def pagy_nav(pagy, id: nil, aria_label: nil, **vars)
+ return nil unless pagy
+
+ # Keep will_paginate behavior of showing nothing if only one page
+ return nil if pagy.series.length <= 1
+
+ id = %( id="#{id}") if id
+ a = pagy_anchor(pagy, **vars)
+
+ html = %(
#{t('a11y.navigation')}
)
+
+ html << %()
+
+ prev_text = pagy_t("pagy.prev")
+ prev_a =
+ if (p_prev = pagy.prev)
+ a.call(p_prev, prev_text)
+ else
+ %(#{prev_text})
+ end
+ html << %(
#{prev_a}
)
+
+ pagy.series(**vars).each do |item| # series example: [1, :gap, 7, 8, "9", 10, 11, :gap, 36]
+ html << %(
)
+ html << case item
+ when Integer
+ a.call(item)
+ when String
+ %(#{pagy.label_for(item)})
+ when :gap
+ %(#{pagy_t('pagy.gap')})
+ else
+ raise InternalError, "expected item types in series to be Integer, String or :gap; got #{item.inspect}"
+ end
+ html << %(
)
+ end
+
+ next_text = pagy_t("pagy.next")
+ next_a =
+ if (p_next = pagy.next)
+ a.call(p_next, next_text)
+ else
+ %(#{next_text})
+ end
+ html << %(
#{next_a}
)
+
+ html << %()
+ end
+end
diff --git a/app/helpers/prompt_restrictions_helper.rb b/app/helpers/prompt_restrictions_helper.rb
new file mode 100755
index 0000000..947e567
--- /dev/null
+++ b/app/helpers/prompt_restrictions_helper.rb
@@ -0,0 +1,76 @@
+module PromptRestrictionsHelper
+ def prompt_restriction_settings(form, include_description = false, allowany, hasprompts)
+
+ result = "".html_safe
+ result += content_tag(:dt, form.label(:optional_tags_allowed, ts("Optional Tags?")) +
+ link_to_help("challenge-optional-tags"))
+ result += content_tag(:dd, form.check_box(:optional_tags_allowed, disabled: (hasprompts ? false : true)))
+
+ result += content_tag(:dt, form.label(:title_allowed, ts("Title:")))
+ result += required_and_allowed_boolean(form, "title", hasprompts)
+
+ result += content_tag(:dt, form.label(:description_allowed, ts("Details/Description:")))
+ result += required_and_allowed_boolean(form, "description", hasprompts)
+
+ result += content_tag(:dt, form.label(:url_required, ts("URL:")))
+ result += required_and_allowed_boolean(form, "url", hasprompts)
+
+ result += content_tag(:dt, form.label(:fandom_num_required, ts("Fandom(s):")))
+ result += required_and_allowed(form, "fandom", hasprompts, allowany)
+
+ result += content_tag(:dt, form.label(:character_num_required, ts("Character(s):")))
+ result += required_and_allowed(form, "character", hasprompts, allowany)
+
+ result += content_tag(:dt, form.label(:relationship_num_required, ts("Relationship(s):")))
+ result += required_and_allowed(form, "relationship", hasprompts, allowany)
+
+ result += content_tag(:dt, form.label(:rating_num_required, ts("Rating(s):")))
+ result += required_and_allowed(form, "rating", hasprompts, allowany)
+
+ result += content_tag(:dt, form.label(:category_num_required, ts("Categories:")) +
+ link_to_help("challenge-category-tags"))
+ result += required_and_allowed(form, "category", hasprompts, allowany)
+
+ result += content_tag(:dt, form.label(:freeform_num_required, ts("Additional tag(s):")))
+ result += required_and_allowed(form, "freeform", hasprompts, allowany)
+
+ result += content_tag(:dt, form.label(:archive_warning_num_required, ts("Archive Warning(s):")))
+ result += required_and_allowed(form, "archive_warning", hasprompts, allowany)
+ end
+
+ def required_and_allowed_boolean(form, fieldname, hasprompts)
+ content_tag(:dd, ("Required: " + form.check_box( ("#{fieldname}_required").to_sym, disabled: (hasprompts ? false : true)) +
+ " Allowed: " + form.check_box( ("#{fieldname}_allowed").to_sym, disabled: (hasprompts ? false : true)) ).html_safe )
+ end
+
+ def required_and_allowed(form, tag_type, hasprompts, allowany)
+ fields = "Required: " + form.text_field( ("#{tag_type}_num_required").to_sym, disabled: (hasprompts ? false : true), class: "number" )
+ fields += " Allowed: " + form.text_field( ("#{tag_type}_num_allowed").to_sym, disabled: (hasprompts ? false : true), class: "number" )
+ if TagSet::TAG_TYPES.include?(tag_type)
+ if allowany
+ fields += label_tag field_id(form, "allow_any_#{tag_type}") do
+ h(ts("Allow Any")) + form.check_box("allow_any_#{tag_type}".to_sym, disabled: (hasprompts ? false : true))
+ end
+ else
+ form.hidden_field :"allow_any_#{tag_type}".to_sym, value: false
+ end
+ fields += label_tag field_id(form, "require_unique_#{tag_type}") do
+ h(ts("Must Be Unique?")) + form.check_box("require_unique_#{tag_type}".to_sym, disabled: (hasprompts ? false : true))
+ end
+ end
+ content_tag(:dd, fields.html_safe, class: "complex", title: ts(tag_type_label_name(tag_type).pluralize.downcase.to_s)) + "\n".html_safe
+ end
+
+ # generate the string to use for the labels on sign-up forms
+ def challenge_signup_label(tag_name, num_allowed, num_required)
+ if num_required > 0 && (num_allowed > num_required)
+ "#{((num_allowed > 1) ? tag_name.pluralize : tag_name).titleize} (#{num_required} - #{num_allowed}): *"
+ elsif num_required > 0 && (num_allowed == num_required)
+ "#{((num_allowed > 1) ? tag_name.pluralize : tag_name).titleize} (#{num_required}): *"
+ elsif num_allowed > 0
+ "#{((num_allowed > 1) ? tag_name.pluralize : tag_name).titleize} (#{num_required} - #{num_allowed}):"
+ else
+ "#{tag_name.titleize}:"
+ end
+ end
+end
diff --git a/app/helpers/pseuds_helper.rb b/app/helpers/pseuds_helper.rb
new file mode 100755
index 0000000..997023d
--- /dev/null
+++ b/app/helpers/pseuds_helper.rb
@@ -0,0 +1,51 @@
+module PseudsHelper
+ # Returns a list of pseuds, with links to each pseud.
+ #
+ # Used on Profile page, and by ProfileController#pseuds.
+ #
+ # The pseuds argument should be a single page of the user's pseuds, generated
+ # by calling user.pseuds.paginate(page: 1) or similar. This allows us to
+ # insert a remote link to dynamically insert the next page of pseuds.
+ def print_pseud_list(user, pseuds, first: true)
+ links = pseuds.map do |pseud|
+ link_to(pseud.name, [user, pseud])
+ end
+
+ difference = pseuds.total_entries - pseuds.length - pseuds.offset
+
+ if difference.positive?
+ links << link_to(
+ t("profile.pseud_list.more_pseuds", count: difference),
+ pseuds_user_profile_path(user, page: pseuds.next_page),
+ remote: true, id: "more_pseuds"
+ )
+ end
+
+ more_pseuds_connector = tag.span(
+ t("support.array.last_word_connector"),
+ id: "more_pseuds_connector"
+ )
+
+ if first
+ to_sentence(links,
+ last_word_connector: more_pseuds_connector)
+ else
+ links.unshift("")
+ to_sentence(links,
+ last_word_connector: more_pseuds_connector,
+ two_words_connector: more_pseuds_connector)
+ end
+ end
+
+ def pseuds_for_sidebar(user, pseud)
+ pseuds = user.pseuds.abbreviated_list - [pseud]
+ pseuds = pseuds.sort
+ pseuds = [pseud] + pseuds if pseud && !pseud.new_record?
+ pseuds
+ end
+
+ # used in the sidebar
+ def pseud_selector(pseuds)
+ pseuds.collect { |pseud| "
" }.join.html_safe
+ end
+end
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
new file mode 100755
index 0000000..dcb9be9
--- /dev/null
+++ b/app/helpers/search_helper.rb
@@ -0,0 +1,64 @@
+module SearchHelper
+
+ # modified from mislav-will_paginate-2.3.2/lib/will_paginate/view_helpers.rb
+ def search_header(collection, search, item_name, parent=nil)
+ header = []
+ if !collection.respond_to?(:total_pages)
+ header << ts("Recent #{item_name.pluralize}")
+ elsif collection.total_pages < 2
+ header << pluralize(collection.total_entries, item_name)
+ else
+ total_entries = collection.total_entries
+ total_entries = collection.unlimited_total_entries if collection.respond_to?(:unlimited_total_entries)
+ header << ts(" %{start_number} - %{end_number} of %{total} %{things}",
+ start_number: number_with_delimiter(collection.offset + 1),
+ end_number: number_with_delimiter(collection.offset + collection.length),
+ total: number_with_delimiter(total_entries),
+ things: item_name.pluralize)
+ end
+ header << ts("found") if search.present? && search.query.present?
+
+ case parent
+ when Collection
+ header << ts("in %{collection_link}", collection_link: link_to(parent.title, parent))
+ when Pseud
+ header << ts("by %{byline}", byline: parent.byline)
+ when User
+ header << ts("by %{username}", username: parent.login)
+ when Language
+ header << ts("in %{language}", language: parent.name)
+ end
+
+ header << ts("in %{tag_link}", tag_link: link_to_tag_with_text(parent, parent.name)) if parent.is_a?(Tag)
+ # The @fandom version is used when following a fandom link from a user's dashboard, which
+ # which will take you to a URL like /users/username/works?fandom_id=123.
+ header << ts("in %{fandom_link}", fandom_link: link_to_tag(@fandom)) if @fandom.present?
+ header.join(" ").html_safe
+ end
+
+ def search_results_found(results)
+ ts("%{count} Found", count: number_with_delimiter(results.unlimited_total_entries))
+ end
+
+ def random_search_tip
+ ArchiveConfig.SEARCH_TIPS[rand(ArchiveConfig.SEARCH_TIPS.size)]
+ end
+
+ def works_original_path
+ url_for(
+ controller: :works,
+ action: :index,
+ only_path: true,
+ **params.slice(:tag_id, :fandom_id, :collection_id, :pseud_id, :user_id).permit!
+ )
+ end
+
+ def bookmarks_original_path
+ url_for(
+ controller: :bookmarks,
+ action: :index,
+ only_path: true,
+ **params.slice(:tag_id, :collection_id, :pseud_id, :user_id).permit!
+ )
+ end
+end
diff --git a/app/helpers/series_helper.rb b/app/helpers/series_helper.rb
new file mode 100755
index 0000000..af77855
--- /dev/null
+++ b/app/helpers/series_helper.rb
@@ -0,0 +1,83 @@
+module SeriesHelper
+
+ def show_series_data(work)
+ series_data = series_data_for_work(work)
+ series_data.join(ArchiveConfig.DELIMITER_FOR_OUTPUT).html_safe
+ end
+
+ # this should only show prev and next works visible to the current user
+ def series_data_for_work(work)
+ series = work.serial_works.map(&:series).compact
+ series.map do |serial|
+ works_in_series = serial.works_in_order.posted.select(
+ # Include only the fields needed to calculate visibility:
+ :id, :restricted, :hidden_by_admin, :posted
+ ).select(&:visible?)
+
+ visible_position = works_in_series.index(work) || works_in_series.length
+ unless !visible_position
+ # Span used at end of previous_link and beginning of next_link to prevent extra
+ # whitespace around main_link if next or previous link is missing. It also allows
+ # us to use CSS to insert a decorative divider
+ divider_span = content_tag(:span, " ", class: "divider")
+ # This is empty if there is no previous work, otherwise it is
+ # Previous Work
+ # with a left-pointing arrow before "Previous"
+ previous_link = if visible_position > 0
+ link_to(ts("← Previous Work").html_safe,
+ works_in_series[visible_position - 1],
+ class: "previous") + divider_span
+ else
+ "".html_safe
+ end
+ # This part is always included
+ # Part # of TITLE
+ main_link = content_tag(:span,
+ ts("Part %{position} of %{series_title}",
+ position: (visible_position + 1).to_s,
+ series_title: link_to(serial.title, serial)).html_safe,
+ class: "position")
+ # This is empty if there is no next work, otherwise it is
+ # Next Work
+ # with a right-pointing arrow after "Work"
+ next_link = if visible_position < works_in_series.size - 1
+ divider_span + link_to(ts("Next Work →").html_safe,
+ works_in_series[visible_position + 1],
+ class: "next")
+ else
+ "".html_safe
+ end
+ # put the parts together and wrap them in
+ content_tag(:span, previous_link + main_link + next_link, class: "series")
+ end
+ end
+ end
+
+ def work_series_description(work, series)
+ serial = SerialWork.where(work_id: work.id, series_id: series.id).first
+ ts("Part %{position} of %{title}".html_safe, position: serial.position, title: link_to(series.title, series))
+ end
+
+ def series_list_for_feeds(work)
+ series = work.series
+ if series.empty?
+ return "None"
+ else
+ list = []
+ for s in series
+ list << ts("Part %{serial_index} of %{link_to_series}", serial_index: s.serial_works.where(work_id: work.id).select(:position).first.position, link_to_series: link_to(s.title, series_url(s)))
+ end
+ return list.join(', ')
+ end
+ end
+
+ # Generates confirmation message for "Remove Me As Co-Creator"
+ def series_removal_confirmation(series, user)
+ if !(series.work_pseuds & user.pseuds).empty?
+ ts("You're listed as a creator of works in this series. Do you want to remove yourself as a creator of this series and all of its works?")
+ else
+ ts("Are you sure you want to be removed as a creator of this series?")
+ end
+ end
+
+end
diff --git a/app/helpers/share_helper.rb b/app/helpers/share_helper.rb
new file mode 100755
index 0000000..73a23f8
--- /dev/null
+++ b/app/helpers/share_helper.rb
@@ -0,0 +1,78 @@
+# Helper for work and bookmark social media sharing code
+module ShareHelper
+ # Get work title, word count, and creators and add app short name,
+ # but do not add formatting so it can be link text for Tumblr sharing.
+ def get_tumblr_embed_link_title(work)
+ title = work.title + " (#{work.word_count} #{ts('words')})"
+ pseud = text_byline(work)
+ "#{title} #{ts("by")} #{pseud} #{ts("[#{ArchiveConfig.APP_SHORT_NAME}]")}"
+ end
+
+ def get_tweet_text(work)
+ if work.unrevealed?
+ ts("Mystery Work")
+ else
+ names = text_byline(work)
+ fandoms = short_fandom_string(work)
+ "#{work.title} by #{names} - #{fandoms}".truncate(95)
+ end
+ end
+
+ def get_tweet_text_for_bookmark(bookmark)
+ return unless bookmark.bookmarkable.is_a?(Work)
+
+ names = bookmark.bookmarkable.creators.to_sentence
+ fandoms = short_fandom_string(bookmark.bookmarkable)
+ "Bookmark of #{bookmark.bookmarkable.title} by #{names} - #{fandoms}".truncate(83)
+ end
+
+ # JavaScript-less share buttons from https://sharingbuttons.io/
+ # We use medium, solid, rectangular buttons.
+ def sharing_button(site, address, text, target: nil)
+ return unless %w[twitter tumblr].include?(site)
+
+ tag.a(
+ tag.div(
+ tag.div(
+ sharing_svg(site),
+ class: "resp-sharing-button__icon resp-sharing-button__icon--solid",
+ aria: { hidden: true }
+ ) + text,
+ class: "resp-sharing-button resp-sharing-button--#{site} resp-sharing-button--medium"
+ ),
+ href: address,
+ target: target,
+ class: "resp-sharing-button__link",
+ aria: { label: text }
+ )
+ end
+
+ private
+
+ def short_fandom_string(work)
+ work.fandoms.size > 2 ? ts("Multifandom") : work.fandom_string
+ end
+
+ # Being able to add line breaks in the sharing templates makes the code
+ # easier to read and edit, but we don't want them in the sharing code itself
+ def remove_newlines(html)
+ html.delete("\n")
+ end
+
+ def sharing_svg(site)
+ return unless %w[twitter tumblr].include?(site)
+
+ path = case site
+ when "twitter"
+ tag.path d: "M23.44 4.83c-.8.37-1.5.38-2.22.02.93-.56.98-.96 1.32-2.02-.88.52-1.86.9-2.9 1.1-.82-.88-2-1.43-3.3-1.43-2.5 0-4.55 2.04-4.55 4.54 0 .36.03.7.1 1.04-3.77-.2-7.12-2-9.36-4.75-.4.67-.6 1.45-.6 2.3 0 1.56.8 2.95 2 3.77-.74-.03-1.44-.23-2.05-.57v.06c0 2.2 1.56 4.03 3.64 4.44-.67.2-1.37.2-2.06.08.58 1.8 2.26 3.12 4.25 3.16C5.78 18.1 3.37 18.74 1 18.46c2 1.3 4.4 2.04 6.97 2.04 8.35 0 12.92-6.92 12.92-12.93 0-.2 0-.4-.02-.6.9-.63 1.96-1.22 2.56-2.14z"
+ when "tumblr"
+ tag.path d: "M13.5.5v5h5v4h-5V15c0 5 3.5 4.4 6 2.8v4.4c-6.7 3.2-12 0-12-4.2V9.5h-3V6.7c1-.3 2.2-.7 3-1.3.5-.5 1-1.2 1.4-2 .3-.7.6-1.7.7-3h3.8z"
+ end
+
+ tag.svg(
+ path,
+ xmlns: "http://www.w3.org/2000/svg",
+ viewBox: "0 0 24 24"
+ )
+ end
+end
diff --git a/app/helpers/skin_cache_helper.rb b/app/helpers/skin_cache_helper.rb
new file mode 100755
index 0000000..7a9c482
--- /dev/null
+++ b/app/helpers/skin_cache_helper.rb
@@ -0,0 +1,28 @@
+module SkinCacheHelper
+ def cache_timestamp
+ Time.now.utc.to_fs(:usec)
+ end
+
+ def skin_cache_version_key(skin_id)
+ skin_id = skin_id.id if skin_id.is_a?(Skin)
+ [:v1, :site_skin, skin_id, :version_key]
+ end
+
+ def skin_cache_version(skin_id)
+ Rails.cache.fetch(skin_cache_version_key(skin_id)) do
+ cache_timestamp
+ end
+ end
+
+ def skin_cache_version_update(skin_id)
+ Rails.cache.write(skin_cache_version_key(skin_id), cache_timestamp)
+ end
+
+ def skin_chooser_key
+ [:v2, :skin_chooser]
+ end
+
+ def skin_chooser_expire_cache
+ ActionController::Base.new.expire_fragment(skin_chooser_key)
+ end
+end
diff --git a/app/helpers/skins_helper.rb b/app/helpers/skins_helper.rb
new file mode 100755
index 0000000..73bd2af
--- /dev/null
+++ b/app/helpers/skins_helper.rb
@@ -0,0 +1,88 @@
+module SkinsHelper
+ def skin_author_link(skin)
+ if skin.author.is_a? User
+ link_to(skin.byline, skin.author)
+ else
+ skin.byline
+ end
+ end
+
+ # we only actually display an image if there's a file
+ def skin_preview_display(skin)
+ return unless skin&.icon&.attached?
+
+ link_to image_tag(rails_blob_url(skin.icon.variant(:standard)),
+ alt: skin.icon_alt_text,
+ class: "icon",
+ skip_pipeline: true),
+ rails_blob_url(skin.icon)
+ end
+
+ # Fetches the current skin. This is determined by the following
+ # 1. Skin ID set by request parameter
+ # 2. Skin ID set in the current session (if someone, a user or admin, is logged in)
+ # 3. Current user's skin preference
+ # 4. The default skin (as set by the active AdminSetting)
+ def current_skin
+ skin = Skin.approved_or_owned_by.usable.find_by(id: params[:site_skin]) if params[:site_skin]
+ skin ||= Skin.approved_or_owned_by.usable.find_by(id: session[:site_skin]) if (logged_in? || logged_in_as_admin?) && session[:site_skin]
+ skin ||= current_user&.preference&.skin
+ skin || AdminSetting.default_skin
+ end
+
+ def skin_tag
+ roles = if logged_in_as_admin?
+ Skin::DEFAULT_ROLES_TO_INCLUDE + ["admin"]
+ else
+ Skin::DEFAULT_ROLES_TO_INCLUDE
+ end
+
+ skin = current_skin
+ return "" unless skin
+
+ # We include the version information for both the skin's id and the
+ # AdminSetting.default_skin_id because the default skin is used in skins of
+ # type "user", so we need to regenerate the cache block when it's modified.
+ #
+ # We also include the default_skin_id in the version number so that we
+ # regenerate the cache block when an admin updates the current default
+ # skin.
+ Rails.cache.fetch(
+ [:v1, :site_skin, skin.id, logged_in_as_admin?],
+ version: [skin_cache_version(skin.id),
+ AdminSetting.default_skin_id,
+ skin_cache_version(AdminSetting.default_skin_id)]
+ ) do
+ skin.get_style(roles)
+ end
+ end
+
+ def show_advanced_skin?(skin)
+ !skin.new_record? &&
+ (skin.role != Skin::DEFAULT_ROLE ||
+ (skin.media.present? && skin.media != Skin::DEFAULT_MEDIA) ||
+ skin.ie_condition.present? ||
+ skin.unusable? ||
+ !skin.skin_parents.empty?)
+ end
+
+ def my_site_skins_link(link_text = nil)
+ link_text ||= "My Site Skins"
+ span_if_current ts(link_text), user_skins_path(current_user, skin_type: "Site"), current_page?(user_skins_path(current_user)) && (params[:skin_type].blank? || params[:skin_type] == "Site")
+ end
+
+ def my_work_skins_link(link_text = nil)
+ link_text ||= "My Work Skins"
+ span_if_current ts(link_text), user_skins_path(current_user, skin_type: "WorkSkin")
+ end
+
+ def public_site_skins_link(link_text = nil)
+ link_text ||= "Public Site Skins"
+ span_if_current ts(link_text), skins_path(skin_type: "Site"), current_page?(skins_path) && (params[:skin_type].blank? || params[:skin_type] == "Site")
+ end
+
+ def public_work_skins_link(link_text = nil)
+ link_text ||= "Public Work Skins"
+ span_if_current ts(link_text), skins_path(skin_type: "WorkSkin")
+ end
+end
diff --git a/app/helpers/statuses_helper.rb b/app/helpers/statuses_helper.rb
new file mode 100644
index 0000000..4b17ed4
--- /dev/null
+++ b/app/helpers/statuses_helper.rb
@@ -0,0 +1,3 @@
+module StatusesHelper
+end
+
diff --git a/app/helpers/tag_sets_helper.rb b/app/helpers/tag_sets_helper.rb
new file mode 100755
index 0000000..35e8329
--- /dev/null
+++ b/app/helpers/tag_sets_helper.rb
@@ -0,0 +1,105 @@
+# These methods return information about the given TagSetNomination or the
+# tags in a a TagSet
+module TagSetsHelper
+
+ def nomination_notes(limit)
+ message = ""
+ if limit[:fandom] > 0
+ if limit[:character] > 0
+ if limit[:relationship] > 0
+ message = ts("You can nominate up to %{f} fandoms and up to %{c} characters and %{r} relationships for each one.",
+ f: limit[:fandom], c: limit[:character], r: limit[:relationship])
+ else
+ message = ts("You can nominate up to %{f} fandoms and up to %{c} characters for each one.", f: limit[:fandom], c: limit[:character])
+ end
+ elsif limit[:relationship] > 0
+ message = ts("You can nominate up to %{f} fandoms and up to %{r} relationships for each one.", f: limit[:fandom], r: limit[:relationship])
+ else
+ message = ts("You can nominate up to %{f} fandoms.", f: limit[:fandom])
+ end
+ else
+ if limit[:character] > 0
+ if limit[:relationship] > 0
+ message = ts("You can nominate up to %{c} characters and %{r} relationships.", c: limit[:character], r: limit[:relationship])
+ else
+ message = ts("You can nominate up to %{c} characters.", c: limit[:character])
+ end
+ elsif limit[:relationship] > 0
+ message = ts("You can nominate up to %{r} relationships.", r: limit[:relationship])
+ end
+ end
+
+ if limit[:freeform] > 0
+ if message.blank?
+ message = ts("You can nominate up to %{ff} additional tags.", ff: limit[:freeform])
+ else
+ message += ts(" You can also nominate up to %{ff} additional tags.", ff: limit[:freeform])
+ end
+ end
+
+ message
+ end
+
+ def nomination_status(nomination=nil)
+ symbol = "?!"
+ status = "unreviewed"
+ tooltip = ts('This nomination has not been reviewed yet.')
+ if nomination
+ if nomination.approved
+ symbol = "✔"
+ status = "approved"
+ tooltip = ts('This nomination has been approved!')
+ elsif nomination.rejected
+ symbol = "✖"
+ status = "rejected"
+ tooltip = ts('This nomination was rejected (but another version may have been approved instead).')
+ elsif @tag_set.nominated
+ symbol = "?!"
+ status = "unreviewed"
+ tooltip = ts('This nomination has not been reviewed yet and can still be changed.')
+ end
+ end
+
+ return content_tag(:span, content_tag(:span, "#{symbol}".html_safe), class: "#{status} symbol", data: {tooltip: "#{tooltip}"})
+ end
+
+ def nomination_tag_information(nominated_tag)
+ tag_object = Tag.find_by(name: nominated_tag.tagname)
+ status = "nonexistent"
+ tooltip = ts("This tag has never been used before. Check the spelling!")
+ title = ts("nonexistent tag")
+ span_content = nominated_tag.tagname
+ synonym_for = ""
+ case
+ when nominated_tag.canonical
+ if nominated_tag.parented
+ status = "canonical"
+ tooltip = ts("This is a canonical tag.")
+ title = ts("canonical tag")
+ span_content = link_to_tag_works(tag_object)
+ else
+ status = "unparented"
+ tooltip = ts("This is a canonical tag but not associated with the specified fandom.")
+ title = ts("canonical tag without parent")
+ span_content = link_to_tag_works(tag_object)
+ end
+ when nominated_tag.synonym
+ status = "synonym"
+ tooltip = ts("This is a synonym of a canonical tag.")
+ title = ts("tag synonym")
+ synonym_for = content_tag(:span, " (#{link_to_tag_works(tag_object.merger, class: "canonical")})".html_safe)
+ when nominated_tag.exists
+ status = "unwrangled"
+ tooltip = ts("This is not a canonical tag.")
+ title = ts("non-canonical tag")
+ end
+
+ return content_tag(:span, "#{span_content}".html_safe, class: "#{status} nomination", title: "#{title}", data: {tooltip: "#{tooltip}"}) + synonym_for
+
+ end
+
+ def tag_relation_to_list(tag_relation)
+ tag_relation.by_name_without_articles.pluck(:name).map {|tagname| content_tag(:li, tagname)}.join("\n").html_safe
+ end
+
+end
diff --git a/app/helpers/tag_type_helper.rb b/app/helpers/tag_type_helper.rb
new file mode 100755
index 0000000..282f154
--- /dev/null
+++ b/app/helpers/tag_type_helper.rb
@@ -0,0 +1,42 @@
+module TagTypeHelper
+
+ # Determines the appropriate CSS class given the tag class name e.g. "Archive"
+ #
+ # Examples
+ #
+ # tag_type_css_class("ArchiveWarning")
+ # # => "warning"
+ def tag_type_css_class(tag_type)
+ tag_type = tag_type.singularize.classify
+ case tag_type
+ when "AdditionalTag"
+ "freeform"
+ when "ArchiveWarning"
+ "warning"
+ else
+ tag_type.downcase
+ end
+ end
+
+ # Determines the Tag Type labels e.g "Warnings", "Categories", "Fandoms"
+ #
+ # Examples
+ #
+ # tag_type_label_name("archive_warning")
+ # # => "Warnings"
+ #
+ # Returns String label in singular form
+ def tag_type_label_name(tag_type)
+ label = case tag_type.singularize.underscore
+ when "archive_warning"
+ ArchiveWarning.label_name
+ when "freeform"
+ Freeform.label_name
+ when "tag"
+ "Bookmarker's Tag"
+ else
+ tag_type.humanize.titleize
+ end
+ label.singularize
+ end
+end
diff --git a/app/helpers/tags_helper.rb b/app/helpers/tags_helper.rb
new file mode 100755
index 0000000..168e778
--- /dev/null
+++ b/app/helpers/tags_helper.rb
@@ -0,0 +1,355 @@
+module TagsHelper
+
+ # Takes an array of tags and returns a marked-up, comma-separated list of links to them
+ def tag_link_list(tags, link_to_works=false)
+ tags = tags.uniq.compact.map {|tag| content_tag(:li, link_to_works ? link_to_tag_works(tag) : link_to_tag(tag))}.join.html_safe
+ end
+
+ def description(tag)
+ tag.name + " (" + tag.class.name + ")"
+ end
+
+ # Adds the appropriate css classes for the tag index page
+ def tag_cloud(tags, classes)
+ max, min = -1.0/0, 1.0/0
+ tags.each { |t|
+ next if t.count.to_i == 0 # 0s make log scales sad
+ max = Math.log(t.count.to_i) if Math.log(t.count.to_i) > max
+ min = Math.log(t.count.to_i) if Math.log(t.count.to_i) < min
+ }
+
+ divisor = ((max - min) / classes.size)
+ tags.each { |t|
+ if divisor.infinite?
+ # all counts were 0
+ yield t, classes[0]
+ else
+ class_idx = ((Math.log(t.count.to_i) - min) / divisor)
+ # handle lower edge case to prevent OOB access
+ if class_idx.nan?
+ class_idx = 0.0
+ end
+ # handle upper edge case to prevent OOB access
+ if class_idx >= classes.size
+ class_idx = classes.size - 1
+ end
+ yield t, classes[class_idx.floor]
+ end
+ }
+ end
+
+ def wrangler_list(wranglers, tag)
+ if wranglers.blank?
+ if @tag[:type] == 'Fandom'
+ sign_up_fandoms = tag.name
+ elsif Tag::USER_DEFINED.include?(@tag.class.name) && !tag.fandoms.blank?
+ sign_up_fandoms = tag.fandoms.collect(&:name).join(', ')
+ end
+ link_to "Sign Up", tag_wranglers_path(sign_up_fandoms: sign_up_fandoms)
+ else
+ wranglers.collect(&:login).join(', ')
+ end
+ end
+
+ def link_to_tag(tag, options = {})
+ link_to_tag_with_text(tag, tag.display_name, options)
+ end
+
+ def link_to_tag_works(tag, options = {})
+ link_to_tag_works_with_text(tag, tag.display_name, options)
+ end
+
+ def link_to_tag_with_text(tag, link_text, options = {})
+ if options[:full_path]
+ link_to_with_tag_class(tag_url(tag), link_text, options)
+ else
+ link_to_with_tag_class(tag_path(tag), link_text, options)
+ end
+ end
+
+ def link_to_edit_tag(tag, options = {})
+ link_to_with_tag_class(edit_tag_path(tag), tag.name, options)
+ end
+
+ def link_to_tag_works_with_text(tag, link_text, options = {})
+ collection = options[:collection]
+ if options[:full_path]
+ link_to_with_tag_class(collection ? collection_tag_works_url(collection, tag) : tag_works_url(tag), link_text, options)
+ else
+ link_to_with_tag_class(collection ? collection_tag_works_path(collection, tag) : tag_works_path(tag), link_text, options)
+ end
+ end
+
+ # the label on checkboxes to remove tag associations
+ # currently blank per wrangler request, can be changed to different label as desired
+ def remove_tag_association_label(tag)
+ "".html_safe
+ end
+
+ # Adds the "tag" classname to links (for tag links)
+ def link_to_with_tag_class(path, text, options)
+ options[:class] ? options[:class] << " tag" : options[:class] = "tag"
+ link_to text, path, options
+ end
+
+ # Used on tag edit page
+ def tag_category_name(tag_type)
+ tag_type == "Merger" ? "Synonyms" : tag_type.pluralize
+ end
+
+ # Should the current user be able to access tag wrangling pages?
+ def can_wrangle?
+ logged_in_as_admin? || (current_user.is_a?(User) && current_user.is_tag_wrangler?)
+ end
+
+ # Determines whether or not to display warnings for a creation
+ def hide_warnings?(creation)
+ current_user.is_a?(User) && current_user.preference && current_user.preference.hide_warnings? && !current_user.is_author_of?(creation)
+ end
+
+ # Determines whether or not to display freeform tags for a creation
+ def hide_freeform?(creation)
+ current_user.is_a?(User) && current_user.preference && current_user.preference.hide_freeform? && !current_user.is_author_of?(creation)
+ end
+
+ # Link to show tags if they're currently hidden
+ def show_hidden_tags_link(creation, tag_type)
+ text = ts("Show %{tag_type}", tag_type: (tag_type == 'freeforms' ? "additional tags" : tag_type.humanize.downcase.split.last))
+ url = {controller: 'tags', action: 'show_hidden', creation_type: creation.class.to_s, creation_id: creation.id, tag_type: tag_type }
+ link_to text, url, remote: true
+ end
+
+ # Makes filters show warnings display name
+ def label_for_filter(tag_type, tag_info)
+ name = (tag_type == "archive_warning") ? warning_display_name(tag_info[:name]) : tag_info[:name]
+ name + " (#{tag_info[:count]})"
+ end
+
+ # Changes display name of warnings in works blurb
+ # Used when we don't have an actual tag object
+ def warning_display_name(name)
+ ArchiveWarning::DISPLAY_NAME_MAPPING[name] || name
+ end
+
+ # Individual results for a tag search
+ def tag_search_result(tag)
+ if tag
+ span = tag.canonical? ? "" : ""
+ span += tag.type + ": " + link_to_tag(tag) + " (#{tag.taggings_count})"
+ span.html_safe
+ end
+ end
+
+ def tag_comment_link(tag)
+ count = tag.total_comments.count.to_s
+ if count == "0"
+ last_comment = ""
+ else
+ last_comment = " (last comment: " + tag.total_comments.order('created_at DESC').first.created_at.to_s + ")"
+ end
+ link_text = count + " comments" + last_comment
+ link_to link_text, {controller: :comments, action: :index, tag_id: tag}
+ end
+
+ def show_wrangling_dashboard
+ can_wrangle? &&
+ (%w(tags tag_wranglings tag_wranglers tag_wrangling_requests unsorted_tags).include?(controller.controller_name) ||
+ (@tag && controller.controller_name == 'comments'))
+ end
+
+ # Returns a nested list of meta tags
+ def meta_tag_tree(tag)
+ meta_ul = "".html_safe
+ unless tag.direct_meta_tags.empty?
+ tag.direct_meta_tags.each do |meta|
+ meta_ul += content_tag(:li, link_to_tag(meta))
+ unless meta.direct_meta_tags.empty?
+ meta_ul += content_tag(:li, meta_tag_tree(meta))
+ end
+ end
+ end
+ content_tag(:ul, meta_ul, class: 'tags tree index')
+ end
+
+ # Returns a nested list of sub tags
+ def sub_tag_tree(tag)
+ sub_ul = ""
+ unless tag.direct_sub_tags.empty?
+ sub_ul << "
"
+ tag.direct_sub_tags.order(:name).each do |sub|
+ sub_ul << "
'
+ end
+ end
+ end
+ end
+ tag_block.html_safe
+ end
+
+ # Takes a tag category class name, e.g. Relationship, and returns a string.
+ # The returned string is plural and used for more than the HTML class
+ # attribute, which is why we don't use tag_type_css_class(tag_type).
+ def tag_block_class_name(category)
+ if category == "ArchiveWarning"
+ "warnings"
+ else
+ category.downcase.pluralize
+ end
+ end
+
+ # Wraps hidden tags toggle in
and tags for blurbs and work meta.
+ # options[:suppress_toggle_class] is used to skip placing an HTML class on the
+ # toggle in work meta. The class will still be on the tags.
+ def show_hidden_tag_link_list_item(item, category, options = {})
+ item_class = item.class.to_s.underscore
+ class_name = tag_block_class_name(category)
+ content_tag(:li,
+ content_tag(:strong,
+ show_hidden_tags_link(item, class_name)),
+ class: options[:suppress_toggle_class] ? nil : class_name,
+ id: "#{item_class}_#{item.id}_category_#{class_name}")
+ end
+
+ def get_title_string(tags, category_name = "")
+ if tags && tags.size > 0
+ # We don't use .to_sentence because these aren't links and we risk making any
+ # connector word (e.g., "and") look like part of the final tag.
+ tags.map(&:display_name).join(t("support.array.words_connector"))
+ elsif tags.blank? && category_name.blank?
+ ArchiveConfig.WARNING_DEFAULT_TAG_DISPLAY_NAME
+ else
+ category_name.blank? ? "" : "No" + " " + category_name
+ end
+ end
+
+ # produce our spiffy pretty block of tag symbols
+ def get_symbols_for(item, tag_groups=nil, symbols_only = false)
+ symbol_block = []
+ symbol_block << "
" unless symbols_only
+
+ # split up the item's tags into groups based on type
+ tag_groups ||= item.tag_groups
+
+ ratings = tag_groups['Rating']
+ symbol_block << get_symbol_link(get_ratings_class(ratings), get_title_string(ratings, "rating"))
+
+ warnings = tag_groups['ArchiveWarning']
+ symbol_block << get_symbol_link(get_warnings_class(warnings), get_title_string(warnings))
+
+ categories = tag_groups['Category']
+ symbol_block << get_symbol_link(get_category_class(categories), get_title_string(categories, "category"))
+
+ if [Work, Series].include?(item.class)
+ if item.complete?
+ symbol_block << get_symbol_link( "complete-yes iswip" , "Complete #{item.class.to_s}")
+ else
+ symbol_block << get_symbol_link( "complete-no iswip", "#{item.class.to_s} in Progress" )
+ end
+ elsif item.class == ExternalWork
+ symbol_block << get_symbol_link('external-work', "External Work")
+ elsif item.is_a?(Prompt)
+ if item.unfulfilled?
+ symbol_block << get_symbol_link( "complete-no iswip", "#{item.class.to_s} Unfulfilled" )
+ else
+ symbol_block << get_symbol_link("complete-yes iswip", "#{item.class.to_s} Fulfilled" )
+ end
+ end
+
+ symbol_block << "
" unless symbols_only
+ return symbol_block.join("\n").html_safe
+ end
+
+ def get_symbol_link(css_class, title_string)
+ content_tag(:li, link_to_help('symbols-key', link = ("" + title_string + "").html_safe))
+ end
+
+ # return the right warnings class
+ def get_warnings_class(warning_tags = [])
+ if warning_tags.blank? # for testing
+ "warning-choosenotto warnings"
+ elsif warning_tags.size == 1 && warning_tags.first.name == ArchiveConfig.WARNING_NONE_TAG_NAME
+ # only one tag and it says "no warnings"
+ "warning-no warnings"
+ elsif warning_tags.size == 1 && warning_tags.first.name == ArchiveConfig.WARNING_DEFAULT_TAG_NAME
+ # only one tag and it says choose not to warn
+ "warning-choosenotto warnings"
+ elsif warning_tags.size == 2 && ((warning_tags.first.name == ArchiveConfig.WARNING_DEFAULT_TAG_NAME && warning_tags.second.name == ArchiveConfig.WARNING_NONE_TAG_NAME) || (warning_tags.first.name == ArchiveConfig.WARNING_NONE_TAG_NAME && warning_tags.second.name == ArchiveConfig.WARNING_DEFAULT_TAG_NAME))
+ # two tags and they are "choose not to warn" and "no archive warnings apply" in either order
+ "warning-choosenotto warnings"
+ else
+ "warning-yes warnings"
+ end
+ end
+
+ def get_ratings_class(rating_tags = [])
+ if rating_tags.blank? # for testing
+ "rating-notrated rating"
+ else
+ names = rating_tags.collect(&:name)
+ if names.include?(ArchiveConfig.RATING_EXPLICIT_TAG_NAME)
+ "rating-explicit rating"
+ elsif names.include?(ArchiveConfig.RATING_MATURE_TAG_NAME)
+ "rating-mature rating"
+ elsif names.include?(ArchiveConfig.RATING_TEEN_TAG_NAME)
+ "rating-teen rating"
+ elsif names.include?(ArchiveConfig.RATING_GENERAL_TAG_NAME)
+ "rating-general-audience rating"
+ else
+ "rating-notrated rating"
+ end
+ end
+ end
+
+ def get_category_class(category_tags)
+ if category_tags.blank?
+ "category-none category"
+ elsif category_tags.length > 1
+ "category-multi category"
+ else
+ case category_tags.first.name
+ when ArchiveConfig.CATEGORY_GEN_TAG_NAME
+ "category-gen category"
+ when ArchiveConfig.CATEGORY_SLASH_TAG_NAME
+ "category-slash category"
+ when ArchiveConfig.CATEGORY_HET_TAG_NAME
+ "category-het category"
+ when ArchiveConfig.CATEGORY_FEMSLASH_TAG_NAME
+ "category-femslash category"
+ when ArchiveConfig.CATEGORY_MULTI_TAG_NAME
+ "category-multi category"
+ when ArchiveConfig.CATEGORY_OTHER_TAG_NAME
+ "category-other category"
+ else
+ "category-none category"
+ end
+ end
+ end
+end
diff --git a/app/helpers/translation_helper.rb b/app/helpers/translation_helper.rb
new file mode 100755
index 0000000..2ccc97c
--- /dev/null
+++ b/app/helpers/translation_helper.rb
@@ -0,0 +1,42 @@
+module TranslationHelper
+
+def time_ago_in_words(from_time, include_seconds = false)
+
+ to_time = Time.now
+ if from_time.respond_to?(:to_time)
+ from_time = from_time.to_time
+ else
+ return
+ end
+ to_time = to_time.to_time if to_time.respond_to?(:to_time)
+ distance_in_minutes = (((to_time - from_time).abs)/60).round
+ distance_in_seconds = ((to_time - from_time).abs).round
+
+ case distance_in_minutes
+ when 0..1
+ return (distance_in_minutes==0) ? 'less than 1 minute ago' : '1 minute ago' unless include_seconds
+ case distance_in_seconds
+ when 0..5 then "less than 5 seconds ago"
+ when 6..10 then "less than 10 seconds ago"
+ when 11..20 then "less than 20 seconds ago"
+ when 21..40 then "half a minute ago"
+ when 41..59 then "less than a minute ago"
+ else "1 minute ago"
+ end
+ when 2..45 then "#{distance_in_minutes} minutes ago"
+ when 46..90 then "1 hour ago"
+ when 90..1440 then "#{(distance_in_minutes.to_f / 60.0).round} hours ago"
+ when 1441..2880 then "1 day ago"
+ else "#{(distance_in_minutes / 1440).round} days ago"
+ end
+ end
+
+alias distance_of_time_in_words_to_now time_ago_in_words
+
+ # Take some text and add whatever punctuation, symbols, and/or spacing
+ # we use to separate a metadata property from its value, e.g., "Property: ",
+ # "Propriété : ".
+ def metadata_property(text)
+ text.html_safe + t("mailer.general.metadata_label_indicator")
+ end
+end
diff --git a/app/helpers/user_invite_requests_helper.rb b/app/helpers/user_invite_requests_helper.rb
new file mode 100755
index 0000000..a71ea20
--- /dev/null
+++ b/app/helpers/user_invite_requests_helper.rb
@@ -0,0 +1,14 @@
+module UserInviteRequestsHelper
+
+ # given a request for invite codes:
+ # generates a link displaying the number of invite codes previously issued to this
+ # user; link goes to overview of user's invite codes.
+ def link_to_previous_invite_requests(request)
+ if request.user.invitations.count > 0
+ link_to request.user.invitations.count, find_admin_invitations_path(user_name: request.user.login)
+ else
+ "0"
+ end
+ end
+
+end
diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb
new file mode 100755
index 0000000..9e71edb
--- /dev/null
+++ b/app/helpers/users_helper.rb
@@ -0,0 +1,192 @@
+module UsersHelper
+ # Can be used to check ownership of items
+ def is_author_of?(item)
+ if @own_bookmarks && item.is_a?(Bookmark)
+ @own_bookmarks.include?(item)
+ elsif @own_works && item.is_a?(Work)
+ @own_works.include?(item)
+ else
+ current_user.is_a?(User) && current_user.is_author_of?(item)
+ end
+ end
+
+ # Can be used to check if user is maintainer of any collections
+ def is_maintainer?
+ current_user.is_a?(User) ? current_user.maintained_collections.present? : false
+ end
+
+ # Prints user pseuds with links to anchors for each pseud on the page and the description as the title
+ def print_pseuds(user)
+ user.pseuds.collect(&:name).join(', ')
+ end
+
+ # Determine which icon to show on user pages
+ def standard_icon(pseud = nil)
+ return "/images/skins/iconsets/default/icon_user.png" unless pseud&.icon&.attached?
+
+ rails_blob_url(pseud.icon.variant(:standard))
+ end
+
+ # no alt text if there isn't specific alt text
+ def icon_display(user = nil, pseud = nil)
+ path = user ? (pseud ? user_pseud_path(pseud.user, pseud) : user_path(user)) : nil
+ pseud ||= user.default_pseud if user
+ icon = standard_icon(pseud)
+ alt_text = pseud.try(:icon_alt_text) || nil
+
+ if path
+ link_to image_tag(icon, alt: alt_text, class: 'icon', skip_pipeline: true), path
+ else
+ image_tag(icon, class: 'icon', skip_pipeline: true)
+ end
+ end
+
+ # Prints coauthors
+ def print_coauthors(user)
+ user.coauthors.collect(&:name).join(', ')
+ end
+
+ # Prints link to bookmarks page with user-appropriate number of bookmarks
+ # (The total should reflect the number of bookmarks the user can actually see.)
+ def bookmarks_link(user, pseud = nil)
+ return pseud_bookmarks_link(pseud) if pseud.present? && !pseud.new_record?
+
+ total = SearchCounts.bookmark_count_for_user(user)
+ span_if_current ts('Bookmarks (%{bookmark_number})', bookmark_number: total.to_s), user_bookmarks_path(@user)
+ end
+
+ def pseud_bookmarks_link(pseud)
+ total = SearchCounts.bookmark_count_for_pseud(pseud)
+ span_if_current ts('Bookmarks (%{bookmark_number})', bookmark_number: total.to_s), user_pseud_bookmarks_path(@user, pseud)
+ end
+
+ # Prints link to works page with user-appropriate number of works
+ # (The total should reflect the number of works the user can actually see.)
+ def works_link(user, pseud = nil)
+ return pseud_works_link(pseud) if pseud.present? && !pseud.new_record?
+
+ total = SearchCounts.work_count_for_user(user)
+ span_if_current ts('Works (%{works_number})', works_number: total.to_s), user_works_path(@user)
+ end
+
+ def pseud_works_link(pseud)
+ total = SearchCounts.work_count_for_pseud(pseud)
+ span_if_current ts('Works (%{works_number})', works_number: total.to_s), user_pseud_works_path(@user, pseud)
+ end
+
+ # Prints link to series page with user-appropriate number of series
+ def series_link(user, pseud = nil)
+ return pseud_series_link(pseud) if pseud.present? && !pseud.new_record?
+
+ total = if current_user.nil?
+ Series.visible_to_all.exclude_anonymous.for_user(user).count.size
+ else
+ Series.visible_to_registered_user.exclude_anonymous.for_user(user).count.size
+ end
+ span_if_current ts("Series (%{series_number})", series_number: total.to_s), user_series_index_path(user)
+ end
+
+ def pseud_series_link(pseud)
+ total = if current_user.nil?
+ Series.visible_to_all.exclude_anonymous.for_pseud(pseud).count.size
+ else
+ Series.visible_to_registered_user.exclude_anonymous.for_pseud(pseud).count.size
+ end
+ span_if_current ts("Series (%{series_number})", series_number: total.to_s), user_pseud_series_index_path(pseud.user, pseud)
+ end
+
+ def gifts_link(user)
+ if current_user.nil?
+ gift_number = user.gift_works.visible_to_all.distinct.count
+ else
+ gift_number = user.gift_works.visible_to_registered_user.distinct.count
+ end
+ span_if_current ts('Gifts (%{gift_number})', gift_number: gift_number.to_s), user_gifts_path(user)
+ end
+
+ def authored_items(pseud, work_counts = {}, rec_counts = {})
+ visible_works = pseud.respond_to?(:work_count) ? pseud.work_count.to_i : (work_counts[pseud.id] || 0)
+ visible_recs = pseud.respond_to?(:rec_count) ? pseud.rec_count.to_i : (rec_counts[pseud.id] || 0)
+ items = visible_works == 1 ? link_to(visible_works.to_s + ' work', user_pseud_works_path(pseud.user, pseud)) : (visible_works > 1 ? link_to(visible_works.to_s + ' works', user_pseud_works_path(pseud.user, pseud)) : '')
+ items += ', ' if (visible_works > 0) && (visible_recs > 0)
+ if visible_recs > 0
+ items += visible_recs == 1 ? link_to(visible_recs.to_s + ' rec', user_pseud_bookmarks_path(pseud.user, pseud, recs_only: true)) : link_to(visible_recs.to_s + ' recs', user_pseud_bookmarks_path(pseud.user, pseud, recs_only: true))
+ end
+ items.html_safe
+ end
+
+ def log_item_action_name(item)
+ action = item.action
+
+ return fnok_action_name(item) if fnok_action?(action)
+
+ case action
+ when ArchiveConfig.ACTION_ACTIVATE
+ t("users_helper.log.validated")
+ when ArchiveConfig.ACTION_ADD_ROLE
+ t("users_helper.log.role_added")
+ when ArchiveConfig.ACTION_REMOVE_ROLE
+ t("users_helper.log.role_removed")
+ when ArchiveConfig.ACTION_SUSPEND
+ t("users_helper.log.suspended")
+ when ArchiveConfig.ACTION_UNSUSPEND
+ t("users_helper.log.lift_suspension")
+ when ArchiveConfig.ACTION_BAN
+ t("users_helper.log.ban")
+ when ArchiveConfig.ACTION_WARN
+ t("users_helper.log.warn")
+ when ArchiveConfig.ACTION_RENAME
+ t("users_helper.log.rename")
+ when ArchiveConfig.ACTION_PASSWORD_CHANGE
+ t("users_helper.log.password_change")
+ when ArchiveConfig.ACTION_NEW_EMAIL
+ t("users_helper.log.email_change")
+ when ArchiveConfig.ACTION_TROUBLESHOOT
+ t("users_helper.log.troubleshot")
+ when ArchiveConfig.ACTION_NOTE
+ t("users_helper.log.note")
+ when ArchiveConfig.ACTION_PASSWORD_RESET
+ t("users_helper.log.password_reset")
+ end
+ end
+
+ # Give the TOS field in the new user form a different name in non-production environments
+ # so that it can be filtered out of the log, for ease of debugging
+ def tos_field_name
+ if Rails.env.production?
+ 'terms_of_service'
+ else
+ 'terms_of_service_non_production'
+ end
+ end
+
+ private
+
+ def fnok_action?(action)
+ [
+ ArchiveConfig.ACTION_ADD_FNOK,
+ ArchiveConfig.ACTION_REMOVE_FNOK,
+ ArchiveConfig.ACTION_ADDED_AS_FNOK,
+ ArchiveConfig.ACTION_REMOVED_AS_FNOK
+ ].include?(action)
+ end
+
+ def fnok_action_name(item)
+ action_leaf =
+ case item.action
+ when ArchiveConfig.ACTION_ADD_FNOK
+ "has_added"
+ when ArchiveConfig.ACTION_REMOVE_FNOK
+ "has_removed"
+ when ArchiveConfig.ACTION_ADDED_AS_FNOK
+ "was_added"
+ when ArchiveConfig.ACTION_REMOVED_AS_FNOK
+ "was_removed"
+ end
+
+ t(
+ "users_helper.log.fnok.#{action_leaf}",
+ user_id: item.fnok_user_id
+ )
+ end
+end
diff --git a/app/helpers/validation_helper.rb b/app/helpers/validation_helper.rb
new file mode 100755
index 0000000..7e7e72f
--- /dev/null
+++ b/app/helpers/validation_helper.rb
@@ -0,0 +1,181 @@
+require 'json'
+
+module ValidationHelper
+
+ # VALIDATION_NAME_MAPPING[options_key], where `options_key` is a valid key for
+ # the `options` Hash passed to `validation_for_field`, returns the corresponding
+ # key for the validation_for_X.add(Validate[key], ...) JavaScript function call.
+ VALIDATION_NAME_MAPPING = {
+ presence: "Presence",
+ maximum_length: "Length",
+ minimum_length: "Length",
+ numericality: "Numericality",
+ exclusion: "Exclusion",
+ }
+
+ # Set a custom error-message handler that puts the errors on
+ # their respective fields instead of on the top of the page
+ ActionView::Base.field_error_proc = Proc.new {|html_tag, instance|
+ %(#{html_tag}).html_safe
+ # # don't put errors on the labels, duh
+ # if !html_tag.match(/label/)
+ # %(#{html_tag}).html_safe
+ # elsif instance.error_message.kind_of?(Array)
+ # %(#{html_tag}
#{instance.error_message.join('
')}
).html_safe
+ # else
+ # %(#{html_tag} #{instance.error_message}).html_safe
+ # end
+ }
+
+ # much simplified and html-safed version of error_messages_for
+ # error messages containing a "^" will have everything before the "^" wiped out
+ def error_messages_for(object)
+ if object.is_a? Symbol
+ object = instance_variable_get("@#{object}")
+ end
+
+ if object && object.errors.any?
+ errors = object.errors.full_messages
+ intro = content_tag(:h4, h(ts("Sorry! We couldn't save this %{objectname} because:", objectname: object.class.model_name.human.to_s.downcase.gsub(/_/, ' '))))
+ error_messages_formatted(errors, intro)
+ end
+ end
+
+ def error_messages_formatted(errors, intro = "")
+ return unless errors.present?
+
+ error_messages = errors.map { |msg| content_tag(:li, msg.gsub(/^(.*?)\^/, "").html_safe) }
+ .join("\n").html_safe
+ content_tag(:div, intro.html_safe + content_tag(:ul, error_messages), id: "error", class: "error")
+ end
+
+ # use to make sure we have consistent name throughout
+ def live_validation_varname(id)
+ "validation_for_#{id}"
+ end
+
+ # puts the standard wrapper around the code and declares the LiveValidation object
+ def live_validation_wrapper(id, validation_code)
+ valid = "var #{live_validation_varname(id)} = new LiveValidation('#{id}', { wait: 500, onlyOnBlur: false });\n".html_safe
+ valid += validation_code
+ return javascript_tag valid
+ end
+
+ # Generate javascript call for live validation. All the messages have default translated values.
+ # Options:
+ # presence: true/false -- ensure the field is not blank. (default TRUE)
+ # failureMessage: msg -- shown if field is blank (default "Must be present.")
+ # validMessage: msg -- shown when field is ok (default has been set to empty in the actual livevalidation.js file)
+ # maximum_length: [max value] -- field must be no more than this many characters long
+ # tooLongMessage: msg -- shown if too long
+ # minimum_length: [min value] -- field must be at least this many characters long
+ # tooShortMessage: msg -- shown if too short
+ #
+ # Most basic usage:
+ #
+ # <%= live_validation_for_field("field_to_validate") %>
+ # This will make sure this field is present and use translated error messages.
+ #
+ # More custom usage (from work form):
+ # <%= c.text_area :content, class: "mce-editor", id: "content" %>
+ # <%= live_validation_for_field('content',
+ # maximum_length: ArchiveConfig.CONTENT_MAX, minimum_length: ArchiveConfig.CONTENT_MIN,
+ # tooLongMessage: 'We salute your ambition! But sadly the content must be less than %d letters long. (Maybe you want to create a multi-chaptered work?)'/ArchiveConfig.CONTENT_MAX,
+ # tooShortMessage: 'Brevity is the soul of wit, but your content does have to be at least %d letters long.'/ArchiveConfig.CONTENT_MIN,
+ # failureMessage: 'You did want to post a story here, right?')
+ # %>
+ #
+ # Add more default values here! There are many more live validation options, see the code in
+ # the javascripts folder for details.
+ def live_validation_for_field(id, options = {})
+ defaults = {presence: true,
+ failureMessage: 'Must be present.',
+ validMessage: ''}
+ if options[:maximum_length]
+ defaults.merge!(tooLongMessage: 'Must be less than ' + options[:maximum_length].to_s + ' letters long.') #/
+ end
+ if options[:minimum_length]
+ defaults.merge!(tooShortMessage: 'Must be at least ' + options[:minimum_length].to_s + ' letters long.') #/
+ end
+ if options[:notANumberMessage]
+ defaults.merge!(notANumberMessage: 'Please enter a number') #/
+ end
+
+ options = defaults.merge(options)
+
+ # Remove things where the value is a falsey, e.g.:
+ # live_validation_for_field(id, {presence: false})
+ options.reject!{|k, v| !v}
+
+ # Generates a Hash mapping option[] keys to Hashes, each Hash being a
+ # representation of the arguments passed to validation_for_X.add() in
+ # JavaScript.
+ validation_hashes = {}
+ options.each do |key, _|
+ validation_hashes[key] =
+ case key
+ when :presence
+ {
+ "failureMessage" => options[:failureMessage].to_s,
+ "validMessage" => options[:validMessage].to_s,
+ }
+ when :maximum_length
+ {
+ "maximum" => options[:maximum_length].to_s,
+ "tooLongMessage" => options[:tooLongMessage].to_s,
+ }
+ when :minimum_length
+ {
+ "minimum" => options[:minimum_length].to_s,
+ "tooShortMessage" => options[:tooShortMessage].to_s,
+ }
+ when :numericality
+ {
+ "notANumberMessage" => options[:notANumberMessage].to_s,
+ "validMessage" => options[:validMessage].to_s
+ }
+ when :exclusion
+ {
+ "within" => options[:exclusion],
+ "failureMessage" => options[:failureMessage],
+ "validMessage" => options[:validMessage],
+ }
+ end
+ end
+
+ # Build the validation code from the hashes created above.
+ validation_code = validation_code_builder(id, validation_hashes)
+
+ return live_validation_wrapper(id, validation_code.html_safe)
+ end
+
+ private
+
+ # Builds validation_for_X.add(...) JavaScript calls.
+ #
+ # Implementation note: JSON is a subset of JavaScript, so `object.to_json`
+ # works *brilliantly* for this.
+ def validation_json(id, validate_key, object)
+ validation_code = live_validation_varname(id)
+ validation_code += ".add(Validate.#{validate_key}, %s);" % object.to_json
+
+ validation_code
+ end
+
+ # Builds a sequence of validation_for_X.add(...) JavaScript calls.
+ #
+ # Takes `id` and a hash mapping `options` keys to a Hash that, when converted
+ # converted to JSON, is the second argument for validation_for_X.add().
+ #
+ # For each key specified both in the hash and in `options`, it adds the
+ # corresponding validation_for_X.add(...) call.
+ def validation_code_builder(id, validation_hashes)
+ validation_hashes.reject! {|k, v| v.nil?}
+
+ validation_hashes.map do |option_key, object|
+ validate_key = VALIDATION_NAME_MAPPING[option_key]
+ validation_json(id, validate_key, object)
+ end.join("\n")
+ end
+
+end
diff --git a/app/helpers/works_helper.rb b/app/helpers/works_helper.rb
new file mode 100755
index 0000000..02b60bc
--- /dev/null
+++ b/app/helpers/works_helper.rb
@@ -0,0 +1,232 @@
+module WorksHelper
+
+ # List of date, chapter and length info for the work show page
+ def work_meta_list(work, chapter = nil)
+ # if we're previewing, grab the unsaved date, else take the saved first chapter date
+ published_date = (chapter && work.preview_mode) ? chapter.published_at : work.first_chapter.published_at
+ list = [[ts("Published:"), "published", localize(published_date)],
+ [ts("Words:"), "words", number_with_delimiter(work.word_count)],
+ [ts("Chapters:"), "chapters", chapter_total_display(work)]]
+
+ if (comment_count = work.count_visible_comments) > 0
+ list.concat([[ts("Comments:"), "comments", number_with_delimiter(work.count_visible_comments)]])
+ end
+
+ if work.all_kudos_count > 0
+ list.concat([[ts("Kudos:"), "kudos", number_with_delimiter(work.all_kudos_count)]])
+ end
+
+ if (bookmark_count = work.public_bookmarks_count) > 0
+ list.concat([[ts("Bookmarks:"), "bookmarks", link_to(number_with_delimiter(bookmark_count), work_bookmarks_path(work))]])
+ end
+ list.concat([[ts("Hits:"), "hits", number_with_delimiter(work.hits)]])
+
+ if work.chaptered? && work.revised_at
+ prefix = work.is_wip ? ts('Updated:') : ts('Completed:')
+ latest_date = (work.preview_mode && work.backdate) ? published_date : date_in_user_time_zone(work.revised_at).to_date
+ list.insert(1, [prefix, 'status', localize(latest_date)])
+ end
+ list = list.map { |list_item| content_tag(:dt, list_item.first, class: list_item.second) + content_tag(:dd, list_item.last.to_s, class: list_item.second) }.join.html_safe
+ content_tag(:dl, list.to_s, class: 'stats').html_safe
+ end
+
+ def recipients_link(work)
+ # join doesn't maintain html_safe, so mark the join safe
+ work.gifts.not_rejected.includes(:pseud).map { |gift| link_to(h(gift.recipient), gift.pseud ? user_gifts_path(gift.pseud.user) : gifts_path(recipient: gift.recipient_name)) }.join(", ").html_safe
+ end
+
+ # select default rating if this is a new work
+ def rating_selected(work)
+ work.nil? || work.rating_string.empty? ? ArchiveConfig.RATING_DEFAULT_TAG_NAME : work.rating_string
+ end
+
+ # Determines whether or not to expand the related work association fields when the work form loads
+ def check_parent_box(work)
+ work.parents_after_saving.present?
+ end
+
+ # Determines whether or not "manage series" dropdown should appear
+ def check_series_box(work)
+ work.series.present? || work_series_value(:id).present? || work_series_value(:title).present?
+ end
+
+ # Passes value of fields for work series back to form when an error occurs on posting
+ def work_series_value(field)
+ params.dig :work, :series_attributes, field
+ end
+
+ def language_link(work)
+ if work.respond_to?(:language) && work.language
+ link_to work.language.name, work.language, lang: work.language.short
+ else
+ "N/A"
+ end
+ end
+
+ # Check whether this non-admin user has permission to view the unrevealed work
+ def can_access_unrevealed_work(work, user)
+ # Creators and invited can see their works
+ return true if work.user_is_owner_or_invited?(user)
+
+ # Moderators can see unrevealed works:
+ work.collections.each do |collection|
+ return true if collection.user_is_maintainer?(user)
+ end
+
+ false
+ end
+
+ def marked_for_later?(work)
+ return unless current_user
+ reading = Reading.find_by(work_id: work.id, user_id: current_user.id)
+ reading && reading.toread?
+ end
+
+ def mark_as_read_button(work)
+ button_to t("works_helper.mark_as_read_button"), mark_as_read_work_path(work), method: :patch
+ end
+
+ def mark_for_later_button(work)
+ button_to t("works_helper.mark_for_later_button"), mark_for_later_work_path(work), method: :patch
+ end
+
+ def get_endnotes_link(work)
+ return "#work_endnotes" unless current_page?({ controller: "chapters", action: "show" })
+
+ if work.posted? && work.last_posted_chapter
+ chapter_path(work.last_posted_chapter.id, anchor: "work_endnotes")
+ else
+ chapter_path(work.last_chapter.id, anchor: "work_endnotes")
+ end
+ end
+
+ def get_related_works_url
+ current_page?({ controller: "chapters", action: "show" }) ?
+ chapter_path(@work.last_posted_chapter.id, anchor: 'children') :
+ "#children"
+ end
+
+ def get_inspired_by(work)
+ work.approved_related_works.where(translation: false)
+ end
+
+ def related_work_note(related_work, relation, download: false)
+ work_link = link_to related_work.title, polymorphic_url(related_work)
+ language = tag.span(related_work.language.name, lang: related_work.language.short) if related_work.language
+ default_locale = download ? :en : nil
+
+ creator_link = if download
+ byline(related_work, visibility: "public", only_path: false)
+ else
+ byline(related_work)
+ end
+
+ if related_work.respond_to?(:unrevealed?) && related_work.unrevealed?
+ if relation == "translated_to"
+ t(".#{relation}.unrevealed_html",
+ language: language)
+ else
+ t(".#{relation}.unrevealed",
+ locale: default_locale)
+ end
+ elsif related_work.restricted? && (download || !logged_in?)
+ t(".#{relation}.restricted_html",
+ language: language,
+ locale: default_locale,
+ creator_link: creator_link)
+ else
+ t(".#{relation}.revealed_html",
+ language: language,
+ locale: default_locale,
+ work_link: work_link,
+ creator_link: creator_link)
+ end
+ end
+
+ # Can the work be downloaded, i.e. is it posted and visible to all registered
+ # users.
+ def downloadable?
+ @work.posted? && !@work.hidden_by_admin && !@work.in_unrevealed_collection?
+ end
+
+ def download_url_for_work(work, format)
+ path = Download.new(work, format: format).public_path
+ url_for("#{path}?updated_at=#{work.updated_at.to_i}").gsub(' ', '%20')
+ end
+
+ # Generates a list of a work's tags and details for use in feeds
+ def feed_summary(work)
+ tags = work.tags.group_by(&:type)
+ text = "
by #{byline(work, { visibility: 'public', full_path: true })}
"
+ text << work.summary if work.summary
+ text << "
"
+ text
+ end
+
+ # Returns true or false to determine whether the work notes module should display
+ def show_work_notes?(work)
+ work.notes.present? ||
+ work.endnotes.present? ||
+ work.gifts.not_rejected.present? ||
+ work.challenge_claims.present? ||
+ work.parents_after_saving.present? ||
+ work.approved_related_works.present?
+ end
+
+ # Returns true or false to determine whether the work associations should be included
+ def show_associations?(work)
+ work.gifts.not_rejected.present? ||
+ work.approved_related_works.where(translation: true).exists? ||
+ work.parents_after_saving.present? ||
+ work.challenge_claims.present?
+ end
+
+ def all_coauthor_skins
+ users = @work.users.to_a
+ users << User.current_user if User.current_user.is_a?(User)
+ WorkSkin.approved_or_owned_by_any(users).order(:title)
+ end
+
+ def sorted_languages
+ Language.default_order
+ end
+
+ # 1/1, 2/3, 5/?, etc.
+ def chapter_total_display(work)
+ current = work.posted? ? work.number_of_posted_chapters : 1
+ number_with_delimiter(current) + "/" + number_with_delimiter(work.wip_length)
+ end
+
+ # For works that are more than 1 chapter, returns "current #/expected #" of chapters
+ # (e.g. 3/5, 2/?), with the current # linked to that chapter. If the work is 1 chapter,
+ # returns the un-linked version.
+ def chapter_total_display_with_link(work)
+ total_posted_chapters = work.number_of_posted_chapters
+ if total_posted_chapters > 1
+ link_to(number_with_delimiter(total_posted_chapters),
+ work_chapter_path(work, work.last_posted_chapter.id)) +
+ "/" +
+ number_with_delimiter(work.wip_length)
+ else
+ chapter_total_display(work)
+ end
+ end
+
+ def get_open_assignments(user)
+ offer_signups = user.offer_assignments.undefaulted.unstarted.sent
+ pinch_hits = user.pinch_hit_assignments.undefaulted.unstarted.sent
+
+ (offer_signups + pinch_hits)
+ end
+end
diff --git a/app/helpers/wrangling_helper.rb b/app/helpers/wrangling_helper.rb
new file mode 100755
index 0000000..6b69f6b
--- /dev/null
+++ b/app/helpers/wrangling_helper.rb
@@ -0,0 +1,14 @@
+module WranglingHelper
+ def tag_counts_per_category
+ counts = {}
+ [Fandom, Character, Relationship, Freeform].each do |klass|
+ counts[klass.to_s.downcase.pluralize.to_sym] = Rails.cache.fetch("/wrangler/counts/sidebar/#{klass}", race_condition_ttl: 10, expires_in: 1.hour) do
+ TagQuery.new(type: klass.to_s, in_use: true, unwrangleable: false, unwrangled: true, has_posted_works: true).count
+ end
+ end
+ counts[:UnsortedTag] = Rails.cache.fetch("/wrangler/counts/sidebar/UnsortedTag", race_condition_ttl: 10, expires_in: 1.hour) do
+ UnsortedTag.count
+ end
+ counts
+ end
+end
diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb
new file mode 100755
index 0000000..2cbf4e3
--- /dev/null
+++ b/app/jobs/application_job.rb
@@ -0,0 +1,14 @@
+class ApplicationJob < ActiveJob::Base
+ include AfterCommitEverywhere
+ extend AfterCommitEverywhere
+
+ queue_as :utilities
+
+ def self.perform_after_commit(*args, **kwargs)
+ after_commit { perform_later(*args, **kwargs) }
+ end
+
+ def enqueue_after_commit
+ after_commit { enqueue }
+ end
+end
diff --git a/app/jobs/application_mailer_job.rb b/app/jobs/application_mailer_job.rb
new file mode 100755
index 0000000..3e76819
--- /dev/null
+++ b/app/jobs/application_mailer_job.rb
@@ -0,0 +1,34 @@
+class ApplicationMailerJob < ActionMailer::MailDeliveryJob
+ # TODO: We have a mix of mailers that take ActiveRecords as arguments, and
+ # mailers that take IDs as arguments. If an item is unavailable when the
+ # notification is sent, it'll produce an ActiveJob::DeserializationError in
+ # the former case, and an ActiveRecord::RecordNotFound error in the latter.
+ #
+ # Ideally, we don't want to catch RecordNotFound errors, because they might
+ # be a sign of a different problem. But until we move all of the mailers over
+ # to taking ActiveRecords as arguments, we need to catch both errors.
+
+ retry_on ActiveJob::DeserializationError,
+ attempts: 3,
+ wait: 1.minute do
+ # silently discard job after 3 failures
+ end
+
+ retry_on ActiveRecord::RecordNotFound,
+ attempts: 3,
+ wait: 1.minute do
+ # silently discard job after 3 failures
+ end
+
+ # Retry three times when calling a method on a nil association, and then fall
+ # back on the Resque failure queue if the error persists (so that we have a
+ # record of it). This should address the issues that arise when we
+ # successfully load an object, but can't load its associations because the
+ # original object has been deleted in the meantime.
+ #
+ # (Most errors for calling a method on a nil object will be NoMethodErrors,
+ # but any that occur within the template itself will be converted to
+ # ActionView::Template:Errors. So we handle both.)
+ retry_on NoMethodError, attempts: 3, wait: 1.minute
+ retry_on ActionView::Template::Error, attempts: 3, wait: 1.minute
+end
diff --git a/app/jobs/async_indexer_job.rb b/app/jobs/async_indexer_job.rb
new file mode 100755
index 0000000..99fd04a
--- /dev/null
+++ b/app/jobs/async_indexer_job.rb
@@ -0,0 +1,15 @@
+# A job to index the record IDs queued up by AsyncIndexer.
+class AsyncIndexerJob < ApplicationJob
+ REDIS = AsyncIndexer::REDIS
+
+ def perform(name)
+ indexer = name.split(":").first.constantize
+ ids = REDIS.smembers(name)
+
+ return if ids.empty?
+
+ batch = indexer.new(ids).index_documents
+ IndexSweeper.new(batch, indexer).process_batch
+ REDIS.del(name)
+ end
+end
diff --git a/app/jobs/hit_count_update_job.rb b/app/jobs/hit_count_update_job.rb
new file mode 100755
index 0000000..9a473a2
--- /dev/null
+++ b/app/jobs/hit_count_update_job.rb
@@ -0,0 +1,44 @@
+# A job for transferring the hit counts collected by the RedisHitCounter from
+# Redis to the database.
+class HitCountUpdateJob < RedisHashJob
+ queue_as :hits
+
+ def self.redis
+ RedisHitCounter.redis
+ end
+
+ def self.job_size
+ ArchiveConfig.HIT_COUNT_JOB_SIZE
+ end
+
+ def self.batch_size
+ ArchiveConfig.HIT_COUNT_BATCH_SIZE
+ end
+
+ def self.base_key
+ :recent_counts
+ end
+
+ # In a single transaction, loop through the works in the batch and update
+ # their hit counts:
+ def perform_on_batch(batch)
+ StatCounter.transaction do
+ batch.sort.each do |work_id, value|
+ work = Work.find_by(id: work_id)
+ stat_counter = StatCounter.lock.find_by(work_id: work_id)
+
+ next if prevent_hits?(work) || stat_counter.nil?
+
+ stat_counter.update(hit_count: stat_counter.hit_count + value.to_i)
+ end
+ end
+ end
+
+ # Check whether the work should allow hits at the moment:
+ def prevent_hits?(work)
+ work.nil? ||
+ work.in_unrevealed_collection ||
+ work.hidden_by_admin ||
+ !work.posted
+ end
+end
diff --git a/app/jobs/instance_method_job.rb b/app/jobs/instance_method_job.rb
new file mode 100755
index 0000000..176d92b
--- /dev/null
+++ b/app/jobs/instance_method_job.rb
@@ -0,0 +1,10 @@
+# Job for calling an instance method on an object.
+class InstanceMethodJob < ApplicationJob
+ def perform(object, *args, **kwargs)
+ object.send(*args, **kwargs)
+ end
+
+ retry_on ActiveJob::DeserializationError, attempts: 3, wait: 5.minutes do
+ # silently discard job after 3 failures
+ end
+end
diff --git a/app/jobs/invite_from_queue_job.rb b/app/jobs/invite_from_queue_job.rb
new file mode 100755
index 0000000..ded0e0f
--- /dev/null
+++ b/app/jobs/invite_from_queue_job.rb
@@ -0,0 +1,13 @@
+class InviteFromQueueJob < ApplicationJob
+ if defined?(Sentry::Cron::MonitorCheckIns)
+ include Sentry::Cron::MonitorCheckIns
+
+ sentry_monitor_check_ins
+ end
+
+ def perform(count:, creator: nil)
+ InviteRequest.order(:id).limit(count).each do |request|
+ request.invite_and_remove(creator)
+ end
+ end
+end
diff --git a/app/jobs/readings_job.rb b/app/jobs/readings_job.rb
new file mode 100755
index 0000000..457c90d
--- /dev/null
+++ b/app/jobs/readings_job.rb
@@ -0,0 +1,37 @@
+class ReadingsJob < RedisSetJob
+ queue_as :readings
+
+ def self.base_key
+ "Reading:new"
+ end
+
+ def self.job_size
+ ArchiveConfig.READING_JOB_SIZE
+ end
+
+ def self.batch_size
+ ArchiveConfig.READING_BATCH_SIZE
+ end
+
+ def perform_on_batch(batch)
+ # Each item in the batch is an array of arguments encoded as a JSON:
+ parsed_batch = batch.map do |json|
+ ActiveSupport::JSON.decode(json)
+ end
+
+ # Sort to try to reduce deadlocks.
+ #
+ # The first argument is user_id, the third argument is work_id:
+ sorted_batch = parsed_batch.sort_by do |args|
+ [args.first.to_i, args.third.to_i]
+ end
+
+ # Finally, start a transaction and call Reading.reading_object to write the
+ # information to the database:
+ Reading.transaction do
+ sorted_batch.each do |args|
+ Reading.reading_object(*args)
+ end
+ end
+ end
+end
diff --git a/app/jobs/redis_hash_job.rb b/app/jobs/redis_hash_job.rb
new file mode 100755
index 0000000..04ff8ea
--- /dev/null
+++ b/app/jobs/redis_hash_job.rb
@@ -0,0 +1,16 @@
+# An abstract subclass of the RedisSetJob class that adapts the class to handle
+# hashes instead of sets.
+class RedisHashJob < RedisSetJob
+ # Add items to be processed when this job runs:
+ def add_to_job(batch)
+ redis.mapped_hmset(key, batch)
+ end
+
+ # Use hscan to iterate through the hash, and hdel to remove:
+ def self.scan_and_remove(redis, key, batch_size:)
+ scan_hash_in_batches(redis, key, batch_size: batch_size) do |batch|
+ yield batch
+ redis.hdel(key, batch.keys)
+ end
+ end
+end
diff --git a/app/jobs/redis_job_spawner.rb b/app/jobs/redis_job_spawner.rb
new file mode 100755
index 0000000..d8f3e87
--- /dev/null
+++ b/app/jobs/redis_job_spawner.rb
@@ -0,0 +1,25 @@
+# An ActiveJob designed to spawn a bunch of subclasses of RedisSetJob or
+# RedisHashJob. Renames the desired redis key to avoid conflicts with other
+# code that might be modifying the same set/hash, then calls spawn_jobs on the
+# desired job class.
+class RedisJobSpawner < ApplicationJob
+ def perform(job_name, *args, key: nil, redis: nil, **kwargs)
+ job_class = job_name.constantize
+
+ # Read settings from the job class:
+ redis ||= job_class.redis
+ key ||= job_class.base_key
+
+ # Bail out early if there's nothing to process:
+ return unless redis.exists(key)
+
+ # Rename the job to a unique name to avoid conflicts when this is called
+ # multiple times in a short period:
+ spawn_id = redis.incr("job:#{job_name.underscore}:spawn:id")
+ spawn_key = "job:#{job_name.underscore}:spawn:#{spawn_id}"
+ redis.rename(key, spawn_key)
+
+ # Tell the job class to handle the spawning.
+ job_class.spawn_jobs(*args, redis: redis, key: spawn_key, **kwargs)
+ end
+end
diff --git a/app/jobs/redis_set_job.rb b/app/jobs/redis_set_job.rb
new file mode 100755
index 0000000..1d2206b
--- /dev/null
+++ b/app/jobs/redis_set_job.rb
@@ -0,0 +1,76 @@
+# An abstract class designed to make it easier to queue up jobs with a Redis
+# set, then split those jobs into chunks to process them.
+class RedisSetJob < ApplicationJob
+ extend RedisScanning
+
+ # For any subclasses of this job, we want to try to recover from deadlocks
+ # and lock wait timeouts. The 5 minute delay should hopefully be long enough
+ # that whatever transaction caused the deadlock will be over with by the time
+ # we retry.
+ retry_on ActiveRecord::Deadlocked, attempts: 10, wait: 5.minutes
+ retry_on ActiveRecord::LockWaitTimeout, attempts: 10, wait: 5.minutes
+
+ # The redis server used for this job.
+ def self.redis
+ REDIS_GENERAL
+ end
+
+ # The default key for the Redis set that we want to process. Used by the
+ # RedisJobSpawner.
+ def self.base_key
+ raise "Must be implemented in subclass!"
+ end
+
+ # The number of items we'd like to have in a single job.
+ def self.job_size
+ 1000
+ end
+
+ # The number of items to process in a single call to perform_on_batch. This
+ # should be smaller than job_size, otherwise it'll just use job_size for the
+ # batch size.
+ def self.batch_size
+ 100
+ end
+
+ def perform(*args, **kwargs)
+ scan_and_remove(redis, key, batch_size: batch_size) do |batch|
+ perform_on_batch(batch, *args, **kwargs)
+ end
+ end
+
+ # This is where the real work happens:
+ def perform_on_batch(*)
+ raise "Must be implemented in subclass!"
+ end
+
+ # The Redis key used to store the objects that this job needs to process:
+ def key
+ @key ||= "job:#{self.class.name.underscore}:batch:#{job_id}"
+ end
+
+ # Add items to be processed when this job runs:
+ def add_to_job(batch)
+ redis.sadd(key, batch)
+ end
+
+ # Use sscan to iterate through the set, and srem to remove:
+ def self.scan_and_remove(redis, key, batch_size:)
+ scan_set_in_batches(redis, key, batch_size: batch_size) do |batch|
+ yield batch
+ redis.srem(key, batch)
+ end
+ end
+
+ # Use scan_and_remove to divide the queue into batches, and create a job for
+ # each batch:
+ def self.spawn_jobs(*args, redis: self.redis, key: self.base_key, **kwargs)
+ scan_and_remove(redis, key, batch_size: job_size) do |batch|
+ job = new(*args, **kwargs)
+ job.add_to_job(batch)
+ job.enqueue
+ end
+ end
+
+ delegate :redis, :job_size, :batch_size, :scan_and_remove, to: :class
+end
diff --git a/app/jobs/report_attachment_job.rb b/app/jobs/report_attachment_job.rb
new file mode 100755
index 0000000..2e3fbad
--- /dev/null
+++ b/app/jobs/report_attachment_job.rb
@@ -0,0 +1,7 @@
+class ReportAttachmentJob < ApplicationJob
+ def perform(ticket_id, work)
+ download = Download.new(work, mime_type: "text/html")
+ html = DownloadWriter.new(download).generate_html
+ FeedbackReporter.new.send_attachment!(ticket_id, "#{download.file_name}.html", html)
+ end
+end
diff --git a/app/jobs/resanitize_batch_job.rb b/app/jobs/resanitize_batch_job.rb
new file mode 100755
index 0000000..8c8e6a4
--- /dev/null
+++ b/app/jobs/resanitize_batch_job.rb
@@ -0,0 +1,44 @@
+# A job for resanitizing all fields for a class, and saving the results to the
+# database. Takes the name of a class and an array of IDs.
+class ResanitizeBatchJob < ApplicationJob
+ include HtmlCleaner
+
+ retry_on StandardError, attempts: 5, wait: 10.minutes
+
+ queue_as :resanitize
+
+ def perform(klass_name, ids)
+ klass = klass_name.constantize
+
+ # Go through all of the fields allowing HTML and look for ones that this
+ # klass has, then calculate a list of pairs like:
+ # [["summary", "summary_sanitizer_version"],
+ # ["notes", "notes_sanitizer_version"]]
+ fields_to_sanitize = []
+
+ ArchiveConfig.FIELDS_ALLOWING_HTML.each do |field|
+ next unless klass.has_attribute?(field) &&
+ klass.has_attribute?("#{field}_sanitizer_version")
+
+ fields_to_sanitize << [field, "#{field}_sanitizer_version"]
+ end
+
+ # Go through each record in the batch, and check the sanitizer version of
+ # all the fields that need processing. If any field has a sanitizer version
+ # less than the archive's SANITIZER_VERSION, run the sanitizer on the field
+ # and re-save it to the database.
+ klass.where(id: ids).each do |record|
+ record.with_lock do
+ fields_to_sanitize.each do |field, sanitizer_version|
+ next if record[sanitizer_version] &&
+ record[sanitizer_version] >= ArchiveConfig.SANITIZER_VERSION
+
+ record[field] = sanitize_value(field, record[field])
+ record[sanitizer_version] = ArchiveConfig.SANITIZER_VERSION
+ end
+
+ record.save(validate: false)
+ end
+ end
+ end
+end
diff --git a/app/jobs/stat_counter_job.rb b/app/jobs/stat_counter_job.rb
new file mode 100755
index 0000000..7e0af2a
--- /dev/null
+++ b/app/jobs/stat_counter_job.rb
@@ -0,0 +1,19 @@
+class StatCounterJob < RedisSetJob
+ queue_as :stats
+
+ def self.base_key
+ "works_to_update_stats"
+ end
+
+ def self.job_size
+ ArchiveConfig.STAT_COUNTER_JOB_SIZE
+ end
+
+ def self.batch_size
+ ArchiveConfig.STAT_COUNTER_BATCH_SIZE
+ end
+
+ def perform_on_batch(work_ids)
+ Work.where(id: work_ids).find_each(&:update_stat_counter)
+ end
+end
diff --git a/app/jobs/tag_count_update_job.rb b/app/jobs/tag_count_update_job.rb
new file mode 100755
index 0000000..15007d2
--- /dev/null
+++ b/app/jobs/tag_count_update_job.rb
@@ -0,0 +1,24 @@
+class TagCountUpdateJob < RedisSetJob
+ queue_as :tag_counts
+
+ def self.base_key
+ "tag_update"
+ end
+
+ def self.job_size
+ ArchiveConfig.TAG_UPDATE_JOB_SIZE
+ end
+
+ def self.batch_size
+ ArchiveConfig.TAG_UPDATE_BATCH_SIZE
+ end
+
+ def perform_on_batch(tag_ids)
+ Tag.transaction do
+ tag_ids.each do |id|
+ value = REDIS_GENERAL.get("tag_update_#{id}_value")
+ Tag.where(id: id).update(taggings_count_cache: value) if value.present?
+ end
+ end
+ end
+end
diff --git a/app/jobs/tag_method_job.rb b/app/jobs/tag_method_job.rb
new file mode 100755
index 0000000..36ca255
--- /dev/null
+++ b/app/jobs/tag_method_job.rb
@@ -0,0 +1,15 @@
+# Subclass of InstanceMethodJob with a custom retry setting that is useful for
+# wrangling-related jobs.
+class TagMethodJob < InstanceMethodJob
+ # Retry if we insert a non-unique record.
+ #
+ # The jobs for this class mostly already check for uniqueness in validations,
+ # so the only way this can be raised is if there are multiple transactions
+ # trying to insert the same record at roughly the same time.
+ retry_on ActiveRecord::RecordNotUnique,
+ attempts: 3, wait: 5.minutes
+
+ # Try to prevent deadlocks and lock wait timeouts:
+ retry_on ActiveRecord::Deadlocked, attempts: 10, wait: 5.minutes
+ retry_on ActiveRecord::LockWaitTimeout, attempts: 10, wait: 5.minutes
+end
diff --git a/app/mailers/README.md b/app/mailers/README.md
new file mode 100755
index 0000000..e97259a
--- /dev/null
+++ b/app/mailers/README.md
@@ -0,0 +1,3 @@
+If `deliver_later` is called within a transaction, the job is added to the Resque queue immediately, but the data written in the transaction is only available once the transaction is complete. This can cause `RecordNotFound` errors when sending emails about brand new objects, as well as other issues with emails containing old data.
+
+To help avoid these transaction issues, use the `deliver_after_commit` method introduced by [this monkeypatch](../../config/initializers/monkeypatches/deliver_after_commit.rb), which uses the `after_commit_everywhere` gem to ensure that the job is only added to the queue after the transaction has finished.
diff --git a/app/mailers/admin_mailer.rb b/app/mailers/admin_mailer.rb
new file mode 100755
index 0000000..5b16703
--- /dev/null
+++ b/app/mailers/admin_mailer.rb
@@ -0,0 +1,35 @@
+class AdminMailer < ApplicationMailer
+ # Sends a spam report
+ def send_spam_alert(spam)
+ # Make sure that the keys of the spam array are integers, so that we can do
+ # an easy look-up with user IDs. We call stringify_keys first because
+ # the currently installed version of Resque::Mailer does odd things when
+ # you pass a hash as an argument, and we want to know what we're dealing with.
+ @spam = spam.stringify_keys.transform_keys(&:to_i)
+
+ @users = User.where(id: @spam.keys).to_a
+ return if @users.empty?
+
+ # The users might have been retrieved from the database out of order, so
+ # re-sort them by their score.
+ @users.sort_by! { |user| @spam[user.id]["score"] }.reverse!
+
+ mail(
+ to: ArchiveConfig.SPAM_ALERT_ADDRESS,
+ subject: "[#{ArchiveConfig.APP_SHORT_NAME}] Potential spam alert"
+ )
+ end
+
+ # Emails newly created admin, giving them info about their account and a link
+ # to set their password. Expects the raw password reset token (not the
+ # encrypted one in the database); it is used to create the reset link.
+ def set_password_notification(admin, token)
+ @admin = admin
+ @token = token
+
+ mail(
+ to: @admin.email,
+ subject: t(".subject", app_name: ArchiveConfig.APP_SHORT_NAME)
+ )
+ end
+end
diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb
new file mode 100755
index 0000000..b3d5f94
--- /dev/null
+++ b/app/mailers/application_mailer.rb
@@ -0,0 +1,7 @@
+class ApplicationMailer < ActionMailer::Base
+ self.delivery_job = ApplicationMailerJob
+
+ layout "mailer"
+ helper :mailer
+ default from: "Sunset Archive" + "<#{ArchiveConfig.RETURN_ADDRESS}>"
+end
diff --git a/app/mailers/archive_devise_mailer.rb b/app/mailers/archive_devise_mailer.rb
new file mode 100755
index 0000000..f19e2c8
--- /dev/null
+++ b/app/mailers/archive_devise_mailer.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+# Class for customizing the reset_password_instructions email.
+class ArchiveDeviseMailer < Devise::Mailer
+ layout "mailer"
+ helper :mailer
+ helper :application
+
+ default from: "Sunset Archive <#{ArchiveConfig.RETURN_ADDRESS}>"
+
+ def reset_password_instructions(record, token, options = {})
+ @user = record
+ @token = token
+ subject = if @user.is_a?(Admin)
+ t("admin.mailer.reset_password_instructions.subject",
+ app_name: ArchiveConfig.APP_SHORT_NAME)
+ else
+ t("users.mailer.reset_password_instructions.subject",
+ app_name: ArchiveConfig.APP_SHORT_NAME)
+ end
+ devise_mail(record, :reset_password_instructions,
+ options.merge(subject: subject))
+ end
+
+ def confirmation_instructions(record, token, opts = {})
+ @token = token
+ subject = t("users.mailer.confirmation_instructions.subject", app_name: ArchiveConfig.APP_SHORT_NAME)
+ devise_mail(record, :confirmation_instructions, opts.merge(subject: subject))
+ end
+
+ def password_change(record, opts = {})
+ @pac_footer = true
+ subject = if record.is_a?(Admin)
+ t("admin.mailer.password_change.subject",
+ app_name: ArchiveConfig.APP_SHORT_NAME)
+ else
+ t("users.mailer.password_change.subject",
+ app_name: ArchiveConfig.APP_SHORT_NAME)
+ end
+ devise_mail(record, :password_change, opts.merge(subject: subject))
+ end
+end
diff --git a/app/mailers/collection_mailer.rb b/app/mailers/collection_mailer.rb
new file mode 100755
index 0000000..cadf1b6
--- /dev/null
+++ b/app/mailers/collection_mailer.rb
@@ -0,0 +1,16 @@
+class CollectionMailer < ApplicationMailer
+ helper :application
+ helper :tags
+ helper :works
+ helper :series
+
+ def item_added_notification(creation_id, collection_id, item_type)
+ @item_type = item_type
+ @item_type == "Work" ? @creation = Work.find(creation_id) : @creation = Bookmark.find(creation_id)
+ @collection = Collection.find(collection_id)
+ mail(
+ to: @collection.email,
+ subject: "[#{ArchiveConfig.APP_SHORT_NAME}] #{@item_type.capitalize} added to " + @collection.title.gsub(">", ">").gsub("<", "<")
+ )
+ end
+end
diff --git a/app/mailers/comment_mailer.rb b/app/mailers/comment_mailer.rb
new file mode 100755
index 0000000..bff84fd
--- /dev/null
+++ b/app/mailers/comment_mailer.rb
@@ -0,0 +1,112 @@
+class CommentMailer < ApplicationMailer
+ # Sends email to an owner of the top-level commentable when a new comment is created
+ # This may be an admin, in which case we use the admin address instead
+ def comment_notification(user, comment)
+ @comment = comment
+ @owner = true
+ email = user.is_a?(Admin) ? ArchiveConfig.ADMIN_ADDRESS : user.email
+ locale = user.try(:preference).try(:locale_for_mails) || I18n.default_locale.to_s
+ I18n.with_locale(locale) do
+ mail(
+ to: email,
+ # i18n-tasks-use t("comment_mailer.comment_notification.subject.chapter")
+ # i18n-tasks-use t("comment_mailer.comment_notification.subject.other")
+ # i18n-tasks-use t("comment_mailer.comment_notification.subject.tag")
+ subject: subject_for_commentable(@comment)
+ )
+ end
+ end
+
+ # Sends email to an owner of the top-level commentable when a comment is edited
+ # This may be an admin, in which case we use the admin address instead
+ def edited_comment_notification(user, comment)
+ @comment = comment
+ @owner = true
+ email = user.is_a?(Admin) ? ArchiveConfig.ADMIN_ADDRESS : user.email
+ locale = user.try(:preference).try(:locale_for_mails) || I18n.default_locale.to_s
+ I18n.with_locale(locale) do
+ mail(
+ to: email,
+ # i18n-tasks-use t("comment_mailer.edited_comment_notification.subject.chapter")
+ # i18n-tasks-use t("comment_mailer.edited_comment_notification.subject.other")
+ # i18n-tasks-use t("comment_mailer.edited_comment_notification.subject.tag")
+ subject: subject_for_commentable(@comment)
+ )
+ end
+ end
+
+ # Sends email to commenter when a reply is posted to their comment
+ # This may be a non-user of the archive
+ def comment_reply_notification(your_comment, comment)
+ return if your_comment.comment_owner_email.blank?
+ return if your_comment.pseud_id.nil? && AdminBlacklistedEmail.is_blacklisted?(your_comment.comment_owner_email)
+
+ @your_comment = your_comment
+ @comment = comment
+ mail(
+ to: @your_comment.comment_owner_email,
+ # i18n-tasks-use t("comment_mailer.comment_reply_notification.subject.chapter")
+ # i18n-tasks-use t("comment_mailer.comment_reply_notification.subject.other")
+ # i18n-tasks-use t("comment_mailer.comment_reply_notification.subject.tag")
+ subject: subject_for_commentable(@comment)
+ )
+ end
+
+ # Sends email to commenter when a reply to their comment is edited
+ # This may be a non-user of the archive
+ def edited_comment_reply_notification(your_comment, edited_comment)
+ return if your_comment.comment_owner_email.blank?
+ return if your_comment.pseud_id.nil? && AdminBlacklistedEmail.is_blacklisted?(your_comment.comment_owner_email)
+ return if your_comment.is_deleted?
+
+ @your_comment = your_comment
+ @comment = edited_comment
+ mail(
+ to: @your_comment.comment_owner_email,
+ # i18n-tasks-use t("comment_mailer.edited_comment_reply_notification.subject.chapter")
+ # i18n-tasks-use t("comment_mailer.edited_comment_reply_notification.subject.other")
+ # i18n-tasks-use t("comment_mailer.edited_comment_reply_notification.subject.tag")
+ subject: subject_for_commentable(@comment)
+ )
+ end
+
+ # Sends email to the poster of a top-level comment
+ def comment_sent_notification(comment)
+ @comment = comment
+ @noreply = true # don't give reply link to your own comment
+ mail(
+ to: @comment.comment_owner_email,
+ # i18n-tasks-use t("comment_mailer.comment_sent_notification.subject.chapter")
+ # i18n-tasks-use t("comment_mailer.comment_sent_notification.subject.other")
+ # i18n-tasks-use t("comment_mailer.comment_sent_notification.subject.tag")
+ subject: subject_for_commentable(@comment)
+ )
+ end
+
+ # Sends email to the poster of a reply to a comment
+ def comment_reply_sent_notification(comment)
+ @comment = comment
+ @parent_comment = comment.commentable
+ @noreply = true
+ mail(
+ to: @comment.comment_owner_email,
+ # i18n-tasks-use t("comment_mailer.comment_reply_sent_notification.subject.chapter")
+ # i18n-tasks-use t("comment_mailer.comment_reply_sent_notification.subject.other")
+ # i18n-tasks-use t("comment_mailer.comment_reply_sent_notification.subject.tag")
+ subject: subject_for_commentable(@comment)
+ )
+ end
+
+ private
+
+ def subject_for_commentable(comment)
+ name = comment.ultimate_parent.commentable_name.gsub(">", ">").gsub("<", "<").html_safe
+ if comment.ultimate_parent.is_a?(Tag)
+ t(".subject.tag", app_name: ArchiveConfig.APP_SHORT_NAME, name: name)
+ elsif comment.parent.is_a?(Chapter) && comment.parent.work.chaptered?
+ t(".subject.chapter", app_name: ArchiveConfig.APP_SHORT_NAME, position: comment.parent.position, title: name)
+ else
+ t(".subject.other", app_name: ArchiveConfig.APP_SHORT_NAME, title: name)
+ end
+ end
+end
diff --git a/app/mailers/kudo_mailer.rb b/app/mailers/kudo_mailer.rb
new file mode 100755
index 0000000..6c210c3
--- /dev/null
+++ b/app/mailers/kudo_mailer.rb
@@ -0,0 +1,27 @@
+class KudoMailer < ApplicationMailer
+ # send a batched-up notification
+ # user_kudos is a hash of arrays converted to JSON string format
+ # [commentable_type]_[commentable_id] =>
+ # names: [array of users who left kudos with the last entry being "# guests" if any]
+ # guest_count: number of guest kudos
+ def batch_kudo_notification(user_id, user_kudos)
+ @commentables = []
+ @user_kudos = JSON.parse(user_kudos)
+ user = User.find(user_id)
+ kudos_hash = JSON.parse(user_kudos)
+
+ kudos_hash.each_pair do |commentable_info, _kudo_givers_hash|
+ # Parse the key to extract the type and id of the commentable so we can
+ # weed out any commentables that no longer exist.
+ commentable_type, commentable_id = commentable_info.split("_")
+ commentable = commentable_type.constantize.find_by(id: commentable_id)
+ next unless commentable
+
+ @commentables << commentable
+ end
+ mail(
+ to: user.email,
+ subject: default_i18n_subject(app_name: ArchiveConfig.APP_SHORT_NAME)
+ )
+ end
+end
diff --git a/app/mailers/tag_wrangling_supervisor_mailer.rb b/app/mailers/tag_wrangling_supervisor_mailer.rb
new file mode 100755
index 0000000..a9c287c
--- /dev/null
+++ b/app/mailers/tag_wrangling_supervisor_mailer.rb
@@ -0,0 +1,12 @@
+class TagWranglingSupervisorMailer < ApplicationMailer
+ default to: ArchiveConfig.TAG_WRANGLER_SUPERVISORS_ADDRESS
+
+ # Send an email to tag wrangling supervisors when a tag wrangler changes their username
+ def wrangler_username_change_notification(old_name, new_name)
+ @old_username = old_name
+ @new_username = new_name
+ mail(
+ subject: default_i18n_subject(app_name: ArchiveConfig.APP_SHORT_NAME)
+ )
+ end
+end
diff --git a/app/mailers/tos_update_mailer.rb b/app/mailers/tos_update_mailer.rb
new file mode 100755
index 0000000..2c694c9
--- /dev/null
+++ b/app/mailers/tos_update_mailer.rb
@@ -0,0 +1,11 @@
+class TosUpdateMailer < ApplicationMailer
+ # Sent by notifications:send_tos_update
+ def tos_update_notification(user, admin_post_id)
+ @username = user.login
+ @admin_post = admin_post_id
+ mail(
+ to: user.email,
+ subject: "[#{ArchiveConfig.APP_SHORT_NAME}] Updates to #{ArchiveConfig.APP_SHORT_NAME}'s Terms of Service"
+ )
+ end
+end
diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb
new file mode 100755
index 0000000..11542c4
--- /dev/null
+++ b/app/mailers/user_mailer.rb
@@ -0,0 +1,417 @@
+class UserMailer < ApplicationMailer
+ helper_method :current_user
+ helper_method :current_admin
+ helper_method :logged_in?
+ helper_method :logged_in_as_admin?
+
+ helper :application
+ helper :tags
+ helper :works
+ helper :users
+ helper :date
+ helper :series
+ include HtmlCleaner
+
+ # Send an email letting a creator know that their work has been added to a collection by an archivist
+ def archivist_added_to_collection_notification(user_id, work_id, collection_id)
+ @user = User.find(user_id)
+ @work = Work.find(work_id)
+ @collection = Collection.find(collection_id)
+ mail(
+ to: @user.email,
+ subject: t(".subject", app_name: ArchiveConfig.APP_SHORT_NAME, collection_title: @collection.title)
+ )
+ end
+
+ # Send a request to a work owner asking that they approve the inclusion
+ # of their work in a collection
+ def invited_to_collection_notification(user_id, work_id, collection_id)
+ @user = User.find(user_id)
+ @work = Work.find(work_id)
+ @collection = Collection.find(collection_id)
+ mail(
+ to: @user.email,
+ subject: default_i18n_subject(app_name: ArchiveConfig.APP_SHORT_NAME, collection_title: @collection.title)
+ )
+ end
+
+ # We use an options hash here, instead of keyword arguments, to avoid
+ # compatibility issues with resque/resque_mailer.
+ def anonymous_or_unrevealed_notification(user_id, work_id, collection_id, options = {})
+ options = options.with_indifferent_access
+
+ @becoming_anonymous = options.fetch(:anonymous, false)
+ @becoming_unrevealed = options.fetch(:unrevealed, false)
+
+ return unless @becoming_anonymous || @becoming_unrevealed
+
+ @user = User.find(user_id)
+ @work = Work.find(work_id)
+ @collection = Collection.find(collection_id)
+
+ # Symbol used to pick the appropriate translation string:
+ @status = if @becoming_anonymous && @becoming_unrevealed
+ :anonymous_unrevealed
+ elsif @becoming_anonymous
+ :anonymous
+ else
+ :unrevealed
+ end
+ mail(
+ to: @user.email,
+ subject: t(".subject.#{@status}", app_name: ArchiveConfig.APP_SHORT_NAME)
+ )
+ end
+
+ # Sends an invitation to join the archive
+ # Must be sent synchronously as it is rescued
+ # TODO refactor to make it asynchronous
+ def invitation(invitation_id)
+ @invitation = Invitation.find(invitation_id)
+ @user_name = (@invitation.creator.is_a?(User) ? @invitation.creator.login : "")
+ mail(
+ to: @invitation.invitee_email,
+ subject: t("user_mailer.invitation.subject", app_name: ArchiveConfig.APP_SHORT_NAME)
+ )
+ end
+
+ # Sends an invitation to join the archive and claim stories that have been imported as part of a bulk import
+ def invitation_to_claim(invitation_id, archivist_login)
+ @invitation = Invitation.find(invitation_id)
+ @external_author = @invitation.external_author
+ @archivist = archivist_login || "An archivist"
+ @token = @invitation.token
+ mail(
+ to: @invitation.invitee_email,
+ subject: t("user_mailer.invitation_to_claim.subject", app_name: ArchiveConfig.APP_SHORT_NAME)
+ )
+ end
+
+ # Notifies a writer that their imported works have been claimed
+ def claim_notification(creator_id, claimed_work_ids)
+ creator = User.find(creator_id)
+ @external_email = creator.email
+ @claimed_works = Work.where(id: claimed_work_ids)
+ mail(
+ to: creator.email,
+ subject: default_i18n_subject(app_name: ArchiveConfig.APP_SHORT_NAME)
+ )
+ end
+
+ # Sends a batched subscription notification
+ def batch_subscription_notification(subscription_id, entries)
+ # Here we use find_by_id so that if the subscription is not found
+ # then the resque job does not error and we just silently fail.
+ @subscription = Subscription.find_by(id: subscription_id)
+ return if @subscription.nil?
+
+ creation_entries = JSON.parse(entries)
+ @creations = []
+ # look up all the creations that have generated updates for this subscription
+ creation_entries.each do |creation_info|
+ creation_type, creation_id = creation_info.split("_")
+ creation = creation_type.constantize.where(id: creation_id).first
+ next unless creation && creation.try(:posted)
+ next if creation.is_a?(Chapter) && !creation.work.try(:posted)
+ next if creation.try(:hidden_by_admin) || (creation.is_a?(Chapter) && creation.work.try(:hidden_by_admin))
+ next if creation.pseuds.any? { |p| p.user == User.orphan_account } # no notifications for orphan works
+
+ # TODO: allow subscriptions to orphan_account to receive notifications
+
+ # If the subscription notification is for a user subscription, we don't
+ # want to send updates about works that have recently become anonymous.
+ if @subscription.subscribable_type == "User"
+ next if Subscription.anonymous_creation?(creation)
+ end
+
+ @creations << creation
+ end
+
+ return if @creations.empty?
+
+ # make sure we only notify once per creation
+ @creations.uniq!
+
+ subject = @subscription.subject_text(@creations.first)
+ subject += " and #{@creations.count - 1} more" if @creations.count > 1
+ I18n.with_locale(@subscription.user.preference.locale_for_mails) do
+ mail(
+ to: @subscription.user.email,
+ subject: "[#{ArchiveConfig.APP_SHORT_NAME}] #{subject}"
+ )
+ end
+ end
+
+ # Emails a user to say they have been given more invitations for their friends
+ def invite_increase_notification(user_id, total)
+ @user = User.find(user_id)
+ @total = total
+ mail(
+ to: @user.email,
+ subject: default_i18n_subject(app_name: ArchiveConfig.APP_SHORT_NAME)
+ )
+ end
+
+ # Emails a user to say that their request for invitation codes has been declined
+ def invite_request_declined(user_id, total, reason)
+ @user = User.find(user_id)
+ @total = total
+ @reason = reason
+ mail(
+ to: @user.email,
+ subject: default_i18n_subject(app_name: ArchiveConfig.APP_SHORT_NAME)
+ )
+ end
+
+ def collection_notification(collection_id, subject, message, email)
+ @message = message
+ @collection = Collection.find(collection_id)
+ mail(
+ to: email,
+ subject: "[#{ArchiveConfig.APP_SHORT_NAME}][#{@collection.title}] #{subject}"
+ )
+ end
+
+ def invalid_signup_notification(collection_id, invalid_signup_ids, email)
+ @collection = Collection.find(collection_id)
+ @invalid_signups = invalid_signup_ids
+ @is_collection_email = (email == @collection.collection_email)
+ mail(
+ to: email,
+ subject: default_i18n_subject(app_name: ArchiveConfig.APP_SHORT_NAME, collection_title: @collection.title)
+ )
+ end
+
+ # This is sent at the end of matching, i.e., after assignments are generated.
+ # It is also sent when assignments are regenerated.
+ def potential_match_generation_notification(collection_id, email)
+ @collection = Collection.find(collection_id)
+ @is_collection_email = (email == @collection.collection_email)
+ mail(
+ to: email,
+ subject: default_i18n_subject(app_name: ArchiveConfig.APP_SHORT_NAME, collection_title: @collection.title)
+ )
+ end
+
+ def challenge_assignment_notification(collection_id, assigned_user_id, assignment_id)
+ @collection = Collection.find(collection_id)
+ @assigned_user = User.find(assigned_user_id)
+ @assignment = ChallengeAssignment.find(assignment_id)
+ @request = @assignment.request_signup
+ mail(
+ to: @assigned_user.email,
+ subject: default_i18n_subject(app_name: ArchiveConfig.APP_SHORT_NAME, collection_title: @collection.title)
+ )
+ end
+
+ # Asks a user to validate and activate their new account
+ def signup_notification(user_id)
+ @user = User.find(user_id)
+ I18n.with_locale(@user.preference.locale_for_mails) do
+ mail(
+ to: @user.email,
+ subject: t("user_mailer.signup_notification.subject", app_name: ArchiveConfig.APP_SHORT_NAME)
+ )
+ end
+ end
+
+ # Confirms to a user that their email was changed
+ def change_email(user_id, old_email, new_email)
+ @user = User.find(user_id)
+ @old_email = old_email
+ @new_email = new_email
+ @pac_footer = true
+ mail(
+ to: @old_email,
+ subject: default_i18n_subject(app_name: ArchiveConfig.APP_SHORT_NAME)
+ )
+ end
+
+ def change_username(user, old_username)
+ @user = user
+ @old_username = old_username
+ @new_username = user.login
+ @next_change_time = user.renamed_at + ArchiveConfig.USER_RENAME_LIMIT_DAYS.days
+ mail(
+ to: @user.email,
+ subject: default_i18n_subject(app_name: ArchiveConfig.APP_SHORT_NAME)
+ )
+ end
+
+ ### WORKS NOTIFICATIONS ###
+
+ # Sends email when an archivist adds someone as a co-creator.
+ def creatorship_notification_archivist(creatorship_id, archivist_id)
+ @creatorship = Creatorship.find(creatorship_id)
+ @archivist = User.find(archivist_id)
+ @user = @creatorship.pseud.user
+ @creation = @creatorship.creation
+ mail(
+ to: @user.email,
+ subject: t("user_mailer.creatorship_notification_archivist.subject",
+ app_name: ArchiveConfig.APP_SHORT_NAME)
+ )
+ end
+
+ # Sends email when a user is added as a co-creator
+ def creatorship_notification(creatorship_id, adding_user_id)
+ @creatorship = Creatorship.find(creatorship_id)
+ @adding_user = User.find(adding_user_id)
+ @user = @creatorship.pseud.user
+ @creation = @creatorship.creation
+ mail(
+ to: @user.email,
+ subject: t("user_mailer.creatorship_notification.subject",
+ app_name: ArchiveConfig.APP_SHORT_NAME)
+ )
+ end
+
+ # Sends email when a user is added as an unapproved/pending co-creator
+ def creatorship_request(creatorship_id, inviting_user_id)
+ @creatorship = Creatorship.find(creatorship_id)
+ @inviting_user = User.find(inviting_user_id)
+ @user = @creatorship.pseud.user
+ @creation = @creatorship.creation
+ mail(
+ to: @user.email,
+ subject: t("user_mailer.creatorship_request.subject",
+ app_name: ArchiveConfig.APP_SHORT_NAME)
+ )
+ end
+
+ # Sends emails to creators whose stories were listed as the inspiration of another work
+ def related_work_notification(user_id, related_work_id)
+ @user = User.find(user_id)
+ @related_work = RelatedWork.find(related_work_id)
+ @related_parent_link = url_for(controller: :works, action: :show, id: @related_work.parent)
+ @related_child_link = url_for(controller: :works, action: :show, id: @related_work.work)
+ mail(
+ to: @user.email,
+ subject: default_i18n_subject(app_name: ArchiveConfig.APP_SHORT_NAME)
+ )
+ end
+
+ # Emails a recipient to say that a gift has been posted for them
+ def recipient_notification(user_id, work_id, collection_id = nil)
+ @user = User.find(user_id)
+ @work = Work.find(work_id)
+ @collection = Collection.find(collection_id) if collection_id
+ subject = if @collection
+ t("user_mailer.recipient_notification.subject.collection",
+ app_name: ArchiveConfig.APP_SHORT_NAME,
+ collection_title: @collection.title)
+ else
+ t("user_mailer.recipient_notification.subject.no_collection",
+ app_name: ArchiveConfig.APP_SHORT_NAME)
+ end
+ mail(
+ to: @user.email,
+ subject: subject
+ )
+ end
+
+ # Emails a prompter to say that a response has been posted to their prompt
+ def prompter_notification(work_id, collection_id = nil)
+ @work = Work.find(work_id)
+ @collection = Collection.find(collection_id) if collection_id
+ @work.challenge_claims.each do |claim|
+ user = User.find(claim.request_signup.pseud.user.id)
+ I18n.with_locale(user.preference.locale_for_mails) do
+ mail(
+ to: user.email,
+ subject: "[#{ArchiveConfig.APP_SHORT_NAME}] A response to your prompt"
+ )
+ end
+ end
+ end
+
+ # Sends email to creators when a creation is deleted
+ # NOTE: this must be sent synchronously! otherwise the work will no longer be there to send
+ # TODO refactor to make it asynchronous by passing the content in the method
+ def delete_work_notification(user, work, deleter)
+ @user = user
+ @work = work
+ @deleter = deleter
+ download = Download.new(@work, mime_type: "text/html", include_draft_chapters: true)
+ html = DownloadWriter.new(download).generate_html
+ html = ::Mail::Encodings::Base64.encode(html)
+ attachments["#{download.file_name}.html"] = { content: html, encoding: "base64" }
+ attachments["#{download.file_name}.txt"] = { content: html, encoding: "base64" }
+
+ mail(
+ to: user.email,
+ subject: default_i18n_subject(app_name: ArchiveConfig.APP_SHORT_NAME)
+ )
+ end
+
+ # Sends email to creators when a creation is deleted by an admin
+ # NOTE: this must be sent synchronously! otherwise the work will no longer be there to send
+ # TODO refactor to make it asynchronous by passing the content in the method
+ def admin_deleted_work_notification(user, work)
+ @user = user
+ @work = work
+ download = Download.new(@work, mime_type: "text/html", include_draft_chapters: true)
+ html = DownloadWriter.new(download).generate_html
+ html = ::Mail::Encodings::Base64.encode(html)
+ attachments["#{download.file_name}.html"] = { content: html, encoding: "base64" }
+ attachments["#{download.file_name}.txt"] = { content: html, encoding: "base64" }
+
+ mail(
+ to: user.email,
+ subject: t("user_mailer.admin_deleted_work_notification.subject", app_name: ArchiveConfig.APP_SHORT_NAME)
+ )
+ end
+
+ # Sends email to creators when a creation is hidden by an admin
+ def admin_hidden_work_notification(creation_ids, user_id)
+ @pac_footer = true
+ @user = User.find_by(id: user_id)
+ @works = Work.where(id: creation_ids)
+ return if @works.empty?
+
+ mail(
+ to: @user.email,
+ subject: default_i18n_subject(app_name: ArchiveConfig.APP_SHORT_NAME, count: @works.size)
+ )
+ end
+
+ def admin_spam_work_notification(creation_id, user_id)
+ @user = User.find_by(id: user_id)
+ @work = Work.find_by(id: creation_id)
+
+ mail(
+ to: @user.email,
+ subject: default_i18n_subject(app_name: ArchiveConfig.APP_SHORT_NAME)
+ )
+ end
+
+ ### OTHER NOTIFICATIONS ###
+
+ # archive feedback
+ def feedback(feedback_id)
+ feedback = Feedback.find(feedback_id)
+ return unless feedback.email
+
+ @summary = feedback.summary
+ @comment = feedback.comment
+ @username = feedback.username if feedback.username.present?
+ @language = feedback.language
+ mail(
+ to: feedback.email,
+ subject: default_i18n_subject(app_name: ArchiveConfig.APP_SHORT_NAME, summary: strip_html_breaks_simple(feedback.summary))
+ )
+ end
+
+ def abuse_report(abuse_report_id)
+ abuse_report = AbuseReport.find(abuse_report_id)
+ @username = abuse_report.username
+ @email = abuse_report.email
+ @url = abuse_report.url
+ @summary = abuse_report.summary
+ @comment = abuse_report.comment
+ mail(
+ to: abuse_report.email,
+ subject: t("user_mailer.abuse_report.subject", app_name: ArchiveConfig.APP_SHORT_NAME, summary: strip_html_breaks_simple(@summary))
+ )
+ end
+end
diff --git a/app/models/abuse_report.rb b/app/models/abuse_report.rb
new file mode 100755
index 0000000..0ea537c
--- /dev/null
+++ b/app/models/abuse_report.rb
@@ -0,0 +1,214 @@
+class AbuseReport < ApplicationRecord
+ validates :email, email_format: { allow_blank: false }
+ validates_presence_of :language
+ validates_presence_of :summary
+ validates_presence_of :comment
+ validates :url, presence: true, length: { maximum: 2080 }
+ validate :url_is_not_over_reported
+ validate :email_is_not_over_reporting
+ validates_length_of :summary, maximum: ArchiveConfig.FEEDBACK_SUMMARY_MAX,
+ too_long: ts('must be less than %{max}
+ characters long.',
+ max: ArchiveConfig.FEEDBACK_SUMMARY_MAX_DISPLAYED)
+
+ before_validation :truncate_url, if: :will_save_change_to_url?
+
+ # It doesn't have the type set properly in the database, so override it here:
+ attribute :summary_sanitizer_version, :integer, default: 0
+
+ # Truncates the user-provided URL to the maximum we can store in the database. We don't want to reject reports with very long URLs, but we need to do
+ # something to avoid a 500 error for long URLs.
+ def truncate_url
+ self.url = url[0..2079]
+ end
+
+ validate :check_for_spam
+ def check_for_spam
+ approved = logged_in_with_matching_email? || !Akismetor.spam?(akismet_attributes)
+ errors.add(:base, ts("This report looks like spam to our system!")) unless approved
+ end
+
+ def logged_in_with_matching_email?
+ User.current_user.present? && User.current_user.email.downcase == email.downcase
+ end
+
+ def akismet_attributes
+ name = username ? username : ""
+ # If the user is logged in and we're sending info to Akismet, we can assume
+ # the email does not match.
+ role = User.current_user.present? ? "user-with-nonmatching-email" : "guest"
+ {
+ comment_type: "contact-form",
+ key: ArchiveConfig.AKISMET_KEY,
+ blog: ArchiveConfig.AKISMET_NAME,
+ user_ip: ip_address,
+ user_role: role,
+ comment_author: name,
+ comment_author_email: email,
+ comment_content: comment
+ }
+ end
+
+ scope :by_date, -> { order('created_at DESC') }
+
+ # Standardize the format of work, chapter, and profile URLs to get it ready
+ # for the url_is_not_over_reported validation.
+ # Work URLs: "works/123"
+ # Chapter URLs: "chapters/123"
+ # Profile URLs: "users/username"
+ before_validation :standardize_url, on: :create
+ def standardize_url
+ return unless url =~ %r{((chapters|works)/\d+)} || url =~ %r{(users\/\w+)}
+
+ self.url = add_scheme_to_url(url)
+ self.url = clean_url(url)
+ self.url = add_work_id_to_url(self.url)
+ end
+
+ def add_scheme_to_url(url)
+ uri = Addressable::URI.parse(url)
+ return url unless uri.scheme.nil?
+
+ "https://#{uri}"
+ end
+
+ # Clean work or profile URLs so we can prevent the same URLs from getting
+ # reported too many times.
+ # If the URL ends without a / at the end, add it: url_is_not_over_reported
+ # uses the / so "/works/1234" isn't a match for "/works/123"
+ def clean_url(url)
+ uri = Addressable::URI.parse(url)
+
+ uri.query = nil
+ uri.fragment = nil
+ uri.path += "/" unless uri.path.end_with? "/"
+
+ uri.to_s
+ end
+
+ # Get the chapter id from the URL and try to get the work id
+ # If successful, add the work id to the URL in front of "/chapters"
+ def add_work_id_to_url(url)
+ return url unless url =~ %r{(chapters/\d+)} && url !~ %r{(works/\d+)}
+
+ chapter_regex = %r{(chapters/)(\d+)}
+ regex_groups = chapter_regex.match url
+ chapter_id = regex_groups[2]
+ work_id = Chapter.find_by(id: chapter_id).try(:work_id)
+
+ return url if work_id.nil?
+
+ uri = Addressable::URI.parse(url)
+ uri.path = "/works/#{work_id}" + uri.path
+
+ uri.to_s
+ end
+
+ validate :url_on_archive, if: :will_save_change_to_url?
+ def url_on_archive
+ parsed_url = Addressable::URI.heuristic_parse(url)
+ errors.add(:url, :not_on_archive) unless ArchiveConfig.PERMITTED_HOSTS.include?(parsed_url.host)
+ rescue Addressable::URI::InvalidURIError
+ errors.add(:url, :not_on_archive)
+ end
+
+ def email_and_send
+ UserMailer.abuse_report(id).deliver_later
+ send_report
+ end
+
+ def send_report
+ return unless zoho_enabled?
+
+ reporter = AbuseReporter.new(
+ title: summary,
+ description: comment,
+ language: language,
+ email: email,
+ username: username,
+ ip_address: ip_address,
+ url: url,
+ creator_ids: creator_ids
+ )
+ response = reporter.send_report!
+ ticket_id = response["id"]
+ return if ticket_id.blank?
+
+ attach_work_download(ticket_id)
+ end
+
+ def creator_ids
+ work_id = reported_work_id
+ return unless work_id
+
+ work = Work.find_by(id: work_id)
+ return "deletedwork" unless work
+
+ ids = work.pseuds.pluck(:user_id).push(*work.original_creators.pluck(:user_id)).uniq.sort
+ ids.prepend("orphanedwork") if ids.delete(User.orphan_account.id)
+ ids.join(", ")
+ end
+
+ # ID of the reported work, unless the report is about comment(s) on the work
+ def reported_work_id
+ comments = url[%r{/comments/}, 0]
+ url[%r{/works/(\d+)}, 1] if comments.nil?
+ end
+
+ def attach_work_download(ticket_id)
+ work_id = reported_work_id
+ return unless work_id
+
+ work = Work.find_by(id: work_id)
+ ReportAttachmentJob.perform_later(ticket_id, work) if work
+ end
+
+ # if the URL clearly belongs to a work (i.e. contains "/works/123")
+ # or a user profile (i.e. contains "/users/username")
+ # make sure it isn't reported more than ABUSE_REPORTS_PER_WORK_MAX
+ # or ABUSE_REPORTS_PER_USER_MAX times per month
+ def url_is_not_over_reported
+ message = ts('This page has already been reported. Our volunteers only
+ need one report in order to investigate and resolve an issue,
+ so please be patient and do not submit another report.')
+ if url =~ /\/works\/\d+/
+ # use "/works/123/" to avoid matching chapter or external work ids
+ work_params_only = url.match(/\/works\/\d+\//).to_s
+ existing_reports_total = AbuseReport.where('created_at > ? AND
+ url LIKE ?',
+ 1.month.ago,
+ "%#{work_params_only}%").count
+ if existing_reports_total >= ArchiveConfig.ABUSE_REPORTS_PER_WORK_MAX
+ errors.add(:base, message)
+ end
+ elsif url =~ /\/users\/\w+/
+ user_params_only = url.match(/\/users\/\w+\//).to_s
+ existing_reports_total = AbuseReport.where('created_at > ? AND
+ url LIKE ?',
+ 1.month.ago,
+ "%#{user_params_only}%").count
+ if existing_reports_total >= ArchiveConfig.ABUSE_REPORTS_PER_USER_MAX
+ errors.add(:base, message)
+ end
+ end
+ end
+
+ def email_is_not_over_reporting
+ existing_reports_total = AbuseReport.where("created_at > ? AND
+ email LIKE ?",
+ 1.day.ago,
+ email).count
+ return if existing_reports_total < ArchiveConfig.ABUSE_REPORTS_PER_EMAIL_MAX
+
+ errors.add(:base, ts("You have reached our daily reporting limit. To keep our
+ volunteers from being overwhelmed, please do not seek
+ out violations to report, but only report violations you
+ encounter during your normal browsing."))
+ end
+
+ private
+
+ def zoho_enabled?
+ %w[staging production].include?(Rails.env)
+ end
+end
diff --git a/app/models/admin.rb b/app/models/admin.rb
new file mode 100755
index 0000000..c8573d6
--- /dev/null
+++ b/app/models/admin.rb
@@ -0,0 +1,43 @@
+class Admin < ApplicationRecord
+ VALID_ROLES = %w[superadmin board board_assistants_team communications development_and_membership docs elections legal translation tag_wrangling support policy_and_abuse open_doors].freeze
+
+ serialize :roles, type: Array, coder: YAML, yaml: { permitted_classes: [String] }
+
+ devise :database_authenticatable,
+ :lockable,
+ :recoverable,
+ :validatable,
+ password_length: ArchiveConfig.ADMIN_PASSWORD_LENGTH_MIN..ArchiveConfig.ADMIN_PASSWORD_LENGTH_MAX,
+ reset_password_within: ArchiveConfig.DAYS_UNTIL_ADMIN_RESET_PASSWORD_LINK_EXPIRES.days,
+ lock_strategy: :none,
+ unlock_strategy: :none
+ devise :pwned_password unless Rails.env.test?
+
+ include BackwardsCompatiblePasswordDecryptor
+
+ has_many :log_items
+ has_many :invitations, as: :creator
+ has_many :wrangled_tags, class_name: 'Tag', as: :last_wrangler
+
+ validates :login,
+ presence: true,
+ uniqueness: true,
+ length: { in: ArchiveConfig.LOGIN_LENGTH_MIN..ArchiveConfig.LOGIN_LENGTH_MAX }
+ validates_presence_of :password_confirmation, if: :new_record?
+ validates_confirmation_of :password, if: :new_record?
+
+ validate :allowed_roles
+ def allowed_roles
+ return unless roles && (roles - VALID_ROLES).present?
+
+ errors.add(:roles, :invalid)
+ end
+
+ # For requesting admins set a new password before their first login. Uses same
+ # mechanism as password reset requests, but different email notification.
+ after_create :send_set_password_notification
+ def send_set_password_notification
+ token = set_reset_password_token
+ AdminMailer.set_password_notification(self, token).deliver
+ end
+end
diff --git a/app/models/admin_activity.rb b/app/models/admin_activity.rb
new file mode 100755
index 0000000..1cc3aca
--- /dev/null
+++ b/app/models/admin_activity.rb
@@ -0,0 +1,25 @@
+class AdminActivity < ApplicationRecord
+ belongs_to :admin
+ belongs_to :target, polymorphic: true
+
+ validates_presence_of :admin_id
+
+ delegate :login, to: :admin, prefix: true
+
+ def self.log_action(admin, target, options={})
+ self.create do |activity|
+ activity.admin = admin
+ activity.target = target
+ activity.action = options[:action]
+ activity.summary = options[:summary]
+ end
+ end
+
+ def target_name
+ if target.is_a?(Pseud)
+ "Pseud #{target.name} (#{target&.user&.login})"
+ else
+ "#{target_type} #{target_id}"
+ end
+ end
+end
diff --git a/app/models/admin_banner.rb b/app/models/admin_banner.rb
new file mode 100755
index 0000000..321f497
--- /dev/null
+++ b/app/models/admin_banner.rb
@@ -0,0 +1,28 @@
+class AdminBanner < ApplicationRecord
+ validates_presence_of :content
+
+ after_save :expire_cached_admin_banner, if: :should_expire_cache?
+ after_destroy :expire_cached_admin_banner, if: :active?
+
+ # update admin banner setting for all users when banner notice is changed
+ def self.banner_on
+ Preference.update_all("banner_seen = false")
+ end
+
+ def self.active?
+ self.active?
+ end
+
+ # we should expire the cache when an active banner is changed or when a banner starts or stops being active
+ def should_expire_cache?
+ self.saved_change_to_active? || self.active?
+ end
+
+ private
+
+ def expire_cached_admin_banner
+ unless Rails.env.development?
+ Rails.cache.delete("admin_banner")
+ end
+ end
+end
diff --git a/app/models/admin_blacklisted_email.rb b/app/models/admin_blacklisted_email.rb
new file mode 100755
index 0000000..f69a304
--- /dev/null
+++ b/app/models/admin_blacklisted_email.rb
@@ -0,0 +1,38 @@
+class AdminBlacklistedEmail < ApplicationRecord
+ before_validation :canonicalize_email
+ after_create :remove_invite_requests
+
+ validates :email, presence: true, uniqueness: { case_sensitive: false }, email_format: true
+
+ def canonicalize_email
+ self.email = AdminBlacklistedEmail.canonical_email(self.email) if self.email
+ end
+
+ def remove_invite_requests
+ InviteRequest.where(simplified_email: self.email).destroy_all
+ end
+
+ # Produces a canonical version of a given email reduced to its simplest form
+ # This is what we store in the db, so that if we subsequently check a submitted email,
+ # we only need to clean the submitted email the same way and look for it.
+ def self.canonical_email(email_to_clean)
+ canonical_email = email_to_clean.downcase
+ canonical_email.strip!
+ canonical_email.sub!('@googlemail.com','@gmail.com')
+
+ # strip periods from gmail addresses
+ if (matchdata = canonical_email.match(/(.+)\@gmail\.com/))
+ canonical_email = matchdata[1].gsub('.', '') + "@gmail.com"
+ end
+
+ # strip out anything after a +
+ canonical_email.sub!(/(\+.*)(@.*$)/, '\2')
+
+ return canonical_email
+ end
+
+ # Check if an email is
+ def self.is_blacklisted?(email_to_check)
+ AdminBlacklistedEmail.exists?(email: AdminBlacklistedEmail.canonical_email(email_to_check))
+ end
+end
diff --git a/app/models/admin_post.rb b/app/models/admin_post.rb
new file mode 100755
index 0000000..d1a873a
--- /dev/null
+++ b/app/models/admin_post.rb
@@ -0,0 +1,140 @@
+class AdminPost < ApplicationRecord
+ self.per_page = 8 # option for WillPaginate
+
+ acts_as_commentable
+ enum :comment_permissions, {
+ enable_all: 0,
+ disable_anon: 1,
+ disable_all: 2
+ }, default: :disable_anon, suffix: :comments
+
+ belongs_to :language
+ belongs_to :translated_post, class_name: "AdminPost"
+ has_many :translations, class_name: "AdminPost", foreign_key: "translated_post_id", dependent: :destroy
+ has_many :admin_post_taggings
+ has_many :tags, through: :admin_post_taggings, source: :admin_post_tag
+
+ validates_presence_of :title
+ validates_length_of :title,
+ minimum: ArchiveConfig.TITLE_MIN,
+ too_short: ts("must be at least %{min} characters long.", min: ArchiveConfig.TITLE_MIN)
+
+ validates_length_of :title,
+ maximum: ArchiveConfig.TITLE_MAX,
+ too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.TITLE_MAX)
+
+ validates_presence_of :content
+ validates_length_of :content, minimum: ArchiveConfig.CONTENT_MIN,
+ too_short: ts("must be at least %{min} characters long.", min: ArchiveConfig.CONTENT_MIN)
+
+ validates_length_of :content, maximum: ArchiveConfig.CONTENT_MAX,
+ too_long: ts("cannot be more than %{max} characters long.", max: ArchiveConfig.CONTENT_MAX)
+
+ validate :translated_post_must_exist
+
+ validate :translated_post_language_must_differ
+
+ scope :non_translated, -> { where("translated_post_id IS NULL") }
+
+ scope :for_homepage, -> { order("created_at DESC").limit(ArchiveConfig.NUMBER_OF_ITEMS_VISIBLE_ON_HOMEPAGE) }
+
+ before_save :inherit_translated_post_comment_permissions, :inherit_translated_post_tags
+ after_save :expire_cached_home_admin_posts, :update_translation_comment_permissions, :update_translation_tags
+ after_destroy :expire_cached_home_admin_posts
+
+ # Return the name to link comments to for this object
+ def commentable_name
+ self.title
+ end
+ def commentable_owners
+ begin
+ [Admin.find(self.admin_id)]
+ rescue
+ []
+ end
+ end
+
+ def tag_list
+ tags.map{ |t| t.name }.join(", ")
+ end
+
+ def tag_list=(list)
+ return if translated_post_id.present?
+
+ self.tags = list.split(",").uniq.collect { |t|
+ AdminPostTag.fetch(name: t.strip, language_id: self.language_id, post: self)
+ }.compact
+ end
+
+ def translated_post_must_exist
+ if translated_post_id.present? && AdminPost.find_by(id: translated_post_id).nil?
+ errors.add(:translated_post_id, "does not exist")
+ end
+ end
+
+ def translated_post_language_must_differ
+ return if translated_post.blank?
+ return unless translated_post.language == language
+
+ errors.add(:translated_post_id, "cannot be same language as original post")
+ end
+
+ ####################
+ # DELAYED JOBS
+ ####################
+
+ include AsyncWithResque
+ @queue = :utilities
+
+ # Turns off comments for all posts that are older than the configured time period.
+ # If the configured period is nil or less than 1 day, no action is taken.
+ def self.disable_old_post_comments
+ return unless ArchiveConfig.ADMIN_POST_COMMENTING_EXPIRATION_DAYS&.positive?
+
+ where.not(comment_permissions: :disable_all)
+ .where(created_at: ..ArchiveConfig.ADMIN_POST_COMMENTING_EXPIRATION_DAYS.days.ago)
+ .update_all(comment_permissions: :disable_all)
+ end
+
+ private
+
+ def expire_cached_home_admin_posts
+ unless Rails.env.development?
+ Rails.cache.delete("home/index/home_admin_posts")
+ end
+ end
+
+ def inherit_translated_post_comment_permissions
+ return if translated_post.blank?
+
+ self.comment_permissions = translated_post.comment_permissions
+ end
+
+ def inherit_translated_post_tags
+ return if translated_post.blank?
+
+ self.tags = translated_post.tags
+ end
+
+ def update_translation_comment_permissions
+ return if translations.blank?
+
+ transaction do
+ translations.find_each do |post|
+ post.comment_permissions = self.comment_permissions
+ post.save
+ end
+ end
+ end
+
+ def update_translation_tags
+ return if translations.blank?
+
+ transaction do
+ translations.find_each do |post|
+ post.tags = self.tags
+ post.save
+ end
+ end
+ end
+end
diff --git a/app/models/admin_post_tag.rb b/app/models/admin_post_tag.rb
new file mode 100755
index 0000000..93c46db
--- /dev/null
+++ b/app/models/admin_post_tag.rb
@@ -0,0 +1,21 @@
+class AdminPostTag < ApplicationRecord
+ belongs_to :language
+ has_many :admin_post_taggings
+ has_many :admin_posts, through: :admin_post_taggings
+
+ validates_presence_of :name
+ validates :name, uniqueness: true
+ validates_format_of :name, with: /[a-zA-Z0-9-]+$/, multiline: true
+
+ # Find or create by name, and set the language if it's a new record
+ def self.fetch(options)
+ unless options[:name].blank?
+ tag = self.find_by_name(options[:name])
+ return tag unless tag.nil?
+ tag = AdminPostTag.new(name: options[:name],
+ language_id: options[:language])
+ tag.save ? tag : nil
+ end
+ end
+
+end
diff --git a/app/models/admin_post_tagging.rb b/app/models/admin_post_tagging.rb
new file mode 100755
index 0000000..894ec7f
--- /dev/null
+++ b/app/models/admin_post_tagging.rb
@@ -0,0 +1,4 @@
+class AdminPostTagging < ApplicationRecord
+ belongs_to :admin_post
+ belongs_to :admin_post_tag
+end
diff --git a/app/models/admin_setting.rb b/app/models/admin_setting.rb
new file mode 100755
index 0000000..3b40e7f
--- /dev/null
+++ b/app/models/admin_setting.rb
@@ -0,0 +1,102 @@
+class AdminSetting < ApplicationRecord
+ include AfterCommitEverywhere
+
+ belongs_to :last_updated, class_name: 'Admin', foreign_key: :last_updated_by
+ validates_presence_of :last_updated_by
+ validates :invite_from_queue_number, numericality: { greater_than_or_equal_to: 1,
+ allow_nil: false, message: "must be greater than 0. To disable invites, uncheck the appropriate setting." }
+
+ before_save :update_invite_date
+ before_update :check_filter_status
+
+ belongs_to :default_skin, class_name: 'Skin'
+
+ DEFAULT_SETTINGS = {
+ invite_from_queue_enabled?: ArchiveConfig.INVITE_FROM_QUEUE_ENABLED,
+ request_invite_enabled?: false,
+ invite_from_queue_at: nil,
+ invite_from_queue_number: ArchiveConfig.INVITE_FROM_QUEUE_NUMBER,
+ invite_from_queue_frequency: ArchiveConfig.INVITE_FROM_QUEUE_FREQUENCY,
+ account_creation_enabled?: ArchiveConfig.ACCOUNT_CREATION_ENABLED,
+ days_to_purge_unactivated: 2,
+ suspend_filter_counts?: false,
+ enable_test_caching?: false,
+ cache_expiration: 10,
+ tag_wrangling_off?: false,
+ downloads_enabled?: true,
+ disable_support_form?: false,
+ default_skin_id: nil
+ }.freeze
+
+ # Create AdminSetting.first on a blank database. We call this only in an initializer
+ # or a testing setup, not as part of any heavily used methods (e.g. AdminSetting.current).
+ def self.default
+ return AdminSetting.first if AdminSetting.first
+
+ settings = AdminSetting.new(
+ invite_from_queue_enabled: ArchiveConfig.INVITE_FROM_QUEUE_ENABLED,
+ invite_from_queue_number: ArchiveConfig.INVITE_FROM_QUEUE_NUMBER,
+ invite_from_queue_frequency: ArchiveConfig.INVITE_FROM_QUEUE_FREQUENCY,
+ account_creation_enabled: ArchiveConfig.ACCOUNT_CREATION_ENABLED,
+ days_to_purge_unactivated: 2
+ )
+ settings.save(validate: false)
+ settings
+ end
+
+ def self.current
+ Rails.cache.fetch("admin_settings-v2", race_condition_ttl: 10.seconds) { AdminSetting.first } || OpenStruct.new(DEFAULT_SETTINGS)
+ end
+
+ class << self
+ delegate *DEFAULT_SETTINGS.keys, :to => :current
+ delegate :default_skin, to: :current
+ end
+
+ # run hourly with the resque scheduler
+ def self.check_queue
+ return unless self.invite_from_queue_enabled? && InviteRequest.any? && Time.current >= self.invite_from_queue_at
+
+ new_time = Time.current + self.invite_from_queue_frequency.hours
+ current_setting = self.first
+ current_setting.invite_from_queue_at = new_time
+ current_setting.save(validate: false, touch: false)
+ InviteFromQueueJob.perform_now(count: invite_from_queue_number)
+ end
+
+ @queue = :admin
+ # This will be called by a worker when a job needs to be processed
+ def self.perform(method, *args)
+ self.send(method, *args)
+ end
+
+ after_save :recache_settings
+ def recache_settings
+ # If the default skin has just been created and set, it will have a closed
+ # file handle from attaching a preview image, and cannot be serialized for
+ # caching. To avoid that, we need to reload a fresh copy of the record,
+ # within the current transaction to guarantee up-to-date data.
+ self.reload
+
+ # However, we only cache it if the transaction is successful.
+ after_commit { Rails.cache.write("admin_settings-v2", self) }
+ end
+
+ private
+
+ def check_filter_status
+ if self.suspend_filter_counts_changed?
+ if self.suspend_filter_counts?
+ self.suspend_filter_counts_at = Time.now
+ else
+ #FilterTagging.update_filter_counts_since(self.suspend_filter_counts_at)
+ end
+ end
+ end
+
+ def update_invite_date
+ if self.invite_from_queue_frequency_changed?
+ self.invite_from_queue_at = Time.current + self.invite_from_queue_frequency.hours
+ end
+ end
+end
diff --git a/app/models/api_key.rb b/app/models/api_key.rb
new file mode 100755
index 0000000..484ff42
--- /dev/null
+++ b/app/models/api_key.rb
@@ -0,0 +1,8 @@
+class ApiKey < ApplicationRecord
+ validates :name, presence: true, uniqueness: true
+ validates :access_token, presence: true, uniqueness: true
+
+ before_validation(on: :create) do
+ self.access_token = SecureRandom.hex
+ end
+end
diff --git a/app/models/application_record.rb b/app/models/application_record.rb
new file mode 100755
index 0000000..b050454
--- /dev/null
+++ b/app/models/application_record.rb
@@ -0,0 +1,18 @@
+class ApplicationRecord < ActiveRecord::Base
+ self.abstract_class = true
+ self.per_page = ArchiveConfig.ITEMS_PER_PAGE
+
+ before_save :update_sanitizer_version
+
+ def update_sanitizer_version
+ ArchiveConfig.FIELDS_ALLOWING_HTML.each do |field|
+ if self.will_save_change_to_attribute?(field)
+ self.send("#{field}_sanitizer_version=", ArchiveConfig.SANITIZER_VERSION)
+ end
+ end
+ end
+
+ def self.random_order
+ order(Arel.sql("RAND()"))
+ end
+end
diff --git a/app/models/archive_faq.rb b/app/models/archive_faq.rb
new file mode 100755
index 0000000..8160272
--- /dev/null
+++ b/app/models/archive_faq.rb
@@ -0,0 +1,32 @@
+class ArchiveFaq < ApplicationRecord
+ acts_as_list
+ translates :title
+ translation_class.include(Globalized)
+
+ has_many :questions, -> { order(:position) }, dependent: :destroy
+ accepts_nested_attributes_for :questions, allow_destroy: true
+
+ validates :slug, presence: true, uniqueness: true
+
+ belongs_to :language
+
+ before_validation :set_slug
+ def set_slug
+ if I18n.locale == :en
+ self.slug = self.title.parameterize
+ end
+ end
+
+ # Change the positions of the questions in the archive_faq
+ def reorder_list(positions)
+ SortableList.new(self.questions.in_order).reorder_list(positions)
+ end
+
+ def to_param
+ slug_was
+ end
+
+ def self.reorder_list(positions)
+ SortableList.new(self.order('position ASC')).reorder_list(positions)
+ end
+end
diff --git a/app/models/archive_warning.rb b/app/models/archive_warning.rb
new file mode 100755
index 0000000..5fed25e
--- /dev/null
+++ b/app/models/archive_warning.rb
@@ -0,0 +1,31 @@
+class ArchiveWarning < Tag
+ validates :canonical, presence: { message: "^Only canonical warning tags are allowed." }
+
+ NAME = ArchiveConfig.WARNING_CATEGORY_NAME
+
+ DISPLAY_NAME_MAPPING = {
+ ArchiveConfig.WARNING_DEFAULT_TAG_NAME => ArchiveConfig.WARNING_DEFAULT_TAG_DISPLAY_NAME,
+ ArchiveConfig.WARNING_NONE_TAG_NAME => ArchiveConfig.WARNING_NONE_TAG_DISPLAY_NAME
+ }.freeze
+
+ def self.warning_tags
+ Set[ArchiveConfig.WARNING_DEFAULT_TAG_NAME,
+ ArchiveConfig.WARNING_NONE_TAG_NAME,
+ ArchiveConfig.WARNING_VIOLENCE_TAG_NAME,
+ ArchiveConfig.WARNING_DEATH_TAG_NAME,
+ ArchiveConfig.WARNING_NONCON_TAG_NAME,
+ ArchiveConfig.WARNING_CHAN_TAG_NAME]
+ end
+
+ def self.warning?(warning)
+ warning_tags.include? warning
+ end
+
+ def self.label_name
+ "Warnings"
+ end
+
+ def display_name
+ DISPLAY_NAME_MAPPING[name] || name
+ end
+end
diff --git a/app/models/banned.rb b/app/models/banned.rb
new file mode 100755
index 0000000..431f94c
--- /dev/null
+++ b/app/models/banned.rb
@@ -0,0 +1,5 @@
+class Banned < Tag
+
+ NAME = ArchiveConfig.BANNED_CATEGORY_NAME
+
+end
diff --git a/app/models/block.rb b/app/models/block.rb
new file mode 100755
index 0000000..f7937ee
--- /dev/null
+++ b/app/models/block.rb
@@ -0,0 +1,27 @@
+class Block < ApplicationRecord
+ belongs_to :blocker, class_name: "User"
+ belongs_to :blocked, class_name: "User"
+
+ validates :blocker, :blocked, presence: true
+ validates :blocked_id, uniqueness: { scope: :blocker_id }
+
+ validate :check_self
+ def check_self
+ errors.add(:blocked, :self) if blocked == blocker
+ end
+
+ validate :check_official, if: :blocked
+ def check_official
+ errors.add(:blocked, :official) if blocked.official
+ end
+
+ validate :check_block_limit
+ def check_block_limit
+ errors.add(:blocked, :limit) if blocker.blocked_users.count >= ArchiveConfig.MAX_BLOCKED_USERS
+ end
+
+ def blocked_byline=(byline)
+ pseud = Pseud.parse_byline(byline)
+ self.blocked = pseud.user unless pseud.nil?
+ end
+end
diff --git a/app/models/bookmark.rb b/app/models/bookmark.rb
new file mode 100755
index 0000000..e2d97f8
--- /dev/null
+++ b/app/models/bookmark.rb
@@ -0,0 +1,216 @@
+class Bookmark < ApplicationRecord
+ include Collectible
+ include Searchable
+ include Responder
+ include Taggable
+
+ belongs_to :bookmarkable, polymorphic: true, inverse_of: :bookmarks
+ belongs_to :pseud, optional: false
+
+ validates_length_of :bookmarker_notes,
+ maximum: ArchiveConfig.NOTES_MAX, too_long: ts("must be less than %{max} letters long.", max: ArchiveConfig.NOTES_MAX)
+
+ validate :not_already_bookmarked_by_user, on: :create
+ def not_already_bookmarked_by_user
+ return unless self.pseud && self.bookmarkable
+
+ return if self.pseud.user.bookmarks.where(bookmarkable: self.bookmarkable).empty?
+
+ errors.add(:base, ts("You have already bookmarked that."))
+ end
+
+ validate :check_new_external_work
+ def check_new_external_work
+ return unless bookmarkable.is_a?(ExternalWork) && bookmarkable.new_record?
+
+ errors.add(:base, "Fandom tag is required") if bookmarkable.fandom_string.blank?
+
+ return if bookmarkable.valid?
+
+ bookmarkable.errors.full_messages.each do |message|
+ errors.add(:base, message)
+ end
+ end
+
+ # renaming scope :public -> :is_public because otherwise it overlaps with the "public" keyword
+ scope :is_public, -> { where(private: false, hidden_by_admin: false) }
+ scope :not_public, -> { where(private: true) }
+ scope :not_private, -> { where(private: false) }
+ scope :since, lambda { |*args| where("bookmarks.created_at > ?", (args.first || 1.week.ago)) }
+ scope :recs, -> { where(rec: true) }
+ scope :order_by_created_at, -> { order("created_at DESC") }
+
+ scope :join_work, -> {
+ joins("LEFT JOIN works ON (bookmarks.bookmarkable_id = works.id AND bookmarks.bookmarkable_type = 'Work')").
+ merge(Work.visible_to_all)
+ }
+
+ scope :join_series, -> {
+ joins("LEFT JOIN series ON (bookmarks.bookmarkable_id = series.id AND bookmarks.bookmarkable_type = 'Series')").
+ merge(Series.visible_to_all)
+ }
+
+ scope :join_external_works, -> {
+ joins("LEFT JOIN external_works ON (bookmarks.bookmarkable_id = external_works.id AND bookmarks.bookmarkable_type = 'ExternalWork')").
+ merge(ExternalWork.visible_to_all)
+ }
+
+ scope :join_bookmarkable, -> {
+ joins("LEFT JOIN works ON (bookmarks.bookmarkable_id = works.id AND bookmarks.bookmarkable_type = 'Work')
+ LEFT JOIN series ON (bookmarks.bookmarkable_id = series.id AND bookmarks.bookmarkable_type = 'Series')
+ LEFT JOIN external_works ON (bookmarks.bookmarkable_id = external_works.id AND bookmarks.bookmarkable_type = 'ExternalWork')")
+ }
+
+ scope :visible_to_all, -> {
+ is_public.with_bookmarkable_visible_to_all
+ }
+
+ scope :visible_to_registered_user, -> {
+ is_public.with_bookmarkable_visible_to_registered_user
+ }
+
+ # Scope for retrieving bookmarks with a bookmarkable visible to registered
+ # users (regardless of the bookmark's hidden_by_admin/private status).
+ scope :with_bookmarkable_visible_to_registered_user, -> {
+ join_bookmarkable.where(
+ "(works.posted = 1 AND works.hidden_by_admin = 0) OR
+ (series.hidden_by_admin = 0) OR
+ (external_works.hidden_by_admin = 0)"
+ )
+ }
+
+ # Scope for retrieving bookmarks with a bookmarkable visible to logged-out
+ # users (regardless of the bookmark's hidden_by_admin/private status).
+ scope :with_bookmarkable_visible_to_all, -> {
+ join_bookmarkable.where(
+ "(works.posted = 1 AND works.restricted = 0 AND works.hidden_by_admin = 0) OR
+ (series.restricted = 0 AND series.hidden_by_admin = 0) OR
+ (external_works.hidden_by_admin = 0)"
+ )
+ }
+
+ # Scope for retrieving bookmarks with a missing bookmarkable (regardless of
+ # the bookmark's hidden_by_admin/private status).
+ scope :with_missing_bookmarkable, -> {
+ join_bookmarkable.where(
+ "works.id IS NULL AND series.id IS NULL AND external_works.id IS NULL"
+ )
+ }
+
+ scope :visible_to_admin, -> { not_private }
+
+ scope :latest, -> { is_public.order_by_created_at.limit(ArchiveConfig.ITEMS_PER_PAGE).join_work }
+
+ scope :for_blurb, -> { includes(:bookmarkable, :tags, :collections, pseud: [:user]) }
+
+ # a complicated dynamic scope here:
+ # if the user is an Admin, we use the "visible_to_admin" scope
+ # if the user is not a logged-in User, we use the "visible_to_all" scope
+ # otherwise, we use a join to get userids and then get all posted works that are either unhidden OR belong to this user.
+ # Note: in that last case we have to use select("DISTINCT works.") because of cases where the same user appears twice
+ # on a work.
+ scope :visible_to_user, lambda {|user|
+ if user.is_a?(Admin)
+ visible_to_admin
+ elsif !user.is_a?(User)
+ visible_to_all
+ else
+ select("DISTINCT bookmarks.*").
+ visible_to_registered_user.
+ joins("JOIN pseuds as p1 ON p1.id = bookmarks.pseud_id JOIN users ON users.id = p1.user_id").
+ where("bookmarks.hidden_by_admin = 0 OR users.id = ?", user.id)
+ end
+ }
+
+ # Use the current user to determine what works are visible
+ scope :visible, -> { visible_to_user(User.current_user) }
+
+ before_destroy :invalidate_bookmark_count
+ after_save :invalidate_bookmark_count, :update_pseud_index
+
+ after_create :update_work_stats
+ after_destroy :update_work_stats, :update_pseud_index
+
+ def invalidate_bookmark_count
+ work = Work.where(id: self.bookmarkable_id)
+ if work.present? && self.bookmarkable_type == 'Work'
+ work.first.invalidate_public_bookmarks_count
+ end
+ end
+
+ # We index the bookmark count, so if it should change, update the pseud
+ def update_pseud_index
+ return unless destroyed? || saved_change_to_id? || saved_change_to_private? || saved_change_to_hidden_by_admin?
+ IndexQueue.enqueue_id(Pseud, pseud_id, :background)
+ end
+
+ def visible?(current_user=User.current_user)
+ return true if current_user == self.pseud.user
+ unless current_user == :false || !current_user
+ # Admins should not see private bookmarks
+ return true if current_user.is_a?(Admin) && self.private == false
+ end
+ if !(self.private? || self.hidden_by_admin?)
+ if self.bookmarkable.nil?
+ # only show bookmarks for deleted works to the user who
+ # created the bookmark
+ return true if pseud.user == current_user
+ else
+ if self.bookmarkable_type == 'Work' || self.bookmarkable_type == 'Series' || self.bookmarkable_type == 'ExternalWork'
+ return true if self.bookmarkable.visible?(current_user)
+ else
+ return true
+ end
+ end
+ end
+ return false
+ end
+
+ # Returns the number of bookmarks on an item visible to the current user
+ def self.count_visible_bookmarks(bookmarkable, current_user=:false)
+ bookmarkable.bookmarks.visible.size
+ end
+
+ # TODO: Is this necessary anymore?
+ before_destroy :save_parent_info
+
+ # Because of the way the elasticsearch parent/child index is set up, we need
+ # to know what the bookmarkable type and id was in order to delete the
+ # bookmark from the index after it's been deleted from the database
+ def save_parent_info
+ expire_time = (Time.now + 2.weeks).to_i
+ REDIS_GENERAL.setex(
+ "deleted_bookmark_parent_#{self.id}",
+ expire_time,
+ "#{bookmarkable_id}-#{bookmarkable_type.underscore}"
+ )
+ end
+
+ #################################
+ ## SEARCH #######################
+ #################################
+
+ def document_json
+ BookmarkIndexer.new({}).document(self)
+ end
+
+ def bookmarker
+ pseud.try(:byline)
+ end
+
+ def with_notes
+ bookmarker_notes.present?
+ end
+
+ def collection_ids
+ approved_collections.pluck(:id, :parent_id).flatten.uniq.compact
+ end
+
+ def bookmarkable_date
+ if bookmarkable.respond_to?(:revised_at)
+ bookmarkable.revised_at
+ elsif bookmarkable.respond_to?(:updated_at)
+ bookmarkable.updated_at
+ end
+ end
+end
diff --git a/app/models/category.rb b/app/models/category.rb
new file mode 100755
index 0000000..b011110
--- /dev/null
+++ b/app/models/category.rb
@@ -0,0 +1,7 @@
+class Category < Tag
+ validates :canonical, presence: { message: "^Only canonical category tags are allowed." }
+
+ NAME = ArchiveConfig.CATEGORY_CATEGORY_NAME
+
+end
+
diff --git a/app/models/challenge_assignment.rb b/app/models/challenge_assignment.rb
new file mode 100755
index 0000000..e5cf994
--- /dev/null
+++ b/app/models/challenge_assignment.rb
@@ -0,0 +1,498 @@
+class ChallengeAssignment < ApplicationRecord
+ # We use "-1" to represent all the requested items matching
+ ALL = -1
+
+ belongs_to :collection
+ belongs_to :offer_signup, class_name: "ChallengeSignup"
+ belongs_to :request_signup, class_name: "ChallengeSignup"
+ belongs_to :pinch_hitter, class_name: "Pseud"
+ belongs_to :pinch_request_signup, class_name: "ChallengeSignup" # TODO: AO3-6851 Remove pinch_request_signup association from the challenge_assignments table
+ belongs_to :creation, polymorphic: true
+
+ # Make sure that the signups are an actual match if we're in the process of assigning
+ # (post-sending, all potential matches have been deleted!)
+ validate :signups_match, on: :update
+ def signups_match
+ if self.sent_at.nil? &&
+ self.request_signup.present? &&
+ self.offer_signup.present? &&
+ !self.request_signup.request_potential_matches.pluck(:offer_signup_id).include?(self.offer_signup_id)
+ errors.add(:base, ts("does not match. Did you mean to write-in a giver?"))
+ end
+ end
+
+ scope :for_request_signup, ->(signup) { where("request_signup_id = ?", signup.id) }
+
+ scope :for_offer_signup, ->(signup) { where("offer_signup_id = ?", signup.id) }
+
+ scope :in_collection, ->(collection) { where("challenge_assignments.collection_id = ?", collection.id) }
+
+ scope :defaulted, -> { where.not(defaulted_at: nil) }
+ scope :undefaulted, -> { where("defaulted_at IS NULL") }
+ scope :uncovered, -> { where("covered_at IS NULL") }
+ scope :covered, -> { where.not(covered_at: nil) }
+ scope :sent, -> { where.not(sent_at: nil) }
+
+ scope :with_pinch_hitter, -> { where.not(pinch_hitter_id: nil) }
+
+ scope :with_offer, -> { where("offer_signup_id IS NOT NULL OR pinch_hitter_id IS NOT NULL") }
+ scope :with_request, -> { where.not(request_signup_id: nil) }
+ scope :with_no_request, -> { where("request_signup_id IS NULL") }
+ scope :with_no_offer, -> { where("offer_signup_id IS NULL AND pinch_hitter_id IS NULL") }
+
+ # sorting by request/offer
+
+ REQUESTING_PSEUD_JOIN = "INNER JOIN challenge_signups ON challenge_assignments.request_signup_id = challenge_signups.id
+ INNER JOIN pseuds ON challenge_signups.pseud_id = pseuds.id".freeze
+
+ OFFERING_PSEUD_JOIN = "LEFT JOIN challenge_signups ON challenge_assignments.offer_signup_id = challenge_signups.id
+ INNER JOIN pseuds ON (challenge_assignments.pinch_hitter_id = pseuds.id OR challenge_signups.pseud_id = pseuds.id)".freeze
+
+ scope :order_by_requesting_pseud, -> { joins(REQUESTING_PSEUD_JOIN).order("pseuds.name") }
+
+ scope :order_by_offering_pseud, -> { joins(OFFERING_PSEUD_JOIN).order("pseuds.name") }
+
+ # Get all of a user's assignments
+ scope :by_offering_user, lambda { |user|
+ select("DISTINCT challenge_assignments.*")
+ .joins(OFFERING_PSEUD_JOIN)
+ .joins("INNER JOIN users ON pseuds.user_id = users.id")
+ .where("users.id = ?", user.id)
+ }
+
+ # sorting by fulfilled/posted status
+ COLLECTION_ITEMS_JOIN = "INNER JOIN collection_items ON (collection_items.collection_id = challenge_assignments.collection_id AND
+ collection_items.item_id = challenge_assignments.creation_id AND
+ collection_items.item_type = challenge_assignments.creation_type)"
+
+ COLLECTION_ITEMS_LEFT_JOIN = "LEFT JOIN collection_items ON (collection_items.collection_id = challenge_assignments.collection_id AND
+ collection_items.item_id = challenge_assignments.creation_id AND
+ collection_items.item_type = challenge_assignments.creation_type)"
+
+ WORKS_JOIN = "INNER JOIN works ON works.id = challenge_assignments.creation_id AND challenge_assignments.creation_type = 'Work'"
+ WORKS_LEFT_JOIN = "LEFT JOIN works ON works.id = challenge_assignments.creation_id AND challenge_assignments.creation_type = 'Work'"
+
+ scope :fulfilled, lambda {
+ joins(COLLECTION_ITEMS_JOIN).joins(WORKS_JOIN)
+ .where("challenge_assignments.creation_id IS NOT NULL AND collection_items.user_approval_status = ? AND collection_items.collection_approval_status = ? AND works.posted = 1",
+ CollectionItem.user_approval_statuses[:approved], CollectionItem.collection_approval_statuses[:approved])
+ }
+
+ scope :posted, -> { joins(WORKS_JOIN).where("challenge_assignments.creation_id IS NOT NULL AND works.posted = 1") }
+
+ # should be faster than unfulfilled scope because no giant left joins
+ def self.unfulfilled_in_collection(collection)
+ fulfilled_ids = ChallengeAssignment.in_collection(collection).fulfilled.pluck(:id)
+ fulfilled_ids.empty? ? in_collection(collection) : in_collection(collection).where.not(challenge_assignments: { id: fulfilled_ids })
+ end
+
+ # faster than unposted scope because no left join!
+ def self.unposted_in_collection(collection)
+ posted_ids = ChallengeAssignment.in_collection(collection).posted.pluck(:id)
+ posted_ids.empty? ? in_collection(collection) : in_collection(collection).where("'challenge_assignments.creation_id IS NULL OR challenge_assignments.id NOT IN (?)", posted_ids)
+ end
+
+ def self.duplicate_givers(collection)
+ ids = in_collection(collection).group("challenge_assignments.offer_signup_id HAVING count(DISTINCT id) > 1").pluck(:offer_signup_id).compact
+ ChallengeAssignment.where(offer_signup_id: ids)
+ end
+
+ def self.duplicate_recipients(collection)
+ ids = in_collection(collection).group("challenge_assignments.request_signup_id HAVING count(DISTINCT id) > 1").pluck(:request_signup_id).compact
+ ChallengeAssignment.where(request_signup_id: ids)
+ end
+
+ # has to be a left join to get assignments that don't have a collection item
+ scope :unfulfilled, lambda {
+ joins(COLLECTION_ITEMS_LEFT_JOIN).joins(WORKS_LEFT_JOIN)
+ .where("challenge_assignments.creation_id IS NULL OR collection_items.user_approval_status != ? OR collection_items.collection_approval_status != ? OR works.posted = 0",
+ CollectionItem.user_approval_statuses[:approved], CollectionItem.collection_approval_statuses[:approved])
+ }
+
+ # ditto
+ scope :unposted, -> { joins(WORKS_LEFT_JOIN).where("challenge_assignments.creation_id IS NULL OR works.posted = 0") }
+
+ scope :unstarted, -> { where("challenge_assignments.creation_id IS NULL") }
+
+ before_destroy :clear_assignment
+ def clear_assignment
+ if offer_signup
+ offer_signup.assigned_as_offer = false
+ offer_signup.save!
+ end
+
+ if request_signup
+ request_signup.assigned_as_request = false
+ request_signup.save!
+ end
+ end
+
+ def get_collection_item
+ return nil unless self.creation
+
+ CollectionItem.where("collection_id = ? AND item_id = ? AND item_type = ?", self.collection_id, self.creation_id, self.creation_type).first
+ end
+
+ def started?
+ !self.creation.nil?
+ end
+
+ def fulfilled?
+ self.posted? && (item = get_collection_item) && item.approved?
+ end
+
+ def posted?
+ self.creation && (creation.respond_to?(:posted?) ? creation.posted? : true)
+ end
+
+ def defaulted=(value)
+ self.defaulted_at = (Time.now if value == "1")
+ end
+
+ def defaulted
+ !self.defaulted_at.nil?
+ end
+ alias defaulted? defaulted
+
+ def offer_signup_pseud=(pseud_byline)
+ if pseud_byline.blank?
+ self.offer_signup = nil
+ else
+ signup = signup_for_byline(pseud_byline)
+ self.offer_signup = signup if signup
+ end
+ end
+
+ def offer_signup_pseud
+ self.offer_signup.try(:pseud).try(:byline) || ""
+ end
+
+ def request_signup_pseud=(pseud_byline)
+ if pseud_byline.blank?
+ self.request_signup = nil
+ else
+ signup = signup_for_byline(pseud_byline)
+ self.request_signup = signup if signup
+ end
+ end
+
+ def request_signup_pseud
+ self.request_signup.try(:pseud).try(:byline) || ""
+ end
+
+ def signup_for_byline(byline)
+ pseud = Pseud.parse_byline(byline)
+ collection.signups.find_by(pseud: pseud)
+ end
+
+ def title
+ "#{self.collection.title} (#{self.request_byline})"
+ end
+
+ def offering_user
+ offering_pseud ? offering_pseud.user : nil
+ end
+
+ def offering_pseud
+ offer_signup ? offer_signup.pseud : pinch_hitter
+ end
+
+ def requesting_pseud
+ request_signup&.pseud
+ end
+
+ def offer_byline
+ if offer_signup && offer_signup.pseud
+ offer_signup.pseud.byline
+ else
+ (pinch_hitter ? I18n.t("challenge_assignment.offer_byline.pinch_hitter", pinch_hitter_byline: pinch_hitter.byline) : I18n.t("challenge_assignment.offer_byline.none"))
+ end
+ end
+
+ def request_byline
+ requesting_pseud&.byline || I18n.t("challenge_assignment.request_byline.none")
+ end
+
+ def pinch_hitter_byline
+ pinch_hitter ? pinch_hitter.byline : ""
+ end
+
+ def pinch_hitter_byline=(byline)
+ self.pinch_hitter = Pseud.parse_byline(byline)
+ end
+
+ def default
+ self.defaulted_at = Time.now
+ save
+ end
+
+ def cover(pseud)
+ new_assignment = self.covered_at ? request_signup.request_assignments.last : ChallengeAssignment.new
+ new_assignment.collection = self.collection
+ new_assignment.request_signup_id = request_signup_id
+ new_assignment.pinch_hitter = pseud
+ new_assignment.sent_at = nil
+ new_assignment.save!
+ new_assignment.send_out
+ self.covered_at = Time.now
+ new_assignment.save && save
+ end
+
+ def send_out
+ # don't resend!
+ unless self.sent_at
+ self.sent_at = Time.now
+ save
+ assigned_to = if self.offer_signup
+ self.offer_signup.pseud.user
+ else
+ (self.pinch_hitter ? self.pinch_hitter.user : nil)
+ end
+ if assigned_to && self.request_signup
+ I18n.with_locale(assigned_to.preference.locale_for_mails) do
+ UserMailer.challenge_assignment_notification(collection.id, assigned_to.id, self.id).deliver_later
+ end
+ end
+ end
+ end
+
+ @queue = :collection
+ # This will be called by a worker when a job needs to be processed
+ def self.perform(method, *args)
+ self.send(method, *args)
+ end
+
+ # send assignments out to all participants
+ def self.send_out(collection)
+ Resque.enqueue(ChallengeAssignment, :delayed_send_out, collection.id)
+ end
+
+ def self.delayed_send_out(collection_id)
+ collection = Collection.find(collection_id)
+
+ # update the collection challenge with the time the assignments are sent
+ challenge = collection.challenge
+ challenge.assignments_sent_at = Time.now
+ challenge.save
+
+ # send out each assignment
+ collection.assignments.each do |assignment|
+ assignment.send_out
+ end
+ collection.notify_maintainers_assignments_sent
+
+ # purge the potential matches! we don't want bazillions of them in our db
+ PotentialMatch.clear!(collection)
+ end
+
+ # generate automatic match for a collection
+ # this requires potential matches to already be generated
+ def self.generate(collection)
+ REDIS_GENERAL.set(progress_key(collection), 1)
+ Resque.enqueue(ChallengeAssignment, :delayed_generate, collection.id)
+ end
+
+ def self.progress_key(collection)
+ "challenge_assignment_in_progress_for_#{collection.id}"
+ end
+
+ def self.in_progress?(collection)
+ REDIS_GENERAL.get(progress_key(collection)) ? true : false
+ end
+
+ def self.delayed_generate(collection_id)
+ collection = Collection.find(collection_id)
+
+ if collection.challenge.assignments_sent_at.present?
+ # If assignments have been sent, we don't want to delete everything and
+ # regenerate. (If the challenge moderator wants to regenerate assignments
+ # after sending assignments, they can use the Purge Assignments button.)
+ return
+ end
+
+ settings = collection.challenge.potential_match_settings
+
+ REDIS_GENERAL.set(progress_key(collection), 1)
+ ChallengeAssignment.clear!(collection)
+
+ # we sort signups into buckets based on how many potential matches they have
+ @request_match_buckets = {}
+ @offer_match_buckets = {}
+ @max_match_count = 0
+ if settings.nil? || settings.no_match_required?
+ # stuff everyone into the same bucket
+ @max_match_count = 1
+ @request_match_buckets[1] = collection.signups
+ @offer_match_buckets[1] = collection.signups
+ else
+ collection.signups.find_each do |signup|
+ next if signup.nil?
+
+ request_match_count = signup.request_potential_matches.count
+ @request_match_buckets[request_match_count] ||= []
+ @request_match_buckets[request_match_count] << signup
+ @max_match_count = (request_match_count > @max_match_count ? request_match_count : @max_match_count)
+
+ offer_match_count = signup.offer_potential_matches.count
+ @offer_match_buckets[offer_match_count] ||= []
+ @offer_match_buckets[offer_match_count] << signup
+ @max_match_count = (offer_match_count > @max_match_count ? offer_match_count : @max_match_count)
+ end
+ end
+
+ # now that we have the buckets, we go through assigning people in order
+ # of people with the fewest options first.
+ # (if someone has no potential matches they get a placeholder assignment with no
+ # matches.)
+ 0.upto(@max_match_count) do |count|
+ if @request_match_buckets[count]
+ @request_match_buckets[count].sort_by { rand }
+ .each do |request_signup|
+ # go through the potential matches in order from best to worst and try and assign
+ request_signup.reload
+ next if request_signup.assigned_as_request
+
+ ChallengeAssignment.assign_request!(collection, request_signup)
+ end
+ end
+
+ next unless @offer_match_buckets[count]
+
+ @offer_match_buckets[count].sort_by { rand }
+ .each do |offer_signup|
+ offer_signup.reload
+ next if offer_signup.assigned_as_offer
+
+ ChallengeAssignment.assign_offer!(collection, offer_signup)
+ end
+ end
+ REDIS_GENERAL.del(progress_key(collection))
+
+ if collection.collection_email.present?
+ UserMailer.potential_match_generation_notification(collection.id, collection.collection_email).deliver_later
+ else
+ collection.maintainers_list.each do |user|
+ I18n.with_locale(user.preference.locale_for_mails) do
+ UserMailer.potential_match_generation_notification(collection.id, user.email).deliver_later
+ end
+ end
+ end
+ end
+
+ # go through the request's potential matches in order from best to worst and try and assign
+ def self.assign_request!(collection, request_signup)
+ assignment = ChallengeAssignment.new(collection: collection, request_signup: request_signup)
+ last_choice = nil
+ assigned = false
+ request_signup.request_potential_matches.sort.reverse.each do |potential_match|
+ # skip if this signup has already been assigned as an offer
+ next if potential_match.offer_signup.assigned_as_offer
+
+ # if there's a circular match let's save it as our last choice
+ if potential_match.offer_signup.assigned_as_request && !last_choice &&
+ collection.assignments.for_request_signup(potential_match.offer_signup).first.offer_signup == request_signup
+ last_choice = potential_match
+ next
+ end
+
+ # otherwise let's use it
+ assigned = ChallengeAssignment.do_assign_request!(assignment, potential_match)
+ break
+ end
+
+ ChallengeAssignment.do_assign_request!(assignment, last_choice) if !assigned && last_choice
+
+ request_signup.assigned_as_request = true
+ request_signup.save!
+
+ assignment.save!
+ assignment
+ end
+
+ # go through the offer's potential matches in order from best to worst and try and assign
+ def self.assign_offer!(collection, offer_signup)
+ assignment = ChallengeAssignment.new(collection: collection, offer_signup: offer_signup)
+ last_choice = nil
+ assigned = false
+ offer_signup.offer_potential_matches.sort.reverse.each do |potential_match|
+ # skip if already assigned as a request
+ next if potential_match.request_signup.assigned_as_request
+
+ # if there's a circular match let's save it as our last choice
+ if potential_match.request_signup.assigned_as_offer && !last_choice &&
+ collection.assignments.for_offer_signup(potential_match.request_signup).first.request_signup == offer_signup
+ last_choice = potential_match
+ next
+ end
+
+ # otherwise let's use it
+ assigned = ChallengeAssignment.do_assign_offer!(assignment, potential_match)
+ break
+ end
+
+ ChallengeAssignment.do_assign_offer!(assignment, last_choice) if !assigned && last_choice
+
+ offer_signup.assigned_as_offer = true
+ offer_signup.save!
+
+ assignment.save!
+ assignment
+ end
+
+ def self.do_assign_request!(assignment, potential_match)
+ assignment.offer_signup = potential_match.offer_signup
+ potential_match.offer_signup.assigned_as_offer = true
+ potential_match.offer_signup.save!
+ end
+
+ def self.do_assign_offer!(assignment, potential_match)
+ assignment.request_signup = potential_match.request_signup
+ potential_match.request_signup.assigned_as_request = true
+ potential_match.request_signup.save!
+ end
+
+ # clear out all previous assignments.
+ # note: this does NOT invoke callbacks because ChallengeAssignments don't have any dependent=>destroy
+ # or associations
+ def self.clear!(collection)
+ ChallengeAssignment.where(collection_id: collection.id).delete_all
+ ChallengeSignup.where(collection_id: collection.id).update_all(assigned_as_offer: false, assigned_as_request: false)
+ end
+
+ # create placeholders for any assignments left empty
+ # (this is for after manual updates have left some users without an
+ # assignment)
+ def self.update_placeholder_assignments!(collection)
+ # delete any assignments that have neither an offer nor a request associated
+ collection.assignments.each do |assignment|
+ assignment.destroy if assignment.offer_signup.blank? && assignment.request_signup.blank?
+ end
+
+ collection.signups.each do |signup|
+ # if this signup has at least one giver now, get rid of any leftover placeholders
+ if signup.request_assignments.count > 1
+ signup.request_assignments.each do |assignment|
+ assignment.destroy if assignment.offer_signup.blank?
+ end
+ end
+ # if this signup has at least one recipient now, get rid of any leftover placeholders
+ if signup.offer_assignments.count > 1
+ signup.offer_assignments.each do |assignment|
+ assignment.destroy if assignment.request_signup.blank?
+ end
+ end
+
+ # if this signup doesn't have any giver now, create a placeholder
+ if signup.request_assignments.empty?
+ assignment = ChallengeAssignment.new(collection: collection, request_signup: signup)
+ assignment.save
+ end
+
+ # if this signup doesn't have any recipient now, create a placeholder
+ if signup.offer_assignments.empty?
+ assignment = ChallengeAssignment.new(collection: collection, offer_signup: signup)
+ assignment.save
+ end
+ end
+ end
+end
diff --git a/app/models/challenge_claim.rb b/app/models/challenge_claim.rb
new file mode 100755
index 0000000..ea104c5
--- /dev/null
+++ b/app/models/challenge_claim.rb
@@ -0,0 +1,155 @@
+class ChallengeClaim < ApplicationRecord
+ belongs_to :claiming_user, class_name: "User", inverse_of: :request_claims
+ belongs_to :collection
+ belongs_to :request_signup, class_name: "ChallengeSignup"
+ belongs_to :request_prompt, class_name: "Prompt"
+ belongs_to :creation, polymorphic: true
+
+ before_create :inherit_fields_from_request_prompt
+ def inherit_fields_from_request_prompt
+ return unless request_prompt
+
+ self.collection = request_prompt.collection
+ self.request_signup = request_prompt.challenge_signup
+ end
+
+ scope :for_request_signup, lambda {|signup|
+ where('request_signup_id = ?', signup.id)
+ }
+
+ scope :by_claiming_user, lambda {|user|
+ select('DISTINCT challenge_claims.*')
+ .joins("INNER JOIN users ON challenge_claims.claiming_user_id = users.id")
+ .where('users.id = ?', user.id)
+ }
+
+ scope :in_collection, lambda {|collection|
+ where('challenge_claims.collection_id = ?', collection.id)
+ }
+
+ scope :with_request, -> { where('request_signup IS NOT NULL') }
+ scope :with_no_request, -> { where('request_signup_id IS NULL') }
+
+ REQUESTING_PSEUD_JOIN = "INNER JOIN challenge_signups ON (challenge_claims.request_signup_id = challenge_signups.id)
+ INNER JOIN pseuds ON challenge_signups.pseud_id = pseuds.id"
+
+ CLAIMING_PSEUD_JOIN = "INNER JOIN users ON challenge_claims.claiming_user_id = users.id"
+
+ COLLECTION_ITEMS_JOIN = "INNER JOIN collection_items ON (collection_items.collection_id = challenge_claims.collection_id AND
+ collection_items.item_id = challenge_claims.creation_id AND
+ collection_items.item_type = challenge_claims.creation_type)"
+
+ COLLECTION_ITEMS_LEFT_JOIN = "LEFT JOIN collection_items ON (collection_items.collection_id = challenge_claims.collection_id AND
+ collection_items.item_id = challenge_claims.creation_id AND
+ collection_items.item_type = challenge_claims.creation_type)"
+
+
+ scope :order_by_date, -> { order("created_at ASC") }
+
+ def self.order_by_requesting_pseud(dir="ASC")
+ if dir.casecmp("ASC").zero?
+ joins(REQUESTING_PSEUD_JOIN).order("pseuds.name ASC")
+ else
+ joins(REQUESTING_PSEUD_JOIN).order("pseuds.name DESC")
+ end
+ end
+
+ def self.order_by_offering_pseud(dir="ASC")
+ if dir.casecmp("ASC").zero?
+ joins(CLAIMING_PSEUD_JOIN).order("pseuds.name ASC")
+ else
+ joins(CLAIMING_PSEUD_JOIN).order("pseuds.name DESC")
+ end
+ end
+
+ WORKS_JOIN = "INNER JOIN works ON works.id = challenge_claims.creation_id AND challenge_claims.creation_type = 'Work'"
+ WORKS_LEFT_JOIN = "LEFT JOIN works ON works.id = challenge_claims.creation_id AND challenge_claims.creation_type = 'Work'"
+
+ scope :fulfilled, -> {
+ joins(COLLECTION_ITEMS_JOIN).joins(WORKS_JOIN)
+ .where("challenge_claims.creation_id IS NOT NULL AND collection_items.user_approval_status = ? AND collection_items.collection_approval_status = ? AND works.posted = 1",
+ CollectionItem.user_approval_statuses[:approved], CollectionItem.collection_approval_statuses[:approved])
+ }
+
+
+ scope :posted, -> { joins(WORKS_JOIN).where("challenge_claims.creation_id IS NOT NULL AND works.posted = 1") }
+
+ # should be faster than unfulfilled scope because no giant left joins
+ def self.unfulfilled_in_collection(collection)
+ fulfilled_ids = ChallengeClaim.in_collection(collection).fulfilled.pluck(:id)
+ fulfilled_ids.empty? ? in_collection(collection) : in_collection(collection).where("challenge_claims.id NOT IN (?)", fulfilled_ids)
+ end
+
+ # faster than unposted scope because no left join!
+ def self.unposted_in_collection(collection)
+ posted_ids = ChallengeClaim.in_collection(collection).posted.pluck(:id)
+ posted_ids.empty? ? in_collection(collection) : in_collection(collection).where("challenge_claims.creation_id IS NULL OR challenge_claims.id NOT IN (?)", posted_ids)
+ end
+
+ # has to be a left join to get works that don't have a collection item
+ scope :unfulfilled, -> {
+ joins(COLLECTION_ITEMS_LEFT_JOIN).joins(WORKS_LEFT_JOIN)
+ .where("challenge_claims.creation_id IS NULL OR collection_items.user_approval_status != ? OR collection_items.collection_approval_status != ? OR works.posted = 0",
+ CollectionItem.user_approval_statuses[:approved], CollectionItem.collection_approval_statuses[:approved])
+ }
+
+ # ditto
+ scope :unposted, -> { joins(WORKS_LEFT_JOIN).where("challenge_claims.creation_id IS NULL OR works.posted = 0") }
+
+ scope :unstarted, -> { where("challenge_claims.creation_id IS NULL") }
+
+ def self.unposted_for_user(user)
+ all_claims = ChallengeClaim.by_claiming_user(user)
+ posted_ids = all_claims.posted.pluck(:id)
+ all_claims.where("challenge_claims.id NOT IN (?)", posted_ids)
+ end
+
+
+ def get_collection_item
+ return nil unless self.creation
+ CollectionItem.where('collection_id = ? AND item_id = ? AND item_type = ?', self.collection_id, self.creation_id, self.creation_type).first
+ end
+
+ def fulfilled?
+ self.creation && (item = get_collection_item) && item.approved?
+ end
+
+ def title
+ if !self.request_prompt.title.blank?
+ title = request_prompt.title
+ else
+ title = ts("Untitled Prompt")
+ end
+ title += " " + ts("in") + " #{self.collection.title}"
+ if self.request_prompt.anonymous?
+ title += " " + ts("(Anonymous)")
+ else
+ title += " (#{self.request_byline})"
+ end
+ return title
+ end
+
+ def claiming_pseud
+ claiming_user.try(:default_pseud)
+ end
+
+ def requesting_pseud
+ request_signup ? request_signup.pseud : nil
+ end
+
+ def claim_byline
+ claiming_pseud.try(:byline) || "deleted user"
+ end
+
+ def request_byline
+ request_signup ? request_signup.pseud.byline : "- None -"
+ end
+
+ def user_allowed_to_destroy?(current_user)
+ (self.claiming_user == current_user) || self.collection.user_is_maintainer?(current_user)
+ end
+
+ def prompt_description
+ request_prompt&.description || ""
+ end
+end
diff --git a/app/models/challenge_models/gift_exchange.rb b/app/models/challenge_models/gift_exchange.rb
new file mode 100755
index 0000000..06853e3
--- /dev/null
+++ b/app/models/challenge_models/gift_exchange.rb
@@ -0,0 +1,81 @@
+class GiftExchange < ApplicationRecord
+ PROMPT_TYPES = %w[requests offers].freeze
+ include ChallengeCore
+
+ override_datetime_setters
+
+ belongs_to :collection
+ has_one :collection, as: :challenge
+
+ # limits the kind of prompts users can submit
+ belongs_to :request_restriction, class_name: "PromptRestriction", dependent: :destroy
+ accepts_nested_attributes_for :request_restriction
+
+ belongs_to :offer_restriction, class_name: "PromptRestriction", dependent: :destroy
+ accepts_nested_attributes_for :offer_restriction
+
+ belongs_to :potential_match_settings, dependent: :destroy
+ accepts_nested_attributes_for :potential_match_settings
+
+ validates :signup_instructions_general, :signup_instructions_requests, :signup_instructions_offers, length: {
+ allow_blank: true,
+ maximum: ArchiveConfig.INFO_MAX, too_long: ts("must be less than %{max} letters long.", max: ArchiveConfig.INFO_MAX)
+ }
+
+ PROMPT_TYPES.each do |type|
+ %w[required allowed].each do |setting|
+ prompt_limit_field = "#{type}_num_#{setting}"
+ validates prompt_limit_field, numericality: {
+ only_integer: true,
+ less_than_or_equal_to: ArchiveConfig.PROMPTS_MAX,
+ greater_than_or_equal_to: 1
+ }
+ end
+ end
+
+ before_validation :update_allowed_values
+ def update_allowed_values
+ %w[request offer].each do |prompt_type|
+ required = send("#{prompt_type}s_num_required")
+ allowed = send("#{prompt_type}s_num_allowed")
+ send("#{prompt_type}s_num_allowed=", required) if required > allowed
+ end
+ end
+
+ # make sure that challenge sign-up / close / open dates aren't contradictory
+ validate :validate_signup_dates
+
+ # When Challenges are deleted, there are two references left behind that need to be reset to nil
+ before_destroy :clear_challenge_references
+
+ after_save :copy_tag_set_from_offer_to_request
+ def copy_tag_set_from_offer_to_request
+ return unless self.offer_restriction
+
+ self.request_restriction.set_owned_tag_sets(self.offer_restriction.owned_tag_sets)
+
+ # copy the tag-set-based restriction settings
+ self.request_restriction.character_restrict_to_fandom = self.offer_restriction.character_restrict_to_fandom
+ self.request_restriction.relationship_restrict_to_fandom = self.offer_restriction.relationship_restrict_to_fandom
+ self.request_restriction.character_restrict_to_tag_set = self.offer_restriction.character_restrict_to_tag_set
+ self.request_restriction.relationship_restrict_to_tag_set = self.offer_restriction.relationship_restrict_to_tag_set
+ self.request_restriction.save
+ end
+
+ # override core
+ def allow_name_change?
+ false
+ end
+
+ def topmost_tag_type
+ self.request_restriction.topmost_tag_type
+ end
+
+ def user_allowed_to_see_requests_summary?(user)
+ self.collection.user_is_maintainer?(user) || self.requests_summary_visible?
+ end
+
+ def user_allowed_to_see_prompt?(user, prompt)
+ self.collection.user_is_maintainer?(user) || prompt.pseud.user == user
+ end
+end
diff --git a/app/models/challenge_models/prompt_meme.rb b/app/models/challenge_models/prompt_meme.rb
new file mode 100755
index 0000000..b398ebf
--- /dev/null
+++ b/app/models/challenge_models/prompt_meme.rb
@@ -0,0 +1,49 @@
+class PromptMeme < ApplicationRecord
+ PROMPT_TYPES = %w(requests)
+ include ChallengeCore
+
+ override_datetime_setters
+
+ belongs_to :collection
+ has_one :collection, as: :challenge
+
+ # limits the kind of prompts users can submit
+ belongs_to :request_restriction, class_name: "PromptRestriction", dependent: :destroy
+ accepts_nested_attributes_for :request_restriction
+
+ validates_length_of :signup_instructions_general, :signup_instructions_requests, {
+ allow_blank: true,
+ maximum: ArchiveConfig.INFO_MAX, too_long: ts("must be less than %{max} letters long.", max: ArchiveConfig.INFO_MAX)
+ }
+
+ PROMPT_TYPES.each do |type|
+ %w(required allowed).each do |setting|
+ prompt_limit_field = "#{type}_num_#{setting}"
+ validates_numericality_of prompt_limit_field, only_integer: true, less_than_or_equal_to: ArchiveConfig.PROMPT_MEME_PROMPTS_MAX, greater_than_or_equal_to: 1
+ end
+ end
+
+ before_validation :update_allowed_values, :update_allowed_prompts
+
+ # make sure that challenge sign-up / close / open dates aren't contradictory
+ validate :validate_signup_dates
+
+ def update_allowed_prompts
+ required = self.requests_num_required
+ allowed = self.requests_num_allowed
+ if required > allowed
+ self.requests_num_allowed = required
+ end
+ end
+
+ # When Challenges are deleted, there are two references left behind that need to be reset to nil
+ before_destroy :clear_challenge_references
+
+ def user_allowed_to_see_signups?(user)
+ true
+ end
+
+ def user_allowed_to_see_claims?(user)
+ user_allowed_to_see_assignments?(user)
+ end
+end
diff --git a/app/models/challenge_signup.rb b/app/models/challenge_signup.rb
new file mode 100755
index 0000000..5d393f6
--- /dev/null
+++ b/app/models/challenge_signup.rb
@@ -0,0 +1,202 @@
+class ChallengeSignup < ApplicationRecord
+ include TagTypeHelper
+
+ # -1 represents all matching
+ ALL = -1
+
+ belongs_to :pseud
+ belongs_to :collection
+
+ has_many :prompts, dependent: :destroy, inverse_of: :challenge_signup
+ has_many :requests, dependent: :destroy, inverse_of: :challenge_signup
+ has_many :offers, dependent: :destroy, inverse_of: :challenge_signup
+
+ has_many :offer_potential_matches, class_name: "PotentialMatch", foreign_key: 'offer_signup_id', dependent: :destroy
+ has_many :request_potential_matches, class_name: "PotentialMatch", foreign_key: 'request_signup_id', dependent: :destroy
+
+ has_many :offer_assignments, class_name: "ChallengeAssignment", foreign_key: 'offer_signup_id'
+ has_many :request_assignments, class_name: "ChallengeAssignment", foreign_key: 'request_signup_id'
+
+ has_many :request_claims, class_name: "ChallengeClaim", foreign_key: 'request_signup_id'
+
+ before_destroy :clear_assignments_and_claims
+ def clear_assignments_and_claims
+ # remove this signup reference from any existing assignments
+ offer_assignments.each {|assignment| assignment.offer_signup = nil; assignment.save}
+ request_assignments.each {|assignment| assignment.request_signup = nil; assignment.save}
+ request_claims.each {|claim| claim.destroy}
+ end
+
+ # we reject prompts if they are empty except for associated references
+ accepts_nested_attributes_for :offers, :prompts, :requests, {allow_destroy: true,
+ reject_if: proc { |attrs|
+ attrs[:url].blank? && attrs[:description].blank? &&
+ (attrs[:tag_set_attributes].nil? || attrs[:tag_set_attributes].all? {|k,v| v.blank?}) &&
+ (attrs[:optional_tag_set_attributes].nil? || attrs[:optional_tag_set_attributes].all? {|k,v| v.blank?})
+ }
+ }
+
+ scope :by_user, lambda {|user|
+ select("DISTINCT challenge_signups.*").
+ joins(pseud: :user).
+ where('users.id = ?', user.id)
+ }
+
+ scope :by_pseud, lambda {|pseud| where('pseud_id = ?', pseud.id) }
+
+ scope :pseud_only, -> { select(:pseud_id) }
+
+ scope :order_by_pseud, -> { joins(:pseud).order("pseuds.name") }
+
+ scope :order_by_date, -> { order("updated_at DESC") }
+
+ scope :in_collection, lambda {|collection| where('challenge_signups.collection_id = ?', collection.id)}
+
+ scope :no_potential_offers, -> { where("id NOT IN (SELECT offer_signup_id FROM potential_matches)") }
+ scope :no_potential_requests, -> { where("id NOT IN (select request_signup_id FROM potential_matches)") }
+
+ # Scopes used to include extra data when loading.
+ scope :with_request_tags, -> { includes(
+ requests: [tag_set: :tags, optional_tag_set: :tags]
+ ) }
+ scope :with_offer_tags, -> { includes(
+ offers: [tag_set: :tags, optional_tag_set: :tags]
+ ) }
+
+ ### VALIDATION
+
+ validates_presence_of :pseud, :collection
+
+ # only one signup per challenge!
+ validates_uniqueness_of :pseud_id, scope: [:collection_id], message: ts("^You seem to already have signed up for this challenge.")
+
+ # we validate number of prompts/requests/offers at the challenge
+ validate :number_of_prompts
+ def number_of_prompts
+ if (challenge = collection.challenge)
+ errors_to_add = []
+ %w(offers requests).each do |prompt_type|
+ allowed = self.send("#{prompt_type}_num_allowed")
+ required = self.send("#{prompt_type}_num_required")
+ count = eval("@#{prompt_type}") ? eval("@#{prompt_type}.size") : eval("#{prompt_type}.size")
+ unless count.between?(required, allowed)
+ if allowed == 0
+ errors_to_add << ts("You cannot submit any #{prompt_type.pluralize} for this challenge.")
+ elsif required == allowed
+ errors_to_add << ts("You must submit exactly %{required} #{required > 1 ? prompt_type.pluralize : prompt_type} for this challenge. You currently have %{count}.",
+ required: required, count: count)
+ else
+ errors_to_add << ts("You must submit between %{required} and %{allowed} #{prompt_type.pluralize} to sign up for this challenge. You currently have %{count}.",
+ required: required, allowed: allowed, count: count)
+ end
+ end
+ end
+ unless errors_to_add.empty?
+ # yuuuuuck :( but so much less ugly than define-method'ing these all
+ self.errors.add(:base, errors_to_add.join("
").html_safe)
+ end
+ end
+ end
+
+ # make sure that tags are unique across each group of prompts
+ validate :unique_tags
+ def unique_tags
+ return unless (challenge = collection.challenge)
+
+ challenge.class::PROMPT_TYPES.each do |prompt_type|
+ # requests => request_restriction, offers => offer_restriction
+ restriction = challenge.send("#{prompt_type.singularize}_restriction")
+
+ next unless restriction
+
+ prompts = send(prompt_type)
+
+ TagSet::TAG_TYPES.each do |tag_type|
+ next unless restriction.require_unique?(tag_type)
+
+ all_tags_used = prompts.flat_map do |prompt|
+ prompt.tag_set.send("#{tag_type}_taglist")
+ end
+
+ unless all_tags_used.size == all_tags_used.uniq.size
+ errors.add(:base, ts("You have submitted more than one %{prompt_type} with the same %{tag_type} tags. This challenge requires them all to be unique.",
+ prompt_type: prompt_type.singularize, tag_type: tag_type_label_name(tag_type).downcase))
+ end
+ end
+ end
+ end
+
+ # define "offers_num_allowed" etc here
+ %w(offers requests).each do |prompt_type|
+ %w(required allowed).each do |permission|
+ define_method("#{prompt_type}_num_#{permission}") do
+ collection.challenge.respond_to?("#{prompt_type}_num_#{permission}") ? collection.challenge.send("#{prompt_type}_num_#{permission}") : 0
+ end
+ end
+ end
+
+ def can_delete?(prompt)
+ prompt_type = prompt.class.to_s.downcase.pluralize
+ current_num_prompts = self.send(prompt_type).count
+ required_num_prompts = self.send("#{prompt_type}_num_required")
+ if current_num_prompts > required_num_prompts
+ true
+ else
+ false
+ end
+ end
+
+ # sort alphabetically
+ include Comparable
+ def <=>(other)
+ self.pseud.name.downcase <=> other.pseud.name.downcase
+ end
+
+ def user
+ self.pseud.user
+ end
+
+ def user_allowed_to_destroy?(current_user)
+ (self.pseud.user == current_user) || self.collection.user_is_maintainer?(current_user)
+ end
+
+ def user_allowed_to_see?(current_user)
+ (self.pseud.user == current_user) || user_allowed_to_see_signups?(current_user)
+ end
+
+ def user_allowed_to_see_signups?(user)
+ self.collection.user_is_maintainer?(user) ||
+ self.collection.challenge_type == "PromptMeme" ||
+ (self.challenge.respond_to?("user_allowed_to_see_signups?") && self.challenge.user_allowed_to_see_signups?(user))
+ end
+
+ def byline
+ pseud.byline
+ end
+
+ # Returns nil if not a match otherwise returns PotentialMatch object
+ # self is the request, other is the offer
+ def match(other, settings = nil)
+ if settings.nil? || settings.no_match_required?
+ # No match is required, so everything matches everything, and the best
+ # match is perfect.
+ return PotentialMatch.new(
+ offer_signup: other,
+ request_signup: self,
+ collection_id: collection_id,
+ num_prompts_matched: ALL,
+ max_tags_matched: ALL
+ )
+ end
+
+ builder = PotentialMatchBuilder.new(self, other, settings)
+
+ requests.each do |request|
+ other.offers.each do |offer|
+ builder.try_prompt_match(request, offer)
+ end
+ end
+
+ builder.build_potential_match
+ end
+end
diff --git a/app/models/challenge_signup_summary.rb b/app/models/challenge_signup_summary.rb
new file mode 100755
index 0000000..305f713
--- /dev/null
+++ b/app/models/challenge_signup_summary.rb
@@ -0,0 +1,151 @@
+class ChallengeSignupSummary
+
+ attr_reader :collection, :challenge
+
+ def initialize(collection)
+ @collection = collection
+ @challenge = collection.challenge
+ end
+
+ ######################################################################
+ # CALCULATING INFO FOR THE SUMMARY
+ ######################################################################
+
+ # The type of tags to be summarized.
+ #
+ # For a multi-fandom challenge, this is probably fandom, but for a
+ # single-fandom challenge, it will probably be character or relationship (or
+ # one of the other tag types, if the challenge doesn't include either).
+ #
+ # Note that this returns the lowercase tag type.
+ def tag_type
+ @tag_type = collection.challenge.topmost_tag_type
+ end
+
+ # Returns an array of tag listings that includes the number of requests and
+ # offers each tag has in this challenge, sorted by least-offered and most-requested
+ def summary
+ @summary ||= tags.map { |tag| tag_summary(tag) }.compact.sort
+ end
+
+ private
+
+ # The class of tags to be summarized. Calls tag_type to retrieve the type.
+ def tag_class
+ raise "Redshirt: Attempted to constantize invalid class initialize tag_class #{tag_type.classify}" unless Tag::TYPES.include?(tag_type.classify)
+ tag_type.classify.constantize
+ end
+
+ # All of the tags of the desired type that have been
+ # used in requests or offers for this challenge
+ def tags
+ @tags ||= tag_class.in_challenge(collection)
+ end
+
+ def tag_summary(tag)
+ request_count = Request.in_collection(collection).with_tag(tag).count
+ offer_count = Offer.in_collection(collection).with_tag(tag).count
+
+ if request_count > 0
+ ChallengeSignupTagSummary.new(tag.id, tag.name, request_count, offer_count)
+ end
+ end
+
+ public
+
+ ######################################################################
+ # GENERATING THE SUMMARY IN RESQUE
+ ######################################################################
+
+ @queue = :collection
+
+ # The action to be performed when this class is enqueued using Resque.
+ def self.perform(collection_id)
+ collection = Collection.find(collection_id)
+ summary = ChallengeSignupSummary.new(collection)
+ summary.generate_in_background
+ end
+
+ # Asynchronously generate the cached version.
+ def enqueue_for_generation
+ Resque.enqueue(ChallengeSignupSummary, collection.id)
+ end
+
+ # Write the summary to Memcached, so that it can be retrieved and displayed.
+ def generate_in_background
+ locals = {
+ challenge_collection: collection,
+ tag_type: self.tag_type,
+ summary_tags: self.summary,
+ generated_live: false
+ }
+
+ partial = "challenge/#{self.challenge.class.name.demodulize.tableize.singularize}/challenge_signups_summary"
+
+ self.cached_contents = ChallengeSignupsController.render(partial: partial,
+ locals: locals)
+ end
+
+ ######################################################################
+ # CACHING THE SUMMARY
+ ######################################################################
+
+ # Retrieve the value of the cache for this signup summary.
+ def cached_contents
+ cached_info[:contents]
+ end
+
+ # Retrieve the time that the cache was last updated.
+ def cached_time
+ cached_info[:time]
+ end
+
+ # The equivalent of touching a file in the file system. Update the
+ # modification time.
+ def touch_cache
+ data = cached_info.dup
+ data[:time] = Time.now
+ Rails.cache.write(cache_key, data)
+ end
+
+ private
+
+ # Set the value of the cache for this signup summary.
+ def cached_contents=(value)
+ data = {
+ contents: value,
+ time: Time.now
+ }
+
+ Rails.cache.write(cache_key, data)
+ end
+
+ # Retrieve the hash containing cached info: the value being cached (if it
+ # exists), and the time that it was updated.
+ def cached_info
+ Rails.cache.read(cache_key) || {}
+ end
+
+ # The key used to store info about the signup summary in memcached.
+ def cache_key
+ "/v1/challenge_signup_summaries/#{collection.id}"
+ end
+end
+
+class ChallengeSignupTagSummary < Struct.new(:id, :name, :requests, :offers)
+
+ # Prioritize tags with the fewest offers and most requests
+ # If they have the same number of offers and requests, sort by name
+ def <=>(other)
+ if self.offers == other.offers
+ if self.requests == other.requests
+ self.name <=> other.name
+ else
+ other.requests <=> self.requests
+ end
+ else
+ self.offers <=> other.offers
+ end
+ end
+
+end
diff --git a/app/models/chapter.rb b/app/models/chapter.rb
new file mode 100755
index 0000000..40545b1
--- /dev/null
+++ b/app/models/chapter.rb
@@ -0,0 +1,209 @@
+# encoding=utf-8
+
+class Chapter < ApplicationRecord
+ include HtmlCleaner
+ include WorkChapterCountCaching
+ include CreationNotifier
+ include Creatable
+
+ belongs_to :work, inverse_of: :chapters
+ # acts_as_list scope: 'work_id = #{work_id}'
+
+ acts_as_commentable
+
+ validates_length_of :title, allow_blank: true, maximum: ArchiveConfig.TITLE_MAX,
+ too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.TITLE_MAX)
+
+ validates_length_of :summary, allow_blank: true, maximum: ArchiveConfig.SUMMARY_MAX,
+ too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.SUMMARY_MAX)
+ validates_length_of :notes, allow_blank: true, maximum: ArchiveConfig.NOTES_MAX,
+ too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.NOTES_MAX)
+ validates_length_of :endnotes, allow_blank: true, maximum: ArchiveConfig.NOTES_MAX,
+ too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.NOTES_MAX)
+
+
+ validates_presence_of :content
+ validates_length_of :content, minimum: ArchiveConfig.CONTENT_MIN,
+ too_short: ts("must be at least %{min} characters long.", min: ArchiveConfig.CONTENT_MIN)
+
+ validates_length_of :content, maximum: ArchiveConfig.CONTENT_MAX,
+ too_long: ts("cannot be more than %{max} characters long.", max: ArchiveConfig.CONTENT_MAX)
+
+ attr_accessor :wip_length_placeholder
+
+ before_validation :inherit_creatorships
+ def inherit_creatorships
+ if work && creatorships.empty? && current_user_pseuds.blank?
+ work.pseuds_after_saving.each do |pseud|
+ creatorships.build(pseud: pseud)
+ end
+ end
+ end
+
+ before_save :strip_title
+ before_save :set_word_count
+ before_save :validate_published_at
+
+ after_create :notify_after_creation
+ after_update :notify_after_update
+
+ scope :in_order, -> { order(:position) }
+ scope :posted, -> { where(posted: true) }
+
+ after_save :fix_positions
+ def fix_positions
+ if work&.persisted?
+ positions_changed = false
+ self.position ||= 1
+ chapters = work.chapters.order(:position)
+ if chapters && chapters.length > 1
+ chapters = chapters - [self]
+ chapters.insert(self.position-1, self)
+ chapters.compact.each_with_index do |chapter, i|
+ if chapter.position != i+1
+ Chapter.where(["id = ?", chapter.id]).update_all(["position = ?", i + 1])
+ positions_changed = true
+ end
+ end
+ end
+ # We're caching the chapter positions in the comment blurbs and the last
+ # chapter link in the work blurbs so we need to expire the blurbs and the
+ # work indexes.
+ if positions_changed
+ work.comments.each{ |c| c.touch }
+ work.expire_caches
+ end
+ end
+ end
+
+ after_save :invalidate_chapter_count,
+ if: Proc.new { |chapter| chapter.saved_change_to_posted? }
+
+ before_destroy :fix_positions_before_destroy, :invalidate_chapter_count
+ def fix_positions_before_destroy
+ if work&.persisted? && position
+ chapters = work.chapters.where(["position > ?", position])
+ chapters.each { |c| c.update_attribute(:position, c.position - 1) }
+ end
+ end
+
+ after_commit :update_series_index
+ def update_series_index
+ return unless work&.series.present? && should_reindex_series?
+ work.serial_works.each(&:update_series_index)
+ end
+
+ def should_reindex_series?
+ pertinent_attributes = %w[id posted]
+ destroyed? || (saved_changes.keys & pertinent_attributes).present?
+ end
+
+ def invalidate_chapter_count
+ if work
+ invalidate_work_chapter_count(work)
+ end
+ end
+
+ def moderated_commenting_enabled?
+ work && work.moderated_commenting_enabled?
+ end
+
+ # strip leading spaces from title
+ def strip_title
+ unless self.title.blank?
+ self.title = self.title.gsub(/^\s*/, '')
+ end
+ end
+
+ def chapter_header
+ "#{ts("Chapter")} #{position}"
+ end
+
+ def chapter_title
+ self.title.blank? ? self.chapter_header : self.title
+ end
+
+ # Header plus title, used in subscriptions
+ def full_chapter_title
+ str = chapter_header
+ if title.present?
+ str += ": #{title}"
+ end
+ str
+ end
+
+ def display_title
+ self.position.to_s + '. ' + self.chapter_title
+ end
+
+ def abbreviated_display_title
+ self.display_title.length > 50 ? (self.display_title[0..50] + "...") : self.display_title
+ end
+
+ # make em-dashes into html code
+# def clean_emdashes
+# self.content.gsub!(/\xE2\x80\"/, '—')
+# end
+ # check if this chapter is the only chapter of its work
+ def is_only_chapter?
+ self.work.chapters.count == 1
+ end
+
+ def only_non_draft_chapter?
+ self.posted? && self.work.chapters.posted.count == 1
+ end
+
+ # Virtual attribute for work wip_length
+ # Chapter needed its own version for sense-checking purposes
+ def wip_length
+ if self.new_record? && self.work.expected_number_of_chapters == self.work.number_of_chapters
+ self.work.expected_number_of_chapters += 1
+ elsif self.work.expected_number_of_chapters && self.work.expected_number_of_chapters < self.work.number_of_chapters
+ "?"
+ else
+ self.work.wip_length
+ end
+ end
+
+ # Can't directly access work from a chapter virtual attribute
+ # Using a placeholder variable for edits, where the value isn't saved immediately
+ def wip_length=(number)
+ self.wip_length_placeholder = number
+ end
+
+ # Checks the chapter published_at date isn't in the future
+ def validate_published_at
+ if !self.published_at
+ self.published_at = Date.current
+ elsif self.published_at > Date.current
+ errors.add(:base, ts("Publication date can't be in the future."))
+ throw :abort
+ end
+ end
+
+ # Set the value of word_count to reflect the length of the text in the chapter content
+ def set_word_count
+ if self.new_record? || self.content_changed? || self.word_count.nil?
+ counter = WordCounter.new(self.content)
+ self.word_count = counter.count
+ else
+ self.word_count
+ end
+ end
+
+ # Return the name to link comments to for this object
+ def commentable_name
+ self.work.title
+ end
+
+ def expire_comments_count
+ super
+ work&.expire_comments_count
+ end
+
+ def expire_byline_cache
+ [true, false].each do |only_path|
+ Rails.cache.delete("#{cache_key}/byline-nonanon/#{only_path}")
+ end
+ end
+end
diff --git a/app/models/character.rb b/app/models/character.rb
new file mode 100755
index 0000000..63f1a2c
--- /dev/null
+++ b/app/models/character.rb
@@ -0,0 +1,32 @@
+class Character < Tag
+
+ NAME = ArchiveConfig.CHARACTER_CATEGORY_NAME
+
+ # Types of tags to which a character tag can belong via common taggings or meta taggings
+ def parent_types
+ ['Fandom', 'MetaTag']
+ end
+ def child_types
+ ['Relationship', 'SubTag', 'Merger']
+ end
+
+ def characters
+ (children + parents).select {|t| t.is_a? Character}.sort
+ end
+
+ def relationships
+ children.by_type('Relationship').by_name
+ end
+
+ def freeforms
+ children.by_type('Freeform').by_name
+ end
+
+ def fandoms
+ parents.by_type('Fandom').by_name
+ end
+
+ def medias
+ parents.by_type('Media').by_name
+ end
+end
diff --git a/app/models/collection.rb b/app/models/collection.rb
new file mode 100755
index 0000000..ad21b7a
--- /dev/null
+++ b/app/models/collection.rb
@@ -0,0 +1,464 @@
+class Collection < ApplicationRecord
+ include Filterable
+ include WorksOwner
+
+ has_one_attached :icon do |attachable|
+ attachable.variant(:standard, resize_to_limit: [100, 100], loader: { n: -1 })
+ end
+
+ # i18n-tasks-use t("errors.attributes.icon.invalid_format")
+ # i18n-tasks-use t("errors.attributes.icon.too_large")
+ validates :icon, attachment: {
+ allowed_formats: %r{image/\S+},
+ maximum_size: ArchiveConfig.ICON_SIZE_KB_MAX.kilobytes
+ }
+
+ belongs_to :parent, class_name: "Collection", inverse_of: :children
+ has_many :children, class_name: "Collection", foreign_key: "parent_id", inverse_of: :parent
+
+ has_one :collection_profile, dependent: :destroy
+ accepts_nested_attributes_for :collection_profile
+
+ has_one :collection_preference, dependent: :destroy
+ accepts_nested_attributes_for :collection_preference
+
+ before_validation :clear_icon
+ before_validation :cleanup_url
+ before_create :ensure_associated
+ def ensure_associated
+ self.collection_preference = CollectionPreference.new unless self.collection_preference
+ self.collection_profile = CollectionProfile.new unless self.collection_profile
+ end
+
+ belongs_to :challenge, dependent: :destroy, polymorphic: true
+ has_many :prompts, dependent: :destroy
+
+ has_many :signups, class_name: "ChallengeSignup", dependent: :destroy
+ has_many :potential_matches, dependent: :destroy
+ has_many :assignments, class_name: "ChallengeAssignment", dependent: :destroy
+ has_many :claims, class_name: "ChallengeClaim", dependent: :destroy
+
+ # We need to get rid of all of these if the challenge is destroyed
+ after_save :clean_up_challenge
+ def clean_up_challenge
+ return if self.challenge_id
+
+ assignments.each(&:destroy)
+ potential_matches.each(&:destroy)
+ signups.each(&:destroy)
+ prompts.each(&:destroy)
+ end
+
+ has_many :collection_items, dependent: :destroy
+ accepts_nested_attributes_for :collection_items, allow_destroy: true
+ has_many :approved_collection_items, -> { approved_by_both }, class_name: "CollectionItem"
+
+ has_many :works, through: :collection_items, source: :item, source_type: "Work"
+ has_many :approved_works, -> { posted }, through: :approved_collection_items, source: :item, source_type: "Work"
+
+ has_many :bookmarks, through: :collection_items, source: :item, source_type: "Bookmark"
+ has_many :approved_bookmarks, through: :approved_collection_items, source: :item, source_type: "Bookmark"
+
+ has_many :collection_participants, dependent: :destroy
+ accepts_nested_attributes_for :collection_participants, allow_destroy: true
+
+ has_many :participants, through: :collection_participants, source: :pseud
+ has_many :users, through: :participants, source: :user
+ has_many :invited, -> { where(collection_participants: { participant_role: CollectionParticipant::INVITED }) }, through: :collection_participants, source: :pseud
+ has_many :owners, -> { where(collection_participants: { participant_role: CollectionParticipant::OWNER }) }, through: :collection_participants, source: :pseud
+ has_many :moderators, -> { where(collection_participants: { participant_role: CollectionParticipant::MODERATOR }) }, through: :collection_participants, source: :pseud
+ has_many :members, -> { where(collection_participants: { participant_role: CollectionParticipant::MEMBER }) }, through: :collection_participants, source: :pseud
+ has_many :posting_participants, -> { where(collection_participants: { participant_role: [CollectionParticipant::MEMBER, CollectionParticipant::MODERATOR, CollectionParticipant::OWNER] }) }, through: :collection_participants, source: :pseud
+
+ CHALLENGE_TYPE_OPTIONS = [
+ ["", ""],
+ [ts("Gift Exchange"), "GiftExchange"],
+ [ts("Prompt Meme"), "PromptMeme"]
+ ].freeze
+
+ validate :must_have_owners
+ def must_have_owners
+ # we have to use collection participants because the association may not exist until after
+ # the collection is saved
+ errors.add(:base, ts("Collection has no valid owners.")) if (self.collection_participants + (self.parent ? self.parent.collection_participants : [])).select(&:is_owner?)
+ .empty?
+ end
+
+ validate :collection_depth
+ def collection_depth
+ errors.add(:base, ts("Sorry, but %{name} is a subcollection, so it can't also be a parent collection.", name: parent.name)) if self.parent&.parent || (self.parent && !self.children.empty?) || (!self.children.empty? && !self.children.collect(&:children).flatten.empty?)
+ end
+
+ validate :parent_exists
+ def parent_exists
+ errors.add(:base, ts("We couldn't find a collection with name %{name}.", name: parent_name)) unless parent_name.blank? || Collection.find_by(name: parent_name)
+ end
+
+ validate :parent_is_allowed
+ def parent_is_allowed
+ if parent
+ if parent == self
+ errors.add(:base, ts("You can't make a collection its own parent."))
+ elsif parent_id_changed? && !parent.user_is_maintainer?(User.current_user)
+ errors.add(:base, ts("You have to be a maintainer of %{name} to make a subcollection.", name: parent.name))
+ end
+ end
+ end
+
+ validates :name, presence: { message: ts("Please enter a name for your collection.") }
+ validates :name, uniqueness: { message: ts("Sorry, that name is already taken. Try again, please!") }
+ validates :name,
+ length: { minimum: ArchiveConfig.TITLE_MIN,
+ too_short: ts("must be at least %{min} characters long.", min: ArchiveConfig.TITLE_MIN) }
+ validates :name,
+ length: { maximum: ArchiveConfig.TITLE_MAX,
+ too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.TITLE_MAX) }
+ validates :name,
+ format: { message: ts("must begin and end with a letter or number; it may also contain underscores. It may not contain any other characters, including spaces."),
+ with: /\A[A-Za-z0-9]\w*[A-Za-z0-9]\Z/ }
+ validates :icon_alt_text, length: { allow_blank: true, maximum: ArchiveConfig.ICON_ALT_MAX,
+ too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.ICON_ALT_MAX) }
+ validates :icon_comment_text, length: { allow_blank: true, maximum: ArchiveConfig.ICON_COMMENT_MAX,
+ too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.ICON_COMMENT_MAX) }
+
+ validates :email, email_format: { allow_blank: true }
+
+ validates :title, presence: { message: ts("Please enter a title to be displayed for your collection.") }
+ validates :title,
+ length: { minimum: ArchiveConfig.TITLE_MIN,
+ too_short: ts("must be at least %{min} characters long.", min: ArchiveConfig.TITLE_MIN) }
+ validates :title,
+ length: { maximum: ArchiveConfig.TITLE_MAX,
+ too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.TITLE_MAX) }
+ validate :no_reserved_strings
+ def no_reserved_strings
+ errors.add(:title, ts("^Sorry, the ',' character cannot be in a collection Display Title.")) if
+ title.match(/,/)
+ end
+
+ # return title.html_safe to overcome escaping done by sanitiser
+ def title
+ self[:title].try(:html_safe)
+ end
+
+ validates :description,
+ length: { allow_blank: true,
+ maximum: ArchiveConfig.SUMMARY_MAX,
+ too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.SUMMARY_MAX) }
+
+ validates :header_image_url, format: { allow_blank: true, with: URI::DEFAULT_PARSER.make_regexp(%w[http https]), message: ts("is not a valid URL.") }
+ validates :header_image_url, format: { allow_blank: true, with: /\A\S+\.(png|gif|jpg)\z/, message: ts("can only point to a gif, jpg, or png file.") }
+
+ validates :tags_after_saving,
+ length: { maximum: ArchiveConfig.COLLECTION_TAGS_MAX,
+ message: "^Sorry, a collection can only have %{count} tags." }
+
+ scope :top_level, -> { where(parent_id: nil) }
+ scope :closed, -> { joins(:collection_preference).where(collection_preferences: { closed: true }) }
+ scope :not_closed, -> { joins(:collection_preference).where(collection_preferences: { closed: false }) }
+ scope :moderated, -> { joins(:collection_preference).where(collection_preferences: { moderated: true }) }
+ scope :unmoderated, -> { joins(:collection_preference).where(collection_preferences: { moderated: false }) }
+ scope :unrevealed, -> { joins(:collection_preference).where(collection_preferences: { unrevealed: true }) }
+ scope :anonymous, -> { joins(:collection_preference).where(collection_preferences: { anonymous: true }) }
+ scope :no_challenge, -> { where(challenge_type: nil) }
+ scope :gift_exchange, -> { where(challenge_type: "GiftExchange") }
+ scope :prompt_meme, -> { where(challenge_type: "PromptMeme") }
+ scope :name_only, -> { select("collections.name") }
+ scope :by_title, -> { order(:title) }
+ scope :for_blurb, -> { includes(:parent, :moderators, :children, :collection_preference, owners: [:user]).with_attached_icon }
+
+ def cleanup_url
+ self.header_image_url = Addressable::URI.heuristic_parse(self.header_image_url) if self.header_image_url
+ end
+
+ # Get only collections with running challenges
+ def self.signup_open(challenge_type)
+ case challenge_type
+ when "PromptMeme"
+ not_closed.where(challenge_type: challenge_type)
+ .joins("INNER JOIN prompt_memes on prompt_memes.id = challenge_id").where("prompt_memes.signup_open = 1")
+ .where("prompt_memes.signups_close_at > ?", Time.zone.now).order("prompt_memes.signups_close_at DESC")
+ when "GiftExchange"
+ not_closed.where(challenge_type: challenge_type)
+ .joins("INNER JOIN gift_exchanges on gift_exchanges.id = challenge_id").where("gift_exchanges.signup_open = 1")
+ .where("gift_exchanges.signups_close_at > ?", Time.zone.now).order("gift_exchanges.signups_close_at DESC")
+ end
+ end
+
+ scope :with_name_like, lambda { |name|
+ where("collections.name LIKE ?", "%#{name}%")
+ .limit(10)
+ }
+
+ scope :with_title_like, lambda { |title|
+ where("collections.title LIKE ?", "%#{title}%")
+ }
+
+ scope :with_item_count, lambda {
+ select("collections.*, count(distinct collection_items.id) as item_count")
+ .joins("left join collections child_collections on child_collections.parent_id = collections.id
+ left join collection_items on ( (collection_items.collection_id = child_collections.id OR collection_items.collection_id = collections.id)
+ AND collection_items.user_approval_status = 1
+ AND collection_items.collection_approval_status = 1)")
+ .group("collections.id")
+ }
+
+ def to_param
+ name_was
+ end
+
+ # Change membership of collection(s) from a particular pseud to the orphan account
+ def self.orphan(pseuds, collections, default: true)
+ pseuds.each do |pseud|
+ collections.each do |collection|
+ if pseud && collection && collection.owners.include?(pseud)
+ orphan_pseud = default ? User.orphan_account.default_pseud : User.orphan_account.pseuds.find_or_create_by(name: pseud.name)
+ pseud.change_membership(collection, orphan_pseud)
+ end
+ end
+ end
+ end
+
+ ## AUTOCOMPLETE
+ # set up autocomplete and override some methods
+ include AutocompleteSource
+
+ def autocomplete_search_string
+ "#{name} #{title}"
+ end
+
+ def autocomplete_search_string_before_last_save
+ "#{name_before_last_save} #{title_before_last_save}"
+ end
+
+ def autocomplete_prefixes
+ ["autocomplete_collection_all",
+ "autocomplete_collection_#{closed? ? 'closed' : 'open'}"]
+ end
+
+ def autocomplete_score
+ all_items.approved_by_collection.approved_by_user.count
+ end
+ ## END AUTOCOMPLETE
+
+ def parent_name=(name)
+ @parent_name = name
+ self.parent = Collection.find_by(name: name)
+ end
+
+ def parent_name
+ @parent_name || (self.parent ? self.parent.name : "")
+ end
+
+ def all_owners
+ (self.owners + (self.parent ? self.parent.owners : [])).uniq
+ end
+
+ def all_moderators
+ (self.moderators + (self.parent ? self.parent.moderators : [])).uniq
+ end
+
+ def all_members
+ (self.members + (self.parent ? self.parent.members : [])).uniq
+ end
+
+ def all_posting_participants
+ (self.posting_participants + (self.parent ? self.parent.posting_participants : [])).uniq
+ end
+
+ def all_participants
+ (self.participants + (self.parent ? self.parent.participants : [])).uniq
+ end
+
+ def all_items
+ CollectionItem.where(collection_id: ([self.id] + self.children.pluck(:id)))
+ end
+
+ def maintainers
+ self.all_owners + self.all_moderators
+ end
+
+ def user_is_owner?(user)
+ user && user != false && !(user.pseuds & self.all_owners).empty?
+ end
+
+ def user_is_moderator?(user)
+ user && user != false && !(user.pseuds & self.all_moderators).empty?
+ end
+
+ def user_is_maintainer?(user)
+ user && user != false && !(user.pseuds & (self.all_moderators + self.all_owners)).empty?
+ end
+
+ def user_is_participant?(user)
+ user && user != false && !get_participating_pseuds_for_user(user).empty?
+ end
+
+ def user_is_posting_participant?(user)
+ user && user != false && !(user.pseuds & self.all_posting_participants).empty?
+ end
+
+ def get_participating_pseuds_for_user(user)
+ (user && user != false) ? user.pseuds & self.all_participants : []
+ end
+
+ def get_participants_for_user(user)
+ return [] unless user
+
+ CollectionParticipant.in_collection(self).for_user(user)
+ end
+
+ def assignment_notification
+ self.collection_profile.assignment_notification || (parent ? parent.collection_profile.assignment_notification : "")
+ end
+
+ def gift_notification
+ self.collection_profile.gift_notification || (parent ? parent.collection_profile.gift_notification : "")
+ end
+
+ def moderated?() = self.collection_preference.moderated
+
+ def closed?() = self.collection_preference.closed
+
+ def unrevealed?() = self.collection_preference.unrevealed
+
+ def anonymous?() = self.collection_preference.anonymous
+
+ def challenge?() = !self.challenge.nil?
+
+ def gift_exchange?
+ self.challenge_type == "GiftExchange"
+ end
+
+ def prompt_meme?
+ self.challenge_type == "PromptMeme"
+ end
+
+ def maintainers_list
+ self.maintainers.collect(&:user).flatten.uniq
+ end
+
+ def collection_email
+ return self.email if self.email.present?
+ return parent.email if parent && parent.email.present?
+ end
+
+ def notify_maintainers_assignments_sent
+ subject = I18n.t("user_mailer.collection_notification.assignments_sent.subject")
+ message = I18n.t("user_mailer.collection_notification.assignments_sent.complete")
+ if self.collection_email.present?
+ UserMailer.collection_notification(self.id, subject, message, self.collection_email).deliver_later
+ else
+ # if collection email is not set and collection parent email is not set, loop through maintainers and send each a notice via email
+ self.maintainers_list.each do |user|
+ I18n.with_locale(user.preference.locale_for_mails) do
+ translated_subject = I18n.t("user_mailer.collection_notification.assignments_sent.subject")
+ translated_message = I18n.t("user_mailer.collection_notification.assignments_sent.complete")
+ UserMailer.collection_notification(self.id, translated_subject, translated_message, user.email).deliver_later
+ end
+ end
+ end
+ end
+
+ def notify_maintainers_challenge_default(challenge_assignment, assignments_page_url)
+ if self.collection_email.present?
+ subject = I18n.t("user_mailer.collection_notification.challenge_default.subject", offer_byline: challenge_assignment.offer_byline)
+ message = I18n.t("user_mailer.collection_notification.challenge_default.complete", offer_byline: challenge_assignment.offer_byline, request_byline: challenge_assignment.request_byline, assignments_page_url: assignments_page_url)
+ UserMailer.collection_notification(self.id, subject, message, self.collection_email).deliver_later
+ else
+ # if collection email is not set and collection parent email is not set, loop through maintainers and send each a notice via email
+ self.maintainers_list.each do |user|
+ I18n.with_locale(user.preference.locale_for_mails) do
+ translated_subject = I18n.t("user_mailer.collection_notification.challenge_default.subject", offer_byline: challenge_assignment.offer_byline)
+ translated_message = I18n.t("user_mailer.collection_notification.challenge_default.complete", offer_byline: challenge_assignment.offer_byline, request_byline: challenge_assignment.request_byline, assignments_page_url: assignments_page_url)
+ UserMailer.collection_notification(self.id, translated_subject, translated_message, user.email).deliver_later
+ end
+ end
+ end
+ end
+
+ include AsyncWithResque
+ @queue = :collection
+
+ def reveal!
+ async(:reveal_collection_items)
+ end
+
+ def reveal_authors!
+ async(:reveal_collection_item_authors)
+ end
+
+ def reveal_collection_items
+ approved_collection_items.each { |collection_item| collection_item.update_attribute(:unrevealed, false) }
+ send_reveal_notifications
+ end
+
+ def reveal_collection_item_authors
+ approved_collection_items.each { |collection_item| collection_item.update_attribute(:anonymous, false) }
+ end
+
+ def send_reveal_notifications
+ approved_collection_items.each(&:notify_of_reveal)
+ end
+
+ def self.sorted_and_filtered(sort, filters, page)
+ pagination_args = { page: page }
+
+ # build up the query with scopes based on the options the user specifies
+ query = Collection.top_level
+
+ if filters[:title].present?
+ # we get the matching collections out of autocomplete and use their ids
+ ids = Collection.autocomplete_lookup(search_param: filters[:title],
+ autocomplete_prefix: (if filters[:closed].blank?
+ "autocomplete_collection_all"
+ else
+ (filters[:closed] ? "autocomplete_collection_closed" : "autocomplete_collection_open")
+ end)).map { |result| Collection.id_from_autocomplete(result) }
+ query = query.where(collections: { id: ids })
+ elsif filters[:closed].present?
+ query = (filters[:closed] == "true" ? query.closed : query.not_closed)
+ end
+ query = (filters[:moderated] == "true" ? query.moderated : query.unmoderated) if filters[:moderated].present?
+ if filters[:challenge_type].present?
+ case filters[:challenge_type]
+ when "gift_exchange"
+ query = query.gift_exchange
+ when "prompt_meme"
+ query = query.prompt_meme
+ when "no_challenge"
+ query = query.no_challenge
+ end
+ end
+ query = query.order(sort).for_blurb
+
+ if filters[:fandom].blank?
+ query.paginate(pagination_args)
+ else
+ fandom = Fandom.find_by_name(filters[:fandom])
+ if fandom
+ (fandom.approved_collections & query).paginate(pagination_args)
+ else
+ []
+ end
+ end
+ end
+
+ # Delete current icon (thus reverting to archive default icon)
+ def delete_icon=(value)
+ @delete_icon = !value.to_i.zero?
+ end
+
+ def delete_icon
+ !!@delete_icon
+ end
+ alias delete_icon? delete_icon
+
+ def clear_icon
+ return unless delete_icon?
+
+ self.icon.purge
+ self.icon_alt_text = nil
+ self.icon_comment_text = nil
+ end
+end
diff --git a/app/models/collection_item.rb b/app/models/collection_item.rb
new file mode 100755
index 0000000..b542d5f
--- /dev/null
+++ b/app/models/collection_item.rb
@@ -0,0 +1,275 @@
+class CollectionItem < ApplicationRecord
+ APPROVAL_OPTIONS = [
+ ["", :unreviewed],
+ [ts("Approved"), :approved],
+ [ts("Rejected"), :rejected]
+ ]
+
+ belongs_to :collection, inverse_of: :collection_items
+ belongs_to :item, polymorphic: :true, inverse_of: :collection_items, touch: true
+ belongs_to :work, class_name: "Work", foreign_key: "item_id", inverse_of: :collection_items
+ belongs_to :bookmark, class_name: "Bookmark", foreign_key: "item_id"
+
+ validates_uniqueness_of :collection_id, scope: [:item_id, :item_type],
+ message: ts("already contains this item.")
+
+ enum :user_approval_status, {
+ rejected: -1,
+ unreviewed: 0,
+ approved: 1
+ }, suffix: :by_user
+
+ enum :collection_approval_status, {
+ rejected: -1,
+ unreviewed: 0,
+ approved: 1
+ }, suffix: :by_collection
+
+ validate :collection_is_open, on: :create
+ def collection_is_open
+ if self.new_record? && self.collection && self.collection.closed? && !self.collection.user_is_maintainer?(User.current_user)
+ errors.add(:base, ts("The collection %{title} is not currently open.", title: self.collection.title))
+ end
+ end
+
+ scope :include_for_works, -> { includes(item: :pseuds) }
+ scope :unrevealed, -> { where(unrevealed: true) }
+ scope :anonymous, -> { where(anonymous: true) }
+
+ def self.for_user(user=User.current_user)
+ # get ids of user's bookmarks and works
+ bookmark_ids = Bookmark.joins(:pseud).where("pseuds.user_id = ?", user.id).pluck(:id)
+ work_ids = Work.joins(:pseuds).where("pseuds.user_id = ?", user.id).pluck(:id)
+ # now return the relation
+ where("(item_id IN (?) AND item_type = 'Work') OR (item_id IN (?) AND item_type = 'Bookmark')", work_ids, bookmark_ids)
+ end
+
+ scope :invited_by_collection, -> { approved_by_collection.unreviewed_by_user }
+ scope :approved_by_both, -> { approved_by_collection.approved_by_user }
+
+ before_save :set_anonymous_and_unrevealed
+ def set_anonymous_and_unrevealed
+ if self.new_record? && collection
+ self.unrevealed = true if collection.reload.unrevealed?
+ self.anonymous = true if collection.reload.anonymous?
+ end
+ end
+
+ after_save :update_work
+ after_destroy :update_work
+
+ # Set associated works to anonymous or unrevealed as appropriate.
+ #
+ # Inverses are set up properly on self.item, so we use that field to check
+ # whether we're currently in the process of saving a brand new work, or
+ # whether the work is in the process of being destroyed. (In which case we
+ # rely on the Work's callbacks to set anon/unrevealed status properly.) But
+ # because we want to discard changes made in preview mode, we perform the
+ # actual anon/unrevealed updates on self.work, which doesn't have proper
+ # inverses and therefore is freshly loaded from the database.
+ def update_work
+ return unless item.is_a?(Work) && item.persisted? && !item.saved_change_to_id?
+
+ if work.present?
+ work.update_anon_unrevealed
+
+ # For a more helpful error message, raise an error saying that the work
+ # is invalid if we fail to save it.
+ raise ActiveRecord::RecordInvalid, work unless work.save(validate: false)
+ end
+ end
+
+ # Poke the item if it's just been approved or unapproved so it gets picked up by the search index
+ after_update :update_item_for_status_change
+ def update_item_for_status_change
+ if saved_change_to_user_approval_status? || saved_change_to_collection_approval_status?
+ item.save!(validate: false)
+ end
+ end
+
+ after_create_commit :notify_of_association
+ def notify_of_association
+ email_notify = self.collection.collection_preference &&
+ self.collection.collection_preference.email_notify
+
+ if email_notify && !self.collection.email.blank?
+ CollectionMailer.item_added_notification(item_id, collection_id, item_type).deliver_later
+ end
+ end
+
+ after_create_commit :notify_archivist_added
+ # Sends emails to item creator(s) in the case that an archivist
+ # has added them to the collection.
+ def notify_archivist_added
+ return unless item.is_a?(Work) && User.current_user&.archivist && collection.user_is_maintainer?(User.current_user)
+
+ item.users.each do |email_recipient|
+ next if email_recipient.preference.collection_emails_off
+
+ I18n.with_locale(email_recipient.preference.locale_for_mails) do
+ UserMailer.archivist_added_to_collection_notification(
+ email_recipient.id,
+ item.id,
+ collection.id
+ ).deliver_later
+ end
+ end
+ end
+
+ before_save :approve_automatically
+ def approve_automatically
+ return unless self.new_record?
+
+ # approve with the current user, who is the person who has just
+ # added this item -- might be either moderator or owner
+ # rubocop:disable Lint/BooleanSymbol
+ approve(User.current_user == :false ? nil : User.current_user)
+ # rubocop:enable Lint/BooleanSymbol
+
+ # if the collection is open or the user who owns this work is a member, go ahead and approve
+ # for the collection
+ return unless !approved_by_collection? && collection
+
+ approve_by_collection if !collection.moderated? || collection.user_is_maintainer?(User.current_user) || collection.user_is_posting_participant?(User.current_user)
+ end
+
+ before_save :send_work_invitation
+ def send_work_invitation
+ return if approved_by_user? || !approved_by_collection? || !self.new_record? || User.current_user.is_author_of?(item)
+
+ # a maintainer is attempting to add this work to their collection
+ # so we send an email to all the works owners
+ item.users.each do |email_author|
+ next if email_author.preference.collection_emails_off
+
+ I18n.with_locale(email_author.preference.locale_for_mails) do
+ UserMailer.invited_to_collection_notification(email_author.id, item.id, collection.id).deliver_now
+ end
+ end
+ end
+
+ after_destroy :expire_caches
+ def expire_caches
+ if self.item.respond_to?(:expire_caches)
+ self.item.expire_caches
+ CacheMaster.record(item_id, 'collection', collection_id)
+ end
+ end
+
+ attr_writer :remove
+ def remove
+ @remove || ""
+ end
+
+ def recipients
+ item.respond_to?(:recipients) ? item.recipients : ""
+ end
+
+ def item_creator_pseuds
+ if self.item
+ if self.item.respond_to?(:pseuds)
+ self.item.pseuds
+ elsif self.item.respond_to?(:pseud)
+ [self.item.pseud]
+ else
+ []
+ end
+ else
+ []
+ end
+ end
+
+ def item_date
+ item.respond_to?(:revised_at) ? item.revised_at : item.updated_at
+ end
+
+ def user_allowed_to_destroy?(user)
+ user.is_author_of?(self.item) ||
+ (self.collection.user_is_maintainer?(user) && !self.rejected_by_user?)
+ end
+
+ def approve_by_user
+ self.user_approval_status = :approved
+ end
+
+ def approve_by_collection
+ self.collection_approval_status = :approved
+ end
+
+ def approved?
+ approved_by_user? && approved_by_collection?
+ end
+
+ def approve(user)
+ if user.nil?
+ # this is being run via rake task eg for importing collections
+ approve_by_user
+ approve_by_collection
+ else
+ author_of_item = user.is_author_of?(item) ||
+ (user == User.current_user && item.respond_to?(:pseuds) ? item.pseuds.empty? : item.pseud.nil?)
+ archivist_maintainer = user.archivist && self.collection.user_is_maintainer?(user)
+ approve_by_user if author_of_item || archivist_maintainer
+ approve_by_collection if self.collection.user_is_maintainer?(user)
+ end
+ end
+
+ def posted?
+ self.item.respond_to?(:posted?) ? self.item.posted? : true
+ end
+
+ def notify_of_reveal
+ unless self.unrevealed? || !self.posted?
+ recipient_pseuds = Pseud.parse_bylines(self.recipients)[:pseuds]
+ recipient_pseuds.each do |pseud|
+ user_preference = pseud.user.preference
+ next if user_preference.recipient_emails_off
+
+ I18n.with_locale(user_preference.locale_for_mails) do
+ UserMailer.recipient_notification(pseud.user.id, self.item.id, self.collection.id).deliver_after_commit
+ end
+ end
+
+ # also notify prompters of responses to their prompt
+ if item_type == "Work" && !item.challenge_claims.blank?
+ UserMailer.prompter_notification(self.item.id, self.collection.id).deliver_after_commit
+ end
+
+ # also notify the owners of any parent/inspired-by works
+ if item_type == "Work" && !item.parent_work_relationships.empty?
+ item.parent_work_relationships.each do |relationship|
+ relationship.notify_parent_owners
+ end
+ end
+ end
+ end
+
+ after_update :notify_of_unrevealed_or_anonymous
+ def notify_of_unrevealed_or_anonymous
+ # This CollectionItem's anonymous/unrevealed status can only affect the
+ # item's status if (a) the CollectionItem is approved by the user and (b)
+ # the item is a work. (Bookmarks can't be anonymous/unrevealed at the
+ # moment.)
+ return unless approved_by_user? && item.is_a?(Work)
+
+ # Check whether anonymous/unrevealed is becoming true, when the work
+ # currently has it set to false:
+ newly_anonymous = (saved_change_to_anonymous?(to: true) && !item.anonymous?)
+ newly_unrevealed = (saved_change_to_unrevealed?(to: true) && !item.unrevealed?)
+
+ return unless newly_unrevealed || newly_anonymous
+
+ # Don't notify if it's one of the work creators who is changing the work's
+ # status.
+ return if item.users.include?(User.current_user)
+
+ item.users.each do |user|
+ I18n.with_locale(user.preference.locale_for_mails) do
+ UserMailer.anonymous_or_unrevealed_notification(
+ user.id, item.id, collection.id,
+ anonymous: newly_anonymous, unrevealed: newly_unrevealed
+ ).deliver_after_commit
+ end
+ end
+ end
+end
diff --git a/app/models/collection_participant.rb b/app/models/collection_participant.rb
new file mode 100755
index 0000000..308980b
--- /dev/null
+++ b/app/models/collection_participant.rb
@@ -0,0 +1,57 @@
+class CollectionParticipant < ApplicationRecord
+ belongs_to :pseud
+ has_one :user, through: :pseud
+ belongs_to :collection
+
+ PARTICIPANT_ROLES = ["None", "Owner", "Moderator", "Member", "Invited"]
+ NONE = PARTICIPANT_ROLES[0]
+ OWNER = PARTICIPANT_ROLES[1]
+ MODERATOR = PARTICIPANT_ROLES[2]
+ MEMBER = PARTICIPANT_ROLES[3]
+ INVITED = PARTICIPANT_ROLES[4]
+ MAINTAINER_ROLES = [PARTICIPANT_ROLES[1], PARTICIPANT_ROLES[2]]
+ PARTICIPANT_ROLE_OPTIONS = [ [ts("None"), NONE],
+ [ts("Invited"), INVITED],
+ [ts("Member"), MEMBER],
+ [ts("Moderator"), MODERATOR],
+ [ts("Owner"), OWNER] ]
+
+ validates_uniqueness_of :pseud_id, scope: [:collection_id],
+ message: ts("That person appears to already be a participant in that collection.")
+
+ validates_presence_of :participant_role
+ validates_inclusion_of :participant_role, in: PARTICIPANT_ROLES,
+ message: ts("That is not a valid participant role.")
+
+ scope :for_user, lambda {|user|
+ select("DISTINCT collection_participants.*").
+ joins(pseud: :user).
+ where('users.id = ?', user.id)
+ }
+
+ scope :in_collection, lambda {|collection|
+ select("DISTINCT collection_participants.*").
+ joins(:collection).
+ where('collections.id = ?', collection.id)
+ }
+
+ def is_owner? ; self.participant_role == OWNER ; end
+ def is_moderator? ; self.participant_role == MODERATOR ; end
+ def is_maintainer? ; is_owner? || is_moderator? ; end
+ def is_member? ; self.participant_role == MEMBER ; end
+ def is_invited? ; self.participant_role == INVITED ; end
+ def is_none? ; self.participant_role == NONE ; end
+
+ def approve_membership!
+ self.participant_role = MEMBER
+ save
+ end
+
+ def user_allowed_to_destroy?(user)
+ self.collection.user_is_maintainer?(user) || self.pseud.user == user
+ end
+
+ def user_allowed_to_promote?(user, role)
+ (role == MEMBER || role == NONE) ? self.collection.user_is_maintainer?(user) : self.collection.user_is_owner?(user)
+ end
+end
diff --git a/app/models/collection_preference.rb b/app/models/collection_preference.rb
new file mode 100755
index 0000000..54b56b3
--- /dev/null
+++ b/app/models/collection_preference.rb
@@ -0,0 +1,15 @@
+class CollectionPreference < ApplicationRecord
+ belongs_to :collection
+ after_update :after_update
+
+ def after_update
+ if self.collection.valid? && self.valid?
+ if self.saved_change_to_unrevealed? && !self.unrevealed?
+ self.collection.reveal!
+ end
+ if self.saved_change_to_anonymous? && !self.anonymous?
+ collection.reveal_authors!
+ end
+ end
+ end
+end
diff --git a/app/models/collection_profile.rb b/app/models/collection_profile.rb
new file mode 100755
index 0000000..7b6443d
--- /dev/null
+++ b/app/models/collection_profile.rb
@@ -0,0 +1,24 @@
+class CollectionProfile < ApplicationRecord
+ belongs_to :collection
+
+ validates_length_of :intro,
+ allow_blank: true,
+ maximum: ArchiveConfig.INFO_MAX, too_long: ts("must be less than %{max} letters long.", max: ArchiveConfig.INFO_MAX)
+
+ validates_length_of :faq,
+ allow_blank: true,
+ maximum: ArchiveConfig.INFO_MAX, too_long: ts("must be less than %{max} letters long.", max: ArchiveConfig.INFO_MAX)
+
+ validates_length_of :rules,
+ allow_blank: true,
+ maximum: ArchiveConfig.INFO_MAX, too_long: ts("must be less than %{max} letters long.", max: ArchiveConfig.INFO_MAX)
+
+ validates_length_of :assignment_notification,
+ allow_blank: true,
+ maximum: ArchiveConfig.SUMMARY_MAX, too_long: ts("must be less than %{max} letters long.", max: ArchiveConfig.SUMMARY_MAX)
+
+ validates_length_of :gift_notification,
+ allow_blank: true,
+ maximum: ArchiveConfig.SUMMARY_MAX, too_long: ts("must be less than %{max} letters long.", max: ArchiveConfig.SUMMARY_MAX)
+
+end
diff --git a/app/models/comment.rb b/app/models/comment.rb
new file mode 100755
index 0000000..ad6ff4c
--- /dev/null
+++ b/app/models/comment.rb
@@ -0,0 +1,555 @@
+class Comment < ApplicationRecord
+ include HtmlCleaner
+ include AfterCommitEverywhere
+
+ belongs_to :pseud
+ belongs_to :commentable, polymorphic: true
+ belongs_to :parent, polymorphic: true
+
+ has_many :inbox_comments, foreign_key: 'feedback_comment_id', dependent: :destroy
+ has_many :users, through: :inbox_comments
+
+ has_many :reviewed_replies, -> { reviewed },
+ class_name: "Comment", as: :commentable, inverse_of: :commentable
+
+ has_many :thread_comments, class_name: 'Comment', foreign_key: :thread
+
+ validates :name, presence: { unless: :pseud_id }, not_forbidden_name: { if: :will_save_change_to_name? }
+ validates :email, email_format: { on: :create, unless: :pseud_id }, email_blacklist: { on: :create, unless: :pseud_id }
+
+ validates_presence_of :comment_content
+ validates_length_of :comment_content,
+ maximum: ArchiveConfig.COMMENT_MAX,
+ too_long: ts("must be less than %{count} characters long.", count: ArchiveConfig.COMMENT_MAX)
+
+ delegate :user, to: :pseud, allow_nil: true
+
+ # Whether the writer of the comment this is replying to allows guest replies
+ validate :guest_can_reply, if: :reply_comment?, unless: :pseud_id, on: :create
+ def guest_can_reply
+ errors.add(:commentable, :guest_replies_off) if commentable.guest_replies_disallowed?
+ end
+
+ # Whether the writer of this comment disallows guest replies
+ def guest_replies_disallowed?
+ return false unless user
+
+ user.preference.guest_replies_off && !user.is_author_of?(ultimate_parent)
+ end
+
+ # Check if the writer of this comment is blocked by the writer of the comment
+ # they're replying to:
+ validates :user, not_blocked: {
+ by: :commentable,
+ if: :reply_comment?,
+ unless: :on_tag?,
+ message: :blocked_reply
+ }
+
+ # Check if the writer of this comment is blocked by one of the creators of
+ # the work they're replying to:
+ validates :user, not_blocked: {
+ by: :ultimate_parent,
+ unless: :on_tag?,
+ message: :blocked_comment
+ }
+
+ def on_tag?
+ parent_type == "Tag"
+ end
+
+ def by_anonymous_creator?
+ ultimate_parent.try(:anonymous?) && user&.is_author_of?(ultimate_parent)
+ end
+
+ validate :check_for_spam, on: :create
+
+ def check_for_spam
+ self.approved = skip_spamcheck? || !spam?
+
+ errors.add(:base, :spam) unless approved
+ end
+
+ validate :edited_spam, on: :update, if: [:will_save_change_to_edited_at?, :will_save_change_to_comment_content?]
+
+ def edited_spam
+ return if skip_spamcheck? || !content_too_different?(comment_content, comment_content_in_database, ArchiveConfig.EDITED_COMMENT_SPAM_CHECK_THRESHOLD)
+
+ errors.add(:base, :spam) if spam?
+ end
+
+ validates :comment_content, uniqueness: {
+ scope: [:commentable_id, :commentable_type, :name, :email, :pseud_id],
+ unless: :is_deleted?,
+ message: :duplicate_comment
+ }
+
+ scope :ordered_by_date, -> { order('created_at DESC') }
+ scope :top_level, -> { where.not(commentable_type: "Comment") }
+ scope :include_pseud, -> { includes(:pseud) }
+ scope :not_deleted, -> { where(is_deleted: false) }
+ scope :reviewed, -> { where(unreviewed: false) }
+ scope :unreviewed_only, -> { where(unreviewed: true) }
+
+ scope :for_display, lambda {
+ includes(
+ pseud: { user: [:roles, :block_of_current_user, :block_by_current_user, :preference] },
+ parent: { work: [:pseuds, :users] }
+ ).merge(Pseud.with_attached_icon)
+ }
+
+ # Gets methods and associations from acts_as_commentable plugin
+ acts_as_commentable
+ has_comment_methods
+
+ def akismet_attributes
+ # While we do have tag comments, those are from logged-in users with special
+ # access granted by admins, so we never spam check them, unlike comments on
+ # works or admin posts.
+ comment_type = ultimate_parent.is_a?(Work) ? "fanwork-comment" : "comment"
+
+ if pseud_id.nil?
+ user_role = "guest"
+ comment_author = name
+ else
+ user_role = "user"
+ comment_author = user.login
+ end
+
+ attributes = {
+ comment_type: comment_type,
+ key: ArchiveConfig.AKISMET_KEY,
+ blog: ArchiveConfig.AKISMET_NAME,
+ user_ip: ip_address,
+ user_agent: user_agent,
+ user_role: user_role,
+ comment_author: comment_author,
+ comment_author_email: comment_owner_email,
+ comment_content: comment_content
+ }
+
+ attributes[:recheck_reason] = "edit" if will_save_change_to_edited_at? && will_save_change_to_comment_content?
+
+ attributes
+ end
+
+ after_create :expire_parent_comments_count
+ after_update :expire_parent_comments_count, if: :saved_change_to_visibility?
+ after_destroy :expire_parent_comments_count
+ def expire_parent_comments_count
+ after_commit { parent&.expire_comments_count }
+ end
+
+ def saved_change_to_visibility?
+ pertinent_attributes = %w[is_deleted hidden_by_admin unreviewed approved]
+ (saved_changes.keys & pertinent_attributes).present?
+ end
+
+ before_validation :set_parent_and_unreviewed, on: :create
+
+ before_create :set_depth
+ before_create :set_thread_for_replies
+ before_create :set_parent_and_unreviewed
+ after_create :update_thread
+ before_create :adjust_threading, if: :reply_comment?
+
+ after_create :update_work_stats
+ after_destroy :update_work_stats
+
+ # If a comment has changed too much, we might need to put it back in moderation:
+ before_update :recheck_unreviewed
+ def recheck_unreviewed
+ return unless edited_at_changed? &&
+ comment_content_changed? &&
+ moderated_commenting_enabled? &&
+ !is_creator_comment? &&
+ content_too_different?(comment_content, comment_content_was, ArchiveConfig.COMMENT_MODERATION_THRESHOLD)
+
+ self.unreviewed = true
+ end
+
+ after_update :after_update
+ def after_update
+ users = []
+
+ if self.saved_change_to_edited_at? || (self.saved_change_to_unreviewed? && !self.unreviewed?)
+ # Reply to owner of parent comment if this is a reply comment
+ # Potentially we are notifying the original commenter of a newly-approved reply to their comment
+ if (parent_comment_owner = notify_parent_comment_owner)
+ users << parent_comment_owner
+ end
+ end
+
+ if self.saved_change_to_edited_at?
+ # notify the commenter
+ if self.comment_owner && notify_user_of_own_comments?(self.comment_owner)
+ users << self.comment_owner
+ end
+ if notify_user_by_email?(self.comment_owner) && notify_user_of_own_comments?(self.comment_owner)
+ if self.reply_comment?
+ CommentMailer.comment_reply_sent_notification(self).deliver_after_commit
+ else
+ CommentMailer.comment_sent_notification(self).deliver_after_commit
+ end
+ end
+
+ # send notification to the owner(s) of the ultimate parent, who can be users or admins
+ # at this point, users contains those who've already been notified
+ if users.empty?
+ users = self.ultimate_parent.commentable_owners
+ else
+ # replace with the owners of the commentable who haven't already been notified
+ users = self.ultimate_parent.commentable_owners - users
+ end
+ users.each do |user|
+ unless user == self.comment_owner && !notify_user_of_own_comments?(user)
+ if notify_user_by_email?(user) || self.ultimate_parent.is_a?(Tag)
+ CommentMailer.edited_comment_notification(user, self).deliver_after_commit
+ end
+ if user.is_a?(User) && notify_user_by_inbox?(user)
+ update_feedback_in_inbox(user)
+ end
+ end
+ end
+ end
+ end
+
+ after_create :after_create
+ def after_create
+ self.reload
+ # eventually we will set the locale to the user's stored language of choice
+ #Locale.set ArchiveConfig.SUPPORTED_LOCALES[ArchiveConfig.DEFAULT_LOCALE]
+ users = []
+
+ # notify the commenter
+ if self.comment_owner && notify_user_of_own_comments?(self.comment_owner)
+ users << self.comment_owner
+ end
+ if notify_user_by_email?(self.comment_owner) && notify_user_of_own_comments?(self.comment_owner)
+ if self.reply_comment?
+ CommentMailer.comment_reply_sent_notification(self).deliver_after_commit
+ else
+ CommentMailer.comment_sent_notification(self).deliver_after_commit
+ end
+ end
+
+ # Reply to owner of parent comment if this is a reply comment
+ if (parent_comment_owner = notify_parent_comment_owner)
+ users << parent_comment_owner
+ end
+
+ # send notification to the owner(s) of the ultimate parent, who can be users or admins
+ # at this point, users contains those who've already been notified
+ if users.empty?
+ users = self.ultimate_parent.commentable_owners
+ else
+ # replace with the owners of the commentable who haven't already been notified
+ users = self.ultimate_parent.commentable_owners - users
+ end
+ users.each do |user|
+ unless user == self.comment_owner && !notify_user_of_own_comments?(user)
+ if notify_user_by_email?(user) || self.ultimate_parent.is_a?(Tag)
+ CommentMailer.comment_notification(user, self).deliver_after_commit
+ end
+ if user.is_a?(User) && notify_user_by_inbox?(user)
+ add_feedback_to_inbox(user)
+ end
+ end
+ end
+ end
+
+ after_create :record_wrangling_activity, if: :on_tag?
+ def record_wrangling_activity
+ self.comment_owner&.update_last_wrangling_activity
+ end
+
+ protected
+
+ def notify_user_of_own_comments?(user)
+ if user.nil? || user == User.orphan_account
+ false
+ elsif user.is_a?(Admin)
+ true
+ else
+ !user.preference.comment_copy_to_self_off?
+ end
+ end
+
+ def notify_user_by_inbox?(user)
+ if user.nil? || user == User.orphan_account
+ false
+ elsif user.is_a?(Admin)
+ true
+ else
+ !user.preference.comment_inbox_off?
+ end
+ end
+
+ def notify_user_by_email?(user)
+ if user.nil? || user == User.orphan_account
+ false
+ elsif user.is_a?(Admin)
+ true
+ else
+ !user.preference.comment_emails_off?
+ end
+ end
+
+ def update_feedback_in_inbox(user)
+ if (edited_feedback = user.inbox_comments.find_by(feedback_comment_id: self.id))
+ edited_feedback.update_attribute(:read, false)
+ else # original inbox comment was deleted
+ add_feedback_to_inbox(user)
+ end
+ end
+
+ def add_feedback_to_inbox(user)
+ new_feedback = user.inbox_comments.build
+ new_feedback.feedback_comment_id = self.id
+ new_feedback.save
+ end
+
+ def content_too_different?(new_content, old_content, threshold)
+ # we added more than the threshold # of chars, just return
+ return true if new_content.length > (old_content.length + threshold)
+
+ # quick and dirty iteration to compare the two strings
+ cost = 0
+ new_i = 0
+ old_i = 0
+ while new_i < new_content.length && old_i < old_content.length
+ if new_content[new_i] == old_content[old_i]
+ new_i += 1
+ old_i += 1
+ next
+ end
+
+ cost += 1
+ # interrupt as soon as we have changed > threshold chars
+ return true if cost > threshold
+
+ # peek ahead to see if we can catch up on either side eg if a letter has been inserted/deleted
+ if new_content[new_i + 1] == old_content[old_i]
+ new_i += 1
+ elsif new_content[new_i] == old_content[old_i + 1]
+ old_i += 1
+ else
+ # just keep going
+ new_i += 1
+ old_i += 1
+ end
+ end
+
+ cost > threshold
+ end
+
+ def not_user_commenter?(parent_comment)
+ (!parent_comment.comment_owner && parent_comment.comment_owner_email && parent_comment.comment_owner_name)
+ end
+
+ def different_owner?(parent_comment)
+ not_user_commenter?(parent_comment) || (parent_comment.comment_owner != self.comment_owner)
+ end
+
+ def notify_parent_comment_owner
+ return unless self.reply_comment? && !self.unreviewed?
+
+ parent_comment = self.commentable
+ parent_comment_owner = parent_comment.comment_owner # will be nil if not a user, including if an admin
+
+ # if I'm replying to a comment you left for me, mark your comment as replied to in my inbox
+ if self.comment_owner && (inbox_comment = self.comment_owner.inbox_comments.find_by(feedback_comment_id: parent_comment.id))
+ inbox_comment.update(replied_to: true, read: true)
+ end
+
+ return unless different_owner?(parent_comment)
+
+ # Never notify people who are not tag wranglers (any more) about comments on tags
+ return if self.ultimate_parent.is_a?(Tag) && !parent_comment_owner&.is_tag_wrangler?
+
+ # send notification to the owner of the original comment if they're not the same as the commenter
+ if !parent_comment_owner || notify_user_by_email?(parent_comment_owner) || self.ultimate_parent.is_a?(Tag)
+ if self.saved_change_to_edited_at?
+ CommentMailer.edited_comment_reply_notification(parent_comment, self).deliver_after_commit
+ else
+ CommentMailer.comment_reply_notification(parent_comment, self).deliver_after_commit
+ end
+ end
+
+ if parent_comment_owner && notify_user_by_inbox?(parent_comment_owner)
+ if self.saved_change_to_edited_at?
+ update_feedback_in_inbox(parent_comment_owner)
+ else
+ add_feedback_to_inbox(parent_comment_owner)
+ end
+ end
+
+ parent_comment_owner
+ end
+
+ public
+
+ # Set the depth of the comment: 0 for a first-class comment, increasing with each level of nesting
+ def set_depth
+ self.depth = self.reply_comment? ? self.commentable.depth + 1 : 0
+ end
+
+ # The thread value for a reply comment should be the same as its parent comment
+ def set_thread_for_replies
+ self.thread = self.commentable.thread if self.reply_comment?
+ end
+
+ # Save the ultimate parent and reviewed status
+ def set_parent_and_unreviewed
+ self.parent = self.reply_comment? ? self.commentable.parent : self.commentable
+ # we only mark comments as unreviewed if moderated commenting is enabled on their parent
+ self.unreviewed = self.parent.respond_to?(:moderated_commenting_enabled?) &&
+ self.parent.moderated_commenting_enabled? &&
+ !User.current_user.try(:is_author_of?, self.ultimate_parent)
+ true
+ end
+
+ # is this a comment by the creator of the ultimate parent
+ def is_creator_comment?
+ pseud && pseud.user && pseud.user.try(:is_author_of?, ultimate_parent)
+ end
+
+ def moderated_commenting_enabled?
+ parent.respond_to?(:moderated_commenting_enabled?) && parent.moderated_commenting_enabled?
+ end
+
+ # We need a unique thread id for replies, so we'll make use of the fact
+ # that ids are unique
+ def update_thread
+ self.update_attribute(:thread, self.id) unless self.thread
+ end
+
+ def adjust_threading
+ self.commentable.add_child(self)
+ end
+
+ # Is this a first-class comment?
+ def top_level?
+ !self.reply_comment?
+ end
+
+ def comment_owner
+ self.pseud.try(:user)
+ end
+
+ def comment_owner_name
+ self.pseud.try(:name) || self.name
+ end
+
+ def comment_owner_email
+ comment_owner.try(:email) || self.email
+ end
+
+ # override this method from commentable_entity.rb
+ # to return the name of the ultimate parent this is on
+ # we have to do this somewhat roundabout because until the comment is
+ # set and saved, the ultimate_parent method will not work (the thread is not set)
+ # and this is being called from before then.
+ def commentable_name
+ self.reply_comment? ? self.commentable.ultimate_parent.commentable_name : self.commentable.commentable_name
+ end
+
+ # override this method from comment_methods.rb to return ultimate
+ alias :original_ultimate_parent :ultimate_parent
+ def ultimate_parent
+ myparent = self.original_ultimate_parent
+ myparent.kind_of?(Chapter) ? myparent.work : myparent
+ end
+
+ def self.commentable_object(commentable)
+ commentable.kind_of?(Work) ? commentable.last_posted_chapter : commentable
+ end
+
+ def find_all_comments
+ self.all_children
+ end
+
+ def count_all_comments
+ self.children_count
+ end
+
+ def count_visible_comments
+ self.children_count #FIXME
+ end
+
+ def skip_spamcheck?
+ return false unless pseud_id
+
+ on_tag? || !user.should_spam_check_comments? || is_creator_comment?
+ end
+
+ def spam?
+ return false unless %w[staging production].include?(Rails.env)
+
+ Akismetor.spam?(akismet_attributes)
+ end
+
+ def submit_spam
+ Rails.env.production? && Akismetor.submit_spam(akismet_attributes)
+ end
+
+ def submit_ham
+ Rails.env.production? && Akismetor.submit_ham(akismet_attributes)
+ end
+
+ def mark_as_spam!
+ update_attribute(:approved, false)
+ submit_spam
+ end
+
+ def mark_as_ham!
+ update_attribute(:approved, true)
+ submit_ham
+ end
+
+ # Freeze single comment.
+ def mark_frozen!
+ update_attribute(:iced, true)
+ end
+
+ # Freeze all comments.
+ def self.mark_all_frozen!(comments)
+ transaction do
+ comments.each(&:mark_frozen!)
+ end
+ end
+
+ # Unfreeze single comment.
+ def mark_unfrozen!
+ update_attribute(:iced, false)
+ end
+
+ # Unfreeze all comments.
+ def self.mark_all_unfrozen!(comments)
+ transaction do
+ comments.each(&:mark_unfrozen!)
+ end
+ end
+
+ def mark_hidden!
+ update_attribute(:hidden_by_admin, true)
+ end
+
+ def mark_unhidden!
+ update_attribute(:hidden_by_admin, false)
+ end
+
+ def sanitized_content
+ sanitize_field(self, :comment_content, image_safety_mode: use_image_safety_mode?)
+ end
+
+ def sanitized_mailer_content
+ sanitize_field(self, :comment_content, image_safety_mode: true)
+ end
+
+ def use_image_safety_mode?
+ pseud_id.nil? || hidden_by_admin || parent_type.in?(ArchiveConfig.PARENTS_WITH_IMAGE_SAFETY_MODE)
+ end
+
+ include Responder
+end
diff --git a/app/models/common_tagging.rb b/app/models/common_tagging.rb
new file mode 100755
index 0000000..0a88f04
--- /dev/null
+++ b/app/models/common_tagging.rb
@@ -0,0 +1,96 @@
+# This class represents parent-child relationships between tags
+# It should probably be renamed "ChildTagging" and have the flip tagging called "ParentTagging"?
+# Also it doesn't need to be polymorphic -- in practice, all the types are Tag
+# -- NN 11/2012
+# Because I never understand without looking at one in the database: common_tag
+# is the child tag and filterable is the parent tag.
+# -- Sarken 01/2019
+class CommonTagging < ApplicationRecord
+ include Wrangleable
+
+ # we need "touch" here so that when a common tagging changes, the tag(s) themselves are updated and
+ # they get noticed by the tag sweeper (which then updates their autocomplete data)
+ belongs_to :common_tag, class_name: 'Tag', touch: true
+ belongs_to :filterable, polymorphic: true, touch: true
+
+ validates_presence_of :common_tag, :filterable, message: "does not exist."
+ validates_uniqueness_of :common_tag_id, scope: :filterable_id
+
+ after_create :update_wrangler
+ after_create :inherit_parents
+ after_create :remove_uncategorized_media
+ after_create :add_to_autocomplete
+
+ before_destroy :remove_from_autocomplete
+
+ after_commit :update_search
+
+ def update_wrangler
+ unless User.current_user.nil?
+ common_tag.update!(last_wrangler: User.current_user)
+ end
+ end
+
+ def add_to_autocomplete
+ return unless filterable.is_a?(Fandom) && common_tag.eligible_for_fandom_autocomplete?
+
+ common_tag.add_to_fandom_autocomplete(filterable)
+ end
+
+ def remove_from_autocomplete
+ return unless filterable.is_a?(Fandom) && common_tag&.was_eligible_for_fandom_autocomplete?
+
+ common_tag.remove_from_fandom_autocomplete(filterable)
+ end
+
+ # A relationship should inherit its characters' fandoms
+ def inherit_parents
+ if common_tag.is_a?(Relationship) && filterable.is_a?(Character)
+ filterable.fandoms.each do |fandom|
+ common_tag.add_association(fandom)
+ end
+ end
+ end
+
+ validate :check_canonical_filterable, :check_compatible_types
+
+ # The parent tag must always be canonical.
+ def check_canonical_filterable
+ return if filterable.nil? || filterable.canonical
+ errors.add(:base, "Parent tag is not canonical.")
+ end
+
+ # The child tag must have a type compatible with the parent tag.
+ def check_compatible_types
+ return if filterable.nil? || common_tag.nil?
+ return if filterable.child_types.include?(common_tag.type)
+
+ errors.add(:base, "A tag of type #{filterable.type} cannot have a child " \
+ "of type #{common_tag.type}.")
+ end
+
+ # Make sure that our fandoms lose the Uncategorized media as soon as they're
+ # assigned to a particular medium.
+ def remove_uncategorized_media
+ return unless common_tag.is_a?(Fandom) && filterable.is_a?(Media)
+ return if common_tag.medias.count < 2
+ common_tag.common_taggings.where(filterable: Media.uncategorized).destroy_all
+ end
+
+ # If a tag's parent changes, reindex immediately to update unwrangled bins.
+ def update_search
+ common_tag&.reindex_document
+ end
+
+ # Go through all CommonTaggings and destroy the invalid ones.
+ def self.destroy_invalid
+ includes(:common_tag, :filterable).find_each do |ct|
+ valid = ct.valid?
+
+ # Let callers do something on each iteration.
+ yield ct, valid if block_given?
+
+ ct.destroy unless valid
+ end
+ end
+end
diff --git a/app/models/concerns/async_with_active_job.rb b/app/models/concerns/async_with_active_job.rb
new file mode 100755
index 0000000..f470343
--- /dev/null
+++ b/app/models/concerns/async_with_active_job.rb
@@ -0,0 +1,17 @@
+# A concern defining the async/async_after_commit functions, which allow
+# instance methods on an object to be used as ActiveJobs.
+module AsyncWithActiveJob
+ extend ActiveSupport::Concern
+
+ included do
+ class_attribute :async_job_class, default: InstanceMethodJob
+ end
+
+ def async(*args, job_class: async_job_class, **kwargs)
+ job_class.perform_later(self, *args, **kwargs)
+ end
+
+ def async_after_commit(*args, job_class: async_job_class, **kwargs)
+ job_class.perform_after_commit(self, *args, **kwargs)
+ end
+end
diff --git a/app/models/concerns/async_with_resque.rb b/app/models/concerns/async_with_resque.rb
new file mode 100755
index 0000000..8549bcd
--- /dev/null
+++ b/app/models/concerns/async_with_resque.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+# A module used to make it easy to call asynchronous methods on ActiveRecord
+# objects (both on the class and on the instance).
+module AsyncWithResque
+ extend ActiveSupport::Concern
+ include AfterCommitEverywhere
+
+ # The instance version of the async function. Uses perform_on_instance so
+ # that we can use the same perform() function for instance methods and class
+ # methods.
+ def async(method, *args)
+ self.class.async(:perform_on_instance, id, method, *args)
+ end
+
+ class_methods do
+ # The class version of the async function. Uses base_class so that we
+ # handle subclasses properly.
+ def async(method, *args)
+ Resque.enqueue(base_class, method, *args)
+ end
+
+ # Actually perform the delayed action.
+ def perform(method, *args)
+ if method.is_a?(Integer)
+ # TODO: For backwards compatibility, if the "method" is an integer, we
+ # treat it like an ID and use perform_on_instance instead. But once all
+ # of the jobs in the queue have been processed (or deleted), we should
+ # be able to remove this check.
+ perform_on_instance(method, *args)
+ else
+ send(method, *args)
+ end
+ end
+
+ # Find the instance with the given ID, and call the method on it instead.
+ # This function allows us to use the same perform() function for both class
+ # functions and instance functions.
+ def perform_on_instance(id, method, *args)
+ find(id).send(method, *args)
+ end
+ end
+
+ # A function that can be used to help with stale data issues. Instead of
+ # immediately adding the desired action to the Resque queue the way that
+ # async() does, it uses AfterCommitEverywhere to call async() after the
+ # commit has gone through.
+ def async_after_commit(*args)
+ after_commit do
+ async(*args)
+ end
+ end
+end
diff --git a/app/models/concerns/creatable.rb b/app/models/concerns/creatable.rb
new file mode 100755
index 0000000..dab9dbb
--- /dev/null
+++ b/app/models/concerns/creatable.rb
@@ -0,0 +1,228 @@
+# frozen_string_literal: true
+
+# A module used for classes that can appear as the "creation" in a Creatorship
+# (i.e. Work, Series, and Chapter).
+module Creatable
+ extend ActiveSupport::Concern
+
+ included do
+ has_many :creatorships,
+ autosave: true,
+ as: :creation,
+ inverse_of: :creation
+
+ has_many :approved_creatorships,
+ -> { Creatorship.approved },
+ class_name: "Creatorship",
+ as: :creation,
+ inverse_of: :creation
+
+ has_many :pseuds,
+ through: :approved_creatorships,
+ before_add: :disallow_pseud_changes,
+ before_remove: :disallow_pseud_changes
+
+ has_many :users,
+ -> { distinct },
+ through: :pseuds
+
+ attr_reader :current_user_pseuds
+
+ validate :check_no_creators
+ validate :check_current_user_pseuds
+ after_save :update_current_user_pseuds
+ after_destroy :destroy_creatorships
+ end
+
+ ########################################
+ # CALLBACKS & VALIDATIONS
+ ########################################
+
+ # Updating pseuds directly goes through the approved_creatorships relation,
+ # so it will automatically approve any pseuds added in this way. So we want
+ # to make sure that this is a read-only relation.
+ def disallow_pseud_changes(*)
+ raise "Cannot add or remove pseuds through the pseuds association!"
+ end
+
+ # Make sure that there will be at least one approved creator after saving:
+ def check_no_creators
+ return if @current_user_pseuds.present? || pseuds_after_saving.any?
+
+ errors.add(:base, ts("%{type} must have at least one creator.",
+ type: model_name.human))
+ end
+
+ # Make sure that if @current_user_pseuds is not nil, then the user has
+ # selected at least one pseud, and that all of the pseuds they've selected
+ # are their own.
+ def check_current_user_pseuds
+ return unless @current_user_pseuds && User.current_user.is_a?(User)
+
+ if @current_user_pseuds.empty?
+ errors.add(:base, ts("You haven't selected any pseuds for this %{type}.",
+ type: model_name.human.downcase))
+ end
+
+ if @current_user_pseuds.any? { |p| p.user_id != User.current_user.id }
+ errors.add(:base, ts("You're not allowed to use that pseud."))
+ end
+ end
+
+ # The variable @current_user_pseuds stores which pseuds the current editor
+ # wants to use on this work. The pseuds should contain only pseuds owned by
+ # User.current_user.
+ def update_current_user_pseuds
+ return unless @current_user_pseuds
+
+ set_current_user_pseuds(@current_user_pseuds)
+ @current_user_pseuds = nil
+ end
+
+ # Clean up all creatorships associated with this item.
+ def destroy_creatorships
+ creatorships.destroy_all
+ end
+
+ ########################################
+ # VIRTUAL ATTRIBUTES
+ ########################################
+
+ # Update all creator-related attributes.
+ def author_attributes=(attributes)
+ self.new_bylines = attributes[:byline] if attributes[:byline].present?
+ self.new_co_creator_ids = attributes[:coauthors] if attributes[:coauthors].present?
+ self.current_user_pseud_ids = attributes[:ids] if attributes[:ids].present?
+ end
+
+ # Invite new co-creators by passing in their byline.
+ def new_bylines=(bylines)
+ bylines.split(",").reject(&:blank?).map(&:strip).each do |byline|
+ self.creatorships.build(byline: byline, enable_notifications: true)
+ end
+ end
+
+ # Invite new co-creators by ID.
+ def new_co_creator_ids=(ids)
+ new_pseuds = Pseud.where(id: ids).to_a
+
+ creatorships.each do |creatorship|
+ if new_pseuds.include?(creatorship.pseud)
+ new_pseuds.delete(creatorship.pseud)
+ end
+ end
+
+ new_pseuds.each do |pseud|
+ self.creatorships.build(pseud: pseud, enable_notifications: true)
+ end
+ end
+
+ # Update which of User.current_user's pseuds should be listed on the byline
+ # after saving.
+ def current_user_pseud_ids=(ids)
+ return unless User.current_user.is_a?(User)
+
+ @current_user_pseuds = Pseud.where(id: ids).to_a
+ end
+
+ # This behaves very similarly to new_bylines=, but because it's designed to
+ # be used for bulk editing works, it doesn't handle ambiguous pseuds well. So
+ # we need to manually refine our guess as much as possible.
+ def pseuds_to_add=(pseud_names)
+ names = pseud_names.split(",").reject(&:blank?).map(&:strip)
+
+ names.each do |name|
+ possible_pseuds = Pseud.parse_byline_ambiguous(name)
+
+ pseud = if possible_pseuds.size > 1
+ Pseud.parse_byline(name)
+ else
+ possible_pseuds.first
+ end
+
+ if pseud
+ creatorship = creatorships.find_or_initialize_by(pseud: pseud)
+ creatorship.enable_notifications = true
+ end
+ end
+ end
+
+ ########################################
+ # USEFUL FUNCTIONS
+ ########################################
+
+ # Update the pseuds on this item so that User.current_user's pseuds are
+ # replaced by the passed-in array of pseuds new_pseuds. If it's a Series, we
+ # also update the user's byline on any owned works in the series. If it's a
+ # Work, we also update the user's byline on any owned chapters in the series.
+ def set_current_user_pseuds(new_pseuds)
+ return unless User.current_user.is_a?(User)
+
+ user_id = User.current_user.id
+
+ children = if is_a?(Work)
+ chapters.to_a
+ elsif is_a?(Series)
+ works.to_a
+ else
+ []
+ end
+
+ transaction do
+ children.each do |child|
+ next unless child.users.include?(User.current_user)
+ child.set_current_user_pseuds(new_pseuds)
+ end
+
+ # Create before destroying, so that we don't run into issues with
+ # deleting the very last creator.
+ new_pseuds.each do |pseud|
+ creatorships.approve_or_create_by(pseud: pseud)
+ end
+
+ creatorships.each do |creatorship|
+ creatorship.destroy unless new_pseuds.include?(creatorship.pseud) ||
+ creatorship.pseud&.user_id != user_id
+ end
+ end
+ end
+
+ # Figure out which creatorships will exist after saving.
+ #
+ # Excludes creatorships with a missing pseud, because those orphaned
+ # creatorships can break various bits of code if they're considered valid.
+ def creatorships_after_saving
+ creatorships.select(&:valid?).reject(&:marked_for_destruction?).
+ reject { |creatorship| creatorship.pseud.nil? }
+ end
+
+ # Calculate what the pseuds on this work will be after saving, taking into
+ # account validity, approval, and @current_user_pseuds.
+ def pseuds_after_saving
+ pseuds = creatorships_after_saving.select(&:approved?).map(&:pseud)
+
+ if @current_user_pseuds
+ pseuds = (pseuds - User.current_user.pseuds) + @current_user_pseuds
+ end
+
+ pseuds.uniq
+ end
+
+ # Check whether the passed-in user has been invited to become a creator.
+ def user_has_creator_invite?(user)
+ return false unless user.is_a?(User)
+ creatorships.unapproved.for_user(user).exists?
+ end
+
+ # Check whether the given user has some kind of creatorship (approved or
+ # unapproved) associated with this item.
+ def user_is_owner_or_invited?(user)
+ return false unless user.is_a?(User)
+ creatorships.for_user(user).exists?
+ end
+
+ # Get all orphan_account pseuds that (co-)created this creatable, excluding the orphan_account's default_pseud
+ def orphan_pseuds
+ self.pseuds.where(user_id: User.orphan_account.id, is_default: false)
+ end
+end
diff --git a/app/models/concerns/filterable.rb b/app/models/concerns/filterable.rb
new file mode 100755
index 0000000..bf7f061
--- /dev/null
+++ b/app/models/concerns/filterable.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+# A module for types that are supposed to have FilterTaggings, calculated based
+# on their tags. Includes Taggable.
+module Filterable
+ extend ActiveSupport::Concern
+ include Taggable
+
+ included do
+ has_many :filter_taggings, as: :filterable, inverse_of: :filterable
+ has_many :filters, through: :filter_taggings
+
+ has_many :direct_filter_taggings,
+ -> { where(inherited: false) },
+ class_name: "FilterTagging",
+ as: :filterable
+ has_many :direct_filters,
+ source: :filter,
+ through: :direct_filter_taggings
+ end
+
+ # Update filters for this particular filterable.
+ def update_filters
+ FilterUpdater.new(self.class.base_class, [id], :main).update
+ end
+
+ class_methods do
+ # Update the filters for all filterables in this relation.
+ def update_filters(async_update: false,
+ reindex_queue: :background,
+ job_queue: :utilities)
+ batch_size = ArchiveConfig.FILTER_UPDATE_BATCH_SIZE
+
+ select(:id).find_in_batches(batch_size: batch_size) do |batch|
+ updater = FilterUpdater.new(base_class, batch.map(&:id), reindex_queue)
+
+ if async_update
+ updater.async_update(job_queue: job_queue)
+ else
+ updater.update
+ end
+
+ # Allow for progress messages in long-running updates.
+ yield if block_given?
+ end
+ end
+
+ # This is the callback that gets called when FilterUpdater is done with a
+ # single batch of Filterables of this type. Designed to reindex all of the
+ # Filterables whose filters changed. It's called in after_commit, to
+ # minimize issues with stale data.
+ #
+ # The _ids argument isn't used here, but is used in some of the subclasses.
+ def reindex_for_filter_changes(_ids, filter_taggings, queue)
+ changed_ids = filter_taggings.map(&:filterable_id)
+ IndexQueue.enqueue_ids(base_class, changed_ids, queue)
+ end
+ end
+
+ ################
+ # SEARCH
+ ################
+
+ # Simple name to make it easier for people to use in full-text search
+ def tag
+ (tags.pluck(:name) + filters.pluck(:name)).uniq
+ end
+
+ # Index all the filters for pulling works
+ def filter_ids
+ (tags.pluck(:id) + filters.pluck(:id)).uniq
+ end
+
+ # Index only direct filters (non meta-tags) for facets
+ def filters_for_facets
+ @filters_for_facets ||= direct_filters.to_a
+ end
+
+ def rating_ids
+ filters_for_facets.select { |t| t.type == "Rating" }.map(&:id)
+ end
+
+ def archive_warning_ids
+ filters_for_facets.select { |t| t.type == "ArchiveWarning" }.map(&:id)
+ end
+
+ def category_ids
+ filters_for_facets.select { |t| t.type == "Category" }.map(&:id)
+ end
+
+ def fandom_ids
+ filters_for_facets.select { |t| t.type == "Fandom" }.map(&:id)
+ end
+
+ def character_ids
+ filters_for_facets.select { |t| t.type == "Character" }.map(&:id)
+ end
+
+ def relationship_ids
+ filters_for_facets.select { |t| t.type == "Relationship" }.map(&:id)
+ end
+
+ def freeform_ids
+ filters_for_facets.select { |t| t.type == "Freeform" }.map(&:id)
+ end
+end
diff --git a/app/models/concerns/globalized.rb b/app/models/concerns/globalized.rb
new file mode 100755
index 0000000..7c41ff1
--- /dev/null
+++ b/app/models/concerns/globalized.rb
@@ -0,0 +1,13 @@
+module Globalized
+ extend ActiveSupport::Concern
+
+ included do
+ validate :check_locale
+ end
+
+ def check_locale
+ return if Locale.exists?(iso: locale)
+
+ globalized_model.errors.add(:base, ts("The locale %{name} does not exist.", name: locale))
+ end
+end
diff --git a/app/models/concerns/justifiable.rb b/app/models/concerns/justifiable.rb
new file mode 100755
index 0000000..bf4a0b3
--- /dev/null
+++ b/app/models/concerns/justifiable.rb
@@ -0,0 +1,70 @@
+module Justifiable
+ extend ActiveSupport::Concern
+
+ included do
+ attr_accessor :ticket_number
+ attr_reader :ticket_url
+
+ before_validation :strip_octothorpe
+ validates :ticket_number,
+ presence: true,
+ # i18n-tasks-use t("activerecord.errors.messages.numeric_with_optional_hash")
+ numericality: { only_integer: true,
+ message: :numeric_with_optional_hash },
+ if: :justification_enabled?
+
+ validate :ticket_number_exists_in_tracker, if: :justification_enabled?
+ end
+
+ private
+
+ def strip_octothorpe
+ return if ticket_number.is_a?(Integer)
+
+ self.ticket_number = self.ticket_number.delete_prefix("#") unless self.ticket_number.nil?
+ end
+
+ def justification_enabled?
+ # Only require a ticket if the record has been changed by an admin.
+ User.current_user.is_a?(Admin) && changed?
+ end
+
+ def ticket_number_exists_in_tracker
+ # Skip ticket lookup if the previous validations fail.
+ return if errors.present?
+
+ if ticket.blank?
+ errors.add(:ticket_number, :required)
+ return
+ end
+
+ # The ticket must not be closed.
+ if ticket["status"].blank? || ticket["status"] == "Closed"
+ errors.add(:ticket_number, :closed_ticket)
+ return
+ end
+
+ # The admin must have the role matching the ticket's department.
+ if admin_can_use_abuse_ticket? || admin_can_use_support_ticket?
+ @ticket_url = ticket["webUrl"]
+ else
+ errors.add(:ticket_number, :invalid_department)
+ end
+ end
+
+ def admin_can_use_abuse_ticket?
+ (User.current_user.roles & %w[policy_and_abuse superadmin]).present? && ticket["departmentId"] == ArchiveConfig.ABUSE_ZOHO_DEPARTMENT_ID
+ end
+
+ def admin_can_use_support_ticket?
+ (User.current_user.roles & %w[superadmin support]).present? && ticket["departmentId"] == ArchiveConfig.SUPPORT_ZOHO_DEPARTMENT_ID
+ end
+
+ def ticket
+ @ticket ||= zoho_resource_client.find_ticket(ticket_number)
+ end
+
+ def zoho_resource_client
+ @zoho_resource_client ||= ZohoResourceClient.new(access_token: ZohoAuthClient.new.access_token)
+ end
+end
diff --git a/app/models/concerns/password_resets_limitable.rb b/app/models/concerns/password_resets_limitable.rb
new file mode 100755
index 0000000..350c753
--- /dev/null
+++ b/app/models/concerns/password_resets_limitable.rb
@@ -0,0 +1,46 @@
+module PasswordResetsLimitable
+ extend ActiveSupport::Concern
+
+ included do
+ def password_resets_remaining
+ return ArchiveConfig.PASSWORD_RESET_LIMIT unless self.last_reset_within_cooldown?
+
+ limit_delta = ArchiveConfig.PASSWORD_RESET_LIMIT - self.resets_requested
+ limit_delta.positive? ? limit_delta : 0
+ end
+
+ def password_resets_limit_reached?
+ password_resets_remaining.zero?
+ end
+
+ def password_resets_available_time
+ self.reset_password_sent_at + ArchiveConfig.PASSWORD_RESET_COOLDOWN_HOURS.hours
+ end
+
+ def update_password_resets_requested
+ if self.resets_requested.positive? && !self.last_reset_within_cooldown?
+ self.resets_requested = 1
+ else
+ self.resets_requested += 1
+ end
+ end
+
+ protected
+
+ # Resets the resets_requested count to the default value -- zero -- when a user successfully _completes_
+ # the reset process. This extends the existing Devise method, which sets `reset_password_sent_at` to `nil`.
+ # If we don't also reset `resets_requested`, we will not know whether the number of resets means further
+ # reset requests should be limited or not.
+ def clear_reset_password_token
+ super
+ self.resets_requested = 0
+ end
+ end
+
+ private
+
+ def last_reset_within_cooldown?
+ self.reset_password_sent_at.present? &&
+ self.reset_password_sent_at > ArchiveConfig.PASSWORD_RESET_COOLDOWN_HOURS.hours.ago
+ end
+end
diff --git a/app/models/concerns/taggable.rb b/app/models/concerns/taggable.rb
new file mode 100755
index 0000000..1538646
--- /dev/null
+++ b/app/models/concerns/taggable.rb
@@ -0,0 +1,163 @@
+module Taggable
+ extend ActiveSupport::Concern
+
+ included do
+ has_many :taggings, as: :taggable, inverse_of: :taggable, dependent: :destroy, autosave: true
+ has_many :tags, through: :taggings, source: :tagger, source_type: "Tag"
+
+ Tag::VISIBLE.each do |type|
+ has_many type.underscore.pluralize.to_sym,
+ -> { where(tags: { type: type }) },
+ through: :taggings,
+ source: :tagger,
+ source_type: "Tag",
+ before_remove: :destroy_tagging
+ end
+ end
+
+ # Used in works and external works:
+ Tag::VISIBLE.each do |type|
+ klass = type.constantize
+ underscore = type.underscore
+
+ define_method("#{underscore}_strings") do
+ tags_after_saving_of_type(klass).map(&:name)
+ end
+
+ define_method("#{underscore}_string") do
+ tags_after_saving_of_type(klass).map(&:name).join(ArchiveConfig.DELIMITER_FOR_OUTPUT)
+ end
+
+ define_method("#{underscore}_string=") do |tag_string|
+ tags = parse_tags_of_type(tag_string, klass)
+ assign_tags_of_type(tags, klass)
+ end
+
+ alias_method "#{underscore}_strings=", "#{underscore}_string="
+ end
+
+ # Used in bookmarks and collections:
+ def tag_strings
+ tags_after_saving.map(&:name)
+ end
+
+ def tag_string
+ tags_after_saving.map(&:name).join(ArchiveConfig.DELIMITER_FOR_OUTPUT)
+ end
+
+ def tag_string=(tag_string)
+ tags = parse_tags(tag_string)
+ assign_tags_of_type(tags, Tag)
+ end
+
+ alias tag_strings= tag_string=
+
+ def tag_groups
+ result = tags_after_saving.group_by(&:type)
+
+ result["Fandom"] ||= []
+ result["Rating"] ||= []
+ result["ArchiveWarning"] ||= []
+ result["Relationship"] ||= []
+ result["Character"] ||= []
+ result["Freeform"] ||= []
+
+ result
+ end
+
+ # a work can only have one rating, so using first will work
+ # should always have a rating, if it doesn't err conservatively
+ def adult?
+ self.ratings.blank? || self.ratings.first.adult?
+ end
+
+ # Returns the list of tags that this object will have after it's saved. This
+ # takes into account taggings that are built (but not saved), and taggings
+ # that have been marked for destruction.
+ def tags_after_saving
+ if taggings.target.empty?
+ # The taggings aren't loaded, and no new taggings have been created, so
+ # we can just resort to the base tags list for efficiency:
+ tags.to_a
+ else
+ taggings.reject(&:marked_for_destruction?).map(&:tagger).compact
+ end
+ end
+
+ # Returns the number of tags of type Fandom, Character, Relationship, or
+ # Freeform.
+ def user_defined_tags_count
+ tags_after_saving.count do |tag|
+ Tag::USER_DEFINED.include?(tag.type)
+ end
+ end
+
+ private
+
+ # Take the list of tags after saving, and filter by type:
+ def tags_after_saving_of_type(klass)
+ tags_after_saving.select { |tag| tag.is_a?(klass) }
+ end
+
+ # Split the tag string at each comma, and remove excess whitespace:
+ def split_tag_string(tag_string)
+ tag_array = if tag_string.is_a?(Array)
+ tag_string
+ else
+ tag_string.gsub(/\uff0c|\u3001/, ",").split(",")
+ end
+
+ tag_array.map(&:squish).reject(&:blank?)
+ end
+
+ # Split the tag_string, and find/create an array of tags of the given type.
+ def parse_tags_of_type(tag_string, klass)
+ tag_names = split_tag_string(tag_string)
+
+ tag_names.map do |tag_name|
+ klass.find_or_create_by_name(tag_name)
+ end.uniq
+ end
+
+ # Split the tag_string, and look up the tags for each of those tag names. We
+ # ignore tag type for this one, and create UnsortedTags for any tag names
+ # that we can't find.
+ #
+ # Used in bookmarks and collections.
+ def parse_tags(tag_string)
+ tag_names = split_tag_string(tag_string)
+
+ tag_names.map do |tag_name|
+ Tag.find_by_name(tag_name) || UnsortedTag.create(name: tag_name)
+ end.uniq
+ end
+
+ # Mark taggings for destruction, and create new taggings, so that we will end
+ # up with the specified set of tags after saving.
+ #
+ # Only deletes/checks tags of the given class.
+ def assign_tags_of_type(tags, klass)
+ missing = Set.new(tags)
+
+ taggings.each do |tagging|
+ tag = tagging.tagger
+
+ next unless tag.is_a?(klass)
+
+ if missing.include?(tag)
+ missing.delete(tag)
+ tagging.reload if tagging.marked_for_destruction?
+ else
+ tagging.mark_for_destruction
+ end
+ end
+
+ missing.each do |tag|
+ taggings.build(tagger: tag)
+ end
+ end
+
+ def destroy_tagging(tag)
+ taggings.find_by(tagger: tag)&.destroy
+ end
+end
diff --git a/app/models/concerns/user_loggable.rb b/app/models/concerns/user_loggable.rb
new file mode 100755
index 0000000..6cc6042
--- /dev/null
+++ b/app/models/concerns/user_loggable.rb
@@ -0,0 +1,61 @@
+module UserLoggable
+ extend ActiveSupport::Concern
+
+ included do
+ before_destroy :log_removal_of_self_from_fnok_relationships
+ end
+
+ def log_removal_of_self_from_fnok_relationships
+ fannish_next_of_kins.each do |fnok|
+ fnok.user.log_removal_of_next_of_kin(self)
+ end
+
+ successor = fannish_next_of_kin&.kin
+ log_removal_of_next_of_kin(successor)
+ end
+
+ def log_assignment_of_next_of_kin(kin, admin:)
+ log_user_history(
+ ArchiveConfig.ACTION_ADD_FNOK,
+ options: { fnok_user_id: kin.id },
+ admin: admin
+ )
+
+ kin.log_user_history(
+ ArchiveConfig.ACTION_ADDED_AS_FNOK,
+ options: { fnok_user_id: self.id },
+ admin: admin
+ )
+ end
+
+ def log_removal_of_next_of_kin(kin, admin: nil)
+ return if kin.blank?
+
+ log_user_history(
+ ArchiveConfig.ACTION_REMOVE_FNOK,
+ options: { fnok_user_id: kin.id },
+ admin: admin
+ )
+
+ kin.log_user_history(
+ ArchiveConfig.ACTION_REMOVED_AS_FNOK,
+ options: { fnok_user_id: self.id },
+ admin: admin
+ )
+ end
+
+ def log_user_history(action, options: {}, admin: nil)
+ if admin.present?
+ options = {
+ admin_id: admin.id,
+ note: "Change made by #{admin.login}",
+ **options
+ }
+ end
+
+ create_log_item({
+ action: action,
+ **options
+ })
+ end
+end
diff --git a/app/models/concerns/wrangleable.rb b/app/models/concerns/wrangleable.rb
new file mode 100755
index 0000000..15ea409
--- /dev/null
+++ b/app/models/concerns/wrangleable.rb
@@ -0,0 +1,19 @@
+module Wrangleable
+ extend ActiveSupport::Concern
+
+ included do
+ after_save :update_last_wrangling_activity, if: :update_wrangling_activity?
+ after_destroy :update_last_wrangling_activity, if: :update_wrangling_activity?
+ end
+
+ private
+
+ def update_last_wrangling_activity
+ current_user = User.current_user
+ current_user.update_last_wrangling_activity if current_user.respond_to?(:update_last_wrangling_activity)
+ end
+
+ def update_wrangling_activity?
+ User.should_update_wrangling_activity
+ end
+end
diff --git a/app/models/creatorship.rb b/app/models/creatorship.rb
new file mode 100755
index 0000000..4870a4d
--- /dev/null
+++ b/app/models/creatorship.rb
@@ -0,0 +1,292 @@
+class Creatorship < ApplicationRecord
+ belongs_to :pseud
+ belongs_to :creation, polymorphic: true, touch: true
+
+ scope :approved, -> { where(approved: true) }
+ scope :unapproved, -> { where(approved: false) }
+
+ scope :for_user, ->(user) { joins(:pseud).merge(user.pseuds) }
+
+ ########################################
+ # VALIDATIONS
+ ########################################
+
+ before_validation :update_approved, on: :create
+
+ validates_presence_of :creation
+ validates_uniqueness_of :pseud, scope: [:creation_type, :creation_id], on: :create
+
+ validate :check_invalid, on: :create
+ validate :check_banned, on: :create
+ validate :check_disallowed, on: :create
+ validate :check_approved_becoming_false, on: :update
+
+ # Update approval status if this creatorship should be automatically approved.
+ def update_approved
+ if !approved? && should_automatically_approve?
+ self.approved = true
+ end
+ end
+
+ # Make sure that the pseud exists, and isn't ambiguous.
+ #
+ # Note that thanks to the definitions of missing? and ambiguous?, this is
+ # equivalent to having a validates_presence_of :pseud check, just with
+ # different error messages.
+ def check_invalid
+ if missing?
+ errors.add(:base, ts("Could not find a pseud %{name}.", name: byline))
+ elsif ambiguous?
+ errors.add(:base, ts("The pseud %{name} is ambiguous.", name: byline))
+ end
+ end
+
+ # Make sure that the user isn't banned or suspended.
+ def check_banned
+ return unless pseud&.user&.banned || pseud&.user&.suspended
+
+ errors.add(:base, ts("%{name} cannot be listed as a co-creator.",
+ name: pseud.byline))
+ throw :abort
+ end
+
+ # Make sure that if this is an invitation, we're not inviting someone who has
+ # disabled invitations.
+ def check_disallowed
+ return if approved? || pseud.nil?
+ return if pseud&.user&.preference&.allow_cocreator
+ errors.add(:base, ts("%{name} does not allow others to invite them to be a co-creator.",
+ name: pseud.byline))
+ end
+
+ # Make sure that we're not trying to set approved to false, since that could
+ # potentially violate some rules about co-creators. (e.g. Having a user
+ # listed as a chapter co-creator, but not a work co-creator.)
+ def check_approved_becoming_false
+ if !approved? && approved_changed?
+ errors.add(:base, "Once approved, a creatorship cannot become unapproved.")
+ end
+ end
+
+ ########################################
+ # CALLBACKS
+ ########################################
+
+ after_create :add_to_parents
+ after_update :add_to_parents, if: :saved_change_to_approved?
+
+ before_destroy :expire_caches
+ before_destroy :check_not_last
+ before_destroy :save_original_creator
+ after_destroy :remove_from_children
+
+ after_commit :update_indices
+
+ after_create_commit :notify_creator, if: :enable_notifications
+
+ # If a pseud is listed as a work co-creator (not invited, actually listed),
+ # they should also be listed on all of the work's series. Similarly, if a
+ # pseud is listed as a chapter co-creator, they should also be listed on the
+ # work.
+ def add_to_parents
+ return unless approved?
+
+ parents = if creation.is_a?(Work)
+ creation.series.to_a
+ elsif creation.is_a?(Chapter)
+ [creation.work]
+ else
+ []
+ end
+
+ parents.each do |parent|
+ parent.creatorships.approve_or_create_by(pseud: pseud)
+ end
+ end
+
+ # In order to make sure that all chapter co-creators are listed on the work,
+ # and all work co-creators are listed on the work's series, we need to make
+ # sure that when a creatorship is deleted, the deletion cascades downwards.
+ def remove_from_children
+ # If the creation is being deleted and it's a work, then its chapters are
+ # also going to be deleted (which will cause their creatorships to be
+ # deleted as well). If the creation is being deleted and it's a series,
+ # then we shouldn't delete the work creatorships. So if the creation is
+ # being deleted, we don't want to cascade the deletion downwards.
+ return if creation.nil? || creation.destroyed?
+
+ children = if creation.is_a?(Work)
+ creation.chapters.to_a
+ elsif creation.is_a?(Series)
+ creation.works.to_a
+ else
+ []
+ end
+
+ children.each do |child|
+ child.creatorships.where(pseud: pseud).destroy_all
+ end
+ end
+
+ # Make sure that both the creation and the pseud are enqueued to be
+ # reindexed.
+ def update_indices
+ if creation.is_a?(Searchable)
+ creation.enqueue_to_index
+ end
+
+ if pseud && creation.is_a?(Work)
+ IndexQueue.enqueue(pseud, :background)
+ end
+ end
+
+ # Only enable notifications for new creatorships when explicitly enabled.
+ attr_accessor :enable_notifications
+
+ # Notify the pseud of their new creatorship.
+ def notify_creator
+ return unless User.current_user.is_a?(User) &&
+ pseud.user != User.current_user &&
+ pseud.user != User.orphan_account
+
+ I18n.with_locale(pseud.user.preference.locale_for_mails) do
+ if approved?
+ if User.current_user.try(:is_archivist?)
+ UserMailer.creatorship_notification_archivist(id, User.current_user.id).deliver_later
+ else
+ UserMailer.creatorship_notification(id, User.current_user.id).deliver_later
+ end
+ else
+ UserMailer.creatorship_request(id, User.current_user.id).deliver_later
+ end
+ end
+ end
+
+ # When deleting a creatorship, we want to make sure we're not deleting the
+ # very last creatorship for that item.
+ def check_not_last
+ # We can always delete unapproved creatorships:
+ return unless approved?
+
+ # Check that the creation hasn't been deleted, and still has creatorships
+ # left:
+ return if creation.nil? || creation.destroyed? ||
+ creation.creatorships.approved.count > 1
+
+ errors.add(:base, ts("Sorry, we can't remove all creators of a %{type}.",
+ type: creation.model_name.human.downcase))
+ raise ActiveRecord::RecordInvalid, self
+ end
+
+ # Record the original creator if the creation is a work.
+ # This information is stored temporarily to make it available for
+ # Policy and Abuse on orphaned works.
+ def save_original_creator
+ return unless approved?
+ return unless creation.is_a?(Work)
+ return if creation.destroyed?
+
+ creation.original_creators.create_or_find_by(user: pseud.user).touch
+ end
+
+ def expire_caches
+ if creation_type == "Work" && self.pseud.present?
+ CacheMaster.record(creation_id, "pseud", self.pseud_id)
+ CacheMaster.record(creation_id, "user", self.pseud.user_id)
+ end
+ end
+
+ ########################################
+ # OTHER METHODS
+ ########################################
+
+ attr_reader :ambiguous_pseuds
+
+ # We define a virtual "byline" attribute to make it easier to handle
+ # ambiguous/missing pseuds. By storing the desired name in the @byline
+ # variable, we can generate nicely formatted messages.
+ def byline=(byline)
+ pseuds = Pseud.parse_byline_ambiguous(byline).to_a
+
+ if pseuds.size == 1
+ self.pseud = pseuds.first
+ @byline = nil
+ @ambiguous_pseuds = nil
+ else
+ self.pseud = nil
+ @byline = byline
+ @ambiguous_pseuds = pseuds
+ end
+ end
+
+ # Retrieve the @byline variable, or, failing that, the pseud's byline.
+ def byline
+ @byline || pseud&.byline
+ end
+
+ # A creatorship counts as "missing" if we couldn't find any pseuds matching
+ # the passed-in byline.
+ def missing?
+ pseud.nil? && @ambiguous_pseuds.blank?
+ end
+
+ # A creatorship counts as "ambiguous" if there was more than one pseud
+ # matching the passed-in byline.
+ def ambiguous?
+ pseud.nil? && @ambiguous_pseuds.present?
+ end
+
+ # Find or initialize a creatorship matching the options, and then set
+ # approved to true and save the results. This is a way of adding a new
+ # approved creatorship without potentially running into issues with a
+ # pre-existing unapproved creatorship.
+ def self.approve_or_create_by(options)
+ creatorship = find_or_initialize_by(options)
+ creatorship.approved = true
+ creatorship.save if creatorship.changed?
+ end
+
+ # Change authorship of works or series from a particular pseud to the orphan account
+ def self.orphan(pseuds, orphans, default=true)
+ for pseud in pseuds
+ for new_orphan in orphans
+ unless pseud.blank? || new_orphan.blank? || !new_orphan.pseuds.include?(pseud)
+ orphan_pseud = default ? User.orphan_account.default_pseud : User.orphan_account.pseuds.find_or_create_by(name: pseud.name)
+ pseud.change_ownership(new_orphan, orphan_pseud)
+ end
+ end
+ end
+ end
+
+ # Calculate whether this creatorship should count as approved, or whether
+ # it's just a creatorship invitation.
+ def should_automatically_approve?
+ # Approve if we're using an API key, or if the current user has special
+ # permissions:
+ return true if User.current_user.nil? ||
+ pseud&.user == User.current_user ||
+ pseud&.user == User.orphan_account ||
+ User.current_user.try(:is_archivist?)
+
+ # Approve if the creation is a chapter and the pseud is already listed on
+ # the work, or if the creation is a series and the pseud is already listed
+ # on one of the works:
+ (creation.is_a?(Chapter) && creation.work.pseuds.include?(pseud)) ||
+ (creation.is_a?(Series) && creation.work_pseuds.include?(pseud))
+ end
+
+ # Accept the creatorship invitation. This consists of setting approved to
+ # true, and, if the creation is a work, adding the pseud to all of its
+ # chapters as well.
+ def accept!
+ transaction do
+ update(approved: true)
+
+ if creation.is_a?(Work)
+ creation.chapters.each do |chapter|
+ chapter.creatorships.approve_or_create_by(pseud: pseud)
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/download.rb b/app/models/download.rb
new file mode 100755
index 0000000..e05af50
--- /dev/null
+++ b/app/models/download.rb
@@ -0,0 +1,140 @@
+class Download
+ attr_reader :work, :file_type, :mime_type
+
+ def initialize(work, options = {})
+ @work = work
+ @file_type = set_file_type(options.slice(:mime_type, :format))
+ @mime_type = Marcel::MimeType.for(extension: @file_type).to_s
+ @include_draft_chapters = options[:include_draft_chapters]
+ end
+
+ def generate
+ DownloadWriter.new(self).write
+ self
+ end
+
+ def exists?
+ File.exist?(file_path)
+ end
+
+ # Removes not just the file but the whole directory
+ # Should change if our approach to downloads ever changes
+ def remove
+ FileUtils.rm_rf(dir)
+ end
+
+ # Given either a file extension or a mime type, figure out
+ # what format we're generating
+ # Defaults to html
+ def set_file_type(options)
+ if options[:mime_type]
+ file_type_from_mime(options[:mime_type])
+ elsif ArchiveConfig.DOWNLOAD_FORMATS.include?(options[:format].to_s)
+ options[:format].to_s
+ else
+ "html"
+ end
+ end
+
+ # Given a mime type, return a file extension
+ def file_type_from_mime(mime)
+ subtype = Marcel::Magic.new(mime.to_s).subtype
+ case subtype
+ when "x-mobipocket-ebook"
+ "mobi"
+ when "x-mobi8-ebook"
+ "azw3"
+ else
+ subtype
+ end
+ end
+
+
+ # The base name of the file (e.g., "War_and_Peace")
+ def file_name
+ name = clean(work.title)
+ # If the file name is 1-2 characters, append "_Work_#{work.id}".
+ # If the file name is blank, name the file "Work_#{work.id}".
+ name = [name, "Work_#{work.id}"].compact_blank.join("_") if name.length < 3
+ name.strip
+ end
+
+ # The public route to this download
+ def public_path
+ "/downloads/#{work.id}/#{file_name}.#{file_type}"
+ end
+
+ # The path to the zip file (eg, "/tmp/42_epub_20190301-24600-17164a8/42.zip")
+ def zip_path
+ "#{dir}/#{work.id}.zip"
+ end
+
+ # The path to the folder where web2disk downloads the xhtml and images
+ def assets_path
+ "#{dir}/assets"
+ end
+
+ # The full path to the HTML file (eg, "/tmp/42_epub_20190301-24600-17164a8/The Hobbit.html")
+ def html_file_path
+ "#{dir}/#{file_name}.html"
+ end
+
+ # The full path to the file (eg, "/tmp/42_epub_20190301-24600-17164a8/The Hobbit.epub")
+ def file_path
+ "#{dir}/#{file_name}.#{file_type}"
+ end
+
+ # Get the temporary directory where downloads will be generated,
+ # creating the directory if it doesn't exist.
+ def dir
+ return @tmpdir if @tmpdir
+ @tmpdir = Dir.mktmpdir("#{work.id}_#{file_type}_")
+ @tmpdir
+ end
+
+ def page_title
+ fandom = if work.fandoms.size > 3
+ "Multifandom"
+ elsif work.fandoms.empty?
+ "No fandom specified"
+ else
+ work.fandom_string
+ end
+ [work.title, authors, fandom].join(" - ")
+ end
+
+ def authors
+ author_names.join(", ")
+ end
+
+ def author_names
+ work.anonymous? ? ["Anonymous"] : work.pseuds.sort.map(&:byline)
+ end
+
+ def chapters
+ if @include_draft_chapters
+ work.chapters.order("position ASC")
+ else
+ work.chapters.order("position ASC").where(posted: true)
+ end
+ end
+
+ private
+
+ # make filesystem-safe
+ # ascii encoding
+ # squash spaces
+ # strip all non-alphanumeric
+ # truncate to 24 chars at a word boundary
+ # replace whitespace with underscore for bug with epub table of contents on Kindle (AO3-6625)
+ def clean(string)
+ # get rid of any HTML entities to avoid things like "amp" showing up in titles
+ string = string.gsub(/\&(\w+)\;/, '')
+ string = string.to_ascii
+ string = string.gsub(/[^[\w _-]]+/, '')
+ string = string.gsub(/ +/, " ")
+ string = string.strip
+ string = string.truncate(24, separator: ' ', omission: '')
+ string.gsub(/\s/, "_")
+ end
+end
diff --git a/app/models/download_writer.rb b/app/models/download_writer.rb
new file mode 100755
index 0000000..7dffe7c
--- /dev/null
+++ b/app/models/download_writer.rb
@@ -0,0 +1,176 @@
+require "open3"
+
+class DownloadWriter
+ attr_reader :download, :work
+
+ def initialize(download)
+ @download = download
+ @work = download.work
+ end
+
+ def write
+ generate_html_download
+ generate_ebook_download unless download.file_type == "html"
+ download
+ end
+
+ def generate_html
+ renderer = ApplicationController.renderer.new(
+ http_host: ArchiveConfig.APP_HOST
+ )
+ renderer.render(
+ template: "downloads/show",
+ layout: "barebones",
+ assigns: {
+ work: work,
+ page_title: download.page_title,
+ chapters: download.chapters
+ }
+ )
+ end
+
+ private
+
+ # Write the HTML version to file
+ def generate_html_download
+ return if download.exists?
+
+ File.open(download.html_file_path, "w:UTF-8") { |f| f.write(generate_html) }
+ end
+
+ # transform HTML version into ebook version
+ def generate_ebook_download
+ return unless %w[azw3 epub mobi pdf].include?(download.file_type)
+ return if download.exists?
+
+ cmds = get_commands
+
+ # Make sure the command is sanitary, and use popen3 in order to
+ # capture and discard the stdin/out info
+ # See http://stackoverflow.com/a/5970819/469544 for details
+ cmds.each do |cmd|
+ exit_status = nil
+ Open3.popen3(*cmd) { |_stdin, _stdout, _stderr, wait_thread| exit_status = wait_thread.value }
+ unless exit_status
+ Rails.logger.warn "Download generation failed: " + cmd.to_s
+ end
+ end
+ end
+
+ # Get the version of the command we need to execute
+ def get_commands
+ [get_web2disk_command, get_zip_command, get_calibre_command]
+ end
+
+ # Create the format-specific command-line call to calibre/ebook-convert
+ def get_calibre_command
+ # Add info about first series if any
+ series = []
+ if meta[:series_title].present?
+ series = ["--series", meta[:series_title],
+ "--series-index", meta[:series_position]]
+ end
+
+ ### Format-specific options
+ # epub: don't generate a cover image
+ epub = download.file_type == "epub" ? ["--no-default-epub-cover"] : []
+
+ pdf = []
+ if download.file_type == "pdf"
+ pdf = [
+ # pdf: decrease margins from 72pt default
+ "--pdf-page-margin-top", "36",
+ "--pdf-page-margin-right", "36",
+ "--pdf-page-margin-bottom", "36",
+ "--pdf-page-margin-left", "36",
+ "--pdf-default-font-size", "17",
+ # pdf: only include necessary characters when embedding fonts
+ "--subset-embedded-fonts"
+ ]
+ end
+
+ ### CSS options
+ # azw3, epub, and mobi get a special stylesheet
+ css = []
+ if %w[azw3 epub mobi].include?(download.file_type)
+ css = ["--extra-css",
+ Rails.public_path.join("stylesheets/ebooks.css").to_s]
+ end
+
+ [
+ "ebook-convert",
+ download.zip_path,
+ download.file_path,
+ "--input-encoding", "utf-8",
+ # Prevent it from turning links to endnotes into entries for the table of
+ # contents on works with fewer than the specified number of chapters.
+ "--toc-threshold", "0",
+ "--use-auto-toc",
+ "--title", meta[:title],
+ "--title-sort", meta[:sortable_title],
+ "--authors", meta[:authors],
+ "--author-sort", meta[:sortable_authors],
+ "--comments", meta[:summary],
+ "--tags", meta[:tags],
+ "--pubdate", meta[:pubdate],
+ "--publisher", ArchiveConfig.APP_NAME,
+ "--language", meta[:language],
+ # XPaths for detecting chapters are overly specific to make sure we don't grab
+ # anything inputted by the user. First path is for single-chapter works,
+ # second for multi-chapter, and third for the preface and afterword
+ "--chapter", "//h:body/h:div[@id='chapters']/h:h2[@class='toc-heading'] | //h:body/h:div[@id='chapters']/h:div[@class='meta group']/h:h2[@class='heading'] | //h:body/h:div[@id='preface' or @id='afterword']/h:h2[@class='toc-heading']"
+ ] + series + css + epub + pdf
+ end
+
+ # Grab the HTML file and any images and put them in --base-dir.
+ # --max-recursions 0 prevents it from grabbing all the linked pages.
+ # --dont-download-stylesheets isn't strictly necessary for us but avoids
+ # creating an empty stylesheets directory.
+ def get_web2disk_command
+ [
+ "web2disk",
+ "--base-dir", download.assets_path,
+ "--max-recursions", "0",
+ "--dont-download-stylesheets",
+ "file://#{download.html_file_path}"
+ ]
+ end
+
+ # Zip the directory containing the HTML file and images.
+ def get_zip_command
+ [
+ "zip",
+ "-r",
+ download.zip_path,
+ download.assets_path
+ ]
+ end
+
+ # A hash of the work data calibre needs
+ def meta
+ return @metadata if @metadata
+ @metadata = {
+ title: work.title,
+ sortable_title: work.sorted_title,
+ # Using ampersands as instructed by Calibre's ebook-convert documentation
+ # hides all but the first author name in Books (formerly iBooks). The
+ # other authors cannot be used for searching or sorting. Using commas
+ # just means Calibre's GUI treats it as one name, e.g. "testy, testy2" is
+ # like "Fangirl, Suzy Q", for searching and sorting.
+ authors: download.authors,
+ sortable_authors: work.authors_to_sort_on,
+ # We add "Fanworks" because Books uses the first tag as the category and
+ # it would otherwise be the work's rating, which is weird.
+ tags: "Fanworks, " + work.tags.pluck(:name).join(", "),
+ pubdate: work.revised_at.to_date.to_s,
+ summary: work.summary.to_s,
+ language: work.language.short
+ }
+ if work.series.exists?
+ series = work.series.first
+ @metadata[:series_title] = series.title
+ @metadata[:series_position] = series.position_of(work).to_s
+ end
+ @metadata
+ end
+end
diff --git a/app/models/external_author.rb b/app/models/external_author.rb
new file mode 100755
index 0000000..ce9d524
--- /dev/null
+++ b/app/models/external_author.rb
@@ -0,0 +1,178 @@
+class ExternalAuthor < ApplicationRecord
+ # send :include, Activation # eventually we will let users create new identities
+
+ EMAIL_LENGTH_MIN = 3
+ EMAIL_LENGTH_MAX = 300
+
+ belongs_to :user
+
+ has_many :external_author_names, dependent: :destroy
+ accepts_nested_attributes_for :external_author_names, allow_destroy: true
+ validates_associated :external_author_names
+
+ has_many :external_creatorships, through: :external_author_names
+ has_many :works, -> { distinct }, through: :external_creatorships, source: :creation, source_type: 'Work'
+
+ has_one :invitation
+
+ validates :email, uniqueness: {
+ allow_blank: true,
+ message: ts('There is already an external author with that email.')
+ }
+
+ validates :email, email_format: true
+
+ def self.claimed
+ where(is_claimed: true)
+ end
+
+ def self.unclaimed
+ where(is_claimed: false)
+ end
+
+ after_create :create_default_name
+
+ def external_work_creatorships
+ external_creatorships.where("external_creatorships.creation_type = 'Work'")
+ end
+
+ def create_default_name
+ @default_name = self.external_author_names.build
+ @default_name.name = self.email.to_s
+ self.save
+ end
+
+ def default_name
+ self.external_author_names.select {|external_name| external_name.name == self.email.to_s }.first
+ end
+
+ def names
+ self.external_author_names
+ end
+
+ def claimed?
+ is_claimed
+ end
+
+ def claim!(claiming_user)
+ raise "There is no user claiming this external author." unless claiming_user
+ raise "This external author is already claimed by another user" if claimed? && self.user != claiming_user
+
+ claimed_works = []
+ external_author_names.each do |external_author_name|
+ external_author_name.external_work_creatorships.each do |external_creatorship|
+ work = external_creatorship.creation
+ other_external_creators = work.external_creatorships - [external_creatorship]
+ other_unclaimed_creators = other_external_creators.reject(&:claimed?)
+
+ # if previously claimed by this user, don't do it again
+ unless work.users.include?(claiming_user)
+ # Get the pseud to associate with the work
+ pseud_to_add = claiming_user.pseuds.select {|pseud| pseud.name == external_author_name.name}.first || claiming_user.default_pseud
+
+ # If there are no other unclaimed authors, or if none of the other unclaimed authors have the same archivist,
+ # remove this user's archivist from the work creators, else just add the claiming user
+ claiming_user_archivist = external_creatorship.archivist
+ if other_unclaimed_creators.map(&:archivist).exclude?(claiming_user_archivist)
+ change_ownership(work, claiming_user_archivist, claiming_user, pseud_to_add)
+ else
+ add_creator(work, claiming_user, pseud_to_add)
+ end
+ claimed_works << work.id
+ end
+ end
+ end
+
+ self.user = claiming_user
+ self.is_claimed = true
+ save
+ notify_user_of_claim(claimed_works)
+ end
+
+ def unclaim!
+ return false unless self.is_claimed
+
+ self.external_work_creatorships.each do |external_creatorship|
+ # remove user, add archivist back
+ archivist = external_creatorship.archivist
+ work = external_creatorship.creation
+ change_ownership(work, user, archivist)
+ end
+
+ self.user = nil
+ self.is_claimed = false
+ save
+ end
+
+ def orphan(remove_pseud)
+ external_author_names.each do |external_author_name|
+ external_author_name.external_work_creatorships.each do |external_creatorship|
+ # remove archivist as owner, convert to the pseud
+ archivist = external_creatorship.archivist
+ work = external_creatorship.creation
+ archivist_pseud = work.pseuds.select {|pseud| archivist.pseuds.include?(pseud)}.first
+ orphan_pseud = remove_pseud ? User.orphan_account.default_pseud : User.orphan_account.pseuds.find_or_create_by(name: external_author_name.name)
+ change_ownership(work, archivist, User.orphan_account, orphan_pseud)
+ end
+ end
+ end
+
+ def delete_works
+ self.external_work_creatorships.each do |external_creatorship|
+ work = external_creatorship.creation
+ work.destroy
+ end
+ end
+
+ def block_import
+ self.do_not_import = true
+ save
+ end
+
+ def notify_user_of_claim(claimed_work_ids)
+ # send announcement to user of the stories they have been given
+ I18n.with_locale(self.user.preference.locale_for_mails) do
+ UserMailer.claim_notification(self.user_id, claimed_work_ids).deliver_later
+ end
+ end
+
+ def find_or_invite(archivist = nil)
+ if self.email
+ matching_user = User.find_by(email: self.email) || User.find_by_id(self.user_id)
+ if matching_user
+ self.claim!(matching_user)
+ else
+ # invite person at the email address unless they don't want invites
+ unless self.do_not_email
+ @invitation = Invitation.new(invitee_email: self.email, external_author: self, creator: User.current_user)
+ @invitation.save
+ end
+ end
+ end
+ end
+
+ private
+
+ # Add a new creator to the work:
+ def add_creator(work, creator_to_add, new_pseud = nil)
+ new_pseud = creator_to_add.default_pseud if new_pseud.nil?
+
+ work.transaction do
+ work.creatorships.find_or_create_by(pseud: new_pseud)
+
+ work.chapters.each do |chapter|
+ chapter.creatorships.find_or_create_by(pseud: new_pseud)
+ end
+ end
+ end
+
+ # Transfer ownership of the work from one user to another
+ def change_ownership(work, old_user, new_user, new_pseud = nil)
+ raise "No new user provided, cannot change ownership" unless new_user
+
+ work.transaction do
+ add_creator(work, new_user, new_pseud)
+ work.remove_author(old_user)
+ end
+ end
+end
diff --git a/app/models/external_author_name.rb b/app/models/external_author_name.rb
new file mode 100755
index 0000000..b4b4b6b
--- /dev/null
+++ b/app/models/external_author_name.rb
@@ -0,0 +1,34 @@
+class ExternalAuthorName < ApplicationRecord
+ NAME_LENGTH_MIN = 1
+ NAME_LENGTH_MAX = 100
+
+ belongs_to :external_author, inverse_of: :external_author_names
+ has_many :external_creatorships, inverse_of: :external_author_name
+ has_many :works, -> { uniq }, through: :external_creatorships, source: :creation, source_type: 'Work'
+
+ validates_presence_of :name
+
+ validates_length_of :name,
+ within: NAME_LENGTH_MIN..NAME_LENGTH_MAX,
+ too_short: ts("is too short (minimum is %{min} characters)", min: NAME_LENGTH_MIN),
+ too_long: ts("is too long (maximum is %{max} characters)", max: NAME_LENGTH_MAX)
+
+ validates :name, uniqueness: { scope: :external_author_id }
+
+ validates_format_of :name,
+ message: ts('can contain letters, numbers, spaces, underscores, @-signs, dots, and dashes.'),
+ with: /\A\w[ \w\-\@\.]*\Z/
+
+ validates_format_of :name,
+ message: ts('must contain at least one letter or number.'),
+ with: /[a-zA-Z0-9]/
+
+ def to_s
+ self.name + ' <' + self.external_author.email + '>'
+ end
+
+ def external_work_creatorships
+ external_creatorships.where("external_creatorships.creation_type = 'Work'")
+ end
+
+end
diff --git a/app/models/external_creatorship.rb b/app/models/external_creatorship.rb
new file mode 100755
index 0000000..81fa402
--- /dev/null
+++ b/app/models/external_creatorship.rb
@@ -0,0 +1,26 @@
+class ExternalCreatorship < ApplicationRecord
+ belongs_to :external_author_name, inverse_of: :external_creatorships
+ belongs_to :archivist, class_name: 'User', foreign_key: 'archivist_id'
+ belongs_to :creation, polymorphic: true , inverse_of: :external_creatorships
+
+ def external_author=(external_author)
+ self.external_author_name = external_author.try(:default_name)
+ end
+
+ def external_author
+ self.external_author_name.try(:external_author)
+ end
+
+ def claimed?
+ self.external_author_name.try(:external_author).try(:claimed?)
+ end
+
+ def to_s
+ ts("%{title} by %{name}", title: self.creation.title, name: self.external_author_name)
+ end
+
+ def author_name
+ self.external_author_name.try(:name) || ""
+ end
+
+end
diff --git a/app/models/external_work.rb b/app/models/external_work.rb
new file mode 100755
index 0000000..9d7a915
--- /dev/null
+++ b/app/models/external_work.rb
@@ -0,0 +1,126 @@
+class ExternalWork < ApplicationRecord
+ include Bookmarkable
+ include Filterable
+ include Searchable
+
+ has_many :related_works, as: :parent
+
+ belongs_to :language
+
+ # .duplicate.count.size returns the number of URLs with multiple external works
+ scope :duplicate, -> { group(:url).having("count(DISTINCT id) > 1") }
+
+ scope :for_blurb, -> { includes(:language, :tags) }
+
+ AUTHOR_LENGTH_MAX = 500
+
+ validates_presence_of :title
+ validates_length_of :title, minimum: ArchiveConfig.TITLE_MIN,
+ too_short: ts("must be at least %{min} characters long.",
+ min: ArchiveConfig.TITLE_MIN)
+ validates_length_of :title, maximum: ArchiveConfig.TITLE_MAX,
+ too_long: ts("must be less than %{max} characters long.",
+ max: ArchiveConfig.TITLE_MAX)
+
+ validates_length_of :summary, allow_blank: true, maximum: ArchiveConfig.SUMMARY_MAX,
+ too_long: ts("must be less than %{max} characters long.",
+ max: ArchiveConfig.SUMMARY_MAX)
+
+ validates :author, presence: { message: ts("can't be blank") }
+ validates_length_of :author, maximum: AUTHOR_LENGTH_MAX,
+ too_long: ts("must be less than %{max} characters long.",
+ max: AUTHOR_LENGTH_MAX)
+
+ validates :user_defined_tags_count,
+ at_most: { maximum: proc { ArchiveConfig.USER_DEFINED_TAGS_MAX } }
+
+ # TODO: External works should have fandoms, but they currently don't get added through the
+ # post new work form so we can't validate them
+ #validates_presence_of :fandoms
+
+ before_validation :cleanup_url
+ # i18n-tasks-use t("errors.attributes.url.invalid")
+ validates :url, presence: true, url_format: true, url_active: true
+ def cleanup_url
+ self.url = Addressable::URI.heuristic_parse(self.url) if self.url
+ rescue Addressable::URI::InvalidURIError
+ # url_format validation creates the error message
+ end
+
+ # Allow encoded characters to display correctly in titles
+ def title
+ read_attribute(:title).try(:html_safe)
+ end
+
+ ########################################################################
+ # VISIBILITY
+ ########################################################################
+ # Adapted from work.rb
+
+ scope :visible_to_all, -> { where(hidden_by_admin: false) }
+ scope :visible_to_registered_user, -> { where(hidden_by_admin: false) }
+ scope :visible_to_admin, -> { where("") }
+
+ # a complicated dynamic scope here:
+ # if the user is an Admin, we use the "visible_to_admin" scope
+ # if the user is not a logged-in User, we use the "visible_to_all" scope
+ # otherwise, we use a join to get userids and then get all posted works that are either unhidden OR belong to this user.
+ # Note: in that last case we have to use select("DISTINCT works.") because of cases where the same user appears twice
+ # on a work.
+ scope :visible_to_user, lambda {|user| user.is_a?(Admin) ? visible_to_admin : visible_to_all}
+
+ # Use the current user to determine what external works are visible
+ scope :visible, -> { visible_to_user(User.current_user) }
+
+ # Visible unless we're hidden by admin, in which case only an Admin can see.
+ def visible?(user=User.current_user)
+ self.hidden_by_admin? ? user.kind_of?(Admin) : true
+ end
+
+ # Visibility has changed, which means we need to reindex
+ # the external work's bookmarker pseuds, to update their bookmark counts.
+ def should_reindex_pseuds?
+ pertinent_attributes = %w[id hidden_by_admin]
+ destroyed? || (saved_changes.keys & pertinent_attributes).present?
+ end
+
+ ######################
+ # SEARCH
+ ######################
+
+ def bookmarkable_json
+ as_json(
+ root: false,
+ only: [
+ :title, :summary, :hidden_by_admin, :created_at
+ ],
+ methods: [
+ :posted, :restricted, :tag, :filter_ids, :rating_ids,
+ :archive_warning_ids, :category_ids, :fandom_ids, :character_ids,
+ :relationship_ids, :freeform_ids, :creators, :revised_at
+ ]
+ ).merge(
+ language_id: language&.short,
+ bookmarkable_type: "ExternalWork",
+ bookmarkable_join: { name: "bookmarkable" }
+ )
+ end
+
+ def posted
+ true
+ end
+ alias_method :posted?, :posted
+
+ def restricted
+ false
+ end
+ alias_method :restricted?, :restricted
+
+ def creators
+ [author]
+ end
+
+ def revised_at
+ created_at
+ end
+end
diff --git a/app/models/fandom.rb b/app/models/fandom.rb
new file mode 100755
index 0000000..d194fce
--- /dev/null
+++ b/app/models/fandom.rb
@@ -0,0 +1,50 @@
+class Fandom < Tag
+
+ NAME = ArchiveConfig.FANDOM_CATEGORY_NAME
+
+ has_many :wrangling_assignments
+ has_many :wranglers, through: :wrangling_assignments, source: :user
+
+ has_many :parents, through: :common_taggings, source: :filterable, source_type: 'Tag', after_remove: :check_media
+ has_many :medias, -> { where(type: 'Media') }, through: :common_taggings, source: :filterable, source_type: 'Tag'
+ has_many :characters, -> { where(type: 'Character') }, through: :child_taggings, source: :common_tag
+ has_many :relationships, -> { where(type: 'Relationship') }, through: :child_taggings, source: :common_tag
+ has_many :freeforms, -> { where(type: 'Freeform') }, through: :child_taggings, source: :common_tag
+
+
+ scope :by_media, lambda {|media| where(media_id: media.id)}
+
+ def unwrangled?
+ Media.uncategorized.fandoms.include?(self)
+ end
+
+ # An association callback to add the default media if all others have been removed
+ def check_media(media)
+ self.add_media_for_uncategorized
+ end
+
+ after_save :add_media_for_uncategorized
+ def add_media_for_uncategorized
+ if self.medias.empty? && self.type == "Fandom" # type could be something else if the tag is in the process of being re-categorised (re-sorted)
+ self.parents << Media.uncategorized
+ end
+ end
+
+ before_update :check_wrangling_status
+ def check_wrangling_status
+ if self.canonical_changed? && !self.canonical?
+ if !self.canonical? && self.merger_id
+ self.merger.wranglers = (self.wranglers + self.merger.wranglers).uniq
+ end
+ self.wranglers = []
+ end
+ end
+
+ # Types of tags to which a fandom tag can belong via common taggings or meta taggings
+ def parent_types
+ ['Media', 'MetaTag']
+ end
+ def child_types
+ ['Character', 'Relationship', 'Freeform', 'SubTag', 'Merger']
+ end
+end
diff --git a/app/models/fannish_next_of_kin.rb b/app/models/fannish_next_of_kin.rb
new file mode 100755
index 0000000..b30eaf8
--- /dev/null
+++ b/app/models/fannish_next_of_kin.rb
@@ -0,0 +1,10 @@
+class FannishNextOfKin < ApplicationRecord
+ belongs_to :user
+ belongs_to :kin, class_name: "User"
+
+ validates :user, :kin, :kin_email, presence: true
+
+ def kin_name
+ kin.try(:login)
+ end
+end
diff --git a/app/models/favorite_tag.rb b/app/models/favorite_tag.rb
new file mode 100755
index 0000000..7d30fb9
--- /dev/null
+++ b/app/models/favorite_tag.rb
@@ -0,0 +1,46 @@
+class FavoriteTag < ApplicationRecord
+ belongs_to :user
+ belongs_to :tag
+
+ validates :user_id, presence: true
+ validates :tag_id, presence: true,
+ uniqueness: { scope: :user_id,
+ message: ts("is already in your favorite tags.")
+ }
+
+ validate :within_limit, on: :create
+ def within_limit
+ if user && user.favorite_tags.reload.count >= ArchiveConfig.MAX_FAVORITE_TAGS
+ errors.add(:base,
+ ts("Sorry, you can only save %{maximum} favorite tags.",
+ maximum: ArchiveConfig.MAX_FAVORITE_TAGS))
+ end
+ end
+
+ validate :canonical, on: :create
+ def canonical
+ unless tag && tag.canonical?
+ errors.add(:base, "Sorry, you can only add canonical tags to your favorite tags.")
+ end
+ end
+
+ after_save :expire_cached_home_favorite_tags
+ after_destroy :expire_cached_home_favorite_tags
+
+ def tag
+ Tag.find_by(id: tag_id)
+ end
+
+ def tag_name
+ tag = self.tag
+ tag.name
+ end
+
+ private
+
+ def expire_cached_home_favorite_tags
+ unless Rails.env.development?
+ Rails.cache.delete("home/index/#{user_id}/home_favorite_tags")
+ end
+ end
+end
diff --git a/app/models/feedback.rb b/app/models/feedback.rb
new file mode 100755
index 0000000..68ea964
--- /dev/null
+++ b/app/models/feedback.rb
@@ -0,0 +1,91 @@
+# Class which holds feedback sent to the archive administrators about the archive as a whole
+class Feedback < ApplicationRecord
+ attr_accessor :ip_address, :referer, :site_skin
+
+ # NOTE: this has NOTHING to do with the Comment class!
+ # This is just the name of the text field in the Feedback
+ # class which holds the user's comments.
+ validates_presence_of :comment
+ validates_presence_of :summary
+ validates_presence_of :language
+ validates :email, email_format: { allow_blank: false }
+ validates_length_of :summary, maximum: ArchiveConfig.FEEDBACK_SUMMARY_MAX,
+ too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.FEEDBACK_SUMMARY_MAX_DISPLAYED)
+
+ validate :check_for_spam
+ def check_for_spam
+ approved = logged_in_with_matching_email? || !Akismetor.spam?(akismet_attributes)
+ errors.add(:base, ts("This report looks like spam to our system!")) unless approved
+ end
+
+ def logged_in_with_matching_email?
+ User.current_user.present? && User.current_user.email == email
+ end
+
+ def akismet_attributes
+ # If the user is logged in and we're sending info to Akismet, we can assume
+ # the email does not match.
+ role = User.current_user.present? ? "user-with-nonmatching-email" : "guest"
+ {
+ comment_type: "contact-form",
+ key: ArchiveConfig.AKISMET_KEY,
+ blog: ArchiveConfig.AKISMET_NAME,
+ user_ip: ip_address,
+ user_agent: user_agent,
+ user_role: role,
+ comment_author_email: email,
+ comment_content: comment
+ }
+ end
+
+ def mark_as_spam!
+ # don't submit spam reports unless in production mode
+ Rails.env.production? && Akismetor.submit_spam(akismet_attributes)
+ end
+
+ def mark_as_ham!
+ # don't submit ham reports unless in production mode
+ Rails.env.production? && Akismetor.submit_ham(akismet_attributes)
+ end
+
+ def email_and_send
+ UserMailer.feedback(id).deliver_later
+ send_report
+ end
+
+ def rollout_string
+ string = ""
+ # ES UPGRADE TRANSITION #
+ # Remove ES version logic, but leave this method for future rollout use
+ # string << if Feedback.use_new_search?
+ # "ES 6.0"
+ # else
+ # "ES 0.90"
+ # end
+ end
+
+ def send_report
+ return unless zoho_enabled?
+
+ reporter = SupportReporter.new(
+ title: summary,
+ description: comment,
+ language: language,
+ email: email,
+ username: username,
+ user_agent: user_agent,
+ site_revision: ArchiveConfig.REVISION.to_s,
+ rollout: rollout,
+ ip_address: ip_address,
+ referer: referer,
+ site_skin: site_skin
+ )
+ reporter.send_report!
+ end
+
+ private
+
+ def zoho_enabled?
+ %w[staging production].include?(Rails.env)
+ end
+end
diff --git a/app/models/feedback_reporters/abuse_reporter.rb b/app/models/feedback_reporters/abuse_reporter.rb
new file mode 100755
index 0000000..f0d6d1a
--- /dev/null
+++ b/app/models/feedback_reporters/abuse_reporter.rb
@@ -0,0 +1,42 @@
+class AbuseReporter < FeedbackReporter
+ attr_accessor :creator_ids
+
+ def report_attributes
+ super.deep_merge(
+ "departmentId" => department_id,
+ "subject" => subject,
+ "description" => ticket_description,
+ "cf" => custom_zoho_fields
+ )
+ end
+
+ private
+
+ def custom_zoho_fields
+ # To avoid issues where Zoho ticket creation silently fails, only grab the first
+ # 2080 characters of the referer URL. That may miss some complex search queries,
+ # but still keep enough to be useful most of the time.
+ truncated_referer = url.present? ? url[0..2079] : ""
+ {
+ "cf_ip" => ip_address.presence || "Unknown IP",
+ "cf_ticket_url" => truncated_referer,
+ "cf_user_id" => creator_ids.presence || ""
+ }
+ end
+
+ def department_id
+ ArchiveConfig.ABUSE_ZOHO_DEPARTMENT_ID
+ end
+
+ def subject
+ return "[#{ArchiveConfig.APP_SHORT_NAME}] Abuse - #{title.html_safe}" if title.present?
+
+ "[#{ArchiveConfig.APP_SHORT_NAME}] Abuse - No Subject"
+ end
+
+ def ticket_description
+ return "No comment submitted." if description.blank?
+
+ strip_images(description.html_safe, keep_src: true)
+ end
+end
diff --git a/app/models/feedback_reporters/feedback_reporter.rb b/app/models/feedback_reporters/feedback_reporter.rb
new file mode 100755
index 0000000..c0cce2e
--- /dev/null
+++ b/app/models/feedback_reporters/feedback_reporter.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+require "zoho_auth_client"
+require "zoho_resource_client"
+
+class FeedbackReporter
+ include HtmlCleaner
+ require "url_formatter"
+
+ attr_accessor :title,
+ :description,
+ :email,
+ :language,
+ :category,
+ :username,
+ :url,
+ :ip_address
+
+ def initialize(attrs = {})
+ attrs.each_pair do |key, val|
+ self.send("#{key}=", val)
+ end
+ end
+
+ def title
+ strip_html_breaks_simple(@title)
+ end
+
+ def description
+ add_break_between_paragraphs(@description)
+ end
+
+ def send_report!
+ zoho_resource_client.create_ticket(ticket_attributes: report_attributes)
+ end
+
+ def send_attachment!(id, filename, download)
+ zoho_resource_client.create_ticket_attachment(
+ ticket_id: id,
+ attachment_attributes: attachment_attributes(filename, download)
+ )
+ end
+
+ def report_attributes
+ {
+ "email" => email,
+ "contactId" => zoho_contact_id,
+ "cf" => {
+ "cf_language" => language.presence || Language.default.name,
+ "cf_name" => username.presence || "Anonymous user"
+ }
+ }
+ end
+
+ def attachment_attributes(filename, download)
+ attachment = StringIO.new(download)
+ # Workaround for HTTParty not recognizing StringIO as a file-like object:
+ # https://github.com/jnunemaker/httparty/issues/675#issuecomment-590757288
+ attachment.define_singleton_method(:path) { filename }
+ { file: attachment }
+ end
+
+ private
+
+ def zoho_contact_id
+ zoho_resource_client.retrieve_contact_id
+ end
+
+ def access_token
+ @access_token ||= ZohoAuthClient.new.access_token
+ end
+
+ def zoho_resource_client
+ @zoho_resource_client ||= ZohoResourceClient.new(
+ access_token: access_token,
+ email: email
+ )
+ end
+end
diff --git a/app/models/feedback_reporters/support_reporter.rb b/app/models/feedback_reporters/support_reporter.rb
new file mode 100755
index 0000000..920cfe3
--- /dev/null
+++ b/app/models/feedback_reporters/support_reporter.rb
@@ -0,0 +1,45 @@
+class SupportReporter < FeedbackReporter
+ attr_accessor :user_agent, :referer, :rollout, :site_revision, :site_skin
+
+ def report_attributes
+ super.deep_merge(
+ "departmentId" => department_id,
+ "subject" => subject,
+ "description" => ticket_description,
+ "cf" => custom_zoho_fields
+ )
+ end
+
+ private
+
+ def custom_zoho_fields
+ # To avoid issues where Zoho ticket creation silently fails, only grab the first
+ # 2080 characters of the referer URL. That may miss some complex search queries,
+ # but still keep enough to be useful most of the time.
+ truncated_referer = referer.present? ? referer[0..2079] : ""
+ {
+ "cf_archive_version" => site_revision.presence || "Unknown site revision",
+ "cf_rollout" => rollout.presence || "Unknown",
+ "cf_user_agent" => user_agent.presence || "Unknown user agent",
+ "cf_ip" => ip_address.presence || "Unknown IP",
+ "cf_ticket_url" => truncated_referer,
+ "cf_site_skin" => site_skin&.public ? site_skin.title : "Custom skin"
+ }
+ end
+
+ def department_id
+ ArchiveConfig.SUPPORT_ZOHO_DEPARTMENT_ID
+ end
+
+ def subject
+ return "[#{ArchiveConfig.APP_SHORT_NAME}] Support - #{title.html_safe}" if title.present?
+
+ "[#{ArchiveConfig.APP_SHORT_NAME}] Support - No Title"
+ end
+
+ def ticket_description
+ return "No description submitted." if description.blank?
+
+ strip_images(description.html_safe, keep_src: true)
+ end
+end
diff --git a/app/models/filter_count.rb b/app/models/filter_count.rb
new file mode 100755
index 0000000..7302592
--- /dev/null
+++ b/app/models/filter_count.rb
@@ -0,0 +1,141 @@
+class FilterCount < ApplicationRecord
+ belongs_to :filter, class_name: 'Tag', inverse_of: :filter_count
+ validates_presence_of :filter_id
+ validates_uniqueness_of :filter_id
+
+ # "Large" filter counts should be updated less frequently, to reduce strain
+ # on the database.
+ def self.large
+ where("unhidden_works_count > ?",
+ ArchiveConfig.LARGE_FILTER_COUNT_THRESHOLD || 1000)
+ end
+
+ # Return a relation containing all tags that need FilterCount objects.
+ # We only need to cache counts for canonical tags, because non-canonicals
+ # don't have associated filter-taggings. And we only need to cache counts for
+ # user-defined tags, because those are the only ones whose counts are used
+ # and/or displayed.
+ def self.filters_needing_counts
+ Tag.canonical.where(type: Tag::USER_DEFINED)
+ end
+
+ # Set accurate filter counts for all canonical tags
+ def self.set_all
+ filters_needing_counts.select(:id).find_in_batches do |batch|
+ enqueue_filters(batch)
+ end
+ end
+
+ def self.suspended?
+ admin_settings = AdminSetting.current
+ admin_settings.suspend_filter_counts?
+ end
+
+ ####################
+ # DELAYED JOBS
+ ####################
+
+ include AsyncWithResque
+ @queue = :utilities
+
+ ####################
+ # QUEUE MANAGEMENT
+ ####################
+
+ REDIS = REDIS_GENERAL
+ QUEUE_KEY_SMALL = "filter_count:queue_small".freeze
+ QUEUE_KEY_LARGE = "filter_count:queue_large".freeze
+ BATCH_SIZE = 1000
+
+ # Queue up a single filter (or filter ID) to have its counts recalculated.
+ def self.enqueue_filter(filter)
+ enqueue_filters([filter])
+ end
+
+ # Queue up a list of filters (or filter IDs) to have their filter counts
+ # recalculated in the next periodic task.
+ def self.enqueue_filters(filters)
+ return if suspended? || filters.blank?
+
+ ids = filters.map do |filter|
+ filter.respond_to?(:id) ? filter.id : filter
+ end.uniq
+
+ # Separate the large filters from the small filters, so that they can be
+ # processed at different intervals.
+ large_ids = FilterCount.large.where(filter_id: ids).pluck(:filter_id)
+ small_ids = ids - large_ids
+
+ # Add all filters to the appropriate queues.
+ REDIS.sadd(QUEUE_KEY_LARGE, large_ids) if large_ids.present?
+ REDIS.sadd(QUEUE_KEY_SMALL, small_ids) if small_ids.present?
+ end
+
+ # Update counts for small filters.
+ def self.update_counts_for_small_queue
+ update_counts_for_queue(QUEUE_KEY_SMALL)
+ end
+
+ # Update counts for large filters.
+ def self.update_counts_for_large_queue
+ update_counts_for_queue(QUEUE_KEY_LARGE)
+ end
+
+ # Divide the queue up into batches of size BATCH_SIZE, and asynchronously
+ # process each batch.
+ def self.update_counts_for_queue(queue_key)
+ return if suspended? || REDIS.scard(queue_key).zero?
+
+ # Rename to a temporary key to make sure that we don't run into concurrency
+ # issues.
+ temp_key = "#{queue_key}:#{Time.now.to_i}"
+ return unless REDIS.renamenx(queue_key, temp_key)
+
+ while REDIS.scard(temp_key).positive?
+ batch = REDIS.spop(temp_key, BATCH_SIZE)
+
+ # Build a separate REDIS set for the next batch to process.
+ batch_key = "#{temp_key}:#{batch.first}"
+ REDIS.sadd(batch_key, batch)
+ async(:update_counts_for_batch, batch_key)
+ end
+
+ REDIS.del(temp_key)
+ end
+
+ # Given the key for a batch of filter IDs, either update or create the
+ # FilterCount objects for those filters.
+ def self.update_counts_for_batch(key)
+ batch_ids = REDIS.smembers(key).map(&:to_i)
+
+ filters_needing_counts.where(id: batch_ids).each do |filter|
+ if filter.filter_count.nil?
+ filter.build_filter_count.update_counts
+ else
+ filter.filter_count.update_counts
+ end
+ end
+
+ REDIS.del(key)
+ end
+
+ ####################
+ # COUNT CALCULATION
+ ####################
+
+ # Calculate the two counts for this object, and save the results.
+ def update_counts
+ # Perform a single SQL query to get the counts of restricted and
+ # unrestricted works with the desired filter.
+ counts = filter.filtered_works.posted.unhidden.group(:restricted).count
+
+ # Retrieve the total number of works with restricted set to false.
+ self.public_works_count = counts[false].to_i
+
+ # Combine the restricted/unrestricted totals to get the number visible to
+ # logged-in users.
+ self.unhidden_works_count = counts.values.sum
+
+ save if changed?
+ end
+end
diff --git a/app/models/filter_tagging.rb b/app/models/filter_tagging.rb
new file mode 100755
index 0000000..2f451b4
--- /dev/null
+++ b/app/models/filter_tagging.rb
@@ -0,0 +1,30 @@
+# This is essentially a mirror of the taggings table as applied to works (right now)
+# except with all works connected to canonical tags instead of their synonyms for
+# browsing and filtering purposes. Filter = tag, filterable = thing that's been tagged.
+#
+# Note that this doesn't have very many validations or callbacks -- this is
+# because the ONLY class that should be adding/updating FilterTaggings is the
+# FilterUpdater, which handles those itself.
+class FilterTagging < ApplicationRecord
+ belongs_to :filter, class_name: "Tag", inverse_of: :filter_taggings
+ belongs_to :filterable, polymorphic: true, inverse_of: :filter_taggings
+
+ validates_uniqueness_of :filter_id, scope: [:filterable_type, :filterable_id]
+
+ after_destroy_commit :expire_caches
+ def expire_caches
+ return unless filterable_type == "Work"
+
+ CacheMaster.record(filterable_id, "tag", filter_id)
+ end
+
+ def self.update_filter_counts_since(date)
+ if date
+ FilterCount.enqueue_filters(
+ FilterTagging.where("created_at > ?", date).distinct.pluck(:filter_id)
+ )
+ else
+ raise "date not set for filter count suspension! very bad!"
+ end
+ end
+end
diff --git a/app/models/filter_updater.rb b/app/models/filter_updater.rb
new file mode 100755
index 0000000..5b044ff
--- /dev/null
+++ b/app/models/filter_updater.rb
@@ -0,0 +1,212 @@
+# frozen_string_literal: true
+
+# A class for calculating the filters that should be on a particular
+# filterable, and updating the FilterTaggings to match.
+#
+# Operates in bulk to try to work faster.
+class FilterUpdater
+ include AfterCommitEverywhere
+
+ attr_reader :klass, :type, :ids, :reindex_queue
+
+ # Takes as argument the type of filterable that we're modifying, the list of
+ # IDs of filterables that we're modifying, and the priority of the current
+ # change. (The priority, reindex_queue, is not used in this class. It's just
+ # passed along to the filterable class through reindex_for_filter_changes.)
+ def initialize(type, ids, reindex_queue)
+ @type = type.to_s
+ @ids = ids.to_a
+ @reindex_queue = reindex_queue
+
+ @klass = [Work, ExternalWork, Collection].find { |klass| klass.to_s == @type }
+
+ raise "FilterUpdater type '#{type}' not allowed." unless @klass
+
+ # A list for storing all of the FilterTaggings that we end up creating,
+ # modifying, or deleting. That way, we have a better idea of what we need
+ # to reindex.
+ @modified = []
+ end
+
+ ########################################
+ # DELAY CALCULATIONS WITH RESQUE
+ ########################################
+
+ # Put this object on the Resque queue so that update will be called later.
+ def async_update(job_queue: :utilities)
+ Resque.enqueue_to(job_queue, self.class, type, ids, reindex_queue)
+ end
+
+ # Perform for Resque.
+ def self.perform(type, ids, reindex_queue)
+ FilterUpdater.new(type, ids, reindex_queue).update
+ end
+
+ ########################################
+ # COMPUTE INFO
+ ########################################
+
+ # Calculate what the filters should be for every valid item in the batch, and
+ # updates the existing FilterTaggings to match.
+ def update
+ FilterTagging.transaction do
+ load_info
+
+ filter_taggings_by_id = FilterTagging.where(
+ filterable_type: type, filterable_id: @valid_item_ids
+ ).group_by(&:filterable_id)
+
+ @valid_item_ids.each do |id|
+ update_filters_for_item(id, filter_taggings_by_id[id] || [])
+ end
+ end
+
+ # Even if we're inside a nested transaction, the asynchronous steps
+ # required by reindexing should always take place outside of the
+ # transaction.
+ after_commit { reindex_changed }
+ end
+
+ private
+
+ # Calculate what the filters should be for a particular item, and perform the
+ # updates needed to ensure that those are the current filter taggings. Takes
+ # as argument the ID of the item to update, and the list of filter_taggings
+ # for that item.
+ def update_filters_for_item(item_id, filter_taggings)
+ missing_direct = Set.new(@direct_filters[item_id])
+ missing_inherited = Set.new(@inherited_filters[item_id])
+
+ filter_taggings.each do |ft|
+ if missing_direct.delete?(ft.filter_id)
+ update_inherited(ft, false)
+ elsif missing_inherited.delete?(ft.filter_id)
+ update_inherited(ft, true)
+ else
+ destroy(ft)
+ end
+ end
+
+ create_multiple(item_id, missing_direct, false)
+ create_multiple(item_id, missing_inherited, true)
+ end
+
+ # Notify the filterable class about the changes that we made, so that it can
+ # perform the appropriate steps to reindex everything.
+ def reindex_changed
+ klass.reindex_for_filter_changes(@valid_item_ids, @modified, reindex_queue)
+ end
+
+ ########################################
+ # RETRIEVE INFO FROM DATABASE
+ ########################################
+
+ # Calculates @direct_filters, @meta_tags, and @inherited_filters for this
+ # batch of items.
+ def load_info
+ load_valid_item_ids
+ load_direct_filters
+ load_meta_tags
+ load_inherited_filters
+ end
+
+ # Calculate which items exist in the database, so that we don't try to create
+ # FilterTaggings for items that have been deleted.
+ def load_valid_item_ids
+ @valid_item_ids = klass.unscoped.where(id: ids).distinct.pluck(:id)
+ end
+
+ # Calculates what the direct filters should be for this batch of items.
+ #
+ # Sets @direct_filters equal to a hash mapping from item IDs to a list of
+ # direct filter IDs (that is, filters that the item is either directly tagged
+ # with, or tagged with one of its synonyms). The default value for the
+ # hash is an empty list.
+ def load_direct_filters
+ taggings = Tagging.where(taggable_type: type, taggable_id: @valid_item_ids)
+
+ filter_relations = [
+ Tag.canonical.joins(:taggings),
+ Tag.canonical.joins(mergers: :taggings)
+ ]
+
+ pairs = filter_relations.flat_map do |filters|
+ filters.merge(taggings).pluck("taggings.taggable_id", "tags.id")
+ end
+
+ @direct_filters = hash_from_pairs(pairs)
+ end
+
+ # Reads MetaTagging info from the database for all tags included in this
+ # batch.
+ #
+ # Sets @meta_tags equal to a hash mapping from tag IDs to the tag's metatag
+ # IDs. The default value for the hash is an empty list.
+ def load_meta_tags
+ all_filters = @direct_filters.values.flatten.uniq
+
+ pairs = Tag.canonical.joins(:sub_taggings).where(
+ meta_taggings: { sub_tag_id: all_filters }
+ ).pluck(:sub_tag_id, :meta_tag_id)
+
+ @meta_tags = hash_from_pairs(pairs)
+ end
+
+ # Uses @direct_filters and @meta_tags to calculate what the inherited filters
+ # should be for each of the items in this batch. Creates a hash
+ # @inherited_filters mapping from item IDs to the inherited tag IDs.
+ def load_inherited_filters
+ @inherited_filters = Hash.new([].freeze)
+
+ @direct_filters.each_pair do |item_id, filter_ids|
+ inherited = filter_ids.flat_map { |filter_id| @meta_tags[filter_id] }
+ @inherited_filters[item_id] = (inherited - filter_ids).uniq
+ end
+ end
+
+ # Given a list of pairs of IDs, treat each pair as a (key, value) pair, and
+ # return a hash that associates each key with a list of values. Sets the
+ # default value of the hash to an empty frozen list, and freezes all of the
+ # lists in the hash at the end.
+ def hash_from_pairs(pairs)
+ hash = Hash.new([].freeze)
+
+ pairs.uniq.each do |key, value|
+ hash[key] = [] unless hash.key?(key)
+ hash[key] << value
+ end
+
+ hash.transform_values!(&:freeze)
+ end
+
+ ########################################
+ # CHANGE FILTERS AND RECORD
+ ########################################
+
+ # Create multiple FilterTaggings for the same item, with the given
+ # filter_ids. Records the new item in the list @modified.
+ def create_multiple(item_id, filter_ids, inherited)
+ filter_ids.each do |filter_id|
+ @modified << FilterTagging.create(filter_id: filter_id,
+ filterable_type: type,
+ filterable_id: item_id,
+ inherited: inherited)
+ end
+ end
+
+ # Modify an existing filter tagging, and record the modified item in the list
+ # @modified.
+ def update_inherited(filter_tagging, inherited)
+ return if filter_tagging.inherited == inherited
+
+ filter_tagging.update(inherited: inherited)
+ @modified << filter_tagging
+ end
+
+ # Destroy an existing filter tagging, and record the modified item in
+ # the list @modified.
+ def destroy(filter_tagging)
+ filter_tagging.destroy
+ @modified << filter_tagging
+ end
+end
diff --git a/app/models/freeform.rb b/app/models/freeform.rb
new file mode 100755
index 0000000..784cd0e
--- /dev/null
+++ b/app/models/freeform.rb
@@ -0,0 +1,37 @@
+class Freeform < Tag
+
+ NAME = ArchiveConfig.FREEFORM_CATEGORY_NAME
+
+ def self.label_name
+ "Additional Tags"
+ end
+
+ # Types of tags to which a freeform tag can belong via common taggings or meta taggings
+ def parent_types
+ ['Fandom', 'MetaTag']
+ end
+ def child_types
+ ['SubTag', 'Merger']
+ end
+
+ def characters
+ parents.select {|t| t.is_a? Character}.sort
+ end
+
+ def relationships
+ parents.select {|t| t.is_a? Relationship}.sort
+ end
+
+ def freeforms
+ (parents + children).select {|t| t.is_a? Freeform}.sort
+ end
+
+ def fandoms
+ parents.select {|t| t.is_a? Fandom}.sort
+ end
+
+ def medias
+ parents.select {|t| t.is_a? Media}.sort
+ end
+
+end
diff --git a/app/models/gift.rb b/app/models/gift.rb
new file mode 100755
index 0000000..3d06a31
--- /dev/null
+++ b/app/models/gift.rb
@@ -0,0 +1,75 @@
+class Gift < ApplicationRecord
+ NAME_LENGTH_MAX = 100
+
+ belongs_to :work, touch: true
+ belongs_to :pseud
+ has_one :user, through: :pseud
+
+ validates_length_of :recipient_name,
+ maximum: NAME_LENGTH_MAX,
+ too_long: ts("must be less than %{max} characters long.", max: NAME_LENGTH_MAX),
+ allow_blank: true
+
+ validates_format_of :recipient_name,
+ message: ts("must contain at least one letter or number."),
+ with: /[a-zA-Z0-9]/,
+ allow_blank: true
+
+ validate :has_name_or_pseud
+ def has_name_or_pseud
+ unless self.pseud || !self.recipient_name.blank?
+ errors.add(:base, ts("A gift must have a recipient specified."))
+ end
+ end
+
+ validates_uniqueness_of :pseud_id,
+ scope: :work_id,
+ message: ts("You can't give a gift to the same person twice.")
+
+ # Don't allow giving the same gift to the same user more than once
+ validate :has_not_given_to_user
+ def has_not_given_to_user
+ if self.pseud && self.work
+ other_pseuds = Gift.where(work_id: self.work_id).pluck(:pseud_id) - [self.pseud_id]
+ if Pseud.where(id: other_pseuds, user_id: self.pseud.user_id).exists?
+ errors.add(:base, ts("You seem to already have given this work to that user."))
+ end
+ end
+ end
+
+ scope :for_pseud, lambda {|pseud| where("pseud_id = ?", pseud.id)}
+
+ scope :for_user, lambda {|user| where("pseud_id IN (?)", user.pseuds.collect(&:id).flatten)}
+
+ scope :for_recipient_name, lambda {|name| where("recipient_name = ?", name)}
+
+ scope :for_name_or_byline, lambda { |name|
+ where("recipient_name = ? OR pseud_id = ?",
+ name,
+ Pseud.parse_byline(name))
+ }
+
+ scope :in_collection, lambda {|collection|
+ select("DISTINCT gifts.*").
+ joins({work: :collection_items}).
+ where("collection_items.collection_id = ?", collection.id)
+ }
+
+ scope :name_only, -> { select(:recipient_name) }
+
+ scope :include_pseuds, -> { includes(work: [:pseuds]) }
+
+ scope :not_rejected, -> { where(rejected: false) }
+
+ scope :are_rejected, -> { where(rejected: true) }
+
+ def recipient=(new_recipient_name)
+ self.pseud = Pseud.parse_byline(new_recipient_name)
+ self.recipient_name = pseud ? nil : new_recipient_name
+ end
+
+ def recipient
+ pseud ? pseud.byline : recipient_name
+ end
+
+end
diff --git a/app/models/inbox_comment.rb b/app/models/inbox_comment.rb
new file mode 100755
index 0000000..e766afb
--- /dev/null
+++ b/app/models/inbox_comment.rb
@@ -0,0 +1,43 @@
+class InboxComment < ApplicationRecord
+ validates_presence_of :user_id
+ validates_presence_of :feedback_comment_id
+
+ belongs_to :user
+ belongs_to :feedback_comment, class_name: 'Comment'
+
+ # Filters inbox comments by read and/or replied to and sorts by date
+ scope :find_by_filters, lambda { |filters|
+ read = case filters[:read]
+ when 'true' then true
+ when 'false' then false
+ else [true, false]
+ end
+ replied_to = case filters[:replied_to]
+ when 'true' then true
+ when 'false' then false
+ else [true, false]
+ end
+ direction = (filters[:date]&.upcase == "ASC" ? "created_at ASC" : "created_at DESC")
+
+ includes(feedback_comment: :pseud).
+ order(direction).
+ where(read: read, replied_to: replied_to)
+ }
+
+ scope :for_homepage, -> {
+ where(read: false).
+ order(created_at: :desc).
+ limit(ArchiveConfig.NUMBER_OF_ITEMS_VISIBLE_ON_HOMEPAGE)
+ }
+
+ # Gets the number of unread comments
+ def self.count_unread
+ where(read: false).count
+ end
+
+ # Remove comments that do not exist, were flagged as spam, or hidden by admin
+ def self.with_bad_comments_removed
+ joins("LEFT JOIN comments ON comments.id = inbox_comments.feedback_comment_id")
+ .where("comments.id IS NOT NULL AND comments.is_deleted = 0 AND comments.approved AND NOT comments.hidden_by_admin")
+ end
+end
diff --git a/app/models/indexing/cache_master.rb b/app/models/indexing/cache_master.rb
new file mode 100755
index 0000000..4a0d5be
--- /dev/null
+++ b/app/models/indexing/cache_master.rb
@@ -0,0 +1,63 @@
+class CacheMaster
+
+ CACHED_CLASSES = %w(Work Tag User Pseud Collection)
+
+ ###################
+ # CLASS METHODS
+ ###################
+
+ def self.expire_caches(work_ids)
+ work_ids.each { |id| self.new(id).expire }
+ end
+
+ def self.record(work_id, owner_type, owner_id)
+ self.new(work_id).record(owner_type, owner_id)
+ end
+
+ ###################
+ # INSTANCE METHODS
+ ###################
+
+ attr_reader :work_id
+
+ def initialize(work_id)
+ @work_id = work_id
+ end
+
+ def key
+ "works:#{work_id}:assocs"
+ end
+
+ def get_hash
+ REDIS_GENERAL.hgetall(key)
+ end
+
+ def get_value(owner_type)
+ REDIS_GENERAL.hget(key, owner_type)
+ end
+
+ def set_value(owner_type, value)
+ REDIS_GENERAL.hset(key, owner_type, value)
+ end
+
+ def reset!
+ REDIS_GENERAL.del(key)
+ end
+
+ def record(owner_type, owner_id)
+ owner_type = owner_type.to_s
+ data = get_value(owner_type)
+ value = data.nil? ? owner_id.to_s : "#{data},#{owner_id}"
+ set_value(owner_type, value)
+ end
+
+ def expire
+ get_hash.each_pair do |key, id_string|
+ raise "Redshirt: Attempted to constantize invalid class initialize expire #{key.classify}" unless CACHED_CLASSES.include?(key.classify)
+ klass = key.classify.constantize
+ klass.expire_ids(id_string.split(','))
+ end
+ reset!
+ end
+
+end
diff --git a/app/models/indexing/index_queue.rb b/app/models/indexing/index_queue.rb
new file mode 100755
index 0000000..c1d61cb
--- /dev/null
+++ b/app/models/indexing/index_queue.rb
@@ -0,0 +1,92 @@
+class IndexQueue
+
+ BATCH_SIZE = 1000
+ REDIS = REDIS_GENERAL
+
+ ##################
+ # CLASS METHODS
+ ##################
+
+ def self.all
+ REDIS.keys("index:*")
+ end
+
+ def self.get_key(klass, label)
+ klass = klass.is_a?(Class) ? klass.base_class : klass
+ "index:#{klass.to_s.underscore}:#{label}"
+ end
+
+ def self.enqueue(object, label)
+ klass = object.is_a?(Tag) ? 'Tag' : object.class.to_s
+ enqueue_id(klass, object.id, label)
+ end
+
+ def self.enqueue_id(klass, id, label)
+ key = get_key(klass, label)
+ new(key).add_id(id)
+ end
+
+ def self.enqueue_ids(klass, ids, label)
+ key = get_key(klass, label)
+ new(key).add_ids(ids)
+ end
+
+ def self.from_class_and_label(klass, label)
+ new(get_key(klass, label))
+ end
+
+ ####################
+ # INSTANCE METHODS
+ ####################
+
+ attr_reader :name
+
+ def initialize(name)
+ @name = name
+ @old_name = name
+ end
+
+ def add_id(id)
+ REDIS.sadd(name, id)
+ end
+
+ def add_ids(ids)
+ REDIS.sadd(name, ids) unless ids.blank?
+ end
+
+ def run
+ return unless exists?
+ rename
+ create_subqueues
+ delete
+ end
+
+ def ids
+ @ids = REDIS.smembers(name)
+ end
+
+ private
+
+ def exists?
+ REDIS.exists(name)
+ end
+
+ def rename
+ @name = "#{name}:#{Time.now.to_i}"
+ REDIS.rename(@old_name, @name)
+ end
+
+ def create_subqueues
+ _, klass, label = name.split(":")
+ klass = klass.classify # convert to the uppercase version
+
+ ids.in_groups_of(BATCH_SIZE, false).each_with_index do |id_batch, i|
+ AsyncIndexer.index(klass, id_batch, label)
+ end
+ end
+
+ def delete
+ REDIS.del(name)
+ end
+
+end
diff --git a/app/models/indexing/scheduled_reindex_job.rb b/app/models/indexing/scheduled_reindex_job.rb
new file mode 100755
index 0000000..5d37481
--- /dev/null
+++ b/app/models/indexing/scheduled_reindex_job.rb
@@ -0,0 +1,18 @@
+class ScheduledReindexJob
+ MAIN_CLASSES = %w[Pseud Tag Work Bookmark Series ExternalWork User].freeze
+
+ def self.perform(reindex_type)
+ classes = case reindex_type
+ when 'main', 'background'
+ MAIN_CLASSES
+ when 'stats'
+ %w(StatCounter)
+ end
+ classes.each{ |klass| run_queue(klass, reindex_type) }
+ end
+
+ def self.run_queue(klass, reindex_type)
+ IndexQueue.from_class_and_label(klass, reindex_type).run
+ end
+
+end
diff --git a/app/models/inherited_meta_tag_updater.rb b/app/models/inherited_meta_tag_updater.rb
new file mode 100755
index 0000000..4af3d63
--- /dev/null
+++ b/app/models/inherited_meta_tag_updater.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+# A helper class for calculating inherited meta taggings.
+class InheritedMetaTagUpdater
+ attr_reader :base, :boundary, :all
+
+ def initialize(base)
+ @base = base
+ @boundary = [base.id]
+ @all = [base.id]
+ end
+
+ # Advance to the next depth of our breadth-first search.
+ def advance
+ return if done?
+
+ @boundary = MetaTagging.where(
+ direct: true,
+ sub_tag_id: @boundary
+ ).pluck(:meta_tag_id) - @all
+
+ @all += @boundary
+ end
+
+ # Check whether we're done finding all of our inherited metatags.
+ def done?
+ @boundary.empty?
+ end
+
+ # Go through the breadth-first search steps to figure out what this tag's
+ # inherited metatags should be.
+ def calculate
+ advance until done?
+ end
+
+ # Generate the missing inherited meta taggings and delete the ones that are
+ # no longer needed. Returns true if any meta taggings were created/deleted,
+ # and false otherwise.
+ def update
+ calculate
+
+ # Keep track of whether the meta taggings were modified:
+ modified = false
+
+ missing = Set.new(all)
+ missing.delete(base.id)
+
+ # Delete the unnecessary meta taggings.
+ base.meta_taggings.each do |mt|
+ # If the metatag ID is in the list of IDs we're looking for, we don't
+ # need to modify the meta tagging at all -- we just need to mark that
+ # we've seen it by removing it from the list of missing metatag IDs.
+ #
+ # We also shouldn't modify the meta tagging if it's direct.
+ next if missing.delete?(mt.meta_tag_id) || mt.direct
+
+ # The inherited metatag isn't one of the ones we're expecting to see,
+ # which means that it shouldn't be here:
+ mt.destroy
+ modified = true
+ end
+
+ # Build the missing meta taggings.
+ Tag.where(id: missing.to_a).each do |tag|
+ base.meta_taggings.create(direct: false, meta_tag: tag)
+ modified = true
+ end
+
+ modified
+ end
+
+ # Fixes inherited metatags for all tags with at least one meta tagging.
+ def self.update_all
+ Tag.joins(:meta_taggings).distinct.find_each do |tag|
+ new(tag).update
+
+ # Yield each tag to allow for progress messages.
+ yield tag if block_given?
+ end
+ end
+end
diff --git a/app/models/invitation.rb b/app/models/invitation.rb
new file mode 100755
index 0000000..7cea09d
--- /dev/null
+++ b/app/models/invitation.rb
@@ -0,0 +1,106 @@
+# Beta invitations
+# http://railscasts.com/episodes/124-beta-invitations
+class Invitation < ApplicationRecord
+ belongs_to :creator, polymorphic: true
+ belongs_to :invitee, polymorphic: true
+ belongs_to :external_author
+
+ validate :recipient_is_not_registered, on: :create
+ def recipient_is_not_registered
+ # we allow invitations to be sent to existing users if the purpose is to claim an external author
+ if self.invitee_email && User.find_by(email: self.invitee_email) && !self.external_author
+ errors.add :invitee_email, ts('is already being used by an account holder.')
+ end
+ end
+
+ # ensure email is valid
+ validates :invitee_email, email_format: true, allow_blank: true
+
+ scope :unsent, -> { where(invitee_email: nil, redeemed_at: nil) }
+ scope :unredeemed, -> { where('invitee_email IS NOT NULL and redeemed_at IS NULL') }
+ scope :redeemed, -> { where('redeemed_at IS NOT NULL') }
+ scope :from_queue, -> { where(external_author: nil).where(creator_type: [nil, "Admin"]) }
+
+ before_validation :generate_token, on: :create
+ after_save :send_and_set_date, if: :saved_change_to_invitee_email?
+ after_save :adjust_user_invite_status
+
+ #Create a certain number of invitations for all valid users
+ def self.grant_all(total)
+ raise unless total > 0 && total < 20
+ User.valid.each do |user|
+ total.times do
+ user.invitations.create
+ end
+ I18n.with_locale(user.preference.locale_for_mails) do
+ UserMailer.invite_increase_notification(user.id, total).deliver_later
+ end
+ end
+ User.out_of_invites.update_all('out_of_invites = 0')
+ end
+
+ #Create a certain number of invitations for all users who are out of them
+ def self.grant_empty(total)
+ raise unless total > 0 && total < 20
+ User.valid.out_of_invites.each do |user|
+ total.times do
+ user.invitations.create
+ end
+ I18n.with_locale(user.preference.locale_for_mails) do
+ UserMailer.invite_increase_notification(user.id, total).deliver_later
+ end
+ end
+ User.out_of_invites.update_all('out_of_invites = 0')
+ end
+
+ def mark_as_redeemed(user=nil)
+ self.invitee = user
+ self.redeemed_at = Time.now
+ save
+ end
+
+ def send_and_set_date(resend: false)
+ return if invitee_email.blank?
+
+ if self.external_author
+ archivist = self.external_author.external_creatorships.collect(&:archivist).collect(&:login).uniq.join(", ")
+ # send invite synchronously for now -- this should now work delayed but just to be safe
+ UserMailer.invitation_to_claim(self.id, archivist).deliver_now
+ else
+ # send invitations actively sent by a user synchronously to avoid delays
+ UserMailer.invitation(self.id).deliver_now
+ end
+
+ # Skip callbacks within after_save by using update_column to avoid a callback loop
+ if resend
+ attrs = { resent_at: Time.current }
+ # This applies to old invites when AO3-6094 wasn't fixed.
+ attrs[:sent_at] = self.created_at if self.sent_at.nil?
+ self.update_columns(attrs)
+ else
+ self.update_column(:sent_at, Time.current)
+ end
+ rescue StandardError => e
+ errors.add(:base, :notification_could_not_be_sent, error: e.message)
+ end
+
+ def can_resend?
+ # created_at fallback is a vestige of the already fixed AO3-6094.
+ checked_date = self.resent_at || self.sent_at || self.created_at
+ checked_date < ArchiveConfig.HOURS_BEFORE_RESEND_INVITATION.hours.ago
+ end
+
+ private
+
+ def generate_token
+ self.token = Digest::SHA1.hexdigest([Time.current, rand].join)
+ end
+
+ #Update the user's out_of_invites status
+ def adjust_user_invite_status
+ if self.creator.respond_to?(:out_of_invites)
+ self.creator.out_of_invites = (self.creator.invitations.unredeemed.count < 1)
+ self.creator.save!(validate: false)
+ end
+ end
+end
diff --git a/app/models/invite_request.rb b/app/models/invite_request.rb
new file mode 100755
index 0000000..d17857c
--- /dev/null
+++ b/app/models/invite_request.rb
@@ -0,0 +1,60 @@
+class InviteRequest < ApplicationRecord
+ validates :email, presence: true, email_format: true
+ validates :email, uniqueness: { message: "is already part of our queue." }
+ before_validation :set_simplified_email, on: :create
+ validate :check_admin_banned_list, on: :create
+ validate :compare_with_users, on: :create
+ validate :simplified_email_uniqueness, on: :create
+
+ # Borrow the blacklist cleaner but just strip out all the periods for all domains
+ def set_simplified_email
+ return if email.blank?
+
+ simplified = AdminBlacklistedEmail.canonical_email(email).split('@')
+ self.simplified_email = simplified.first.delete(".").gsub(/\+.+$/, "") + "@#{simplified.last}"
+ end
+
+ # Doing this with a method so the error message makes more sense
+ def simplified_email_uniqueness
+ # Exit if raw email uniqueness error already exists
+ return if errors.of_kind?(:email, :taken)
+
+ errors.add(:email, "is already part of our queue.") if InviteRequest.exists?(simplified_email: simplified_email)
+ end
+
+ def proposed_fill_time
+ admin_settings = AdminSetting.current
+ number_of_rounds = (self.position.to_f/admin_settings.invite_from_queue_number.to_f).ceil - 1
+ proposed_time = admin_settings.invite_from_queue_at + (admin_settings.invite_from_queue_frequency * number_of_rounds).hours
+ Time.current > proposed_time ? Time.current : proposed_time
+ end
+
+ def position
+ InviteRequest.where("id <= ?", id).count
+ end
+
+ # Ensure that email is not banned
+ def check_admin_banned_list
+ return unless email.present? && AdminBlacklistedEmail.is_blacklisted?(email)
+
+ errors.add(:email, :blocked_email)
+ throw :abort
+ end
+
+ # Ensure that invite request is for a new user
+ def compare_with_users
+ return unless User.exists?(email: self.email)
+
+ errors.add(:email, :email_in_use)
+ throw :abort
+ end
+
+ # Turn a request into an invite and then remove it from the queue
+ def invite_and_remove(creator=nil)
+ invitation = creator ? creator.invitations.build(invitee_email: self.email, from_queue: true) :
+ Invitation.new(invitee_email: self.email, from_queue: true)
+ if invitation.save
+ self.destroy
+ end
+ end
+end
diff --git a/app/models/known_issue.rb b/app/models/known_issue.rb
new file mode 100755
index 0000000..d00c8b0
--- /dev/null
+++ b/app/models/known_issue.rb
@@ -0,0 +1,20 @@
+class KnownIssue < ApplicationRecord
+ # why is this included here? FIXME?
+ include HtmlCleaner
+
+ validates_presence_of :title
+ validates_length_of :title,
+ minimum: ArchiveConfig.TITLE_MIN,
+ too_short: ts("must be at least %{min} letters long.", min: ArchiveConfig.TITLE_MIN)
+
+ validates_length_of :title,
+ maximum: ArchiveConfig.TITLE_MAX,
+ too_long: ts("must be less than %{max} letters long.", max: ArchiveConfig.TITLE_MAX)
+
+ validates_presence_of :content
+ validates_length_of :content, minimum: ArchiveConfig.CONTENT_MIN,
+ too_short: ts("must be at least %{min} letters long.", min: ArchiveConfig.CONTENT_MIN)
+
+ validates_length_of :content, maximum: ArchiveConfig.CONTENT_MAX,
+ too_long: ts("cannot be more than %{max} characters long.", max: ArchiveConfig.CONTENT_MAX)
+end
diff --git a/app/models/kudo.rb b/app/models/kudo.rb
new file mode 100755
index 0000000..f420453
--- /dev/null
+++ b/app/models/kudo.rb
@@ -0,0 +1,105 @@
+class Kudo < ApplicationRecord
+ include Responder
+
+ VALID_COMMENTABLE_TYPES = %w[Work].freeze
+
+ belongs_to :user
+ belongs_to :commentable, polymorphic: true
+
+ validates :commentable_type, inclusion: { in: VALID_COMMENTABLE_TYPES }
+ validates :commentable,
+ presence: true,
+ if: proc { |c| VALID_COMMENTABLE_TYPES.include?(c.commentable_type) }
+
+ validates :user, not_blocked: { by: :commentable, on: :create }
+
+ validate :cannot_be_author, on: :create
+ def cannot_be_author
+ return unless user&.is_author_of?(commentable)
+
+ errors.add(:commentable, :author_on_own_work)
+ end
+
+ validate :guest_cannot_kudos_restricted_work, on: :create
+ def guest_cannot_kudos_restricted_work
+ return unless user.blank? && commentable.is_a?(Work) && commentable.restricted?
+
+ errors.add(:commentable, :guest_on_restricted)
+ end
+
+ validate :cannot_be_suspended, on: :create
+ def cannot_be_suspended
+ return unless user&.banned || user&.suspended
+
+ if user.banned
+ errors.add(:commentable, :user_is_banned)
+ else
+ errors.add(:commentable, :user_is_suspended)
+ end
+ end
+
+ validates :ip_address,
+ uniqueness: { scope: [:commentable_id, :commentable_type] },
+ if: proc { |kudo| kudo.ip_address.present? }
+
+ validates :user_id,
+ uniqueness: { scope: [:commentable_id, :commentable_type] },
+ if: proc { |kudo| kudo.user.present? }
+
+ validate :cannot_be_official_user, on: :create
+ def cannot_be_official_user
+ return unless user&.official
+
+ errors.add(:user, :official)
+ end
+
+ validate :cannot_be_archivist_account, on: :create
+ def cannot_be_archivist_account
+ return unless user&.archivist
+
+ errors.add(:user, :archivist)
+ end
+
+ scope :with_user, -> { where("user_id IS NOT NULL") }
+ scope :by_guest, -> { where("user_id IS NULL") }
+
+ after_destroy :update_work_stats
+ after_create :after_create, :update_work_stats
+ def after_create
+ users = self.commentable.pseuds.map(&:user).uniq
+
+ users.each do |user|
+ if notify_user_by_email?(user)
+ RedisMailQueue.queue_kudo(user, self)
+ end
+ end
+ end
+
+ after_save :expire_caches
+ def expire_caches
+ if commentable_type == "Work"
+ # Expire the work's cached total kudos count.
+ Rails.cache.delete("works/#{commentable_id}/kudos_count-v2")
+ # If it's a guest kudo, also expire the work's cached guest kudos count.
+ Rails.cache.delete("works/#{commentable_id}/guest_kudos_count-v2") if user_id.nil?
+ end
+
+ # Expire the cached kudos section under the work.
+ ActionController::Base.new.expire_fragment("#{commentable.cache_key}/kudos-v4")
+ end
+
+ def notify_user_by_email?(user)
+ user.nil? ? false : ( user.is_a?(Admin) ? true :
+ !(user == User.orphan_account || user.preference.kudos_emails_off?) )
+ end
+
+ # Return either the name of the kudo-giver or "guest".
+ # Used in kudo notifications.
+ def name
+ if self.user
+ user.login
+ else
+ "guest"
+ end
+ end
+end
diff --git a/app/models/language.rb b/app/models/language.rb
new file mode 100755
index 0000000..314d6e1
--- /dev/null
+++ b/app/models/language.rb
@@ -0,0 +1,28 @@
+class Language < ApplicationRecord
+ include WorksOwner
+ validates :short, presence: true, uniqueness: true, length: { maximum: 4 }
+ validates :name, presence: true, uniqueness: true
+
+ has_many :works
+ has_many :locales
+ has_many :admin_posts
+ has_many :archive_faqs
+
+ scope :default_order, -> { order(Arel.sql("COALESCE(NULLIF(sortable_name,''), short)")) }
+
+ def to_param
+ short
+ end
+
+ def self.default
+ self.find_or_create_by(short: ArchiveConfig.DEFAULT_LANGUAGE_SHORT, name: ArchiveConfig.DEFAULT_LANGUAGE_NAME)
+ end
+
+ def work_count
+ self.works.where(posted: true).count
+ end
+
+ def fandom_count
+ Fandom.joins(:works).where(works: { id: self.works.posted.collect(&:id) }).distinct.select("tags.id").count
+ end
+end
diff --git a/app/models/last_wrangling_activity.rb b/app/models/last_wrangling_activity.rb
new file mode 100755
index 0000000..ab9577e
--- /dev/null
+++ b/app/models/last_wrangling_activity.rb
@@ -0,0 +1,3 @@
+class LastWranglingActivity < ApplicationRecord
+ belongs_to :user
+end
diff --git a/app/models/locale.rb b/app/models/locale.rb
new file mode 100755
index 0000000..253d97c
--- /dev/null
+++ b/app/models/locale.rb
@@ -0,0 +1,28 @@
+class Locale < ApplicationRecord
+ belongs_to :language
+ validates_presence_of :iso
+ validates :iso, uniqueness: true
+ validates_presence_of :name
+
+ scope :default_order, -> { order(:iso) }
+
+ def to_param
+ iso
+ end
+
+ def self.default
+ language = Language.default
+ Locale.set_base_locale(iso: ArchiveConfig.DEFAULT_LOCALE_ISO, name: ArchiveConfig.DEFAULT_LOCALE_NAME, language_id: language.id)
+ end
+
+ def self.set_base_locale(locale={iso: "en", name: "English"})
+ language = Language.find_by(short: ArchiveConfig.DEFAULT_LANGUAGE_SHORT)
+ Locale.find_by(iso: locale[:iso].to_s) || language.locales.create(iso: locale[:iso].to_s, name: locale[:name].to_s, main: 1)
+ end
+
+ after_update :update_translations, if: :saved_change_to_iso?
+ def update_translations
+ ArchiveFaq::Translation.where(locale: iso_before_last_save).update_all(locale: iso)
+ Question::Translation.where(locale: iso_before_last_save).update_all(locale: iso)
+ end
+end
diff --git a/app/models/log_item.rb b/app/models/log_item.rb
new file mode 100755
index 0000000..3c14592
--- /dev/null
+++ b/app/models/log_item.rb
@@ -0,0 +1,17 @@
+class LogItem < ApplicationRecord
+ # Ignore the note_sanitizer_version field until it can be deleted.
+ self.ignored_columns = [:note_sanitizer_version]
+
+ belongs_to :user
+ belongs_to :admin
+
+ belongs_to :role
+
+ belongs_to :fnok_user, class_name: "User"
+
+ validates_presence_of :note
+ validates_presence_of :action
+
+ validates_length_of :note, maximum: ArchiveConfig.LOGNOTE_MAX
+
+end
diff --git a/app/models/media.rb b/app/models/media.rb
new file mode 100755
index 0000000..366f905
--- /dev/null
+++ b/app/models/media.rb
@@ -0,0 +1,36 @@
+class Media < Tag
+ NAME = ArchiveConfig.MEDIA_CATEGORY_NAME
+
+ has_many :common_taggings, as: :filterable
+ has_many :fandoms, -> { where(type: 'Fandom') }, through: :common_taggings, source: :common_tag
+
+ after_create :expire_caches
+ after_update :expire_caches, if: -> { saved_change_to_name? || saved_change_to_type? || saved_change_to_canonical? }
+ after_destroy :expire_caches
+
+ def expire_caches
+ ActionController::Base.new.expire_fragment("menu-fandoms-version5")
+ ActionController::Base.new.expire_fragment("homepage-fandoms-version2")
+ end
+
+ def child_types
+ ['Fandom']
+ end
+
+ # The media tag for unwrangled fandoms
+ def self.uncategorized
+ tag = self.find_or_create_by_name(ArchiveConfig.MEDIA_UNCATEGORIZED_NAME)
+ tag.update(canonical: true) unless tag.canonical
+ tag
+ end
+
+ # The list of media used for the menu on every page. All media except "No
+ # Media" and "Uncategorized Fandoms" are listed in order, and then
+ # "Uncategorized Fandoms" is tacked onto the list at the end.
+ def self.for_menu
+ canonical.by_name.where.not(
+ name: [ArchiveConfig.MEDIA_UNCATEGORIZED_NAME,
+ ArchiveConfig.MEDIA_NO_TAG_NAME]
+ ).to_a + [uncategorized]
+ end
+end
diff --git a/app/models/meta_tagging.rb b/app/models/meta_tagging.rb
new file mode 100755
index 0000000..6be7247
--- /dev/null
+++ b/app/models/meta_tagging.rb
@@ -0,0 +1,55 @@
+# Relationships between meta and sub tags
+# Meta tags represent a superset of sub tags
+class MetaTagging < ApplicationRecord
+ include Wrangleable
+
+ belongs_to :meta_tag, class_name: 'Tag'
+ belongs_to :sub_tag, class_name: 'Tag'
+
+ validates_presence_of :meta_tag, :sub_tag, message: "does not exist."
+ validates_uniqueness_of :meta_tag_id,
+ scope: :sub_tag_id,
+ message: "has already been added (possibly as an indirect metatag)."
+
+ after_create :expire_caching
+ after_destroy :expire_caching
+ after_commit :update_inherited
+
+ validate :meta_tag_validation
+ def meta_tag_validation
+ if self.meta_tag && self.sub_tag
+ unless self.meta_tag.class == self.sub_tag.class
+ self.errors.add(:base, "Meta taggings can only exist between two tags of the same type.")
+ end
+ unless self.meta_tag.canonical? && self.sub_tag.canonical
+ self.errors.add(:base, "Meta taggings can only exist between canonical tags.")
+ end
+ if self.meta_tag == self.sub_tag
+ self.errors.add(:base, "A tag can't be its own metatag.")
+ end
+ if self.meta_tag.meta_taggings.where(meta_tag: self.sub_tag).exists?
+ self.errors.add(:base, "A metatag can't be its own grandparent.")
+ end
+ end
+ end
+
+ def update_inherited
+ sub_tag.async(:update_inherited_meta_tags) if direct && sub_tag
+ end
+
+ def expire_caching
+ self.meta_tag&.update_works_index_timestamp!
+ end
+
+ # Go through all MetaTaggings and destroy the invalid ones.
+ def self.destroy_invalid
+ includes(:sub_tag, meta_tag: :meta_tags).find_each do |mt|
+ valid = mt.valid?
+
+ # Let callers do something on each iteration.
+ yield mt, valid if block_given?
+
+ mt.destroy unless valid
+ end
+ end
+end
diff --git a/app/models/moderated_work.rb b/app/models/moderated_work.rb
new file mode 100755
index 0000000..75760fd
--- /dev/null
+++ b/app/models/moderated_work.rb
@@ -0,0 +1,71 @@
+class ModeratedWork < ApplicationRecord
+ belongs_to :work
+ validates :work_id, uniqueness: true
+
+ delegate :title, :revised_at, to: :work
+
+ def self.register(work)
+ find_or_create_by(work_id: work.id)
+ end
+
+ def self.mark_reviewed(work)
+ register(work).mark_reviewed!
+ end
+
+ def self.mark_approved(work)
+ register(work).mark_approved!
+ end
+
+ def self.bulk_update(params = {})
+ ids = processed_bulk_ids(params)
+ transaction do
+ bulk_review(ids[:spam])
+ bulk_approve(ids[:ham])
+ end
+ end
+
+ def self.processed_bulk_ids(params = {})
+ spam_ids = params[:spam] || []
+ ham_ids = params[:ham] || []
+ # Ensure no overlap
+ admin_confusion = spam_ids & ham_ids
+ if admin_confusion
+ spam_ids -= admin_confusion
+ ham_ids -= admin_confusion
+ end
+ { spam: spam_ids, ham: ham_ids }
+ end
+
+ def self.bulk_review(ids)
+ return true unless ids.present?
+ where(id: ids).update_all(reviewed: true, approved: false)
+ # Ensure works are hidden and spam if they weren't already
+ Work.joins(:moderated_work).where("moderated_works.id IN (?)", ids).each do |work|
+ work.notify_of_hiding_for_spam unless work.hidden_by_admin?
+ work.update(hidden_by_admin: true, spam: true)
+ end
+ end
+
+ def self.bulk_approve(ids)
+ return true unless ids.present?
+ where(id: ids).update_all("approved = 1")
+ Work.joins(:moderated_work).where("moderated_works.id IN (?)", ids).each do |work|
+ work.mark_as_ham!
+ end
+ end
+
+ def mark_reviewed!
+ update_attribute(:reviewed, true)
+ end
+
+ def mark_approved!
+ update_attribute(:approved, true)
+ end
+
+ # Easy access to the creators of spam works
+ def admin_user_links
+ work.users.map do |u|
+ "#{u.login}"
+ end.join(", ").html_safe
+ end
+end
diff --git a/app/models/mute.rb b/app/models/mute.rb
new file mode 100755
index 0000000..3bf8387
--- /dev/null
+++ b/app/models/mute.rb
@@ -0,0 +1,35 @@
+class Mute < ApplicationRecord
+ include MuteHelper
+
+ belongs_to :muter, class_name: "User"
+ belongs_to :muted, class_name: "User"
+
+ validates :muter, :muted, presence: true
+ validates :muted_id, uniqueness: { scope: :muter_id }
+
+ validate :check_self
+ def check_self
+ errors.add(:muted, :self) if muted == muter
+ end
+
+ validate :check_official, if: :muted
+ def check_official
+ errors.add(:muted, :official) if muted.official
+ end
+
+ validate :check_mute_limit
+ def check_mute_limit
+ errors.add(:muted, :limit) if muter.muted_users.count >= ArchiveConfig.MAX_MUTED_USERS
+ end
+
+ after_create :update_cache
+ after_destroy :update_cache
+ def update_cache
+ Rails.cache.write(mute_css_key(muter), mute_css_uncached(muter))
+ end
+
+ def muted_byline=(byline)
+ pseud = Pseud.parse_byline(byline)
+ self.muted = pseud.user unless pseud.nil?
+ end
+end
diff --git a/app/models/offer.rb b/app/models/offer.rb
new file mode 100755
index 0000000..2156771
--- /dev/null
+++ b/app/models/offer.rb
@@ -0,0 +1,7 @@
+class Offer < Prompt
+ belongs_to :challenge_signup, touch: true, inverse_of: :offers
+
+ def prompt_restriction
+ collection&.challenge&.offer_restriction
+ end
+end
diff --git a/app/models/opendoors.rb b/app/models/opendoors.rb
new file mode 100755
index 0000000..5610ea1
--- /dev/null
+++ b/app/models/opendoors.rb
@@ -0,0 +1,5 @@
+module Opendoors
+ def self.table_name_prefix
+ 'opendoors_'
+ end
+end
diff --git a/app/models/potential_match.rb b/app/models/potential_match.rb
new file mode 100755
index 0000000..4d735cc
--- /dev/null
+++ b/app/models/potential_match.rb
@@ -0,0 +1,284 @@
+class PotentialMatch < ApplicationRecord
+ # We use "-1" to represent all the requested items matching
+ ALL = -1
+
+ CACHE_PROGRESS_KEY = "potential_match_status_for_".freeze
+ CACHE_SIGNUP_KEY = "potential_match_signups_for_".freeze
+ CACHE_INTERRUPT_KEY = "potential_match_interrupt_for_".freeze
+ CACHE_INVALID_SIGNUP_KEY = "potential_match_invalid_signup_for_".freeze
+
+ belongs_to :collection
+ belongs_to :offer_signup, class_name: "ChallengeSignup"
+ belongs_to :request_signup, class_name: "ChallengeSignup"
+
+
+ def self.progress_key(collection)
+ CACHE_PROGRESS_KEY + collection.id.to_s
+ end
+
+ def self.signup_key(collection)
+ CACHE_SIGNUP_KEY + collection.id.to_s
+ end
+
+ def self.interrupt_key(collection)
+ CACHE_INTERRUPT_KEY + collection.id.to_s
+ end
+
+ def self.invalid_signup_key(collection)
+ CACHE_INVALID_SIGNUP_KEY + collection.id.to_s
+ end
+
+ def self.clear!(collection)
+ # rapidly delete all potential prompt matches and potential matches
+ # WITHOUT CALLBACKS
+ pmids = collection.potential_matches.pluck(:id)
+ PotentialMatch.where(id: pmids).delete_all
+ end
+
+ def self.set_up_generating(collection)
+ REDIS_GENERAL.set progress_key(collection), "0.0"
+ end
+
+ def self.cancel_generation(collection)
+ REDIS_GENERAL.set interrupt_key(collection), "1"
+ end
+
+ def self.canceled?(collection)
+ REDIS_GENERAL.get(interrupt_key(collection)) == "1"
+ end
+
+ @queue = :collection
+
+ # This only works on class methods
+ def self.perform(method, *args)
+ self.send(method, *args)
+ end
+
+ def self.generate(collection)
+ Resque.enqueue(self, :generate_in_background, collection.id)
+ end
+
+ # Regenerate the potential matches for a given signup
+ def self.regenerate_for_signup(signup)
+ Resque.enqueue(self, :regenerate_for_signup_in_background, signup.id)
+ end
+
+ def self.invalid_signups_for(collection)
+ REDIS_GENERAL.smembers(invalid_signup_key(collection))
+ end
+
+ def self.clear_invalid_signups(collection)
+ REDIS_GENERAL.del invalid_signup_key(collection)
+ end
+
+ # The actual method that generates the potential matches for an entire collection
+ def self.generate_in_background(collection_id)
+ collection = Collection.find(collection_id)
+
+ if collection.challenge.assignments_sent_at.present?
+ # If assignments have been sent, we don't want to delete everything and
+ # regenerate. (If the challenge moderator wants to recalculate potential
+ # matches after sending assignments, they can use the Purge Assignments
+ # button.)
+ return
+ end
+
+ # check for invalid signups
+ PotentialMatch.clear_invalid_signups(collection)
+ invalid_signup_ids = collection.signups.reject(&:valid?)
+ .collect(&:id)
+ if invalid_signup_ids.present?
+ invalid_signup_ids.each { |sid| REDIS_GENERAL.sadd invalid_signup_key(collection), sid }
+
+ if collection.collection_email.present?
+ UserMailer.invalid_signup_notification(collection.id, invalid_signup_ids, collection.collection_email).deliver_later
+ else
+ collection.maintainers_list.each do |user|
+ I18n.with_locale(user.preference.locale_for_mails) do
+ UserMailer.invalid_signup_notification(collection.id, invalid_signup_ids, user.email).deliver_later
+ end
+ end
+ end
+ PotentialMatch.cancel_generation(collection)
+ else
+
+ PotentialMatch.clear!(collection)
+ settings = collection.challenge.potential_match_settings
+
+ if settings.no_match_required?
+ matcher = PotentialMatcherUnconstrained.new(collection)
+ else
+ index_type = PromptTagTypeInfo.new(collection).good_index_types.first
+ matcher = PotentialMatcherConstrained.new(collection, index_type)
+ end
+
+ matcher.generate
+ end
+ # TODO: for any signups with no potential matches try regenerating?
+ PotentialMatch.finish_generation(collection)
+ end
+
+ # Generate potential matches for a single signup.
+ def self.generate_for_signup(collection, signup, settings, collection_tag_sets, required_types, prompt_type = "request")
+ # only check the signups that have any overlap
+ match_signup_ids = PotentialMatch.matching_signup_ids(collection, signup, collection_tag_sets, required_types, prompt_type)
+
+ # We randomize the signup ids to make sure potential matches are distributed across all the participants
+ match_signup_ids.shuffle.each do |other_signup_id|
+ next if signup.id == other_signup_id
+
+ # The "match" method of ChallengeSignup creates and returns a new
+ # (unsaved) potential match object. It assumes the signup that is calling
+ # is the requesting signup, so if this is meant to be an offering signup
+ # instead, we call it from the other signup.
+ if prompt_type == "request"
+ other_signup = ChallengeSignup.with_offer_tags.find(other_signup_id)
+ potential_match = signup.match(other_signup, settings)
+ else
+ other_signup = ChallengeSignup.with_request_tags.find(other_signup_id)
+ potential_match = other_signup.match(signup, settings)
+ end
+
+ potential_match.save if potential_match&.valid?
+ end
+ end
+
+ # Get the ids of all signups that have some overlap in the tag types required for matching
+ def self.matching_signup_ids(collection, signup, collection_tag_sets, required_types, prompt_type = "request")
+ matching_signup_ids = []
+
+ if required_types.empty?
+ # nothing is required, so any signup can match -- check all of them
+ return collection.signups.pluck(:id)
+ end
+
+ # get the tagsets used in the signup we are trying to match
+ signup_tagsets = signup.send(prompt_type.pluralize).pluck(:tag_set_id, :optional_tag_set_id).flatten.compact
+
+ # get the ids of all the tags of the required types in the signup's tagsets
+ signup_tags = SetTagging.where(tag_set_id: signup_tagsets).joins(:tag).where(tags: { type: required_types }).pluck(:tag_id)
+
+ if signup_tags.empty?
+ # a match is required by the settings but the user hasn't put any of the required tags in, meaning they are open to anything
+ return collection.signups.pluck(:id)
+ else
+ # now find all the tagsets in the collection that share the original signup's tags
+ match_tagsets = SetTagging.where(tag_id: signup_tags, tag_set_id: collection_tag_sets).pluck(:tag_set_id).uniq
+
+ # and now we look up any signups that have one of those tagsets in the opposite position -- ie,
+ # if this signup is a request, we are looking for offers with the same tag; if it's an offer, we're
+ # looking for requests with the same tag.
+ matching_signup_ids = (prompt_type == "request" ? Offer : Request)
+ .where("tag_set_id IN (?) OR optional_tag_set_id IN (?)", match_tagsets, match_tagsets)
+ .pluck(:challenge_signup_id).compact
+
+ # now add on "any" matches for the required types
+ condition = case required_types.first.underscore
+ when "fandom"
+ "any_fandom = 1"
+ when "character"
+ "any_character = 1"
+ when "rating"
+ "any_rating = 1"
+ when "relationship"
+ "any_relationship = 1"
+ when "category"
+ "any_category = 1"
+ when "archive_warning"
+ "any_archive_warning = 1"
+ when "freeform"
+ "any_freeform = 1"
+ else
+ " 1 = 0"
+ end
+ matching_signup_ids += collection.prompts.where(condition).pluck(:challenge_signup_id)
+ end
+
+ matching_signup_ids.uniq
+ end
+
+ # Regenerate potential matches for a single signup within a challenge where (presumably)
+ # the other signups already have matches generated.
+ # To do this, we have to regenerate its potential matches both as a request and as an offer
+ # (instead of just generating them as a request as we do when generating ALL potential matches)
+ def self.regenerate_for_signup_in_background(signup_id)
+ # The signup will be acting as both offer and request, so we want to load
+ # both request tags and offer tags.
+ signup = ChallengeSignup.with_request_tags.with_offer_tags.find(signup_id)
+ collection = signup.collection
+
+ # Get all the data
+ settings = collection.challenge.potential_match_settings
+ collection_tag_sets = Prompt.where(collection_id: collection.id).pluck(:tag_set_id, :optional_tag_set_id).flatten.compact
+ required_types = settings.required_types.map(&:classify)
+
+ # clear the existing potential matches for this signup in each direction
+ signup.offer_potential_matches.destroy_all
+ signup.request_potential_matches.destroy_all
+
+ # We check the signup in both directions -- as a request signup and as an offer signup
+ %w[request offer].each do |prompt_type|
+ PotentialMatch.generate_for_signup(collection, signup, settings, collection_tag_sets, required_types, prompt_type)
+ end
+ end
+
+ # Finish off the potential match generation
+ def self.finish_generation(collection)
+ REDIS_GENERAL.del progress_key(collection)
+ REDIS_GENERAL.del signup_key(collection)
+ if PotentialMatch.canceled?(collection)
+ REDIS_GENERAL.del interrupt_key(collection)
+ # eventually we'll want to be able to pick up where we left off,
+ # but not there yet
+ PotentialMatch.clear!(collection)
+ else
+ ChallengeAssignment.delayed_generate(collection.id)
+ end
+ end
+
+ def self.in_progress?(collection)
+ if REDIS_GENERAL.get(progress_key(collection))
+ if PotentialMatch.canceled?(collection)
+ self.finish_generation(collection)
+ return false
+ end
+ return true
+ end
+ false
+ end
+
+ # The PotentialMatcherProgress class calculates the percent, so we just need
+ # to retrieve it from redis.
+ def self.progress(collection)
+ REDIS_GENERAL.get(progress_key(collection))
+ end
+
+ # sorting routine -- this gets used to rank the relative goodness of potential matches
+ include Comparable
+ def <=>(other)
+ return 0 if self.id == other.id
+
+ # start with seeing how many offers/requests match
+ cmp = compare_all(self.num_prompts_matched, other.num_prompts_matched)
+ return cmp unless cmp.zero?
+
+ # compare the "quality" of the best prompt match
+ # (i.e. the number of matching tags between the most closely-matching
+ # request prompt/offer prompt pair)
+ cmp = compare_all(max_tags_matched, other.max_tags_matched)
+ return cmp unless cmp.zero?
+
+ # if we're a match down to here just match on id
+ self.id <=> other.id
+ end
+
+ protected
+
+ def compare_all(self_value, other_value)
+ if self_value == ALL
+ other_value == ALL ? 0 : 1
+ else
+ (other_value == ALL ? -1 : self_value <=> other_value)
+ end
+ end
+end
diff --git a/app/models/potential_match_builder.rb b/app/models/potential_match_builder.rb
new file mode 100755
index 0000000..bc510e8
--- /dev/null
+++ b/app/models/potential_match_builder.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+# A class for building PotentialMatch objects by trying to add pairs of
+# matching prompts.
+class PotentialMatchBuilder
+ attr_accessor :num_prompts_matched, :max_tags_matched
+
+ ALL = -1
+
+ def initialize(request, offer, settings)
+ @request = request
+ @offer = offer
+ @settings = settings
+
+ @num_prompts_matched = 0
+ @max_tags_matched = 0
+ end
+
+ # Check whether the PotentialMatch we're building is valid -- that is,
+ # whether it has the required number of prompt matches.
+ def valid?
+ desired_matches = @settings.num_required_prompts
+ desired_matches = @request.requests.size if desired_matches == ALL
+ @num_prompts_matched >= desired_matches
+ end
+
+ # Check whether the two prompts match, and if so, record information about
+ # the match.
+ def try_prompt_match(request_prompt, offer_prompt)
+ return unless request_prompt.matches?(offer_prompt, @settings)
+ add_prompt_match(request_prompt, offer_prompt)
+ end
+
+ # Record the fact that the two passed-in prompts match.
+ def add_prompt_match(request_prompt, offer_prompt)
+ @num_prompts_matched += 1
+
+ # Compute the number of matching tags, and update max_tags_matched if
+ # necessary.
+ curr_tags_matched = request_prompt.count_tags_matched(offer_prompt)
+ @max_tags_matched = [@max_tags_matched, curr_tags_matched].max
+ end
+
+ # If possible, create the potential match that we've been building.
+ def build_potential_match
+ return nil unless valid?
+
+ PotentialMatch.new(
+ offer_signup: @offer,
+ request_signup: @request,
+ collection_id: @offer.collection_id,
+ num_prompts_matched: @num_prompts_matched,
+ max_tags_matched: @max_tags_matched
+ )
+ end
+end
diff --git a/app/models/potential_match_settings.rb b/app/models/potential_match_settings.rb
new file mode 100755
index 0000000..94868d3
--- /dev/null
+++ b/app/models/potential_match_settings.rb
@@ -0,0 +1,41 @@
+class PotentialMatchSettings < ApplicationRecord
+ ALL = -1
+ REQUIRED_MATCH_OPTIONS = [
+ [ts("All"), ALL],
+ ["0", 0],
+ ["1", 1],
+ ["2", 2],
+ ["3", 3],
+ ["4", 4],
+ ["5", 5]
+ ]
+
+ # VALIDATION
+ REQUIRED_TAG_ATTRIBUTES = %w(num_required_fandoms num_required_characters num_required_relationships num_required_freeforms num_required_categories
+ num_required_ratings num_required_archive_warnings)
+
+ REQUIRED_TAG_ATTRIBUTES.each do |tag_limit_field|
+ validates_inclusion_of tag_limit_field, in: REQUIRED_MATCH_OPTIONS.collect {|entry| entry[1]},
+ message: "%{value} is not a valid match setting"
+ end
+
+ # must have at least one matching request
+ validates_inclusion_of :num_required_prompts, in: REQUIRED_MATCH_OPTIONS.collect {|entry| entry[1]}.delete_if {|elem| elem == 0}, message: "%{value} is not a valid match setting"
+
+ # are all settings 0
+ def no_match_required?
+ REQUIRED_TAG_ATTRIBUTES.all? {|attrib| self.send("#{attrib}") == 0}
+ end
+
+ def required_types
+ TagSet::TAG_TYPES.select {|type| self.send("num_required_#{type.tableize}") != 0}
+ end
+
+ def topmost_required_type
+ required_types.first
+ end
+
+ def include_optional?(type)
+ send("include_optional_#{type.tableize}")
+ end
+end
diff --git a/app/models/potential_matcher/potential_matcher_constrained.rb b/app/models/potential_matcher/potential_matcher_constrained.rb
new file mode 100755
index 0000000..3901d1f
--- /dev/null
+++ b/app/models/potential_matcher/potential_matcher_constrained.rb
@@ -0,0 +1,127 @@
+# frozen_string_literal: true
+
+# Generates potential matches when the matching settings restrict who can be
+# matched with whom. Uses PromptBatches to speed up matching. Does not generate
+# assignments.
+#
+# The runtime is asymptotically quadratic in the number of signups, but the
+# batching keeps the constants relatively low.
+class PotentialMatcherConstrained
+ attr_reader :collection, :settings, :batch_size
+ attr_reader :index_tag_type, :index_optional
+
+ def initialize(collection, index_tag_type = nil, batch_size = 100)
+ @collection = collection
+ @settings = collection.challenge.potential_match_settings
+ @batch_size = batch_size
+
+ @required_types = @settings.required_types
+ @index_tag_type = index_tag_type || @required_types.first
+ @index_optional = @settings.include_optional?(@index_tag_type)
+
+ # Set up a new progress object for recording our progress.
+ @progress = PotentialMatcherProgress.new(collection)
+
+ # Set up a structure for holding multiple PotentialMatchBuilders.
+ @builders = {}
+ end
+
+ private
+
+ # Makes a batch for the given set of signups.
+ # Passes @index_tag_type and @index_optional to the constructor, so that the
+ # prompt batch knows how to build its indices properly.
+ def make_batch(signups, prompt_type)
+ PromptBatch.new(signups, prompt_type, @index_tag_type, @index_optional)
+ end
+
+ # Try matching the two prompts.
+ def try_match_prompts(request, offer)
+ return unless request.matches?(offer, @settings)
+
+ request_signup = request.challenge_signup
+ offer_signup = offer.challenge_signup
+ pair_key = "#{request_signup.id}|#{offer_signup.id}"
+
+ @builders[pair_key] ||= PotentialMatchBuilder.new(
+ request_signup, offer_signup, @settings
+ )
+
+ # We've already checked that the request matches the offer, so we can use
+ # add_prompt_match instead of try_prompt_match (to avoid duplicating work).
+ @builders[pair_key].add_prompt_match(request, offer)
+ end
+
+ # Builds and saves all PotentialMatches using @builders, then clears out the
+ # @builders table for the next batch.
+ def save_potential_matches
+ return if @builders.empty?
+
+ PotentialMatch.transaction do
+ @builders.each_value do |builder|
+ match = builder.build_potential_match
+ match.save unless match.nil?
+ end
+ end
+
+ @builders.clear
+ end
+
+ # Tries to calculate all pairs of matching prompts between the given
+ # challenge signup (to be used as a request), and the batch of offers.
+ def build_matches_for_request(request_signup, offer_batch)
+ request_signup.requests.each do |request|
+ offer_candidates = offer_batch.candidates_for_matching(request)
+
+ offer_candidates.each do |offer|
+ try_match_prompts(request, offer)
+ end
+ end
+ end
+
+ # Generates (and saves) all PotentialMatches for the given request batch and
+ # offer batch.
+ def make_batch_matches(request_batch, offer_batch)
+ @progress.start_subtask(request_batch.signups.size)
+
+ request_batch.signups.each do |request_signup|
+ build_matches_for_request(request_signup, offer_batch)
+ save_potential_matches
+ @progress.increment
+ end
+
+ @progress.end_subtask
+ end
+
+ public
+
+ # Generates all potential matches for the collection.
+ def generate
+ # These two lines won't trigger SQL queries (which is good, because that'd
+ # be an awful lot of data to load). They're just defining relations that we
+ # can call find_in_batches on.
+ offers = @collection.signups.with_offer_tags
+ requests = @collection.signups.with_request_tags
+
+ # We process a quadratic number of batch pairs.
+ batch_count = 1 + (@collection.signups.count - 1) / @batch_size
+ @progress.start_subtask(batch_count * batch_count)
+
+ offers.find_in_batches(batch_size: @batch_size) do |offer_signups|
+ break if PotentialMatch.canceled?(@collection)
+
+ offer_batch = make_batch(offer_signups, :offers)
+
+ requests.find_in_batches(batch_size: @batch_size) do |request_signups|
+ break if PotentialMatch.canceled?(@collection)
+
+ request_batch = make_batch(request_signups, :requests)
+ make_batch_matches(request_batch, offer_batch)
+
+ @progress.increment
+ end
+ end
+
+ @progress.end_subtask
+ end
+end
diff --git a/app/models/potential_matcher/potential_matcher_progress.rb b/app/models/potential_matcher/potential_matcher_progress.rb
new file mode 100755
index 0000000..713faa3
--- /dev/null
+++ b/app/models/potential_matcher/potential_matcher_progress.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+# A class for recording the progress of potential match generation.
+#
+# Designed to make it easier to record progress on subtasks. It keeps two
+# stacks: @progress, and @limit. When you call start_subtask, it pushes 0 onto
+# the @progress stack, and the number of ticks in the subtask onto the @limit
+# stack. When you call increment, it increases the value at the very end of the
+# @progress stack (so that the percent completion for that subtask, computed
+# with @progress.last / @limit.last, increases). When you call end_subtask, it
+# pops the last value off of the @progress and @limit, so that you go back to
+# recording progress for the previous subtask.
+#
+# The overall percent completion is computed by examining all subtasks on the
+# stack. You start from the end of the stack (with the subtask that was added
+# last), and divide the progress by the limit to get the fractional completion.
+# Then you move back to the previous subtask, add that fractional completion to
+# that subtask's progress, and divide by the limit. Repeat until you've reached
+# the first subtask.
+#
+# For a concrete example: If your first subtask has a limit of 10 "ticks" of
+# work, and you've called increment 3 times, the percent progress will be 30%.
+# If you then create a subtask with a limit of 5 ticks, and you call increment
+# once, then you're 1 / 5 = 0.2 = 20% of the way through the subtask, but your
+# progress on the whole work is (3 + 0.2) / 10 = 32%. Another call to increment
+# will bring you up to 34%, then 36%, 38%, and finally 40% when you finish all
+# five ticks of work. Then you can call end_task and increment (which will keep
+# you at 40%, but without the subtask), and continue with the work that will
+# bring you from 40% to 50%.
+class PotentialMatcherProgress
+ attr_reader :redis_progress_key
+ attr_reader :progress_enabled
+
+ # Create a new progress object.
+ def initialize(collection, progress_enabled = true)
+ @progress = []
+ @limit = []
+ @percent = 0
+
+ @progress_enabled = progress_enabled
+ @redis_progress_key = PotentialMatch.progress_key(collection)
+ end
+
+ # Signal that you're starting work on a subtask. The sole argument gives the
+ # number of "ticks" in the subtask that you're about to work on, so that it's
+ # possible to calculate the percent completion for the subtask.
+ def start_subtask(max)
+ @progress.push(0)
+ @limit.push(max)
+ end
+
+ # Signal that you're finishing work on the current subtask.
+ #
+ # NOTE: Never call start_subtask immediately after end_subtask. You should
+ # always have a call to increment in between, otherwise it'll look like your
+ # progress is getting lower. (In fact, in order to keep the counter from
+ # going backwards, you should always call increment immediately after
+ # end_subtask.)
+ def end_subtask
+ @progress.pop
+ @limit.pop
+ end
+
+ # Signal that you've just done a "tick" of work on the current subtask.
+ def increment
+ value = @progress.pop
+ @progress.push(value + 1)
+ update if @progress_enabled
+ end
+
+ # Calculate the percent progress.
+ def percent
+ value = 0.0
+
+ (@progress.size - 1).downto 0 do |index|
+ value = (@progress[index] + value) / @limit[index]
+ end
+
+ (value * 10_000).floor / 100.0
+ end
+
+ # Update the REDIS progress key with the current percent.
+ def update
+ return unless @progress_enabled
+ REDIS_GENERAL.set @redis_progress_key, percent.to_s
+ end
+end
diff --git a/app/models/potential_matcher/potential_matcher_unconstrained.rb b/app/models/potential_matcher/potential_matcher_unconstrained.rb
new file mode 100755
index 0000000..0ff6ebc
--- /dev/null
+++ b/app/models/potential_matcher/potential_matcher_unconstrained.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+# A class used to generate PotentialMatch objects when the matching is
+# unconstrained -- that is, anyone can be assigned to anyone else.
+class PotentialMatcherUnconstrained
+ ALL = -1
+
+ def initialize(collection, batch_size = 100)
+ @collection = collection
+ @batch_size = batch_size
+
+ # Set up a new progress object for recording our progress.
+ @progress = PotentialMatcherProgress.new(collection)
+ end
+
+ # Generates potential match objects for all (valid) pairs of requests and
+ # offers from the passed-in lists of ids. Saves time by not loading the
+ # offers or requests, because unconstrained challenges always have ALL for
+ # num_prompts_matched and max_tags_matched.
+ def make_all_matches(request_ids, offer_ids)
+ PotentialMatch.transaction do
+ request_ids.each do |request_id|
+ offer_ids.each do |offer_id|
+ next if offer_id == request_id
+
+ PotentialMatch.create(collection: @collection,
+ offer_signup_id: offer_id,
+ request_signup_id: request_id,
+ num_prompts_matched: ALL,
+ max_tags_matched: ALL)
+ end
+ end
+ end
+ end
+
+ # Divides the given array into batches using @batch_size.
+ def divide_into_batches(array)
+ temp = array.dup
+
+ batches = []
+ batches << temp.shift(@batch_size) until temp.empty?
+
+ batches
+ end
+
+ # Generates all potential matches for the collection, under the assumption
+ # that matching isn't constrained (so that everyone can match with everyone
+ # else, and all matches are equally good).
+ def generate
+ all_ids = @collection.signups.pluck(:id)
+ batched_ids = divide_into_batches(all_ids)
+
+ @progress.start_subtask(all_ids.size)
+
+ all_ids.each do |request_id|
+ break if PotentialMatch.canceled?(@collection)
+
+ batched_ids.each do |offer_ids|
+ make_all_matches([request_id], offer_ids)
+ end
+
+ @progress.increment
+ end
+
+ @progress.end_subtask
+ end
+end
diff --git a/app/models/potential_matcher/prompt_batch.rb b/app/models/potential_matcher/prompt_batch.rb
new file mode 100755
index 0000000..b3fd52c
--- /dev/null
+++ b/app/models/potential_matcher/prompt_batch.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+# A datastructure representing a batch of prompts. Used in order to speed up
+# matching.
+class PromptBatch
+ ALL = -1
+
+ attr_reader :signups, :prompt_type
+ attr_reader :prompts
+
+ def initialize(signups, prompt_type, index_tag_type, index_optional)
+ @signups = signups
+ @prompt_type = prompt_type
+ @index_tag_type = index_tag_type
+ @index_optional = index_optional
+
+ @prompts = if @prompt_type == :requests
+ @signups.flat_map(&:requests)
+ else
+ @signups.flat_map(&:offers)
+ end
+ end
+
+ private
+
+ # For the given prompt, get the list of all tags of the indexed tag type.
+ def indexed_tags_for_prompt(prompt)
+ if @index_optional
+ prompt.full_tag_set.tag_ids_by_type[@index_tag_type] || []
+ else
+ prompt.tag_set.tag_ids_by_type[@index_tag_type] || []
+ end
+ end
+
+ # Build a list of prompts that accept "any" for the indexed tag type.
+ def build_prompts_with_any
+ @prompts_with_any = @prompts.select do |prompt|
+ prompt.accepts_any?(@index_tag_type)
+ end
+ end
+
+ # Build a mapping from tag IDs to lists of prompts that want that tag.
+ def build_prompts_with_tag
+ @prompts_with_tag = {}
+
+ @prompts.each do |prompt|
+ indexed_tags_for_prompt(prompt).each do |tag|
+ @prompts_with_tag[tag] ||= []
+ @prompts_with_tag[tag] << prompt
+ end
+ end
+ end
+
+ # Returns a list of prompts that accept "any" for the indexed tag type.
+ # Calls build_prompts_with_any if the list doesn't already exist.
+ def prompts_with_any
+ build_prompts_with_any if @prompts_with_any.nil?
+ @prompts_with_any
+ end
+
+ # Returns a list of prompts that have the given tag.
+ # Calls build_prompts_with_tag if the hash table doesn't already exist.
+ def prompts_with_tag(tag)
+ build_prompts_with_tag if @prompts_with_tag.nil?
+ @prompts_with_tag[tag] || []
+ end
+
+ public
+
+ # Computes the prompts in this set that are "candidates" for matching the
+ # passed-in prompt -- that is, prompts that share a tag, or prompts with any.
+ # If the passed-in prompt has no tags of the indexed type, or accepts any for
+ # the indexed type, this returns all prompts.
+ def candidates_for_matching(prompt)
+ tags = indexed_tags_for_prompt(prompt)
+
+ if tags.empty? || prompt.accepts_any?(@index_tag_type)
+ @prompts
+ else
+ candidates = tags.flat_map { |tag_id| prompts_with_tag(tag_id) }
+ candidates += prompts_with_any
+ candidates.uniq
+ end
+ end
+end
diff --git a/app/models/potential_matcher/prompt_tag_type_info.rb b/app/models/potential_matcher/prompt_tag_type_info.rb
new file mode 100755
index 0000000..ce418dc
--- /dev/null
+++ b/app/models/potential_matcher/prompt_tag_type_info.rb
@@ -0,0 +1,109 @@
+# frozen_string_literal: true
+
+# A class used for gathering information about the rough frequency of different
+# required tag types in all of the prompts in a collection. Used to figure out
+# what would be a good "index type" -- that is, a type that we can use to try
+# to look up matches with.
+class PromptTagTypeInfo
+ # We use a larger batch size here because prompts are smaller than signups
+ # (which can contain many prompts), so we can store more of them in memory at
+ # the same time.
+ def initialize(collection, batch_size = 300)
+ @collection = collection
+ @batch_size = batch_size
+
+ load_collection_info
+ initialize_data_tables
+ end
+
+ private
+
+ # Load information about the passed-in collection.
+ def load_collection_info
+ @total_prompts = @collection.prompts.count
+ @total_signups = @collection.signups.count
+ @settings = @collection.challenge.potential_match_settings
+ @required_types = @settings.required_types
+
+ # Control the number of tags of each type that we want to see, in order to
+ # consider the tag type "good." (This is a lower limit -- the more
+ # different tags we see, the easier it will be to match on that type.)
+ @limit_first_tags = [
+ ArchiveConfig.PREPROCESS_COUNT_TAGS_MAX,
+ @total_signups / ArchiveConfig.PREPROCESS_COUNT_TAGS_DIVISOR
+ ].min
+
+ # Control the number of prompts with any that we want to see for a given
+ # type, in order to consider the type "good." (This is an upper limit --
+ # the more prompts we see with "any," the harder it will be to match on
+ # that type.)
+ @limit_prompts_with_any = [
+ ArchiveConfig.PREPROCESS_COUNT_ANY_MIN,
+ @total_prompts / ArchiveConfig.PREPROCESS_COUNT_ANY_DIVISOR
+ ].max
+ end
+
+ # Set up @count_prompts_with_any and @first_tags_of_type with the correct
+ # defaults.
+ def initialize_data_tables
+ @count_prompts_with_any = {}
+ @first_tags_of_type = {}
+
+ @required_types.each do |type|
+ @count_prompts_with_any[type] = 0
+ @first_tags_of_type[type] = []
+ end
+ end
+
+ # A good type to use for indexing has a fair number of tags of that type (so
+ # that we aren't, e.g. trying to match prompts on fandom in a single-fandom
+ # exchange), and doesn't have very many people requesting "any" (so that we
+ # have a lot of constraints on who can match with whom).
+ def calculate_good_index_types
+ @good_index_types = @required_types.select do |type|
+ (@first_tags_of_type[type].size >= @limit_first_tags) &&
+ (@count_prompts_with_any[type] <= @limit_prompts_with_any)
+ end
+ end
+
+ # Read in data from the passed-in prompt, and use it to update our data
+ # stored in @first_tags_of_type and @count_prompts_with_any.
+ def process_prompt(prompt)
+ @required_types.each do |type|
+ @count_prompts_with_any[type] += 1 if prompt.send("any_#{type}")
+
+ next if @first_tags_of_type[type].size >= @limit_first_tags
+
+ prompt_tags = prompt.full_tag_set.tag_ids_by_type[type]
+ next if prompt_tags.nil?
+
+ @first_tags_of_type[type] += prompt_tags
+ @first_tags_of_type[type].uniq!
+ end
+ end
+
+ # Iterate through all prompts in the collection, and process data from each.
+ def build_good_index_types
+ return if @required_types.nil? || @required_types.empty?
+
+ prompts = @collection.prompts.includes(
+ tag_set: :tags,
+ optional_tag_set: :tags
+ )
+
+ prompts.find_each(batch_size: @batch_size) do |prompt|
+ process_prompt(prompt)
+ end
+
+ calculate_good_index_types
+ end
+
+ public
+
+ # Builds the list of good index types if necessary; otherwise just returns
+ # the pre-calculated list.
+ def good_index_types
+ build_good_index_types if @good_index_types.nil?
+ @good_index_types
+ end
+end
diff --git a/app/models/preference.rb b/app/models/preference.rb
new file mode 100755
index 0000000..62fa5e8
--- /dev/null
+++ b/app/models/preference.rb
@@ -0,0 +1,48 @@
+class Preference < ApplicationRecord
+ # Ignore the email_visible and date_of_birth_visible fields until they can be deleted:
+ self.ignored_columns = [:email_visible, :date_of_birth_visible]
+
+ belongs_to :user
+ belongs_to :skin
+ belongs_to :locale, foreign_key: "preferred_locale"
+
+ validates :work_title_format,
+ format: {
+ with: /\A[a-zA-Z0-9_\-,\. ]+\z/,
+ message: ts("can only contain letters, numbers, spaces, and some limited punctuation (comma, period, dash, underscore).")
+ }
+
+ validate :can_use_skin, if: :skin_id_changed?
+
+ before_create :set_default_skin
+ def set_default_skin
+ self.skin_id = AdminSetting.current.default_skin_id
+ end
+
+ def self.disable_work_skin?(param)
+ return false if param == "creator"
+ return true if %w[light disable].include?(param)
+ return false unless User.current_user.is_a?(User)
+
+ User.current_user.try(:preference).try(:disable_work_skins)
+ end
+
+ def can_use_skin
+ return if skin_id == AdminSetting.default_skin_id ||
+ (skin.is_a?(Skin) && skin.approved_or_owned_by?(user))
+
+ errors.add(:base, "You don't have permission to use that skin!")
+ end
+
+ def locale
+ $rollout.active?(:set_locale_preference, user) ? super : Locale.default
+ end
+
+ def locale_for_mails
+ # Use preferred_locale to bypass the second $rollout check
+ l = Locale.find(preferred_locale)
+ return I18n.default_locale.to_s unless $rollout.active?(:set_locale_preference, user) && l.email_enabled
+
+ l.iso
+ end
+end
diff --git a/app/models/profile.rb b/app/models/profile.rb
new file mode 100755
index 0000000..5585e8f
--- /dev/null
+++ b/app/models/profile.rb
@@ -0,0 +1,16 @@
+class Profile < ApplicationRecord
+ include Justifiable
+
+ PROFILE_TITLE_MAX = 255
+ ABOUT_ME_MAX = 2000
+
+ # Ignore the location and date_of_birth fields until they can be deleted:
+ self.ignored_columns = [:location, :date_of_birth]
+
+ belongs_to :user
+
+ validates_length_of :title, allow_blank: true, maximum: PROFILE_TITLE_MAX,
+ too_long: ts("must be less than %{max} characters long.", max: PROFILE_TITLE_MAX)
+ validates_length_of :about_me, allow_blank: true, maximum: ABOUT_ME_MAX,
+ too_long: ts("must be less than %{max} characters long.", max: ABOUT_ME_MAX)
+end
diff --git a/app/models/prompt.rb b/app/models/prompt.rb
new file mode 100755
index 0000000..cdae4bb
--- /dev/null
+++ b/app/models/prompt.rb
@@ -0,0 +1,297 @@
+class Prompt < ApplicationRecord
+ include TagTypeHelper
+
+ # -1 represents all matching
+ ALL = -1
+
+ # ASSOCIATIONS
+
+ belongs_to :collection
+ belongs_to :pseud
+ has_one :user, through: :pseud
+
+ belongs_to :challenge_signup, touch: true, inverse_of: :prompts
+
+ belongs_to :tag_set, dependent: :destroy
+ accepts_nested_attributes_for :tag_set
+ has_many :tags, through: :tag_set
+
+ belongs_to :optional_tag_set, class_name: "TagSet", dependent: :destroy
+ accepts_nested_attributes_for :optional_tag_set
+ has_many :optional_tags, through: :optional_tag_set, source: :tag
+
+ has_many :request_claims, class_name: "ChallengeClaim", foreign_key: "request_prompt_id", inverse_of: :request_prompt, dependent: :destroy
+
+ # SCOPES
+
+ scope :claimed, -> { joins("INNER JOIN challenge_claims on prompts.id = challenge_claims.request_prompt_id") }
+
+ scope :in_collection, lambda {|collection| where(collection_id: collection.id) }
+
+ scope :with_tag, lambda { |tag|
+ joins("JOIN set_taggings ON set_taggings.tag_set_id = prompts.tag_set_id").
+ where("set_taggings.tag_id = ?", tag.id)
+ }
+
+ # VALIDATIONS
+
+ before_validation :inherit_from_signup, on: :create, if: :challenge_signup
+ def inherit_from_signup
+ self.pseud = challenge_signup.pseud
+ self.collection = challenge_signup.collection
+ end
+
+ validates_presence_of :collection_id
+
+ validates_presence_of :challenge_signup
+
+ # based on the prompt restriction
+ validates_presence_of :url, if: :url_required?
+ validates_presence_of :description, if: :description_required?
+ validates_presence_of :title, if: :title_required?
+
+ delegate :url_required?, :description_required?, :title_required?,
+ to: :prompt_restriction, allow_nil: true
+
+ validates_length_of :description,
+ maximum: ArchiveConfig.NOTES_MAX,
+ too_long: ts("must be less than %{max} letters long.", max: ArchiveConfig.NOTES_MAX)
+ validates_length_of :title,
+ maximum: ArchiveConfig.TITLE_MAX,
+ too_long: ts("must be less than %{max} letters long.", max: ArchiveConfig.TITLE_MAX)
+
+ # i18n-tasks-use t("errors.attributes.url.invalid")
+ validates :url, url_format: {allow_blank: true} # we validate the presence above, conditionally
+
+ before_validation :cleanup_url
+ def cleanup_url
+ self.url = Addressable::URI.heuristic_parse(self.url) if self.url
+ rescue Addressable::URI::InvalidURIError
+ # url_format validation creates the error message
+ end
+
+ validate :correct_number_of_tags
+ def correct_number_of_tags
+ prompt_type = self.class.name
+ restriction = prompt_restriction
+ if restriction
+ # make sure tagset has no more/less than the required/allowed number of tags of each type
+ TagSet::TAG_TYPES.each do |tag_type|
+ # get the tags of this type the user has specified
+ taglist = tag_set ? eval("tag_set.#{tag_type}_taglist") : []
+ tag_count = taglist.count
+ tag_label = tag_type_label_name(tag_type).downcase
+
+ # check if user has chosen the "Any" option
+ if self.send("any_#{tag_type}")
+ if tag_count > 0
+ errors.add(:base, ts("^You have specified tags for %{tag_label} in your %{prompt_type} but also chose 'Any,' which will override them! Please only choose one or the other.",
+ tag_label: tag_label, prompt_type: prompt_type))
+ end
+ next
+ end
+
+ # otherwise let's make sure they offered the right number of tags
+ required = eval("restriction.#{tag_type}_num_required")
+ allowed = eval("restriction.#{tag_type}_num_allowed")
+ unless tag_count.between?(required, allowed)
+ taglist_string = taglist.empty? ?
+ ts("none") :
+ "(#{tag_count}) -- " + taglist.collect(&:name).join(ArchiveConfig.DELIMITER_FOR_OUTPUT)
+ if allowed == 0
+ errors.add(:base, ts("^#{prompt_type}: Your #{prompt_type} cannot include any #{tag_label} tags, but you have included %{taglist}.",
+ taglist: taglist_string))
+ elsif required == allowed
+ errors.add(:base, ts("^#{prompt_type}: Your #{prompt_type} must include exactly %{required} #{tag_label} tags, but you have included #{tag_count} #{tag_label} tags in your current #{prompt_type}.",
+ required: required))
+ else
+ errors.add(:base, ts("^#{prompt_type}: Your #{prompt_type} must include between %{required} and %{allowed} #{tag_label} tags, but you have included #{tag_count} #{tag_label} tags in your current #{prompt_type}.",
+ required: required, allowed: allowed))
+ end
+ end
+ end
+ end
+ end
+
+ # make sure that if there is a specified set of allowed tags, the user's choices
+ # are within that set, or otherwise canonical
+ validate :allowed_tags
+ def allowed_tags
+ restriction = prompt_restriction
+
+ return unless restriction && tag_set
+
+ TagSet::TAG_TYPES.each do |tag_type|
+ # if we have a specified set of tags of this type, make sure that all the
+ # tags in the prompt are in the set.
+
+ # skip the check, these will be tested in restricted_tags below
+ next if TagSet::TAG_TYPES_RESTRICTED_TO_FANDOM.include?(tag_type) && restriction.send("#{tag_type}_restrict_to_fandom")
+
+ taglist = tag_set.send("#{tag_type}_taglist")
+ next if taglist.empty?
+
+ if restriction.has_tags?(tag_type)
+ disallowed_taglist = taglist - restriction.tags(tag_type)
+ unless disallowed_taglist.empty?
+ errors.add(
+ :base,
+ ts(
+ "^These %{tag_label} tags in your %{prompt_type} are not allowed in this challenge: %{taglist}",
+ tag_label: tag_type_label_name(tag_type).downcase,
+ prompt_type: self.class.name.downcase,
+ taglist: disallowed_taglist.collect(&:name).join(ArchiveConfig.DELIMITER_FOR_OUTPUT)
+ )
+ )
+ end
+ else
+ noncanonical_taglist = taglist.reject(&:canonical)
+ unless noncanonical_taglist.empty?
+ errors.add(
+ :base,
+ ts(
+ "^These %{tag_label} tags in your %{prompt_type} are not canonical and cannot be used in this challenge: %{taglist}. To fix this, please ask your challenge moderator to set up a tag set for the challenge. New tags can be added to the tag set manually by the moderator or through open nominations.",
+ tag_label: tag_type_label_name(tag_type).downcase,
+ prompt_type: self.class.name.downcase,
+ taglist: noncanonical_taglist.collect(&:name).join(ArchiveConfig.DELIMITER_FOR_OUTPUT)
+ )
+ )
+ end
+ end
+ end
+ end
+
+ # make sure that if any tags are restricted to fandom, the user's choices are
+ # actually in the fandom they have chosen.
+ validate :restricted_tags
+ def restricted_tags
+ restriction = prompt_restriction
+ return unless restriction
+
+ tag_set_associations = TagSetAssociation.where(owned_tag_set_id: restriction.owned_tag_sets.pluck(:id))
+
+ TagSet::TAG_TYPES_RESTRICTED_TO_FANDOM.each do |tag_type|
+ next unless restriction.send("#{tag_type}_restrict_to_fandom")
+
+ # tag_type is one of a set set so we know it is safe for constantize
+ allowed_tags = tag_type.classify.constantize.with_parents(tag_set.fandom_taglist).canonical
+ disallowed_taglist = tag_set ? tag_set.send("#{tag_type}_taglist") - allowed_tags : []
+
+ # check for tag set associations
+ disallowed_taglist -= tag_set_associations
+ .where(tag: disallowed_taglist, parent_tag_id: tag_set.fandom_taglist)
+ .includes(:tag)
+ .map(&:tag)
+ next if disallowed_taglist.empty?
+
+ errors.add(:base, :tags_not_in_fandom,
+ prompt_type: self.class.name.downcase,
+ tag_label: tag_type_label_name(tag_type).downcase, fandom: tag_set.fandom_taglist.pluck(:name).join(I18n.t("support.array.words_connector")),
+ taglist: disallowed_taglist.pluck(:name).join(I18n.t("support.array.words_connector")))
+ end
+ end
+
+ # INSTANCE METHODS
+
+ def can_delete?
+ if challenge_signup && !challenge_signup.can_delete?(self)
+ false
+ else
+ true
+ end
+ end
+
+ def unfulfilled_claims
+ self.request_claims.unfulfilled_in_collection(self.collection)
+ end
+
+ def fulfilled_claims
+ self.request_claims.fulfilled
+ end
+
+ # Computes the "full" tag set (tag_set + optional_tag_set), and stores the
+ # result as an instance variable for speed. This is used by the matching
+ # algorithm, which doesn't change any signup/prompt/tagset information, so
+ # it's okay to cache some information. (And if the info does change
+ # mid-matching process, it's okay that we're using the tag sets that were
+ # there when the moderator started the matching process.)
+ def full_tag_set
+ if @full_tag_set.nil?
+ @full_tag_set = optional_tag_set ? tag_set + optional_tag_set : tag_set
+ end
+
+ @full_tag_set
+ end
+
+ # Returns true if there's a match, false otherwise.
+ # self is the request, other is the offer
+ def matches?(other, settings = nil)
+ return nil if challenge_signup.id == other.challenge_signup.id
+ return nil if settings.nil?
+
+ TagSet::TAG_TYPES.each do |type|
+ # We definitely match in this type if the request or the offer accepts
+ # "any" for it. No need to check any more info for this type.
+ next if send("any_#{type}") || other.send("any_#{type}")
+
+ required_count = settings.send("num_required_#{type.pluralize}")
+ match_count = if settings.send("include_optional_#{type.pluralize}")
+ full_tag_set.match_rank(other.full_tag_set, type)
+ else
+ # we don't use optional tags to count towards required
+ tag_set.match_rank(other.tag_set, type)
+ end
+
+ # if we have to match all and don't, not a match
+ return false if required_count == ALL && match_count != ALL
+
+ # we are a match only if we either match all or at least as many as required
+ return false if match_count != ALL && match_count < required_count
+ end
+
+ true
+ end
+
+ # Count the number of overlapping tags of all types. Does not use ALL to
+ # indicate a 100% match, since the goal is to give a bonus to matches where
+ # both requester and offerer were specific about their desires, and had a lot
+ # of overlap.
+ def count_tags_matched(other)
+ self_tags = full_tag_set.tags.map(&:id)
+ other_tags = other.full_tag_set.tags.map(&:id)
+ (self_tags & other_tags).size
+ end
+
+ def accepts_any?(type)
+ send("any_#{type.downcase}")
+ end
+
+ def prompt_restriction
+ raise "Base-type Prompt objects cannot have prompt restrictions. Try creating a Request or an Offer."
+ end
+
+ # tag groups
+ def tag_groups
+ self.tag_set ? self.tag_set.tags.group_by { |t| t.type.to_s } : {}
+ end
+
+ def claim_by(user)
+ ChallengeClaim.where(request_prompt_id: self.id, claiming_user_id: user.id)
+ end
+
+ # checks if a prompt has been filled in a prompt meme
+ def unfulfilled?
+ if self.request_claims.empty? || !self.request_claims.fulfilled.exists?
+ return true
+ end
+ end
+
+ # currently only prompt meme prompts can be claimed, and by any number of people
+ def claimable?
+ if self.collection.challenge.is_a?(PromptMeme)
+ true
+ else
+ false
+ end
+ end
+end
diff --git a/app/models/prompt_restriction.rb b/app/models/prompt_restriction.rb
new file mode 100755
index 0000000..ed47b02
--- /dev/null
+++ b/app/models/prompt_restriction.rb
@@ -0,0 +1,124 @@
+class PromptRestriction < ApplicationRecord
+ has_many :owned_set_taggings, as: :set_taggable, dependent: :destroy
+ has_many :owned_tag_sets, -> { select("DISTINCT owned_tag_sets.*") }, through: :owned_set_taggings
+ has_many :tag_sets, through: :owned_tag_sets
+
+ # note: there is no has_one/has_many association here because this class may or may not
+ # be used by many different challenge classes. For convenience, if you use this class in
+ # a challenge class, add that challenge class to this list so other coders can see where
+ # it is used and how it behaves:
+ #
+ # challenge/gift_exchange
+ #
+
+ # VALIDATION
+ %w(fandom_num_required category_num_required rating_num_required character_num_required
+ relationship_num_required freeform_num_required archive_warning_num_required
+ fandom_num_allowed category_num_allowed rating_num_allowed character_num_allowed
+ relationship_num_allowed freeform_num_allowed archive_warning_num_allowed).each do |tag_limit_field|
+ validates_numericality_of tag_limit_field, only_integer: true, less_than_or_equal_to: ArchiveConfig.PROMPT_TAGS_MAX, greater_than_or_equal_to: 0
+ end
+
+ before_validation :update_allowed_values
+ # if anything is required make sure it is also allowed
+ def update_allowed_values
+ self.url_allowed = true if url_required
+ self.description_allowed = true if description_required
+ self.title_allowed = true if title_required
+
+ TagSet::TAG_TYPES.each do |tag_type|
+ required = eval("#{tag_type}_num_required") || eval("self.#{tag_type}_num_required") || 0
+ allowed = eval("#{tag_type}_num_allowed") || eval("self.#{tag_type}_num_allowed") || 0
+ if required > allowed
+ eval("self.#{tag_type}_num_allowed = required")
+ end
+ end
+ end
+
+ def required(tag_type)
+ self.send("#{tag_type}_num_required")
+ end
+
+ def allowed(tag_type)
+ self.send("#{tag_type}_num_allowed")
+ end
+
+ def restricted?(tag_type, restriction)
+ self.send("#{tag_type}_restrict_to_#{restriction}")
+ end
+
+ def allow_any?(tag_type)
+ self.send("allow_any_#{tag_type}")
+ end
+
+ def require_unique?(tag_type)
+ self.send("require_unique_#{tag_type}")
+ end
+
+ def topmost_tag_type
+ topmost_type = ""
+ TagSet::TAG_TYPES.each do |tag_type|
+ if self.allowed(tag_type) > 0
+ topmost_type = tag_type
+ break
+ end
+ end
+ topmost_type
+ end
+
+ def set_owned_tag_sets(sets)
+ self.owned_set_taggings.delete_all
+ current = self.owned_tag_sets
+ new_sets = sets - self.owned_tag_sets
+ remove_sets = self.owned_tag_sets - sets
+ self.owned_tag_sets += new_sets
+ self.owned_tag_sets -= remove_sets
+ end
+
+ def tag_sets_to_add=(tag_set_titles)
+ tag_set_titles.split(',').reject {|title| title.blank?}.each do |title|
+ title.strip!
+ ots = OwnedTagSet.find_by(title: title)
+ errors.add(:base, ts("We couldn't find the tag set {{title}}.", title: title)) and return if ots.nil?
+ errors.add(:base, ts("The tag set {{title}} is not available for public use.", title: title)) and return if (!ots.usable && !ots.user_is_moderator?(User.current_user))
+ unless self.owned_tag_sets.include?(ots)
+ self.owned_tag_sets << ots
+ end
+ end
+ end
+
+ def tag_sets_to_remove=(tag_set_ids)
+ tag_set_ids.reject {|id| id.blank?}.each do |id|
+ id.strip!
+ ots = OwnedTagSet.find(id) || nil
+ if ots && self.owned_tag_sets.include?(ots)
+ self.owned_tag_sets -= [ots]
+ end
+ end
+ end
+
+ def tag_sets_to_add; nil; end
+ def tag_sets_to_remove; nil; end
+
+ # Efficiently get ids of all tagsets thanks to Valium
+ def owned_tag_set_ids
+ OwnedSetTagging.where(set_taggable_type: self.class.name, set_taggable_id: self.id).pluck(:owned_tag_set_id)
+ end
+
+ def tag_set_ids
+ TagSet.joins("INNER JOIN owned_tag_sets ON owned_tag_sets.tag_set_id = tag_sets.id
+ INNER JOIN owned_set_taggings ON owned_set_taggings.owned_tag_set_id = owned_tag_sets.id").
+ where("owned_set_taggings.set_taggable_id = ? AND owned_set_taggings.set_taggable_type = 'PromptRestriction'", self.id).pluck :id
+ end
+
+ def has_tags?(type="tag")
+ tags(type).exists?
+ end
+
+ def tags(type="tag")
+ type = type.gsub(/\s+/, "").classify
+ raise "Redshirt: Attempted to constantize invalid class initialize tags -#{type}-" unless Tag::TYPES.include?(type)
+ type.constantize.in_prompt_restriction(self) # Safe constantize checked above
+ end
+
+end
diff --git a/app/models/pseud.rb b/app/models/pseud.rb
new file mode 100755
index 0000000..f6877df
--- /dev/null
+++ b/app/models/pseud.rb
@@ -0,0 +1,455 @@
+class Pseud < ApplicationRecord
+ include Searchable
+ include WorksOwner
+ include Justifiable
+ include AfterCommitEverywhere
+
+ has_one_attached :icon do |attachable|
+ attachable.variant(:standard, resize_to_limit: [100, 100], loader: { n: -1 })
+ end
+
+ # i18n-tasks-use t("errors.attributes.icon.invalid_format")
+ # i18n-tasks-use t("errors.attributes.icon.too_large")
+ validates :icon, attachment: {
+ allowed_formats: %w[image/gif image/jpeg image/png],
+ maximum_size: ArchiveConfig.ICON_SIZE_KB_MAX.kilobytes
+ }
+
+ NAME_LENGTH_MIN = 1
+ NAME_LENGTH_MAX = 40
+ DESCRIPTION_MAX = 500
+
+ belongs_to :user
+
+ delegate :login, to: :user, prefix: true, allow_nil: true
+ alias user_name user_login
+
+ has_many :bookmarks, dependent: :destroy
+ has_many :recs, -> { where(rec: true) }, class_name: 'Bookmark'
+ has_many :comments
+
+ has_many :creatorships, dependent: :destroy
+ has_many :approved_creatorships, -> { Creatorship.approved }, class_name: "Creatorship"
+
+ has_many :works, through: :approved_creatorships, source: :creation, source_type: "Work"
+ has_many :chapters, through: :approved_creatorships, source: :creation, source_type: "Chapter"
+ has_many :series, through: :approved_creatorships, source: :creation, source_type: "Series"
+
+ has_many :tags, through: :works
+ has_many :filters, through: :works
+ has_many :direct_filters, through: :works
+ has_many :collection_participants, dependent: :destroy
+ has_many :collections, through: :collection_participants
+ has_many :tag_set_ownerships, dependent: :destroy
+ has_many :tag_sets, through: :tag_set_ownerships
+ has_many :challenge_signups, dependent: :destroy
+ has_many :gifts, -> { where(rejected: false) }, inverse_of: :pseud, dependent: :destroy
+ has_many :gift_works, through: :gifts, source: :work
+ has_many :rejected_gifts, -> { where(rejected: true) }, class_name: "Gift", inverse_of: :pseud, dependent: :destroy
+ has_many :rejected_gift_works, through: :rejected_gifts, source: :work
+
+ has_many :offer_assignments, -> { where("challenge_assignments.sent_at IS NOT NULL") }, through: :challenge_signups
+ has_many :pinch_hit_assignments, -> { where("challenge_assignments.sent_at IS NOT NULL") }, class_name: "ChallengeAssignment", foreign_key: "pinch_hitter_id"
+
+ has_many :prompts, dependent: :destroy
+
+ before_validation :clear_icon
+
+ validates_presence_of :name
+ validates_length_of :name,
+ within: NAME_LENGTH_MIN..NAME_LENGTH_MAX,
+ too_short: ts("is too short (minimum is %{min} characters)", min: NAME_LENGTH_MIN),
+ too_long: ts("is too long (maximum is %{max} characters)", max: NAME_LENGTH_MAX)
+ validates :name, uniqueness: { scope: :user_id }
+ validates_format_of :name,
+ message: ts('can contain letters, numbers, spaces, underscores, and dashes.'),
+ with: /\A[\p{Word} -]+\Z/u
+ validates_format_of :name,
+ message: ts('must contain at least one letter or number.'),
+ with: /\p{Alnum}/u
+ validates_length_of :description, allow_blank: true, maximum: DESCRIPTION_MAX,
+ too_long: ts("must be less than %{max} characters long.", max: DESCRIPTION_MAX)
+ validates_length_of :icon_alt_text, allow_blank: true, maximum: ArchiveConfig.ICON_ALT_MAX,
+ too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.ICON_ALT_MAX)
+ validates_length_of :icon_comment_text, allow_blank: true, maximum: ArchiveConfig.ICON_COMMENT_MAX,
+ too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.ICON_COMMENT_MAX)
+
+ after_create :reindex_user
+ after_update :check_default_pseud
+ after_update :expire_caches
+ after_update :reindex_user, if: :name_changed?
+ after_destroy :reindex_user
+ after_commit :reindex_creations, :touch_comments
+
+ scope :alphabetical, -> { order(:name) }
+ scope :default_alphabetical, -> { order(is_default: :desc).alphabetical }
+ scope :abbreviated_list, -> { default_alphabetical.limit(ArchiveConfig.ITEMS_PER_PAGE) }
+ scope :for_search, -> { includes(:user).with_attached_icon }
+
+ def self.not_orphaned
+ where("user_id != ?", User.orphan_account)
+ end
+
+ # Enigel Dec 12 08: added sort method
+ # sorting by pseud name or by login name in case of equality
+ def <=>(other)
+ (self.name.downcase <=> other.name.downcase) == 0 ? (self.user_name.downcase <=> other.user_name.downcase) : (self.name.downcase <=> other.name.downcase)
+ end
+
+ def to_param
+ name
+ end
+
+ scope :public_work_count_for, -> (pseud_ids) {
+ select('pseuds.id, count(pseuds.id) AS work_count')
+ .joins(:works)
+ .where(
+ pseuds: { id: pseud_ids }, works: { posted: true, hidden_by_admin: false, restricted: false }
+ ).group('pseuds.id')
+ }
+
+ scope :posted_work_count_for, -> (pseud_ids) {
+ select('pseuds.id, count(pseuds.id) AS work_count')
+ .joins(:works)
+ .where(
+ pseuds: { id: pseud_ids }, works: { posted: true, hidden_by_admin: false }
+ ).group('pseuds.id')
+ }
+
+ scope :public_rec_count_for, -> (pseud_ids) {
+ select('pseuds.id, count(pseuds.id) AS rec_count')
+ .joins(:bookmarks)
+ .where(
+ pseuds: { id: pseud_ids }, bookmarks: { private: false, hidden_by_admin: false, rec: true }
+ )
+ .group('pseuds.id')
+ }
+
+ def self.rec_counts_for_pseuds(pseuds)
+ if pseuds.blank?
+ {}
+ else
+ pseuds_with_counts = Pseud.public_rec_count_for(pseuds.collect(&:id))
+ count_hash = {}
+ pseuds_with_counts.each {|p| count_hash[p.id] = p.rec_count.to_i}
+ count_hash
+ end
+ end
+
+ def self.work_counts_for_pseuds(pseuds)
+ if pseuds.blank?
+ {}
+ else
+ if User.current_user.nil?
+ pseuds_with_counts = Pseud.public_work_count_for(pseuds.collect(&:id))
+ else
+ pseuds_with_counts = Pseud.posted_work_count_for(pseuds.collect(&:id))
+ end
+
+ count_hash = {}
+ pseuds_with_counts.each {|p| count_hash[p.id] = p.work_count.to_i}
+ count_hash
+ end
+ end
+
+ def unposted_works
+ @unposted_works = self.works.where(posted: false).order(created_at: :desc)
+ end
+
+ # Produces a byline that indicates the user's name if pseud is not unique
+ def byline
+ (name != user_name) ? "#{name} (#{user_name})" : name
+ end
+
+ # get the former byline
+ def byline_was
+ past_name = name_was.blank? ? name : name_was
+ # if we have a user and their login has changed get the old one
+ past_user_name = user.blank? ? "" : (user.login_was.blank? ? user.login : user.login_was)
+ (past_name != past_user_name) ? "#{past_name} (#{past_user_name})" : past_name
+ end
+
+ # Parse a string of the "pseud.name (user.login)" format into an array
+ # [pseud.name, user.login]. If there is no parenthesized login after the
+ # pseud name, returns [pseud.name, nil].
+ def self.split_byline(byline)
+ pseud_name, login = byline.split("(", 2)
+ [pseud_name&.strip, login&.strip&.delete_suffix(")")]
+ end
+
+ # Parse a string of the "pseud.name (user.login)" format into a pseud. If the
+ # form is just "pseud.name" with no parenthesized login, assumes that
+ # pseud.name = user.login and goes from there.
+ def self.parse_byline(byline)
+ pseud_name, login = split_byline(byline)
+ login ||= pseud_name
+
+ Pseud.joins(:user).find_by(pseuds: { name: pseud_name }, users: { login: login })
+ end
+
+ # Parse a string of the "pseud.name (user.login)" format into a list of
+ # pseuds. Usually this will be just one pseud, but if the byline is of the
+ # form "pseud.name" with no parenthesized username, it'll look for any pseud
+ # with that name.
+ def self.parse_byline_ambiguous(byline)
+ pseud_name, login = split_byline(byline)
+
+ if login
+ Pseud.joins(:user).where(pseuds: { name: pseud_name }, users: { login: login })
+ else
+ Pseud.where(name: pseud_name)
+ end
+ end
+
+ # Takes a comma-separated list of bylines
+ # Returns a hash containing an array of pseuds and an array of bylines that couldn't be found
+ def self.parse_bylines(bylines)
+ valid_pseuds = []
+ failures = []
+ banned_pseuds = []
+
+ bylines.split(",").each do |byline|
+ pseud = parse_byline(byline)
+ if pseud.nil?
+ failures << byline.strip
+ elsif pseud.user.banned? || pseud.user.suspended?
+ banned_pseuds << pseud
+ else
+ valid_pseuds << pseud
+ end
+ end
+
+ {
+ pseuds: valid_pseuds.flatten.uniq,
+ invalid_pseuds: failures,
+ banned_pseuds: banned_pseuds.flatten.uniq.map(&:byline)
+ }
+ end
+
+ ## AUTOCOMPLETE
+ # set up autocomplete and override some methods
+ include AutocompleteSource
+ def autocomplete_prefixes
+ [ "autocomplete_pseud" ]
+ end
+
+ def autocomplete_value
+ "#{id}#{AUTOCOMPLETE_DELIMITER}#{byline}"
+ end
+
+ # This method is for use in before_* callbacks
+ def autocomplete_value_was
+ "#{id}#{AUTOCOMPLETE_DELIMITER}#{byline_was}"
+ end
+
+ # See byline_before_last_save for the reasoning behind why both this and
+ # autocomplete_value_was exist in this model
+ #
+ # This method is for use in after_* callbacks
+ def autocomplete_value_before_last_save
+ "#{id}#{AUTOCOMPLETE_DELIMITER}#{byline_before_last_save}"
+ end
+
+ def byline_before_last_save
+ past_name = name_before_last_save.blank? ? name : name_before_last_save
+
+ # In this case, self belongs to a user that has already been saved
+ # during it's (self's) callback cycle, which means we need to
+ # look *back* at the user's [attributes]_before_last_save, since
+ # [attribute]_was for the pseud's user will behave as if this were an
+ # after_* callback on the user, instead of a before_* callback on self.
+ #
+ # see psued_sweeper.rb:13 for more context
+ #
+ past_user_name = user.blank? ? "" : (user.login_before_last_save.blank? ? user.login : user.login_before_last_save)
+ (past_name != past_user_name) ? "#{past_name} (#{past_user_name})" : past_name
+ end
+
+ # This method is for removing stale autocomplete records in a before_*
+ # callback, such as the one used in PseudSweeper
+ #
+ # This is a particular case for the Pseud model
+ def remove_stale_from_autocomplete_before_save
+ self.class.remove_from_autocomplete(self.autocomplete_search_string_was, self.autocomplete_prefixes, self.autocomplete_value_was)
+ end
+
+
+ ## END AUTOCOMPLETE
+
+ def replace_me_with_default
+ replacement = user.default_pseud
+
+ # We don't use change_ownership here because we want to transfer both
+ # approved and unapproved creatorships.
+ self.creatorships.includes(:creation).each do |creatorship|
+ next if creatorship.creation.nil?
+
+ existing =
+ replacement.creatorships.find_by(creation: creatorship.creation)
+
+ if existing
+ existing.update(approved: existing.approved || creatorship.approved)
+ else
+ creatorship.update(pseud: replacement)
+ end
+ end
+
+ # Update the pseud ID for all comments. Also updates the timestamp, so that
+ # the cache is invalidated and the pseud change will be visible.
+ Comment.where(pseud_id: self.id).update_all(pseud_id: replacement.id,
+ updated_at: Time.now)
+ change_collections_membership
+ change_gift_recipients
+ change_challenge_participation
+ self.destroy
+ end
+
+ # Change the ownership of a creation from one pseud to another
+ def change_ownership(creation, pseud, options={})
+ transaction do
+ # Update children before updating the creation itself, since deleting
+ # creatorships from the creation will also delete them from the creation's
+ # children.
+ unless options[:skip_children]
+ children = if creation.is_a?(Work)
+ creation.chapters
+ elsif creation.is_a?(Series)
+ creation.works
+ else
+ []
+ end
+
+ children.each do |child|
+ change_ownership(child, pseud, options)
+ end
+ end
+
+ # Should only add new creatorships if we're an approved co-creator.
+ if creation.creatorships.approved.where(pseud: self).exists?
+ creation.creatorships.find_or_create_by(pseud: pseud)
+ end
+
+ # But we should delete all creatorships, even invited ones:
+ creation.creatorships.where(pseud: self).destroy_all
+
+ if creation.is_a?(Work)
+ creation.series.each do |series|
+ if series.work_pseuds.where(id: id).exists?
+ series.creatorships.find_or_create_by(pseud: pseud)
+ else
+ change_ownership(series, pseud, options.merge(skip_children: true))
+ end
+ end
+ comments = creation.total_comments.where("comments.pseud_id = ?", self.id)
+ comments.each do |comment|
+ comment.update_attribute(:pseud_id, pseud.id)
+ end
+ end
+
+ # make sure changes affect caching/search/author fields
+ creation.save
+ end
+ end
+
+ def change_membership(collection, new_pseud)
+ self.collection_participants.in_collection(collection).each do |cparticipant|
+ cparticipant.pseud = new_pseud
+ cparticipant.save
+ end
+ end
+
+ def change_challenge_participation
+ # We want to update all prompts associated with this pseud, but although
+ # each prompt contains a pseud_id column, they're not indexed on it. That
+ # means doing the search Prompt.where(pseud_id: self.id) would require
+ # searching all rows of the prompts table. So instead, we do a join on the
+ # challenge_signups table and look up prompts whose ChallengeSignup has the
+ # pseud_id that we want to change.
+ Prompt.joins(:challenge_signup).
+ where("challenge_signups.pseud_id = #{id}").
+ update_all("prompts.pseud_id = #{user.default_pseud.id}")
+
+ ChallengeSignup.where("pseud_id = #{self.id}").update_all("pseud_id = #{self.user.default_pseud.id}")
+ ChallengeAssignment.where("pinch_hitter_id = #{self.id}").update_all("pinch_hitter_id = #{self.user.default_pseud.id}")
+ return
+ end
+
+ def change_gift_recipients
+ Gift.where("pseud_id = #{self.id}").update_all("pseud_id = #{self.user.default_pseud.id}")
+ end
+
+ def change_bookmarks_ownership
+ Bookmark.where("pseud_id = #{self.id}").update_all("pseud_id = #{self.user.default_pseud.id}")
+ end
+
+ def change_collections_membership
+ CollectionParticipant.where("pseud_id = #{self.id}").update_all("pseud_id = #{self.user.default_pseud.id}")
+ end
+
+ def check_default_pseud
+ if !self.is_default? && self.user.pseuds.to_enum.find(&:is_default?) == nil
+ default_pseud = self.user.pseuds.select{|ps| ps.name.downcase == self.user_name.downcase}.first
+ default_pseud.update_attribute(:is_default, true)
+ end
+ end
+
+ def expire_caches
+ if saved_change_to_name?
+ works.touch_all
+ series.each(&:expire_byline_cache)
+ chapters.each(&:expire_byline_cache)
+ end
+ end
+
+ def touch_comments
+ comments.touch_all
+ end
+
+ # Delete current icon (thus reverting to archive default icon)
+ def delete_icon=(value)
+ @delete_icon = !value.to_i.zero?
+ end
+
+ def delete_icon
+ !!@delete_icon
+ end
+ alias_method :delete_icon?, :delete_icon
+
+ def clear_icon
+ return unless delete_icon?
+
+ self.icon.purge
+ self.icon_alt_text = nil
+ self.icon_comment_text = nil
+ end
+
+ #################################
+ ## SEARCH #######################
+ #################################
+
+ def collection_ids
+ collections.pluck(:id)
+ end
+
+ def document_json
+ PseudIndexer.new({}).document(self)
+ end
+
+ def should_reindex_creations?
+ pertinent_attributes = %w[id name]
+ destroyed? || (saved_changes.keys & pertinent_attributes).present?
+ end
+
+ # If the pseud gets renamed, anything indexed with the old name needs to be reindexed:
+ # works, series, bookmarks.
+ def reindex_creations
+ return unless should_reindex_creations?
+ IndexQueue.enqueue_ids(Work, works.pluck(:id), :main)
+ IndexQueue.enqueue_ids(Bookmark, bookmarks.pluck(:id), :main)
+ IndexQueue.enqueue_ids(Series, series.pluck(:id), :main)
+ end
+
+ def reindex_user
+ after_commit { user.enqueue_to_index }
+ end
+end
diff --git a/app/models/question.rb b/app/models/question.rb
new file mode 100755
index 0000000..46fcde3
--- /dev/null
+++ b/app/models/question.rb
@@ -0,0 +1,33 @@
+class Question < ApplicationRecord
+ acts_as_list
+
+ # The attributes that should be delegated to the translated class:
+ translates :question, :content, :is_translated, :content_sanitizer_version
+ translation_class.include(Globalized)
+
+ # Ignore the screencast_sanitizer_version field until it can be deleted:
+ translation_class.ignored_columns = [:screencast_sanitizer_version]
+
+ belongs_to :archive_faq
+
+ validates_presence_of :question, before: :create
+ validates_presence_of :anchor, before: :create
+ validates_presence_of :content, before: :create
+
+ validates_length_of :content, minimum: ArchiveConfig.CONTENT_MIN,
+ too_short: ts('must be at least %{min} letters long.',
+ min: ArchiveConfig.CONTENT_MIN)
+
+ validates_length_of :content, maximum: ArchiveConfig.CONTENT_MAX,
+ too_long: ts('cannot be more than %{max} characters
+ long.',
+ max: ArchiveConfig.CONTENT_MAX)
+
+ scope :in_order, -> { order(:position) }
+
+ # Change the positions of the questions in the
+ def self.reorder(positions)
+ SortableList.new(self.find(:all, order: 'position ASC')).
+ reorder_list(positions)
+ end
+end
diff --git a/app/models/rating.rb b/app/models/rating.rb
new file mode 100755
index 0000000..e9fd6d8
--- /dev/null
+++ b/app/models/rating.rb
@@ -0,0 +1,24 @@
+class Rating < Tag
+ validates :canonical, presence: { message: "^Only canonical rating tags are allowed." }
+
+ NAME = ArchiveConfig.RATING_CATEGORY_NAME
+
+ def self.label_name
+ to_s
+ end
+
+ # Gives us the default ratings as Not Rated + low to high
+ def self.defaults_by_severity
+ ratings = [ArchiveConfig.RATING_DEFAULT_TAG_NAME,
+ ArchiveConfig.RATING_GENERAL_TAG_NAME,
+ ArchiveConfig.RATING_TEEN_TAG_NAME,
+ ArchiveConfig.RATING_MATURE_TAG_NAME,
+ ArchiveConfig.RATING_EXPLICIT_TAG_NAME]
+ ratings_by_id = Rating.where(name: ratings).inject({}) do |result, rating|
+ result[rating.name] = rating
+ result
+ end
+ ratings.map { |id| ratings_by_id[id] }
+ end
+
+end
diff --git a/app/models/reading.rb b/app/models/reading.rb
new file mode 100755
index 0000000..08d1dca
--- /dev/null
+++ b/app/models/reading.rb
@@ -0,0 +1,52 @@
+class Reading < ApplicationRecord
+ belongs_to :user
+ belongs_to :work
+
+ after_save :expire_cached_home_marked_for_later, if: :saved_change_to_toread?
+ after_destroy :expire_cached_home_marked_for_later, if: :toread?
+
+ scope :visible, -> { left_joins(:work).merge(Work.visible_to_registered_user.or(Work.where(id: nil))) }
+
+ # called from show in work controller
+ def self.update_or_create(work, user)
+ if user && user.preference.try(:history_enabled) && !user.is_author_of?(work)
+ reading_json = [user.id, Time.now, work.id, work.major_version, work.minor_version, false].to_json
+ REDIS_GENERAL.sadd("Reading:new", reading_json)
+ end
+ end
+
+ # called from reading controller
+ def self.mark_to_read_later(work, user, toread)
+ reading = Reading.find_or_initialize_by(work_id: work.id, user_id: user.id)
+ reading.major_version_read = work.major_version
+ reading.minor_version_read = work.minor_version
+ reading.last_viewed = Time.now
+ reading.toread = toread
+ reading.save
+ end
+
+ # create a reading object, but only if the user has reading
+ # history enabled and is not the author of the work
+ def self.reading_object(user_id, time, work_id, major_version, minor_version, later)
+ reading = Reading.find_or_initialize_by(work_id: work_id, user_id: user_id)
+
+ # Only update the view time/version number if it's newer:
+ if reading.last_viewed.nil? || reading.last_viewed < time
+ reading.last_viewed = time
+ reading.major_version_read = major_version
+ reading.minor_version_read = minor_version
+ end
+
+ reading.view_count = reading.view_count + 1 unless later
+ reading.save
+ reading
+ end
+
+ private
+
+ def expire_cached_home_marked_for_later
+ unless Rails.env.development?
+ Rails.cache.delete("home/index/#{user_id}/home_marked_for_later")
+ end
+ end
+end
diff --git a/app/models/redis_hit_counter.rb b/app/models/redis_hit_counter.rb
new file mode 100755
index 0000000..9fdb17a
--- /dev/null
+++ b/app/models/redis_hit_counter.rb
@@ -0,0 +1,131 @@
+# A class for keeping track of hits/IP addresses in redis. Writes the values in
+# redis to the database when the HitCountUpdateJobs run.
+class RedisHitCounter
+ class << self
+ include RedisScanning
+
+ # Records a hit for the given IP address on the given work ID. If the IP
+ # address hasn't visited the work within the current 24 hour block, we
+ # increment the work's hit count. Otherwise, we do nothing.
+ def add(work_id, ip_address)
+ key = "visits:#{current_timestamp}"
+ visit = "#{work_id}:#{ip_address}"
+
+ # Add the (work ID, IP address) pair to the set for this date.
+ added_visit = redis.sadd(key, visit)
+
+ # If trying to add the (work ID, IP address) pair resulted in sadd
+ # returning true, we know that the user hasn't visited this work
+ # recently. So we increment the count of recent hits.
+ redis.hincrby(:recent_counts, work_id, 1) if added_visit
+ end
+
+ # Go through the list of all keys starting with "visits:", compute the
+ # timestamp from the key, and delete the sets associated with any
+ # timestamps from before today.
+ def remove_old_visits
+ # NOTE: It's perfectly safe to convert the timestamp to an integer to be
+ # able to compare with other timestamps, as we're doing here. However,
+ # the integers shouldn't be used in any other way (e.g. subtraction,
+ # addition, etc.) since they won't behave the way you'd expect dates to.
+ last_timestamp = current_timestamp.to_i
+
+ redis.scan_each(match: "visits:*", count: batch_size) do |key|
+ _, timestamp = key.split(":")
+
+ next unless timestamp.to_i < last_timestamp
+
+ enqueue_remove_set(key)
+ end
+ end
+
+ protected
+
+ # Remove the set at the given key.
+ #
+ # Deletion technique adapted from:
+ # https://www.redisgreen.net/blog/deleting-large-sets/
+ def enqueue_remove_set(key)
+ garbage_key = make_garbage_key
+
+ # In order to make sure that we're not simultaneously adding to the set
+ # and deleting it, we rename it.
+ redis.rename(key, garbage_key)
+
+ # We use async to perform the deletion because we don't want to lose
+ # track of our garbage. If the key we're removing is in Resque, and the
+ # job fails, it'll be retried until it succeeds.
+ async(:remove_set, garbage_key)
+ end
+
+ # Scan through the given set and delete it batch-by-batch.
+ def remove_set(key)
+ scan_set_in_batches(redis, key, batch_size: batch_size) do |batch|
+ redis.srem(key, batch)
+ end
+ end
+
+ # Constructs an all-new key to use for deleting sets:
+ def make_garbage_key
+ "garbage:#{redis.incr('garbage:index')}"
+ end
+
+ public
+
+ # The redis instance that we want to use for hit counts. We use a namespace
+ # so that we can use simpler key names throughout this class.
+ def redis
+ @redis ||= Redis::Namespace.new(
+ "hit_count",
+ redis: REDIS_HITS
+ )
+ end
+
+ # Take the current time (offset by the rollover hour) and convert it to a
+ # date. We use this date as part of the key for storing which IP addresses
+ # have viewed a work recently.
+ def current_timestamp
+ (Time.now.utc - rollover_hour.hours).to_date.strftime("%Y%m%d")
+ end
+
+ # The hour (in UTC time) that we want the hit counts to rollover at. If
+ # someone views the work shortly before this hour and shortly after, it
+ # counts as two hits.
+ def rollover_hour
+ ArchiveConfig.HIT_COUNT_ROLLOVER_HOUR
+ end
+
+ # The size of the batches to be retrieved from redis.
+ def batch_size
+ ArchiveConfig.HIT_COUNT_BATCH_SIZE
+ end
+
+ # Check whether the work should allow hits at the moment:
+ def prevent_hits?(work)
+ work.nil? ||
+ work.in_unrevealed_collection ||
+ work.hidden_by_admin ||
+ !work.posted
+ end
+
+ ####################
+ # DELAYED JOBS
+ ####################
+
+ # The default queue to use when enqueuing Resque jobs in this class:
+ def queue
+ :utilities
+ end
+
+ # This will be called by a worker when it's trying to perform a delayed
+ # task. Calls the passed-in class method with the passed-in arguments.
+ def perform(method, *args)
+ send(method, *args)
+ end
+
+ # Queue up a method to be called later.
+ def async(method, *args)
+ Resque.enqueue(self, method, *args)
+ end
+ end
+end
diff --git a/app/models/redis_mail_queue.rb b/app/models/redis_mail_queue.rb
new file mode 100755
index 0000000..c40684d
--- /dev/null
+++ b/app/models/redis_mail_queue.rb
@@ -0,0 +1,104 @@
+class RedisMailQueue
+ # queue a kudo notification in redis
+ # we create a separate list in redis for each author and work to be notified on
+ # and store the names of each kudo'er in that list ("guest" for guest kudos)
+ def self.queue_kudo(author, kudo)
+ key = "kudos_#{author.id}_#{kudo.commentable_type}_#{kudo.commentable_id}"
+ REDIS_KUDOS.rpush(key, kudo.name)
+ REDIS_KUDOS.sadd("kudos_#{author.id}", key)
+ REDIS_KUDOS.sadd("notification_kudos", author.id)
+ end
+
+ # batch and deliver all the outstanding kudo notifications
+ # this should be called from schedule.rb at some regular interval
+ def self.deliver_kudos
+ author_list = to_notify("kudos")
+
+ author_list.each do |author_id|
+ user_kudos = {}
+ keys, = REDIS_KUDOS.multi do |redis|
+ kudos_list = "kudos_#{author_id}"
+ redis.smembers(kudos_list)
+ redis.del(kudos_list)
+ end
+ keys.each do |key|
+ # atomically get the info and then delete the key
+ guest_count, names, = REDIS_KUDOS.multi do
+ REDIS_KUDOS.lrem(key, 0, "guest")
+ REDIS_KUDOS.lrange(key, 0, -1)
+ REDIS_KUDOS.del(key)
+ end
+
+ # get the commentable
+ _prefix, _author, commentable_type, commentable_id = key.split("_")
+
+ # batch it
+ user_kudos["#{commentable_type}_#{commentable_id}"] = { names: names, guest_count: guest_count }
+ end
+
+ next if user_kudos.blank?
+
+ # queue the notification for delivery
+ begin
+ # don't die if we hit one deleted user
+ I18n.with_locale(User.find(author_id).preference.locale_for_mails) do
+ KudoMailer.batch_kudo_notification(author_id, user_kudos.to_json).deliver_later
+ end
+ rescue StandardError
+ # TODO: this should be reported to monitoring software so it can be used in analysis and alerting.
+ # However, we likely want this moved to ApplicationJob from its current Rake home first.
+ end
+ end
+ end
+
+ # queue a subscription notification in redis
+ # we create a separate list in redis for each subscriber and subscription to be notified on
+ # and store the creation type and id in that set
+ def self.queue_subscription(subscription, creation)
+ key = "subscription_#{subscription.id}"
+ entry = "#{creation.class.name}_#{creation.id}"
+ REDIS_GENERAL.rpush(key, entry)
+ REDIS_GENERAL.sadd("notification_subscription", subscription.id)
+ end
+
+ # batch and deliver all the outstanding subscription notifications
+ # this should be called from schedule.rb at some regular interval
+ def self.deliver_subscriptions
+ subscription_list = to_notify("subscription")
+ subscription_list.each do |subscription_id|
+ key = "subscription_#{subscription_id}"
+ entries, = REDIS_GENERAL.multi do
+ REDIS_GENERAL.lrange(key, 0, -1)
+ REDIS_GENERAL.del(key)
+ end
+ begin
+ # don't die if we hit one deleted subscription
+ UserMailer.batch_subscription_notification(subscription_id, entries.to_json).deliver_later
+ rescue ActiveRecord::RecordNotFound
+ # never rescue all errors
+ end
+ end
+ end
+
+ def self.clear_queue(notification_type)
+ redis = redis_for_type(notification_type)
+ keys = redis.keys("#{notification_type}_*")
+ redis.del(*keys) unless keys.empty?
+ redis.del("notification_#{notification_type}")
+ end
+
+ # Return and empty the list of users to be notified for a given type of notification
+ def self.to_notify(notification_type)
+ redis = redis_for_type(notification_type)
+ # atomically get all the users to notify and then delete the list
+ list, = redis.multi do
+ redis.smembers("notification_#{notification_type}")
+ redis.del "notification_#{notification_type}"
+ end
+ list
+ end
+
+ def self.redis_for_type(notification_type)
+ notification_type == "kudos" ? REDIS_KUDOS : REDIS_GENERAL
+ end
+end
diff --git a/app/models/related_work.rb b/app/models/related_work.rb
new file mode 100755
index 0000000..97d7dc4
--- /dev/null
+++ b/app/models/related_work.rb
@@ -0,0 +1,61 @@
+class RelatedWork < ActiveRecord::Base
+ belongs_to :work
+ belongs_to :parent, polymorphic: true, autosave: true
+
+ attribute :url, :string
+ attribute :title, :string
+ attribute :author, :string
+ attribute :language_id, :integer
+
+ scope :posted, -> {
+ joins("INNER JOIN `works` `child_works` ON `child_works`.`id` = `related_works`.`work_id`").
+ where("child_works.posted = 1")
+ }
+
+ before_validation :set_parent, if: :new_record?
+ def set_parent
+ return if parent
+
+ if url.include?(ArchiveConfig.APP_HOST)
+ if url.match(%r{/works/(\d+)})
+ self.parent = Work.find_by(id: Regexp.last_match(1))
+ else
+ errors.add(:parent, :not_work)
+ throw :abort # don't generate any further errors
+ end
+ else
+ self.parent = ExternalWork.find_or_initialize_by(
+ url: url,
+ title: title,
+ author: author,
+ language_id: language_id
+ )
+ end
+ end
+
+ validates :parent, presence: true, on: :create
+
+ validate :check_parent_protected, on: :create
+ def check_parent_protected
+ return unless parent.respond_to?(:users)
+ return if parent.anonymous? || parent.unrevealed?
+
+ parent.users.each do |user|
+ errors.add(:parent, :protected, login: user.login) if user.protected_user
+ end
+ end
+
+ def notify_parent_owners
+ if parent.respond_to?(:pseuds)
+ users = parent.pseuds.collect(&:user).uniq
+ orphan_account = User.orphan_account
+ users.each do |user|
+ unless user == orphan_account
+ I18n.with_locale(user.preference.locale_for_mails) do
+ UserMailer.related_work_notification(user.id, self.id).deliver_after_commit
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/relationship.rb b/app/models/relationship.rb
new file mode 100755
index 0000000..9b0a17d
--- /dev/null
+++ b/app/models/relationship.rb
@@ -0,0 +1,43 @@
+class Relationship < Tag
+
+ NAME = ArchiveConfig.RELATIONSHIP_CATEGORY_NAME
+
+ # Types of tags to which a relationship tag can belong via common taggings or meta taggings
+ def parent_types
+ ['Fandom', 'Character', 'MetaTag']
+ end
+ def child_types
+ ['SubTag', 'Merger']
+ end
+
+ def characters
+ parents.by_type('Character').by_name
+ end
+
+ def all_characters
+ all = self.characters
+ if self.merger
+ all << self.merger.characters
+ end
+ all_with_synonyms = all.flatten.uniq.compact
+ all_with_synonyms << all_with_synonyms.collect{|c| c.mergers}
+ all_with_synonyms.flatten.uniq.compact
+ end
+
+ def relationships
+ (parents + children).select {|t| t.is_a? Relationship}.sort
+ end
+
+ def freeforms
+ children.by_type('Freeform').by_name
+ end
+
+ def fandoms
+ parents.by_type('Fandom').by_name
+ end
+
+ def medias
+ parents.by_type('Media').by_name
+ end
+
+end
diff --git a/app/models/request.rb b/app/models/request.rb
new file mode 100755
index 0000000..5ec7013
--- /dev/null
+++ b/app/models/request.rb
@@ -0,0 +1,7 @@
+class Request < Prompt
+ belongs_to :challenge_signup, touch: true, inverse_of: :requests
+
+ def prompt_restriction
+ collection&.challenge&.request_restriction
+ end
+end
diff --git a/app/models/role.rb b/app/models/role.rb
new file mode 100755
index 0000000..137eeea
--- /dev/null
+++ b/app/models/role.rb
@@ -0,0 +1,11 @@
+# Defines named roles for users that may be applied to
+# objects in a polymorphic fashion. For example, you could create a role
+# "moderator" for an instance of a model (i.e., an object), a model class,
+# or without any specification at all.
+class Role < ApplicationRecord
+ has_many :roles_users
+ has_many :users, through: :roles_users
+ belongs_to :authorizable, polymorphic: true
+
+ scope :assignable, -> { where(authorizable_id: nil, authorizable_type: nil) }
+end
diff --git a/app/models/roles_user.rb b/app/models/roles_user.rb
new file mode 100755
index 0000000..a49bfd3
--- /dev/null
+++ b/app/models/roles_user.rb
@@ -0,0 +1,38 @@
+class RolesUser < ApplicationRecord
+ belongs_to :user
+ belongs_to :role
+
+ delegate :enqueue_to_index, to: :user
+
+ after_create :log_role_addition
+ after_create :enqueue_to_index
+ after_destroy :log_role_removal
+ after_destroy :destroy_last_wrangling_activity
+ after_destroy :enqueue_to_index
+
+ def log_role_addition
+ admin = User.current_user
+ note = "Change made by #{admin&.login}"
+ user.create_log_item({ admin_id: admin&.id,
+ action: ArchiveConfig.ACTION_ADD_ROLE,
+ note: note,
+ role_id: role_id })
+ end
+
+ def log_role_removal
+ admin = User.current_user
+ note = "Change made by #{admin&.login}"
+ user.create_log_item({ admin_id: admin&.id,
+ action: ArchiveConfig.ACTION_REMOVE_ROLE,
+ note: note,
+ role_id: role_id })
+ end
+
+ # After removing the tag_wrangler role, remove the
+ # user's last wrangling activity as well.
+ def destroy_last_wrangling_activity
+ return unless role.name == "tag_wrangler"
+
+ user.last_wrangling_activity&.destroy
+ end
+end
diff --git a/app/models/scheduled_tag_job.rb b/app/models/scheduled_tag_job.rb
new file mode 100755
index 0000000..80fb430
--- /dev/null
+++ b/app/models/scheduled_tag_job.rb
@@ -0,0 +1,10 @@
+class ScheduledTagJob
+ def self.perform(job_type)
+ case job_type
+ when 'add_counts_to_queue'
+ Tag.where("taggings_count_cache > ?", 40 * (ArchiveConfig.TAGGINGS_COUNT_CACHE_DIVISOR || 1500)).each do |tag|
+ tag.async(:update_counts_cache, tag.id)
+ end
+ end
+ end
+end
diff --git a/app/models/search/async_indexer.rb b/app/models/search/async_indexer.rb
new file mode 100755
index 0000000..f96e2f1
--- /dev/null
+++ b/app/models/search/async_indexer.rb
@@ -0,0 +1,68 @@
+class AsyncIndexer
+ REDIS = REDIS_GENERAL
+
+ ####################
+ # CLASS METHODS
+ ####################
+
+ def self.perform(name)
+ # TODO: Keep the method so we can still run queued jobs from previous
+ # versions. However, tests should no longer depend on it.
+ #
+ # Remove in a future version, once all old jobs have been retried or
+ # cleared.
+ raise "Avoid using AsyncIndexer.perform in tests" if Rails.env.test?
+
+ indexer = name.split(":").first.constantize
+ ids = REDIS.smembers(name)
+
+ return if ids.empty?
+
+ batch = indexer.new(ids).index_documents
+ IndexSweeper.new(batch, indexer).process_batch
+ REDIS.del(name)
+ end
+
+ # Get the appropriate indexers for the class and pass the ids off to them
+ # This method is only called internally and klass is not a user-supplied value
+ def self.index(klass, ids, priority)
+ if klass.to_s =~ /Indexer/
+ indexers = [klass]
+ else
+ klass = klass.constantize if klass.respond_to?(:constantize)
+ indexers = klass.new.indexers
+ end
+ indexers.each do |indexer|
+ self.new(indexer, priority).enqueue_ids(ids)
+ end
+ end
+
+ ####################
+ # INSTANCE METHODS
+ ####################
+
+ attr_reader :indexer, :priority
+
+ # Just standardizing priority/queue names
+ def initialize(indexer, priority)
+ @indexer = indexer
+ @priority = case priority.to_s
+ when "main"
+ "high"
+ when "background"
+ "low"
+ else
+ priority
+ end
+ end
+
+ def enqueue_ids(ids)
+ name = "#{indexer}:#{ids.first}:#{Time.now.to_i}"
+ REDIS.sadd(name, ids)
+ AsyncIndexerJob.set(queue: queue).perform_later(name)
+ end
+
+ def queue
+ "reindex_#{priority}"
+ end
+end
diff --git a/app/models/search/bookmark_indexer.rb b/app/models/search/bookmark_indexer.rb
new file mode 100755
index 0000000..7cd3682
--- /dev/null
+++ b/app/models/search/bookmark_indexer.rb
@@ -0,0 +1,113 @@
+class BookmarkIndexer < Indexer
+
+ def self.klass
+ "Bookmark"
+ end
+
+ # Create the bookmarkable index/mapping first
+ # Skip delete on the subclasses so it doesn't delete the ones we've just
+ # reindexed
+ def self.index_all(options = {})
+ unless options[:skip_delete]
+ options[:skip_delete] = true
+ BookmarkableIndexer.delete_index
+ BookmarkableIndexer.create_index(shards: ArchiveConfig.BOOKMARKABLE_SHARDS)
+ create_mapping
+ end
+ BookmarkedExternalWorkIndexer.index_all(skip_delete: true)
+ BookmarkedSeriesIndexer.index_all(skip_delete: true)
+ BookmarkedWorkIndexer.index_all(skip_delete: true)
+ super
+ end
+
+ def self.mapping
+ {
+ properties: {
+ bookmarkable_join: {
+ type: "join",
+ relations: {
+ bookmarkable: "bookmark"
+ }
+ },
+ title: {
+ type: "text",
+ analyzer: "simple"
+ },
+ creators: {
+ type: "text",
+ analyzer: "standard"
+ },
+ work_types: {
+ type: "keyword"
+ },
+ bookmarkable_type: {
+ type: "keyword"
+ },
+ bookmarker: {
+ type: "text",
+ analyzer: "standard"
+ },
+ tag: {
+ type: "text",
+ analyzer: "simple"
+ },
+ sort_id: {
+ type: "keyword"
+ }
+ }
+ }
+ end
+
+ ####################
+ # INSTANCE METHODS
+ ####################
+
+ def routing_info(id)
+ object = objects[id.to_i]
+ {
+ "_index" => index_name,
+ "_id" => id,
+ "routing" => parent_id(id, object)
+ }
+ end
+
+ def parent_id(id, object)
+ if object.nil?
+ deleted_bookmark_info(id)
+ else
+ "#{object.bookmarkable_id}-#{object.bookmarkable_type.underscore}"
+ end
+ end
+
+ def document(object)
+ tags = object.tags
+ json_object = object.as_json(
+ root: false,
+ only: [
+ :id, :created_at, :bookmarkable_type, :bookmarkable_id,
+ :private, :updated_at, :hidden_by_admin, :pseud_id, :rec
+ ],
+ methods: [:bookmarker, :collection_ids, :with_notes, :bookmarkable_date]
+ ).merge(
+ user_id: object.pseud&.user_id,
+ tag: tags.map(&:name),
+ tag_ids: tags.map(&:id),
+ notes: object.bookmarker_notes
+ )
+
+ unless parent_id(object.id, object).match("deleted")
+ json_object.merge!(
+ bookmarkable_join: {
+ name: "bookmark",
+ parent: parent_id(object.id, object)
+ }
+ )
+ end
+
+ json_object
+ end
+
+ def deleted_bookmark_info(id)
+ REDIS_GENERAL.get("deleted_bookmark_parent_#{id}")
+ end
+end
diff --git a/app/models/search/bookmark_query.rb b/app/models/search/bookmark_query.rb
new file mode 100755
index 0000000..0ef43e3
--- /dev/null
+++ b/app/models/search/bookmark_query.rb
@@ -0,0 +1,364 @@
+class BookmarkQuery < Query
+ attr_accessor :bookmarkable_query
+
+ def klass
+ 'Bookmark'
+ end
+
+ def index_name
+ BookmarkIndexer.index_name
+ end
+
+ def document_type
+ BookmarkIndexer.document_type
+ end
+
+ # Load the options and create the linked BookmarkableQuery class (which is
+ # used to generate all of our parent filters).
+ def initialize(options = {})
+ @options = HashWithIndifferentAccess.new(options)
+ add_owner
+ self.bookmarkable_query = BookmarkableQuery.new(self)
+ end
+
+ # Combine the filters and queries for both the bookmark and the bookmarkable.
+ def filtered_query
+ make_bool(
+ # Score is based on our query + the bookmarkable query:
+ must: make_list(queries, bookmarkable_queries_and_filters),
+ filter: filters,
+ must_not: make_list(exclusion_filters, bookmarkable_exclusion_filters)
+ )
+ end
+
+ # Queries that apply only to the bookmark. Bookmarkable queries are handled
+ # in filtered_query, and should not be included here.
+ def queries
+ @queries ||= make_list(
+ general_query
+ )
+ end
+
+ # Filters that apply only to the bookmark. Bookmarkable filters are handled
+ # in filtered_query, and should not be included here.
+ def filters
+ @filters ||= make_list(
+ privacy_filter,
+ hidden_filter,
+ bookmarks_only_filter,
+ pseud_filter,
+ user_filter,
+ rec_filter,
+ notes_filter,
+ tags_filter,
+ named_tag_inclusion_filter,
+ collections_filter,
+ type_filter,
+ date_filter
+ )
+ end
+
+ # Exclusion filters that apply only to the bookmark. Exclusion filters for
+ # the bookmarkable are handled in filtered_query, and should not be included
+ # here.
+ def exclusion_filters
+ @exclusion_filters ||= make_list(
+ tag_exclusion_filter,
+ named_tag_exclusion_filter
+ )
+ end
+
+ def add_owner
+ owner = options[:parent]
+ field = case owner
+ when Tag
+ # Note that in a bookmark search for a Tag owner, we want to return
+ # the bookmarkables, not bookmarks, with that tag.
+ # This field will be handled in the linked BookmarkableQuery.
+ :filter_ids
+ when Pseud
+ :pseud_ids
+ when User
+ :user_ids
+ when Collection
+ :collection_ids
+ end
+ return unless field.present?
+ options[field] ||= []
+ options[field] << owner.id
+ end
+
+ ####################
+ # QUERIES
+ ####################
+
+ def general_query
+ return nil if bookmark_query_text.blank?
+
+ { query_string: { query: bookmark_query_text, default_operator: "AND" } }
+ end
+
+ def bookmark_query_text
+ query_text = (options[:bookmark_query] || "").dup
+ query_text << split_query_text_words(:bookmarker, options[:bookmarker])
+ query_text << split_query_text_words(:notes, options[:notes])
+ escape_slashes(query_text.strip)
+ end
+
+ ####################
+ # SORTING AND AGGREGATIONS
+ ####################
+
+ def sort_column
+ @sort_column ||=
+ options[:sort_column].present? ? options[:sort_column] : default_sort
+ end
+
+ def sort_direction
+ @sort_direction ||=
+ options[:sort_direction].present? ? options[:sort_direction] : "desc"
+ end
+
+ def default_sort
+ facet_tags? ? 'created_at' : '_score'
+ end
+
+ def sort
+ sort_hash = { sort_column => { order: sort_direction } }
+
+ if %w(created_at bookmarkable_date).include?(sort_column)
+ sort_hash[sort_column][:unmapped_type] = 'date'
+ end
+
+ [sort_hash, { id: { order: sort_direction } }]
+ end
+
+ # The aggregations for just the bookmarks:
+ def bookmark_aggregations
+ aggs = {}
+
+ if facet_collections?
+ aggs[:collections] = { terms: { field: 'collection_ids' } }
+ end
+
+ if facet_tags?
+ aggs[:tag] = { terms: { field: "tag_ids" } }
+ end
+
+ aggs
+ end
+
+ # Combine the bookmark aggregations with the bookmarkable aggregations from
+ # the bookmarkable query.
+ def aggregations
+ aggs = bookmark_aggregations
+
+ bookmarkable_aggregations = bookmarkable_query.bookmarkable_aggregations
+ if bookmarkable_aggregations.present?
+ aggs[:bookmarkable] = {
+ parent: { type: "bookmark" },
+ aggs: bookmarkable_aggregations
+ }
+ end
+
+ { aggs: aggs } if aggs.present?
+ end
+
+ ####################
+ # BOOKMARKABLE
+ ####################
+
+ # Wrap both the queries and the filters from the bookmarkable query into a
+ # single has_parent query. (The fewer has_parent queries we have, the faster
+ # the query will be.)
+ def bookmarkable_queries_and_filters
+ bool = make_bool(
+ must: bookmarkable_query.queries,
+ filter: bookmarkable_query.filters
+ )
+
+ return if bool.nil?
+
+ {
+ has_parent: {
+ parent_type: "bookmarkable",
+ score: true, # include the score from the bookmarkable
+ query: bool
+ }
+ }
+ end
+
+ # Wrap all of the must_not/not filters on the bookmarkable into a single
+ # has_parent query. Note that we wrap them in a should/or query because if
+ # any of the parent queries return true, we want to return false. (De
+ # Morgan's Law.)
+ def bookmarkable_exclusion_filters
+ return if bookmarkable_query.exclusion_filters.blank?
+
+ {
+ has_parent: {
+ parent_type: "bookmarkable",
+ query: make_bool(
+ should: bookmarkable_query.exclusion_filters
+ )
+ }
+ }
+ end
+
+ ####################
+ # FILTERS
+ ####################
+
+ def privacy_filter
+ term_filter(:private, 'false') unless include_private?
+ end
+
+ def hidden_filter
+ term_filter(:hidden_by_admin, 'false')
+ end
+
+ def rec_filter
+ term_filter(:rec, 'true') if %w(1 true).include?(options[:rec].to_s)
+ end
+
+ def notes_filter
+ term_filter(:with_notes, 'true') if %w(1 true).include?(options[:with_notes].to_s)
+ end
+
+ def type_filter
+ term_filter(:bookmarkable_type, options[:bookmarkable_type].gsub(" ", "")) if options[:bookmarkable_type].present?
+ end
+
+ # The date filter on the bookmark (i.e. when the bookmark was created).
+ def date_filter
+ if options[:date].present?
+ { range: { created_at: SearchRange.parsed(options[:date]) } }
+ end
+ end
+
+ def pseud_filter
+ if options[:pseud_ids].present?
+ terms_filter(:pseud_id, options[:pseud_ids].flatten.uniq)
+ end
+ end
+
+ def user_filter
+ return unless options[:user_ids].present?
+ options[:user_ids].flatten.uniq.map { |user_id| term_filter(:user_id, user_id) }
+ end
+
+ def tags_filter
+ if included_bookmark_tag_ids.present?
+ included_bookmark_tag_ids.map { |tag_id| term_filter(:tag_ids, tag_id) }
+ end
+ end
+
+ def collections_filter
+ terms_filter(:collection_ids, options[:collection_ids]) if options[:collection_ids].present?
+ end
+
+ def tag_exclusion_filter
+ terms_filter(:tag_ids, excluded_bookmark_tag_ids) if excluded_bookmark_tag_ids.present?
+ end
+
+ # We don't want to accidentally return Bookmarkable documents when we're
+ # doing a search for Bookmarks. So we should only include documents that are
+ # marked as "bookmark" in their bookmarkable_join field.
+ def bookmarks_only_filter
+ term_filter(:bookmarkable_join, "bookmark")
+ end
+
+ # This filter is used to restrict our results to only include bookmarks whose
+ # "tag" text matches all of the tag names in included_bookmark_tag_names.
+ # This is useful when the user enters a non-existent tag, which would be
+ # discarded by the included_bookmark_tag_ids function.
+ def named_tag_inclusion_filter
+ return if included_bookmark_tag_names.blank?
+ match_filter(:tag, included_bookmark_tag_names.join(" "))
+ end
+
+ # This set of filters is used to prevent us from matching any bookmarks
+ # whose "tag" text matches one of the passed-in tag names. This is useful
+ # when the user enters a non-existent tag, which would be discarded by the
+ # excluded_bookmark_tag_ids function.
+ #
+ # Unlike the inclusion filter, we separate the queries to make sure that with
+ # tags "A B" and "C D", we're searching for "not(A and B) and not(C and D)",
+ # instead of "not(A and B and C and D)" or "not(A or B or C or D)".
+ def named_tag_exclusion_filter
+ excluded_bookmark_tag_names.map do |tag_name|
+ match_filter(:tag, tag_name)
+ end
+ end
+
+ ####################
+ # HELPERS
+ ####################
+
+ def facet_tags?
+ options[:faceted]
+ end
+
+ def facet_collections?
+ false
+ end
+
+ def include_private?
+ # Use fetch instead of || here to make sure that we don't accidentally
+ # override a deliberate choice not to show private bookmarks.
+ options.fetch(:show_private,
+ User.current_user.is_a?(User) &&
+ user_ids.include?(User.current_user.id))
+ end
+
+ def user_ids
+ user_ids = []
+ if options[:user_ids].present?
+ user_ids += options[:user_ids].map(&:to_i)
+ end
+ if options[:pseud_ids].present?
+ user_ids += Pseud.where(id: options[:pseud_ids]).pluck(:user_id)
+ end
+ user_ids
+ end
+
+ # The list of all tag IDs that should be required for our bookmarks.
+ def included_bookmark_tag_ids
+ @included_bookmark_tag_ids ||= [
+ options[:tag_ids],
+ parsed_included_tags[:ids]
+ ].flatten.compact.uniq
+ end
+
+ # The list of all tag IDs that should be prohibited for our bookmarks.
+ def excluded_bookmark_tag_ids
+ @excluded_bookmark_tag_ids ||= [
+ options[:excluded_bookmark_tag_ids],
+ parsed_excluded_tags[:ids]
+ ].flatten.compact.uniq
+ end
+
+ # The list of included tag names that weren't found in the database (and thus
+ # have to be used as text-matching constraints on the tag field).
+ def included_bookmark_tag_names
+ parsed_included_tags[:missing]
+ end
+
+ # The list of excluded tag names that weren't found in the database (and thus
+ # have to be used as text-matching constraints on the tag field).
+ def excluded_bookmark_tag_names
+ parsed_excluded_tags[:missing]
+ end
+
+ # Parse the tag names that should be included in our results.
+ def parsed_included_tags
+ @parsed_included_tags ||=
+ bookmarkable_query.parse_named_tags(%i[other_bookmark_tag_names])
+ end
+
+ # Parse the tag names that should be excluded from our results.
+ def parsed_excluded_tags
+ @parsed_excluded_tags ||=
+ bookmarkable_query.parse_named_tags(%i[excluded_bookmark_tag_names])
+ end
+end
diff --git a/app/models/search/bookmark_search_form.rb b/app/models/search/bookmark_search_form.rb
new file mode 100755
index 0000000..3e4c69b
--- /dev/null
+++ b/app/models/search/bookmark_search_form.rb
@@ -0,0 +1,200 @@
+class BookmarkSearchForm
+
+ extend ActiveModel::Naming
+ include ActiveModel::Conversion
+ include ActiveModel::Validations
+
+ ATTRIBUTES = [
+ :bookmark_query,
+ :bookmarkable_query,
+ :rec,
+ :bookmark_notes,
+ :with_notes,
+ :date,
+ :show_private,
+ :pseud_ids,
+ :user_ids,
+ :bookmarker,
+ :bookmarkable_pseud_names,
+ :bookmarkable_pseud_ids,
+ :bookmarkable_type,
+ :language_id,
+ :excluded_tag_names,
+ :excluded_bookmark_tag_names,
+ :excluded_tag_ids,
+ :excluded_bookmark_tag_ids,
+ :other_tag_names,
+ :other_bookmark_tag_names,
+ :tag_ids,
+ :filter_ids,
+ :filter_names,
+ :fandom_ids,
+ :character_ids,
+ :relationship_ids,
+ :freeform_ids,
+ :rating_ids,
+ :archive_warning_ids,
+ :category_ids,
+ :bookmarkable_title,
+ :bookmarkable_date,
+ :bookmarkable_complete,
+ :collection_ids,
+ :bookmarkable_collection_ids,
+ :sort_column,
+ :show_restricted,
+ :page,
+ :faceted
+ ]
+
+ attr_accessor :options
+
+ ATTRIBUTES.each do |filterable|
+ define_method(filterable) { options[filterable] }
+ end
+
+ def initialize(options = {})
+ @options = processed_options(options)
+ @searcher = BookmarkQuery.new(@options)
+ end
+
+ def persisted?
+ false
+ end
+
+ # This is used by SearchHelper.search_header.
+ def query
+ queries = []
+ %w[bookmarkable_query bookmark_query].each do |key|
+ queries << options[key] if options[key].present?
+ end
+ queries.join(', ')
+ end
+
+ def summary
+ summary = []
+ %w[bookmarkable_query bookmark_query].each do |key|
+ if options[key].present?
+ summary << options[key]
+ end
+ end
+ if options[:bookmarker].present?
+ summary << "Bookmarker: #{options[:bookmarker]}"
+ end
+ if options[:notes].present?
+ summary << "Notes: #{options[:notes]}"
+ end
+ tags = []
+ %w[other_tag_names other_bookmark_tag_names].each do |key|
+ if options[key].present?
+ tags << options[key]
+ end
+ end
+ all_tag_ids = []
+ [:filter_ids, :fandom_ids, :rating_ids, :category_ids, :archive_warning_ids, :character_ids, :relationship_ids, :freeform_ids].each do |tag_ids|
+ if options[tag_ids].present?
+ all_tag_ids += options[tag_ids]
+ end
+ end
+ unless all_tag_ids.empty?
+ tags << Tag.where(id: all_tag_ids).pluck(:name).join(", ")
+ end
+ unless tags.empty?
+ summary << "Tags: #{tags.uniq.join(", ")}"
+ end
+ if self.bookmarkable_type.present?
+ summary << "Type: #{self.bookmarkable_type}"
+ end
+ if self.language_id.present?
+ language = Language.find_by(short: self.language_id)
+ if language.present?
+ summary << "Work language: #{language.name}"
+ end
+ end
+ if %w(1 true).include?(self.rec.to_s)
+ summary << "Rec"
+ end
+ if %w(1 true).include?(self.with_notes.to_s)
+ summary << "With Notes"
+ end
+ [:date, :bookmarkable_date].each do |countable|
+ if options[countable].present?
+ desc = (countable == :date) ? "Date bookmarked" : "Date updated"
+ summary << "#{desc}: #{options[countable]}"
+ end
+ end
+ summary.join(", ")
+ end
+
+ def search_results
+ @searcher.search_results
+ end
+
+ # Special function for returning the BookmarkableQuery results (instead of
+ # the BookmarkQuery results).
+ def bookmarkable_search_results
+ @searcher.bookmarkable_query.search_results
+ end
+
+ ###############
+ # SORTING
+ ###############
+
+ def sort_options
+ [
+ ['Date Bookmarked', 'created_at'],
+ ['Date Updated', 'bookmarkable_date'],
+ ]
+ end
+
+ def sort_values
+ sort_options.map{ |option| option.last }
+ end
+
+ def sort_direction(sort_column)
+ 'desc'
+ end
+
+ private
+
+ def processed_options(opts = {})
+ [:date, :bookmarkable_date].each do |countable|
+ if opts[countable].present?
+ opts[countable] = opts[countable].gsub(">", ">").
+ gsub("<", "<")
+ end
+ end
+
+ # If we call the form field 'notes', the parser adds html to it
+ opts[:notes] = opts[:bookmark_notes]
+
+ opts = standardize_language_ids(opts)
+
+ # Support legacy warning searches
+ if opts[:warning_ids].present?
+ opts[:archive_warning_ids] = opts.delete(:warning_ids)
+ end
+
+ # We need to respect some options that are deliberately set to false, and
+ # false.blank? is true, so we check for nil? and not blank? here.
+ opts.delete_if { |_, v| v.nil? }
+ end
+
+ # Maintain backward compatibility for old bookmark searches/filters:
+ def standardize_language_ids(opts)
+ # - Using language IDs in the "Work language" dropdown
+ if opts[:language_id].present? && opts[:language_id].to_i != 0
+ language = Language.find_by(id: opts[:language_id])
+ opts[:language_id] = language.short if language.present?
+ end
+
+ # - Using language IDs in "Any field on work" (search) or "Search within results" (filters)
+ if opts[:bookmarkable_query].present?
+ opts[:bookmarkable_query] = opts[:bookmarkable_query].gsub(/\blanguage_id\s*:\s*(\d+)/) do
+ lang = Language.find_by(id: Regexp.last_match[1])
+ lang = Language.default if lang.blank?
+ "language_id: " + lang.short
+ end
+ end
+ opts
+ end
+end
diff --git a/app/models/search/bookmarkable_indexer.rb b/app/models/search/bookmarkable_indexer.rb
new file mode 100755
index 0000000..e6734fc
--- /dev/null
+++ b/app/models/search/bookmarkable_indexer.rb
@@ -0,0 +1,37 @@
+class BookmarkableIndexer < Indexer
+
+ def self.index_name
+ "#{ArchiveConfig.ELASTICSEARCH_PREFIX}_#{Rails.env}_bookmarks"
+ end
+
+ def self.document_type
+ 'bookmark'
+ end
+
+ def self.mapping
+ BookmarkIndexer.mapping
+ end
+
+ # When we fail, we don't want to just keep adding the -klass suffix.
+ def self.find_elasticsearch_ids(ids)
+ ids.map(&:to_i)
+ end
+
+ def routing_info(id)
+ {
+ "_index" => index_name,
+ "_id" => document_id(id)
+ }
+ end
+
+ def document(object)
+ object.bookmarkable_json.merge(
+ sort_id: document_id(object.id)
+ )
+ end
+
+ def document_id(id)
+ "#{id}-#{klass.underscore}"
+ end
+
+end
diff --git a/app/models/search/bookmarkable_query.rb b/app/models/search/bookmarkable_query.rb
new file mode 100755
index 0000000..19105a3
--- /dev/null
+++ b/app/models/search/bookmarkable_query.rb
@@ -0,0 +1,287 @@
+class BookmarkableQuery < Query
+ include TaggableQuery
+
+ attr_accessor :bookmark_query
+
+ # Rather than compute this information twice, we rely on the BookmarkQuery
+ # class to calculate information about sorting.
+ delegate :sort_column, :sort_direction,
+ to: :bookmark_query
+
+ # The "klass" function here returns the class name used to load search
+ # results. The BookmarkableQuery is unique among Query classes because it can
+ # return objects from more than one table, so we need to use a special class
+ # that can handle IDs of multiple types.
+ def klass
+ 'BookmarkableDecorator'
+ end
+
+ def index_name
+ BookmarkableIndexer.index_name
+ end
+
+ def document_type
+ BookmarkableIndexer.document_type
+ end
+
+ # The BookmarkableQuery is unique among queries in that it depends wholly on
+ # the BookmarkQuery for all of its options. So we have a slightly different
+ # constructor.
+ def initialize(bookmark_query)
+ self.bookmark_query = bookmark_query
+ @options = bookmark_query.options
+ end
+
+ # Combine the filters and queries for both the bookmark and the bookmarkable.
+ def filtered_query
+ make_bool(
+ # All queries/filters/exclusion filters for the bookmark are wrapped in a
+ # single has_child query by the bookmark_filter function:
+ must: bookmark_filter,
+ # We never sort by score, so we can always ignore the score on our
+ # queries, grouping them together with our filters. (Note, however, that
+ # the bookmark search can incorporate our score, so there is a
+ # distinction between queries and filters -- just not in this function.)
+ filter: make_list(queries, filters),
+ must_not: exclusion_filters
+ )
+ end
+
+ # Queries that apply only to the bookmarkable. Bookmark queries are handled
+ # in filtered_query, and should not be included here.
+ def queries
+ @queries ||= make_list(
+ general_query
+ )
+ end
+
+ # Filters that apply only to the bookmarkable. Bookmark filters are handled
+ # in filtered_query, and should not be included here.
+ def filters
+ @filters ||= make_list(
+ complete_filter,
+ language_filter,
+ filter_id_filter,
+ named_tag_inclusion_filter,
+ date_filter
+ )
+ end
+
+ # Exclusion filters that apply only to the bookmarkable. Exclusion filters
+ # for the bookmark are handled in filtered_query, and should not be included
+ # here.
+ def exclusion_filters
+ @exclusion_filters ||= make_list(
+ unposted_filter,
+ hidden_filter,
+ restricted_filter,
+ tag_exclusion_filter,
+ named_tag_exclusion_filter
+ )
+ end
+
+ ####################
+ # QUERIES
+ ####################
+
+ def general_query
+ return nil if bookmarkable_query_text.blank?
+
+ { query_string: { query: bookmarkable_query_text, default_operator: "AND" } }
+ end
+
+ def bookmarkable_query_text
+ query_text = (options[:bookmarkable_query] || "").dup
+ escape_slashes(query_text.strip)
+ end
+
+ ####################
+ # SORTING AND AGGREGATIONS
+ ####################
+
+ # When sorting by bookmarkable date, we use the revised_at field to order the
+ # results. When sorting by created_at, we use _score to sort (because the
+ # only way to sort by a child's fields is to store the value in the _score
+ # field and sort by score).
+ def sort
+ if sort_column == "bookmarkable_date"
+ sort_hash = { revised_at: { order: sort_direction, unmapped_type: "date" } }
+ else
+ sort_hash = { _score: { order: sort_direction } }
+ end
+
+ [sort_hash, { sort_id: { order: sort_direction } }]
+ end
+
+ # Define the aggregations for just the bookmarkable. This is combined with
+ # the bookmark's aggregations below.
+ def bookmarkable_aggregations
+ aggs = {}
+
+ if bookmark_query.facet_tags?
+ %w[rating archive_warning category fandom character relationship freeform].each do |facet_type|
+ aggs[facet_type] = {
+ terms: {
+ field: "#{facet_type}_ids"
+ }
+ }
+ end
+ end
+
+ aggs
+ end
+
+ # Combine the bookmarkable aggregations with the bookmark aggregations from
+ # the bookmark query.
+ def aggregations
+ aggs = bookmarkable_aggregations
+
+ bookmark_aggregations = bookmark_query.bookmark_aggregations
+ if bookmark_aggregations.present?
+ aggs[:bookmarks] = {
+ # Aggregate on our child bookmarks.
+ children: { type: "bookmark" },
+ aggs: {
+ filtered_bookmarks: {
+ filter: bookmark_bool,
+ aggs: bookmark_aggregations
+ }
+ }
+ }
+ end
+
+ { aggs: aggs } if aggs.present?
+ end
+
+ ####################
+ # BOOKMARKS
+ ####################
+
+ # Create a single has_child query with ALL of the child's queries and filters
+ # included. In order to avoid issues with multiple bookmarks combining to
+ # create an (incorrect) bookmarkable match, there MUST be exactly one
+ # has_child query. (Plus, it probably makes it faster.)
+ def bookmark_filter
+ bool = bookmark_bool
+
+ # If we're sorting by created_at, we actually need to fetch the bookmarks'
+ # created_at as the score of this query, so that we can sort by score (and
+ # therefore by the bookmarks' created_at).
+ bool = field_value_score("created_at", bool) if sort_column == "created_at"
+
+ {
+ has_child: {
+ type: "bookmark",
+ score_mode: "max",
+ query: bool,
+ inner_hits: {
+ size: inner_hits_size,
+ sort: { created_at: { order: "desc", unmapped_type: "date" } }
+ }
+ }
+ }
+ end
+
+ # The bool used in the has_child query and to filter the bookmark
+ # aggregations. Contains all of the constraints on bookmarks, and no
+ # constraints on bookmarkables.
+ def bookmark_bool
+ make_bool(
+ must: bookmark_query.queries,
+ filter: bookmark_query.filters,
+ must_not: bookmark_query.exclusion_filters
+ )
+ end
+
+ ####################
+ # FILTERS
+ ####################
+
+ def complete_filter
+ term_filter(:complete, 'true') if options[:complete].present?
+ end
+
+ def language_filter
+ term_filter(:"language_id.keyword", options[:language_id]) if options[:language_id].present?
+ end
+
+ def filter_id_filter
+ if filter_ids.present?
+ filter_ids.map { |filter_id| term_filter(:filter_ids, filter_id) }
+ end
+ end
+
+ # The date filter on the bookmarkable (i.e. when the bookmarkable was last
+ # updated).
+ def date_filter
+ if options[:bookmarkable_date].present?
+ { range: { revised_at: SearchRange.parsed(options[:bookmarkable_date]) } }
+ end
+ end
+
+ # Exclude drafts from bookmarkable search results.
+ # Note that this is used as an exclusion filter, not an inclusion filter, so
+ # the boolean is flipped from the way you might expect.
+ def unposted_filter
+ term_filter(:posted, 'false')
+ end
+
+ # Exclude items hidden by admin from bookmarkable search results.
+ # Note that this is used as an exclusion filter, not an inclusion filter, so
+ # the boolean is flipped from the way you might expect.
+ def hidden_filter
+ term_filter(:hidden_by_admin, 'true')
+ end
+
+ # Exclude restricted works/series when the user isn't logged in.
+ # Note that this is used as an exclusion filter, not an inclusion filter, so
+ # the boolean is flipped from the way you might expect.
+ def restricted_filter
+ term_filter(:restricted, 'true') unless include_restricted?
+ end
+
+ def tag_exclusion_filter
+ if exclusion_ids.present?
+ terms_filter(:filter_ids, exclusion_ids)
+ end
+ end
+
+ # This filter is used to restrict our results to only include bookmarkables
+ # whose "tag" text matches all of the tag names in included_tag_names. This
+ # is useful when the user enters a non-existent tag, which would be discarded
+ # by the TaggableQuery.filter_ids function.
+ def named_tag_inclusion_filter
+ return if included_tag_names.blank?
+ match_filter(:tag, included_tag_names.join(" "))
+ end
+
+ # This set of filters is used to prevent us from matching any bookmarkables
+ # whose "tag" text matches one of the passed-in tag names. This is useful
+ # when the user enters a non-existent tag, which would be discarded by the
+ # TaggableQuery.exclusion_ids function.
+ #
+ # Note that we separate these into different filters to get the logic of tag
+ # exclusion right: if we're excluding "A B" and "C D", we want the query to
+ # be "not(A and B) and not(C and D)", which can't be accomplished in a single
+ # match query.
+ def named_tag_exclusion_filter
+ excluded_tag_names.map do |tag_name|
+ match_filter(:tag, tag_name)
+ end
+ end
+
+ ####################
+ # HELPERS
+ ####################
+
+ # The number of bookmarks to return with each bookmarkable.
+ def inner_hits_size
+ ArchiveConfig.NUMBER_OF_BOOKMARKS_SHOWN_PER_BOOKMARKABLE || 5
+ end
+
+ def include_restricted?
+ # Use fetch instead of || here to make sure that we don't accidentally
+ # override a deliberate choice not to show restricted bookmarks.
+ options.fetch(:show_restricted, User.current_user.present?)
+ end
+end
diff --git a/app/models/search/bookmarked_external_work_indexer.rb b/app/models/search/bookmarked_external_work_indexer.rb
new file mode 100755
index 0000000..39ea759
--- /dev/null
+++ b/app/models/search/bookmarked_external_work_indexer.rb
@@ -0,0 +1,5 @@
+class BookmarkedExternalWorkIndexer < BookmarkableIndexer
+ def self.klass
+ "ExternalWork"
+ end
+end
diff --git a/app/models/search/bookmarked_series_indexer.rb b/app/models/search/bookmarked_series_indexer.rb
new file mode 100755
index 0000000..0b86d4b
--- /dev/null
+++ b/app/models/search/bookmarked_series_indexer.rb
@@ -0,0 +1,5 @@
+class BookmarkedSeriesIndexer < BookmarkableIndexer
+ def self.klass
+ "Series"
+ end
+end
diff --git a/app/models/search/bookmarked_work_indexer.rb b/app/models/search/bookmarked_work_indexer.rb
new file mode 100755
index 0000000..3ae772b
--- /dev/null
+++ b/app/models/search/bookmarked_work_indexer.rb
@@ -0,0 +1,18 @@
+class BookmarkedWorkIndexer < BookmarkableIndexer
+ def self.klass
+ "Work"
+ end
+
+ def self.klass_with_includes
+ Work.includes(
+ :approved_collections,
+ :direct_filters,
+ :external_author_names,
+ :filters,
+ :language,
+ :tags,
+ :users,
+ pseuds: :user
+ )
+ end
+end
diff --git a/app/models/search/index_sweeper.rb b/app/models/search/index_sweeper.rb
new file mode 100755
index 0000000..865d8cb
--- /dev/null
+++ b/app/models/search/index_sweeper.rb
@@ -0,0 +1,111 @@
+class IndexSweeper
+
+ REDIS = AsyncIndexer::REDIS
+
+ def self.async_cleanup(klass, expected_ids, found_ids)
+ deleted_ids = expected_ids.map(&:to_i).select { |id| !found_ids.include?(id) }
+
+ if deleted_ids.any?
+ AsyncIndexer.index(klass, deleted_ids, "cleanup")
+ end
+ end
+
+ def initialize(batch, indexer)
+ @batch = batch
+ @indexer = indexer
+ @success_ids = []
+ @rerun_ids = []
+ end
+
+ def process_batch
+ return if @batch.nil?
+
+ load_errors
+
+ @batch["items"].each do |item|
+ process_document(item)
+ end
+
+ save_errors
+
+ if @success_ids.present? && @indexer.respond_to?(:handle_success)
+ @indexer.handle_success(@success_ids)
+ end
+
+ if @rerun_ids.any?
+ AsyncIndexer.new(@indexer, "failures").enqueue_ids(
+ @indexer.find_elasticsearch_ids(@rerun_ids)
+ )
+ end
+ end
+
+ # Returns a list of all permanent failures associated with the given indexer.
+ # Used for testing purposes. (Can be used for diagnostic purposes, as well.)
+ def self.permanent_failures(indexer)
+ failures = []
+
+ REDIS.hgetall("#{indexer}:failures").each_pair do |id, value|
+ JSON.parse(value).each do |info|
+ if info["count"] >= 3
+ failures << { id.to_s => info["error"] }
+ end
+ end
+ end
+
+ failures
+ end
+
+ private
+
+ # Calculate which IDs were included in this batch.
+ def batch_ids
+ @batch_ids ||= @batch["items"].map { |item| item.values.first["_id"].to_s }
+ end
+
+ # Load information about previous errors for all the items in this batch.
+ def load_errors
+ @errors = REDIS.mapped_hmget("#{@indexer}:failures", *batch_ids)
+ @errors.transform_values! { |value| JSON.parse(value || "[]") }
+ end
+
+ # Save information about all the errors for all the items in this batch.
+ def save_errors
+ return unless @errors.present?
+
+ # Clear out the blank errors.
+ blank = @errors.select { |_, v| v.blank? }.keys
+ REDIS.hdel("#{@indexer}:failures", blank) if blank.present?
+
+ # Save the items with non-blank errors.
+ present = @errors.select { |_, v| v.present? }
+ present.transform_values!(&:to_json)
+ REDIS.mapped_hmset("#{@indexer}:failures", present) if present.present?
+ end
+
+ def process_document(item)
+ document = item[item.keys.first] # update/index/delete
+ id = document["_id"]
+
+ if document["error"]
+ if add_error(id, document["error"]) < 3
+ @rerun_ids << id
+ end
+ else
+ @errors[id.to_s].clear
+ @success_ids << id
+ end
+ end
+
+ # Add an error for the given document ID. Return the total number of times
+ # that error has occurred.
+ def add_error(id, error)
+ @errors[id.to_s].each do |info|
+ next unless info["error"] == error
+ return info["count"] += 1
+ end
+
+ # The error hasn't been seen before, so we need to add it with a count of 1.
+ @errors[id.to_s] << { "error" => error, "count" => 1 }
+ 1 # we return the count
+ end
+end
diff --git a/app/models/search/indexer.rb b/app/models/search/indexer.rb
new file mode 100755
index 0000000..eb29471
--- /dev/null
+++ b/app/models/search/indexer.rb
@@ -0,0 +1,228 @@
+class Indexer
+ BATCH_SIZE = 1000
+ INDEXERS_FOR_CLASS = {
+ Work: %w[WorkIndexer WorkCreatorIndexer BookmarkedWorkIndexer],
+ Bookmark: %w[BookmarkIndexer],
+ Tag: %w[TagIndexer],
+ Pseud: %w[PseudIndexer],
+ Series: %w[BookmarkedSeriesIndexer],
+ ExternalWork: %w[BookmarkedExternalWorkIndexer],
+ User: %w[UserIndexer]
+ }.freeze
+
+ delegate :klass, :klass_with_includes, :index_name, :document_type, to: :class
+
+ ##################
+ # CLASS METHODS
+ ##################
+
+ def self.klass
+ raise "Must be defined in subclass"
+ end
+
+ def self.klass_with_includes
+ klass.constantize
+ end
+
+ def self.all
+ [
+ BookmarkedExternalWorkIndexer,
+ BookmarkedSeriesIndexer,
+ BookmarkedWorkIndexer,
+ BookmarkIndexer,
+ PseudIndexer,
+ TagIndexer,
+ UserIndexer,
+ WorkIndexer,
+ WorkCreatorIndexer
+ ]
+ end
+
+ # Originally added to allow IndexSweeper to find the Elasticsearch document
+ # ids when they do not match the associated ActiveRecord objects' ids.
+ #
+ # Override in subclasses if necessary.
+ def self.find_elasticsearch_ids(ids)
+ ids
+ end
+
+ def self.delete_index
+ if $elasticsearch.indices.exists(index: index_name)
+ $elasticsearch.indices.delete(index: index_name)
+ end
+ end
+
+ def self.create_index(shards: 5)
+ $elasticsearch.indices.create(
+ index: index_name,
+ body: {
+ settings: {
+ index: {
+ # static settings
+ number_of_shards: shards,
+ # dynamic settings
+ max_result_window: ArchiveConfig.MAX_SEARCH_RESULTS,
+ }
+ }.merge(settings),
+ mappings: mapping,
+ }
+ )
+ end
+
+ def self.prepare_for_testing
+ raise "Wrong environment for test prep!" unless Rails.env.test?
+
+ delete_index
+ # Relevance sorting is unpredictable with multiple shards
+ # and small amounts of data
+ create_index(shards: 1)
+ end
+
+ def self.refresh_index
+ # Refreshes are resource-intensive, we use them only in tests.
+ raise "Wrong environment for index refreshes!" unless Rails.env.test?
+
+ return unless $elasticsearch.indices.exists(index: index_name)
+
+ $elasticsearch.indices.refresh(index: index_name)
+ end
+
+ # Note that the index must exist before you can set the mapping
+ def self.create_mapping
+ $elasticsearch.indices.put_mapping(
+ index: index_name,
+ body: mapping
+ )
+ end
+
+ def self.mapping
+ {
+ properties: {
+ # add properties in subclasses
+ }
+ }
+ end
+
+ def self.settings
+ {
+ analyzer: {
+ custom_analyzer: {
+ # add properties in subclasses
+ }
+ }
+ }
+ end
+
+ def self.index_all(options = {})
+ unless options[:skip_delete]
+ delete_index
+ create_index
+ end
+ index_from_db
+ end
+
+ def self.index_from_db
+ total = (indexables.count / BATCH_SIZE) + 1
+ i = 1
+ indexables.find_in_batches(batch_size: BATCH_SIZE) do |group|
+ puts "Queueing #{klass} batch #{i} of #{total}" unless Rails.env.test?
+ AsyncIndexer.new(self, :world).enqueue_ids(group.map(&:id))
+ i += 1
+ end
+ end
+
+ # Add conditions here
+ def self.indexables
+ klass.constantize
+ end
+
+ def self.index_name
+ "#{ArchiveConfig.ELASTICSEARCH_PREFIX}_#{Rails.env}_#{klass.underscore.pluralize}"
+ end
+
+ def self.document_type
+ klass.underscore
+ end
+
+ # Given a searchable object, what indexers should handle it?
+ # Returns an array of indexers
+ def self.for_object(object)
+ name = object.is_a?(Tag) ? 'Tag' : object.class.to_s
+ (INDEXERS_FOR_CLASS[name.to_sym] || []).map(&:constantize)
+ end
+
+ # Should be called after a batch update, with the IDs that were successfully
+ # updated. Calls successful_reindex on the indexable class.
+ def self.handle_success(ids)
+ if indexables.respond_to?(:successful_reindex)
+ indexables.successful_reindex(ids)
+ end
+ end
+
+ ####################
+ # INSTANCE METHODS
+ ####################
+
+ attr_reader :ids
+
+ def initialize(ids)
+ @ids = ids
+ end
+
+ def objects
+ @objects ||= klass_with_includes.where(id: ids).inject({}) do |h, obj|
+ h.merge(obj.id => obj)
+ end
+ end
+
+ def batch
+ return @batch if @batch
+
+ @batch = []
+ ids.each do |id|
+ object = objects[id.to_i]
+ if object.present?
+ @batch << { index: routing_info(id) }
+ @batch << document(object)
+ else
+ @batch << { delete: routing_info(id) }
+ end
+ end
+ @batch
+ end
+
+ def index_documents
+ return if batch.empty?
+
+ $elasticsearch.bulk(body: batch)
+ end
+
+ def index_document(object)
+ info = {
+ index: index_name,
+ id: document_id(object.id),
+ body: document(object)
+ }
+ if respond_to?(:parent_id)
+ info.merge!(routing: parent_id(object.id, object))
+ end
+ $elasticsearch.index(info)
+ end
+
+ def routing_info(id)
+ {
+ "_index" => index_name,
+ "_id" => id
+ }
+ end
+
+ def document(object)
+ object.as_json(root: false)
+ end
+
+ # can be overriden by our bookmarkable indexers
+ def document_id(id)
+ id
+ end
+
+end
diff --git a/app/models/search/pseud_indexer.rb b/app/models/search/pseud_indexer.rb
new file mode 100755
index 0000000..14e9bd7
--- /dev/null
+++ b/app/models/search/pseud_indexer.rb
@@ -0,0 +1,128 @@
+class PseudIndexer < Indexer
+
+ def self.klass
+ "Pseud"
+ end
+
+ def self.mapping
+ {
+ properties: {
+ name: {
+ type: "text",
+ analyzer: "simple"
+ },
+ # adding extra name field for sorting
+ sortable_name: {
+ type: "keyword"
+ },
+ byline: {
+ type: "text",
+ analyzer: "standard"
+ },
+ user_login: {
+ type: "text",
+ analyzer: "simple"
+ },
+ fandom: {
+ type: "nested"
+ }
+ }
+ }
+ end
+
+ def document(object)
+ object.as_json(
+ root: false,
+ only: [:id, :user_id, :name, :description, :created_at],
+ methods: [
+ :user_login,
+ :byline,
+ :collection_ids
+ ]
+ ).merge(extras(object).as_json)
+ end
+
+ def extras(pseud)
+ work_counts = work_counts(pseud)
+ {
+ sortable_name: pseud.name.downcase,
+ fandoms: fandoms(pseud),
+ general_bookmarks_count: general_bookmarks_count(pseud),
+ public_bookmarks_count: public_bookmarks_count(pseud),
+ general_works_count: work_counts.values.sum,
+ public_works_count: work_counts[false] || 0
+ }
+ end
+
+ private
+
+ def fandoms(pseud)
+ tag_info(pseud, "Fandom")
+ end
+
+ # Produces an array of hashes with the format
+ # [{id: 1, name: "Star Trek", count: 5}]
+ def tag_info(pseud, tag_type)
+ info = []
+ info +=
+ pseud.direct_filters.where(works: countable_works_conditions)
+ .by_type(tag_type).group_by(&:id)
+ .map do |id, tags|
+ {
+ id: id,
+ name: tags.first.name,
+ count: tags.length
+ }
+ end
+ info +=
+ pseud.direct_filters.where(works: countable_works_conditions.merge(restricted: false))
+ .by_type(tag_type).group_by(&:id)
+ .map do |id, tags|
+ {
+ id_for_public: id,
+ name: tags.first.name,
+ count: tags.length
+ }
+ end
+ info
+ end
+
+ # The relation containing all bookmarks that should be included in the count
+ # for logged-in users (when restricted to a particular pseud).
+ def general_bookmarks
+ @general_bookmarks ||=
+ Bookmark.with_missing_bookmarkable
+ .or(Bookmark.with_bookmarkable_visible_to_registered_user)
+ .is_public
+ end
+
+ # The relation containing all bookmarks that should be included in the count
+ # for logged-out users (when restricted to a particular pseud).
+ def public_bookmarks
+ @public_bookmarks ||=
+ Bookmark.with_missing_bookmarkable
+ .or(Bookmark.with_bookmarkable_visible_to_all)
+ .is_public
+ end
+
+ def general_bookmarks_count(pseud)
+ general_bookmarks.merge(pseud.bookmarks).count
+ end
+
+ def public_bookmarks_count(pseud)
+ public_bookmarks.merge(pseud.bookmarks).count
+ end
+
+ def work_counts(pseud)
+ pseud.works.where(countable_works_conditions).group(:restricted).count
+ end
+
+ def countable_works_conditions
+ {
+ posted: true,
+ hidden_by_admin: false,
+ in_anon_collection: false,
+ in_unrevealed_collection: false
+ }
+ end
+end
diff --git a/app/models/search/pseud_query.rb b/app/models/search/pseud_query.rb
new file mode 100755
index 0000000..fd14054
--- /dev/null
+++ b/app/models/search/pseud_query.rb
@@ -0,0 +1,62 @@
+class PseudQuery < Query
+
+ # The "klass" function in the query classes is used only to determine what
+ # type of search results to return (that is, which class the QueryResult
+ # class will call "load_from_elasticsearch" on). Because the Pseud search
+ # should always wrap Pseuds up in a PseudDecorator, we return PseudDecorator
+ # instead of Pseud.
+ def klass
+ 'PseudDecorator'
+ end
+
+ def index_name
+ PseudIndexer.index_name
+ end
+
+ def document_type
+ PseudIndexer.document_type
+ end
+
+ def filters
+ [collection_filter, fandom_filter].flatten.compact
+ end
+
+ def queries
+ [general_query, name_query].compact
+ end
+
+ ###########
+ # FILTERS
+ ###########
+
+ def collection_filter
+ { term: { collection_ids: options[:collection_id] } } if options[:collection_id]
+ end
+
+ def fandom_filter
+ key = User.current_user.present? ? "fandoms.id" : "fandoms.id_for_public"
+ if options[:fandom_ids]
+ options[:fandom_ids].map do |fandom_id|
+ { term: { key => fandom_id } }
+ end
+ end
+ end
+
+ ###########
+ # QUERIES
+ ###########
+
+ def general_query
+ {
+ simple_query_string:{
+ query: escape_reserved_characters(options[:query]),
+ fields: ["byline^5", "name^4", "user_login^2", "description"],
+ default_operator: "AND"
+ }
+ } if options[:query]
+ end
+
+ def name_query
+ { match: { byline: escape_reserved_characters(options[:name]) } } if options[:name]
+ end
+end
diff --git a/app/models/search/pseud_search_form.rb b/app/models/search/pseud_search_form.rb
new file mode 100755
index 0000000..ef2ff37
--- /dev/null
+++ b/app/models/search/pseud_search_form.rb
@@ -0,0 +1,40 @@
+class PseudSearchForm
+
+ extend ActiveModel::Naming
+ include ActiveModel::Conversion
+ include ActiveModel::Validations
+
+ ATTRIBUTES = [
+ :query,
+ :name,
+ :collection_ids,
+ :fandom
+ ]
+
+ attr_accessor :options
+
+ ATTRIBUTES.each do |filterable|
+ define_method(filterable) { options[filterable] }
+ end
+
+ def initialize(options={})
+ @options = options
+ set_fandoms
+ @searcher = PseudQuery.new(@options.delete_if { |_, v| v.blank? })
+ end
+
+ def persisted?
+ false
+ end
+
+ def search_results
+ @searcher.search_results
+ end
+
+ def set_fandoms
+ return unless @options[:fandom].present?
+ names = @options[:fandom].split(',').map(&:squish)
+ @options[:fandom_ids] = Tag.where(name: names).pluck(:id)
+ end
+
+end
diff --git a/app/models/search/query.rb b/app/models/search/query.rb
new file mode 100755
index 0000000..e8a3395
--- /dev/null
+++ b/app/models/search/query.rb
@@ -0,0 +1,272 @@
+class Query
+
+ attr_reader :options
+
+ # Options: page, per_page
+ def initialize(options={})
+ @options = HashWithIndifferentAccess.new(options)
+ end
+
+ def search
+ begin
+ $elasticsearch.search(
+ index: index_name,
+ body: generated_query,
+ track_total_hits: true
+ )
+ rescue Elastic::Transport::Transport::Errors::BadRequest
+ { error: "Your search failed because of a syntax error. Please try again." }
+ end
+ end
+
+ def search_results
+ response = search
+ QueryResult.new(klass, response, { page: page, per_page: per_page })
+ end
+
+ # Perform a count query based on the given options
+ def count
+ $elasticsearch.count(
+ index: index_name,
+ body: { query: generated_query[:query] }
+ )['count']
+ end
+
+ # Retrieve a randomly sampled selection of results:
+ def sample(count: 5)
+ response = $elasticsearch.search(
+ index: index_name,
+ body: {
+ query: {
+ function_score: {
+ query: filtered_query,
+ random_score: {},
+ boost_mode: "replace"
+ }
+ },
+ size: count
+ }
+ )
+
+ QueryResult.new(klass, response, { page: 1, per_page: count })
+ end
+
+ # Perform a specific aggregation:
+ def aggregation_search(aggregation)
+ $elasticsearch.search(
+ index: index_name,
+ body: {
+ query: filtered_query,
+ size: 0, # aggregations only
+ aggs: { aggregation: aggregation }
+ }
+ ).dig("aggregations", "aggregation")
+ end
+
+ # Use a composite aggregation to get all values that a particular field can
+ # take on. Returns a hash mapping from values to counts.
+ def field_values(field_name, batch_size: 100)
+ aggregation = {
+ composite: {
+ size: batch_size,
+ sources: [{ field_value: { terms: { field: field_name } } }]
+ }
+ }
+
+ counts = {}
+
+ loop do
+ results = aggregation_search(aggregation)
+
+ results["buckets"].each do |info|
+ counts[info.dig("key", "field_value")] = info.dig("doc_count")
+ end
+
+ after_key = results["after_key"]
+ return counts if after_key.nil?
+
+ aggregation[:composite][:after] = after_key
+ end
+ end
+
+ # Return (an approximation of) the number of distinct values that a
+ # particular field can take on:
+ def field_count(field_name, precision_threshold: 1000)
+ aggregation_search(
+ cardinality: {
+ field: field_name,
+ precision_threshold: precision_threshold
+ }
+ ).dig("value")
+ end
+
+ # Sort by relevance by default, override in subclasses as necessary
+ def sort
+ { _score: { order: "desc" } }
+ end
+
+ # Search query with filters
+ def generated_query
+ q = {
+ query: filtered_query,
+ size: per_page,
+ from: pagination_offset,
+ sort: sort
+ }
+ if (aggs = aggregations).present?
+ q.merge!(aggs)
+ end
+ q
+ end
+
+ # Combine the filters and queries, with a fallback in case there are no
+ # filters or queries:
+ def filtered_query
+ make_bool(
+ must: queries, # required, score calculated
+ filter: filters, # required, score ignored
+ must_not: exclusion_filters # disallowed, score ignored
+ ) || { match_all: {} }
+ end
+
+ # Define specifics in subclasses
+
+ def filters
+ @filters
+ end
+
+ def term_filter(field, value, options={})
+ { term: options.merge(field => value) }
+ end
+
+ def terms_filter(field, value, options={})
+ { terms: options.merge(field => value) }
+ end
+
+ def exists_filter(field)
+ { exists: { field: field } }
+ end
+
+ # A filter used to match all words in a particular field, most frequently
+ # used for matching non-existent tags. The match query doesn't allow
+ # negation/or/and/wildcards, so it should only be used on fields where the
+ # users are expected to enter, e.g. canonical tags.
+ def match_filter(field, value, options = {})
+ { match: { field => { query: value, operator: "and" }.merge(options) } }
+ end
+
+ # Replaces the existing scores for a query with the value of a field. The
+ # optional value "missing" determines what score value should be used if the
+ # specified field is missing from a document.
+ def field_value_score(field, query, missing: 0)
+ {
+ function_score: {
+ query: query,
+ field_value_factor: {
+ field: field,
+ missing: missing
+ },
+ boost_mode: :replace
+ }
+ }
+ end
+
+ def bool_value(str)
+ %w(true 1 T).include?(str.to_s)
+ end
+
+ def exclusion_filters
+ @exclusion_filters
+ end
+
+ def queries
+ end
+
+ def aggregations
+ end
+
+ def index_name
+ end
+
+ def document_type
+ end
+
+ def per_page
+ options[:per_page] || ArchiveConfig.ITEMS_PER_PAGE
+ end
+
+ # Example: if the limit is 3 results, and we're displaying 2 per page,
+ # disallow pages beyond page 2.
+ def page
+ [
+ options[:page] || 1,
+ (ArchiveConfig.MAX_SEARCH_RESULTS / per_page.to_f).ceil
+ ].min
+ end
+
+ def pagination_offset
+ (page * per_page) - per_page
+ end
+
+ # Only escape if it isn't already escaped
+ def escape_slashes(word)
+ word.gsub(/([^\\])\//) { |s| $1 + '\\/' }
+ end
+
+ def escape_reserved_characters(word)
+ word = escape_slashes(word)
+ word.gsub!('!', '\\!')
+ word.gsub!('+', '\\\\+')
+ word.gsub!('-', '\\-')
+ word.gsub!('?', '\\?')
+ word.gsub!("~", '\\~')
+ word.gsub!("(", '\\(')
+ word.gsub!(")", '\\)')
+ word.gsub!("[", '\\[')
+ word.gsub!("]", '\\]')
+ word.gsub!(':', '\\:')
+ word
+ end
+
+ def split_query_text_phrases(fieldname, text)
+ str = ""
+ return str if text.blank?
+ text.split(",").map(&:squish).each do |phrase|
+ str << " #{fieldname}:\"#{phrase}\""
+ end
+ str
+ end
+
+ def split_query_text_words(fieldname, text)
+ str = ""
+ return str if text.blank?
+ text.split(" ").each do |word|
+ if word.length >= 2 && word[0] == "-"
+ str << " NOT"
+ word.slice!(0)
+ end
+ word = escape_reserved_characters(word)
+ str << " #{fieldname}:#{word}"
+ end
+ str
+ end
+
+ def make_bool(query)
+ query.reject! { |_, value| value.blank? }
+ query[:minimum_should_match] = 1 if query[:should].present?
+
+ if query.empty?
+ nil
+ elsif query.values.flatten.size == 1 && (query[:must] || query[:should])
+ # There's only one clause in our boolean, so we might as well skip the
+ # bool and just require it.
+ query.values.flatten.first
+ else
+ { bool: query }
+ end
+ end
+
+ def make_list(*args)
+ args.flatten.compact
+ end
+end
diff --git a/app/models/search/query_cleaner.rb b/app/models/search/query_cleaner.rb
new file mode 100755
index 0000000..ccd4085
--- /dev/null
+++ b/app/models/search/query_cleaner.rb
@@ -0,0 +1,103 @@
+# This is currently being tested without loading most of Rails
+# so beware of changes that rely on other classes or Rails methods
+class QueryCleaner
+
+ attr_reader :params
+
+ SORT_OPTIONS = [
+ %w[Creator authors_to_sort_on],
+ %w[Title title_to_sort_on],
+ ['Date Posted', 'created_at'],
+ ['Date Updated', 'revised_at'],
+ ['Word Count', 'word_count'],
+ %w[Hits hits],
+ %w[Kudos kudos_count],
+ %w[Comments comments_count],
+ %w[Bookmarks bookmarks_count]
+ ].freeze
+
+ def initialize(params = {})
+ @params = params.dup
+ end
+
+ def clean
+ return params if params[:query].nil? || params[:query].empty?
+ unescape_angle_brackets
+ extract_countables
+ set_sorting
+ add_quotes_to_categories
+ escape_angle_brackets
+ clean_up_query
+
+ params
+ end
+
+ private
+
+ def unescape_angle_brackets
+ params[:query] = params[:query].gsub('>', '>').gsub('<', '<')
+ end
+
+ def escape_angle_brackets
+ params[:query] = params[:query].gsub('>', '>').gsub('<', '<')
+ end
+
+ # extract countable params
+ def extract_countables
+ %w(word kudo comment bookmark hit).each do |term|
+ count_regex = /#{term}s?\s*(?:\_?count)?\s*:?\s*((?:<|>|=|:)\s*\d+(?:\-\d+)?)/i
+ m = params[:query].match(count_regex)
+ next if m.nil?
+ params[:query] = params[:query].gsub(count_regex, '')
+ # pluralize, add _count, convert to symbol
+ term = term.pluralize unless term == 'word'
+ term += '_count' unless term == 'hits'
+ term = term.to_sym
+
+ value = m[1].gsub(/^(:|=)/, '').strip # get rid of : and =
+ # don't overwrite if submitting from advanced search?
+ params[term] = value if params[term].nil? || params[term].empty?
+ end
+ end
+
+ # get sort-by
+ def set_sorting
+ sort_regex = /sort(?:ed)?\s*(?:by)?\s*:?\s*(<|>|=|:)\s*(\w+)\s*(ascending|descending)?/i
+ return unless m = params[:query].match(sort_regex)
+ params[:query] = params[:query].gsub(sort_regex, '')
+ sortdir = m[3] || m[1]
+ # turn word_count or word count or words into just "word" eg
+ sortby = m[2].gsub(/\s*_?count/, '').singularize
+
+ sort_column = SORT_OPTIONS.find { |opt, _| opt =~ /#{sortby}/i }&.last
+ params[:sort_column] = sort_column unless sort_column.nil?
+ params[:sort_direction] = sort_direction(sortdir)
+ end
+
+ def sort_direction(sortdir)
+ if sortdir == '>' || sortdir == 'ascending'
+ 'asc'
+ elsif sortdir == '<' || sortdir == 'descending'
+ 'desc'
+ end
+ end
+
+ # put categories into quotes
+ # don't match if the letters are part of larger words (ie, "Tom/Mark")
+ def add_quotes_to_categories
+ qr = Regexp.new('(?:"|\')?')
+ %w(m/m f/f f/m m/f).each do |cat|
+ cr = Regexp.new("(\\A|\\s)#{qr}#{cat}#{qr}(\\z|\\s)", Regexp::IGNORECASE)
+ params[:query] = params[:query].gsub(cr, " \"#{cat}\" ")
+ end
+ end
+
+ # If we've stripped out everything in the query, null it out
+ def clean_up_query
+ if params[:query] =~ /^\s*$/
+ params[:query] = nil
+ else
+ params[:query] = params[:query].strip
+ end
+ end
+end
diff --git a/app/models/search/query_facet.rb b/app/models/search/query_facet.rb
new file mode 100755
index 0000000..0afb4e2
--- /dev/null
+++ b/app/models/search/query_facet.rb
@@ -0,0 +1,2 @@
+class QueryFacet < Struct.new(:id, :name, :count)
+end
diff --git a/app/models/search/query_result.rb b/app/models/search/query_result.rb
new file mode 100755
index 0000000..1dc7c87
--- /dev/null
+++ b/app/models/search/query_result.rb
@@ -0,0 +1,130 @@
+class QueryResult
+
+ include Enumerable
+
+ attr_reader :klass, :response, :current_page, :per_page, :error, :notice
+
+ def initialize(model_name, response, options={})
+ @klass = model_name.classify.constantize
+ @response = response
+ @current_page = options[:page] || 1
+ @per_page = options[:per_page] || ArchiveConfig.ITEMS_PER_PAGE
+ @error = response[:error]
+ @notice = max_search_results_notice
+ end
+
+ def hits
+ response.dig('hits', 'hits')
+ end
+
+ def items
+ return [] if response[:error]
+ if @items.nil?
+ @items = klass.load_from_elasticsearch(hits, scopes: @scopes)
+ end
+ @items
+ end
+
+ def scope(*args)
+ @scopes ||= []
+ @scopes += args
+ @items = nil # reset the items in case we already loaded them
+ self # for chaining
+ end
+
+ def each(&block)
+ items.each(&block)
+ end
+
+ def empty?
+ items.empty?
+ end
+
+ def size
+ items.size
+ end
+ alias :length :size
+
+ def [](index)
+ items[index]
+ end
+
+ def to_ary
+ items
+ end
+
+ def load_tag_facets(type, info)
+ @facets[type] = []
+ buckets = info["buckets"]
+ ids = buckets.map { |result| result['key'] }
+ tags = Tag.where(id: ids).group_by(&:id)
+ buckets.each do |facet|
+ unless tags[facet['key'].to_i].blank?
+ @facets[type] << QueryFacet.new(facet['key'], tags[facet['key'].to_i].first.name, facet['doc_count'])
+ end
+ end
+ end
+
+ def load_collection_facets(info)
+ @facets["collections"] = []
+ buckets = info["buckets"]
+ ids = buckets.map { |result| result['key'] }
+ collections = Collection.where(id: ids).group_by(&:id)
+ buckets.each do |facet|
+ unless collections[facet['key'].to_i].blank?
+ @facets["collections"] << QueryFacet.new(facet['key'], collections[facet['key'].to_i].first.title, facet['doc_count'])
+ end
+ end
+ end
+
+ def load_facets(aggregations)
+ aggregations.each_pair do |term, results|
+ if Tag::TYPES.include?(term.classify) || term == "tag"
+ load_tag_facets(term, results)
+ elsif term == "collections"
+ load_collection_facets(results)
+ elsif term == "bookmarks"
+ load_facets(results["filtered_bookmarks"])
+ elsif term == "bookmarkable"
+ load_facets(results)
+ end
+ end
+ end
+
+ def facets
+ return if response["aggregations"].nil?
+
+ if @facets.nil?
+ @facets = {}
+ load_facets(response["aggregations"])
+ end
+
+ @facets
+ end
+
+ def total_pages
+ (total_entries / per_page.to_f).ceil rescue 0
+ end
+
+ # For pagination / fetching results.
+ def total_entries
+ [unlimited_total_entries, ArchiveConfig.MAX_SEARCH_RESULTS].min
+ end
+
+ def unlimited_total_entries
+ response.dig('hits', 'total', 'value') || 0
+ end
+
+ def offset
+ (current_page * per_page) - per_page
+ end
+
+ def max_search_results_notice
+ # if we're on the last page of search results AND there are more results than we can show
+ return unless current_page >= total_pages && unlimited_total_entries > total_entries
+ ActionController::Base.helpers.ts("Displaying %{displayed} results out of %{total}. Please use the filters or edit your search to customize this list further.",
+ displayed: total_entries,
+ total: unlimited_total_entries
+ ).html_safe
+ end
+end
diff --git a/app/models/search/search_counts.rb b/app/models/search/search_counts.rb
new file mode 100755
index 0000000..4d60c2d
--- /dev/null
+++ b/app/models/search/search_counts.rb
@@ -0,0 +1,126 @@
+module SearchCounts
+ module_function
+
+ ######################################################################
+ # COUNTS OF ITEMS IN COLLECTIONS
+ ######################################################################
+
+ def collection_works_query(collection)
+ WorkQuery.new(collection_ids: [collection.id],
+ show_restricted: User.current_user.present?)
+ end
+
+ def collection_bookmarks_query(collection)
+ BookmarkQuery.new(collection_ids: [collection.id],
+ show_restricted: User.current_user.present?)
+ end
+
+ def work_count_for_collection(collection)
+ Rails.cache.fetch(collection_cache_key(collection, :works),
+ collection_cache_options) do
+ collection_works_query(collection).count
+ end
+ end
+
+ def bookmarkable_count_for_collection(collection)
+ Rails.cache.fetch(collection_cache_key(collection, :bookmarkables),
+ collection_cache_options) do
+ collection_bookmarks_query(collection).bookmarkable_query.count
+ end
+ end
+
+ def fandom_count_for_collection(collection)
+ Rails.cache.fetch(collection_cache_key(collection, :fandom_count),
+ collection_cache_options) do
+ collection_works_query(collection).field_count(:fandom_ids)
+ end
+ end
+
+ def fandom_ids_for_collection(collection)
+ Rails.cache.fetch(collection_cache_key(collection, :fandom_ids),
+ collection_cache_options) do
+ collection_works_query(collection).field_values(:fandom_ids)
+ end
+ end
+
+ def collection_cache_key(collection, key)
+ "collection_count_#{collection.id}_#{key}_#{logged_in}"
+ end
+
+ ######################################################################
+ # WORK COUNTS FOR USER/PSEUD DASHBOARD
+ ######################################################################
+
+ def work_count_for_user(user)
+ Rails.cache.fetch(work_cache_key(user), dashboard_cache_options) do
+ WorkQuery.new(user_ids: [user.id]).count
+ end
+ end
+
+ def work_count_for_pseud(pseud)
+ Rails.cache.fetch(work_cache_key(pseud), dashboard_cache_options) do
+ WorkQuery.new(pseud_ids: [pseud.id]).count
+ end
+ end
+
+ # If we want to invalidate cached counts whenever the owner (which for
+ # this method can only be a user or a pseud) has a new work, we can use
+ # "#{owner.works_index_cache_key}" instead of "#{owner.model_name.cache_key}_#{owner.id}".
+ # See lib/works_owner.rb.
+ def work_cache_key(owner)
+ "work_count_#{owner.model_name.cache_key}_#{owner.id}_#{logged_in}"
+ end
+
+ ######################################################################
+ # BOOKMARK COUNTS FOR USER/PSEUD DASHBOARD
+ ######################################################################
+
+ def bookmark_count_for_user(user)
+ show_private = User.current_user.is_a?(Admin) || user == User.current_user
+
+ Rails.cache.fetch(bookmark_cache_key(user, show_private), dashboard_cache_options) do
+ BookmarkQuery.new(user_ids: [user.id], show_private: show_private).count
+ end
+ end
+
+ def bookmark_count_for_pseud(pseud)
+ show_private = User.current_user.is_a?(Admin) || pseud.user == User.current_user
+
+ Rails.cache.fetch(bookmark_cache_key(pseud, show_private), dashboard_cache_options) do
+ BookmarkQuery.new(pseud_ids: [pseud.id], show_private: show_private).count
+ end
+ end
+
+ def bookmark_cache_key(owner, show_private)
+ private_status = show_private ? "_private" : ""
+ "bookmark_count_#{owner.model_name.cache_key}_#{owner.id}_#{logged_in}#{private_status}"
+ end
+
+ ######################################################################
+ # USEFUL FUNCTIONS
+ ######################################################################
+
+ def logged_in
+ User.current_user ? :logged_in : :logged_out
+ end
+
+ ######################################################################
+ # CACHE OPTIONS
+ ######################################################################
+
+ # Options for the dashboard (user/pseud) caches.
+ def dashboard_cache_options
+ {
+ expires_in: ArchiveConfig.SECONDS_UNTIL_DASHBOARD_COUNTS_EXPIRE.seconds,
+ race_condition_ttl: 10.seconds
+ }
+ end
+
+ # Options for the collection caches.
+ def collection_cache_options
+ {
+ expires_in: ArchiveConfig.SECONDS_UNTIL_COLLECTION_COUNTS_EXPIRE.seconds,
+ race_condition_ttl: 10.seconds
+ }
+ end
+end
diff --git a/app/models/search/stat_counter_indexer.rb b/app/models/search/stat_counter_indexer.rb
new file mode 100755
index 0000000..398c5a3
--- /dev/null
+++ b/app/models/search/stat_counter_indexer.rb
@@ -0,0 +1,65 @@
+# A class for reindexing work stats.
+
+# Does not inherit from the standard Indexer, because it needs to do updates to
+# existing records rather than creating whole records from scratch.
+class StatCounterIndexer
+ attr_reader :ids
+
+ # Find StatCounter elasticsearch ids (StatCounters are stored by their
+ # associated work_id) from provided StatCounter ActiveRecord object ids.
+ def self.find_elasticsearch_ids(ids)
+ StatCounter.where(work_id: ids).pluck(:id)
+ end
+
+ def initialize(ids)
+ @ids = ids
+ end
+
+ def objects
+ # Since we're updating works, the IDs of the individual stat counters don't
+ # matter very much. If one of the stat counters that we're supposed to
+ # reindex is missing from the database, there's nothing for us to delete --
+ # it would only be destroyed if the corresponding work was destroyed, and
+ # we're not responsible for cleaning up old works. (That's the
+ # WorkIndexer's job.)
+ @objects ||= StatCounter.where(id: ids).to_a
+ end
+
+ def batch
+ return @batch if @batch
+
+ @batch = []
+ objects.each do |object|
+ @batch << { update: routing_info(object) }
+ @batch << document(object)
+ end
+ @batch
+ end
+
+ def index_documents
+ return if batch.empty?
+
+ $elasticsearch.bulk(body: batch)
+ end
+
+ # Use the routing information from the WorkIndexer, since we don't have an
+ # index of our own. And use the work_id rather than our own id.
+ def routing_info(stat_counter)
+ {
+ "_index" => WorkIndexer.index_name,
+ "_id" => stat_counter.work_id
+ }
+ end
+
+ # Since we're doing an update instead of an index, nest the values.
+ def document(stat_counter)
+ {
+ doc: {
+ hits: stat_counter.hit_count,
+ comments_count: stat_counter.comments_count,
+ kudos_count: stat_counter.kudos_count,
+ bookmarks_count: stat_counter.bookmarks_count
+ }
+ }
+ end
+end
diff --git a/app/models/search/tag_indexer.rb b/app/models/search/tag_indexer.rb
new file mode 100755
index 0000000..2dbb890
--- /dev/null
+++ b/app/models/search/tag_indexer.rb
@@ -0,0 +1,90 @@
+class TagIndexer < Indexer
+
+ def self.klass
+ "Tag"
+ end
+
+ def self.mapping
+ {
+ properties: {
+ name: {
+ type: "text",
+ analyzer: "tag_name_analyzer",
+ fields: {
+ exact: {
+ type: "text",
+ analyzer: "exact_tag_analyzer"
+ },
+ keyword: {
+ type: "keyword",
+ normalizer: "keyword_lowercase"
+ }
+ }
+ },
+ tag_type: { type: "keyword" },
+ sortable_name: { type: "keyword" },
+ uses: { type: "integer" },
+ unwrangled: { type: "boolean" }
+ }
+ }
+ end
+
+ def self.settings
+ {
+ analysis: {
+ analyzer: {
+ tag_name_analyzer: {
+ type: "custom",
+ tokenizer: "standard",
+ filter: [
+ "lowercase"
+ ]
+ },
+ exact_tag_analyzer: {
+ type: "custom",
+ tokenizer: "keyword",
+ filter: [
+ "lowercase"
+ ]
+ }
+ },
+ normalizer: {
+ keyword_lowercase: {
+ type: "custom",
+ filter: ["lowercase"]
+ }
+ }
+ }
+ }
+ end
+
+ def document(object)
+ object.as_json(
+ root: false,
+ only: [
+ :id, :name, :sortable_name, :merger_id, :canonical, :created_at,
+ :unwrangleable
+ ]
+ ).merge(
+ has_posted_works: object.has_posted_works?,
+ tag_type: object.type,
+ uses: object.taggings_count_cache,
+ unwrangled: object.unwrangled?
+ ).merge(parent_data(object))
+ end
+
+ # Index parent data for tag wrangling searches
+ def parent_data(tag)
+ data = {}
+ %w(Media Fandom Character).each do |parent_type|
+ if tag.parent_types.include?(parent_type)
+ key = "#{parent_type.downcase}_ids"
+ data[key] = tag.parents.by_type(parent_type).pluck(:id)
+ next if parent_type == "Media"
+ data["pre_#{key}"] = tag.suggested_parent_ids(parent_type)
+ end
+ end
+ data
+ end
+
+end
diff --git a/app/models/search/tag_query.rb b/app/models/search/tag_query.rb
new file mode 100755
index 0000000..05e6bc9
--- /dev/null
+++ b/app/models/search/tag_query.rb
@@ -0,0 +1,161 @@
+class TagQuery < Query
+
+ def klass
+ 'Tag'
+ end
+
+ def index_name
+ TagIndexer.index_name
+ end
+
+ def document_type
+ TagIndexer.document_type
+ end
+
+ def filters
+ [
+ type_filter,
+ wrangling_status_filter,
+ unwrangleable_filter,
+ posted_works_filter,
+ media_filter,
+ fandom_filter,
+ character_filter,
+ suggested_fandom_filter,
+ suggested_character_filter,
+ in_use_filter,
+ unwrangled_filter
+ ].flatten.compact
+ end
+
+ def exclusion_filters
+ [
+ wrangled_filter
+ ].compact
+ end
+
+ def queries
+ [name_query].compact
+ end
+
+ # Tags have a different default per_page value:
+ def per_page
+ options[:per_page] || ArchiveConfig.TAGS_PER_SEARCH_PAGE || 50
+ end
+
+ def sort
+ direction = options[:sort_direction]&.downcase
+ case options[:sort_column]
+ when "taggings_count_cache", "uses"
+ column = "uses"
+ direction ||= "desc"
+ when "created_at"
+ column = "created_at"
+ direction ||= "desc"
+ else
+ column = "name.keyword"
+ direction ||= "asc"
+ end
+ sort_hash = { column => { order: direction } }
+
+ if column == "created_at"
+ sort_hash[column][:unmapped_type] = "date"
+ end
+
+ sort_by_id = { id: { order: direction } }
+
+ return [sort_hash, { "name.keyword" => { order: "asc" } }, sort_by_id] if column == "uses"
+
+ [sort_hash, sort_by_id]
+ end
+
+ ################
+ # FILTERS
+ ################
+
+ def type_filter
+ { term: { tag_type: options[:type] } } if options[:type]
+ end
+
+ def wrangling_status_filter
+ case options[:wrangling_status]
+ when "canonical"
+ term_filter(:canonical, true)
+ when "noncanonical"
+ term_filter(:canonical, false)
+ when "synonymous"
+ [exists_filter("merger_id"), term_filter(:canonical, false)]
+ when "canonical_synonymous"
+ { bool: { should: [exists_filter("merger_id"), term_filter(:canonical, true)] } }
+ when "noncanonical_nonsynonymous"
+ [{ bool: { must_not: exists_filter("merger_id") } }, term_filter(:canonical, false)]
+ end
+ end
+
+ def unwrangleable_filter
+ term_filter(:unwrangleable, bool_value(options[:unwrangleable])) unless options[:unwrangleable].nil?
+ end
+
+ def posted_works_filter
+ term_filter(:has_posted_works, bool_value(options[:has_posted_works])) unless options[:has_posted_works].nil?
+ end
+
+ def media_filter
+ terms_filter(:media_ids, options[:media_ids]) if options[:media_ids]
+ end
+
+ def fandom_filter
+ options[:fandom_ids]&.map { |fandom_id| term_filter(:fandom_ids, fandom_id) }
+ end
+
+ def character_filter
+ terms_filter(:character_ids, options[:character_ids]) if options[:character_ids]
+ end
+
+ def suggested_fandom_filter
+ terms_filter(:pre_fandom_ids, options[:pre_fandom_ids]) if options[:pre_fandom_ids]
+ end
+
+ def suggested_character_filter
+ terms_filter(:pre_character_ids, options[:pre_character_ids]) if options[:pre_character_ids]
+ end
+
+ # Canonical tags are treated as used even if they technically aren't
+ def in_use_filter
+ return if options[:in_use].nil?
+
+ unless options[:in_use]
+ # Check if not used AND not canonical
+ return [term_filter(:uses, 0), term_filter(:canonical, false)]
+ end
+
+ # Check if used OR canonical
+ { bool: { should: [{ range: { uses: { gt: 0 } } }, term_filter(:canonical, true)] } }
+ end
+
+ def unwrangled_filter
+ term_filter(:unwrangled, bool_value(options[:unwrangled])) unless options[:unwrangled].nil?
+ end
+
+ # Filter to only include tags that have no assigned fandom_ids. Checks that
+ # the fandom exists, because this particular filter is included in the
+ # exclusion_filters section.
+ def wrangled_filter
+ exists_filter("fandom_ids") unless options[:wrangled].nil?
+ end
+
+ ################
+ # QUERIES
+ ################
+
+ def name_query
+ return unless options[:name]
+ {
+ query_string: {
+ query: escape_reserved_characters(options[:name]),
+ fields: ["name.exact^2", "name"],
+ default_operator: "and"
+ }
+ }
+ end
+end
diff --git a/app/models/search/tag_search_form.rb b/app/models/search/tag_search_form.rb
new file mode 100755
index 0000000..e2312b7
--- /dev/null
+++ b/app/models/search/tag_search_form.rb
@@ -0,0 +1,78 @@
+class TagSearchForm
+
+ extend ActiveModel::Naming
+ include ActiveModel::Conversion
+ include ActiveModel::Validations
+
+ ATTRIBUTES = [
+ :query,
+ :name,
+ :canonical,
+ :wrangling_status,
+ :fandoms,
+ :type,
+ :created_at,
+ :uses,
+ :sort_column,
+ :sort_direction
+ ].freeze
+
+ attr_accessor :options
+
+ ATTRIBUTES.each do |filterable|
+ define_method(filterable) { options[filterable] }
+ end
+
+ def initialize(options={})
+ @options = options
+ set_fandoms
+ set_wrangling_status
+ @searcher = TagQuery.new(@options.delete_if { |_, v| v.blank? })
+ end
+
+ def persisted?
+ false
+ end
+
+ def search_results
+ @searcher.search_results
+ end
+
+ def set_fandoms
+ return if @options[:fandoms].blank?
+
+ names = @options[:fandoms].split(",").map(&:squish)
+ @options[:fandom_ids] = Tag.where(name: names).pluck(:id)
+ end
+
+ def bool_value(str)
+ %w[true 1 T].include?(str.to_s)
+ end
+
+ def set_wrangling_status
+ return if @options[:canonical].blank?
+
+ # Match old behavior for canonical param
+ @options[:wrangling_status] = bool_value(@options[:canonical]) ? "canonical" : "noncanonical"
+ end
+
+ def sort_columns
+ options[:sort_column] || "name"
+ end
+
+ def sort_direction
+ options[:sort_direction] || default_sort_direction
+ end
+
+ def sort_options
+ [
+ %w[Name name],
+ ["Date Created", "created_at"],
+ %w[Uses uses]
+ ]
+ end
+
+ def default_sort_direction
+ %w[created_at uses].include?(sort_column) ? "desc" : "asc"
+ end
+end
diff --git a/app/models/search/taggable_query.rb b/app/models/search/taggable_query.rb
new file mode 100755
index 0000000..16e1599
--- /dev/null
+++ b/app/models/search/taggable_query.rb
@@ -0,0 +1,82 @@
+# Shared methods for work and bookmarkable queries
+module TaggableQuery
+
+ def filter_ids
+ return @filter_ids if @filter_ids.present?
+ @filter_ids = options[:filter_ids] || []
+ %w(fandom rating archive_warning category character relationship freeform).each do |tag_type|
+ if options["#{tag_type}_ids".to_sym].present?
+ ids = options["#{tag_type}_ids".to_sym]
+ @filter_ids += ids.is_a?(Array) ? ids : [ids]
+ end
+ end
+ @filter_ids += parsed_included_tags[:ids]
+ @filter_ids = @filter_ids.uniq
+ end
+
+ def exclusion_ids
+ return @exclusion_ids if @exclusion_ids.present?
+ return if options[:excluded_tag_names].blank? && options[:excluded_tag_ids].blank?
+
+ ids = options[:excluded_tag_ids] || []
+ ids += parsed_excluded_tags[:ids]
+ @exclusion_ids = ids.uniq.compact
+ end
+
+ # Returns a list of tag names that should be included in all results. Only
+ # returns the ones that aren't in the database, because the ones that are in
+ # the database will be covered by the filter_ids function.
+ def included_tag_names
+ parsed_included_tags[:missing]
+ end
+
+ # Returns parse_named_tags of all of the fields used to include tag names.
+ def parsed_included_tags
+ @parsed_included_tags ||= parse_named_tags(
+ %i[fandom_names character_names relationship_names freeform_names
+ other_tag_names]
+ )
+ end
+
+ # Returns a list of tag names that should be excluded from all results. Only
+ # returns the ones that aren't in the database, because the ones that are in
+ # the database will be covered by the exclusion_ids function.
+ def excluded_tag_names
+ parsed_excluded_tags[:missing]
+ end
+
+ # Returns parse_named_tags of all of the fields used to exclude tag names.
+ def parsed_excluded_tags
+ @parsed_excluded_tags ||= parse_named_tags(%i[excluded_tag_names])
+ end
+
+ # Uses the database to look up all of the tag names listed in the passed-in
+ # fields. Returns a hash with the following format:
+ # {
+ # ids: [1, 2, 3],
+ # missing: ["missing tag name", "other missing"]
+ # }
+ def parse_named_tags(fields)
+ names = all_tag_names(fields)
+ found = if names.present?
+ Tag.where(name: names).pluck(:id, :name)
+ else
+ []
+ end
+
+ {
+ ids: found.map(&:first),
+ missing: (names - found.map(&:second)).uniq
+ }
+ end
+
+ # Parse the options for each of the passed-in fields, treating each one as a
+ # comma-separated list of tags. Returns the list of all tags, with blank and
+ # duplicate tags removed.
+ def all_tag_names(fields)
+ fields.flat_map do |field|
+ next if options[field].blank?
+ options[field].split(",").map(&:squish)
+ end.reject(&:blank?).uniq
+ end
+end
diff --git a/app/models/search/user_indexer.rb b/app/models/search/user_indexer.rb
new file mode 100755
index 0000000..5f1d37e
--- /dev/null
+++ b/app/models/search/user_indexer.rb
@@ -0,0 +1,79 @@
+class UserIndexer < Indexer
+ def self.klass
+ "User"
+ end
+
+ def self.klass_with_includes
+ User.includes(:pseuds, :roles, :audits)
+ end
+
+ def self.index_all(options = {})
+ unless options[:skip_delete]
+ delete_index
+ create_index(shards: ArchiveConfig.USER_SHARDS)
+ end
+ options[:skip_delete] = true
+ super(options)
+ end
+
+ def self.mapping
+ {
+ properties: {
+ login: {
+ type: "keyword",
+ normalizer: "keyword_normalizer"
+ },
+ email: {
+ type: "keyword",
+ normalizer: "keyword_normalizer"
+ },
+ names: {
+ type: "keyword",
+ normalizer: "keyword_normalizer"
+ },
+ all_names: {
+ type: "keyword",
+ normalizer: "keyword_normalizer"
+ },
+ all_emails: {
+ type: "keyword",
+ normalizer: "keyword_normalizer"
+ }
+ }
+ }
+ end
+
+ def self.settings
+ {
+ analysis: {
+ normalizer: {
+ keyword_normalizer: {
+ type: "custom",
+ filter: %w[lowercase asciifolding]
+ }
+ }
+ }
+ }
+ end
+
+ def document(object)
+ object.as_json(
+ root: false,
+ only: [:id, :login, :email, :created_at],
+ methods: [:role_ids]
+ ).merge(extra_info(object))
+ end
+
+ def extra_info(object)
+ names = ([object.login] + object.pseuds.map(&:name)).uniq
+ past_names = object.historic_values("login")
+ past_emails = object.historic_values("email")
+
+ {
+ active: object.active?,
+ names: names,
+ all_names: (names + past_names).uniq,
+ all_emails: ([object.email] + past_emails).uniq
+ }
+ end
+end
diff --git a/app/models/search/user_query.rb b/app/models/search/user_query.rb
new file mode 100755
index 0000000..44a2bcc
--- /dev/null
+++ b/app/models/search/user_query.rb
@@ -0,0 +1,59 @@
+class UserQuery < Query
+ def klass
+ "User"
+ end
+
+ def index_name
+ UserIndexer.index_name
+ end
+
+ def document_type
+ UserIndexer.document_type
+ end
+
+ def filters
+ @filters ||= [
+ id_filter,
+ inactive_filter,
+ role_filter,
+ email_filter,
+ name_filter
+ ].flatten.compact
+ end
+
+ def sort
+ [{ login: { order: :asc } }, { id: { order: :asc } }]
+ end
+
+ ################
+ # FILTERS
+ ################
+
+ def id_filter
+ { term: { id: options[:user_id] } } if options[:user_id].present?
+ end
+
+ def inactive_filter
+ { term: { active: false } } if options[:inactive].present?
+ end
+
+ def role_filter
+ { term: { role_ids: options[:role_id] } } if options[:role_id].present?
+ end
+
+ def name_filter
+ return if options[:name].blank?
+
+ field = options[:search_past].present? ? :all_names : :names
+
+ { wildcard: { field => options[:name] } }
+ end
+
+ def email_filter
+ return if options[:email].blank?
+
+ field = options[:search_past].present? ? :all_emails : :email
+
+ { wildcard: { field => options[:email] } }
+ end
+end
diff --git a/app/models/search/work_creator_indexer.rb b/app/models/search/work_creator_indexer.rb
new file mode 100755
index 0000000..0f9456e
--- /dev/null
+++ b/app/models/search/work_creator_indexer.rb
@@ -0,0 +1,47 @@
+# A class for reindexing private work creator info (info that should not be
+# available during normal searches).
+class WorkCreatorIndexer < Indexer
+ def self.klass
+ "Work"
+ end
+
+ def self.klass_with_includes
+ Work.includes(:pseuds, :users)
+ end
+
+ def self.mapping
+ WorkIndexer.mapping
+ end
+
+ # When we fail, we don't want to just keep adding the -klass suffix.
+ def self.find_elasticsearch_ids(ids)
+ ids.map(&:to_i)
+ end
+
+ def routing_info(id)
+ {
+ "_index" => index_name,
+ "_id" => document_id(id),
+ "routing" => parent_id(id, nil)
+ }
+ end
+
+ def document_id(id)
+ "#{id}-creator"
+ end
+
+ def parent_id(id, _object)
+ id
+ end
+
+ def document(object)
+ {
+ private_user_ids: object.user_ids,
+ private_pseud_ids: object.pseud_ids,
+ creator_join: {
+ name: :creator,
+ parent: object.id
+ }
+ }
+ end
+end
diff --git a/app/models/search/work_indexer.rb b/app/models/search/work_indexer.rb
new file mode 100755
index 0000000..c3b65eb
--- /dev/null
+++ b/app/models/search/work_indexer.rb
@@ -0,0 +1,135 @@
+class WorkIndexer < Indexer
+ def self.klass
+ "Work"
+ end
+
+ def self.klass_with_includes
+ Work.includes(
+ :approved_collections,
+ :direct_filters,
+ :external_author_names,
+ :filters,
+ :language,
+ :stat_counter,
+ :tags,
+ :users,
+ :relationships,
+ fandoms: { meta_tags: :meta_tags, merger: { meta_tags: :meta_tags } },
+ pseuds: :user,
+ serial_works: :series
+ )
+ end
+
+ def self.index_all(options = {})
+ unless options[:skip_delete]
+ delete_index
+ create_index(shards: ArchiveConfig.WORKS_SHARDS)
+ end
+ options[:skip_delete] = true
+ super(options)
+ end
+
+ def self.mapping
+ {
+ properties: {
+ creator_join: {
+ type: :join,
+ relations: { work: :creator }
+ },
+ title: {
+ type: "text",
+ analyzer: "standard"
+ },
+ creators: {
+ type: "text"
+ },
+ tag: {
+ type: "text"
+ },
+ series: {
+ type: "object"
+ },
+ authors_to_sort_on: {
+ type: "keyword"
+ },
+ title_to_sort_on: {
+ type: "keyword"
+ },
+ imported_from_url: {
+ type: "keyword"
+ },
+ work_types: {
+ type: "keyword"
+ },
+ posted: { type: "boolean" },
+ restricted: { type: "boolean" },
+ hidden_by_admin: { type: "boolean" },
+ complete: { type: "boolean" },
+ in_anon_collection: { type: "boolean" },
+ in_unrevealed_collection: { type: "boolean" }
+ }
+ }
+ end
+
+ def document(object)
+ object.as_json(
+ root: false,
+ only: [
+ :id, :expected_number_of_chapters, :created_at, :updated_at,
+ :major_version, :minor_version, :posted, :restricted,
+ :title, :summary, :notes, :word_count, :hidden_by_admin, :revised_at,
+ :title_to_sort_on, :backdate, :endnotes,
+ :imported_from_url, :complete, :work_skin_id, :in_anon_collection,
+ :in_unrevealed_collection,
+ ],
+ methods: [
+ :authors_to_sort_on,
+ :rating_ids,
+ :archive_warning_ids,
+ :category_ids,
+ :fandom_ids,
+ :character_ids,
+ :relationship_ids,
+ :freeform_ids,
+ :filter_ids,
+ :tag,
+ :collection_ids,
+ :hits,
+ :comments_count,
+ :kudos_count,
+ :bookmarks_count,
+ :creators,
+ :crossover,
+ :otp,
+ :work_types,
+ :nonfiction
+ ]
+ ).merge(
+ language_id: object.language&.short,
+ series: series_data(object),
+ creator_join: { name: :work }
+ ).merge(creator_data(object))
+ end
+
+ def creator_data(work)
+ if work.anonymous? || work.unrevealed?
+ {}
+ else
+ {
+ user_ids: work.user_ids,
+ pseud_ids: work.pseud_ids
+ }
+ end
+ end
+
+ # Format the id, title, and position of each series as a hash:
+ def series_data(object)
+ object.serial_works.map do |sw|
+ {
+ id: sw.series_id,
+ title: sw.series&.title,
+ position: sw.position
+ }
+ end
+ end
+end
diff --git a/app/models/search/work_query.rb b/app/models/search/work_query.rb
new file mode 100755
index 0000000..dd6d0ec
--- /dev/null
+++ b/app/models/search/work_query.rb
@@ -0,0 +1,370 @@
+class WorkQuery < Query
+ include TaggableQuery
+
+ def klass
+ 'Work'
+ end
+
+ def index_name
+ WorkIndexer.index_name
+ end
+
+ def document_type
+ WorkIndexer.document_type
+ end
+
+ # Combine the available filters
+ def filters
+ add_owner
+
+ @filters ||= (
+ visibility_filters +
+ work_filters +
+ creator_filters +
+ collection_filters +
+ tag_filters +
+ range_filters
+ ).flatten.compact
+ end
+
+ def exclusion_filters
+ @exclusion_filters ||= [
+ tag_exclusion_filter,
+ named_tag_exclusion_filter
+ ].flatten.compact
+ end
+
+ # Combine the available queries
+ # In this case, name is the only text field
+ def queries
+ @queries = [
+ general_query
+ ].flatten.compact
+ end
+
+ def add_owner
+ owner = options[:works_parent]
+
+ if owner.is_a?(Language)
+ options[:language_id] = owner.short
+ return
+ end
+
+ field = case owner
+ when Tag
+ :filter_ids
+ when Pseud
+ :pseud_ids
+ when User
+ :user_ids
+ when Collection
+ :collection_ids
+ end
+ return unless field.present?
+ options[field] ||= []
+ options[field] << owner.id
+ end
+
+ ####################
+ # GROUPS OF FILTERS
+ ####################
+
+ def visibility_filters
+ [
+ posted_filter,
+ hidden_filter,
+ restricted_filter,
+ unrevealed_filter,
+ anon_filter
+ ]
+ end
+
+ def work_filters
+ [
+ complete_filter,
+ single_chapter_filter,
+ language_filter,
+ crossover_filter,
+ type_filter
+ ]
+ end
+
+ def creator_filters
+ [user_filter, pseud_filter]
+ end
+
+ def collection_filters
+ [collection_filter]
+ end
+
+ def tag_filters
+ [
+ filter_id_filter,
+ named_tag_inclusion_filter
+ ].flatten.compact
+ end
+
+ def range_filters
+ ranges = []
+ [:word_count, :hits, :kudos_count, :comments_count, :bookmarks_count, :revised_at].each do |countable|
+ if options[countable].present?
+ ranges << { range: { countable => SearchRange.parsed(options[countable]) } }
+ end
+ end
+ ranges += [date_range_filter, word_count_filter].compact
+ ranges
+ end
+
+ ####################
+ # FILTERS
+ ####################
+
+ def posted_filter
+ term_filter(:posted, 'true')
+ end
+
+ def hidden_filter
+ term_filter(:hidden_by_admin, 'false')
+ end
+
+ def restricted_filter
+ term_filter(:restricted, 'false') unless include_restricted?
+ end
+
+ def unrevealed_filter
+ term_filter(:in_unrevealed_collection, 'false') unless include_unrevealed?
+ end
+
+ def anon_filter
+ term_filter(:in_anon_collection, 'false') unless include_anon?
+ end
+
+ def complete_filter
+ term_filter(:complete, bool_value(options[:complete])) if options[:complete].present?
+ end
+
+ def single_chapter_filter
+ term_filter(:expected_number_of_chapters, 1) if options[:single_chapter].present?
+ end
+
+ def language_filter
+ term_filter(:"language_id.keyword", options[:language_id]) if options[:language_id].present?
+ end
+
+ def crossover_filter
+ term_filter(:crossover, bool_value(options[:crossover])) if options[:crossover].present?
+ end
+
+ def type_filter
+ terms_filter(:work_type, options[:work_types]) if options[:work_types]
+ end
+
+ def user_filter
+ return if user_ids.blank?
+
+ if viewing_own_collected_works_page?
+ {
+ has_child: {
+ type: "creator",
+ query: terms_filter(:private_user_ids, user_ids)
+ }
+ }
+ else
+ terms_filter(:user_ids, user_ids)
+ end
+ end
+
+ def pseud_filter
+ terms_filter(:pseud_ids, pseud_ids) if pseud_ids.present?
+ end
+
+ def collection_filter
+ terms_filter(:collection_ids, options[:collection_ids]) if options[:collection_ids].present?
+ end
+
+ def filter_id_filter
+ if filter_ids.present?
+ filter_ids.map { |filter_id| term_filter(:filter_ids, filter_id) }
+ end
+ end
+
+ def tag_exclusion_filter
+ if exclusion_ids.present?
+ exclusion_ids.map { |exclusion_id| term_filter(:filter_ids, exclusion_id) }
+ end
+ end
+
+ # This filter is used to restrict our results to only include works
+ # whose "tag" text matches all of the tag names in included_tag_names. This
+ # is useful when the user enters a non-existent tag, which would be discarded
+ # by the TaggableQuery.filter_ids function.
+ def named_tag_inclusion_filter
+ return if included_tag_names.blank?
+ match_filter(:tag, included_tag_names.join(" "))
+ end
+
+ # This set of filters is used to prevent us from matching any works whose
+ # "tag" text matches one of the passed-in tag names. This is useful when the
+ # user enters a non-existent tag, which would be discarded by the
+ # TaggableQuery.exclusion_ids function.
+ #
+ # Unlike the inclusion filter, we must separate these into different match
+ # filters to get the results that we want (that is, excluding "A B" and "C D"
+ # is the same as "not(A and B) and not(C and D)").
+ def named_tag_exclusion_filter
+ excluded_tag_names.map do |tag_name|
+ match_filter(:tag, tag_name)
+ end
+ end
+
+ def date_range_filter
+ return unless options[:date_from].present? || options[:date_to].present?
+ begin
+ range = {}
+ range[:gte] = clamp_search_date(options[:date_from].to_date) if options[:date_from].present?
+ range[:lte] = clamp_search_date(options[:date_to].to_date) if options[:date_to].present?
+ { range: { revised_at: range } }
+ rescue ArgumentError
+ nil
+ end
+ end
+
+ def word_count_filter
+ return unless options[:words_from].present? || options[:words_to].present?
+ range = {}
+ range[:gte] = options[:words_from].delete(",._").to_i if options[:words_from].present?
+ range[:lte] = options[:words_to].delete(",._").to_i if options[:words_to].present?
+ { range: { word_count: range } }
+ end
+
+ ####################
+ # QUERIES
+ ####################
+
+ # Search for a tag by name
+ # Note that fields don't need to be explicitly included in the
+ # field list to be searchable directly (ie, "complete:true" will still work)
+ def general_query
+ input = (options[:q] || options[:query] || "").dup
+ query = generate_search_text(input)
+
+ return {
+ query_string: {
+ query: query,
+ fields: ["creators^5", "title^7", "endnotes", "notes", "summary", "tag", "series.title"],
+ default_operator: "AND"
+ }
+ } unless query.blank?
+ end
+
+ def generate_search_text(query = '')
+ search_text = query
+ %i[title creators].each do |field|
+ search_text << split_query_text_words(field, options[field])
+ end
+
+ if options[:series_titles].present?
+ search_text << split_query_text_words("series.title", options[:series_titles])
+ end
+
+ if options[:collection_ids].blank? && collected?
+ search_text << " collection_ids:*"
+ end
+ escape_slashes(search_text.strip)
+ end
+
+ def sort
+ column = options[:sort_column].present? ? options[:sort_column] : default_sort
+ direction = options[:sort_direction].present? ? options[:sort_direction] : 'desc'
+ sort_hash = { column => { order: direction } }
+
+ if column == 'revised_at'
+ sort_hash[column][:unmapped_type] = 'date'
+ end
+
+ [sort_hash, { id: { order: direction } }]
+ end
+
+ # When searching outside of filters, use relevance instead of date
+ def default_sort
+ facet_tags? || collected? ? 'revised_at' : '_score'
+ end
+
+ def aggregations
+ aggs = {}
+ if collected?
+ aggs[:collections] = { terms: { field: 'collection_ids' } }
+ end
+
+ if facet_tags?
+ %w(rating archive_warning category fandom character relationship freeform).each do |facet_type|
+ aggs[facet_type] = { terms: { field: "#{facet_type}_ids" } }
+ end
+ end
+
+ { aggs: aggs }
+ end
+
+ def works_per_language(languages_count)
+ response = $elasticsearch.search(index: index_name, body: {
+ size: 0,
+ query: filtered_query,
+ aggregations: {
+ languages: {
+ terms: { field: "language_id.keyword", size: languages_count }
+ }
+ }
+ })
+ language_counts = response.dig("aggregations", "languages", "buckets") || []
+ language_counts.map(&:values).to_h
+ end
+
+ ####################
+ # HELPERS
+ ####################
+
+ def facet_tags?
+ options[:faceted]
+ end
+
+ def collected?
+ options[:collected]
+ end
+
+ def viewing_own_collected_works_page?
+ collected? && options[:works_parent].present? &&
+ options[:works_parent] == User.current_user
+ end
+
+ def include_restricted?
+ User.current_user.present? || options[:show_restricted]
+ end
+
+ # Include unrevealed works only if we're on a collection page
+ # OR the collected works page of a user
+ def include_unrevealed?
+ options[:collection_ids].present? || collected?
+ end
+
+ # Include anonymous works if we're not on a user/pseud page
+ # OR if the user is viewing their own collected works
+ def include_anon?
+ (user_ids.blank? && pseud_ids.blank?) ||
+ viewing_own_collected_works_page?
+ end
+
+ def user_ids
+ options[:user_ids]
+ end
+
+ def pseud_ids
+ options[:pseud_ids]
+ end
+
+ # By default, ES6 expects yyyy-MM-dd and can't parse years with 4+ digits.
+ def clamp_search_date(date)
+ return date.change(year: 0) if date.year.negative?
+ return date.change(year: 9999) if date.year > 9999
+ date
+ end
+end
diff --git a/app/models/search/work_search_form.rb b/app/models/search/work_search_form.rb
new file mode 100755
index 0000000..d8ab552
--- /dev/null
+++ b/app/models/search/work_search_form.rb
@@ -0,0 +1,228 @@
+class WorkSearchForm
+
+ extend ActiveModel::Naming
+ include ActiveModel::Conversion
+ include ActiveModel::Validations
+
+ ATTRIBUTES = [
+ :query,
+ :title,
+ :creators,
+ :collected,
+ :faceted,
+ :revised_at,
+ :language_id,
+ :complete,
+ :crossover,
+ :single_chapter,
+ :word_count,
+ :hits,
+ :kudos_count,
+ :bookmarks_count,
+ :comments_count,
+ :pseud_ids,
+ :collection_ids,
+ :tag,
+ :excluded_tag_names,
+ :excluded_tag_ids,
+ :other_tag_names,
+ :filter_ids,
+ :fandom_names,
+ :fandom_ids,
+ :rating_ids,
+ :category_ids,
+ :archive_warning_ids,
+ :character_names,
+ :character_ids,
+ :relationship_names,
+ :relationship_ids,
+ :freeform_names,
+ :freeform_ids,
+ :date_from,
+ :date_to,
+ :words_from,
+ :words_to,
+ :sort_column,
+ :sort_direction,
+ :page
+ ].freeze
+
+ attr_accessor :options
+
+ ATTRIBUTES.each do |filterable|
+ define_method(filterable) { options[filterable] }
+ end
+
+ def initialize(opts={})
+ @options = opts
+ process_options
+ @searcher = WorkQuery.new(@options)
+ end
+
+ def process_options
+ @options.delete_if { |k, v| v == "0" || v.blank? }
+ standardize_creator_queries
+ standardize_language_ids
+ set_sorting
+ clean_up_angle_brackets
+ rename_warning_field
+ end
+
+ # Make the creator/creators change backwards compatible
+ def standardize_creator_queries
+ return unless @options[:query].present?
+ @options[:query] = @options[:query].gsub('creator:', 'creators:')
+ end
+
+ def standardize_language_ids
+ # Maintain backward compatibility for old work searches/filters:
+
+ # - Using language IDs in the "Language" dropdown
+ if @options[:language_id].present? && @options[:language_id].to_i != 0
+ language = Language.find_by(id: options[:language_id])
+ options[:language_id] = language.short if language.present?
+ end
+
+ # - Using language IDs in "Any field" (search) or "Search within results" (filters)
+ if @options[:query].present?
+ @options[:query] = @options[:query].gsub(/\blanguage_id\s*:\s*(\d+)/) do
+ lang = Language.find_by(id: Regexp.last_match[1])
+ lang = Language.default if lang.blank?
+ "language_id: " + lang.short
+ end
+ end
+ end
+
+ def set_sorting
+ @options[:sort_column] ||= default_sort_column
+ @options[:sort_direction] ||= default_sort_direction
+ end
+
+ def clean_up_angle_brackets
+ [:word_count, :hits, :kudos_count, :comments_count, :bookmarks_count, :revised_at, :query].each do |countable|
+ next unless @options[countable].present?
+ str = @options[countable]
+ @options[countable] = str.gsub(">", ">").gsub("<", "<")
+ end
+ end
+
+ def rename_warning_field
+ if @options[:warning_ids].present?
+ @options[:archive_warning_ids] = @options.delete(:warning_ids)
+ end
+ end
+
+ def persisted?
+ false
+ end
+
+ def summary
+ summary = []
+ if @options[:query].present?
+ summary << @options[:query].gsub('creators:', 'creator:')
+ end
+ if @options[:title].present?
+ summary << "Title: #{@options[:title]}"
+ end
+ if @options[:creators].present?
+ summary << "Creator: #{@options[:creators]}"
+ end
+ tags = @searcher.included_tag_names
+ all_tag_ids = @searcher.filter_ids
+ unless all_tag_ids.empty?
+ tags << Tag.where(id: all_tag_ids).pluck(:name).join(", ")
+ end
+ unless tags.empty?
+ summary << "Tags: #{tags.uniq.join(", ")}"
+ end
+ if complete.to_s == "T"
+ summary << "Complete"
+ elsif complete.to_s == "F"
+ summary << "Incomplete"
+ end
+ if crossover.to_s == "T"
+ summary << "Only Crossovers"
+ elsif crossover.to_s == "F"
+ summary << "No Crossovers"
+ end
+ if %w(1 true).include?(self.single_chapter.to_s)
+ summary << "Single Chapter"
+ end
+ if @options[:language_id].present?
+ language = Language.find_by(short: @options[:language_id])
+ if language.present?
+ summary << "Language: #{language.name}"
+ end
+ end
+ [:word_count, :hits, :kudos_count, :comments_count, :bookmarks_count, :revised_at].each do |countable|
+ if @options[countable].present?
+ summary << "#{countable.to_s.humanize.downcase}: #{@options[countable]}"
+ end
+ end
+ if @options[:sort_column].present?
+ # Use pretty name if available, otherwise fall back to plain column name
+ pretty_sort_name = name_for_sort_column(@options[:sort_column])
+ direction = if @options[:sort_direction].present?
+ @options[:sort_direction] == "asc" ? " ascending" : " descending"
+ else
+ ""
+ end
+ summary << ("sort by: #{pretty_sort_name&.downcase || @options[:sort_column]}" + direction)
+ end
+ summary.join(" ")
+ end
+
+ def search_results
+ @searcher.search_results
+ end
+
+ ###############
+ # SORTING
+ ###############
+
+ SORT_OPTIONS = [
+ ["Best Match", "_score"],
+ %w[Creator authors_to_sort_on],
+ %w[Title title_to_sort_on],
+ ["Date Posted", "created_at"],
+ ["Date Updated", "revised_at"],
+ ["Word Count", "word_count"],
+ %w[Hits hits],
+ %w[Kudos kudos_count],
+ %w[Comments comments_count],
+ %w[Bookmarks bookmarks_count]
+ ].freeze
+
+ def sort_columns
+ options[:sort_column] || default_sort_column
+ end
+
+ def sort_direction
+ options[:sort_direction] || default_sort_direction
+ end
+
+ def sort_options
+ options[:faceted] || options[:collected] ? SORT_OPTIONS[1..-1] : SORT_OPTIONS
+ end
+
+ def sort_values
+ sort_options.map{ |option| option.last }
+ end
+
+ # extract the pretty name
+ def name_for_sort_column(sort_column)
+ Hash[SORT_OPTIONS.map { |v| [v[1], v[0]] }][sort_column]
+ end
+
+ def default_sort_column
+ options[:faceted] || options[:collected] ? 'revised_at' : '_score'
+ end
+
+ def default_sort_direction
+ if %w[authors_to_sort_on title_to_sort_on].include?(sort_column)
+ 'asc'
+ else
+ 'desc'
+ end
+ end
+end
diff --git a/app/models/search_range.rb b/app/models/search_range.rb
new file mode 100755
index 0000000..ab28d79
--- /dev/null
+++ b/app/models/search_range.rb
@@ -0,0 +1,118 @@
+class SearchRange
+
+ attr_accessor :text_range
+
+ TIME_REGEX = /^([<>]*)\s*([\d -]+)\s*(year|week|month|day|hour)s?(\s*ago)?\s*$/
+ NUMBER_REGEX = /^([<>]*)\s*([\d,. -]+)\s*$/
+ OUT_OF_RANGE_DATE = 1000.years.ago
+
+ # Takes a string that includes a time or number range and
+ # returns it as a hash that can be used in elasticsearch queries
+ def self.parsed(str)
+ new(str).parse
+ end
+
+ def initialize(str)
+ @text_range = str || ""
+ end
+
+ def parse
+ standardize_text
+ range = {}
+ if match = text_range.match(TIME_REGEX)
+ range = time_range(match[1], match[2], match[3])
+ elsif match = text_range.match(NUMBER_REGEX)
+ range = numerical_range(match[1], match[2].gsub(",", ""))
+ end
+ range
+ end
+
+ private
+
+ def standardize_text
+ @text_range = @text_range.gsub(">", ">").
+ gsub("<", "<").
+ downcase
+ end
+
+
+ # create numerical range from operand and string
+ # operand can be "<", ">" or ""
+ # string must be an integer unless operand is ""
+ # in which case it can be two numbers connected by "-"
+ def numerical_range(operand, string)
+ case operand
+ when "<"
+ { lt: string.to_i }
+ when ">"
+ { gt: string.to_i }
+ when ""
+ match = string.match(/-/)
+ if match
+ { gte: match.pre_match.to_i, lte: match.post_match.to_i }
+ else
+ { gte: string.to_i, lte: string.to_i }
+ end
+ end
+ end
+
+ # create time range from operand, amount and period
+ # period must be one known by time_from_string
+ def time_range(operand, amount, period)
+ case operand
+ when "<"
+ time = time_from_string(amount, period)
+ { gt: time }
+ when ">"
+ time = time_from_string(amount, period)
+ { lt: time }
+ when ""
+ match = amount.match(/-/)
+ if match
+ time1 = time_from_string(match.pre_match, period)
+ time2 = time_from_string(match.post_match, period)
+ { gte: time2, lte: time1 }
+ else
+ range_from_string(amount, period)
+ end
+ end
+ end
+
+ # helper method to create times from two strings
+ # Elasticsearch gets grumpy with negative years, so the simple fix
+ # is just to go back a thousand years
+ # TODO: rework date query formats if Homer ever starts posting his stuff to AO3
+ def time_from_string(amount, period)
+ date = amount.to_i.send(period).ago
+ date.year.negative? ? OUT_OF_RANGE_DATE : date
+ end
+
+ # Generate a range based on one number
+ # Interval is based on period used, ie 1 month ago = range from beginning to end of month
+ def range_from_string(amount, period)
+ case period
+ when /year/
+ a = amount.to_i.year.ago.beginning_of_year
+ a2 = a.end_of_year
+ when /month/
+ a = amount.to_i.month.ago.beginning_of_month
+ a2 = a.end_of_month
+ when /week/
+ a = amount.to_i.week.ago.beginning_of_week
+ a2 = a.end_of_week
+ when /day/
+ a = amount.to_i.day.ago.beginning_of_day
+ a2 = a.end_of_day
+ when /hour/
+ a = amount.to_i.hour.ago.change(min: 0, sec: 0, usec: 0)
+ a2 = (a + 60.minutes)
+ else
+ raise "unknown period: " + period
+ end
+ a, a2 = [a, a2].map do |date|
+ date.year.negative? ? OUT_OF_RANGE_DATE : date
+ end
+ { gte: a, lte: a2 }
+ end
+
+end
diff --git a/app/models/serial_work.rb b/app/models/serial_work.rb
new file mode 100755
index 0000000..ae19798
--- /dev/null
+++ b/app/models/serial_work.rb
@@ -0,0 +1,50 @@
+class SerialWork < ApplicationRecord
+ belongs_to :series, touch: true
+ belongs_to :work, touch: true
+ validates_uniqueness_of :work_id, scope: [:series_id]
+ acts_as_list scope: :series
+
+ after_create :adjust_series_visibility
+ after_destroy :adjust_series_visibility
+ after_destroy :delete_empty_series
+ after_create :update_series_index, :update_work_index
+ after_destroy :update_series_index, :update_work_index
+
+ scope :in_order, -> { order(:position) }
+
+ # If you add or remove a work from a series, make sure restricted? is still accurate
+ def adjust_series_visibility
+ self.series.adjust_restricted unless self.series.blank?
+ end
+
+ # If you delete a work from a series and it was the last one, delete the series too
+ def delete_empty_series
+ if self.series.present? && self.series.serial_works.blank?
+ self.series.destroy
+ end
+ end
+
+ # Reindex works added to or removed from a series
+ def update_work_index
+ work&.enqueue_to_index
+ end
+
+ # Ensure series bookmarks are reindexed when a new work is added to a series
+ def update_series_index
+ return if series.blank?
+ series.enqueue_to_index
+ IndexQueue.enqueue_ids(Bookmark, series.bookmarks.pluck(:id), :main)
+ end
+
+ after_create :update_series_creatorships
+ def update_series_creatorships
+ return unless work && series
+
+ work.pseuds_after_saving.each do |pseud|
+ creatorship = series.creatorships.find_or_initialize_by(pseud: pseud)
+ creatorship.approved = true
+ creatorship.enable_notifications = true
+ creatorship.save
+ end
+ end
+end
diff --git a/app/models/series.rb b/app/models/series.rb
new file mode 100755
index 0000000..96726ed
--- /dev/null
+++ b/app/models/series.rb
@@ -0,0 +1,315 @@
+class Series < ApplicationRecord
+ include Bookmarkable
+ include Searchable
+ include Creatable
+
+ has_many :serial_works, dependent: :destroy
+ has_many :works, through: :serial_works
+ has_many :work_tags, -> { distinct }, through: :works, source: :tags
+ has_many :work_pseuds, -> { distinct }, through: :works, source: :pseuds
+
+ has_many :taggings, as: :taggable, dependent: :destroy
+ has_many :tags, through: :taggings, source: :tagger, source_type: 'Tag'
+
+ has_many :subscriptions, as: :subscribable, dependent: :destroy
+
+ validates_presence_of :title
+ validates_length_of :title,
+ minimum: ArchiveConfig.TITLE_MIN,
+ too_short: ts("must be at least %{min} letters long.", min: ArchiveConfig.TITLE_MIN)
+
+ validates_length_of :title,
+ maximum: ArchiveConfig.TITLE_MAX,
+ too_long: ts("must be less than %{max} letters long.", max: ArchiveConfig.TITLE_MAX)
+
+ # return title.html_safe to overcome escaping done by sanitiser
+ def title
+ read_attribute(:title).try(:html_safe)
+ end
+
+ validates_length_of :summary,
+ allow_blank: true,
+ maximum: ArchiveConfig.SUMMARY_MAX,
+ too_long: ts("must be less than %{max} letters long.", max: ArchiveConfig.SUMMARY_MAX)
+
+ validates_length_of :series_notes,
+ allow_blank: true,
+ maximum: ArchiveConfig.NOTES_MAX,
+ too_long: ts("must be less than %{max} letters long.", max: ArchiveConfig.NOTES_MAX)
+
+ after_save :adjust_restricted
+ after_update_commit :expire_caches, :update_work_index
+
+ scope :visible_to_registered_user, -> { where(hidden_by_admin: false).order('series.updated_at DESC') }
+ scope :visible_to_all, -> { where(hidden_by_admin: false, restricted: false).order('series.updated_at DESC') }
+
+ scope :exclude_anonymous, -> {
+ joins("INNER JOIN `serial_works` ON (`series`.`id` = `serial_works`.`series_id`)
+ INNER JOIN `works` ON (`works`.`id` = `serial_works`.`work_id`)").
+ group("series.id").
+ having("MAX(works.in_anon_collection) = 0 AND MAX(works.in_unrevealed_collection) = 0")
+ }
+
+ scope :for_pseud, lambda { |pseud|
+ joins(:approved_creatorships).where(creatorships: { pseud: pseud })
+ }
+
+ scope :for_user, lambda { |user|
+ joins(approved_creatorships: :pseud).where(pseuds: { user: user })
+ }
+
+ scope :for_blurb, -> { includes(:work_tags, :pseuds) }
+
+ def posted_works
+ self.works.posted
+ end
+
+ def works_in_order
+ works.order("serial_works.position")
+ end
+
+ # Get the filters for the works in this series
+ def filters
+ Tag.joins("JOIN filter_taggings ON tags.id = filter_taggings.filter_id
+ JOIN works ON works.id = filter_taggings.filterable_id
+ JOIN serial_works ON serial_works.work_id = works.id").
+ where("serial_works.series_id = #{self.id} AND
+ works.posted = 1 AND
+ filter_taggings.filterable_type = 'Work'").
+ group("tags.id")
+ end
+
+ def direct_filters
+ filters.where("filter_taggings.inherited = 0")
+ end
+
+ # visibility aped from the work model
+ def visible?(user = User.current_user)
+ return true if user.is_a?(Admin)
+
+ if posted && !hidden_by_admin
+ user.is_a?(User) || !restricted
+ else
+ user_is_owner_or_invited?(user)
+ end
+ end
+
+ # Override the default definition to check whether the user was invited to
+ # any works in the series.
+ def user_is_owner_or_invited?(user)
+ return false unless user.is_a?(User)
+ return true if super
+
+ works.joins(:creatorships).merge(user.creatorships).exists? ||
+ works.joins(chapters: :creatorships).merge(user.creatorships).exists?
+ end
+
+ def visible_work_count
+ if User.current_user.nil?
+ self.works.posted.unrestricted.count
+ else
+ self.works.posted.count
+ end
+ end
+
+ def visible_word_count
+ if User.current_user.nil?
+ # visible_works_wordcount = self.works.posted.unrestricted.sum(:word_count)
+ visible_works_wordcount = self.works.posted.unrestricted.pluck(:word_count).compact.sum
+ else
+ # visible_works_wordcount = self.works.posted.sum(:word_count)
+ visible_works_wordcount = self.works.posted.pluck(:word_count).compact.sum
+ end
+ visible_works_wordcount
+ end
+
+ def anonymous?
+ !self.works.select { |work| work.anonymous? }.empty?
+ end
+
+ def unrevealed?
+ !self.works.select { |work| work.unrevealed? }.empty?
+ end
+
+ # if the series includes an unrestricted work, restricted should be false
+ # if the series includes no unrestricted works, restricted should be true
+ def adjust_restricted
+ unless self.restricted? == !(self.works.where(restricted: false).count > 0)
+ self.restricted = !(self.works.where(restricted: false).count > 0)
+ self.save!(validate: false)
+ end
+ end
+
+ # Visibility has changed, which means we need to reindex
+ # the series' bookmarker pseuds, to update their bookmark counts.
+ def should_reindex_pseuds?
+ pertinent_attributes = %w[id restricted hidden_by_admin]
+ destroyed? || (saved_changes.keys & pertinent_attributes).present?
+ end
+
+ def expire_caches
+ self.works.touch_all
+ end
+
+ def expire_byline_cache
+ [true, false].each do |only_path|
+ Rails.cache.delete("#{cache_key}/byline-nonanon/#{only_path}")
+ end
+ end
+
+ # Change the positions of the serial works in the series
+ def reorder_list(positions)
+ SortableList.new(self.serial_works.in_order).reorder_list(positions)
+ end
+
+ def position_of(work)
+ serial_works.where(work_id: work.id).pluck(:position).first
+ end
+
+ # return list of pseuds on this series
+ def allpseuds
+ works.collect(&:pseuds).flatten.compact.uniq.sort
+ end
+
+ # Remove a user (and all their pseuds) as an author of this series.
+ #
+ # We call Work#remove_author before destroying the series creatorships to
+ # make sure that we can handle tricky chapter creatorship cases.
+ def remove_author(author_to_remove)
+ pseuds_with_author_removed = pseuds.where.not(user_id: author_to_remove.id)
+
+ if pseuds_with_author_removed.empty?
+ errors.add(:base, ts("Sorry, we can't remove all creators of a series."))
+ raise ActiveRecord::RecordInvalid, self
+ end
+
+ transaction do
+ authored_works_in_series = self.works.merge(author_to_remove.works)
+
+ authored_works_in_series.each do |work|
+ work.remove_author(author_to_remove)
+ end
+
+ creatorships.where(pseud: author_to_remove.pseuds).destroy_all
+ end
+ end
+
+ # returns list of fandoms on this series
+ def allfandoms
+ works.collect(&:fandoms).flatten.compact.uniq.sort
+ end
+
+ def author_tags
+ self.work_tags.select{|t| t.type == "Relationship"}.sort + self.work_tags.select{|t| t.type == "Character"}.sort + self.work_tags.select{|t| t.type == "Freeform"}.sort
+ end
+
+ def tag_groups
+ self.work_tags.group_by { |t| t.type.to_s }
+ end
+
+ # Grabs the earliest published_at date of the visible works in the series
+ def published_at
+ if self.works.visible.posted.blank?
+ self.created_at
+ else
+ Work.in_series(self).visible.collect(&:published_at).compact.uniq.sort.first
+ end
+ end
+
+ def revised_at
+ if self.works.visible.posted.blank?
+ self.updated_at
+ else
+ Work.in_series(self).visible.collect(&:revised_at).compact.uniq.sort.last
+ end
+ end
+
+ ######################
+ # SEARCH
+ ######################
+
+ def bookmarkable_json
+ as_json(
+ root: false,
+ only: [
+ :title, :summary, :hidden_by_admin, :restricted, :created_at,
+ :complete
+ ],
+ methods: [
+ :revised_at, :posted, :tag, :filter_ids, :rating_ids,
+ :archive_warning_ids, :category_ids, :fandom_ids, :character_ids,
+ :relationship_ids, :freeform_ids, :creators,
+ :word_count, :work_types]
+ ).merge(
+ language_id: language&.short,
+ anonymous: anonymous?,
+ unrevealed: unrevealed?,
+ pseud_ids: anonymous? || unrevealed? ? nil : pseud_ids,
+ user_ids: anonymous? || unrevealed? ? nil : user_ids,
+ bookmarkable_type: 'Series',
+ bookmarkable_join: { name: "bookmarkable" }
+ )
+ end
+
+ def update_work_index
+ self.works.each(&:enqueue_to_index) if saved_change_to_title?
+ end
+
+ def word_count
+ self.works.posted.pluck(:word_count).compact.sum
+ end
+
+ # FIXME: should series have their own language?
+ def language
+ works.first.language if works.present?
+ end
+
+ def posted
+ !posted_works.empty?
+ end
+ alias_method :posted?, :posted
+
+ # Simple name to make it easier for people to use in full-text search
+ def tag
+ (work_tags + filters).uniq.map{ |t| t.name }
+ end
+
+ # Index all the filters for pulling works
+ def filter_ids
+ (work_tags.pluck(:id) + filters.pluck(:id)).uniq
+ end
+
+ # Index only direct filters (non meta-tags) for facets
+ def filters_for_facets
+ @filters_for_facets ||= direct_filters
+ end
+ def rating_ids
+ filters_for_facets.select{ |t| t.type.to_s == 'Rating' }.map{ |t| t.id }
+ end
+ def archive_warning_ids
+ filters_for_facets.select{ |t| t.type.to_s == 'ArchiveWarning' }.map{ |t| t.id }
+ end
+ def category_ids
+ filters_for_facets.select{ |t| t.type.to_s == 'Category' }.map{ |t| t.id }
+ end
+ def fandom_ids
+ filters_for_facets.select{ |t| t.type.to_s == 'Fandom' }.map{ |t| t.id }
+ end
+ def character_ids
+ filters_for_facets.select{ |t| t.type.to_s == 'Character' }.map{ |t| t.id }
+ end
+ def relationship_ids
+ filters_for_facets.select{ |t| t.type.to_s == 'Relationship' }.map{ |t| t.id }
+ end
+ def freeform_ids
+ filters_for_facets.select{ |t| t.type.to_s == 'Freeform' }.map{ |t| t.id }
+ end
+
+ def creators
+ anonymous? ? ['Anonymous'] : pseuds.map(&:byline)
+ end
+
+ def work_types
+ works.map(&:work_types).flatten.uniq
+ end
+end
diff --git a/app/models/skin.rb b/app/models/skin.rb
new file mode 100755
index 0000000..0a41733
--- /dev/null
+++ b/app/models/skin.rb
@@ -0,0 +1,602 @@
+require 'fileutils'
+
+class Skin < ApplicationRecord
+ include HtmlCleaner
+ include CssCleaner
+ include SkinCacheHelper
+ include SkinWizard
+
+ TYPE_OPTIONS = [
+ [ts("Site Skin"), "Skin"],
+ [ts("Work Skin"), "WorkSkin"],
+ ]
+
+ # any media types that are not a single alphanumeric word have to be specially
+ # handled in get_media_for_filename/parse_media_from_filename
+ MEDIA = %w(all screen handheld speech print braille embossed projection tty tv) + [
+ "only screen and (max-width: 42em)",
+ "only screen and (max-width: 62em)",
+ "(prefers-color-scheme: dark)",
+ "(prefers-color-scheme: light)"
+ ]
+ IE_CONDITIONS = %w(IE IE5 IE6 IE7 IE8 IE9 IE8_or_lower)
+ ROLES = %w(user override)
+ ROLE_NAMES = {"user" => "add on to archive skin", "override" => "replace archive skin entirely"}
+ # We don't show some roles to users
+ ALL_ROLES = ROLES + %w(admin translator site)
+ DEFAULT_ROLE = "user"
+ DEFAULT_ROLES_TO_INCLUDE = %w(user override site)
+ DEFAULT_MEDIA = ["all"]
+
+ SKIN_PATH = 'stylesheets/skins/'
+ SITE_SKIN_PATH = 'stylesheets/site/'
+
+ belongs_to :author, class_name: 'User'
+ has_many :preferences
+
+ serialize :media, type: Array, coder: YAML, yaml: { permitted_classes: [String] }
+
+ # a skin can be both parent and child
+ has_many :skin_parents, foreign_key: 'child_skin_id',
+ class_name: 'SkinParent',
+ dependent: :destroy, inverse_of: :child_skin
+ has_many :parent_skins, -> { order("skin_parents.position ASC") }, through: :skin_parents, inverse_of: :child_skins
+
+ has_many :skin_children, foreign_key: 'parent_skin_id',
+ class_name: 'SkinParent', dependent: :destroy, inverse_of: :parent_skin
+ has_many :child_skins, through: :skin_children, inverse_of: :parent_skins
+
+ accepts_nested_attributes_for :skin_parents, allow_destroy: true, reject_if: proc { |attrs| attrs[:position].blank? || (attrs[:parent_skin_title].blank? && attrs[:parent_skin_id].blank?) }
+
+ has_one_attached :icon do |attachable|
+ attachable.variant(:standard, resize_to_limit: [100, 100], loader: { n: -1 })
+ end
+
+ # i18n-tasks-use t("errors.attributes.icon.invalid_format")
+ # i18n-tasks-use t("errors.attributes.icon.too_large")
+ validates :icon, attachment: {
+ allowed_formats: %r{image/\S+},
+ maximum_size: ArchiveConfig.ICON_SIZE_KB_MAX.kilobytes
+ }
+
+ after_save :skin_invalidate_cache
+ def skin_invalidate_cache
+ skin_chooser_expire_cache
+ skin_cache_version_update(id)
+
+ # Work skins can't have children, but site skins (which have type nil)
+ # might have children that need expiration:
+ return unless type.nil?
+
+ SkinParent.get_all_child_ids(id).each do |child_id|
+ skin_cache_version_update(child_id)
+ end
+ end
+
+ validates_length_of :icon_alt_text, allow_blank: true, maximum: ArchiveConfig.ICON_ALT_MAX,
+ too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.ICON_ALT_MAX)
+
+ validates_length_of :description, allow_blank: true, maximum: ArchiveConfig.SUMMARY_MAX,
+ too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.SUMMARY_MAX)
+
+ validates_length_of :css, allow_blank: true, maximum: ArchiveConfig.CONTENT_MAX,
+ too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.CONTENT_MAX)
+
+ before_validation :clean_media
+ def clean_media
+ # handle bizarro cucumber-only error that prevents media from deserializing correctly when attachments are made
+ if media && media.is_a?(Array) && !media.empty?
+ new_media = media.flatten.compact.collect {|m| m.gsub(/\["(\w+)"\]/, '\1')}
+ self.media = new_media
+ end
+ end
+
+ validate :valid_media
+ def valid_media
+ if media && media.is_a?(Array) && media.any? {|m| !MEDIA.include?(m)}
+ errors.add(
+ :base,
+ :invalid_media,
+ media: media.join(", ")
+ )
+ end
+ end
+
+ validates :ie_condition, inclusion: {in: IE_CONDITIONS, allow_nil: true, allow_blank: true}
+ validates :role, inclusion: {in: ALL_ROLES, allow_blank: true, allow_nil: true }
+
+ validate :valid_public_preview
+ def valid_public_preview
+ return true if self.official? || !self.public? || self.icon.attached?
+ errors.add(:base, :no_public_preview)
+ end
+
+ validates :title, presence: true, uniqueness: { case_sensitive: false }
+ validate :allowed_title
+ def allowed_title
+ return true unless self.title.match(/archive/i)
+
+ authorized_roles = if self.is_a?(WorkSkin)
+ %w[superadmin support]
+ else
+ %w[superadmin]
+ end
+
+ return true if (User.current_user.roles & authorized_roles).present?
+
+ errors.add(:base, :archive_in_title)
+ end
+
+ validates_numericality_of :margin, :base_em, allow_nil: true
+ validate :valid_font
+ def valid_font
+ return if self.font.blank?
+ self.font.split(',').each do |subfont|
+ if sanitize_css_font(subfont).blank?
+ errors.add(:font, "cannot use #{subfont}.")
+ end
+ end
+ end
+
+ validate :valid_colors
+ def valid_colors
+
+ if !self.background_color.blank? && sanitize_css_value(self.background_color).blank?
+ errors.add(:background_color, "uses a color that is not allowed.")
+ end
+
+ if !self.foreground_color.blank? && sanitize_css_value(self.foreground_color).blank?
+ errors.add(:foreground_color, "uses a color that is not allowed.")
+ end
+ end
+
+ validate :clean_css
+ def clean_css
+ return if self.css.blank?
+ self.css = clean_css_code(self.css)
+ end
+
+ scope :public_skins, -> { where(public: true) }
+ scope :approved_skins, -> { where(official: true, public: true) }
+ scope :unapproved_skins, -> { where(public: true, official: false, rejected: false) }
+ scope :rejected_skins, -> { where(public: true, official: false, rejected: true) }
+ scope :site_skins, -> { where(type: nil) }
+ scope :wizard_site_skins, -> { where("type IS NULL AND (
+ margin IS NOT NULL OR
+ background_color IS NOT NULL OR
+ foreground_color IS NOT NULL OR
+ font IS NOT NULL OR
+ base_em IS NOT NULL OR
+ paragraph_margin IS NOT NULL OR
+ headercolor IS NOT NULL OR
+ accent_color IS NOT NULL
+ )
+ ") }
+
+ def self.cached
+ where(cached: true)
+ end
+
+ def self.in_chooser
+ where(in_chooser: true)
+ end
+
+ def self.featured
+ where(featured: true)
+ end
+
+ def self.approved_or_owned_by(user = User.current_user)
+ if user.nil?
+ approved_skins
+ else
+ approved_or_owned_by_any([user])
+ end
+ end
+
+ def self.approved_or_owned_by_any(users)
+ where("(public = 1 AND official = 1) OR author_id in (?)", users.map(&:id))
+ end
+
+ def self.usable
+ where(unusable: false)
+ end
+
+ def self.sort_by_recent
+ order("updated_at DESC")
+ end
+
+ def self.sort_by_recent_featured
+ order("featured DESC, updated_at DESC")
+ end
+
+ def approved_or_owned_by?(user)
+ self.public? && self.official? || author_id == user.id
+ end
+
+ def remove_me_from_preferences
+ Preference.where(skin_id: self.id).update_all(skin_id: AdminSetting.default_skin_id)
+ end
+
+ def editable?
+ if self.filename.present?
+ return false
+ elsif self.official && self.public
+ return true if User.current_user.is_a? Admin
+ elsif self.author == User.current_user
+ return true
+ else
+ return false
+ end
+ end
+
+ def byline
+ if self.author.is_a? User
+ author.login
+ else
+ ArchiveConfig.APP_SHORT_NAME
+ end
+ end
+
+ def wizard_settings?
+ margin.present? || font.present? || background_color.present? || foreground_color.present? || base_em.present? || paragraph_margin.present? || headercolor.present? || accent_color.present?
+ end
+
+ # create the minimal number of files we can, containing all the css for this entire skin
+ def cache!
+ self.clear_cache!
+ self.public = true
+ self.official = true
+ save!
+ css_to_cache = ""
+ last_role = ""
+ file_count = 1
+ skin_dir = Skin.skins_dir + skin_dirname
+ FileUtils.mkdir_p skin_dir
+ (get_all_parents + [self]).each do |next_skin|
+ if next_skin.get_sheet_role != last_role
+ # save to file
+ if css_to_cache.present?
+ cache_filename = skin_dir + "#{file_count}_#{last_role}.css"
+ file_count+=1
+ File.open(cache_filename, 'w') {|f| f.write(css_to_cache)}
+ css_to_cache = ""
+ end
+ last_role = next_skin.get_sheet_role
+ end
+ css_to_cache += next_skin.get_css
+ end
+ # TODO this repetition is all wrong but my brain is fried
+ if css_to_cache.present?
+ cache_filename = skin_dir + "#{file_count}_#{last_role}.css"
+ File.open(cache_filename, 'w') {|f| f.write(css_to_cache)}
+ css_to_cache = ""
+ end
+ self.cached = true
+ save!
+ end
+
+ def clear_cache!
+ skin_dir = Skin.skins_dir + skin_dirname
+ FileUtils.rm_rf skin_dir # clear out old if exists
+ self.cached = false
+ save!
+ end
+
+ def recache_children!
+ child_ids = SkinParent.get_all_child_ids(id)
+ Skin.where(cached: true, id: child_ids).find_each(&:cache!)
+ end
+
+ def get_sheet_role
+ "#{get_role}_#{get_media_for_filename}_#{ie_condition}"
+ end
+
+ # have to handle any media types that aren't a single alphanumeric word here
+ def get_media_for_filename
+ ((media.nil? || media.empty?) ? DEFAULT_MEDIA : media).map {|m|
+ case
+ when m.match(/max-width: 42em/)
+ "narrow"
+ when m.match(/max-width: 62em/)
+ "midsize"
+ when m.match(/prefers-color-scheme: dark/)
+ "dark"
+ when m.match(/prefers-color-scheme: light/)
+ "light"
+ else
+ m
+ end
+ }.join(".")
+ end
+
+ def parse_media_from_filename(media_string)
+ media_string.gsub(/narrow/, "only screen and (max-width: 42em)")
+ .gsub(/midsize/, "only screen and (max-width: 62em)")
+ .gsub(/dark/, "(prefers-color-scheme: dark)")
+ .gsub(/light/, "(prefers-color-scheme: light)")
+ .gsub(".", ", ")
+ end
+
+ def parse_sheet_role(role_string)
+ (sheet_role, sheet_media, sheet_ie_condition) = role_string.split('_')
+ sheet_media = parse_media_from_filename(sheet_media)
+ [sheet_role, sheet_media, sheet_ie_condition]
+ end
+
+ def get_css
+ if filename
+ File.read(Rails.public_path.join(filename))
+ else
+ css
+ end
+ end
+
+ def get_media(separator=", ")
+ ((media.nil? || media.empty?) ? DEFAULT_MEDIA : media).join(separator)
+ end
+
+ def get_role
+ self.role || DEFAULT_ROLE
+ end
+
+ def get_all_parents
+ all_parents = []
+ parent_skins.each do |parent|
+ all_parents += parent.get_all_parents
+ all_parents << parent
+ end
+ all_parents
+ end
+
+ # This is the main function that actually returns code to be embedded in a page
+ def get_style(roles_to_include = DEFAULT_ROLES_TO_INCLUDE)
+ style = ""
+
+ if self.get_role != "override" && self.get_role != "site" &&
+ self.id != AdminSetting.default_skin_id &&
+ AdminSetting.default_skin.is_a?(Skin)
+ style += AdminSetting.default_skin.get_style(roles_to_include)
+ end
+
+ style += self.get_style_block(roles_to_include)
+ style.html_safe
+ end
+
+ def get_ie_comment(style, ie_condition = self.ie_condition)
+ if ie_condition.present?
+ ie_comment= ""
+ else
+ style
+ end
+ end
+
+ # This builds the stylesheet, so the order is important
+ def get_wizard_settings
+ style = ""
+
+ style += font_size_styles(base_em) if base_em.present?
+
+ style += font_styles(font) if font.present?
+
+ style += background_color_styles(background_color) if background_color.present?
+
+ style += paragraph_margin_styles(paragraph_margin) if paragraph_margin.present?
+
+ style += foreground_color_styles(foreground_color) if foreground_color.present?
+
+ style += header_styles(headercolor) if headercolor.present?
+
+ style += accent_color_styles(accent_color) if accent_color.present?
+
+ style += work_margin_styles(margin) if margin.present?
+
+ style
+ end
+
+ def get_style_block(roles_to_include)
+ block = ""
+ if self.cached?
+ # cached skin in a directory
+ block = get_cached_style(roles_to_include)
+ else
+ # recursively get parents
+ parent_skins.each do |parent|
+ block += parent.get_style_block(roles_to_include) + "\n"
+ end
+
+ # finally get this skin
+ if roles_to_include.include?(get_role)
+ if self.filename.present?
+ block += get_ie_comment(stylesheet_link(self.filename, get_media))
+ else
+ if (wizard_block = get_wizard_settings).present?
+ block += ''
+ end
+ if self.css.present?
+ block += get_ie_comment('')
+ end
+ end
+ end
+ end
+ return block
+ end
+
+ def get_cached_style(roles_to_include)
+ block = ""
+ self_skin_dir = Skin.skins_dir + self.skin_dirname
+ Skin.skin_dir_entries(self_skin_dir, /^\d+_(.*)\.css$/).each do |sub_file|
+ if sub_file.match(/^\d+_(.*)\.css$/)
+ (sheet_role, sheet_media, sheet_ie_condition) = parse_sheet_role($1)
+ if roles_to_include.include?(sheet_role)
+ block += get_ie_comment(stylesheet_link(SKIN_PATH + self.skin_dirname + sub_file, sheet_media), sheet_ie_condition) + "\n"
+ end
+ end
+ end
+ block
+ end
+
+ def stylesheet_link(file, media)
+ # we want one and only one / in the url path
+ ''
+ end
+
+ def self.naturalized(string)
+ string.scan(/[^\d]+|[\d]+/).collect { |f| f.match(/\d+(\.\d+)?/) ? f.to_f : f }
+ end
+
+ def self.load_site_css
+ Skin.skin_dir_entries(Skin.site_skins_dir, /^\d+\.\d+$/).each do |version|
+ version_dir = "#{Skin.site_skins_dir + version}/"
+ if File.directory?(version_dir)
+ # let's load up the file
+ skins = []
+ Skin.skin_dir_entries(version_dir, /^(\d+)-(.*)\.css/).each do |skin_file|
+ filename = SITE_SKIN_PATH + version + '/' + skin_file
+ skin_file.match(/^(\d+)-(.*)\.css/)
+ position = $1.to_i
+ title = $2
+ title.gsub!(/(\-|\_)/, ' ')
+ description = "Version #{version} of the #{title} component (#{position}) of the default archive site design."
+ firstline = File.open(version_dir + skin_file, &:readline)
+ skin_role = "site"
+ if firstline.match(/ROLE: (\w+)/)
+ skin_role = $1
+ end
+ skin_media = ["screen"]
+ if firstline.match(/MEDIA: (.*?) ENDMEDIA/)
+ skin_media = $1.split(/,\s?/)
+ elsif firstline.match(/MEDIA: (\w+)/)
+ skin_media = [$1]
+ end
+ skin_ie = ""
+ if firstline.match(/IE_CONDITION: (\w+)/)
+ skin_ie = $1
+ end
+
+ full_title = "Archive #{version}: (#{position}) #{title}"
+ skin = Skin.find_by(title: full_title)
+ if skin.nil?
+ skin = Skin.new
+ end
+
+ # update the attributes
+ skin.title ||= full_title
+ skin.filename = filename
+ skin.description = description
+ skin.public = true
+ skin.media = skin_media
+ skin.role = skin_role
+ skin.ie_condition = skin_ie
+ skin.unusable = true
+ skin.official = true
+ skin.icon.attach(io: File.open("#{version_dir}preview.png", "rb"), content_type: "image/png", filename: "preview.png")
+ skin.save!(validate: false)
+ skins << skin
+ end
+
+ # set up the parent relationship of all the skins in this version
+ top_skin = Skin.find_by(title: "Archive #{version}")
+ if top_skin
+ top_skin.clear_cache! if top_skin.cached?
+ top_skin.skin_parents.delete_all
+ else
+ top_skin = Skin.new(title: "Archive #{version}", css: "", description: "Version #{version} of the default Archive style.",
+ public: true, role: "site", media: ["screen"])
+ end
+ top_skin.icon.attach(io: File.open("#{version_dir}preview.png", "rb"), content_type: "image/png", filename: "preview.png")
+ top_skin.official = true
+ top_skin.save!(validate: false)
+ skins.each_with_index do |skin, index|
+ skin_parent = top_skin.skin_parents.build(child_skin: top_skin, parent_skin: skin, position: index+1)
+ skin_parent.save!
+ end
+ if %w(staging unproduction).include? Rails.env
+ top_skin.cache!
+ end
+ end
+ end
+ end
+
+ # get the directory name for the skin file
+ def skin_dirname
+ "skin_#{self.id}_#{self.title.gsub(/[^\w]/, '_')}/".downcase
+ end
+
+ def self.skins_dir
+ Rails.public_path.join(SKIN_PATH).to_s
+ end
+
+ def self.skin_dir_entries(dir, regex)
+ Dir.entries(dir).select {|f| f.match(regex)}.sort_by {|f| Skin.naturalized(f.to_s)}
+ end
+
+ def self.site_skins_dir
+ Rails.public_path.join(SITE_SKIN_PATH).to_s
+ end
+
+ # Get the most recent version and find the topmost skin
+ def self.get_current_version
+ Skin.skin_dir_entries(Skin.site_skins_dir, /^\d+\.\d+$/).last
+ end
+
+ def self.get_current_site_skin
+ current_version = Skin.get_current_version
+ if current_version
+ Skin.find_by(title: "Archive #{Skin.get_current_version}", official: true)
+ else
+ nil
+ end
+ end
+
+ def self.default
+ Skin.find_by(title: "Default", official: true) || Skin.create_default
+ end
+
+ def self.create_default
+ transaction do
+ skin = Skin.find_or_initialize_by(title: "Default")
+
+ skin.official = true
+ skin.public = true
+ skin.role = "site"
+ skin.css = ""
+ skin.set_thumbnail_from_current_version
+
+ skin.save!
+ skin
+ end
+ end
+
+ def self.set_default_to_current_version
+ transaction do
+ default_skin = default
+
+ default_skin.set_thumbnail_from_current_version
+
+ parent_skin = get_current_site_skin
+ if parent_skin && default_skin.parent_skins != [parent_skin]
+ default_skin.skin_parents.destroy_all
+ default_skin.skin_parents.build(parent_skin: parent_skin, position: 1)
+ end
+
+ default_skin.save!
+ end
+ end
+
+ def set_thumbnail_from_current_version
+ current_version = self.class.get_current_version
+
+ icon_path = if current_version
+ self.class.site_skins_dir + current_version + "/preview.png"
+ else
+ self.class.site_skins_dir + "preview.png"
+ end
+
+ self.icon.attach(io: File.open(icon_path), content_type: "image/png", filename: "preview.png")
+ end
+end
diff --git a/app/models/skin_parent.rb b/app/models/skin_parent.rb
new file mode 100755
index 0000000..d733157
--- /dev/null
+++ b/app/models/skin_parent.rb
@@ -0,0 +1,77 @@
+class SkinParent < ApplicationRecord
+ belongs_to :child_skin, class_name: "Skin", touch: true
+ belongs_to :parent_skin, class_name: "Skin"
+
+ validates :position,
+ uniqueness: {scope: [:child_skin_id, :parent_skin_id], message: ts("^Position has to be unique for each parent.")},
+ numericality: {only_integer: true, greater_than: 0}
+
+ validates_presence_of :child_skin, :parent_skin
+
+ validate :no_site_parent
+ def no_site_parent
+ return unless parent_skin&.get_role == "site" && %w[override site].exclude?(child_skin.get_role)
+
+ errors.add(:base, :site_parent, title: parent_skin.title)
+ end
+
+ validate :no_circular_skin
+ def no_circular_skin
+ if parent_skin == child_skin
+ errors.add(:base, ts("^You can't make a skin its own parent"))
+ end
+ parent_ids = SkinParent.get_all_parent_ids(self.child_skin_id)
+ if parent_ids.include?(self.parent_skin_id)
+ errors.add(:base, ts("^%{parent_title} is already a parent of %{child_title}", child_title: child_skin.title, parent_title: parent_skin.title))
+ end
+
+ child_ids = SkinParent.get_all_child_ids(self.child_skin_id)
+ if child_ids.include?(self.parent_skin_id)
+ errors.add(:base, ts("^%{parent_title} is a child of %{child_title}", child_title: child_skin.title, parent_title: parent_skin.title))
+ end
+
+ # also don't allow duplication
+
+ end
+
+ # Takes as argument the ID of the skin to start at, and a block that should
+ # take a list of skin IDs and produce a list of "adjacent" skin IDs (i.e.
+ # parents or children).
+ def self.search_skin_ids(root_id)
+ found = Set.new([root_id])
+ boundary = [root_id]
+
+ until boundary.empty?
+ new_boundary = yield boundary
+
+ boundary = []
+
+ new_boundary.each do |skin_id|
+ boundary << skin_id if found.add?(skin_id)
+ end
+ end
+
+ found.delete(root_id)
+ found.to_a
+ end
+
+ def self.get_all_parent_ids(skin_id)
+ search_skin_ids(skin_id) do |skin_ids|
+ SkinParent.where(child_skin_id: skin_ids).pluck(:parent_skin_id)
+ end
+ end
+
+ def self.get_all_child_ids(skin_id)
+ search_skin_ids(skin_id) do |skin_ids|
+ SkinParent.where(parent_skin_id: skin_ids).pluck(:child_skin_id)
+ end
+ end
+
+ def parent_skin_title
+ self.parent_skin.try(:title) || ""
+ end
+
+ def parent_skin_title=(title)
+ self.parent_skin = Skin.find_by(title: title)
+ end
+end
diff --git a/app/models/spam_report.rb b/app/models/spam_report.rb
new file mode 100755
index 0000000..f4510f2
--- /dev/null
+++ b/app/models/spam_report.rb
@@ -0,0 +1,75 @@
+class SpamReport
+ attr_reader :recent_date, :new_date
+
+ def self.run
+ new.run
+ end
+
+ def initialize
+ @recent_date = 14.days.ago
+ @new_date = 1.day.ago
+ end
+
+ def run
+ spam = {}
+ users.each do |user|
+ new_works, score = score_user(user)
+ if score > ArchiveConfig.SPAM_THRESHOLD
+ spam[user.id] = { score: score, work_ids: new_works }
+ end
+ end
+ spam = Hash[spam.sort_by { |_user_id, info| info[:score] }.reverse]
+ if spam.length > 0
+ AdminMailer.send_spam_alert(spam).deliver_later
+ end
+ end
+
+ private
+
+ def all_new_works
+ Work.where("created_at > ?", new_date).posted.unhidden
+ end
+
+ def users
+ all_new_works.map { |w| w.users }.flatten.uniq
+ end
+
+ # Scoring rules:
+ # For every new spam work, add 4 to the score
+ # For every new non-spam work, add 1
+ # For every older non-spam work,
+ # decrease the score by 2
+ # Add the number of different ip addresses used to post
+ def score_user(user)
+ new_works = []
+ ips = []
+ score = 0
+ works = user.works.
+ visible_to_registered_user.
+ where("works.created_at > ?", new_date)
+ works.each do |work|
+ unless work.spam_checked?
+ work.check_for_spam
+ end
+
+ if work.created_at > new_date
+ new_works << work.id
+ ips << work.ip_address
+ if work.spam?
+ score += 4
+ else
+ score += 1
+ end
+ end
+ end
+
+ count = user.works.
+ where("works.created_at > ? AND works.created_at < ?",
+ recent_date,
+ new_date).
+ posted.not_spam.size
+ score -= (count * 2)
+ score += ips.uniq.length
+ [new_works, score]
+ end
+end
diff --git a/app/models/stat_counter.rb b/app/models/stat_counter.rb
new file mode 100755
index 0000000..f362907
--- /dev/null
+++ b/app/models/stat_counter.rb
@@ -0,0 +1,14 @@
+class StatCounter < ApplicationRecord
+ belongs_to :work
+
+ after_commit :enqueue_to_index, on: :update
+
+ def enqueue_to_index
+ IndexQueue.enqueue(self, :stats)
+ end
+
+ # Specify the indexer that should be used for this class
+ def indexers
+ [StatCounterIndexer]
+ end
+end
diff --git a/app/models/status.rb b/app/models/status.rb
new file mode 100644
index 0000000..cb7ca12
--- /dev/null
+++ b/app/models/status.rb
@@ -0,0 +1,5 @@
+class Status < ApplicationRecord
+ belongs_to :user
+ has_one_attached :icon
+end
+
diff --git a/app/models/story_parser.rb b/app/models/story_parser.rb
new file mode 100755
index 0000000..f7d2d35
--- /dev/null
+++ b/app/models/story_parser.rb
@@ -0,0 +1,981 @@
+# Parse stories from other websites and uploaded files, looking for metadata to harvest
+# and put into the archive.
+#
+class StoryParser
+ require 'timeout'
+ require 'nokogiri'
+ require 'mechanize'
+ require 'open-uri'
+ include HtmlCleaner
+
+ OPTIONAL_META = {notes: 'Note',
+ freeform_string: 'Tag',
+ fandom_string: 'Fandom',
+ rating_string: 'Rating',
+ archive_warning_string: 'Warning',
+ relationship_string: 'Relationship|Pairing',
+ character_string: 'Character' }.freeze
+ REQUIRED_META = { title: 'Title',
+ summary: 'Summary',
+ revised_at: 'Date|Posted|Posted on|Posted at',
+ chapter_title: 'Chapter Title' }.freeze
+
+ # Use this for raising custom error messages
+ # (so that we can distinguish them from unexpected exceptions due to
+ # faulty code)
+ class Error < StandardError
+ end
+
+ # These attributes need to be moved from the work to the chapter
+ # format: {work_attribute_name: :chapter_attribute_name} (can be the same)
+ CHAPTER_ATTRIBUTES_ONLY = {}
+
+ # These attributes need to be copied from the work to the chapter
+ CHAPTER_ATTRIBUTES_ALSO = { revised_at: :published_at }.freeze
+
+ ### NOTE ON KNOWN SOURCES
+ # These lists will stop with the first one it matches, so put more-specific matches
+ # towards the front of the list.
+
+ # places for which we have a custom parse_story_from_[source] method
+ # for getting information out of the downloaded text
+ KNOWN_STORY_PARSERS = %w[deviantart dw lj].freeze
+
+ # places for which we have a custom parse_author_from_[source] method
+ # which returns an external_author object including an email address
+ KNOWN_AUTHOR_PARSERS = %w[lj].freeze
+
+ # places for which we have a download_story_from_[source]
+ # used to customize the downloading process
+ KNOWN_STORY_LOCATIONS = %w[lj].freeze
+
+ # places for which we have a download_chaptered_from
+ # to get a set of chapters all together
+ CHAPTERED_STORY_LOCATIONS = %w[ffnet thearchive_net efiction quotev].freeze
+
+ # regular expressions to match against the URLS
+ SOURCE_LJ = '((live|dead|insane)journal\.com)|journalfen(\.net|\.com)|dreamwidth\.org'.freeze
+ SOURCE_DW = 'dreamwidth\.org'.freeze
+ SOURCE_FFNET = '(^|[^A-Za-z0-9-])fanfiction\.net'.freeze
+ SOURCE_DEVIANTART = 'deviantart\.com'.freeze
+ SOURCE_THEARCHIVE_NET = 'the\-archive\.net'.freeze
+ SOURCE_EFICTION = 'viewstory\.php'.freeze
+ SOURCE_QUOTEV = 'quotev\.com'.freeze
+
+ # time out if we can't download fast enough
+ STORY_DOWNLOAD_TIMEOUT = 60
+ MAX_CHAPTER_COUNT = 200
+
+ # To check for duplicate chapters, take a slice this long out of the story
+ # (in characters)
+ DUPLICATE_CHAPTER_LENGTH = 10_000
+
+
+ # Import many stories
+ def import_many(urls, options = {})
+ # Try to get the works
+ works = []
+ failed_urls = []
+ errors = []
+ @options = options
+ urls.each do |url|
+ begin
+ response = download_and_parse_work(url, options)
+ work = response[:work]
+ if response[:status] == :created
+ if work && work.save
+ work.chapters.each(&:save)
+ works << work
+ else
+ failed_urls << url
+ errors << work.errors.values.join(", ")
+ work.delete if work
+ end
+ elsif response[:status] == :already_imported
+ raise StoryParser::Error, response[:message]
+ end
+ rescue Timeout::Error
+ failed_urls << url
+ errors << "Import has timed out. This may be due to connectivity problems with the source site. Please try again in a few minutes, or check Known Issues to see if there are import problems with this site."
+ work.delete if work
+ rescue Error => exception
+ failed_urls << url
+ errors << "We couldn't successfully import that work, sorry: #{exception.message}"
+ work.delete if work
+ end
+ end
+ [works, failed_urls, errors]
+ end
+
+ # Downloads a story and passes it on to the parser.
+ # If the URL of the story is from a site for which we have special rules
+ # (eg, downloading from a livejournal clone, you want to use ?format=light
+ # to get a nice and consistent post format), it will pre-process the url
+ # according to the rules for that site.
+ def download_and_parse_work(location, options = {})
+ status = :created
+ message = ""
+ work = Work.find_by_url(location)
+ if work.nil?
+ @options = options
+ source = get_source_if_known(CHAPTERED_STORY_LOCATIONS, location)
+ if source.nil?
+ story = download_text(location)
+ work = parse_story(story, location, options)
+ else
+ work = download_and_parse_chaptered_story(source, location, options)
+ end
+ else
+ status = :already_imported
+ message = "A work has already been imported from #{location}."
+ end
+ {
+ status: status,
+ message: message,
+ work: work
+ }
+ end
+
+ # Given an array of urls for chapters of a single story,
+ # download them all and combine into a single work
+ def import_chapters_into_story(locations, options = {})
+ status = :created
+ work = Work.find_by_url(locations.first)
+ if work.nil?
+ chapter_contents = []
+ @options = options
+ locations.each do |location|
+ chapter_contents << download_text(location)
+ end
+ work = parse_chapters_into_story(locations.first, chapter_contents, options)
+ message = "Successfully created work \"" + work.title + "\"."
+ else
+ status = :already_imported
+ message = "A work has already been imported from #{locations.first}."
+ end
+ {
+ status: status,
+ message: message,
+ work: work
+ }
+ end
+
+
+ ### OLD PARSING METHODS
+
+ # Import many stories
+ def import_from_urls(urls, options = {})
+ # Try to get the works
+ works = []
+ failed_urls = []
+ errors = []
+ @options = options
+ urls.each do |url|
+ begin
+ work = download_and_parse_story(url, options)
+ if work && work.save
+ work.chapters.each(&:save)
+ works << work
+ else
+ failed_urls << url
+ errors << work.errors.values.join(", ")
+ work.delete if work
+ end
+ rescue Timeout::Error
+ failed_urls << url
+ errors << "Import has timed out. This may be due to connectivity problems with the source site. Please try again in a few minutes, or check Known Issues to see if there are import problems with this site."
+ work.delete if work
+ rescue Error => exception
+ failed_urls << url
+ errors << "We couldn't successfully import that work, sorry: #{exception.message}"
+ work.delete if work
+ end
+ end
+ [works, failed_urls, errors]
+ end
+
+ # Downloads a story and passes it on to the parser.
+ # If the URL of the story is from a site for which we have special rules
+ # (eg, downloading from a livejournal clone, you want to use ?format=light
+ # to get a nice and consistent post format), it will pre-process the url
+ # according to the rules for that site.
+ def download_and_parse_story(location, options = {})
+ check_for_previous_import(location)
+ @options = options
+ source = get_source_if_known(CHAPTERED_STORY_LOCATIONS, location)
+ if source.nil?
+ story = download_text(location)
+ work = parse_story(story, location, options)
+ else
+ work = download_and_parse_chaptered_story(source, location, options)
+ end
+ work
+ end
+
+ # Given an array of urls for chapters of a single story,
+ # download them all and combine into a single work
+ def download_and_parse_chapters_into_story(locations, options = {})
+ check_for_previous_import(locations.first)
+ chapter_contents = []
+ @options = options
+ locations.each do |location|
+ chapter_contents << download_text(location)
+ end
+ parse_chapters_into_story(locations.first, chapter_contents, options)
+ end
+
+ ### PARSING METHODS
+
+ # Parses the text of a story, optionally from a given location.
+ def parse_story(story, location, options = {})
+ work_params = parse_common(story, location, options[:encoding], options[:detect_tags])
+
+ # move any attributes from work to chapter if necessary
+ set_work_attributes(Work.new(work_params), location, options)
+ end
+
+ # parses and adds a new chapter to the end of the work
+ def parse_chapter_of_work(work, chapter_content, location, options = {})
+ tmp_work_params = parse_common(chapter_content, location, options[:encoding], options[:detect_tags])
+ chapter = get_chapter_from_work_params(tmp_work_params)
+ work.chapters << set_chapter_attributes(work, chapter)
+ work
+ end
+
+ def parse_chapters_into_story(location, chapter_contents, options = {})
+ work = nil
+ chapter_contents.each do |content|
+ work_params = parse_common(content, location, options[:encoding], options[:detect_tags])
+ if work.nil?
+ # create the new work
+ work = Work.new(work_params)
+ else
+ new_chapter = get_chapter_from_work_params(work_params)
+ work.chapters << set_chapter_attributes(work, new_chapter)
+ end
+ end
+ set_work_attributes(work, location, options)
+ end
+
+ # Everything below here is protected and should not be touched by outside
+ # code -- please use the above functions to parse external works.
+
+ protected
+
+ # tries to create an external author for a given url
+ def parse_author(location, ext_author_name, ext_author_email)
+ if location.present? && ext_author_name.blank? && ext_author_email.blank?
+ source = get_source_if_known(KNOWN_AUTHOR_PARSERS, location)
+ if source.nil?
+ raise Error, "No external author name or email specified"
+ else
+ send("parse_author_from_#{source.downcase}", location)
+ end
+ else
+ parse_author_common(ext_author_email, ext_author_name)
+ end
+ end
+
+ # download an entire story from an archive type where we know how to parse multi-chaptered works
+ # this should only be called from download_and_parse_story
+ def download_and_parse_chaptered_story(source, location, options = {})
+ chapter_contents = send("download_chaptered_from_#{source.downcase}", location)
+ parse_chapters_into_story(location, chapter_contents, options)
+ end
+
+ # our custom url finder checks for previously imported URL in almost any format it may have been presented
+ def check_for_previous_import(location)
+ if Work.find_by_url(location).present?
+ raise Error, "A work has already been imported from #{location}."
+ end
+ end
+
+ def set_chapter_attributes(work, chapter)
+ chapter.position = work.chapters.length + 1
+ chapter.posted = true
+ chapter
+ end
+
+ def set_work_attributes(work, location = "", options = {})
+ raise Error, "Work could not be downloaded" if work.nil?
+
+ @options = options
+ work.imported_from_url = location
+ work.ip_address = options[:ip_address]
+ work.expected_number_of_chapters = work.chapters.length
+ work.revised_at = work.chapters.last.published_at
+ if work.revised_at && work.revised_at.to_date < Date.current
+ work.backdate = true
+ end
+
+ # set authors for the works
+ pseuds = []
+ pseuds << User.current_user.default_pseud unless options[:do_not_set_current_author] || User.current_user.nil?
+ pseuds << options[:archivist].default_pseud if options[:archivist]
+ pseuds << options[:pseuds] if options[:pseuds]
+ pseuds = pseuds.flatten.compact.uniq
+ raise Error, "A work must have at least one author specified" if pseuds.empty?
+ pseuds.each do |pseud|
+ work.creatorships.build(pseud: pseud, enable_notifications: true)
+ work.chapters.each do |chapter|
+ chapter.creatorships.build(pseud: pseud)
+ end
+ end
+
+ # handle importing works for others
+ # build an external creatorship for each author
+ if options[:importing_for_others]
+ external_author_names = options[:external_author_names] || parse_author(location, options[:external_author_name], options[:external_author_email])
+ # convert to an array if not already one
+ external_author_names = [external_author_names] if external_author_names.is_a?(ExternalAuthorName)
+ if options[:external_coauthor_name].present?
+ external_author_names << parse_author(location, options[:external_coauthor_name], options[:external_coauthor_email])
+ end
+ external_author_names.each do |external_author_name|
+ next if !external_author_name || external_author_name.external_author.blank?
+ if external_author_name.external_author.do_not_import
+ # we're not allowed to import works from this address
+ raise Error, "Author #{external_author_name.name} at #{external_author_name.external_author.email} does not allow importing their work to this archive."
+ end
+ work.external_creatorships.build(external_author_name: external_author_name, archivist: (options[:archivist] || User.current_user))
+ end
+ end
+
+ # lock to registered users if specified or importing for others
+ work.restricted = options[:restricted] || options[:importing_for_others] || false
+
+ # set comment permissions
+ work.comment_permissions = options[:comment_permissions] || "enable_all"
+ work.moderated_commenting_enabled = options[:moderated_commenting_enabled] || false
+
+ # set default values for required tags
+ work.fandom_string = meta_or_default(work.fandom_string, options[:fandom], ArchiveConfig.FANDOM_NO_TAG_NAME)
+ work.rating_string = meta_or_default(work.rating_string, options[:rating], ArchiveConfig.RATING_DEFAULT_TAG_NAME)
+ work.archive_warning_strings = meta_or_default(work.archive_warning_strings, options[:archive_warning], ArchiveConfig.WARNING_DEFAULT_TAG_NAME)
+ work.category_string = meta_or_default(work.category_string, options[:category], [])
+ work.character_string = meta_or_default(work.character_string, options[:character], [])
+ work.relationship_string = meta_or_default(work.relationship_string, options[:relationship], [])
+ work.freeform_string = meta_or_default(work.freeform_string, options[:freeform], [])
+
+ # set default value for title
+ work.title = meta_or_default(work.title, options[:title], "Untitled Imported Work")
+ work.summary = meta_or_default(work.summary, options[:summary], '')
+ work.notes = meta_or_default(work.notes, options[:notes], '')
+
+ # set collection name if present
+ work.collection_names = get_collection_names(options[:collection_names]) if options[:collection_names].present?
+
+ # set default language (English)
+ work.language_id = options[:language_id] || Language.default.id
+
+ work.posted = true if options[:post_without_preview]
+ work.chapters.each do |chapter|
+ if chapter.content.length > ArchiveConfig.CONTENT_MAX
+ # TODO: eventually: insert a new chapter
+ chapter.content.truncate(ArchiveConfig.CONTENT_MAX, omission: "WARNING: import truncated automatically because chapter was too long! Please add a new chapter for remaining content.", separator: "
")
+ elsif chapter.content.empty?
+ raise Error, "Chapter #{chapter.position} of \"#{work.title}\" is blank."
+ end
+
+ chapter.posted = true # do not save - causes the chapters to exist even if work doesn't get created!
+ end
+ work
+ end
+
+ def parse_author_from_lj(location)
+ return if location !~ %r{^(?:http:\/\/)?(?[^.]*).(?livejournal\.com|dreamwidth\.org|insanejournal\.com|journalfen.net)}
+ email = ""
+ lj_name = Regexp.last_match[:lj_name]
+ site_name = Regexp.last_match[:site_name]
+ if lj_name == "community"
+ # whups
+ post_text = download_text(location)
+ doc = Nokogiri.parse(post_text)
+ lj_name = doc.xpath("/html/body/div[2]/div/div/div/table/tbody/tr/td[2]/span/a[2]/b").content
+ end
+ profile_url = "http://#{lj_name}.#{site_name}/profile"
+ lj_profile = download_text(profile_url)
+ doc = Nokogiri.parse(lj_profile)
+ contact = doc.css('div.contact').inner_html
+ if contact.present?
+ contact.gsub! '
Contact:
', ""
+ contact.gsub! /<\/?(span|i)>/, ""
+ contact.delete! "\n"
+ contact.gsub! " ", ""
+ if contact =~ /(.*@.*\..*)/
+ email = Regexp.last_match[1]
+ end
+ end
+ email = "#{lj_name}@#{site_name}" if email.blank?
+ parse_author_common(email, lj_name)
+ end
+
+ def parse_author_from_unknown(_location)
+ # for now, nothing
+ nil
+ end
+
+ def parse_author_common(email, name)
+ errors = []
+
+ errors << "No author name specified" if name.blank?
+
+ if email.present?
+ external_author = ExternalAuthor.find_or_create_by(email: email)
+ errors += external_author.errors.full_messages
+ else
+ errors << "No author email specified"
+ end
+
+ raise Error, errors.join("\n") if errors.present?
+
+ # convert to ASCII and strip out invalid characters (everything except alphanumeric characters, _, @ and -)
+ redacted_name = name.to_ascii.gsub(/[^\w[ \-@.]]/u, "")
+ if redacted_name.present?
+ external_author.names.find_or_create_by(name: redacted_name)
+ else
+ external_author.default_name
+ end
+ end
+
+ def get_chapter_from_work_params(work_params)
+ @chapter = Chapter.new(work_params[:chapter_attributes])
+ # don't override specific chapter params (eg title) with work params
+ chapter_params = work_params.delete_if do |name, _param|
+ !@chapter.attribute_names.include?(name.to_s) || !@chapter.send(name.to_s).blank?
+ end
+ @chapter.update(chapter_params)
+ @chapter
+ end
+
+ def download_text(location)
+ source = get_source_if_known(KNOWN_STORY_LOCATIONS, location)
+ if source.nil?
+ download_with_timeout(location)
+ else
+ send("download_from_#{source.downcase}", location)
+ end
+ end
+
+ # canonicalize the url for downloading from lj or clones
+ def download_from_lj(location)
+ url = location
+ url.gsub!(/\#(.*)$/, "") # strip off any anchor information
+ url.gsub!(/\?(.*)$/, "") # strip off any existing params at the end
+ url.gsub!('_', '-') # convert underscores in usernames to hyphens
+ url += "?format=light" # go to light format
+ text = download_with_timeout(url)
+
+ if text.match(/adult_check/)
+ Timeout::timeout(STORY_DOWNLOAD_TIMEOUT) {
+ begin
+ agent = Mechanize.new
+ url.include?("dreamwidth") ? form = agent.get(url).forms.first : form = agent.get(url).forms.third
+ page = agent.submit(form, form.buttons.first) # submits the adult concepts form
+ text = page.body.force_encoding(agent.page.encoding)
+ rescue
+ text = ""
+ end
+ }
+ end
+ text
+ end
+
+ # grab all the chapters of the story from ff.net
+ def download_chaptered_from_ffnet(_location)
+ raise Error, "Sorry, Fanfiction.net does not allow imports from their site."
+ end
+
+ def download_chaptered_from_quotev(_location)
+ raise Error, "Sorry, Quotev.com does not allow imports from their site."
+ end
+
+ # this is an efiction archive but it doesn't handle chapters normally
+ # best way to handle is to get the full story printable version
+ # We have to make it a download-chaptered because otherwise it gets sent to the
+ # generic efiction version since chaptered sources are checked first
+ def download_chaptered_from_thearchive_net(location)
+ if location.match(/^(.*)\/.*viewstory\.php.*[^p]sid=(\d+)($|&)/i)
+ location = "#{$1}/viewstory.php?action=printable&psid=#{$2}"
+ end
+ text = download_with_timeout(location)
+ text.sub!('', '') unless text.match('')
+ [text]
+ end
+
+ # grab all the chapters of a story from an efiction-based site
+ def download_chaptered_from_efiction(location)
+ chapter_contents = []
+ if location.match(/^(?.*)\/.*viewstory\.php.*sid=(?\d+)($|&)/i)
+ site = Regexp.last_match[:site]
+ storyid = Regexp.last_match[:storyid]
+ chapnum = 1
+ last_body = ""
+ Timeout::timeout(STORY_DOWNLOAD_TIMEOUT) do
+ loop do
+ url = "#{site}/viewstory.php?action=printable&sid=#{storyid}&chapter=#{chapnum}"
+ body = download_with_timeout(url)
+ # get a section to check that this isn't a duplicate of previous chapter
+ body_to_check = body.slice(10, DUPLICATE_CHAPTER_LENGTH)
+ if body.nil? || body_to_check == last_body || chapnum > MAX_CHAPTER_COUNT || body.match(/
by <\/div>/) || body.match(/Access denied./) || body.match(/Chapter : /)
+ break
+ end
+ # save the value to check for duplicate chapter
+ last_body = body_to_check
+
+ # clean up the broken head in many efiction printable sites
+ body.sub!('', '') unless body.match('')
+ chapter_contents << body
+ chapnum += 1
+ end
+ end
+ end
+ chapter_contents
+ end
+
+
+ # This is the heavy lifter, invoked by all the story and chapter parsers.
+ # It takes a single string containing the raw contents of a story, parses it with
+ # Nokogiri into the @doc object, and then and calls a subparser.
+ #
+ # If the story source can be identified as one of the sources we know how to parse in some custom/
+ # special way, parse_common calls the customized parse_story_from_[source] method.
+ # Otherwise, it falls back to parse_story_from_unknown.
+ #
+ # This produces a hash equivalent to the params hash that is normally created by the standard work
+ # upload form.
+ #
+ # parse_common then calls sanitize_params (which would also be called on the standard work upload
+ # form results) and returns the final sanitized hash.
+ #
+ def parse_common(story, location = nil, encoding = nil, detect_tags = true)
+ work_params = { title: "Untitled Imported Work", chapter_attributes: { content: "" } }
+
+ # Encode as HTML - the dummy "foo" tag will be stripped out by the sanitizer but forces Nokogiri to
+ # preserve line breaks in plain text documents
+ # Rescue all errors as Nokogiri complains about things the sanitizer will fix later
+ story.prepend("")
+ @doc =
+ begin
+ Nokogiri::HTML5.parse(story, encoding: encoding)
+ rescue StandardError
+ Nokogiri::HTML5.parse("")
+ end
+
+ # Try to convert all relative links to absolute
+ base = @doc.at_css("base") ? @doc.css("base")[0]["href"] : location.split("?").first
+ if base.present?
+ @doc.css("a").each do |link|
+ next if link["href"].blank? || link["href"].start_with?("#")
+ begin
+ query = link["href"].match(/(\?.*)$/) ? $1 : ""
+ link["href"] = URI.join(base, link["href"].gsub(/(\?.*)$/, "")).to_s + query
+ rescue
+# ignored
+ end
+ end
+ end
+
+ # Extract metadata (unless detect_tags is false)
+ if location && (source = get_source_if_known(KNOWN_STORY_PARSERS, location))
+ params = send("parse_story_from_#{source.downcase}", story, detect_tags)
+ work_params.merge!(params)
+ else
+ work_params.merge!(parse_story_from_unknown(story, detect_tags))
+ end
+
+ shift_chapter_attributes(sanitize_params(work_params))
+ end
+
+ # our fallback: parse a story from an unknown source, so we have no special
+ # rules.
+ def parse_story_from_unknown(story, detect_tags = true)
+ work_params = { chapter_attributes: {} }
+ story_head = ""
+ story_head = @doc.css("head").inner_html if @doc.css("head")
+
+ # Story content - Look for progressively less specific containers or grab everything
+ element = @doc.at_css('.chapter-content') || @doc.at_css('body') || @doc.at_css('html') || @doc
+ storytext = element ? element.inner_html : story
+
+ meta = {}
+ meta.merge!(scan_text_for_meta(story_head, detect_tags)) unless story_head.blank?
+ meta.merge!(scan_text_for_meta(story, detect_tags))
+ meta[:title] ||= @doc.css('title').inner_html
+ work_params[:chapter_attributes][:title] = meta.delete(:chapter_title)
+ work_params[:chapter_attributes][:content] = clean_storytext(storytext)
+ work_params.merge!(meta)
+ end
+
+ # Parses a story from livejournal or a livejournal equivalent (eg, dreamwidth, insanejournal)
+ # Assumes that we have downloaded the story from one of those equivalents (ie, we've downloaded
+ # it in format=light which is a stripped-down plaintext version.)
+ #
+ def parse_story_from_lj(_story, detect_tags = true)
+ work_params = { chapter_attributes: {} }
+
+ # in LJ "light" format, the story contents are in the second div
+ # inside the body.
+ body = @doc.css("body")
+ storytext = body.css("article.b-singlepost-body").inner_html
+ storytext = body.css("div.aentry-post__text").inner_html if storytext.empty?
+ storytext = body.inner_html if storytext.empty?
+
+ # cleanup the text
+ # storytext.gsub!(/ /i, "\n") # replace the breaks with newlines
+ storytext = clean_storytext(storytext)
+
+ work_params[:chapter_attributes][:content] = storytext
+ work_params[:title] = @doc.css("title").inner_html
+ work_params[:title].gsub! /^[^:]+: /, ""
+ work_params.merge!(scan_text_for_meta(storytext, detect_tags))
+
+ date = @doc.css("time.b-singlepost-author-date")
+ date = @doc.css("p.aentry-head__date/time") if date.empty?
+ work_params[:revised_at] = convert_revised_at(date.first.inner_text) unless date.empty?
+
+ work_params
+ end
+
+ def parse_story_from_dw(_story, detect_tags = true)
+ work_params = { chapter_attributes: {} }
+
+ body = @doc.css("body")
+ content_divs = body.css("div.contents")
+
+ if content_divs[0].present?
+ # Get rid of the DW metadata table
+ content_divs[0].css("div.currents, ul.entry-management-links, div.header.inner, span.restrictions, h3.entry-title").each(&:remove)
+ storytext = content_divs[0].inner_html
+ else
+ storytext = body.inner_html
+ end
+
+ # cleanup the text
+ storytext = clean_storytext(storytext)
+
+ work_params[:chapter_attributes][:content] = storytext
+ work_params[:title] = @doc.css("title").inner_html
+ work_params[:title].gsub! /^[^:]+: /, ""
+ work_params.merge!(scan_text_for_meta(storytext, detect_tags))
+
+ font_blocks = @doc.xpath('//font')
+ unless font_blocks.empty?
+ date = font_blocks.first.inner_text
+ work_params[:revised_at] = convert_revised_at(date)
+ end
+
+ # get the date
+ date = @doc.css("span.date").inner_text
+ work_params[:revised_at] = convert_revised_at(date)
+
+ work_params
+ end
+
+ def parse_story_from_deviantart(_story, detect_tags = true)
+ work_params = { chapter_attributes: {} }
+ storytext = ""
+ notes = ""
+
+ body = @doc.css("body")
+ title = @doc.css("title").inner_html.gsub /\s*on deviantart$/i, ""
+
+ # Find the image (original size) if it's art
+ image_full = body.css("div.dev-view-deviation img.dev-content-full")
+ unless image_full[0].nil?
+ storytext = "
"
+ end
+
+ # Find the fic text if it's fic (needs the id for disambiguation, the "deviantART loves you" bit in the footer has the same class path)
+ text_table = body.css(".grf-indent > div:nth-child(1)")[0]
+ unless text_table.nil?
+ # Try to remove some metadata (title and author) from the work's text, if possible
+ # Try to remove the title: if it exists, and if it's the same as the browser title
+ if text_table.css("h1")[0].present? && title && title.match(text_table.css("h1")[0].text)
+ text_table.css("h1")[0].remove
+ end
+
+ # Try to remove the author: if it exists, and if it follows a certain pattern
+ if text_table.css("small")[0].present? && text_table.css("small")[0].inner_html.match(/by ~.*?}i, "\n") # replace the breaks with newlines
+ storytext = clean_storytext(storytext)
+ work_params[:chapter_attributes][:content] = storytext
+
+ # Find the notes
+ content_divs = body.css("div.text-ctrl div.text")
+ notes = content_divs[0].inner_html unless content_divs[0].nil?
+
+ # cleanup the notes
+ notes.gsub!(%r{ }i, "\n") # replace the breaks with newlines
+ notes = clean_storytext(notes, "notes")
+ work_params[:notes] = notes
+
+ work_params.merge!(scan_text_for_meta(notes, detect_tags))
+ work_params[:title] = title
+
+ body.css("div.dev-title-container h1 a").each do |node|
+ if node["class"] != "u"
+ work_params[:title] = node.inner_html
+ end
+ end
+
+ tags = []
+ @doc.css("div.dev-about-cat-cc a.h").each { |node| tags << node.inner_html }
+ work_params[:freeform_string] = clean_tags(tags.join(ArchiveConfig.DELIMITER_FOR_OUTPUT))
+
+ details = @doc.css("div.dev-right-bar-content span[title]")
+ unless details[0].nil?
+ work_params[:revised_at] = convert_revised_at(details[0].inner_text)
+ end
+
+ work_params
+ end
+
+ # Move and/or copy any meta attributes that need to be on the chapter rather
+ # than on the work itself
+ def shift_chapter_attributes(work_params)
+ CHAPTER_ATTRIBUTES_ONLY.each_pair do |work_attrib, chapter_attrib|
+ if work_params[work_attrib] && !work_params[:chapter_attributes][chapter_attrib]
+ work_params[:chapter_attributes][chapter_attrib] = work_params[work_attrib]
+ work_params.delete(work_attrib)
+ end
+ end
+
+ # copy any attributes from work to chapter as necessary
+ CHAPTER_ATTRIBUTES_ALSO.each_pair do |work_attrib, chapter_attrib|
+ if work_params[work_attrib] && !work_params[:chapter_attributes][chapter_attrib]
+ work_params[:chapter_attributes][chapter_attrib] = work_params[work_attrib]
+ end
+ end
+
+ work_params
+ end
+
+ # Find any cases of the given pieces of meta in the given text
+ # and return a hash of meta values
+ def scan_text_for_meta(text, detect_tags = true)
+ # break up the text with some extra newlines to make matching more likely
+ # and strip out some tags
+ text = text.gsub(/ /, '')
+
+ meta = {}
+ metapatterns = detect_tags ? REQUIRED_META.merge(OPTIONAL_META) : REQUIRED_META
+ is_tag = {}.tap do |h|
+ %w[fandom_string relationship_string freeform_string rating_string archive_warning_string].each do |c|
+ h[c.to_sym] = true
+ end
+ end
+ handler = {}.tap do |h|
+ %w[rating_string revised_at].each do |c|
+ h[c.to_sym] = "convert_#{c.to_s.downcase}"
+ end
+ end
+
+ # 1. Look for Pattern: (whatever), optionally followed by a closing p or div tag
+ # 2. Set meta[:metaname] = whatever
+ # eg, if it finds Fandom: Stargate SG-1 it will set meta[:fandom] = Stargate SG-1
+ # 3. convert_ for cleanup if such a function is defined (eg convert_rating_string)
+ metapatterns.each do |metaname, pattern|
+ metapattern = Regexp.new("(?:#{pattern}|#{pattern.pluralize})\s*:\s*(.*?)(?:(?:p|div)>)?$", Regexp::IGNORECASE)
+ if text.match(metapattern)
+ value = Regexp.last_match[1]
+ value = clean_tags(value) if is_tag[metaname]
+ value = clean_close_html_tags(value)
+ value.strip! # lose leading/trailing whitespace
+ value = send(handler[metaname], value) if handler[metaname]
+
+ meta[metaname] = value
+ end
+ end
+ post_process_meta meta
+ end
+
+ def download_with_timeout(location, limit = 10)
+ story = ""
+ Timeout.timeout(STORY_DOWNLOAD_TIMEOUT) do
+ begin
+ # we do a little cleanup here in case the user hasn't included the 'http://'
+ # or if they've used capital letters or an underscore in the hostname
+ uri = UrlFormatter.new(location).standardized
+ raise Error, I18n.t("story_parser.on_archive") if ArchiveConfig.PERMITTED_HOSTS.include?(uri.host)
+
+ response = Net::HTTP.get_response(uri)
+ case response
+ when Net::HTTPSuccess
+ story = response.body
+ when Net::HTTPRedirection
+ if limit.positive?
+ new_uri = URI.parse(response["location"])
+ new_uri = URI.join(uri, new_uri) if new_uri.relative?
+ story = download_with_timeout(new_uri.to_s, limit - 1)
+ end
+ else
+ Rails.logger.error("------- STORY PARSER: download_with_timeout: response is not success or redirection ------")
+ nil
+ end
+ rescue Errno::ECONNREFUSED, SocketError, EOFError => e
+ Rails.logger.error("------- STORY PARSER: download_with_timeout: error rescue: \n#{e.inspect} ------")
+ nil
+ end
+ end
+ if story.blank?
+ raise Error, "We couldn't download anything from #{location}. Please make sure that the URL is correct and complete, and try again."
+ end
+
+ # clean up any erroneously included string terminator (AO3-2251)
+ story.delete("\000")
+ end
+
+ def get_last_modified(location)
+ Timeout.timeout(STORY_DOWNLOAD_TIMEOUT) do
+ resp = open(location)
+ resp.last_modified
+ end
+ end
+
+ def get_source_if_known(known_sources, location)
+ known_sources.each do |source|
+ pattern = Regexp.new(eval("SOURCE_#{source.upcase}"), Regexp::IGNORECASE)
+ return source if location.match(pattern)
+ end
+ nil
+ end
+
+ def clean_close_html_tags(value)
+ # if there are any closing html tags at the start of the value let's ditch them
+ value.gsub(/^(\s*<\/[^>]+>)+/, '')
+ end
+
+ # We clean the text as if it had been submitted as the content of a chapter
+ def clean_storytext(storytext, field = "content")
+ storytext = storytext.encode("UTF-8", invalid: :replace, undef: :replace, replace: "") unless storytext.encoding.name == "UTF-8"
+ sanitize_value(field, storytext)
+ end
+
+ # works conservatively -- doesn't split on
+ # spaces and truncates instead.
+ def clean_tags(tags)
+ tags = Sanitize.clean(tags.force_encoding("UTF-8")) # no html allowed in tags
+ tags_list = tags =~ /,/ ? tags.split(/,/) : [tags]
+ new_list = []
+ tags_list.each do |tag|
+ tag.gsub!(/[*<>]/, '')
+ tag = truncate_on_word_boundary(tag, ArchiveConfig.TAG_MAX)
+ new_list << tag unless tag.blank?
+ end
+ new_list.join(ArchiveConfig.DELIMITER_FOR_OUTPUT)
+ end
+
+ def truncate_on_word_boundary(text, max_length)
+ return if text.blank?
+ words = text.split
+ truncated = words.first
+ if words.length > 1
+ words[1..words.length].each do |word|
+ truncated += " " + word if truncated.length + word.length + 1 <= max_length
+ end
+ end
+ truncated[0..max_length - 1]
+ end
+
+ # convert space-separated tags to comma-separated
+ def clean_and_split_tags(tags)
+ tags = tags.split(/\s+/).join(',') if !tags.match(/,/) && tags.match(/\s/)
+ clean_tags(tags)
+ end
+
+ # Convert the common ratings into whatever ratings we're
+ # using on this archive.
+ def convert_rating_string(rating)
+ rating = rating.downcase
+ if rating =~ /^(nc-?1[78]|x|ma|explicit)/
+ ArchiveConfig.RATING_EXPLICIT_TAG_NAME
+ elsif rating =~ /^(r|m|mature)/
+ ArchiveConfig.RATING_MATURE_TAG_NAME
+ elsif rating =~ /^(pg-?1[35]|t|teen)/
+ ArchiveConfig.RATING_TEEN_TAG_NAME
+ elsif rating =~ /^(pg|g|k+|k|general audiences)/
+ ArchiveConfig.RATING_GENERAL_TAG_NAME
+ else
+ ArchiveConfig.RATING_DEFAULT_TAG_NAME
+ end
+ end
+
+ def convert_revised_at(date_string)
+ begin
+ date = nil
+ if date_string =~ /^(\d+)$/
+ # probably seconds since the epoch
+ date = Time.at(Regex.last_match[1].to_i)
+ end
+ date ||= Date.parse(date_string)
+ return '' if date > Date.current
+ return date
+ rescue ArgumentError, TypeError
+ return ''
+ end
+ end
+
+ # Additional processing for meta - currently to make sure warnings
+ # that aren't Archive warnings become additional tags instead
+ def post_process_meta(meta)
+ if meta[:archive_warning_string]
+ result = process_warnings(meta[:archive_warning_string], meta[:freeform_string])
+ meta[:archive_warning_string] = result[:archive_warning_string]
+ meta[:freeform_string] = result[:freeform_string]
+ end
+ meta
+ end
+
+ def process_warnings(warning_string, freeform_string)
+ result = {
+ archive_warning_string: warning_string,
+ freeform_string: freeform_string
+ }
+ new_warning = ''
+ result[:archive_warning_string].split(/\s?,\s?/).each do |warning|
+ if ArchiveWarning.warning? warning
+ new_warning += ', ' unless new_warning.blank?
+ new_warning += warning
+ else
+ result[:freeform_string] = (result[:freeform_string] || '') + ", #{warning}"
+ end
+ end
+ result[:archive_warning_string] = new_warning
+ result
+ end
+
+ # tries to find appropriate existing collections and converts them to comma-separated collection names only
+ def get_collection_names(collection_string)
+ collections = ""
+ collection_string.split(',').map(&:squish).each do |collection_name|
+ collection = Collection.find_by(name: collection_name) || Collection.find_by(title: collection_name)
+ if collection
+ collections += ", " unless collections.blank?
+ collections += collection.name
+ end
+ end
+ collections
+ end
+
+ # determine which value to use for a metadata field
+ def meta_or_default(detected_field, provided_field, default = nil)
+ if @options[:override_tags] || detected_field.blank?
+ if provided_field.blank?
+ detected_field.blank? ? default : detected_field
+ else
+ provided_field
+ end
+ else
+ detected_field
+ end
+ end
+end
diff --git a/app/models/subscription.rb b/app/models/subscription.rb
new file mode 100755
index 0000000..8ad19d7
--- /dev/null
+++ b/app/models/subscription.rb
@@ -0,0 +1,55 @@
+class Subscription < ApplicationRecord
+ VALID_SUBSCRIBABLES = %w(Work User Series).freeze
+
+ belongs_to :user
+ belongs_to :subscribable, polymorphic: true
+
+ validates_presence_of :user
+
+ validates :subscribable_type, inclusion: { in: VALID_SUBSCRIBABLES }
+ # Without the condition, you get a 500 error instead of a validation error
+ # if there's an invalid subscribable type
+ validates :subscribable, presence: true,
+ if: proc { |s| VALID_SUBSCRIBABLES.include?(s.subscribable_type) }
+
+ # Get the subscriptions associated with this work
+ # currently: users subscribed to work, users subscribed to creator of work
+ scope :for_work, lambda {|work|
+ where(["(subscribable_id = ? AND subscribable_type = 'Work')
+ OR (subscribable_id IN (?) AND subscribable_type = 'User')
+ OR (subscribable_id IN (?) AND subscribable_type = 'Series')",
+ work.id,
+ work.pseuds.pluck(:user_id),
+ work.serial_works.pluck(:series_id)]).
+ group(:user_id)
+ }
+
+ # The name of the object to which the user is subscribed
+ def name
+ if subscribable.respond_to?(:login)
+ subscribable.login
+ elsif subscribable.respond_to?(:name)
+ subscribable.name
+ elsif subscribable.respond_to?(:title)
+ subscribable.title
+ else
+ I18n.t("subscriptions.deleted")
+ end
+ end
+
+ def subject_text(creation)
+ authors = if self.class.anonymous_creation?(creation)
+ "Anonymous"
+ else
+ creation.pseuds.map(&:byline).to_sentence
+ end
+ chapter_text = creation.is_a?(Chapter) ? "#{creation.chapter_header} of " : ""
+ work_title = creation.is_a?(Chapter) ? creation.work.title : creation.title
+ text = "#{authors} posted #{chapter_text}#{work_title}"
+ text += subscribable_type == "Series" ? " in the #{self.name} series" : ""
+ end
+
+ def self.anonymous_creation?(creation)
+ (creation.is_a?(Work) && creation.anonymous?) || (creation.is_a?(Chapter) && creation.work.anonymous?)
+ end
+end
diff --git a/app/models/tag.rb b/app/models/tag.rb
new file mode 100755
index 0000000..10c1a06
--- /dev/null
+++ b/app/models/tag.rb
@@ -0,0 +1,1254 @@
+require "unicode_utils/casefold"
+
+class Tag < ApplicationRecord
+ include Searchable
+ include StringCleaner
+ include WorksOwner
+ include Wrangleable
+ include Rails.application.routes.url_helpers
+
+ NAME = "Tag"
+
+ # Note: the order of this array is important.
+ # It is the order that tags are shown in the header of a work
+ # (banned tags are not shown)
+ TYPES = ['Rating', 'ArchiveWarning', 'Category', 'Media', 'Fandom', 'Relationship', 'Character', 'Freeform', 'Banned' ]
+
+ # these tags can be filtered on
+ FILTERS = TYPES - ['Banned', 'Media']
+
+ # these tags show up on works
+ VISIBLE = TYPES - ['Media', 'Banned']
+
+ # these are tags which have been created by users
+ # the order is important, and it is the order in which they appear in the tag wrangling interface
+ USER_DEFINED = ['Fandom', 'Character', 'Relationship', 'Freeform']
+
+ def self.label_name
+ to_s.pluralize
+ end
+
+ delegate :document_type, to: :class
+
+ def document_json
+ TagIndexer.new({}).document(self)
+ end
+
+ def self.taggings_count_expiry(count)
+ # What we are trying to do here is work out a resonable amount of time for a work to be cached for
+ # This should take the number of taggings and divide it by TAGGINGS_COUNT_CACHE_DIVISOR ( defaults to 1500 )
+ # such that for example 1500, would be naturally be tagged for one minute while 105,000 would be cached for
+ # 70 minutes. However we then apply a filter such that the minimum amount of time we will cache something for
+ # would be TAGGINGS_COUNT_MIN_TIME ( defaults to 3 minutes ) and the maximum amount of time would be
+ # TAGGINGS_COUNT_MAX_TIME ( defaulting to an hour ).
+ expiry_time = count / (ArchiveConfig.TAGGINGS_COUNT_CACHE_DIVISOR || 1500)
+ [[expiry_time, (ArchiveConfig.TAGGINGS_COUNT_MIN_TIME || 3)].max, (ArchiveConfig.TAGGINGS_COUNT_MAX_TIME || 50) + count % 20 ].min
+ end
+
+ def taggings_count_cache_key
+ "/v1/taggings_count/#{id}"
+ end
+
+ def write_taggings_to_redis(value)
+ # Atomically set the value while extracting the old value.
+ old_redis_value = REDIS_GENERAL.getset("tag_update_#{id}_value", value).to_i
+
+ # If the value hasn't changed from the saved version or the REDIS version,
+ # there's no need to write an update to the database, so let's just bail
+ # out.
+ return value if value == old_redis_value && value == taggings_count_cache
+
+ # If we've reached here, then the value has changed, and we need to make
+ # sure that the new value is written to the database.
+ REDIS_GENERAL.sadd("tag_update", id)
+ value
+ end
+
+ def taggings_count=(value)
+ expiry_time = Tag.taggings_count_expiry(value)
+ # Only write to the cache if there are more than a number of uses.
+ Rails.cache.write(taggings_count_cache_key, value, race_condition_ttl: 10, expires_in: expiry_time.minutes) if value >= ArchiveConfig.TAGGINGS_COUNT_MIN_CACHE_COUNT
+ write_taggings_to_redis(value)
+ end
+
+ def taggings_count
+ cache_read = Rails.cache.read(taggings_count_cache_key)
+ return cache_read unless cache_read.nil?
+ real_value = taggings.count
+ self.taggings_count = real_value
+ real_value
+ end
+
+ def update_tag_cache
+ cache_read = Rails.cache.read(taggings_count_cache_key)
+ taggings_count if cache_read.nil? || (cache_read < ArchiveConfig.TAGGINGS_COUNT_MIN_CACHE_COUNT)
+ end
+
+ def update_counts_cache(id)
+ tag = Tag.find(id)
+ tag.taggings_count = tag.taggings.count
+ end
+
+ acts_as_commentable
+ def commentable_name
+ self.name
+ end
+
+ # For a tag, the commentable owners are the wranglers of the fandom(s)
+ def commentable_owners
+ # if the tag is a fandom, grab its wranglers or the wranglers of its canonical merger
+ if self.is_a?(Fandom)
+ self.canonical? ? self.wranglers : (self.merger_id ? self.merger.wranglers : [])
+ # if the tag is any other tag, try to grab all the wranglers of all its parent fandoms, if applicable
+ else
+ begin
+ self.fandoms.collect {|f| f.wranglers}.compact.flatten.uniq
+ rescue
+ []
+ end
+ end
+ end
+
+ has_many :mergers, foreign_key: 'merger_id', class_name: 'Tag'
+ belongs_to :merger, class_name: "Tag"
+ belongs_to :fandom
+ belongs_to :media
+ belongs_to :last_wrangler, polymorphic: true
+
+ has_many :filter_taggings, foreign_key: 'filter_id', dependent: :destroy
+ has_many :filtered_works, through: :filter_taggings, source: :filterable, source_type: 'Work'
+ has_many :filtered_external_works, through: :filter_taggings, source: :filterable, source_type: "ExternalWork"
+ has_many :filtered_collections, through: :filter_taggings, source: :filterable, source_type: "Collection"
+
+ has_one :filter_count, foreign_key: 'filter_id'
+ has_many :direct_filter_taggings,
+ -> { where(inherited: 0) },
+ class_name: "FilterTagging",
+ foreign_key: 'filter_id'
+
+ # not used anymore? has_many :direct_filtered_works, through: :direct_filter_taggings, source: :filterable, source_type: 'Work'
+
+ has_many :common_taggings, foreign_key: 'common_tag_id', dependent: :destroy
+ has_many :child_taggings, class_name: 'CommonTagging', as: :filterable
+ has_many :children, through: :child_taggings, source: :common_tag
+ has_many :parents,
+ through: :common_taggings,
+ source: :filterable,
+ source_type: 'Tag',
+ before_remove: :destroy_common_tagging,
+ after_remove: :update_wrangler
+
+ has_many :meta_taggings, foreign_key: 'sub_tag_id', dependent: :destroy
+ has_many :meta_tags, through: :meta_taggings, source: :meta_tag, before_remove: :destroy_meta_tagging
+ has_many :sub_taggings, class_name: 'MetaTagging', foreign_key: 'meta_tag_id', dependent: :destroy
+ has_many :sub_tags, through: :sub_taggings, source: :sub_tag, before_remove: :destroy_sub_tagging
+ has_many :direct_meta_tags, -> { where('meta_taggings.direct = 1') }, through: :meta_taggings, source: :meta_tag
+ has_many :direct_sub_tags, -> { where('meta_taggings.direct = 1') }, through: :sub_taggings, source: :sub_tag
+ has_many :taggings, as: :tagger
+ has_many :works, through: :taggings, source: :taggable, source_type: 'Work'
+ has_many :collections, through: :taggings, source: :taggable, source_type: "Collection"
+
+ has_many :bookmarks, through: :taggings, source: :taggable, source_type: 'Bookmark'
+ has_many :external_works, through: :taggings, source: :taggable, source_type: 'ExternalWork'
+ has_many :approved_collections, through: :filtered_works
+
+ has_many :favorite_tags, dependent: :destroy
+
+ has_many :set_taggings, dependent: :destroy
+ has_many :tag_sets, through: :set_taggings
+ has_many :owned_tag_sets, through: :tag_sets
+
+ has_many :tag_set_associations, dependent: :destroy
+ has_many :parent_tag_set_associations, class_name: 'TagSetAssociation', foreign_key: 'parent_tag_id', dependent: :destroy
+
+ validates :name, presence: true
+ validates :name, uniqueness: true
+ validates :name,
+ length: { minimum: 1,
+ message: "cannot be blank." }
+ validates :name,
+ length: { maximum: ArchiveConfig.TAG_MAX,
+ message: "^Tag name '%{value}' is too long -- try using less than %{count} characters or using commas to separate your tags." }
+ validates :name,
+ format: { with: /\A[^,,、*<>^{}=`\\%]+\z/,
+ message: "^Tag name '%{value}' cannot include the following restricted characters: , ^ * < > { } = ` , 、 \\ %" }
+ validates :name,
+ format: { without: /\A\p{Cf}+\z/,
+ message: "^Tag name cannot be blank." }
+ validates :sortable_name, presence: true
+
+ validate :unwrangleable_status
+ def unwrangleable_status
+ return unless unwrangleable?
+
+ self.errors.add(:unwrangleable, "can't be set on a canonical or synonymized tag.") if canonical? || merger_id.present?
+ self.errors.add(:unwrangleable, "can't be set on an unsorted tag.") if is_a?(UnsortedTag)
+ end
+
+ before_validation :check_synonym
+ def check_synonym
+ if !self.new_record? && self.name_changed?
+ # ordinary wranglers can change case and accents but not punctuation or the actual letters in the name
+ # admins can change tags with no restriction
+ unless User.current_user.is_a?(Admin) || only_case_changed?
+ self.errors.add(:name, "can only be changed by an admin.")
+ end
+ end
+ if self.merger_id
+ if self.canonical?
+ self.errors.add(:base, "A canonical can't be a synonym")
+ end
+ if self.merger_id == self.id
+ self.errors.add(:base, "A tag can't be a synonym of itself.")
+ end
+ unless self.merger.class == self.class
+ self.errors.add(:base, "A tag can only be a synonym of a tag in the same category as itself.")
+ end
+ end
+ end
+
+ before_validation :squish_name
+ def squish_name
+ self.name = name.squish if self.name
+ end
+
+ before_validation :set_sortable_name
+ def set_sortable_name
+ if sortable_name.blank?
+ self.sortable_name = remove_articles_from_string(self.name)
+ end
+ end
+
+ after_update :queue_flush_work_cache
+ def queue_flush_work_cache
+ async_after_commit(:flush_work_cache) if saved_change_to_name? || saved_change_to_type?
+ end
+
+ def flush_work_cache
+ self.work_ids.each do |work|
+ Work.expire_work_blurb_version(work)
+ end
+ end
+
+ # queue_flush_work_cache will update the cached work (bookmarkable) info for
+ # bookmarks, but we still need to expire the portion of bookmark blurbs that
+ # contains the bookmarker's tags.
+ after_update :queue_flush_bookmark_cache
+ def queue_flush_bookmark_cache
+ async_after_commit(:flush_bookmark_cache) if saved_change_to_name?
+ end
+
+ def flush_bookmark_cache
+ self.bookmarks.each do |bookmark|
+ ActionController::Base.new.expire_fragment("bookmark-owner-blurb-#{bookmark.cache_key}-v3")
+ ActionController::Base.new.expire_fragment("bookmark-blurb-#{bookmark.cache_key}-v3")
+ end
+ end
+
+ before_save :set_last_wrangler
+ def set_last_wrangler
+ unless User.current_user.nil?
+ self.last_wrangler = User.current_user
+ end
+ end
+ def update_wrangler(tag)
+ unless User.current_user.nil?
+ self.update(last_wrangler: User.current_user)
+ end
+ end
+
+ after_save :check_type_changes, if: :saved_change_to_type?
+ def check_type_changes
+ return if type_before_last_save.nil?
+
+ retyped = Tag.find(self.id)
+
+ # Clean up invalid CommonTaggings.
+ retyped.common_taggings.destroy_invalid
+ retyped.child_taggings.destroy_invalid
+
+ # If the tag has just become a Fandom, it needs the Uncategorized media
+ # added to it manually (the after_save hook on Fandom won't take effect,
+ # since it's not a Fandom yet)
+ retyped.add_media_for_uncategorized if retyped.is_a?(Fandom)
+ end
+
+ # Callback for has_many :parents.
+ # Destroy the common tagging so we trigger CommonTagging's callbacks when a
+ # parent is removed. We're specifically interested in the update_search
+ # callback that will reindex the tag and return it to the unwrangled bin.
+ def destroy_common_tagging(parent)
+ self.common_taggings.find_by(filterable_id: parent.id).try(:destroy)
+ end
+
+ scope :id_only, -> { select("tags.id") }
+
+ scope :canonical, -> { where(canonical: true) }
+ scope :noncanonical, -> { where(canonical: false) }
+ scope :nonsynonymous, -> { noncanonical.where(merger_id: nil) }
+ scope :synonymous, -> { noncanonical.where("merger_id IS NOT NULL") }
+ scope :unfilterable, -> { nonsynonymous.where(unwrangleable: false) }
+ scope :unwrangleable, -> { where(unwrangleable: true) }
+
+ scope :in_use, -> { where("canonical = 1 OR taggings_count_cache > 0") }
+ scope :first_class, -> { joins("LEFT JOIN `meta_taggings` ON meta_taggings.sub_tag_id = tags.id").where("meta_taggings.id IS NULL") }
+
+ # Tags that have sub tags
+ scope :meta_tag, -> { joins(:sub_taggings).where("meta_taggings.id IS NOT NULL").group("tags.id") }
+ # Tags that don't have sub tags
+ scope :non_meta_tag, -> { joins(:sub_taggings).where("meta_taggings.id IS NULL").group("tags.id") }
+
+ scope :by_popularity, -> { order('taggings_count_cache DESC') }
+ scope :by_name, -> { order('sortable_name ASC') }
+ scope :by_date, -> { order('created_at DESC') }
+ scope :visible, -> { where('type in (?)', VISIBLE).by_name }
+
+ scope :by_pseud, lambda {|pseud|
+ joins(works: :pseuds).
+ where(pseuds: {id: pseud.id})
+ }
+
+ scope :by_type, lambda {|*types| where(types.first.blank? ? "" : {type: types.first})}
+ scope :with_type, lambda {|type| where({type: type}) }
+
+ # This will return all tags that have one of the given tags as a parent
+ scope :with_parents, lambda {|parents|
+ joins(:common_taggings).where("filterable_id in (?)", parents.first.is_a?(Integer) ? parents : (parents.respond_to?(:pluck) ? parents.pluck(:id) : parents.collect(&:id)))
+ }
+
+ scope :with_no_parents, -> {
+ joins("LEFT JOIN common_taggings ON common_taggings.common_tag_id = tags.id").
+ where("filterable_id IS NULL")
+ }
+
+ scope :starting_with, lambda {|letter| where('SUBSTR(name,1,1) = ?', letter)}
+
+ scope :visible_to_all_with_count, -> {
+ joins(:filter_count).
+ select("tags.*, filter_counts.public_works_count as count").
+ where('filter_counts.public_works_count > 0 AND tags.canonical = 1')
+ }
+
+ scope :visible_to_registered_user_with_count, -> {
+ joins(:filter_count).
+ select("tags.*, filter_counts.unhidden_works_count as count").
+ where('filter_counts.unhidden_works_count > 0 AND tags.canonical = 1')
+ }
+
+ scope :public_top, lambda { |tag_count|
+ visible_to_all_with_count.
+ limit(tag_count).
+ order('filter_counts.public_works_count DESC')
+ }
+
+ scope :unhidden_top, lambda { |tag_count|
+ visible_to_registered_user_with_count.
+ limit(tag_count).
+ order('filter_counts.unhidden_works_count DESC')
+ }
+
+ scope :popular, -> {
+ (User.current_user.is_a?(Admin) || User.current_user.is_a?(User)) ?
+ visible_to_registered_user_with_count.order('filter_counts.unhidden_works_count DESC') :
+ visible_to_all_with_count.order('filter_counts.public_works_count DESC')
+ }
+
+ scope :random, -> {
+ (User.current_user.is_a?(Admin) || User.current_user.is_a?(User)) ?
+ visible_to_registered_user_with_count.random_order :
+ visible_to_all_with_count.random_order
+ }
+
+ scope :with_count, -> {
+ (User.current_user.is_a?(Admin) || User.current_user.is_a?(User)) ?
+ visible_to_registered_user_with_count : visible_to_all_with_count
+ }
+
+ scope :for_collections, lambda { |collections|
+ joins(filtered_works: :approved_collection_items).merge(Work.posted)
+ .where("collection_items.collection_id IN (?)", collections.collect(&:id))
+ }
+
+ scope :for_collection, lambda { |collection| for_collections([collection]) }
+
+ scope :for_collections_with_count, lambda { |collections|
+ for_collections(collections).
+ select("tags.*, count(tags.id) as count").
+ group(:id).
+ order(:name)
+ }
+
+ scope :with_scoped_count, lambda {
+ select("tags.*, count(tags.id) as count").
+ group(:id)
+ }
+
+ scope :by_relationships, lambda {|relationships|
+ select("DISTINCT tags.*").
+ joins(:children).
+ where('children_tags.id IN (?)', relationships.collect(&:id))
+ }
+
+ # Get the tags for a challenge's signups, checking both the main tag set
+ # and the optional tag set for each prompt
+ def self.in_challenge(collection, prompt_type=nil)
+ ['', 'optional_'].map { |tag_set_type|
+ join = "INNER JOIN set_taggings ON (tags.id = set_taggings.tag_id)
+ INNER JOIN tag_sets ON (set_taggings.tag_set_id = tag_sets.id)
+ INNER JOIN prompts ON (prompts.#{tag_set_type}tag_set_id = tag_sets.id)
+ INNER JOIN challenge_signups ON (prompts.challenge_signup_id = challenge_signups.id)"
+
+ tags = self.joins(join).where("challenge_signups.collection_id = ?", collection.id)
+ tags = tags.where("prompts.type = ?", prompt_type) if prompt_type.present?
+ tags
+ }.flatten.compact.uniq
+ end
+
+ scope :requested_in_challenge, lambda {|collection|
+ in_challenge(collection, 'Request')
+ }
+
+ scope :offered_in_challenge, lambda {|collection|
+ in_challenge(collection, 'Offer')
+ }
+
+ # Code for delayed jobs:
+ include AsyncWithActiveJob
+ self.async_job_class = TagMethodJob
+
+ # Class methods
+
+
+ def self.in_prompt_restriction(restriction)
+ joins("INNER JOIN set_taggings ON set_taggings.tag_id = tags.id
+ INNER JOIN tag_sets ON tag_sets.id = set_taggings.tag_set_id
+ INNER JOIN owned_tag_sets ON owned_tag_sets.tag_set_id = tag_sets.id
+ INNER JOIN owned_set_taggings ON owned_set_taggings.owned_tag_set_id = owned_tag_sets.id
+ INNER JOIN prompt_restrictions ON (prompt_restrictions.id = owned_set_taggings.set_taggable_id AND owned_set_taggings.set_taggable_type = 'PromptRestriction')").
+ where("prompt_restrictions.id = ?", restriction.id)
+ end
+
+ def self.by_name_without_articles(fieldname = "name")
+ fieldname = "name" unless fieldname.match(/^([\w]+\.)?[\w]+$/)
+ order(Arel.sql("case when lower(substring(#{fieldname} from 1 for 4)) = 'the ' then substring(#{fieldname} from 5)
+ when lower(substring(#{fieldname} from 1 for 2)) = 'a ' then substring(#{fieldname} from 3)
+ when lower(substring(#{fieldname} from 1 for 3)) = 'an ' then substring(#{fieldname} from 4)
+ else #{fieldname}
+ end"))
+ end
+
+ def self.in_tag_set(tag_set)
+ if tag_set.is_a?(OwnedTagSet)
+ joins(:set_taggings).where("set_taggings.tag_set_id = ?", tag_set.tag_set_id)
+ else
+ joins(:set_taggings).where("set_taggings.tag_set_id = ?", tag_set.id)
+ end
+ end
+
+ # gives you [parent_name, child_name], [parent_name, child_name], ...
+ def self.parent_names(parent_type = 'fandom')
+ joins(:parents).where("parents_tags.type = ?", parent_type.capitalize).
+ select("parents_tags.name as parent_name, tags.name as child_name").
+ by_name_without_articles("parent_name").
+ by_name_without_articles("child_name")
+ end
+
+ # Because this can be called by a gigantor tag set and all we need are names not objects,
+ # we do an end-run around ActiveRecord and just get the results straight from the db, but
+ # we borrow the sql from parent_names above
+ # returns a hash[parent_name] = child_names
+ def self.names_by_parent(child_relation, parent_type = 'fandom')
+ hash = {}
+ results = ActiveRecord::Base.connection.execute(child_relation.parent_names(parent_type).to_sql)
+ results.each {|row| hash[row.first] ||= Array.new; hash[row.first] << row.second}
+ hash
+ end
+
+ # Used for associations, such as work.fandoms.string
+ # Yields a comma-separated list of tag names
+ def self.string
+ all.map{|tag| tag.name}.join(ArchiveConfig.DELIMITER_FOR_OUTPUT)
+ end
+
+ # Use the tag name in urls and escape url-unfriendly characters
+ def to_param
+ # can't find a tag with a name that hasn't been saved yet
+ saved_name = self.name_changed? ? self.name_was : self.name
+ saved_name.gsub('/', '*s*').gsub('&', '*a*').gsub('.', '*d*').gsub('?', '*q*').gsub('#', '*h*')
+ end
+
+ def display_name
+ name
+ end
+
+ # Make sure that the global ID doesn't depend on the type, so that we don't
+ # experience errors when switching types:
+ def to_global_id(options = {})
+ GlobalID.create(becomes(Tag), options)
+ end
+
+ ## AUTOCOMPLETE
+ # set up autocomplete and override some methods
+ include AutocompleteSource
+ def autocomplete_prefixes
+ prefixes = [ "autocomplete_tag_#{type.downcase}", "autocomplete_tag_all" ]
+ prefixes
+ end
+
+ def add_to_autocomplete(score = nil)
+ if eligible_for_fandom_autocomplete?
+ parents.each do |parent|
+ add_to_fandom_autocomplete(parent, score) if parent.is_a?(Fandom)
+ end
+ end
+ super
+ end
+
+ def add_to_fandom_autocomplete(fandom, score = nil)
+ score ||= autocomplete_score
+ REDIS_AUTOCOMPLETE.zadd(self.transliterate("autocomplete_fandom_#{fandom.name.downcase}_#{type.downcase}"), score, autocomplete_value)
+ end
+
+ def remove_from_autocomplete
+ super
+
+ return unless was_eligible_for_fandom_autocomplete?
+
+ parents.each do |parent|
+ remove_from_fandom_autocomplete(parent) if parent.is_a?(Fandom)
+ end
+ end
+
+ def remove_from_fandom_autocomplete(fandom)
+ REDIS_AUTOCOMPLETE.zrem(self.transliterate("autocomplete_fandom_#{fandom.name.downcase}_#{type.downcase}"), autocomplete_value)
+ end
+
+ def eligible_for_fandom_autocomplete?
+ (self.is_a?(Character) || self.is_a?(Relationship)) && canonical
+ end
+
+ def was_eligible_for_fandom_autocomplete?
+ (self.is_a?(Character) || self.is_a?(Relationship)) && (canonical || canonical_before_last_save)
+ end
+
+ def remove_stale_from_autocomplete
+ super
+
+ return unless was_eligible_for_fandom_autocomplete?
+
+ parents.each do |parent|
+ REDIS_AUTOCOMPLETE.zrem(self.transliterate("autocomplete_fandom_#{parent.name.downcase}_#{type.downcase}"), autocomplete_value_before_last_save) if parent.is_a?(Fandom)
+ end
+ end
+
+ def self.parse_autocomplete_value(current_autocomplete_value)
+ current_autocomplete_value.split(AUTOCOMPLETE_DELIMITER, 2)
+ end
+
+
+ def autocomplete_score
+ taggings_count_cache
+ end
+
+ # look up tags that have been wrangled into a given fandom
+ def self.autocomplete_fandom_lookup(options = {})
+ options.reverse_merge!({term: "", tag_type: "character", fandom: "", fallback: true})
+ search_param = options[:term]
+ tag_type = options[:tag_type]
+ fandoms = Tag.get_search_terms(options[:fandom])
+
+ # fandom sets are too small to bother breaking up
+ # we're just getting ALL the tags in the set(s) for the fandom(s) and then manually matching
+ results = []
+ fandoms.each do |single_fandom|
+ if search_param.blank?
+ # just return ALL the characters
+ results += REDIS_AUTOCOMPLETE.zrevrange(self.transliterate("autocomplete_fandom_#{single_fandom}_#{tag_type}"), 0, -1)
+ else
+ search_regex = Tag.get_search_regex(search_param)
+ results += REDIS_AUTOCOMPLETE.zrevrange(self.transliterate("autocomplete_fandom_#{single_fandom}_#{tag_type}"), 0, -1).select { |tag| tag.match(search_regex) }
+ end
+ end
+ if options[:fallback] && results.empty? && search_param.length > 0
+ # do a standard tag lookup instead
+ Tag.autocomplete_lookup(search_param: search_param, autocomplete_prefix: "autocomplete_tag_#{tag_type}")
+ else
+ results
+ end
+ end
+
+ ## END AUTOCOMPLETE
+
+ # Substitute characters that are particularly prone to cause trouble in urls
+ def self.find_by_name(string)
+ return unless string.is_a? String
+
+ self.find_by(name: from_param(string))
+ end
+
+ def self.find_by_name!(string)
+ return unless string.is_a? String
+
+ self.find_by!(name: from_param(string))
+ end
+
+ def self.from_param(string)
+ string.gsub(
+ /\*[sadqh]\*/,
+ '*s*' => '/',
+ '*a*' => '&',
+ '*d*' => '.',
+ '*q*' => '?',
+ '*h*' => '#'
+ )
+ end
+
+ # If a tag by this name exists in another class, add a suffix to disambiguate them
+ def self.find_or_create_by_name(new_name)
+ if new_name && new_name.is_a?(String)
+ new_name.squish!
+ tag = Tag.find_by_name(new_name)
+ # if the tag exists and has the proper class, or it is an unsorted tag and it can be sorted to the self class
+ if tag && (tag.class == self || tag.class == UnsortedTag && tag = tag.recategorize(self.to_s))
+ tag
+ elsif tag
+ self.find_or_create_by_name(new_name + " - " + self.to_s)
+ else
+ self.create(name: new_name, type: self.to_s)
+ end
+ end
+ end
+
+ def self.create_canonical(name, adult=false)
+ tag = self.find_or_create_by_name(name)
+ raise "how did this happen?" unless tag
+ tag.update_attribute(:canonical,true)
+ tag.update_attribute(:adult, adult)
+ raise "how did this happen?" unless tag.canonical?
+ return tag
+ end
+
+ # Inherited tag classes can set this to indicate types of tags with which they may have a parent/child
+ # relationship (ie. media: parent, fandom: child; fandom: parent, character: child)
+ def parent_types
+ []
+ end
+ def child_types
+ []
+ end
+
+ # Instance methods that are common to all subclasses (may be overridden in the subclass)
+
+ def unfilterable?
+ !(self.canonical? || self.unwrangleable? || self.merger_id.present? || self.mergers.any?)
+ end
+
+ # Returns true if a tag has been used in posted works that are revealed and not hidden
+ def has_posted_works? # rubocop:disable Naming/PredicateName
+ self.works.posted.revealed.unhidden.any?
+ end
+
+ # sort tags by name
+ def <=>(another_tag)
+ name.downcase <=> another_tag.name.downcase
+ end
+
+ # only allow changing the tag type for unwrangled tags not used in any tag sets or on any works
+ def can_change_type?
+ self.unfilterable? && self.set_taggings.count == 0 && self.works.count == 0
+ end
+
+ # tags having their type changed need to be reloaded to be seen as an instance of the proper subclass
+ def recategorize(new_type)
+ self.update_attribute(:type, new_type)
+ # return a new instance of the tag, with the correct class
+ Tag.find(self.id)
+ end
+
+ #### FILTERING ####
+
+ before_update :reindex_associated_for_name_or_type_change
+ def reindex_associated_for_name_or_type_change
+ return unless name_changed? || type_changed?
+
+ reindex_pseuds = (type == "Fandom") || (type_was == "Fandom")
+ async_after_commit(:reindex_associated, reindex_pseuds)
+ end
+
+ # Reindex anything even remotely related to this tag. This is overkill in
+ # most cases, but necessary when something fundamental like the name or type
+ # of a tag has changed.
+ def reindex_associated(reindex_pseuds = false)
+ works.reindex_all
+ external_works.reindex_all
+ bookmarks.reindex_all
+
+ filtered_works.reindex_all
+ filtered_external_works.reindex_all
+
+ Series.joins(works: :taggings)
+ .merge(self.taggings).reindex_all
+ Series.joins(works: :filter_taggings)
+ .merge(self.filter_taggings).reindex_all
+
+ # We only want to reindex pseuds if this tag is a Fandom. Unfortunately, we
+ # can't just check the current type, because tags can change type, and we'd
+ # still need to reindex if the old type was Fandom. So we have an option to
+ # control it.
+ if reindex_pseuds
+ Pseud.joins(works: :filter_taggings)
+ .merge(self.direct_filter_taggings).reindex_all
+ end
+ end
+
+ # The version of the tag that should be used for filtering, if any
+ def filter
+ self.canonical? ? self : ((self.merger && self.merger.canonical?) ? self.merger : nil)
+ end
+
+ # Update filters for all works and external works directly tagged with this
+ # tag.
+ def update_filters_for_taggables
+ works.update_filters
+ external_works.update_filters
+ collections.update_filters
+ end
+
+ # Update filters for all works and external works that already have this tag
+ # as one of their filters.
+ def update_filters_for_filterables
+ filtered_works.update_filters
+ filtered_external_works.update_filters
+ filtered_collections.update_filters
+ end
+
+ # When canonical or merger_id changes, only the items directly tagged with
+ # this tag need their filters updated, so we queue up a call to
+ # update_filters_for_taggables after commit.
+ #
+ # Note that when a tag becomes non-canonical, all of its filter-taggings need
+ # to be deleted. But when a tag becomes non-canonical, all of its mergers and
+ # sub-tags will be deleted, which will result in the necessary items having
+ # their filters fixed.
+ after_update :update_filters_for_canonical_or_merger_change
+ def update_filters_for_canonical_or_merger_change
+ return unless saved_change_to_canonical? || saved_change_to_merger_id?
+
+ async_after_commit(:update_filters_for_taggables)
+ end
+
+ # Recalculate the inherited metatags for this tag, and once those changes
+ # are committed, update the filters for every work or external work that's
+ # filter-tagged with this tag.
+ def update_inherited_meta_tags
+ MetaTagging.transaction do
+ InheritedMetaTagUpdater.new(self).update
+
+ sub_tags.find_each do |sub_tag|
+ InheritedMetaTagUpdater.new(sub_tag).update
+ end
+ end
+
+ async_after_commit(:update_filters_for_filterables)
+ end
+
+ # When deleting a metatag, we destroy the meta-tagging first to trigger the
+ # appropriate destroy callback.
+ def destroy_meta_tagging(meta_tag)
+ meta_taggings.find_by(meta_tag: meta_tag)&.destroy
+ end
+
+ # When deleting a subtag, we destroy the sub-tagging first to trigger the
+ # appropriate destroy callback.
+ def destroy_sub_tagging(sub_tag)
+ sub_taggings.find_by(sub_tag: sub_tag)&.destroy
+ end
+
+ def reset_filter_count
+ FilterCount.enqueue_filter(filter)
+ end
+
+ #### END FILTERING ####
+
+ # methods for counting visible
+
+ def visible_works_count
+ User.current_user.nil? ? self.works.posted.unhidden.unrestricted.count : self.works.posted.unhidden.count
+ end
+
+ def visible_bookmarks_count
+ self.bookmarks.is_public.count
+ end
+
+ def visible_external_works_count
+ self.external_works.where(hidden_by_admin: false).count
+ end
+
+ def banned
+ self.is_a?(Banned)
+ end
+
+ def synonyms
+ self.canonical? ? self.mergers : [self.merger] + self.merger.mergers - [self]
+ end
+
+ # Add a common tagging association
+ def add_association(tag)
+ build_association(tag).save
+ end
+
+ def has_parent?(tag)
+ self.common_taggings.where(filterable_id: tag.id).count > 0
+ end
+
+ def has_child?(tag)
+ self.child_taggings.where(common_tag_id: tag.id).count > 0
+ end
+
+ def associations_to_remove; @associations_to_remove ? @associations_to_remove : []; end
+ def associations_to_remove=(taglist)
+ taglist.reject {|tid| tid.blank?}.each do |tag_id|
+ remove_association(tag_id)
+ end
+ end
+
+ # Determine how two tags are related and divorce them from each other
+ def remove_association(tag_id)
+ tag = Tag.find(tag_id)
+
+ if tag.class == self.class
+ tag.update(merger: nil) if tag.merger == self
+ meta_taggings.where(direct: true, meta_tag: tag).destroy_all
+ sub_taggings.where(direct: true, sub_tag: tag).destroy_all
+ else
+ common_taggings.where(filterable: tag).destroy_all
+ child_taggings.where(common_tag: tag).destroy_all
+ end
+
+ tag.touch
+ self.touch
+ end
+
+ # When canonical or merger is changed, we need to make sure that the
+ # associations (parents, children, metatags, mergers) are fixed. Note that
+ # these are all async calls, so we use async_after_commit to reduce the
+ # likelihood of issues with stale data.
+ before_update :update_associations_for_canonical_or_merger_change
+ def update_associations_for_canonical_or_merger_change
+ if (merger_id_changed? && merger_id.present?) ||
+ (canonical_changed? && !canonical?)
+ async_after_commit(:transfer_or_remove_favorite_tags)
+ async_after_commit(:transfer_or_remove_associations)
+ end
+ end
+
+ # Make it possible to go from a synonym to a canonical in one step.
+ before_validation :reset_merger_when_becoming_canonical
+ def reset_merger_when_becoming_canonical
+ return unless self.canonical_changed? && self.canonical?
+
+ self.merger_id = nil
+ end
+
+ # If this tag has a canonical merger, transfer associations to the merger.
+ # Then, regardless of whether it has a merger, delete all canonical
+ # associations (i.e. meta taggings, and associations where this tag is the
+ # parent).
+ def transfer_or_remove_associations
+ transaction do
+ # Try to prevent some concurrency issues.
+ lock!
+
+ # Abort if the tag has changed back to being canonical between the time
+ # this was enqueued and the time it ran.
+ return if self.canonical?
+
+ add_associations_to_merger if self.merger&.canonical?
+
+ self.mergers.find_each { |tag| tag.update(merger_id: nil) }
+ self.child_taggings.destroy_all
+ self.sub_taggings.destroy_all
+ self.meta_taggings.destroy_all
+ end
+ end
+
+ # When we make this tag a synonym of another canonical tag, we want to move
+ # all the associations this tag has (subtags, metatags, etc) over to that
+ # canonical tag.
+ #
+ # The callbacks that occur when changing the associations will trigger the
+ # necessary reindexing, so we don't need to call extra reindexing code here.
+ def add_associations_to_merger
+ self.parents.find_each do |tag|
+ self.merger.add_association(tag)
+ end
+
+ self.children.find_each do |tag|
+ self.merger.add_association(tag)
+ end
+
+ self.mergers.find_each { |tag| tag.update(merger: self.merger) }
+
+ merger.parents.where(type: %w[Media Fandom]).find_each do |tag|
+ self.add_association(tag)
+ end
+
+ self.direct_meta_tags.find_each do |tag|
+ meta_tagging = self.merger.meta_taggings.find_or_initialize_by(meta_tag: tag)
+ meta_tagging.update(direct: true)
+ end
+
+ self.direct_sub_tags.find_each do |tag|
+ sub_tagging = self.merger.sub_taggings.find_or_initialize_by(sub_tag: tag)
+ sub_tagging.update(direct: true)
+ end
+ end
+
+ # If this tag has a canonical merger, move all favorite tags to the merger.
+ # Otherwise, delete all favorite tags.
+ def transfer_or_remove_favorite_tags
+ if merger&.canonical
+ favorite_tags.find_each do |ft|
+ ft.update(tag_id: merger_id)
+ end
+ end
+
+ # We perform this after the if (instead of as a separate branch) because
+ # updating the tag_id can fail if the user has both this tag and its merger
+ # as favorite tags. So we want to clean up any failures, which just so
+ # happens to be exactly the same thing we need to do if there's no
+ # canonical merger to transfer the favorite tags to.
+ favorite_tags.find_each(&:destroy)
+ end
+
+ attr_reader :meta_tag_string, :sub_tag_string, :merger_string
+
+ # Uses the value of parent_types to determine whether the passed-in tag
+ # should be added as a parent or a child, and then generates the association
+ # (if it doesn't already exist). If it does already exist, returns the
+ # existing CommonTagging object.
+ def build_association(tag)
+ if parent_types.include?(tag&.type)
+ common_taggings.find_or_initialize_by(filterable: tag)
+ else
+ child_taggings.find_or_initialize_by(common_tag: tag)
+ end
+ end
+
+ # Splits up the passed-in string into a sequence of individual tag names,
+ # then finds (and yields) the tag for each. Used by add_association_string,
+ # meta_tag_string=, and sub_tag_string=.
+ def parse_tag_string(tag_string)
+ tag_string.split(",").map(&:squish).each do |name|
+ yield name, Tag.find_by_name(name)
+ end
+ end
+
+ # Try to create new associations with the tags of type tag_type whose names
+ # are listed in tag_string.
+ def add_association_string(tag_type, tag_string)
+ parse_tag_string(tag_string) do |name, parent|
+ prefix = "Cannot add association to '#{name}':"
+ if parent && parent.type != tag_type
+ errors.add(:base, "#{prefix} #{parent.type} added in #{tag_type} field.")
+ else
+ association = build_association(parent)
+ save_and_gather_errors(association, prefix)
+ end
+ end
+ end
+
+ # Save an item to the database, if it's valid. If it's invalid, read in the
+ # error messages from the item and copy them over to this tag.
+ def save_and_gather_errors(item, prefix)
+ return unless item.new_record? || item.changed?
+ return if item.valid? && item.save
+
+ item.errors.full_messages.each do |message|
+ errors.add(:base, "#{prefix} #{message}")
+ end
+ end
+
+ # Find and destroy all invalid CommonTaggings and MetaTaggings associated
+ # with this tag.
+ def destroy_invalid_associations
+ common_taggings.destroy_invalid
+ child_taggings.destroy_invalid
+ meta_taggings.destroy_invalid
+ sub_taggings.destroy_invalid
+ end
+
+ # defines fandom_string=, media_string=, character_string=, relationship_string=, freeform_string=
+ %w(Fandom Media Character Relationship Freeform).each do |tag_type|
+ attr_reader "#{tag_type.downcase}_string"
+
+ define_method("#{tag_type.downcase}_string=") do |tag_string|
+ add_association_string(tag_type, tag_string)
+ end
+ end
+
+ def meta_tag_string=(tag_string)
+ parse_tag_string(tag_string) do |name, parent|
+ meta_tagging = meta_taggings.find_or_initialize_by(meta_tag: parent)
+ meta_tagging.direct = true
+ save_and_gather_errors(meta_tagging, "Invalid metatag '#{name}':")
+ end
+ end
+
+ def sub_tag_string=(tag_string)
+ parse_tag_string(tag_string) do |name, sub|
+ sub_tagging = sub_taggings.find_or_initialize_by(sub_tag: sub)
+ sub_tagging.direct = true
+ save_and_gather_errors(sub_tagging, "Invalid subtag '#{name}':")
+ end
+ end
+
+ def syn_string
+ self.merger.name if self.merger
+ end
+
+ # Make this tag a synonym of another tag -- tag_string is the name of the other tag (which should be canonical)
+ # NOTE for potential confusion
+ # "merger" is the canonical tag of which this one will be a synonym
+ # "mergers" are the tags which are (currently) synonyms of THIS one
+ def syn_string=(tag_string)
+ # If the tag_string is blank, our tag should be given no merger
+ if tag_string.blank?
+ self.merger_id = nil
+ return
+ end
+
+ new_merger = Tag.find_by(name: tag_string)
+
+ # Bail out if the new merger is the same as the current merger
+ return if new_merger && new_merger == self.merger
+
+ # Return an error if a non-admin tries to make a canonical into a synonym
+ if self.canonical? && !User.current_user.is_a?(Admin)
+ self.errors.add(:base, "Only an admin can make a canonical tag into a synonym of another tag.")
+ return
+ end
+
+ if new_merger && new_merger == self
+ self.errors.add(:base, tag_string + " is considered the same as " + self.name + " by the database.")
+ elsif new_merger && !new_merger.canonical?
+ self.errors.add(:base, "#{new_merger.name} is not a canonical tag. Please make it canonical before adding synonyms to it.")
+ elsif new_merger && new_merger.class != self.class
+ self.errors.add(:base, new_merger.name + " is a #{new_merger.type.to_s.downcase}. Synonyms must belong to the same category.")
+ elsif !new_merger
+ new_merger = self.class.new(name: tag_string, canonical: true)
+ unless new_merger.save
+ self.errors.add(:base, tag_string + " could not be saved. Please make sure that it's a valid tag name.")
+ end
+ end
+
+ # If we don't have any errors, update the tag to add the new merger
+ if new_merger && self.errors.empty?
+ self.canonical = false
+ self.merger_id = new_merger.id
+ end
+ end
+
+ def merger_string=(tag_string)
+ names = tag_string.split(',').map(&:squish)
+ names.each do |name|
+ syn = Tag.find_by_name(name)
+ if syn && !syn.canonical?
+ syn.update(merger_id: self.id)
+ end
+ end
+ end
+
+ # unwrangleable:
+ # - A boolean stored in the tags table
+ # - Default false
+ # - Set to true by wranglers on tags that should be excluded from the wrangling process altogether. Example: freeform tags like "idk how to explain it but trust me"
+ #
+ # unwrangled:
+ # - A computed value
+ # - True for "orphan" tags yet to be tied to something (fandom, character, etc.) by wranglers
+ # - Exact meaning may change depending on the nature of the tag (search for definitions of unwrangled? overriding this one)
+ #
+ def unwrangled?
+ common_taggings.empty?
+ end
+
+ #################################
+ ## SEARCH #######################
+ #################################
+
+ def unwrangled_query(tag_type, options = {})
+ self_type = %w[Character Fandom Media].include?(self.type) ? self.type.downcase : "fandom"
+ TagQuery.new(options.merge(
+ type: tag_type,
+ unwrangleable: false,
+ wrangled: false,
+ has_posted_works: true,
+ "pre_#{self_type}_ids": [self.id],
+ per_page: Tag.per_page
+ ))
+ end
+
+ def unwrangled_tags(tag_type, options = {})
+ unwrangled_query(tag_type, options).search_results
+ end
+
+ def unwrangled_tag_count(tag_type)
+ key = "unwrangled_#{tag_type}_#{self.id}_#{self.updated_at}"
+ Rails.cache.fetch(key, expires_in: 4.hours) do
+ unwrangled_query(tag_type).count
+ end
+ end
+
+ def suggested_parent_tags(parent_type, options = {})
+ limit = options[:limit] || 50
+ work_ids = works.limit(limit).pluck(:id)
+ Tag.distinct.joins(:taggings).where(
+ "tags.type" => parent_type,
+ taggings: {
+ taggable_type: 'Work',
+ taggable_id: work_ids
+ }
+ )
+ end
+
+ # For works that haven't been wrangled yet, get the fandom/character tags
+ # that are used on their works as a place to start
+ def suggested_parent_ids(parent_type)
+ return [] if !parent_types.include?(parent_type) ||
+ unwrangleable? ||
+ parents.by_type(parent_type).exists?
+
+ suggested_parent_tags(parent_type).pluck(:id, :merger_id).
+ flatten.compact.uniq
+ end
+
+ def queue_child_tags_for_reindex
+ all_with_child_type = Tag.where(type: child_types & Tag::USER_DEFINED)
+ works.select(:id).find_in_batches do |batch|
+ relevant_taggings = Tagging.where(taggable: batch)
+ tag_ids = all_with_child_type.joins(:taggings).merge(relevant_taggings).distinct.pluck(:id)
+ IndexQueue.enqueue_ids(Tag, tag_ids, :background)
+ end
+ end
+
+ after_create :after_create
+ def after_create
+ tag = self
+ if tag.canonical
+ tag.add_to_autocomplete
+ end
+ end
+
+ after_update :after_update
+ def after_update
+ tag = self
+ if tag.saved_change_to_canonical?
+ if tag.canonical
+ # newly canonical tag
+ tag.add_to_autocomplete
+ else
+ # decanonicalised tag
+ tag.remove_from_autocomplete
+ end
+ else
+ tag.refresh_autocomplete
+ end
+
+ # Expire caching when a merger is added or removed
+ if tag.saved_change_to_merger_id?
+ if tag.merger_id_before_last_save.present?
+ old = Tag.find(tag.merger_id_before_last_save)
+ old.update_works_index_timestamp!
+ end
+ if tag.merger_id.present?
+ tag.merger.update_works_index_timestamp!
+ end
+ async_after_commit(:queue_child_tags_for_reindex)
+ end
+
+ # if type has changed, expire the tag's parents' children cache (it stores the children's type)
+ if tag.saved_change_to_type?
+ tag.parents.each do |parent_tag|
+ ActionController::Base.new.expire_fragment("views/tags/#{parent_tag.id}/children")
+ end
+ end
+
+ # Reindex immediately to update the unwrangled bin.
+ if tag.saved_change_to_unwrangleable?
+ tag.reindex_document
+ end
+ end
+
+ def refresh_autocomplete
+ return unless canonical
+
+ remove_stale_from_autocomplete
+ add_to_autocomplete
+ end
+
+ before_destroy :before_destroy
+ def before_destroy
+ tag = self
+ if Tag::USER_DEFINED.include?(tag.type) && tag.canonical
+ tag.remove_from_autocomplete
+ end
+ end
+
+ private
+
+ after_save :update_tag_nominations
+ def update_tag_nominations
+ TagNomination.where(tagname: name).update_all(
+ canonical: canonical,
+ synonym: merger.nil? ? nil : merger.name,
+ parented: false, # we'll fix this later in the callback
+ exists: true
+ )
+
+ if canonical?
+ # Calculate the fandoms associated with this tag, because we'll set any
+ # TagNominations with a matching parent_tagname to have parented: true.
+ parent_names = parents.where(type: "Fandom").pluck(:name)
+
+ # If this tag has any fandoms at all, we also want to count it as parented
+ # for nominations with a blank parent_tagname. See the set_parented
+ # function in TagNominations for the calculation that we're trying to mimic
+ # here.
+ parent_names << "" if parent_names.present?
+
+ TagNomination.where(tagname: name, parent_tagname: parent_names).update_all(parented: true)
+ end
+
+ return unless saved_change_to_name? && name_before_last_save.present?
+
+ # Act as if the tag with the previous name was deleted and mirror clear_tag_nominations
+ TagNomination.where(tagname: name_before_last_save).update_all(
+ canonical: false,
+ exists: false,
+ parented: false,
+ synonym: nil
+ )
+ end
+
+ before_destroy :clear_tag_nominations
+ def clear_tag_nominations
+ TagNomination.where(tagname: name).update_all(
+ canonical: false,
+ exists: false,
+ parented: false,
+ synonym: nil
+ )
+ end
+
+ def only_case_changed?
+ new_normalized_name = normalize_for_tag_comparison(self.name)
+ old_normalized_name = normalize_for_tag_comparison(self.name_was)
+ (self.name.downcase == self.name_was.downcase) ||
+ (new_normalized_name == old_normalized_name)
+ end
+
+ def normalize_for_tag_comparison(string)
+ UnicodeUtils.casefold(string).mb_chars.unicode_normalize(:nfkd).gsub(/[\u0300-\u036F]/u, "")
+ end
+end
diff --git a/app/models/tagging.rb b/app/models/tagging.rb
new file mode 100755
index 0000000..63a1617
--- /dev/null
+++ b/app/models/tagging.rb
@@ -0,0 +1,45 @@
+class Tagging < ApplicationRecord
+ belongs_to :tagger, polymorphic: true, inverse_of: :taggings, autosave: true
+ belongs_to :taggable, polymorphic: true, touch: true, inverse_of: :taggings
+
+ validates_presence_of :tagger, :taggable
+
+ # When we create or destroy a tagging, it may change the taggings count.
+ after_create :update_taggings_count
+ after_destroy :update_taggings_count
+ after_commit :update_search
+
+ after_create :update_filters
+ after_destroy :update_filters
+
+ def update_filters
+ return unless taggable.is_a?(Filterable)
+
+ taggable.update_filters
+ end
+
+ def self.find_by_tag(taggable, tag)
+ Tagging.find_by(tagger_id: tag.id, taggable_id: taggable.id, tagger_type: 'Tag', taggable_type: taggable.class.name)
+ end
+
+ # Most of the time, we don't need the taggings_count_cache stored in the
+ # database to be perfectly accurate. But because of the way Tag.in_use is
+ # defined and used, the difference between a value of 0 and a value of 1 is
+ # important. So we make sure to poke the taggings_count cache every time we
+ # create or destroy a tagging. If it's a large tag, it'll fall back on the
+ # cached value. If it's a small tag, it'll recompute -- and make sure that it
+ # handles the transition from 0 uses to 1 use properly.
+ def update_taggings_count
+ tagger.update_tag_cache unless tagger.blank? || tagger.destroyed?
+ end
+
+ def update_search
+ return unless tagger && Tag::USER_DEFINED.include?(tagger.type)
+
+ # Reindex the tag for updated suggested tags.
+ # Suggested tags help wranglers figure out where to wrangle new tags
+ # and if it's necessary to disambiguate existing canonical/unfilterable tags
+ # in multiple fandoms.
+ tagger.enqueue_to_index if tagger.taggings_count < ArchiveConfig.TAGGINGS_COUNT_REINDEX_LIMIT
+ end
+end
diff --git a/app/models/tagset_models/cast_nomination.rb b/app/models/tagset_models/cast_nomination.rb
new file mode 100755
index 0000000..4600934
--- /dev/null
+++ b/app/models/tagset_models/cast_nomination.rb
@@ -0,0 +1,43 @@
+class CastNomination < TagNomination
+ belongs_to :tag_set_nomination
+ has_one :owned_tag_set, through: :tag_set_nomination
+ belongs_to :fandom_nomination
+
+ validate :known_fandom, unless: :blank_tagname?
+ def known_fandom
+ return true if (!parent_tagname.blank? || self.fandom_nomination || from_fandom_nomination)
+ return true if (tag = Tag.find_by_name(self.tagname)) && tag.parents.any? {|p| p.is_a?(Fandom)}
+ errors.add(:base, ts("^We need to know what fandom %{tagname} belongs in.", tagname: self.tagname))
+ end
+
+ before_save :set_tag_set_nomination
+ def set_tag_set_nomination
+ if fandom_nomination && !tag_set_nomination
+ self.tag_set_nomination = fandom_nomination.tag_set_nomination
+ end
+ end
+
+ def get_parent_tagname
+ self.fandom_nomination ? self.fandom_nomination.tagname : (
+ self.parent_tagname.present? ? self.parent_tagname :
+ Tag.find_by_name(self.tagname).try(:parents).try(:first).try(:name))
+ end
+
+ def get_owned_tag_set
+ @tag_set || self.tag_set_nomination ? self.tag_set_nomination.owned_tag_set : self.fandom_nomination.get_owned_tag_set
+ end
+
+ def set_approval_status
+ set_noms = tag_set_nomination
+ set_noms = fandom_nomination.tag_set_nomination if !set_noms && from_fandom_nomination
+ # if the fandom is rejected so are we
+ self.rejected = (from_fandom_nomination && set_noms.owned_tag_set.already_rejected?(get_parent_tagname)) || set_noms.owned_tag_set.already_rejected?(tagname) || false
+ if self.rejected
+ self.approved = false
+ else
+ self.approved = set_noms.owned_tag_set.already_in_set?(tagname) || (synonym && set_noms.owned_tag_set.already_in_set?(synonym)) || false
+ end
+ true
+ end
+
+end
\ No newline at end of file
diff --git a/app/models/tagset_models/character_nomination.rb b/app/models/tagset_models/character_nomination.rb
new file mode 100755
index 0000000..1b6c6ec
--- /dev/null
+++ b/app/models/tagset_models/character_nomination.rb
@@ -0,0 +1,5 @@
+class CharacterNomination < CastNomination
+ belongs_to :tag_set_nomination
+ belongs_to :fandom_nomination, inverse_of: :character_nominations
+ has_one :owned_tag_set, through: :tag_set_nomination
+end
\ No newline at end of file
diff --git a/app/models/tagset_models/fandom_nomination.rb b/app/models/tagset_models/fandom_nomination.rb
new file mode 100755
index 0000000..bacf92c
--- /dev/null
+++ b/app/models/tagset_models/fandom_nomination.rb
@@ -0,0 +1,34 @@
+class FandomNomination < TagNomination
+ belongs_to :tag_set_nomination
+ has_one :owned_tag_set, through: :tag_set_nomination
+
+ has_many :character_nominations, dependent: :destroy, inverse_of: :fandom_nomination
+ accepts_nested_attributes_for :character_nominations, allow_destroy: true, reject_if: proc { |attrs| attrs[:tagname].blank? && attrs[:id].blank? }
+
+ has_many :relationship_nominations, dependent: :destroy, inverse_of: :fandom_nomination
+ accepts_nested_attributes_for :relationship_nominations, allow_destroy: true, reject_if: proc { |attrs| attrs[:tagname].blank? && attrs[:id].blank? }
+
+
+ def character_tagnames
+ CharacterNomination.for_tag_set(owned_tag_set).where(parent_tagname: tagname).pluck :tagname
+ end
+
+ def relationship_tagnames
+ RelationshipNomination.for_tag_set(owned_tag_set).where(parent_tagname: tagname).pluck :tagname
+ end
+
+ after_save :reject_children, if: :rejected?
+ def reject_children
+ character_nominations.each {|char| char.rejected = true; char.save}
+ relationship_nominations.each {|rel| rel.rejected = true; rel.save}
+ true
+ end
+
+ after_save :update_child_parent_tagnames, if: Proc.new { |nom| nom.saved_change_to_tagname? }
+ def update_child_parent_tagnames
+ self.character_nominations.readonly(false).each {|char| char.parent_tagname = self.tagname; char.save}
+ self.relationship_nominations.readonly(false).each {|rel| rel.parent_tagname = self.tagname; rel.save}
+ true
+ end
+
+end
diff --git a/app/models/tagset_models/freeform_nomination.rb b/app/models/tagset_models/freeform_nomination.rb
new file mode 100755
index 0000000..c1063af
--- /dev/null
+++ b/app/models/tagset_models/freeform_nomination.rb
@@ -0,0 +1,5 @@
+class FreeformNomination < TagNomination
+ belongs_to :tag_set_nomination #, inverse_of: :freeform_nominations
+ has_one :owned_tag_set, through: :tag_set_nomination #, inverse_of: :freeform_nominations
+
+end
\ No newline at end of file
diff --git a/app/models/tagset_models/owned_set_tagging.rb b/app/models/tagset_models/owned_set_tagging.rb
new file mode 100755
index 0000000..39a8815
--- /dev/null
+++ b/app/models/tagset_models/owned_set_tagging.rb
@@ -0,0 +1,6 @@
+class OwnedSetTagging < ApplicationRecord
+ belongs_to :owned_tag_set
+ belongs_to :set_taggable, polymorphic: :true
+
+ validates_uniqueness_of :owned_tag_set_id, scope: [:set_taggable_id, :set_taggable_type], message: ts("^That tag set is already being used here.")
+end
diff --git a/app/models/tagset_models/owned_tag_set.rb b/app/models/tagset_models/owned_tag_set.rb
new file mode 100755
index 0000000..89382a8
--- /dev/null
+++ b/app/models/tagset_models/owned_tag_set.rb
@@ -0,0 +1,279 @@
+class OwnedTagSet < ApplicationRecord
+ # Rather than use STI or polymorphic associations, since really what we want to do here
+ # is build an extra layer of functionality on top of the generic tag set structure,
+ # I've gone with creating a separate model and making it contain a generic tag set
+ # as a member. This way we don't have to duplicate the tag set code or functionality
+ # and can just add on the extra stuff without cramming the tag set table full of empty
+ # unused fields and having the controller have to sift out all the generic tag sets
+ # being used in prompts.
+ # -- NN May 2011
+
+ belongs_to :tag_set, dependent: :destroy
+ accepts_nested_attributes_for :tag_set
+
+ # delegate the tag set commands
+ delegate :tags, :with_type, :has_type?, to: :tag_set, allow_nil: true
+
+ has_many :tag_set_associations, dependent: :destroy
+ accepts_nested_attributes_for :tag_set_associations, allow_destroy: true,
+ reject_if: proc { |attrs| !attrs[:create_association] || attrs[:tag_id].blank? || (attrs[:parent_tag_id].blank? && attrs[:parent_tagname].blank?) }
+
+ has_many :tag_set_nominations, dependent: :destroy
+ has_many :tag_nominations, through: :tag_set_nominations, dependent: :destroy
+ has_many :fandom_nominations, through: :tag_set_nominations
+ has_many :character_nominations, through: :tag_set_nominations
+ has_many :relationship_nominations, through: :tag_set_nominations
+ has_many :freeform_nominations, through: :tag_set_nominations
+
+ has_many :tag_set_ownerships, dependent: :destroy
+ has_many :moderators, -> { where('tag_set_ownerships.owner = ?', false) }, through: :tag_set_ownerships, source: :pseud
+ has_many :owners, -> { where('tag_set_ownerships.owner = ?', true) }, through: :tag_set_ownerships, source: :pseud
+
+ has_many :owned_set_taggings, dependent: :destroy
+ has_many :set_taggables, through: :owned_set_taggings
+
+ validates_presence_of :title, message: ts("^Please enter a title for your tag set.")
+ validates :title, uniqueness: { message: ts("^Sorry, that name is already taken. Try again, please!") }
+ validates_length_of :title,
+ minimum: ArchiveConfig.TITLE_MIN,
+ too_short: ts("must be at least %{min} characters long.", min: ArchiveConfig.TITLE_MIN)
+ validates_length_of :title,
+ maximum: ArchiveConfig.TITLE_MAX,
+ too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.TITLE_MAX)
+ validates_format_of :title,
+ with: /\A[^,*<>^{}=`\\%]+\z/,
+ message: '^The title of a tag set cannot include the following restricted characters: , ^ * < > { } = ` \\ %'
+
+ validates_length_of :description,
+ allow_blank: true,
+ maximum: ArchiveConfig.SUMMARY_MAX,
+ too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.SUMMARY_MAX)
+
+ validates_numericality_of :fandom_nomination_limit, :character_nomination_limit, :relationship_nomination_limit, :freeform_nomination_limit,
+ only_integer: true, less_than_or_equal_to: 20, greater_than_or_equal_to: 0,
+ message: ts('must be an integer between 0 and 20.')
+
+
+ after_update :cleanup_outdated_associations
+ def cleanup_outdated_associations
+ tag_ids = SetTagging.where(tag_set_id: self.tag_set.id).collect(&:tag_id)
+ TagSetAssociation.where(owned_tag_set_id: self.id).where("tag_id NOT IN (?) OR parent_tag_id NOT IN (?)", tag_ids, tag_ids).delete_all
+ end
+
+ validate :no_midstream_nomination_changes
+ def no_midstream_nomination_changes
+ if !self.tag_set_nominations.empty? &&
+ %w(fandom_nomination_limit character_nomination_limit relationship_nomination_limit freeform_nomination_limit).any? {|field| self.changed.include?(field)}
+ errors.add(:base, ts("You cannot make changes to nomination settings when nominations already exist. Please review and delete existing nominations first."))
+ end
+ end
+
+ def add_tagnames(tag_type, tagnames_to_add)
+ return true if tagnames_to_add.blank?
+ self.tag_set.send("#{tag_type}_tagnames_to_add=", tagnames_to_add)
+ return false unless self.tag_set.save && self.save
+
+ # update the nominations -- approve any where an approved tag was either a synonym or the tag itself
+ TagNomination.for_tag_set(self).where(type: "#{tag_type.classify}Nomination").where("tagname IN (?)", tagnames_to_add).where(rejected: false).update_all(approved: true)
+ true
+ end
+
+ def remove_tagnames(tag_type, tagnames_to_remove)
+ return true if tagnames_to_remove.blank?
+ self.tag_set.tagnames_to_remove = tagnames_to_remove.join(',')
+ return false unless self.save
+ TagNomination.for_tag_set(self).where(type: "#{tag_type.classify}Nomination").where("tagname IN (?)", tagnames_to_remove).where(approved: false).update_all(rejected: true)
+
+ if tag_type == "fandom"
+ # reject children of rejected fandom
+ TagNomination.for_tag_set(self).where(type: "#{tag_type.classify}Nomination").where("tagname IN (?)", tagnames_to_remove).each do |rejected_fandom_nom|
+ rejected_fandom_nom.reject_children
+ end
+ end
+
+ true
+ end
+
+ def self.owned_by(user = User.current_user)
+ if user.is_a?(User)
+ select("DISTINCT owned_tag_sets.*").
+ joins("INNER JOIN tag_set_ownerships ON owned_tag_sets.id = tag_set_ownerships.owned_tag_set_id
+ INNER JOIN pseuds ON tag_set_ownerships.pseud_id = pseuds.id
+ INNER JOIN users ON pseuds.user_id = users.id").
+ where("users.id = ?", user.id)
+ end
+ end
+
+ def self.visible(user = User.current_user)
+ if user.is_a?(User)
+ select("DISTINCT owned_tag_sets.*").
+ joins("INNER JOIN tag_set_ownerships ON owned_tag_sets.id = tag_set_ownerships.owned_tag_set_id
+ INNER JOIN pseuds ON tag_set_ownerships.pseud_id = pseuds.id
+ INNER JOIN users ON pseuds.user_id = users.id").
+ where("owned_tag_sets.visible = true OR users.id = ?", user.id)
+ else
+ where("owned_tag_sets.visible = true")
+ end
+ end
+
+ def self.usable(user = User.current_user)
+ if user.is_a?(User)
+ select("DISTINCT owned_tag_sets.*").
+ joins("INNER JOIN tag_set_ownerships ON owned_tag_sets.id = tag_set_ownerships.owned_tag_set_id
+ INNER JOIN pseuds ON tag_set_ownerships.pseud_id = pseuds.id
+ INNER JOIN users ON pseuds.user_id = users.id").
+ where("owned_tag_sets.usable = true OR users.id = ?", user.id)
+ else
+ where("owned_tag_sets.usable = true")
+ end
+ end
+
+ def self.in_prompt_restriction(restriction)
+ joins(:owned_set_taggings).
+ where("owned_set_taggings.set_taggable_type = ?", restriction.class.to_s).
+ where("owned_set_taggings.set_taggable_id = ?", restriction.id)
+ end
+
+ #### MODERATOR/OWNER
+
+ def user_is_owner?(user)
+ user.is_a?(User) && !(owners & user.pseuds).empty?
+ end
+
+ def user_is_moderator?(user)
+ user.is_a?(User) && (user_is_owner?(user) || !(moderators & user.pseuds).empty?)
+ end
+
+ def add_owner(pseud)
+ tag_set_ownerships.build({pseud: pseud, owner: true})
+ end
+
+ def add_moderator(pseud)
+ tag_set_ownerships.build({pseud: pseud, owner: false})
+ end
+
+ def owner_changes=(pseud_list)
+ Pseud.parse_bylines(pseud_list)[:pseuds].each do |pseud|
+ if self.owners.include?(pseud)
+ self.owners -= [pseud] if self.owners.count > 1
+ else
+ self.moderators -= [pseud] if self.moderators.include?(pseud)
+ add_owner(pseud)
+ end
+ end
+ end
+
+ def moderator_changes=(pseud_list)
+ Pseud.parse_bylines(pseud_list)[:pseuds].each do |pseud|
+ if self.moderators.include?(pseud)
+ self.moderators -= [pseud]
+ else
+ add_moderator(pseud) unless self.owners.include?(pseud)
+ end
+ end
+ end
+
+ def owner_changes; nil; end
+ def moderator_changes; nil; end
+
+ ##### MANAGING NOMINATIONS
+
+ # we can use redis to speed this up since tagset data is loaded there for autocomplete
+ def already_in_set?(tagname)
+ tags_in_set.where("tags.name = ?", tagname).exists?
+ end
+
+ # returns an array of arrays [id, name]
+ def tags_in_set
+ Tag.joins(:set_taggings).where("set_taggings.tag_set_id = ?", self.tag_set.id)
+ end
+
+ def already_nominated?(tagname)
+ TagNomination.joins(tag_set_nomination: :owned_tag_set).where("tag_set_nominations.owned_tag_set_id = ?", self.id).exists?(tagname: tagname)
+ end
+
+ def already_rejected?(tagname)
+ TagNomination.joins(tag_set_nomination: :owned_tag_set).where("tag_set_nominations.owned_tag_set_id = ?", self.id).exists?(tagname: tagname, rejected: true)
+ end
+
+ def already_approved?(tagname)
+ TagNomination.joins(tag_set_nomination: :owned_tag_set).where("tag_set_nominations.owned_tag_set_id = ?", self.id).exists?(tagname: tagname, approved: true)
+ end
+
+ def clear_nominations!
+ TagSetNomination.where(owned_tag_set_id: self.id).delete_all
+ end
+
+
+ ##### MANAGING ASSOCIATIONS
+
+ def associations_to_remove=(assoc_ids)
+ TagSetAssociation.for_tag_set(self).where(id: assoc_ids).delete_all
+ end
+
+ def load_batch_associations!(batch_associations, options = {})
+ options.reverse_merge!({do_relationships: false})
+ association_lines = batch_associations.split("\n")
+ fandom_tagnames_to_add = []
+ child_tagnames_to_add = []
+ assocs_to_save = []
+ failed = []
+ canonical = []
+
+ association_lines.each do |line|
+ children_names = line.split(',')
+ parent_name = children_names.shift.strip
+ parent_tag_id = Fandom.where(name: parent_name).pluck(:id).first
+ unless parent_tag_id
+ failed << line
+ next
+ end
+ failed_children = []
+ added_parent = false
+ children_names.map {|c| c.strip}.each_with_index do |child_name|
+ child_tag_id = (options[:do_relationships] ? Relationship : Character).where(name: child_name).pluck(:id).first
+ unless child_tag_id
+ failed_children << child_name
+ next
+ end
+ assoc = tag_set_associations.build(tag_id: child_tag_id, parent_tag_id: parent_tag_id)
+ unless assoc.valid?
+ failed_children << child_name
+ next
+ end
+ assocs_to_save << assoc
+ child_tagnames_to_add << child_name
+ fandom_tagnames_to_add << parent_name unless added_parent
+ added_parent = true
+ end
+ failed << "#{parent_name},#{failed_children.join(',')}" unless failed_children.empty?
+ end
+
+ # add the tags to the set
+ tag_set.fandom_tagnames_to_add = fandom_tagnames_to_add
+ tag_set.send (options[:do_relationships] ? :relationship_tagnames_to_add= : :character_tagnames_to_add=), child_tagnames_to_add
+
+ if tag_set.save
+ # save the associations
+ assocs_to_save.each {|assoc| assoc.save}
+ else
+ # whoops, nothing worked
+ failed = association_lines
+ end
+
+ return failed
+ end
+
+ # Turn our various tag nomination limits into a single hash object
+ def limits
+ limit_hash = {}
+ %w(fandom character relationship freeform).each do |tag_type|
+ limit_hash[tag_type] = send("#{tag_type}_nomination_limit")
+ end
+ limit_hash.with_indifferent_access
+ end
+
+ def includes_fandoms?
+ fandom_nomination_limit > 0
+ end
+end
diff --git a/app/models/tagset_models/relationship_nomination.rb b/app/models/tagset_models/relationship_nomination.rb
new file mode 100755
index 0000000..81a92b9
--- /dev/null
+++ b/app/models/tagset_models/relationship_nomination.rb
@@ -0,0 +1,5 @@
+class RelationshipNomination < CastNomination
+ belongs_to :tag_set_nomination
+ belongs_to :fandom_nomination, inverse_of: :relationship_nominations
+ has_one :owned_tag_set, through: :tag_set_nomination
+end
\ No newline at end of file
diff --git a/app/models/tagset_models/set_tagging.rb b/app/models/tagset_models/set_tagging.rb
new file mode 100755
index 0000000..898f53a
--- /dev/null
+++ b/app/models/tagset_models/set_tagging.rb
@@ -0,0 +1,6 @@
+class SetTagging < ApplicationRecord
+ belongs_to :tag
+ belongs_to :tag_set
+
+ validates_uniqueness_of :tag_id, scope: [:tag_set_id], message: ts("^That tag already seems to be in this set.")
+end
diff --git a/app/models/tagset_models/tag_nomination.rb b/app/models/tagset_models/tag_nomination.rb
new file mode 100755
index 0000000..5095a6d
--- /dev/null
+++ b/app/models/tagset_models/tag_nomination.rb
@@ -0,0 +1,183 @@
+class TagNomination < ApplicationRecord
+ belongs_to :tag_set_nomination, inverse_of: :tag_nominations
+ has_one :owned_tag_set, through: :tag_set_nomination
+
+ attr_accessor :from_fandom_nomination
+
+ validates_length_of :tagname,
+ maximum: ArchiveConfig.TAG_MAX,
+ message: ts("^Tag nominations must be between 1 and #{ArchiveConfig.TAG_MAX} characters.")
+
+ validates_format_of :tagname,
+ if: Proc.new { |tag_nomination| !tag_nomination.tagname.blank? },
+ with: /\A[^,*<>^{}=`\\%]+\z/,
+ message: ts("^Tag nominations cannot include the following restricted characters: , ^ * < > { } = ` \\ %")
+
+ validate :type_validity, unless: :blank_tagname?
+ def type_validity
+ return unless (tag = Tag.find_by_name(tagname)) && "#{tag.type}Nomination" != self.type
+
+ errors.add(:base, ts("^The tag %{tagname} is already in the archive as a #{tag.type} tag. (All tags have to be unique.) Try being more specific, for instance tacking on the medium or the fandom.", tagname: self.tagname))
+ end
+
+ validate :not_already_reviewed, on: :update
+ def not_already_reviewed
+ # allow mods and the archive code to update
+ unless (!User.current_user || (User.current_user && User.current_user.is_a?(User) && owned_tag_set.user_is_moderator?(User.current_user)))
+ if tagname_changed? && (self.approved || self.rejected) && (tagname != tagname_was) && !tagname_was.blank?
+ errors.add(:base, ts("^You cannot change %{tagname_was} to %{tagname} because that nomination has already been reviewed.", tagname_was: self.tagname_was, tagname: self.tagname))
+ tagname = self.tagname_was
+ end
+ end
+ end
+
+ # This makes sure no tagnames are nominated for different parents in this tag set
+ validate :require_unique_tagname_with_parent, unless: :blank_tagname?
+ def require_unique_tagname_with_parent
+ query = TagNomination.for_tag_set(get_owned_tag_set).where(tagname: self.tagname).where("parent_tagname != ?", (self.get_parent_tagname || ''))
+ # let people change their own!
+ query = query.where("tag_nominations.id != ?", self.id) if !(self.new_record?)
+ if query.exists?
+ other_parent = query.pluck(:parent_tagname).uniq.join(", ") # should only be one but just in case
+ errors.add(:base, ts("^Someone else has already nominated the tag %{tagname} for this set but in fandom %{other_parent}. (All nominations have to be unique for the approval process to work.) Try making your nomination more specific, for instance tacking on (%{fandom}).", tagname: self.tagname, other_parent: other_parent, fandom: self.get_parent_tagname || 'Fandom'))
+ end
+ end
+
+ def get_owned_tag_set
+ @tag_set || self.tag_set_nomination.owned_tag_set
+ end
+
+ def blank_tagname?
+ tagname.blank?
+ end
+
+ before_save :set_tag_status, unless: :blank_tagname?
+ def set_tag_status
+ if (tag = Tag.find_by_name(tagname))
+ self.exists = true
+ self.tagname = tag.name
+ self.canonical = tag.canonical
+ self.synonym = tag.merger ? tag.merger.name : nil
+ else
+ self.exists = false
+ self.canonical = false
+ self.synonym = nil
+ end
+ true
+ end
+
+ before_save :set_parented, unless: :blank_tagname?
+ def set_parented
+ self.parented = true and return if type == "FreeformNomination"
+
+ self.parent_tagname ||= get_parent_tagname
+
+ tag = Tag.find_by_name(tagname)
+ unless tag
+ self.parented = false
+ return
+ end
+
+ self.parented = tag.canonical? &&
+ ((!tag.parents.empty? && get_parent_tagname.blank?) ||
+ tag.parents.pluck(:name).include?(get_parent_tagname))
+ end
+
+ # sneaky bit: if the tag set moderator has already rejected or approved this tag, don't
+ # show it to them again.
+ before_save :set_approval_status, unless: :blank_tagname?
+ def set_approval_status
+ set_noms = tag_set_nomination
+ set_noms = fandom_nomination.tag_set_nomination if !set_noms && from_fandom_nomination
+ self.rejected = set_noms.owned_tag_set.already_rejected?(tagname) || false
+ if self.rejected
+ self.approved = false
+ elsif synonym && set_noms.owned_tag_set.already_in_set?(synonym)
+ self.tagname = synonym
+ self.synonym = nil
+ self.approved = true
+ else
+ self.approved = set_noms.owned_tag_set.already_in_set?(tagname) || false
+ end
+ true
+ end
+
+ after_save :destroy_if_blank
+ def destroy_if_blank
+ self.destroy if blank_tagname?
+ end
+
+ def self.for_tag_set(tag_set)
+ joins(tag_set_nomination: :owned_tag_set).
+ where("owned_tag_sets.id = ?", tag_set.id)
+ end
+
+ def self.names_with_count
+ select("tagname, count(*) as count").group("tagname").order("tagname")
+ end
+
+ def self.unreviewed
+ where(approved: false).where(rejected: false)
+ end
+
+ # returns an array of all the parent tagnames for the given tag
+ # can be chained with other queries but must come at the end
+ def self.nominated_parents(child_tagname, parent_search_term="")
+ parents = where(tagname: child_tagname).where("parent_tagname != ''")
+ unless parent_search_term.blank?
+ parents = parents.where("parent_tagname LIKE ?", "%#{parent_search_term}%")
+ end
+ parents.group("parent_tagname").order("count_id DESC").count('id').keys
+ end
+
+ # We need this manual join in order to do a query over multiple types of tags
+ # (ie, via TagNomination.where(type: ...))
+ def self.join_fandom_nomination
+ joins("INNER JOIN tag_nominations fandom_nominations_tag_nominations ON
+ fandom_nominations_tag_nominations.id = tag_nominations.fandom_nomination_id AND
+ fandom_nominations_tag_nominations.type = 'FandomNomination'")
+ end
+
+ # Can we change the name to this new name?
+ def change_tagname?(new_tagname)
+ self.tagname = new_tagname
+ if self.valid?
+ return true
+ else
+ return false
+ end
+ end
+
+ # If the mod is changing our name, change all other noms in this set as well
+ def self.change_tagname!(owned_tag_set_to_change, old_tagname, new_tagname)
+ TagNomination.for_tag_set(owned_tag_set_to_change).where(tagname: old_tagname).readonly(false).each do |tagnom|
+ tagnom.tagname = new_tagname
+ tagnom.save or return false
+ end
+ return true
+ end
+
+ # here so we can override it in char/relationship noms
+ def get_parent_tagname
+ self.parent_tagname.present? ? self.parent_tagname : nil
+ end
+
+ def unreviewed?
+ !approved && !rejected
+ end
+
+ def reviewed?
+ approved || rejected
+ end
+
+ def approve!
+ self.approved = true
+ self.rejected = false
+ self.owned_tag_set.tag_set.send("#{self.class.to_s.gsub(/Nomination/,'').downcase}_tagnames_to_add=", self.tagname)
+ self.owned_tag_set.tag_set.save
+ end
+
+ def times_nominated(tag_set)
+ TagNomination.for_tag_set(tag_set).where(tagname: self.tagname).count
+ end
+end
diff --git a/app/models/tagset_models/tag_set.rb b/app/models/tagset_models/tag_set.rb
new file mode 100755
index 0000000..8a237d2
--- /dev/null
+++ b/app/models/tagset_models/tag_set.rb
@@ -0,0 +1,297 @@
+class TagSet < ApplicationRecord
+ # a complete match is numerically represented with ALL
+ ALL = -1
+
+ TAG_TYPES = %w(fandom character relationship freeform category rating archive_warning)
+ TAG_TYPES_INITIALIZABLE = %w(fandom character relationship freeform)
+ TAG_TYPES_RESTRICTED_TO_FANDOM = %w(character relationship)
+ TAGS_AS_CHECKBOXES = %w(category rating archive_warning)
+
+ attr_accessor :from_owned_tag_set
+
+ has_many :set_taggings, dependent: :destroy
+ has_many :tags, through: :set_taggings
+
+ has_one :owned_tag_set
+
+ has_one :prompt, autosave: false
+
+ # how this works: we don't want to set the actual "tags" variable initially because that will
+ # create SetTaggings even if the tags are not canonical or wrong. So we need to create a temporary
+ # virtual attribute "tagnames" to use instead until after validation.
+ attr_writer :tagnames
+ def tagnames
+ @tagnames || tags.select('tags.name').order('tags.name').collect(&:name).join(ArchiveConfig.DELIMITER_FOR_OUTPUT)
+ end
+
+ def taglist
+ @tagnames ? tagnames_to_list(@tagnames) : tags
+ end
+
+ attr_writer :tagnames_to_remove
+ def tagnames_to_remove
+ @tagnames_to_remove || ""
+ end
+
+ # this code just sets up functions fandom_tagnames/fandom_tagnames=, character_tagnames... etc
+ # that work like tagnames above, except on separate types.
+ #
+ # NOTE: you can't use both these individual
+ # setters and tagnames in the same form -- ie, if you set tagnames and then you set fandom_tagnames, you
+ # will wipe out the fandom tagnames set in tagnames.
+ #
+ TAG_TYPES.each do |type|
+ attr_writer "#{type}_tagnames".to_sym
+
+ define_method("#{type}_tagnames") do
+ self.instance_variable_get("@#{type}_tagnames") || (self.new_record? ? self.tags.select {|t| t.type == type.classify}.collect(&:name).join(ArchiveConfig.DELIMITER_FOR_OUTPUT) :
+ self.tags.with_type(type.classify).select('tags.name').order('tags.name').collect(&:name).join(ArchiveConfig.DELIMITER_FOR_OUTPUT))
+ end
+
+ define_method("#{type}_taglist") do
+ self.instance_variable_get("@#{type}_tagnames") ? tagnames_to_list(self.instance_variable_get("@#{type}_tagnames"), type) : with_type(type)
+ end
+
+ # _to_add/remove only
+ attr_writer "#{type}_tagnames_to_add".to_sym
+ define_method("#{type}_tagnames_to_add") do
+ self.instance_variable_get("@#{type}_tagnames_to_add") || ""
+ end
+
+ attr_writer "#{type}_tags_to_remove".to_sym
+ define_method("#{type}_tags_to_remove") do
+ self.instance_variable_get("@#{type}_tags_to_remove") || ""
+ end
+
+ end
+
+ # this actually runs and saves the tags and updates the autocomplete
+ # NOTE: if more than one is set, the precedence is as follows:
+ # tagnames=
+ # tagnames_to_add/_remove
+ # [type]_tagnames
+ # [type]_tagnames_to_add/_remove
+ after_save :assign_tags
+ def assign_tags
+ tags_to_add = []
+ tags_to_remove = []
+
+ TAG_TYPES.each do |type|
+ if self.instance_variable_get("@#{type}_tagnames")
+ # explicitly set the list of type_tagnames
+ new_tags = self.send("#{type}_taglist")
+ old_tags = self.with_type(type.classify)
+ tags_to_add += (new_tags - old_tags)
+ tags_to_remove += (old_tags - new_tags)
+ else
+ #
+ unless self.instance_variable_get("@#{type}_tagnames_to_add").blank?
+ tags_to_add += tagnames_to_list(self.instance_variable_get("@#{type}_tagnames_to_add"), type)
+ end
+ unless self.instance_variable_get("@#{type}_tags_to_remove").blank?
+ tagclass = type.classify.constantize # Safe constantize itterating over TAG_TYPES
+ tags_to_remove += (self.instance_variable_get("@#{type}_tags_to_remove").map {|tag_id| tag_id.blank? ? nil : tagclass.find(tag_id)}.compact)
+ end
+ end
+ end
+
+ # This overrides the type-specific
+ if @tagnames_to_remove.present?
+ tags_to_remove = @tagnames_to_remove.split(ArchiveConfig.DELIMITER_FOR_INPUT).map {|tname| Tag.find_by_name(tname.squish)}.compact
+ end
+
+ # And this overrides the add/remove-specific
+ if @tagnames
+ new_tags = tagnames_to_list(@tagnames)
+ tags_to_add = new_tags - self.tags
+ tags_to_remove = (self.tags - new_tags)
+ end
+
+ # actually remove and add the tags, and update autocomplete
+ remove_from_set(tags_to_remove.uniq)
+ add_to_set(tags_to_add.uniq)
+ end
+
+ def remove_from_set(tags_to_remove)
+ return unless tags_to_remove.present?
+ self.set_taggings.where(tag_id: tags_to_remove.map(&:id)).delete_all
+ remove_tags_from_autocomplete(tags_to_remove)
+ owned_tag_set&.touch
+ end
+
+ def add_to_set(tags_to_add)
+ return unless tags_to_add.present?
+ existing_ids = self.set_taggings.where(tag_id: tags_to_add.map(&:id)).pluck(:tag_id)
+ tags_to_add.each do |tag|
+ next if existing_ids.include?(tag.id)
+ self.set_taggings.create(tag_id: tag.id)
+ end
+ add_tags_to_autocomplete(tags_to_add)
+ owned_tag_set&.touch
+ end
+
+ # Tags must already exist unless they are being added to an owned tag set
+ validate :tagnames_must_exist, unless: :from_owned_tag_set
+ def tagnames_must_exist
+ nonexist = []
+ if @tagnames
+ nonexist += @tagnames.split(ArchiveConfig.DELIMITER_FOR_INPUT).select {|t| !Tag.where(name: t.squish).exists?}
+ end
+ if owned_tag_set.nil?
+ TAG_TYPES.each do |type|
+ if (tagnames = self.instance_variable_get("@#{type}_tagnames_to_add"))
+ tagnames = (tagnames.is_a?(Array) ? tagnames : tagnames.split(ArchiveConfig.DELIMITER_FOR_INPUT)).map {|t| t.squish}
+ nonexist += tagnames.select {|t| !t.blank? && !Tag.where(name: t).exists?}
+ end
+ end
+ end
+
+ unless nonexist.empty?
+ errors.add(:tagnames, ts("^The following tags don't exist and can't be used: %{taglist}", taglist: nonexist.join(", ") ))
+ end
+ end
+
+ ### Various utility methods
+
+ def +(other)
+ TagSet.new(tags: (self.tags + other.tags))
+ end
+
+ def -(other)
+ TagSet.new(tags: (self.tags - other.tags))
+ end
+
+ def with_type(type)
+ # this is required because otherwise tag sets created on the fly (eg with + during potential match generation)
+ # that are not saved in the database will return empty list.
+ # We use Tag.where so that we can still chain this with other AR queries
+ return self.new_record? ? Tag.where(id: self.tags.select {|t| t.type == type.classify}.collect(&:id)) : self.tags.with_type(type)
+ end
+
+ def has_type?(type)
+ with_type(type).exists?
+ end
+
+ def empty?
+ self.tags.empty?
+ end
+
+ ### Matching
+
+ # Computes the "match rank" of the two arrays. The match rank is ALL if the
+ # request tags are a subset of (or equal to) the offer tags, and otherwise is
+ # equal to the size of the overlap between the two.
+ def self.match_array_rank(request, offer)
+ return ALL if request.nil? || request.empty?
+ return 0 if offer.nil? || offer.empty?
+ overlap = request & offer
+ overlap.size == request.size ? ALL : overlap.size
+ end
+
+ # Creates a hash mapping from tag types (lowercase) to lists of tag ids.
+ # Stores the result as an instance variable, to make sure that we only
+ # perform the work once. (This is only used by the matching algorithm, which
+ # doesn't update any tag set information, so it's okay to cache the results
+ # of this computation. Particularly since we want matching to go as fast as
+ # possible.)
+ def tag_ids_by_type
+ if @tag_ids_by_type.nil?
+ @tag_ids_by_type = tags.group_by { |tag| tag.type.underscore }
+ @tag_ids_by_type.each_value { |tag_list| tag_list.map!(&:id) }
+ end
+
+ @tag_ids_by_type
+ end
+
+ # Computes the match rank of the two tag sets, in the given type. (Or in all
+ # types, if type is nil.)
+ def match_rank(another, type = nil)
+ request = type ? tag_ids_by_type[type] : tags.map(&:id)
+ offer = type ? another.tag_ids_by_type[type] : another.tags.map(&:id)
+ TagSet.match_array_rank(request, offer)
+ end
+
+ ### protected
+
+ protected
+ def tagnames_to_list(taglist, type=nil)
+ taglist = (taglist.kind_of?(String) ? taglist.split(ArchiveConfig.DELIMITER_FOR_INPUT) : taglist).uniq
+ if type
+ raise "Redshirt: Attempted to constantize invalid class initialize tagnames_to_list #{type}" unless TAG_TYPES.include?(type)
+ if Tag::USER_DEFINED.include?(type.classify)
+ # allow users to create these
+ taglist.reject {|tagname| tagname.blank? }.map {|tagname| (type.classify.constantize).find_or_create_by_name(tagname.squish)} # Safe constantize checked above
+ else
+ taglist.reject {|tagname| tagname.blank? }.map {|tagname| (type.classify.constantize).find_by(name: tagname.squish)}.compact # Safe constantize checked above
+ end
+ else
+ taglist.reject {|tagname| tagname.blank? }.map {|tagname| Tag.find_by_name(tagname.squish) || Freeform.find_or_create_by_name(tagname.squish)}
+ end
+ end
+
+ ### autocomplete
+ public
+
+ # set up autocomplete and override some methods
+ include AutocompleteSource
+
+ def autocomplete_prefixes
+ prefixes = [ ]
+ prefixes
+ end
+
+ def add_to_autocomplete(score = nil)
+ add_tags_to_autocomplete(self.tags)
+ end
+
+ def remove_from_autocomplete
+ REDIS_AUTOCOMPLETE.del("autocomplete_tagset_#{self.id}")
+ end
+
+ def add_tags_to_autocomplete(tags_to_add)
+ tags_to_add.each do |tag|
+ value = tag.autocomplete_value
+ REDIS_AUTOCOMPLETE.zadd("autocomplete_tagset_all_#{self.id}", 0, value)
+ REDIS_AUTOCOMPLETE.zadd("autocomplete_tagset_#{tag.type.downcase}_#{self.id}", 0, value)
+ end
+ end
+
+ def remove_tags_from_autocomplete(tags_to_remove)
+ tags_to_remove.each do |tag|
+ value = tag.autocomplete_value
+ REDIS_AUTOCOMPLETE.zrem("autocomplete_tagset_all_#{self.id}", value)
+ REDIS_AUTOCOMPLETE.zrem("autocomplete_tagset_#{tag.type.downcase}_#{self.id}", value)
+ end
+ end
+
+ # returns tags that are in ANY or ALL of the specified tag sets
+ def self.autocomplete_lookup(options={})
+ options.reverse_merge!({term: "", tag_type: "all", tag_set: "", in_any: true})
+ tag_type = options[:tag_type]
+ search_param = options[:term]
+ tag_sets = TagSet.get_search_terms(options[:tag_set])
+
+ combo_key = "autocomplete_tagset_combo_#{tag_sets.join('_')}"
+
+ # get the intersection of the wrangled fandom and the associations from the various tag sets
+ keys_to_lookup = tag_sets.map {|set| "autocomplete_tagset_#{tag_type}_#{set}"}.flatten
+
+ if options[:in_any]
+ # get the union since we want tags in ANY of these sets
+ REDIS_AUTOCOMPLETE.zunionstore(combo_key, keys_to_lookup, aggregate: :max)
+ else
+ # take the intersection of ALL of these sets
+ REDIS_AUTOCOMPLETE.zinterstore(combo_key, keys_to_lookup, aggregate: :max)
+ end
+ results = REDIS_AUTOCOMPLETE.zrevrange(combo_key, 0, -1)
+ # expire fast
+ REDIS_AUTOCOMPLETE.expire combo_key, 1
+
+ unless search_param.blank?
+ search_regex = Tag.get_search_regex(search_param)
+ return results.select {|tag| tag.match(search_regex)}
+ else
+ return results
+ end
+ end
+end
diff --git a/app/models/tagset_models/tag_set_association.rb b/app/models/tagset_models/tag_set_association.rb
new file mode 100755
index 0000000..37399b1
--- /dev/null
+++ b/app/models/tagset_models/tag_set_association.rb
@@ -0,0 +1,132 @@
+class TagSetAssociation < ApplicationRecord
+ belongs_to :owned_tag_set
+ belongs_to :tag
+ belongs_to :parent_tag, class_name: "Tag"
+
+ validates_uniqueness_of :tag_id, scope: [:owned_tag_set_id, :parent_tag_id], message: ts("^You have already associated those tags in your set.")
+ validates_presence_of :tag_id, :parent_tag_id, :owned_tag_set_id
+
+ attr_accessor :create_association
+
+ def to_s
+ "#{tag.name} (#{parent_tag.name})"
+ end
+
+ # sort by names stripping off the articles
+ def self.by_name_without_articles(fieldname = "name")
+ fieldname = "name" unless fieldname.match(/^([\w]+\.)?[\w]+$/)
+ order(Arel.sql("case when lower(substring(#{fieldname} from 1 for 4)) = 'the ' then substring(#{fieldname} from 5)
+ when lower(substring(#{fieldname} from 1 for 2)) = 'a ' then substring(#{fieldname} from 3)
+ when lower(substring(#{fieldname} from 1 for 3)) = 'an ' then substring(#{fieldname} from 4)
+ else #{fieldname}
+ end"))
+ end
+
+ def self.for_tag_set(tagset)
+ where(owned_tag_set_id: tagset.id)
+ end
+
+ # almost exactly like the same code in tag.rb
+ def self.parent_names(child_type, parent_type = "fandom")
+ joins(:tag, :parent_tag).where("tags.type = ? AND parent_tags_tag_set_associations.type = ?", child_type.capitalize, parent_type.capitalize).
+ select("parent_tags_tag_set_associations.name as parent_name, tags.name as child_name").
+ by_name_without_articles("parent_name").
+ by_name_without_articles("child_name")
+ end
+
+ def self.names_by_parent(child_relation, child_type, parent_type = "fandom")
+ hash = {}
+ results = ActiveRecord::Base.connection.execute(child_relation.parent_names(child_type, parent_type).to_sql)
+ results.each {|row| hash[row.first] ||= Array.new; hash[row.first] << row.second}
+ hash
+ end
+
+ def parent_tagname
+ @parent_tagname || self.parent_tag.name
+ end
+
+ def parent_tagname=(parent_tagname)
+ self.parent_tag = Tag.find_by_name(parent_tagname)
+ end
+
+ def make_official!
+ tag.add_association(parent_tag)
+ self.destroy
+ end
+
+ after_save :add_to_autocomplete
+ before_destroy :remove_from_autocomplete
+
+ ## AUTOCOMPLETE
+ # set up autocomplete and override some methods
+ include AutocompleteSource
+
+ def autocomplete_prefixes
+ prefixes = [ ]
+ prefixes
+ end
+
+ # the value and score in autocomplete are the value/score of the child tag
+ def autocomplete_value
+ tag.autocomplete_value
+ end
+
+ def autocomplete_score
+ tag.autocomplete_score
+ end
+
+ def self.parse_autocomplete_value(current_autocomplete_value)
+ Tag.parse_autocomplete_value(current_autocomplete_value)
+ end
+
+ def add_to_autocomplete(score = nil)
+ score ||= autocomplete_score
+ REDIS_AUTOCOMPLETE.zadd("autocomplete_association_#{tag.type.downcase}_#{owned_tag_set.tag_set_id}_#{parent_tag.name.downcase}", score, autocomplete_value)
+ end
+
+ def remove_from_autocomplete
+ REDIS_AUTOCOMPLETE.zrem("autocomplete_association_#{tag.type.downcase}_#{owned_tag_set.tag_set_id}_#{parent_tag.name.downcase}", autocomplete_value)
+ end
+
+ # returns tags that have been associated with a given fandom OR wrangled
+ def self.autocomplete_lookup(options = {})
+ options.reverse_merge!({term: "", tag_type: "character", tag_set: "", fandom: "", include_wrangled: "true"})
+ search_param = options[:term]
+ tag_type = options[:tag_type]
+ fandoms = TagSetAssociation.get_search_terms(options[:fandom])
+ tag_sets = TagSetAssociation.get_search_terms(options[:tag_set])
+
+ combo_key = "autocomplete_association_combo_#{tag_type}_#{tag_sets.join('_')}_#{fandoms.join('_')}"
+
+ # get the union of the wrangled fandom and the associations from the various tag sets
+ keys_to_lookup = tag_sets.map {|set| fandoms.map {|fandom| "autocomplete_association_#{tag_type}_#{set}_#{fandom}"}}.flatten
+ keys_to_lookup += fandoms.map {|fandom| "autocomplete_fandom_#{fandom}_#{tag_type}"}.flatten
+ return [] if keys_to_lookup.empty?
+
+ # if we don't want tags that aren't in the tag set(s), we need to first
+ # get the union of all the tags in the tag set(s), then get the intersection
+ # of those tags, and the associated tags
+ if options[:include_wrangled] == "false"
+ combo_key2 = combo_key + "2"
+ combo_key3 = combo_key + "3"
+ keys_for_intersect = tag_sets.map {|set| "autocomplete_tagset_#{tag_type}_#{set}"}.flatten
+ REDIS_AUTOCOMPLETE.zunionstore(combo_key2, keys_to_lookup, aggregate: :max)
+ REDIS_AUTOCOMPLETE.zunionstore(combo_key3, keys_for_intersect, aggregate: :max)
+ REDIS_AUTOCOMPLETE.zinterstore(combo_key, [combo_key2, combo_key3], aggregate: :max)
+ REDIS_AUTOCOMPLETE.expire combo_key2, 1
+ REDIS_AUTOCOMPLETE.expire combo_key3, 1
+ else
+ REDIS_AUTOCOMPLETE.zunionstore(combo_key, keys_to_lookup, aggregate: :max)
+ end
+
+ results = REDIS_AUTOCOMPLETE.zrevrange(combo_key, 0, -1)
+ REDIS_AUTOCOMPLETE.expire combo_key, 1
+
+ unless search_param.blank?
+ search_regex = Tag.get_search_regex(search_param)
+ results.select! {|tag| tag.match(search_regex)}
+ end
+ return results
+ end
+
+end
diff --git a/app/models/tagset_models/tag_set_nomination.rb b/app/models/tagset_models/tag_set_nomination.rb
new file mode 100755
index 0000000..eb279f1
--- /dev/null
+++ b/app/models/tagset_models/tag_set_nomination.rb
@@ -0,0 +1,102 @@
+class TagSetNomination < ApplicationRecord
+ belongs_to :pseud
+ belongs_to :owned_tag_set, inverse_of: :tag_set_nominations
+
+ has_many :tag_nominations, dependent: :destroy, inverse_of: :tag_set_nomination
+ has_many :fandom_nominations, dependent: :destroy, inverse_of: :tag_set_nomination
+ has_many :character_nominations, dependent: :destroy, inverse_of: :tag_set_nomination
+ has_many :relationship_nominations, dependent: :destroy, inverse_of: :tag_set_nomination
+ has_many :freeform_nominations, dependent: :destroy, inverse_of: :tag_set_nomination
+
+ accepts_nested_attributes_for :fandom_nominations, :character_nominations, :relationship_nominations, :freeform_nominations, {
+ allow_destroy: true,
+ reject_if: proc { |attrs| attrs[:tagname].blank? && attrs[:id].blank? }
+ }
+
+ validates_presence_of :owned_tag_set_id
+ validates_presence_of :pseud_id
+
+ validates_uniqueness_of :owned_tag_set_id, scope: [:pseud_id], message: ts("You have already submitted nominations for that tag set. Try editing them instead.")
+
+ validate :can_nominate
+ def can_nominate
+ unless owned_tag_set.nominated
+ errors.add(:base, ts("%{title} is not currently accepting nominations.", title: owned_tag_set.title))
+ end
+ end
+
+ validate :nomination_limits
+ def nomination_limits
+ TagSet::TAG_TYPES_INITIALIZABLE.each do |tag_type|
+ limit = self.owned_tag_set.send("#{tag_type}_nomination_limit")
+ if count_by_fandom?(tag_type)
+ if self.fandom_nominations.any? {|fandom_nom| fandom_nom.send("#{tag_type}_nominations").try(:count) > limit}
+ errors.add(:base, ts("You can only nominate %{limit} #{tag_type} tags per fandom.", limit: limit))
+ end
+ else
+ count = self.send("#{tag_type}_nominations").count
+ errors.add(:base, ts("You can only nominate %{limit} #{tag_type} tags", limit: limit)) if count > limit
+ end
+ end
+ end
+
+ # This makes sure a single user doesn't nominate the same tagname twice
+ validate :require_unique_tagnames
+ def require_unique_tagnames
+ noms = self.fandom_nominations + self.freeform_nominations
+ if self.fandom_nominations.empty?
+ noms += self.character_nominations + self.relationship_nominations
+ else
+ noms += self.fandom_nominations.collect(&:character_nominations).flatten
+ noms += self.fandom_nominations.collect(&:relationship_nominations).flatten
+ end
+ tagnames = noms.map(&:tagname).reject {|t| t.blank?}
+ duplicates = tagnames.group_by {|tagname| tagname}.select {|k,v| v.size > 1}.keys
+ errors.add(:base, ts("You seem to be trying to nominate %{duplicates} more than once.", duplicates: duplicates.join(', '))) unless duplicates.empty?
+ end
+
+ # Have NONE of the nominations been reviewed?
+ def unreviewed?
+ TagSet::TAG_TYPES_INITIALIZABLE.each do |tag_type|
+ return false if self.send("#{tag_type}_nominations").any? {|tn| tn.reviewed?}
+ end
+ return true
+ end
+
+ # Have ALL the nominations been reviewed?
+ def reviewed?
+ TagSet::TAG_TYPES_INITIALIZABLE.each do |tag_type|
+ return false if self.send("#{tag_type}_nominations").any? {|tn| tn.unreviewed?}
+ end
+ return true
+ end
+
+ def count_by_fandom?(tag_type)
+ %w(character relationship).include?(tag_type) && self.owned_tag_set.fandom_nomination_limit > 0
+ end
+
+ def self.owned_by(user = User.current_user)
+ select("DISTINCT tag_set_nominations.*").
+ joins(pseud: :user).
+ where("users.id = ?", user.id)
+ end
+
+ def self.for_tag_set(tag_set)
+ where(owned_tag_set_id: tag_set.id)
+ end
+
+ def nominated_tags(tag_type = "fandom", index = -1)
+ if count_by_fandom?(tag_type)
+ if index == -1
+ # send ALL the collected char/relationship nominations per fandom
+ self.fandom_nominations.collect(&("#{tag_type}_nominations".to_sym)).flatten
+ else
+ # send just the nominations for this fandom
+ self.fandom_nominations[index].send("#{tag_type}_nominations")
+ end
+ else
+ self.send("#{tag_type}_nominations")
+ end
+ end
+
+end
diff --git a/app/models/tagset_models/tag_set_ownership.rb b/app/models/tagset_models/tag_set_ownership.rb
new file mode 100755
index 0000000..5330f4b
--- /dev/null
+++ b/app/models/tagset_models/tag_set_ownership.rb
@@ -0,0 +1,4 @@
+class TagSetOwnership < ApplicationRecord
+ belongs_to :pseud
+ belongs_to :owned_tag_set
+end
diff --git a/app/models/unsorted_tag.rb b/app/models/unsorted_tag.rb
new file mode 100755
index 0000000..a6fd16b
--- /dev/null
+++ b/app/models/unsorted_tag.rb
@@ -0,0 +1,3 @@
+class UnsortedTag < Tag
+ NAME = "Unsorted Tag"
+end
diff --git a/app/models/user.rb b/app/models/user.rb
new file mode 100755
index 0000000..c9b0f2f
--- /dev/null
+++ b/app/models/user.rb
@@ -0,0 +1,626 @@
+class User < ApplicationRecord
+ audited redacted: [:encrypted_password, :password_salt]
+ include Justifiable
+ include WorksOwner
+ include PasswordResetsLimitable
+ include UserLoggable
+ include Searchable
+
+ devise :database_authenticatable,
+ :confirmable,
+ :registerable,
+ :rememberable,
+ :trackable,
+ :validatable,
+ :lockable,
+ :recoverable
+ devise :pwned_password unless Rails.env.test?
+
+ # Must come after Devise modules in order to alias devise_valid_password?
+ # properly
+ include BackwardsCompatiblePasswordDecryptor
+
+ # Allows other models to get the current user with User.current_user
+ thread_cattr_accessor :current_user
+
+ # Authorization plugin
+ acts_as_authorized_user
+ acts_as_authorizable
+ has_many :roles_users
+ has_many :roles, through: :roles_users, dependent: :destroy
+
+ ### BETA INVITATIONS ###
+ has_many :invitations, as: :creator
+ has_one :invitation, as: :invitee
+ has_many :user_invite_requests, dependent: :destroy
+
+ attr_accessor :invitation_token
+ # attr_accessible :invitation_token
+ after_create :mark_invitation_redeemed, :remove_from_queue
+
+ has_many :external_authors, dependent: :destroy
+ has_many :external_creatorships, foreign_key: "archivist_id"
+
+ has_many :fannish_next_of_kins, dependent: :delete_all, inverse_of: :kin, foreign_key: :kin_id
+ has_one :fannish_next_of_kin, dependent: :destroy
+
+ has_many :favorite_tags, dependent: :destroy
+
+ has_one :default_pseud, -> { where(is_default: true) }, class_name: "Pseud", inverse_of: :user
+ delegate :id, to: :default_pseud, prefix: true
+
+ has_many :pseuds, dependent: :destroy
+ validates_associated :pseuds
+
+ has_one :profile, dependent: :destroy
+ validates_associated :profile
+
+ has_one :preference, dependent: :destroy
+ validates_associated :preference
+ has_many :statuses, dependent: :destroy
+ has_many :blocks_as_blocked, class_name: "Block", dependent: :delete_all, inverse_of: :blocked, foreign_key: :blocked_id
+ has_many :blocks_as_blocker, class_name: "Block", dependent: :delete_all, inverse_of: :blocker, foreign_key: :blocker_id
+ has_many :blocked_users, through: :blocks_as_blocker, source: :blocked
+
+ # The block (if it exists) with this user as the blocker and
+ # User.current_user as the blocked:
+ has_one :block_of_current_user,
+ -> { where(blocked: User.current_user) },
+ class_name: "Block", foreign_key: :blocker_id, inverse_of: :blocker
+
+ # The block (if it exists) with User.current_user as the blocker and this
+ # user as the blocked:
+ has_one :block_by_current_user,
+ -> { where(blocker: User.current_user) },
+ class_name: "Block", foreign_key: :blocked_id, inverse_of: :blocked
+
+ has_many :mutes_as_muted, class_name: "Mute", dependent: :delete_all, inverse_of: :muted, foreign_key: :muted_id
+ has_many :mutes_as_muter, class_name: "Mute", dependent: :delete_all, inverse_of: :muter, foreign_key: :muter_id
+ has_many :muted_users, through: :mutes_as_muter, source: :muted
+
+ # The mute (if it exists) with User.current_user as the muter and this
+ # user as the muted:
+ has_one :mute_by_current_user,
+ -> { where(muter: User.current_user) },
+ class_name: "Mute", foreign_key: :muted_id, inverse_of: :muted
+
+ has_many :skins, foreign_key: "author_id", dependent: :nullify
+ has_many :work_skins, foreign_key: "author_id", dependent: :nullify
+
+ before_create :create_default_associateds
+ before_destroy :remove_user_from_kudos
+
+ before_update :add_renamed_at, if: :will_save_change_to_login?
+ after_update :update_pseud_name
+ after_update :send_wrangler_username_change_notification, if: :is_tag_wrangler?
+ after_update :log_change_if_login_was_edited, if: :saved_change_to_login?
+ after_update :log_email_change, if: :saved_change_to_email?
+
+ after_commit :reindex_user_creations_after_rename
+
+ has_many :collection_participants, through: :pseuds
+ has_many :collections, through: :collection_participants
+ has_many :invited_collections, -> { where("collection_participants.participant_role = ?", CollectionParticipant::INVITED) }, through: :collection_participants, source: :collection
+ has_many :participated_collections, -> { where("collection_participants.participant_role IN (?)", [CollectionParticipant::OWNER, CollectionParticipant::MODERATOR, CollectionParticipant::MEMBER]) }, through: :collection_participants, source: :collection
+ has_many :maintained_collections, -> { where("collection_participants.participant_role IN (?)", [CollectionParticipant::OWNER, CollectionParticipant::MODERATOR]) }, through: :collection_participants, source: :collection
+ has_many :owned_collections, -> { where("collection_participants.participant_role = ?", CollectionParticipant::OWNER) }, through: :collection_participants, source: :collection
+
+ has_many :challenge_signups, through: :pseuds
+ has_many :offer_assignments, through: :pseuds
+ has_many :pinch_hit_assignments, through: :pseuds
+ has_many :request_claims, class_name: "ChallengeClaim", foreign_key: "claiming_user_id", inverse_of: :claiming_user
+ has_many :gifts, -> { where(rejected: false) }, through: :pseuds
+ has_many :gift_works, -> { distinct }, through: :pseuds
+ has_many :rejected_gifts, -> { where(rejected: true) }, class_name: "Gift", through: :pseuds
+ has_many :rejected_gift_works, -> { distinct }, through: :pseuds
+ has_many :readings, dependent: :delete_all
+ has_many :bookmarks, through: :pseuds
+ has_many :bookmark_collection_items, through: :bookmarks, source: :collection_items
+ has_many :comments, through: :pseuds
+ has_many :kudos
+
+ # Nested associations through creatorships got weird after 3.0.x
+ has_many :creatorships, through: :pseuds
+
+ has_many :works, -> { distinct }, through: :pseuds
+ has_many :series, -> { distinct }, through: :pseuds
+ has_many :chapters, through: :pseuds
+ has_many :related_works, through: :works
+ has_many :parent_work_relationships, through: :works
+
+ has_many :tags, through: :works
+ has_many :filters, through: :works
+ has_many :direct_filters, through: :works
+
+ has_many :bookmark_tags, through: :bookmarks, source: :tags
+
+ has_many :subscriptions, dependent: :destroy
+ has_many :followings,
+ class_name: "Subscription",
+ as: :subscribable,
+ dependent: :destroy
+ has_many :subscribed_users,
+ through: :subscriptions,
+ source: :subscribable,
+ source_type: "User"
+ has_many :subscribers,
+ through: :followings,
+ source: :user
+
+ thread_cattr_accessor :should_update_wrangling_activity
+ has_many :wrangling_assignments, dependent: :destroy
+ has_many :fandoms, through: :wrangling_assignments
+ has_many :wrangled_tags, class_name: "Tag", as: :last_wrangler
+ has_one :last_wrangling_activity, dependent: :destroy
+
+ has_many :inbox_comments, dependent: :destroy
+ has_many :feedback_comments, -> { where(is_deleted: false, approved: true).order(created_at: :desc) }, through: :inbox_comments
+
+ has_many :log_items, dependent: :destroy
+ validates_associated :log_items
+
+ after_update :expire_caches
+
+ def expire_caches
+ return unless saved_change_to_login?
+ series.each(&:expire_byline_cache)
+ chapters.each(&:expire_byline_cache)
+ self.works.each do |work|
+ work.touch
+ work.expire_caches
+ end
+ end
+
+ def remove_user_from_kudos
+ # TODO: AO3-2195 Display orphaned kudos (no users; no IPs so not counted as guest kudos).
+ Kudo.where(user: self).update_all(user_id: nil)
+ end
+
+ def read_inbox_comments
+ inbox_comments.where(read: true)
+ end
+ def unread_inbox_comments
+ inbox_comments.where(read: false)
+ end
+ def unread_inbox_comments_count
+ unread_inbox_comments.with_bad_comments_removed.count
+ end
+
+ scope :alphabetical, -> { order(:login) }
+ scope :starting_with, -> (letter) { where('login like ?', "#{letter}%") }
+ scope :valid, -> { where(banned: false, suspended: false) }
+ scope :out_of_invites, -> { where(out_of_invites: true) }
+
+ validates :login,
+ length: { within: ArchiveConfig.LOGIN_LENGTH_MIN..ArchiveConfig.LOGIN_LENGTH_MAX },
+ format: {
+ with: /\A[A-Za-z0-9]\w*[A-Za-z0-9]\Z/,
+ min_login: ArchiveConfig.LOGIN_LENGTH_MIN,
+ max_login: ArchiveConfig.LOGIN_LENGTH_MAX
+ },
+ uniqueness: true,
+ not_forbidden_name: { if: :will_save_change_to_login? }
+ validate :username_is_not_recently_changed, if: :will_save_change_to_login?
+ validate :admin_username_generic, if: :will_save_change_to_login?
+
+ # allow nil so can save existing users
+ validates_length_of :password,
+ within: ArchiveConfig.PASSWORD_LENGTH_MIN..ArchiveConfig.PASSWORD_LENGTH_MAX,
+ allow_nil: true,
+ too_short: ts("is too short (minimum is %{min_pwd} characters)",
+ min_pwd: ArchiveConfig.PASSWORD_LENGTH_MIN),
+ too_long: ts("is too long (maximum is %{max_pwd} characters)",
+ max_pwd: ArchiveConfig.PASSWORD_LENGTH_MAX)
+
+ validates :email, email_format: true, uniqueness: true
+
+ # Virtual attribute for age check, data processing agreement, and terms of service
+ attr_accessor :age_over_13, :data_processing, :terms_of_service
+
+ validates :data_processing, acceptance: { allow_nil: false, if: :first_save? }
+ validates :age_over_13, acceptance: { allow_nil: false, if: :first_save? }
+ validates :terms_of_service, acceptance: { allow_nil: false, if: :first_save? }
+
+ def to_param
+ login
+ end
+
+ # Override of Devise method to allow user to login with login OR username as
+ # well as to make login case insensitive without losing user-preferred case
+ # for login display
+ def self.find_first_by_auth_conditions(tainted_conditions, options = {})
+ conditions = devise_parameter_filter.filter(tainted_conditions).merge(options)
+ login = conditions.delete(:login)
+ relation = self.where(conditions)
+
+ if login.present?
+ # MySQL is case-insensitive with utf8mb4_unicode_ci so we don't have to use
+ # lowercase values
+ relation = relation.where(["login = :value OR email = :value",
+ value: login])
+ end
+
+ relation.first
+ end
+
+ def self.for_claims(claims_ids)
+ joins(:request_claims).
+ where("challenge_claims.id IN (?)", claims_ids)
+ end
+
+ scope :with_includes_for_admin_index, -> { includes(:roles, :fannish_next_of_kin) }
+
+ def self.search_multiple_by_email(emails = [])
+ # Normalise and dedupe emails
+ all_emails = emails.map(&:downcase)
+ unique_emails = all_emails.uniq
+ # Find users and their email addresses
+ users = User.where(email: unique_emails)
+ found_emails = users.map(&:email).map(&:downcase)
+ # Remove found users from the total list of unique emails and count duplicates
+ not_found_emails = unique_emails - found_emails
+ num_duplicates = emails.size - unique_emails.size
+
+ [users, not_found_emails, num_duplicates]
+ end
+
+ ### AUTHENTICATION AND PASSWORDS
+ def active?
+ !confirmed_at.nil?
+ end
+
+ def activate
+ return false if self.active?
+ self.update_attribute(:confirmed_at, Time.now.utc)
+ end
+
+ def create_default_associateds
+ self.pseuds << Pseud.new(name: self.login, is_default: true)
+ self.profile = Profile.new
+ self.preference = Preference.new(locale: Locale.default)
+ end
+
+ def prevent_password_resets?
+ is_protected_user? || no_resets?
+ end
+
+ protected
+
+ def first_save?
+ self.new_record?
+ end
+
+ # Override of Devise method for email sending to set I18n.locale
+ # Based on https://github.com/heartcombo/devise/blob/v4.9.3/lib/devise/models/authenticatable.rb#L200
+ def send_devise_notification(notification, *args)
+ I18n.with_locale(preference.locale_for_mails) do
+ devise_mailer.send(notification, self, *args).deliver_now
+ end
+ end
+
+ public
+
+ # Returns an array (of pseuds) of this user's co-authors
+ def coauthors
+ works.collect(&:pseuds).flatten.uniq - pseuds
+ end
+
+ # Gets the user's most recent unposted work
+ def unposted_work
+ return @unposted_work if @unposted_work
+ @unposted_work = unposted_works.first
+ end
+
+ def unposted_works
+ return @unposted_works if @unposted_works
+ @unposted_works = works.where(posted: false).order("works.created_at DESC")
+ end
+
+ # removes ALL unposted works
+ def wipeout_unposted_works
+ works.where(posted: false).destroy_all
+ end
+
+ # Removes all of the user's series that don't have any listed works.
+ def destroy_empty_series
+ series.left_joins(:serial_works).where(serial_works: { id: nil }).
+ destroy_all
+ end
+
+ # Checks authorship of any sort of object
+ def is_author_of?(item)
+ if item.respond_to?(:pseud_id)
+ pseud_ids.include?(item.pseud_id)
+ elsif item.respond_to?(:user_id)
+ id == item.user_id
+ elsif item.respond_to?(:pseuds)
+ item.pseuds.pluck(:user_id).include?(id)
+ elsif item.respond_to?(:author)
+ self == item.author
+ elsif item.respond_to?(:creator_id)
+ self.id == item.creator_id
+ else
+ false
+ end
+ end
+
+ # Gets the number of works by this user that the current user can see
+ def visible_work_count
+ Work.owned_by(self).visible_to_user(User.current_user).revealed.non_anon.distinct.count(:id)
+ end
+
+ # Gets the user account for authored objects if orphaning is enabled
+ def self.orphan_account
+ User.fetch_orphan_account if ArchiveConfig.ORPHANING_ALLOWED
+ end
+
+ # Is this user an authorized tag wrangler?
+ def tag_wrangler
+ self.is_tag_wrangler?
+ end
+
+ def is_tag_wrangler?
+ has_role?(:tag_wrangler)
+ end
+
+ # Is this user an authorized archivist?
+ def archivist
+ self.is_archivist?
+ end
+
+ def is_archivist?
+ has_role?(:archivist)
+ end
+
+ # Is this user an authorized official?
+ def official
+ has_role?(:official)
+ end
+
+ # Is this user a protected user? These are users experiencing certain types
+ # of harassment.
+ def protected_user
+ self.is_protected_user?
+ end
+
+ def is_protected_user?
+ has_role?(:protected_user)
+ end
+
+ # Is this user assigned the no resets role? These users do not wish to receive
+ # password resets.
+ def no_resets?
+ has_role?(:no_resets)
+ end
+
+ # Should this user's comments be spam-checked?
+ def should_spam_check_comments?
+ # When account_age_threshold_for_comment_spam_check is 0, no users' comments should be spam-checked
+ (Time.current - created_at).seconds.in_days.to_i < AdminSetting.current.account_age_threshold_for_comment_spam_check
+ end
+
+ # Creates log item tracking changes to user
+ def create_log_item(options = {})
+ options.reverse_merge! note: "System Generated", user_id: self.id
+ LogItem.create(options)
+ end
+
+ def update_last_wrangling_activity
+ return unless is_tag_wrangler?
+
+ last_activity = LastWranglingActivity.find_or_create_by(user: self)
+ last_activity.touch
+ end
+
+ # Returns true if user is the sole author of a work
+ # Should also be true if the user has used more than one of their pseuds on a work
+ def is_sole_author_of?(item)
+ other_pseuds = item.pseuds - pseuds
+ self.is_author_of?(item) && other_pseuds.blank?
+ end
+
+ # Returns array of works where the user is the sole author
+ def sole_authored_works
+ @sole_authored_works = []
+ works.where(posted: 1).each do |w|
+ if self.is_sole_author_of?(w)
+ @sole_authored_works << w
+ end
+ end
+ return @sole_authored_works
+ end
+
+ # Returns array of the user's co-authored works
+ def coauthored_works
+ @coauthored_works = []
+ works.where(posted: 1).each do |w|
+ unless self.is_sole_author_of?(w)
+ @coauthored_works << w
+ end
+ end
+ return @coauthored_works
+ end
+
+ # Returns array of collections where the user is the sole author
+ def sole_owned_collections
+ self.collections.to_a.delete_if { |collection| !(collection.all_owners - pseuds).empty? }
+ end
+
+ ### BETA INVITATIONS ###
+
+ #If a new user has an invitation_token (meaning they were invited), the method sets the redeemed_at column for that invitation to Time.now
+ def mark_invitation_redeemed
+ unless self.invitation_token.blank?
+ invitation = Invitation.find_by(token: self.invitation_token)
+ if invitation
+ self.update_attribute(:invitation_id, invitation.id)
+ invitation.mark_as_redeemed(self)
+ end
+ end
+ end
+
+ # Existing users should be removed from the invitations queue
+ def remove_from_queue
+ invite_request = InviteRequest.find_by(email: self.email)
+ invite_request.destroy if invite_request
+ end
+
+ def fix_user_subscriptions
+ # Delete any subscriptions the user has to deleted items because this causes
+ # the user's subscription page to error
+ @subscriptions = subscriptions.includes(:subscribable)
+ @subscriptions.to_a.each do |sub|
+ if sub.name.nil?
+ sub.destroy
+ end
+ end
+ end
+
+ def set_user_work_dates
+ # Fix user stats page error caused by the existence of works with nil revised_at dates
+ works.each do |work|
+ if work.revised_at.nil?
+ work.save
+ end
+ IndexQueue.enqueue(work, :main)
+ end
+ end
+
+ def reindex_user_creations
+ IndexQueue.enqueue_ids(Work, works.pluck(:id), :main)
+ IndexQueue.enqueue_ids(Bookmark, bookmarks.pluck(:id), :main)
+ IndexQueue.enqueue_ids(Series, series.pluck(:id), :main)
+ IndexQueue.enqueue_ids(Pseud, pseuds.pluck(:id), :main)
+ end
+
+ # Function to make it easier to retrieve info from the audits table.
+ #
+ # Looks up all past values of the given field, excluding the current value of
+ # the field:
+ def historic_values(field)
+ field = field.to_s
+
+ audits.filter_map do |audit|
+ audit.audited_changes[field]
+ end.flatten.uniq.without(self[field])
+ end
+
+ private
+
+ # Override the default Justifiable enabled check, because we only need to justify
+ # username changes at the moment.
+ def justification_enabled?
+ User.current_user.is_a?(Admin) && login_changed?
+ end
+
+ # Create and/or return a user account for holding orphaned works
+ def self.fetch_orphan_account
+ orphan_account = User.find_or_create_by(login: "orphan_account")
+ if orphan_account.new_record?
+ Rails.logger.fatal "You must have a User with the login 'orphan_account'. Please create one."
+ end
+ orphan_account
+ end
+
+ def update_pseud_name
+ return unless saved_change_to_login? && login_before_last_save.present?
+ old_pseud = pseuds.where(name: login_before_last_save).first
+ if login.downcase == login_before_last_save.downcase
+ old_pseud.name = login
+ old_pseud.save!
+ else
+ new_pseud = pseuds.where(name: login).first
+ # do nothing if they already have the matching pseud
+ if new_pseud.blank?
+ if old_pseud.present?
+ # change the old pseud to match
+ old_pseud.name = login
+ old_pseud.save!(validate: false)
+ else
+ # shouldn't be able to get here, but just in case
+ Pseud.create!(name: login, user_id: id)
+ end
+ end
+ end
+ end
+
+ def reindex_user_creations_after_rename
+ return unless saved_change_to_login? && login_before_last_save.present?
+ # Everything is indexed with the user's byline,
+ # which has the old username, so they all need to be reindexed.
+ reindex_user_creations
+ end
+
+ def add_renamed_at
+ if User.current_user == self
+ self.renamed_at = Time.current
+ else
+ self.admin_renamed_at = Time.current
+ end
+ end
+
+ def log_change_if_login_was_edited
+ current_admin = User.current_user if User.current_user.is_a?(Admin)
+ options = {
+ action: ArchiveConfig.ACTION_RENAME,
+ admin: current_admin
+ }
+ options[:note] = if current_admin
+ "Old Username: #{login_before_last_save}, New Username: #{login}, Changed by: #{current_admin.login}, Ticket ID: ##{ticket_number}"
+ else
+ "Old Username: #{login_before_last_save}; New Username: #{login}"
+ end
+ create_log_item(options)
+ end
+
+ def send_wrangler_username_change_notification
+ return unless saved_change_to_login? && login_before_last_save.present?
+
+ TagWranglingSupervisorMailer.wrangler_username_change_notification(login_before_last_save, login).deliver_now
+ end
+
+ def log_email_change
+ current_admin = User.current_user if User.current_user.is_a?(Admin)
+ options = {
+ action: ArchiveConfig.ACTION_NEW_EMAIL,
+ admin_id: current_admin&.id
+ }
+ options[:note] = "Change made by #{current_admin&.login}" if current_admin
+ create_log_item(options)
+ end
+
+ def remove_stale_from_autocomplete
+ self.class.remove_from_autocomplete(self.autocomplete_search_string_was, self.autocomplete_prefixes, self.autocomplete_value_was)
+ end
+
+ def username_is_not_recently_changed
+ return if User.current_user.is_a?(Admin)
+
+ change_interval_days = ArchiveConfig.USER_RENAME_LIMIT_DAYS
+ return unless renamed_at && change_interval_days.days.ago <= renamed_at
+
+ errors.add(:login,
+ :changed_too_recently,
+ count: change_interval_days,
+ renamed_at: I18n.l(renamed_at))
+ end
+
+ def admin_username_generic
+ return unless User.current_user.is_a?(Admin)
+
+ errors.add(:login, :admin_must_use_default) unless login == "user#{id}"
+ end
+
+ # Extra callback to make sure readings are deleted in an order consistent
+ # with the ReadingsJob.
+ #
+ # TODO: In the long term, it might be better to change the indexes on the
+ # readings table so that it deletes things in the correct order by default if
+ # we just set dependent: :delete_all, but for now we need to explicitly sort
+ # by work_id to make sure that the readings are locked in the correct order.
+ before_destroy :clear_readings, prepend: true
+ def clear_readings
+ readings.order(:work_id).each(&:delete)
+ end
+end
+
diff --git a/app/models/user_invite_request.rb b/app/models/user_invite_request.rb
new file mode 100755
index 0000000..754e240
--- /dev/null
+++ b/app/models/user_invite_request.rb
@@ -0,0 +1,34 @@
+class UserInviteRequest < ApplicationRecord
+ MAX_USER_INVITE_REQUEST = ArchiveConfig.MAX_USER_INVITE_REQUEST
+
+ belongs_to :user
+ validates_presence_of :quantity
+ validates_presence_of :reason
+ validates :quantity, numericality: {less_than_or_equal_to: MAX_USER_INVITE_REQUEST}
+
+ before_update :check_status, :grant_request
+
+ scope :not_handled, -> { where(handled: false) }
+
+ private
+
+ #Mark the request granted and/or handled as appropriate
+ def check_status
+ self.handled = true
+ if self.quantity > 0
+ self.granted = true
+ end
+ end
+
+ #Create new invitations for the user who requested them
+ def grant_request
+ if self.granted?
+ self.quantity.times do
+ self.user.invitations.create
+ end
+ I18n.with_locale(self.user.preference.locale_for_mails) do
+ UserMailer.invite_increase_notification(self.user.id, self.quantity).deliver_after_commit
+ end
+ end
+ end
+end
diff --git a/app/models/user_manager.rb b/app/models/user_manager.rb
new file mode 100755
index 0000000..271aefe
--- /dev/null
+++ b/app/models/user_manager.rb
@@ -0,0 +1,134 @@
+# Allows admins to manage user status via the admin users interface
+class UserManager
+ attr_reader :admin,
+ :user,
+ :admin_note,
+ :admin_action,
+ :suspension_length,
+ :errors,
+ :successes
+
+ PERMITTED_ACTIONS = %w[note warn suspend unsuspend ban unban spamban].freeze
+
+ def initialize(admin, user, params)
+ @admin = admin
+ @user = user
+ @admin_note = params[:admin_note]
+ @admin_action = params[:admin_action]
+ @suspension_length = params[:suspend_days]
+ @errors = []
+ @successes = []
+ end
+
+ def save
+ validate_user_and_admin &&
+ validate_orphan_account &&
+ validate_admin_note &&
+ validate_suspension &&
+ save_admin_action
+ end
+
+ def success_message
+ successes.join(" ")
+ end
+
+ def error_message
+ errors.join(" ")
+ end
+
+ private
+
+ def validate_user_and_admin
+ if user && admin
+ true
+ else
+ errors << "Must have a valid user and admin account to proceed."
+ false
+ end
+ end
+
+ def validate_orphan_account
+ if user == User.orphan_account
+ errors << "orphan_account cannot be warned, suspended, or banned."
+ false
+ else
+ true
+ end
+ end
+
+ def validate_admin_note
+ return true if admin_note.present? || admin_action.blank?
+
+ if admin_action == "spamban"
+ @admin_note = "Banned for spam"
+ elsif admin_action.present?
+ errors << "You must include notes in order to perform this action."
+ false
+ end
+ end
+
+ def validate_suspension
+ if admin_action == "suspend" && suspension_length.blank?
+ errors << "Please enter the number of days for which the user should be suspended."
+ false
+ else
+ true
+ end
+ end
+
+ def save_admin_action
+ return true if admin_action.blank?
+
+ send("#{admin_action}_user") if PERMITTED_ACTIONS.include?(admin_action)
+ end
+
+ def note_user
+ log_action(ArchiveConfig.ACTION_NOTE)
+ successes << "Note was recorded."
+ end
+
+ def warn_user
+ log_action(ArchiveConfig.ACTION_WARN)
+ successes << "Warning was recorded."
+ end
+
+ def suspend_user
+ user.suspended = true
+ user.suspended_until = suspension_length.to_i.days.from_now
+ user.save!
+ log_action(ArchiveConfig.ACTION_SUSPEND, enddate: user.suspended_until)
+ successes << "User has been temporarily suspended."
+ end
+
+ def unsuspend_user
+ user.suspended = false
+ user.suspended_until = nil
+ user.save!
+ log_action(ArchiveConfig.ACTION_UNSUSPEND)
+ successes << "Suspension has been lifted."
+ end
+
+ def ban_user
+ user.banned = true
+ user.save!
+ log_action(ArchiveConfig.ACTION_BAN)
+ successes << "User has been permanently suspended."
+ end
+ alias spamban_user ban_user
+
+ def unban_user
+ user.banned = false
+ user.save!
+ log_action(ArchiveConfig.ACTION_UNSUSPEND)
+ successes << "Suspension has been lifted."
+ end
+
+ def log_action(message, options = {})
+ options.merge!(
+ action: message,
+ note: admin_note,
+ admin_id: admin.id
+ )
+ user.create_log_item(options)
+ end
+end
diff --git a/app/models/work.rb b/app/models/work.rb
new file mode 100755
index 0000000..36ec2ee
--- /dev/null
+++ b/app/models/work.rb
@@ -0,0 +1,1324 @@
+class Work < ApplicationRecord
+ include Filterable
+ include CreationNotifier
+ include Collectible
+ include Bookmarkable
+ include Searchable
+ include BookmarkCountCaching
+ include WorkChapterCountCaching
+ include Creatable
+
+ ########################################################################
+ # ASSOCIATIONS
+ ########################################################################
+
+ has_many :external_creatorships, as: :creation, dependent: :destroy, inverse_of: :creation
+ has_many :archivists, through: :external_creatorships
+ has_many :external_author_names, through: :external_creatorships, inverse_of: :works
+ has_many :external_authors, -> { distinct }, through: :external_author_names
+
+ # we do NOT use dependent => destroy here because we want to destroy chapters in REVERSE order
+ has_many :chapters, inverse_of: :work, autosave: true
+
+ has_many :serial_works, dependent: :destroy
+ has_many :series, through: :serial_works
+
+ has_many :related_works, as: :parent
+ has_many :approved_related_works, -> { where(reciprocal: 1) }, as: :parent, class_name: "RelatedWork"
+ has_many :parent_work_relationships, class_name: "RelatedWork", dependent: :destroy
+ has_many :children, through: :related_works, source: :work
+ has_many :approved_children, through: :approved_related_works, source: :work
+
+ accepts_nested_attributes_for :parent_work_relationships, allow_destroy: true, reject_if: proc { |attrs| attrs.values_at(:url, :author, :title).all?(&:blank?) }
+
+ has_many :gifts, dependent: :destroy
+ accepts_nested_attributes_for :gifts, allow_destroy: true
+
+ has_many :subscriptions, as: :subscribable, dependent: :destroy
+
+ has_many :challenge_assignments, as: :creation
+ has_many :challenge_claims, as: :creation
+ accepts_nested_attributes_for :challenge_claims
+
+ acts_as_commentable
+ has_many :total_comments, class_name: 'Comment', through: :chapters
+ has_many :kudos, as: :commentable, dependent: :destroy
+
+ has_many :original_creators, class_name: "WorkOriginalCreator", dependent: :destroy
+
+ belongs_to :language
+ belongs_to :work_skin
+ validate :work_skin_allowed, on: :save
+ def work_skin_allowed
+ unless self.users.include?(self.work_skin.author) || (self.work_skin.public? && self.work_skin.official?)
+ errors.add(:base, ts("You do not have permission to use that custom work stylesheet."))
+ end
+ end
+ # statistics
+ has_one :stat_counter, dependent: :destroy
+ after_create :create_stat_counter
+ def create_stat_counter
+ counter = self.build_stat_counter
+ counter.save
+ end
+ # moderation
+ has_one :moderated_work, dependent: :destroy
+
+ ########################################################################
+ # VIRTUAL ATTRIBUTES
+ ########################################################################
+
+ # Virtual attribute to use as a placeholder for pseuds before the work has been saved
+ # Can't write to work.pseuds until the work has an id
+ attr_accessor :new_parent, :url_for_parent
+ attr_accessor :new_gifts
+ attr_accessor :preview_mode
+
+ # Virtual attribute for whether the hidden-for-spam email has been sent, so the normal work-hidden email should not be sent
+ attr_accessor :notified_of_hiding_for_spam
+
+ # return title.html_safe to overcome escaping done by sanitiser
+ def title
+ read_attribute(:title).try(:html_safe)
+ end
+
+ ########################################################################
+ # VALIDATION
+ ########################################################################
+ validates_presence_of :title
+ validates_length_of :title,
+ minimum: ArchiveConfig.TITLE_MIN,
+ too_short: ts("must be at least %{min} characters long.", min: ArchiveConfig.TITLE_MIN)
+
+ validates_length_of :title,
+ maximum: ArchiveConfig.TITLE_MAX,
+ too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.TITLE_MAX)
+
+ validates_length_of :summary,
+ allow_blank: true,
+ maximum: ArchiveConfig.SUMMARY_MAX,
+ too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.SUMMARY_MAX)
+
+ validates_length_of :notes,
+ allow_blank: true,
+ maximum: ArchiveConfig.NOTES_MAX,
+ too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.NOTES_MAX)
+
+ validates_length_of :endnotes,
+ allow_blank: true,
+ maximum: ArchiveConfig.NOTES_MAX,
+ too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.NOTES_MAX)
+
+ validate :language_present_and_supported
+
+ def language_present_and_supported
+ errors.add(:base, ts("Language cannot be blank.")) if self.language.blank?
+ end
+
+ # Makes sure the title has no leading spaces
+ validate :clean_and_validate_title
+
+ def clean_and_validate_title
+ unless self.title.blank?
+ self.title = self.title.strip
+ if self.title.length < ArchiveConfig.TITLE_MIN
+ errors.add(:base, ts("Title must be at least %{min} characters long without leading spaces.", min: ArchiveConfig.TITLE_MIN))
+ throw :abort
+ else
+ self.title_to_sort_on = self.sorted_title
+ end
+ end
+ end
+
+ def validate_published_at
+ return unless first_chapter
+
+ if !self.first_chapter.published_at
+ self.first_chapter.published_at = Date.current
+ elsif self.first_chapter.published_at > Date.current
+ errors.add(:base, ts("Publication date can't be in the future."))
+ throw :abort
+ end
+ end
+
+ validates :fandom_string,
+ presence: { message: "^Please fill in at least one fandom." }
+ validates :archive_warning_string,
+ presence: { message: "^Please select at least one warning." }
+ validates :rating_string,
+ presence: { message: "^Please choose a rating." }
+
+ validate :only_one_rating
+ def only_one_rating
+ return unless split_tag_string(rating_string).count > 1
+
+ errors.add(:base, ts("Only one rating is allowed."))
+ end
+
+ # rephrases the "chapters is invalid" message
+ after_validation :check_for_invalid_chapters
+ def check_for_invalid_chapters
+ if self.errors[:chapters].any?
+ self.errors.add(:base, ts("Please enter your story in the text field below."))
+ self.errors.delete(:chapters)
+ end
+ end
+
+ validates :user_defined_tags_count,
+ at_most: { maximum: proc { ArchiveConfig.USER_DEFINED_TAGS_MAX } }
+
+ # If the recipient doesn't allow gifts, it should not be possible to give them
+ # a gift work unless it fulfills a gift exchange assignment or non-anonymous
+ # prompt meme claim for the recipient.
+ # We don't want the work to save if the gift shouldn't exist, but the gift
+ # model can't access a work's challenge_assignments or challenge_claims until
+ # the work and its assignments and claims are saved. Gifts are created after
+ # the work is saved, so it's too late then to prevent the work from saving.
+ # Additionally, the work's assignments and claims don't appear to be available
+ # by the time gift validations run, which means the gift is never created if
+ # the user doesn't allow them.
+ validate :new_recipients_allow_gifts
+
+ def new_recipients_allow_gifts
+ return if self.new_gifts.blank?
+
+ self.new_gifts.each do |gift|
+ next if gift.pseud.blank?
+ next if gift.pseud&.user&.preference&.allow_gifts?
+ next if challenge_bypass(gift)
+
+ self.errors.add(:base, :blocked_gifts, byline: gift.pseud.byline)
+ end
+ end
+
+ validate :new_recipients_have_not_blocked_gift_giver
+ def new_recipients_have_not_blocked_gift_giver
+ return if self.new_gifts.blank?
+
+ self.new_gifts.each do |gift|
+ # Already dealt with in #new_recipients_allow_gifts
+ next if gift.pseud&.user&.preference && !gift.pseud.user.preference.allow_gifts?
+
+ next if challenge_bypass(gift)
+
+ blocked_users = gift.pseud&.user&.blocked_users || []
+ next if blocked_users.empty?
+
+ pseuds_after_saving.each do |pseud|
+ next unless blocked_users.include?(pseud.user)
+
+ if User.current_user == pseud.user
+ self.errors.add(:base, :blocked_your_gifts, byline: gift.pseud.byline)
+ else
+ self.errors.add(:base, :blocked_gifts, byline: gift.pseud.byline)
+ end
+ end
+ end
+ end
+
+ enum :comment_permissions, {
+ enable_all: 0,
+ disable_anon: 1,
+ disable_all: 2
+ }, suffix: :comments, default: 1
+
+ ########################################################################
+ # HOOKS
+ # These are methods that run before/after saves and updates to ensure
+ # consistency and that associated variables are updated.
+ ########################################################################
+
+ before_save :clean_and_validate_title, :validate_published_at, :ensure_revised_at
+
+ after_save :post_first_chapter
+ before_save :set_word_count
+
+ after_save :save_chapters, :save_new_gifts
+
+ before_create :set_anon_unrevealed
+ after_create :notify_after_creation
+
+ after_update :adjust_series_restriction, :notify_after_update
+
+ before_save :hide_spam
+ after_save :moderate_spam
+ after_save :notify_of_hiding
+
+ after_save :notify_recipients, :expire_caches, :update_pseud_index, :update_tag_index, :touch_series, :touch_related_works
+ after_destroy :expire_caches, :update_pseud_index
+
+ before_destroy :send_deleted_work_notification, prepend: true
+ def send_deleted_work_notification
+ return unless self.posted? && users.present?
+
+ orphan_account = User.orphan_account
+ users.each do |user|
+ next if user == orphan_account
+
+ I18n.with_locale(user.preference.locale_for_mails) do
+ # Check to see if this work is being deleted by an Admin
+ if User.current_user.is_a?(Admin)
+ # this has to use the synchronous version because the work is going to be destroyed
+ UserMailer.admin_deleted_work_notification(user, self).deliver_now
+ else
+ # this has to use the synchronous version because the work is going to be destroyed
+ UserMailer.delete_work_notification(user, self, User.current_user).deliver_now
+ end
+ end
+ end
+ end
+
+ def expire_caches
+ pseuds.each do |pseud|
+ pseud.update_works_index_timestamp!
+ pseud.user.update_works_index_timestamp!
+ end
+
+ collections.each do |this_collection|
+ collection = this_collection
+ # Flush this collection and all its parents
+ loop do
+ collection.update_works_index_timestamp!
+ collection = collection.parent
+ break unless collection
+ end
+ end
+
+ filters.each do |tag|
+ tag.update_works_index_timestamp!
+ end
+
+ tags.each do |tag|
+ tag.update_tag_cache
+ end
+
+ series.each(&:expire_caches)
+
+ Work.expire_work_blurb_version(id)
+ Work.flush_find_by_url_cache unless imported_from_url.blank?
+ end
+
+ def update_pseud_index
+ return unless should_reindex_pseuds?
+ IndexQueue.enqueue_ids(Pseud, pseud_ids, :background)
+ end
+
+ # Visibility has changed, which means we need to reindex
+ # the work's pseuds, to update their work counts, as well as
+ # the work's bookmarker pseuds, to update their bookmark counts.
+ def should_reindex_pseuds?
+ pertinent_attributes = %w(id posted restricted in_anon_collection
+ in_unrevealed_collection hidden_by_admin)
+ destroyed? || (saved_changes.keys & pertinent_attributes).present?
+ end
+
+ # If the work gets posted, (un)hidden, or (un)revealed, we should (potentially) reindex the tags,
+ # so they get the correct visibility status.
+ def update_tag_index
+ return unless saved_change_to_posted? || saved_change_to_hidden_by_admin? || saved_change_to_in_unrevealed_collection?
+
+ taggings.each(&:update_search)
+ end
+
+ def self.work_blurb_version_key(id)
+ "/v4/work_blurb_tag_cache_key/#{id}"
+ end
+
+ def self.work_blurb_version(id)
+ Rails.cache.fetch(Work.work_blurb_version_key(id), raw: true) { rand(1..1000) }
+ end
+
+ def self.expire_work_blurb_version(id)
+ Rails.cache.increment(Work.work_blurb_version_key(id))
+ end
+
+ # When works are done being reindexed, expire the appropriate caches
+ def self.successful_reindex(ids)
+ CacheMaster.expire_caches(ids)
+ tag_ids = FilterTagging.where(filterable_id: ids, filterable_type: 'Work').
+ group(:filter_id).
+ pluck(:filter_id)
+
+ collection_ids = CollectionItem.where(item_id: ids, item_type: 'Work').
+ group(:collection_id).
+ pluck(:collection_id)
+
+ pseuds = Pseud.select("pseuds.id, pseuds.user_id").
+ joins(:creatorships).
+ where(creatorships: {
+ creation_id: ids,
+ creation_type: 'Work'
+ }
+ )
+
+ pseuds.each { |p| p.update_works_index_timestamp! }
+ User.expire_ids(pseuds.map(&:user_id).uniq)
+ Tag.expire_ids(tag_ids)
+ Collection.expire_ids(collection_ids)
+ end
+
+ def touch_series
+ series.touch_all if saved_change_to_in_anon_collection?
+ end
+
+ after_destroy :destroy_chapters_in_reverse
+ def destroy_chapters_in_reverse
+ chapters.sort_by(&:position).reverse.each(&:destroy)
+ end
+
+ after_destroy :clean_up_assignments
+ def clean_up_assignments
+ self.challenge_assignments.each {|a| a.creation = nil; a.save!}
+ end
+
+ ########################################################################
+ # RESQUE
+ ########################################################################
+
+ include AsyncWithResque
+ @queue = :utilities
+
+ ########################################################################
+ # IMPORTING
+ ########################################################################
+
+ def self.find_by_url_generation_key
+ "/v1/find_by_url_generation_key"
+ end
+
+ def self.find_by_url_generation
+ Rails.cache.fetch(Work.find_by_url_generation_key, raw: true) { rand(1..1000) }
+ end
+
+ def self.flush_find_by_url_cache
+ Rails.cache.increment(Work.find_by_url_generation_key)
+ end
+
+ def self.find_by_url_cache_key(url)
+ url = UrlFormatter.new(url)
+ "/v1/find_by_url/#{Work.find_by_url_generation}/#{url.encoded}"
+ end
+
+ # Match `url` to a work's imported_from_url field using progressively fuzzier matching:
+ # 1. first exact match
+ # 2. first exact match with variants of the provided url
+ # 3. first match on variants of both the imported_from_url and the provided url if there is a partial match
+
+ def self.find_by_url_uncached(url)
+ url = UrlFormatter.new(url)
+ Work.where(imported_from_url: url.original).first ||
+ Work.where(imported_from_url: [url.minimal,
+ url.with_http, url.with_https,
+ url.no_www, url.with_www,
+ url.encoded, url.decoded,
+ url.minimal_no_protocol_no_www]).first ||
+ Work.where("imported_from_url LIKE ? or imported_from_url LIKE ?",
+ "http://#{url.minimal_no_protocol_no_www}%",
+ "https://#{url.minimal_no_protocol_no_www}%").select do |w|
+ work_url = UrlFormatter.new(w.imported_from_url)
+ %w[original minimal no_www with_www with_http with_https encoded decoded].any? do |method|
+ work_url.send(method) == url.send(method)
+ end
+ end.first
+ end
+
+ def self.find_by_url(url)
+ Rails.cache.fetch(Work.find_by_url_cache_key(url)) do
+ find_by_url_uncached(url)
+ end
+ end
+
+ # Remove all pseuds associated with a particular user. Raises an exception if
+ # this would result in removing all creators from the work.
+ #
+ # Callbacks handle most of the work when deleting creatorships, but we do
+ # have one special case: if a co-created work has a chapter that only has
+ # one listed creator, and that creator removes themselves from the work, we
+ # need to update the chapter to add the other creators on the work.
+ def remove_author(author_to_remove)
+ pseuds_with_author_removed = pseuds.where.not(user_id: author_to_remove.id)
+
+ if pseuds_with_author_removed.empty?
+ errors.add(:base, ts("Sorry, we can't remove all creators of a work."))
+ raise ActiveRecord::RecordInvalid, self
+ end
+
+ transaction do
+ chapters.each do |chapter|
+ if (chapter.pseuds - author_to_remove.pseuds).empty?
+ pseuds_with_author_removed.each do |new_pseud|
+ chapter.creatorships.find_or_create_by(pseud: new_pseud)
+ end
+ end
+
+ chapter.creatorships.where(pseud: author_to_remove.pseuds).destroy_all
+ end
+
+ creatorships.where(pseud: author_to_remove.pseuds).destroy_all
+ end
+ end
+
+ # Override the default behavior so that we also check for creatorships
+ # associated with one of the chapters.
+ def user_is_owner_or_invited?(user)
+ return false unless user.is_a?(User)
+ return true if super
+
+ chapters.joins(:creatorships).merge(user.creatorships).exists?
+ end
+
+ def set_challenge_info
+ # if this is fulfilling a challenge, add the collection and recipient
+ challenge_assignments.each do |assignment|
+ add_to_collection(assignment.collection)
+ self.gifts << Gift.new(pseud: assignment.requesting_pseud) unless (assignment.requesting_pseud.blank? || recipients && recipients.include?(assignment.request_byline))
+ end
+ end
+
+ # If this is fulfilling a challenge claim, add the collection.
+ #
+ # Unlike set_challenge_info, we don't automatically add the prompter as a
+ # recipient, because (a) some prompters are anonymous, so there has to be a
+ # prompter notification (separate from the recipient notification) ensuring
+ # that anonymous prompters are notified, and (b) if the prompter is not
+ # anonymous, they'll receive two notifications with roughly the same info
+ # (gift notification + prompter notification).
+ def set_challenge_claim_info
+ challenge_claims.each do |claim|
+ add_to_collection(claim.collection)
+ end
+ end
+
+ def challenge_assignment_ids
+ challenge_assignments.map(&:id)
+ end
+
+ def challenge_claim_ids
+ challenge_claims.map(&:id)
+ end
+
+ # Only allow a work to fulfill an assignment assigned to one of this work's authors
+ def challenge_assignment_ids=(ids)
+ valid_users = (self.users + [User.current_user]).compact
+
+ self.challenge_assignments =
+ ChallengeAssignment.where(id: ids)
+ .select { |assign| valid_users.include?(assign.offering_user) }
+ end
+
+ def recipients=(recipient_names)
+ new_gifts = []
+ gifts = [] # rebuild the list of associated gifts using the new list of names
+ # add back in the rejected gift recips; we don't let users delete rejected gifts in order to prevent regifting
+ recip_names = recipient_names.split(',') + self.gifts.are_rejected.collect(&:recipient)
+ recip_names.uniq.each do |name|
+ name.strip!
+ gift = self.gifts.for_name_or_byline(name).first
+ if gift
+ gifts << gift # new gifts are added after saving, not now
+ new_gifts << gift unless self.posted # all gifts are new if work not posted
+ else
+ g = self.gifts.new(recipient: name)
+ if g.valid?
+ new_gifts << g # new gifts are added after saving, not now
+ else
+ g.errors.full_messages.each { |msg| self.errors.add(:base, msg) }
+ end
+ end
+ end
+ self.gifts = gifts
+ self.new_gifts = new_gifts
+ end
+
+ def recipients(for_form = false)
+ names = (for_form ? self.gifts.not_rejected : self.gifts).collect(&:recipient)
+ names << self.new_gifts.collect(&:recipient) if self.new_gifts.present?
+ names.flatten.uniq.join(",")
+ end
+
+ def save_new_gifts
+ return if self.new_gifts.blank?
+
+ self.new_gifts.each do |gift|
+ next if self.gifts.for_name_or_byline(gift.recipient).present?
+
+ # Recreate the gift once the work is saved. This ensures the work_id is
+ # set properly.
+ Gift.create(recipient: gift.recipient, work: self)
+ end
+ end
+
+ def marked_for_later?(user)
+ Reading.where(work_id: self.id, user_id: user.id, toread: true).exists?
+ end
+
+ ########################################################################
+ # VISIBILITY
+ ########################################################################
+
+ def visible?(user = User.current_user)
+ return true if user.is_a?(Admin)
+
+ if posted && !hidden_by_admin
+ user.is_a?(User) || !restricted
+ else
+ user_is_owner_or_invited?(user)
+ end
+ end
+
+ def unrevealed?(user=User.current_user)
+ # eventually here is where we check if it's in a challenge that hasn't been made public yet
+ #!self.collection_items.unrevealed.empty?
+ in_unrevealed_collection?
+ end
+
+ def anonymous?(user = User.current_user)
+ # here we check if the story is in a currently-anonymous challenge
+ #!self.collection_items.anonymous.empty?
+ in_anon_collection?
+ end
+
+ before_update :bust_anon_caching
+ def bust_anon_caching
+ if in_anon_collection_changed?
+ async(:poke_cached_creator_comments)
+ end
+ end
+
+ # This work's collections and parent collections
+ def all_collections
+ Collection.where(id: self.collection_ids) || []
+ end
+
+ ########################################################################
+ # VERSIONS & REVISION DATES
+ ########################################################################
+
+ def set_revised_at(date=nil)
+ date ||= self.chapters.where(posted: true).maximum('published_at') ||
+ self.revised_at || self.created_at || Time.current
+
+ if date.instance_of?(Date)
+ # We need a time, not a Date. So if the date is today, set it to the
+ # current time; otherwise, set it to noon UTC (so that almost every
+ # single time zone will have the revised_at date match the published_at
+ # date, and those that don't will have revised_at follow published_at).
+ date = (date == Date.current) ? Time.current : date.to_time(:utc).noon
+ end
+
+ self.revised_at = date
+ end
+
+ def set_revised_at_by_chapter(chapter)
+ # Invalidate chapter count cache
+ self.invalidate_work_chapter_count(self)
+ return if self.posted? && !chapter.posted?
+
+ unless self.posted_changed?
+ if chapter.posted_changed?
+ self.major_version = self.major_version + 1
+ else
+ self.minor_version = self.minor_version + 1
+ end
+ end
+
+ if (self.new_record? || chapter.posted_changed?) && chapter.published_at == Date.current
+ self.set_revised_at(Time.current) # a new chapter is being posted, so most recent update is now
+ else
+ # Calculate the most recent chapter publication date:
+ max_date = self.chapters.where('id != ? AND posted = 1', chapter.id).maximum('published_at')
+ max_date = max_date.nil? ? chapter.published_at : [max_date, chapter.published_at].max
+
+ # Update revised_at to match the chapter publication date unless the
+ # dates already match:
+ set_revised_at(max_date) unless revised_at && revised_at.to_date == max_date
+ end
+ end
+
+ # Just to catch any cases that haven't gone through set_revised_at
+ def ensure_revised_at
+ self.set_revised_at if self.revised_at.nil?
+ end
+
+ def published_at
+ self.first_chapter.published_at
+ end
+
+ # ensure published_at date is correct: reset its value for non-backdated works
+ # "chapter" arg should be the unsaved session instance of the work's first chapter
+ def reset_published_at(chapter)
+ if !self.backdate
+ if self.backdate_changed? # work was backdated but now it's not
+ # so reset its date to our best guess at its original pub date:
+ chapter.published_at = self.created_at.to_date
+ else # pub date may have changed without user's explicitly setting backdate option
+ # so reset it to the previous value:
+ chapter.published_at = chapter.published_at_was || Date.current
+ end
+ end
+ end
+
+ def default_date
+ backdate = first_chapter.try(:published_at) if self.backdate
+ backdate || Date.current
+ end
+
+ ########################################################################
+ # SERIES
+ ########################################################################
+
+ # Virtual attribute for series
+ def series_attributes=(attributes)
+ if !attributes[:id].blank?
+ old_series = Series.find(attributes[:id])
+ if old_series.pseuds.none? { |pseud| pseud.user == User.current_user }
+ errors.add(:base, ts("You can't add a work to that series."))
+ return
+ end
+ unless old_series.blank? || self.series.include?(old_series)
+ self.serial_works.build(series: old_series)
+ end
+ elsif !attributes[:title].blank?
+ new_series = Series.new
+ new_series.title = attributes[:title]
+ new_series.restricted = self.restricted
+ (User.current_user.pseuds & self.pseuds_after_saving).each do |pseud|
+ # Only add the current user's pseuds now -- the after_create callback
+ # on the serial work will do the rest.
+ new_series.creatorships.build(pseud: pseud)
+ end
+ self.serial_works.build(series: new_series)
+ end
+ end
+
+ # Make sure the series restriction level is in line with its works
+ def adjust_series_restriction
+ unless self.series.blank?
+ self.series.each {|s| s.adjust_restricted }
+ end
+ end
+
+ ########################################################################
+ # CHAPTERS
+ ########################################################################
+
+ # Save chapter data when the work is updated
+ def save_chapters
+ !self.chapters.first.save(validate: false)
+ end
+
+ # If the work is posted, the first chapter should be posted too
+ def post_first_chapter
+ chapter_one = self.first_chapter
+
+ return unless self.saved_change_to_posted? && self.posted
+ return if chapter_one&.posted
+
+ chapter_one.published_at = Date.current unless self.backdate
+ chapter_one.posted = true
+ chapter_one.save
+ end
+
+ # Virtual attribute for first chapter
+ def chapter_attributes=(attributes)
+ self.new_record? ? self.chapters.build(attributes) : self.chapters.first.attributes = attributes
+ self.chapters.first.posted = self.posted
+ end
+
+ # Virtual attribute for # of chapters
+ def wip_length
+ self.expected_number_of_chapters.nil? ? "?" : self.expected_number_of_chapters
+ end
+
+ def wip_length=(number)
+ number = number.to_i
+ self.expected_number_of_chapters = (number != 0 && number >= self.chapters.length) ? number : nil
+ end
+
+ # Change the positions of the chapters in the work
+ def reorder_list(positions)
+ SortableList.new(chapters_in_order(include_drafts: true)).reorder_list(positions)
+ # We're caching the chapter positions in the comment blurbs
+ # so we need to expire them
+ async(:poke_cached_comments)
+ end
+
+ def poke_cached_comments
+ self.comments.each { |c| c.touch }
+ end
+
+ def poke_cached_creator_comments
+ self.creator_comments.each { |c| c.touch }
+ end
+
+ # Get the total number of chapters for a work
+ def number_of_chapters
+ Rails.cache.fetch(key_for_chapter_total_counting(self)) do
+ self.chapters.count
+ end
+ end
+
+ # Get the total number of posted chapters for a work
+ # Issue 1316: total number needs to reflect the actual number of chapters posted
+ # rather than the total number of chapters indicated by user
+ def number_of_posted_chapters
+ Rails.cache.fetch(key_for_chapter_posted_counting(self)) do
+ self.chapters.posted.count
+ end
+ end
+
+ def chapters_in_order(include_drafts: false, include_content: true)
+ # in order
+ chapters = self.chapters.order('position ASC')
+ # only posted chapters unless specified
+ chapters = chapters.where(posted: true) unless include_drafts
+ # when doing navigation pass false as contents are not needed
+ chapters = chapters.select('published_at, id, work_id, title, position, posted') unless include_content
+ chapters
+ end
+
+ # Gets the current first chapter
+ def first_chapter
+ if self.new_record?
+ self.chapters.first || self.chapters.build
+ else
+ self.chapters.order('position ASC').first
+ end
+ end
+
+ # Gets the current last chapter
+ def last_chapter
+ self.chapters.order('position DESC').first
+ end
+
+ # Gets the current last posted chapter
+ def last_posted_chapter
+ self.chapters.posted.order('position DESC').first
+ end
+
+ # Returns true if a work has or will have more than one chapter
+ def chaptered?
+ self.expected_number_of_chapters != 1
+ end
+
+ # Returns true if a work has more than one chapter
+ def multipart?
+ self.number_of_chapters > 1
+ end
+
+ after_save :update_complete_status
+ # Note: this can mark a work complete but it can also mark a complete work
+ # as incomplete if its status has changed
+ def update_complete_status
+ # self.chapters.posted.count ( not self.number_of_posted_chapter , here be dragons )
+ self.complete = self.chapters.posted.count == expected_number_of_chapters
+ if self.will_save_change_to_attribute?(:complete)
+ Work.where(id: id).update_all(["complete = ?", complete])
+ end
+ end
+
+ # Returns true if a work is not yet complete
+ def is_wip
+ self.expected_number_of_chapters.nil? || self.expected_number_of_chapters != self.number_of_posted_chapters
+ end
+
+ # Returns true if a work is complete
+ def is_complete
+ return !self.is_wip
+ end
+
+ # Set the value of word_count to reflect the length of the chapter content
+ # Called before_save
+ def set_word_count(preview = false)
+ if self.new_record? || preview
+ self.word_count = 0
+ chapters.each do |chapter|
+ self.word_count += chapter.set_word_count
+ end
+ else
+ # AO3-3498: For posted works, the word count is visible to people other than the creator and
+ # should only include posted chapters. For drafts, we can count everything.
+ self.word_count = if self.posted
+ Chapter.select("SUM(word_count) AS work_word_count").where(work_id: self.id, posted: true).first.work_word_count
+ else
+ Chapter.select("SUM(word_count) AS work_word_count").where(work_id: self.id).first.work_word_count
+ end
+ end
+ end
+
+ #######################################################################
+ # TAGGING
+ # Works are taggable objects.
+ #######################################################################
+
+ # When the filters on a work change, we need to perform some extra checks.
+ def self.reindex_for_filter_changes(ids, filter_taggings, queue)
+ # The crossover/OTP status of a work can change without actually changing
+ # the filters (e.g. if you have a work tagged with canonical fandom A and
+ # unfilterable fandom B, synning B to A won't change the work's filters,
+ # but the work will immediately stop qualifying as a crossover). So we want
+ # to reindex all works whose filters were checked, not just the works that
+ # had their filters changed.
+ IndexQueue.enqueue_ids(Work, ids, queue)
+
+ # Only works are included in the filter count, so if a work's
+ # filter-taggings change, the FilterCount probably needs updating.
+ FilterCount.enqueue_filters(filter_taggings.map(&:filter_id))
+
+ # From here, we only want to update works whose filter_taggings have
+ # actually changed.
+ changed_ids = filter_taggings.map(&:filterable_id)
+ return unless changed_ids.present?
+
+ # Reindex any series associated with works whose filters have changed.
+ series_ids = SerialWork.where(work_id: changed_ids).pluck(:series_id)
+ IndexQueue.enqueue_ids(Series, series_ids, queue)
+
+ # Reindex any pseuds associated with works whose filters have changed.
+ pseud_ids = Creatorship.where(creation_id: changed_ids,
+ creation_type: "Work",
+ approved: true).pluck(:pseud_id)
+ IndexQueue.enqueue_ids(Pseud, pseud_ids, queue)
+ end
+
+ # FILTERING CALLBACKS
+ after_save :adjust_filter_counts
+
+ # We need to do a recount for our filters if:
+ # - the work is brand new
+ # - the work is posted from a draft
+ # - the work is hidden or unhidden by an admin
+ # - the work's restricted status has changed
+ # Note that because the two filter counts both include unrevealed works, we
+ # don't need to check whether in_unrevealed_collection has changed -- it
+ # won't change the counts either way.
+ # (Modelled on Work.should_reindex_pseuds?)
+ def should_reset_filters?
+ pertinent_attributes = %w(id posted restricted hidden_by_admin)
+ (saved_changes.keys & pertinent_attributes).present?
+ end
+
+ # Recalculates filter counts on all the work's filters
+ def adjust_filter_counts
+ FilterCount.enqueue_filters(filters.reload) if should_reset_filters?
+ end
+
+ ################################################################################
+ # COMMENTING & BOOKMARKS
+ # We don't actually have comments on works currently but on chapters.
+ # Comment support -- work acts as a commentable object even though really we
+ # override to consolidate the comments on all the chapters.
+ ################################################################################
+
+ # Gets all comments for all chapters in the work
+ def find_all_comments
+ Comment.where(
+ parent_type: 'Chapter',
+ parent_id: self.chapters.pluck(:id)
+ )
+ end
+
+ # Returns number of comments
+ # Hidden and deleted comments are referenced in the view because of
+ # the threading system - we don't necessarily need to
+ # hide their existence from other users
+ def count_all_comments
+ find_all_comments.count
+ end
+
+ # Count the number of comment threads visible to the user (i.e. excluding
+ # threads that have been marked as spam). Used on the work stats page.
+ def comment_thread_count
+ comments.where(approved: true).count
+ end
+
+ # returns the top-level comments for all chapters in the work
+ def comments
+ Comment.where(
+ commentable_type: 'Chapter',
+ commentable_id: self.chapters.pluck(:id)
+ )
+ end
+
+ # All comments left by the creators of this work
+ def creator_comments
+ pseud_ids = Pseud.where(user_id: self.pseuds.pluck(:user_id)).pluck(:id)
+ find_all_comments.where(pseud_id: pseud_ids)
+ end
+
+ def guest_kudos_count
+ Rails.cache.fetch "works/#{id}/guest_kudos_count-v2" do
+ kudos.by_guest.count
+ end
+ end
+
+ def all_kudos_count
+ Rails.cache.fetch "works/#{id}/kudos_count-v2" do
+ kudos.count
+ end
+ end
+
+ def update_stat_counter
+ counter = self.stat_counter || self.create_stat_counter
+ counter.update(
+ kudos_count: self.kudos.count,
+ comments_count: self.count_visible_comments_uncached,
+ bookmarks_count: self.bookmarks.where(private: false).count
+ )
+ end
+
+ ########################################################################
+ # RELATED WORKS
+ # These are for inspirations/remixes/etc
+ ########################################################################
+
+ def parents_after_saving
+ parent_work_relationships.reject(&:marked_for_destruction?)
+ end
+
+ def touch_related_works
+ return unless saved_change_to_in_unrevealed_collection?
+
+ # Make sure download URLs of child and parent works expire to preserve anonymity.
+ children.touch_all
+ parents_after_saving.each { |rw| rw.parent.touch }
+ end
+
+ #################################################################################
+ #
+ # In this section we define various named scopes that can be chained together
+ # to do finds in the database
+ #
+ #################################################################################
+
+ public
+
+ scope :id_only, -> { select("works.id") }
+
+ scope :ordered_by_title_desc, -> { order("title_to_sort_on DESC") }
+ scope :ordered_by_title_asc, -> { order("title_to_sort_on ASC") }
+ scope :ordered_by_word_count_desc, -> { order("word_count DESC") }
+ scope :ordered_by_word_count_asc, -> { order("word_count ASC") }
+ scope :ordered_by_hit_count_desc, -> { order("hit_count DESC") }
+ scope :ordered_by_hit_count_asc, -> { order("hit_count ASC") }
+ scope :ordered_by_date_desc, -> { order("revised_at DESC") }
+ scope :ordered_by_date_asc, -> { order("revised_at ASC") }
+
+ scope :recent, lambda { |*args| where("revised_at > ?", (args.first || 4.weeks.ago.to_date)) }
+ scope :within_date_range, lambda { |*args| where("revised_at BETWEEN ? AND ?", (args.first || 4.weeks.ago), (args.last || Time.now)) }
+ scope :posted, -> { where(posted: true) }
+ scope :unposted, -> { where(posted: false) }
+ scope :not_spam, -> { where(spam: false) }
+ scope :restricted , -> { where(restricted: true) }
+ scope :unrestricted, -> { where(restricted: false) }
+ scope :hidden, -> { where(hidden_by_admin: true) }
+ scope :unhidden, -> { where(hidden_by_admin: false) }
+ scope :visible_to_all, -> { posted.unrestricted.unhidden }
+ scope :visible_to_registered_user, -> { posted.unhidden }
+ scope :visible_to_admin, -> { posted }
+ scope :visible_to_owner, -> { posted }
+ scope :all_with_tags, -> { includes(:tags) }
+
+ scope :giftworks_for_recipient_name, lambda { |name| select("DISTINCT works.*").joins(:gifts).where("recipient_name = ?", name).where("gifts.rejected = FALSE") }
+
+ scope :non_anon, -> { where(in_anon_collection: false) }
+ scope :unrevealed, -> { where(in_unrevealed_collection: true) }
+ scope :revealed, -> { where(in_unrevealed_collection: false) }
+ scope :latest, -> { visible_to_all.
+ revealed.
+ order("revised_at DESC").
+ limit(ArchiveConfig.ITEMS_PER_PAGE) }
+
+ # a complicated dynamic scope here:
+ # if the user is an Admin, we use the "visible_to_admin" scope
+ # if the user is not a logged-in User, we use the "visible_to_all" scope
+ # otherwise, we use a join to get userids and then get all posted works that are either unhidden OR belong to this user.
+ # Note: in that last case we have to use select("DISTINCT works.") because of cases where the same user appears twice
+ # on a work.
+ def self.visible_to_user(user=User.current_user)
+ case user.class.to_s
+ when 'Admin'
+ visible_to_admin
+ when 'User'
+ select("DISTINCT works.*").
+ posted.
+ joins({pseuds: :user}).
+ where("works.hidden_by_admin = false OR users.id = ?", user.id)
+ else
+ visible_to_all
+ end
+ end
+
+ # Use the current user to determine what works are visible
+ def self.visible(user=User.current_user)
+ visible_to_user(user)
+ end
+
+ scope :owned_by, lambda {|user| select("DISTINCT works.*").joins({pseuds: :user}).where('users.id = ?', user.id)}
+
+ def self.in_series(series)
+ joins(:series).
+ where("series.id = ?", series.id)
+ end
+
+ scope :with_columns_for_blurb, lambda {
+ select(:id, :created_at, :updated_at, :expected_number_of_chapters,
+ :posted, :language_id, :restricted, :title, :summary, :word_count,
+ :hidden_by_admin, :revised_at, :complete, :in_anon_collection,
+ :in_unrevealed_collection, :summary_sanitizer_version)
+ }
+
+ scope :with_includes_for_blurb, lambda {
+ includes(:pseuds, :approved_collections, :stat_counter)
+ }
+
+ scope :for_blurb, -> { with_columns_for_blurb.with_includes_for_blurb }
+
+ ########################################################################
+ # SORTING
+ ########################################################################
+
+ SORTED_AUTHOR_REGEX = %r{^[\+\-=_\?!'"\.\/]}
+
+ def authors_to_sort_on
+ if self.anonymous?
+ "Anonymous"
+ else
+ self.pseuds.sort.map(&:name).join(", ").downcase.gsub(SORTED_AUTHOR_REGEX, '')
+ end
+ end
+
+ def sorted_title
+ sorted_title = self.title.downcase.gsub(/^["'\.\/]/, '')
+ sorted_title = sorted_title.gsub(/^(an?) (.*)/, '\2, \1')
+ sorted_title = sorted_title.gsub(/^the (.*)/, '\1, the')
+ sorted_title = sorted_title.rjust(5, "0") if sorted_title.match(/^\d/)
+ sorted_title
+ end
+
+ # sort works by title
+ def <=>(another_work)
+ self.title_to_sort_on <=> another_work.title_to_sort_on
+ end
+
+ ########################################################################
+ # SPAM CHECKING
+ ########################################################################
+
+ def akismet_attributes
+ content = chapters_in_order(include_drafts: true).map(&:content).join
+ user = users.first
+ {
+ comment_type: "fanwork-post",
+ key: ArchiveConfig.AKISMET_KEY,
+ blog: ArchiveConfig.AKISMET_NAME,
+ user_ip: ip_address,
+ user_role: "user",
+ comment_date_gmt: created_at.to_time.iso8601,
+ blog_lang: language.short,
+ comment_author: user.login,
+ comment_author_email: user.email,
+ comment_content: content
+ }
+ end
+
+ def spam_checked?
+ spam_checked_at.present?
+ end
+
+ def check_for_spam
+ return unless %w(staging production).include?(Rails.env)
+ self.spam = Akismetor.spam?(akismet_attributes)
+ self.spam_checked_at = Time.now
+ save
+ end
+
+ def hide_spam
+ return unless spam?
+ admin_settings = AdminSetting.current
+ if admin_settings.hide_spam?
+ return if self.hidden_by_admin
+
+ self.hidden_by_admin = true
+ notify_of_hiding_for_spam
+ end
+ end
+
+ def moderate_spam
+ ModeratedWork.register(self) if spam?
+ end
+
+ def mark_as_spam!
+ update_attribute(:spam, true)
+ ModeratedWork.mark_reviewed(self)
+ # don't submit spam reports unless in production mode
+ Rails.env.production? && Akismetor.submit_spam(akismet_attributes)
+ end
+
+ def mark_as_ham!
+ update(spam: false, hidden_by_admin: false)
+ ModeratedWork.mark_approved(self)
+ # don't submit ham reports unless in production mode
+ Rails.env.production? && Akismetor.submit_ham(akismet_attributes)
+ end
+
+ def notify_of_hiding
+ return unless hidden_by_admin? && saved_change_to_hidden_by_admin?
+ return if notified_of_hiding_for_spam
+
+ users.each do |user|
+ I18n.with_locale(user.preference.locale_for_mails) do
+ UserMailer.admin_hidden_work_notification([id], user.id).deliver_after_commit
+ end
+ end
+ end
+
+ def notify_of_hiding_for_spam
+ users.each do |user|
+ I18n.with_locale(user.preference.locale_for_mails) do
+ UserMailer.admin_spam_work_notification(id, user.id).deliver_after_commit
+ end
+ end
+ self.notified_of_hiding_for_spam = true
+ end
+
+ #############################################################################
+ #
+ # SEARCH INDEX
+ #
+ #############################################################################
+
+ def document_json
+ WorkIndexer.new({}).document(self)
+ end
+
+ def bookmarkable_json
+ as_json(
+ root: false,
+ only: [
+ :title, :summary, :hidden_by_admin, :restricted, :posted,
+ :created_at, :revised_at, :word_count, :complete
+ ],
+ methods: [
+ :tag, :filter_ids, :rating_ids, :archive_warning_ids, :category_ids,
+ :fandom_ids, :character_ids, :relationship_ids, :freeform_ids,
+ :creators, :collection_ids, :work_types
+ ]
+ ).merge(
+ language_id: language&.short,
+ anonymous: anonymous?,
+ unrevealed: unrevealed?,
+ pseud_ids: anonymous? || unrevealed? ? nil : pseud_ids,
+ user_ids: anonymous? || unrevealed? ? nil : user_ids,
+ bookmarkable_type: 'Work',
+ bookmarkable_join: { name: "bookmarkable" }
+ )
+ end
+
+ def collection_ids
+ approved_collections.pluck(:id, :parent_id).flatten.uniq.compact
+ end
+
+ delegate :comments_count, :kudos_count, :bookmarks_count,
+ to: :stat_counter, allow_nil: true
+
+ def hits
+ stat_counter&.hit_count
+ end
+
+ def creators
+ if anonymous?
+ ["Anonymous"]
+ else
+ pseuds.map(&:byline) + external_author_names.pluck(:name)
+ end
+ end
+
+ # A work with multiple fandoms which are not related
+ # to one another can be considered a crossover
+ def crossover
+ # Short-circuit the check if there's only one fandom tag:
+ return false if fandoms.size == 1
+
+ # Replace fandoms with their mergers if possible,
+ # as synonyms should have no meta tags themselves
+ all_without_syns = fandoms.map { |f| f.merger || f }.uniq
+
+ # For each fandom, find the set of all meta tags for that fandom (including
+ # the fandom itself).
+ meta_tag_groups = all_without_syns.map do |f|
+ # TODO: This is more complicated than it has to be. Once the
+ # meta_taggings table is fixed so that the inherited meta-tags are
+ # correctly calculated, this can be simplified.
+ boundary = [f] + f.meta_tags
+ all_meta_tags = []
+
+ until boundary.empty?
+ all_meta_tags.concat(boundary)
+ boundary = boundary.flat_map(&:meta_tags).uniq - all_meta_tags
+ end
+
+ all_meta_tags.uniq
+ end
+
+ # Two fandoms are "related" if they share at least one meta tag. A work is
+ # considered a crossover if there is no single fandom on the work that all
+ # the other fandoms on the work are "related" to.
+ meta_tag_groups.none? do |meta_tags1|
+ meta_tag_groups.all? do |meta_tags2|
+ (meta_tags1 & meta_tags2).any?
+ end
+ end
+ end
+
+ # Does this work have only one relationship tag?
+ # (not counting synonyms)
+ def otp
+ return true if relationships.size == 1
+
+ all_without_syns = relationships.map { |r| r.merger_id || r.id }
+ .uniq
+ all_without_syns.count == 1
+ end
+
+ # Quick and dirty categorization of the most obvious stuff
+ # To be replaced by actual categories
+ def work_types
+ types = []
+ video_ids = [44011] # Video
+ audio_ids = [70308, 1098169] # Podfic, Audio Content
+ art_ids = [7844, 125758, 3863] # Fanart, Arts
+ types << "Video" if (filter_ids & video_ids).present?
+ types << "Audio" if (filter_ids & audio_ids).present?
+ types << "Art" if (filter_ids & art_ids).present?
+ # Very arbitrary cut off here, but wanted to make sure we
+ # got fic + art/podfic/video tagged as text as well
+ if types.empty? || (word_count && word_count > 200)
+ types << "Text"
+ end
+ types
+ end
+
+ # To be replaced by actual category
+ # Can't use the 'Meta' tag since that has too many different uses
+ def nonfiction
+ nonfiction_tags = [125773, 66586, 123921, 747397] # Essays, Nonfiction, Reviews, Reference
+ (filter_ids & nonfiction_tags).present?
+ end
+
+ # Determines if this work allows invitations to collections,
+ # meaning that at least one of the creators has opted-in.
+ def allow_collection_invitation?
+ users.any? { |user| user.preference.allow_collection_invitation }
+ end
+
+ private
+
+ def challenge_bypass(gift)
+ self.challenge_assignments.map(&:requesting_pseud).include?(gift.pseud) ||
+ self.challenge_claims
+ .reject { |c| c.request_prompt.anonymous? }
+ .map(&:requesting_pseud)
+ .include?(gift.pseud)
+ end
+end
diff --git a/app/models/work_original_creator.rb b/app/models/work_original_creator.rb
new file mode 100755
index 0000000..d5cd74d
--- /dev/null
+++ b/app/models/work_original_creator.rb
@@ -0,0 +1,23 @@
+class WorkOriginalCreator < ApplicationRecord
+ belongs_to :work
+ belongs_to :user
+
+ # Get the id and username (if still available) for the associated user.
+ def display
+ user ? "#{user_id} (#{user.login})" : user_id.to_s
+ end
+
+ ####################
+ # DELAYED JOBS
+ ####################
+
+ include AsyncWithResque
+ @queue = :utilities
+
+ # Remove any original creator records that have been around for longer
+ # than the TTL in the archive configuration.
+ def self.cleanup
+ WorkOriginalCreator
+ .delete_by("updated_at <= ?", ArchiveConfig.ORIGINAL_CREATOR_TTL_HOURS.hours.ago)
+ end
+end
diff --git a/app/models/work_skin.rb b/app/models/work_skin.rb
new file mode 100755
index 0000000..b44208e
--- /dev/null
+++ b/app/models/work_skin.rb
@@ -0,0 +1,52 @@
+class WorkSkin < Skin
+ include SkinCacheHelper
+
+ has_many :works
+ after_save :skin_invalidate_cache
+
+ # override parent's clean_css to append a prefix
+ def clean_css
+ return if self.css.blank?
+ check = lambda {|ruleset, property, value|
+ # If it starts with --, assume the user was trying to define a custom property.
+ if property.match(/\A--/)
+ errors.add(:base, :work_skin_custom_properties)
+ return false
+ end
+ if value.match(/\bvar\b/i)
+ errors.add(:base, :work_skin_var)
+ return false
+ end
+ if property == "position" && value == "fixed"
+ # Do not internationalize the , used as a join in this error -- it's reflective of the comma used in the list of selectors, which does not change based on locale.
+ errors.add(:base, :work_skin_banned_value_for_property, property: property, selectors: ruleset.selectors.join(", "), value: value)
+ return false
+ end
+ return true
+ }
+ options = {prefix: "#workskin", caller_check: check}
+ self.css = clean_css_code(self.css, options)
+ end
+
+ def self.model_name
+ # re-use the model_name of the superclass (Skin)
+ self.superclass.model_name
+ end
+
+ def self.basic_formatting
+ Skin.find_by(title: "Basic Formatting", official: true) || WorkSkin.import_basic_formatting
+ end
+
+ def self.import_basic_formatting
+ css = File.read(File.join(Rails.public_path, "/stylesheets/work_skins/basic_formatting.css"))
+ skin = WorkSkin.find_or_create_by(title: "Basic Formatting", css: css, role: "user", public: true, official: true)
+ skin.icon.attach(
+ io: File.open(File.join(Rails.public_path, "/images/skins/previews/basic_formatting.png"), "rb"),
+ filename: "basic_formatting.png",
+ content_type: "image/png"
+ )
+ skin.official = true
+ skin.save!
+ skin
+ end
+end
diff --git a/app/models/wrangling_assignment.rb b/app/models/wrangling_assignment.rb
new file mode 100755
index 0000000..9b488a5
--- /dev/null
+++ b/app/models/wrangling_assignment.rb
@@ -0,0 +1,9 @@
+class WranglingAssignment < ApplicationRecord
+ belongs_to :user
+ belongs_to :fandom
+
+ validates_uniqueness_of :user_id, scope: :fandom_id
+ validates_presence_of :user_id
+ validates_presence_of :fandom_id
+
+end
diff --git a/app/models/wrangling_guideline.rb b/app/models/wrangling_guideline.rb
new file mode 100755
index 0000000..1d70a2e
--- /dev/null
+++ b/app/models/wrangling_guideline.rb
@@ -0,0 +1,11 @@
+class WranglingGuideline < ApplicationRecord
+ acts_as_list
+
+ validates_presence_of :content, :title
+ validates_length_of :content, maximum: ArchiveConfig.CONTENT_MAX,
+ too_long: ts('cannot be more than %{max} characters long.', max: ArchiveConfig.CONTENT_MAX)
+
+ def self.reorder_list(positions)
+ SortableList.new(self.all.order(position: :asc)).reorder_list(positions)
+ end
+end
diff --git a/app/policies/admin_activity_policy.rb b/app/policies/admin_activity_policy.rb
new file mode 100755
index 0000000..f6bf713
--- /dev/null
+++ b/app/policies/admin_activity_policy.rb
@@ -0,0 +1,9 @@
+class AdminActivityPolicy < ApplicationPolicy
+ PERMITTED_ROLES = %w[policy_and_abuse superadmin].freeze
+
+ def index?
+ user_has_roles?(PERMITTED_ROLES)
+ end
+
+ alias show? index?
+end
diff --git a/app/policies/admin_banner_policy.rb b/app/policies/admin_banner_policy.rb
new file mode 100755
index 0000000..7f1ccff
--- /dev/null
+++ b/app/policies/admin_banner_policy.rb
@@ -0,0 +1,16 @@
+class AdminBannerPolicy < ApplicationPolicy
+ ACCESS_AND_EDIT_ROLES = %w[superadmin board board_assistants_team communications development_and_membership support].freeze
+ CREATE_AND_DESTROY_ROLES = %w[superadmin board board_assistants_team communications support].freeze
+
+ def index?
+ user_has_roles?(ACCESS_AND_EDIT_ROLES)
+ end
+
+ def create?
+ user_has_roles?(CREATE_AND_DESTROY_ROLES)
+ end
+
+ alias show? index?
+ alias update? index?
+ alias destroy? create?
+end
diff --git a/app/policies/admin_blacklisted_email_policy.rb b/app/policies/admin_blacklisted_email_policy.rb
new file mode 100755
index 0000000..b966b55
--- /dev/null
+++ b/app/policies/admin_blacklisted_email_policy.rb
@@ -0,0 +1,8 @@
+class AdminBlacklistedEmailPolicy < ApplicationPolicy
+ def index?
+ user_has_roles?(%w[superadmin policy_and_abuse support])
+ end
+
+ alias create? index?
+ alias destroy? index?
+end
diff --git a/app/policies/admin_post_policy.rb b/app/policies/admin_post_policy.rb
new file mode 100755
index 0000000..957e1c4
--- /dev/null
+++ b/app/policies/admin_post_policy.rb
@@ -0,0 +1,13 @@
+class AdminPostPolicy < ApplicationPolicy
+ POSTING_ROLES = %w[superadmin board board_assistants_team communications support translation].freeze
+
+ def can_post?
+ user_has_roles?(POSTING_ROLES)
+ end
+
+ alias new? can_post?
+ alias edit? can_post?
+ alias create? can_post?
+ alias update? can_post?
+ alias destroy? can_post?
+end
diff --git a/app/policies/admin_setting_policy.rb b/app/policies/admin_setting_policy.rb
new file mode 100755
index 0000000..38981c8
--- /dev/null
+++ b/app/policies/admin_setting_policy.rb
@@ -0,0 +1,47 @@
+class AdminSettingPolicy < ApplicationPolicy
+ # Defines the roles that allow admins to view all settings.
+ SETTINGS_ROLES = %w[policy_and_abuse superadmin support tag_wrangling].freeze
+
+ # Define which roles can update which settings.
+ ALLOWED_SETTINGS_BY_ROLES = {
+ "policy_and_abuse" => %i[
+ hide_spam
+ invite_from_queue_enabled
+ invite_from_queue_number
+ request_invite_enabled
+ account_age_threshold_for_comment_spam_check
+ ],
+ "superadmin" => %i[
+ account_creation_enabled
+ cache_expiration
+ creation_requires_invite
+ days_to_purge_unactivated
+ disable_support_form
+ disabled_support_form_text
+ downloads_enabled
+ enable_test_caching
+ hide_spam
+ guest_comments_off
+ account_age_threshold_for_comment_spam_check
+ invite_from_queue_enabled
+ invite_from_queue_frequency
+ invite_from_queue_number
+ request_invite_enabled
+ suspend_filter_counts
+ tag_wrangling_off
+ ],
+ "support" => %i[disable_support_form disabled_support_form_text],
+ "tag_wrangling" => %i[tag_wrangling_off]
+ }.freeze
+
+ def can_view_settings?
+ user_has_roles?(SETTINGS_ROLES)
+ end
+
+ def permitted_attributes
+ ALLOWED_SETTINGS_BY_ROLES.values_at(*user.roles).compact.flatten
+ end
+
+ alias index? can_view_settings?
+ alias update? can_view_settings?
+end
diff --git a/app/policies/api_key_policy.rb b/app/policies/api_key_policy.rb
new file mode 100755
index 0000000..52640c3
--- /dev/null
+++ b/app/policies/api_key_policy.rb
@@ -0,0 +1,14 @@
+class ApiKeyPolicy < ApplicationPolicy
+ PERMITTED_ROLES = %w[superadmin].freeze
+
+ def index?
+ user_has_roles?(PERMITTED_ROLES)
+ end
+
+ alias show? index?
+ alias new? index?
+ alias edit? index?
+ alias create? index?
+ alias update? index?
+ alias destroy? index?
+end
diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb
new file mode 100755
index 0000000..a400d17
--- /dev/null
+++ b/app/policies/application_policy.rb
@@ -0,0 +1,59 @@
+class ApplicationPolicy
+ attr_reader :user, :record
+
+ def initialize(user, record)
+ @user = user
+ @record = record
+ end
+
+ def index?
+ false
+ end
+
+ def show?
+ false
+ end
+
+ def create?
+ false
+ end
+
+ def new?
+ create?
+ end
+
+ def update?
+ false
+ end
+
+ def edit?
+ update?
+ end
+
+ def destroy?
+ false
+ end
+
+ def confirm_delete?
+ destroy?
+ end
+
+ # Explicitly check that the user is an admin because regular users can have
+ # roles (e.g. archivist) as well, but we don't handle those with pundit.
+ def user_has_roles?(roles)
+ user&.is_a?(Admin) && user.respond_to?(:roles) && (user.roles & roles).present?
+ end
+
+ class Scope
+ attr_reader :user, :scope
+
+ def initialize(user, scope)
+ @user = user
+ @scope = scope
+ end
+
+ def resolve
+ scope.all
+ end
+ end
+end
diff --git a/app/policies/archive_faq_policy.rb b/app/policies/archive_faq_policy.rb
new file mode 100755
index 0000000..87c8a7e
--- /dev/null
+++ b/app/policies/archive_faq_policy.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+class ArchiveFaqPolicy < ApplicationPolicy
+ TRANSLATION_ACCESS_ROLES = %w[superadmin docs support translation].freeze
+ # a subset of TRANSLATION_ACCESS_ROLES
+ FULL_ACCESS_ROLES = %w[superadmin docs support].freeze
+
+ def translation_access?
+ user_has_roles?(TRANSLATION_ACCESS_ROLES)
+ end
+
+ def full_access?
+ user_has_roles?(FULL_ACCESS_ROLES)
+ end
+
+ alias edit? translation_access?
+ alias update? translation_access?
+ alias new? full_access?
+ alias create? full_access?
+ alias manage? full_access?
+ alias update_positions? full_access?
+ alias confirm_delete? full_access?
+ alias destroy? full_access?
+end
diff --git a/app/policies/block_policy.rb b/app/policies/block_policy.rb
new file mode 100755
index 0000000..e0946df
--- /dev/null
+++ b/app/policies/block_policy.rb
@@ -0,0 +1,5 @@
+class BlockPolicy < ApplicationPolicy
+ def index?
+ user_has_roles?(%w[policy_and_abuse support superadmin])
+ end
+end
diff --git a/app/policies/bookmark_policy.rb b/app/policies/bookmark_policy.rb
new file mode 100755
index 0000000..93894e5
--- /dev/null
+++ b/app/policies/bookmark_policy.rb
@@ -0,0 +1,2 @@
+class BookmarkPolicy < UserCreationPolicy
+end
diff --git a/app/policies/comment_policy.rb b/app/policies/comment_policy.rb
new file mode 100755
index 0000000..857219c
--- /dev/null
+++ b/app/policies/comment_policy.rb
@@ -0,0 +1,68 @@
+class CommentPolicy < ApplicationPolicy
+ DESTROY_COMMENT_ROLES = %w[superadmin board legal policy_and_abuse support].freeze
+ DESTROY_ADMIN_POST_COMMENT_ROLES = %w[superadmin board board_assistants_team communications elections legal policy_and_abuse support].freeze
+ FREEZE_TAG_COMMENT_ROLES = %w[superadmin tag_wrangling].freeze
+ FREEZE_WORK_COMMENT_ROLES = %w[superadmin policy_and_abuse].freeze
+ HIDE_TAG_COMMENT_ROLES = %w[superadmin legal tag_wrangling].freeze
+ HIDE_WORK_COMMENT_ROLES = %w[superadmin legal policy_and_abuse].freeze
+ SPAM_ADMIN_POST_COMMENT_ROLES = %w[superadmin board board_assistants_team communications elections policy_and_abuse support].freeze
+ SPAM_COMMENT_ROLES = %w[superadmin board policy_and_abuse support].freeze
+
+ def can_destroy_comment?
+ case record.ultimate_parent
+ when AdminPost
+ user_has_roles?(DESTROY_ADMIN_POST_COMMENT_ROLES)
+ else
+ user_has_roles?(DESTROY_COMMENT_ROLES)
+ end
+ end
+
+ def can_freeze_comment?
+ case record.ultimate_parent
+ when AdminPost
+ user&.is_a?(Admin)
+ when Tag
+ user_has_roles?(FREEZE_TAG_COMMENT_ROLES)
+ when Work
+ user_has_roles?(FREEZE_WORK_COMMENT_ROLES)
+ end
+ end
+
+ def can_hide_comment?
+ case record.ultimate_parent
+ when AdminPost
+ user&.is_a?(Admin)
+ when Tag
+ user_has_roles?(HIDE_TAG_COMMENT_ROLES)
+ when Work
+ user_has_roles?(HIDE_WORK_COMMENT_ROLES)
+ end
+ end
+
+ def can_mark_comment_spam?
+ case record.ultimate_parent
+ when AdminPost
+ user_has_roles?(SPAM_ADMIN_POST_COMMENT_ROLES)
+ else
+ user_has_roles?(SPAM_COMMENT_ROLES)
+ end
+ end
+
+ def can_review_comment?
+ record.ultimate_parent.is_a?(AdminPost) && user&.is_a?(Admin)
+ end
+
+ def can_review_all?
+ record.is_a?(AdminPost) && user&.is_a?(Admin)
+ end
+
+ alias destroy? can_destroy_comment?
+ alias approve? can_mark_comment_spam?
+ alias reject? can_mark_comment_spam?
+ alias review? can_review_comment?
+ alias review_all? can_review_all?
+
+ def show_email?
+ user_has_roles?(%w[legal policy_and_abuse support superadmin])
+ end
+end
diff --git a/app/policies/external_work_policy.rb b/app/policies/external_work_policy.rb
new file mode 100755
index 0000000..0109229
--- /dev/null
+++ b/app/policies/external_work_policy.rb
@@ -0,0 +1,5 @@
+class ExternalWorkPolicy < UserCreationPolicy
+ def update?
+ user_has_roles?(%w[superadmin policy_and_abuse])
+ end
+end
diff --git a/app/policies/inbox_comment_policy.rb b/app/policies/inbox_comment_policy.rb
new file mode 100755
index 0000000..8ec7d75
--- /dev/null
+++ b/app/policies/inbox_comment_policy.rb
@@ -0,0 +1,7 @@
+class InboxCommentPolicy < ApplicationPolicy
+ VIEW_INBOX_ROLES = %w[superadmin policy_and_abuse].freeze
+
+ def show?
+ user_has_roles?(VIEW_INBOX_ROLES)
+ end
+end
diff --git a/app/policies/invitation_policy.rb b/app/policies/invitation_policy.rb
new file mode 100755
index 0000000..8c9c3cb
--- /dev/null
+++ b/app/policies/invitation_policy.rb
@@ -0,0 +1,7 @@
+class InvitationPolicy < ApplicationPolicy
+ EXTRA_INFO_ROLES = %w[superadmin open_doors policy_and_abuse support tag_wrangling].freeze
+
+ def access_invitee_details?
+ user_has_roles?(EXTRA_INFO_ROLES)
+ end
+end
diff --git a/app/policies/invite_request_policy.rb b/app/policies/invite_request_policy.rb
new file mode 100755
index 0000000..defbe3a
--- /dev/null
+++ b/app/policies/invite_request_policy.rb
@@ -0,0 +1,10 @@
+class InviteRequestPolicy < ApplicationPolicy
+ MANAGE_ROLES = %w[superadmin policy_and_abuse support].freeze
+
+ def can_manage?
+ user_has_roles?(MANAGE_ROLES)
+ end
+
+ alias manage? can_manage?
+ alias destroy? can_manage?
+end
diff --git a/app/policies/known_issue_policy.rb b/app/policies/known_issue_policy.rb
new file mode 100755
index 0000000..a74de07
--- /dev/null
+++ b/app/policies/known_issue_policy.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class KnownIssuePolicy < ApplicationPolicy
+ MANAGE_ROLES = %w[superadmin support].freeze
+
+ def admin_index?
+ user_has_roles?(MANAGE_ROLES)
+ end
+
+ alias destroy? admin_index?
+ alias edit? admin_index?
+ alias create? admin_index?
+ alias new? admin_index?
+ alias show? admin_index?
+ alias update? admin_index?
+end
diff --git a/app/policies/language_policy.rb b/app/policies/language_policy.rb
new file mode 100755
index 0000000..37810ec
--- /dev/null
+++ b/app/policies/language_policy.rb
@@ -0,0 +1,35 @@
+class LanguagePolicy < ApplicationPolicy
+ LANGUAGE_EDIT_ACCESS = %w[superadmin translation support policy_and_abuse].freeze
+ LANGUAGE_CREATE_ACCESS = %w[superadmin translation].freeze
+
+ def new?
+ user_has_roles?(LANGUAGE_CREATE_ACCESS)
+ end
+
+ def edit?
+ user_has_roles?(LANGUAGE_EDIT_ACCESS)
+ end
+
+ # Define which roles can update which attributes
+ ALLOWED_ATTRIBUTES_BY_ROLES = {
+ "superadmin" => %i[name short support_available abuse_support_available sortable_name],
+ "translation" => %i[name short support_available abuse_support_available sortable_name],
+ "support" => %i[name short support_available sortable_name],
+ "policy_and_abuse" => %i[abuse_support_available]
+ }.freeze
+
+ def permitted_attributes
+ ALLOWED_ATTRIBUTES_BY_ROLES.values_at(*user.roles).compact.flatten
+ end
+
+ def can_edit_abuse_fields?
+ user_has_roles?(%w[superadmin translation policy_and_abuse])
+ end
+
+ def can_edit_non_abuse_fields?
+ user_has_roles?(%w[superadmin translation support])
+ end
+
+ alias create? new?
+ alias update? edit?
+end
diff --git a/app/policies/locale_policy.rb b/app/policies/locale_policy.rb
new file mode 100755
index 0000000..2ca456c
--- /dev/null
+++ b/app/policies/locale_policy.rb
@@ -0,0 +1,12 @@
+class LocalePolicy < ApplicationPolicy
+ MANAGE_LOCALES = %w[superadmin translation].freeze
+
+ def index?
+ user_has_roles?(MANAGE_LOCALES)
+ end
+
+ alias new? index?
+ alias edit? index?
+ alias update? index?
+ alias create? index?
+end
diff --git a/app/policies/moderated_work_policy.rb b/app/policies/moderated_work_policy.rb
new file mode 100755
index 0000000..20f6a94
--- /dev/null
+++ b/app/policies/moderated_work_policy.rb
@@ -0,0 +1,9 @@
+class ModeratedWorkPolicy < ApplicationPolicy
+ MANAGE_MODERATED_WORK = %w[superadmin policy_and_abuse].freeze
+
+ def index?
+ user_has_roles?(MANAGE_MODERATED_WORK)
+ end
+
+ alias bulk_update? index?
+end
diff --git a/app/policies/mute_policy.rb b/app/policies/mute_policy.rb
new file mode 100755
index 0000000..b5c559e
--- /dev/null
+++ b/app/policies/mute_policy.rb
@@ -0,0 +1,5 @@
+class MutePolicy < ApplicationPolicy
+ def index?
+ user_has_roles?(%w[policy_and_abuse support superadmin])
+ end
+end
diff --git a/app/policies/profile_policy.rb b/app/policies/profile_policy.rb
new file mode 100755
index 0000000..dc43234
--- /dev/null
+++ b/app/policies/profile_policy.rb
@@ -0,0 +1,10 @@
+class ProfilePolicy < ApplicationPolicy
+ # Roles that allow updating a user's profile.
+ EDIT_ROLES = %w[superadmin policy_and_abuse].freeze
+
+ def can_edit_profile?
+ user_has_roles?(EDIT_ROLES)
+ end
+
+ alias update? can_edit_profile?
+end
diff --git a/app/policies/pseud_policy.rb b/app/policies/pseud_policy.rb
new file mode 100755
index 0000000..62ebd3a
--- /dev/null
+++ b/app/policies/pseud_policy.rb
@@ -0,0 +1,25 @@
+class PseudPolicy < ApplicationPolicy
+ # Roles that allow updating a pseud.
+ EDIT_ROLES = %w[superadmin policy_and_abuse].freeze
+
+ def can_edit?
+ user_has_roles?(EDIT_ROLES)
+ end
+
+ # Define which roles can update which attributes.
+ ALLOWED_ATTRIBUTES_BY_ROLES = {
+ "superadmin" => [:delete_icon, :description, :ticket_number],
+ "policy_and_abuse" => [:delete_icon, :description, :ticket_number]
+ }.freeze
+
+ def permitted_attributes
+ if user.is_a?(Admin)
+ ALLOWED_ATTRIBUTES_BY_ROLES.values_at(*user.roles).compact.flatten
+ else
+ [:name, :description, :is_default, :icon, :delete_icon, :icon_alt_text,
+ :icon_comment_text]
+ end
+ end
+
+ alias update? can_edit?
+end
diff --git a/app/policies/series_policy.rb b/app/policies/series_policy.rb
new file mode 100755
index 0000000..d411f38
--- /dev/null
+++ b/app/policies/series_policy.rb
@@ -0,0 +1,2 @@
+class SeriesPolicy < UserCreationPolicy
+end
diff --git a/app/policies/skin_policy.rb b/app/policies/skin_policy.rb
new file mode 100755
index 0000000..2fc2c14
--- /dev/null
+++ b/app/policies/skin_policy.rb
@@ -0,0 +1,21 @@
+class SkinPolicy < ApplicationPolicy
+ ACCESS_SKINS = %w[superadmin support].freeze
+ MANAGE_SITE_SKINS = %w[superadmin].freeze
+ MANAGE_WORK_SKINS = %w[superadmin support].freeze
+
+ def index?
+ user_has_roles?(ACCESS_SKINS)
+ end
+
+ def update?
+ user_has_roles?(MANAGE_SITE_SKINS) && !@record.is_a?(WorkSkin) ||
+ user_has_roles?(MANAGE_WORK_SKINS) && @record.is_a?(WorkSkin)
+ end
+
+ def set_default?
+ user_has_roles?(MANAGE_SITE_SKINS)
+ end
+
+ alias index_approved? index?
+ alias index_rejected? index?
+end
diff --git a/app/policies/user_creation_policy.rb b/app/policies/user_creation_policy.rb
new file mode 100755
index 0000000..3eb5f8b
--- /dev/null
+++ b/app/policies/user_creation_policy.rb
@@ -0,0 +1,23 @@
+class UserCreationPolicy < ApplicationPolicy
+ FULL_ACCESS_ROLES = %w[superadmin legal policy_and_abuse].freeze
+
+ def show_admin_options?
+ destroy? || hide? || edit?
+ end
+
+ def destroy?
+ user_has_roles?(FULL_ACCESS_ROLES)
+ end
+
+ def hide?
+ user_has_roles?(FULL_ACCESS_ROLES)
+ end
+
+ def show_ip_address?
+ user_has_roles?(FULL_ACCESS_ROLES)
+ end
+
+ def show_original_creators?
+ user_has_roles?(FULL_ACCESS_ROLES)
+ end
+end
diff --git a/app/policies/user_manager_policy.rb b/app/policies/user_manager_policy.rb
new file mode 100755
index 0000000..ae4ead4
--- /dev/null
+++ b/app/policies/user_manager_policy.rb
@@ -0,0 +1,18 @@
+class UserManagerPolicy < ApplicationPolicy
+ # Roles that allow adding notes to users.
+ NOTE_ROLES = %w[superadmin policy_and_abuse support].freeze
+
+ # Roles that allow warning, suspending, and banning users.
+ JUDGE_ROLES = %w[superadmin policy_and_abuse].freeze
+
+ def can_manage_users?
+ user_has_roles?(NOTE_ROLES) || user_has_roles?(JUDGE_ROLES)
+ end
+
+ def update_status?
+ return true if user_has_roles?(JUDGE_ROLES)
+ return record.admin_action == "note" if user_has_roles?(NOTE_ROLES)
+
+ false
+ end
+end
diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb
new file mode 100755
index 0000000..118ca25
--- /dev/null
+++ b/app/policies/user_policy.rb
@@ -0,0 +1,90 @@
+class UserPolicy < ApplicationPolicy
+ # Roles that allow:
+ # - troubleshooting for a user
+ # - managing a user's invitations
+ # - updating a user's email and roles (e.g. wranglers, archivists, not admin roles)
+ # This is further restricted using ALLOWED_ATTRIBUTES_BY_ROLES.
+ MANAGE_ROLES = %w[superadmin legal policy_and_abuse open_doors support tag_wrangling].freeze
+
+ # Roles that are allowed to set a generic username for users.
+ CHANGE_USERNAME_ROLES = %w[superadmin policy_and_abuse].freeze
+
+ # Roles that allow updating the Fannish Next Of Kin of a user.
+ MANAGE_NEXT_OF_KIN_ROLES = %w[superadmin policy_and_abuse support].freeze
+
+ # Roles that allow deleting all of a spammer's creations.
+ SPAM_CLEANUP_ROLES = %w[superadmin policy_and_abuse].freeze
+
+ # Roles that allow viewing of past user emails and logins.
+ VIEW_PAST_USER_INFO_ROLES = %w[superadmin policy_and_abuse open_doors support tag_wrangling].freeze
+
+ # Roles that allow accessing a summary of a user's works and comments.
+ REVIEW_CREATIONS_ROLES = %w[superadmin policy_and_abuse].freeze
+
+ # Define which roles can update which attributes.
+ ALLOWED_ATTRIBUTES_BY_ROLES = {
+ "open_doors" => [roles: []],
+ "policy_and_abuse" => [:email, { roles: [] }],
+ "superadmin" => [:email, { roles: [] }],
+ "support" => [:email, { roles: [] }],
+ "tag_wrangling" => [roles: []]
+ }.freeze
+
+ # Define which admin roles can edit which user roles.
+ ALLOWED_USER_ROLES_BY_ADMIN_ROLES = {
+ "open_doors" => %w[archivist no_resets opendoors],
+ "policy_and_abuse" => %w[no_resets protected_user],
+ "superadmin" => %w[archivist no_resets official opendoors protected_user tag_wrangler],
+ "support" => %w[no_resets],
+ "tag_wrangling" => %w[tag_wrangler]
+ }.freeze
+
+ def can_manage_users?
+ user_has_roles?(MANAGE_ROLES)
+ end
+
+ def can_manage_next_of_kin?
+ user_has_roles?(MANAGE_NEXT_OF_KIN_ROLES)
+ end
+
+ def can_destroy_spam_creations?
+ user_has_roles?(SPAM_CLEANUP_ROLES)
+ end
+
+ def can_view_past?
+ user_has_roles?(VIEW_PAST_USER_INFO_ROLES)
+ end
+
+ def can_access_creation_summary?
+ user_has_roles?(REVIEW_CREATIONS_ROLES)
+ end
+
+ def can_change_username?
+ user_has_roles?(CHANGE_USERNAME_ROLES)
+ end
+
+ def permitted_attributes
+ ALLOWED_ATTRIBUTES_BY_ROLES.values_at(*user.roles).compact.flatten
+ end
+
+ def can_edit_user_role?(role)
+ ALLOWED_USER_ROLES_BY_ADMIN_ROLES.values_at(*user.roles).compact.flatten.include?(role.name)
+ end
+
+ alias index? can_manage_users?
+ alias bulk_search? can_manage_users?
+ alias show? can_manage_users?
+ alias update? can_manage_users?
+ alias change_username? can_change_username?
+ alias changed_username? can_change_username?
+
+ alias update_next_of_kin? can_manage_next_of_kin?
+
+ alias confirm_delete_user_creations? can_destroy_spam_creations?
+ alias destroy_user_creations? can_destroy_spam_creations?
+
+ alias creations? can_access_creation_summary?
+
+ alias troubleshoot? can_manage_users?
+ alias activate? can_manage_users?
+end
diff --git a/app/policies/work_policy.rb b/app/policies/work_policy.rb
new file mode 100755
index 0000000..afa1de1
--- /dev/null
+++ b/app/policies/work_policy.rb
@@ -0,0 +1,28 @@
+class WorkPolicy < UserCreationPolicy
+ def show_admin_options?
+ super || edit_tags? || set_spam?
+ end
+
+ # Allow admins to edit work tags and languages.
+ # Include support admins due to AO3-4932.
+ def update_tags?
+ user_has_roles?(%w[superadmin policy_and_abuse support])
+ end
+
+ alias edit_tags? update_tags?
+
+ # Support admins need to be able to delete duplicate works.
+ def destroy?
+ super || user_has_roles?(%w[support])
+ end
+
+ def set_spam?
+ user_has_roles?(%w[superadmin policy_and_abuse])
+ end
+
+ def remove_pseud?
+ user_has_roles?(%w[superadmin support policy_and_abuse])
+ end
+
+ alias confirm_remove_pseud? remove_pseud?
+end
diff --git a/app/policies/wrangling_policy.rb b/app/policies/wrangling_policy.rb
new file mode 100755
index 0000000..ba17ced
--- /dev/null
+++ b/app/policies/wrangling_policy.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class WranglingPolicy < ApplicationPolicy
+ FULL_ACCESS_ROLES = %w[superadmin tag_wrangling].freeze
+ READ_ACCESS_ROLES = (FULL_ACCESS_ROLES + %w[policy_and_abuse]).freeze
+
+ def full_access?
+ user_has_roles?(FULL_ACCESS_ROLES)
+ end
+
+ def read_access?
+ user_has_roles?(READ_ACCESS_ROLES)
+ end
+
+ alias create? full_access?
+ alias destroy? full_access?
+ alias mass_update? full_access?
+ alias show? full_access?
+ alias report_csv? full_access?
+ alias new? full_access?
+ alias edit? full_access?
+ alias manage? full_access?
+ alias update? full_access?
+ alias update_positions? full_access?
+end
diff --git a/app/sweepers/collection_sweeper.rb b/app/sweepers/collection_sweeper.rb
new file mode 100755
index 0000000..da0b9db
--- /dev/null
+++ b/app/sweepers/collection_sweeper.rb
@@ -0,0 +1,61 @@
+class CollectionSweeper < ActionController::Caching::Sweeper
+ observe Collection, CollectionItem, CollectionParticipant, CollectionProfile, Work
+
+ def after_create(record)
+ record.add_to_autocomplete if record.is_a?(Collection)
+ end
+
+ def after_update(record)
+ if record.is_a?(Collection) && (record.saved_change_to_name? || record.saved_change_to_title?)
+ record.remove_stale_from_autocomplete
+ record.add_to_autocomplete
+ end
+ end
+
+ def after_save(record)
+ expire_collection_cache_for(record)
+ end
+
+ def before_destroy(record)
+ record.remove_from_autocomplete if record.is_a?(Collection)
+ end
+
+ def after_destroy(record)
+ expire_collection_cache_for(record)
+ end
+
+ private
+ # return one or many collections associated with the changed record
+ # converted into an array
+ def get_collections_from_record(record)
+ if record.is_a?(Collection)
+ # send collection, its parent, and any children
+ ([record, record.parent] + record.children).compact
+ elsif record.respond_to?(:collection) && !record.collection.nil?
+ ([record.collection, record.collection.parent] + record.collection.children).compact
+ elsif record.respond_to?(:collections)
+ (record.collections + record.collections.collect(&:parent) + record.collections.collect(&:children).flatten).compact
+ else
+ []
+ end
+ end
+
+ # Whenever these records are updated, we need to blank out the collections cache
+ def expire_collection_cache_for(record)
+ collections = get_collections_from_record(record)
+ collections.each do |collection|
+ CollectionSweeper.expire_collection_blurb_and_profile(collection)
+ end
+ end
+
+ # Expire the collection blurb and profile
+ def self.expire_collection_blurb_and_profile(collection)
+ # Expire both versions of the blurb, whether the user is logged in or not.
+ %w[logged-in logged-out].each do |logged_in|
+ cache_key = "collection-blurb-#{logged_in}-#{collection.id}-v4"
+ ActionController::Base.new.expire_fragment(cache_key)
+ end
+
+ ActionController::Base.new.expire_fragment("collection-profile-#{collection.id}")
+ end
+end
diff --git a/app/sweepers/feed_sweeper.rb b/app/sweepers/feed_sweeper.rb
new file mode 100755
index 0000000..3377d61
--- /dev/null
+++ b/app/sweepers/feed_sweeper.rb
@@ -0,0 +1,35 @@
+class FeedSweeper < ActionController::Caching::Sweeper
+ include Rails.application.routes.url_helpers
+
+ observe Chapter, Work
+
+ def after_create(record)
+ if record.posted? && (record.is_a?(Work) || (record.is_a?(Chapter) && record.work.present? && record.work.posted?))
+ expire_caches(record)
+ end
+ end
+
+ def after_update(record)
+ if record.posted?
+ expire_caches(record)
+ end
+ end
+
+ private
+
+ # When a chapter or work is created, updated or destroyed, expire:
+ # - the cached feed page for each of its canonical tags
+ # - the works index caches for its canonical tags, pseuds, users and collections
+ def expire_caches(record)
+ work = record
+ work = record.work if record.is_a?(Chapter)
+
+ return unless work.present?
+
+ work.filters.each do |tag|
+ # expire the atom feed page for the tags on the work and the corresponding filter tags
+ ActionController::Base.expire_page feed_tag_path(tag.id, format: 'atom')
+ end
+ end
+
+end
diff --git a/app/sweepers/pseud_sweeper.rb b/app/sweepers/pseud_sweeper.rb
new file mode 100755
index 0000000..293119e
--- /dev/null
+++ b/app/sweepers/pseud_sweeper.rb
@@ -0,0 +1,43 @@
+class PseudSweeper < ActionController::Caching::Sweeper
+ observe User, Pseud
+
+ def after_create(record)
+ record.add_to_autocomplete if record.is_a?(Pseud)
+ end
+
+ def before_update(record)
+ if record.changed.include?("name") || record.changed.include?("login")
+ if record.is_a?(User)
+ record.pseuds.each(&:remove_stale_from_autocomplete_before_save)
+ else
+ if record.user.saved_changes.any?
+ # In this case, `remove_stale_from_autocomplete` needs to look at the
+ # changed attributes on the pseud's user as if it were an after_*
+ # callback on the user instead of a before_* callback on the pseud.
+ record.remove_stale_from_autocomplete
+ else
+ record.remove_stale_from_autocomplete_before_save
+ end
+ end
+ end
+ end
+
+ def after_update(record)
+ if record.saved_changes.keys.include?("name") || record.saved_changes.keys.include?("login")
+ if record.is_a?(User)
+ record.pseuds.each do |pseud|
+ # have to reload the pseud from the db otherwise it has the outdated login
+ pseud.reload
+ pseud.add_to_autocomplete
+ end
+ else
+ record.add_to_autocomplete
+ end
+ end
+ end
+
+ def before_destroy(record)
+ record.remove_from_autocomplete if record.is_a?(Pseud)
+ end
+
+end
diff --git a/app/sweepers/tag_set_sweeper.rb b/app/sweepers/tag_set_sweeper.rb
new file mode 100755
index 0000000..b4bb9a7
--- /dev/null
+++ b/app/sweepers/tag_set_sweeper.rb
@@ -0,0 +1,40 @@
+class TagSetSweeper < ActionController::Caching::Sweeper
+ observe TagSet, TagSetAssociation, OwnedTagSet
+
+ def after_create(record)
+ expire_cache_for(record)
+ end
+
+ def after_update(record)
+ expire_cache_for(record)
+ end
+
+ def after_destroy(record)
+ expire_cache_for(record)
+ end
+
+ private
+
+ def get_tagset_from_record(record)
+ case record.class.to_s
+ when "TagSetAssociation"
+ record.owned_tag_set ? record.owned_tag_set.tag_set : nil
+ when "OwnedTagSet"
+ record.tag_set
+ when "TagSet"
+ record
+ else
+ nil
+ end
+ end
+
+ def expire_cache_for(record)
+ tag_set = get_tagset_from_record(record)
+ unless tag_set.nil?
+ # expire the tag_set show page and fragments
+ ActionController::Base.new.expire_fragment("tag_set_show_#{tag_set.id}")
+ TagSet::TAG_TYPES.each {|type| ActionController::Base.new.expire_fragment("tag_set_show_#{tag_set.id}_#{type}")}
+ end
+ end
+
+end
diff --git a/app/validators/at_most_validator.rb b/app/validators/at_most_validator.rb
new file mode 100755
index 0000000..7a0a118
--- /dev/null
+++ b/app/validators/at_most_validator.rb
@@ -0,0 +1,17 @@
+# This validator is very similar to the less_than_or_equal_to option for the
+# numericality validator, but it computes the difference between the value and
+# the maximum so that it can be included in the error message.
+class AtMostValidator < ActiveModel::EachValidator
+ def validate_each(record, attribute, value)
+ maximum = options[:maximum]
+ maximum = record.send(maximum) if maximum.is_a?(Symbol)
+ maximum = maximum.call(record) if maximum.is_a?(Proc)
+
+ return unless value > maximum
+
+ record.errors.add(
+ attribute, :at_most,
+ value: value, count: maximum, diff: value - maximum
+ )
+ end
+end
diff --git a/app/validators/attachment_validator.rb b/app/validators/attachment_validator.rb
new file mode 100755
index 0000000..79fa026
--- /dev/null
+++ b/app/validators/attachment_validator.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+# Custom validator to ensure that a field using ActiveStorage
+# * matches the given formats, specified with regex or by a list (leave empty to allow any)
+# * is less than the given maximum (if none is given, the default is 500kb)
+class AttachmentValidator < ActiveModel::EachValidator
+ def validate_each(record, attribute, value)
+ return unless value&.attached?
+
+ allowed_formats = options[:allowed_formats]
+ maximum_size = options[:maximum_size] || 500.kilobytes
+
+ case allowed_formats
+ when Regexp
+ record.errors.add(attribute, :invalid_format) unless allowed_formats.match?(value.content_type)
+ when Array
+ record.errors.add(attribute, :invalid_format) unless allowed_formats.include?(value.content_type)
+ end
+
+ record.errors.add(attribute, :too_large, maximum_size: maximum_size.to_fs(:human_size)) unless value.blob.byte_size < maximum_size
+
+ value.purge if record.errors[attribute].any?
+ end
+end
diff --git a/app/validators/email_blacklist_validator.rb b/app/validators/email_blacklist_validator.rb
new file mode 100755
index 0000000..79af00b
--- /dev/null
+++ b/app/validators/email_blacklist_validator.rb
@@ -0,0 +1,10 @@
+class EmailBlacklistValidator < ActiveModel::EachValidator
+ def validate_each(record,attribute,value)
+ if AdminBlacklistedEmail.is_blacklisted?(value)
+ record.errors.add(attribute, options[:message] || I18n.t("validators.email.blacklist"))
+ return false
+ else
+ return true
+ end
+ end
+end
diff --git a/app/validators/email_format_validator.rb b/app/validators/email_format_validator.rb
new file mode 100755
index 0000000..673fe60
--- /dev/null
+++ b/app/validators/email_format_validator.rb
@@ -0,0 +1,30 @@
+# From Authlogic, to mimic old behavior
+#
+# https://github.com/binarylogic/authlogic/blob/v3.6.0/lib/authlogic/regex.rb#L13
+#
+# https://github.com/binarylogic/authlogic/blob/v3.6.0/lib/authlogic/acts_as_authentic/email.rb#L90
+#
+class EmailFormatValidator < ActiveModel::EachValidator
+ def validate_each(record, attribute, value)
+ email_regex ||= begin
+ email_name_regex = '[A-Z0-9_\.&%\+\-\']+'
+ domain_head_regex = '(?:[A-Z0-9\-]+\.)+'
+ domain_tld_regex = '(?:[A-Z]{2,25})'
+ /\A#{email_name_regex}@#{domain_head_regex}#{domain_tld_regex}\z/i
+ end
+
+ if (options[:allow_blank] && value.blank?) || (value.present? && value.match(email_regex))
+ result = true
+ else
+ result = false
+ end
+
+ unless result
+ if options[:allow_blank]
+ record.errors.add(attribute, options[:message] || I18n.t("validators.email.format.allow_blank"))
+ else
+ record.errors.add(attribute, options[:message] || I18n.t("validators.email.format.no_blank"))
+ end
+ end
+ end
+end
diff --git a/app/validators/not_blocked_validator.rb b/app/validators/not_blocked_validator.rb
new file mode 100755
index 0000000..a751e3f
--- /dev/null
+++ b/app/validators/not_blocked_validator.rb
@@ -0,0 +1,25 @@
+class NotBlockedValidator < ActiveModel::EachValidator
+ include BlockHelper
+
+ def validate_each(record, attribute, value)
+ return if value.nil?
+
+ blocker = options[:by]
+ case blocker
+ when Symbol
+ blocker = record.send(blocker)
+ when Proc
+ blocker = blocker.call(record)
+ end
+
+ blocker_users = users_for(blocker)
+ blocked_users = users_for(value)
+
+ # You can't be blocked from interacting with your own things:
+ return if (blocked_users - blocker_users).empty?
+
+ return unless Block.exists?(blocker: blocker_users, blocked: blocked_users)
+
+ record.errors.add(attribute, options.fetch(:message, :blocked))
+ end
+end
diff --git a/app/validators/not_forbidden_name_validator.rb b/app/validators/not_forbidden_name_validator.rb
new file mode 100755
index 0000000..54541ff
--- /dev/null
+++ b/app/validators/not_forbidden_name_validator.rb
@@ -0,0 +1,9 @@
+class NotForbiddenNameValidator < ActiveModel::EachValidator
+ def validate_each(record, attribute, value)
+ return if value.nil?
+ return unless ArchiveConfig.FORBIDDEN_USERNAMES.include?(value.downcase)
+
+ # i18n-tasks-use t("activerecord.errors.messages.forbidden")
+ record.errors.add(attribute, :forbidden, **options.merge(value: value))
+ end
+end
diff --git a/app/validators/url_active_validator.rb b/app/validators/url_active_validator.rb
new file mode 100755
index 0000000..9a07476
--- /dev/null
+++ b/app/validators/url_active_validator.rb
@@ -0,0 +1,27 @@
+require 'timeout'
+require 'uri'
+
+class UrlActiveValidator < ActiveModel::EachValidator
+
+ # Checks the status of the webpage at the given url
+ # To speed things up we ONLY request the head and not the entire page.
+ # Bypass check for fanfiction.net and ficbook.net because of ip block
+ def validate_each(record,attribute,value)
+ return true if value.match("fanfiction.net") || value.match("ficbook.net")
+
+ inactive_url_msg = "could not be reached. If the URL is correct and the site is currently down, please try again later."
+ inactive_url_timeout = 10 # seconds
+ begin
+ status = Timeout::timeout(options[:timeout] || inactive_url_timeout) {
+ url = Addressable::URI.parse(value)
+ response_code = Net::HTTP.start(url.host, url.port) {|http| http.head(url.path.blank? ? '/' : url.path).code}
+ active_status = %w[200 301 302 307 308]
+ active_status.include? response_code
+ }
+ rescue
+ status = false
+ end
+ record.errors.add(attribute, options[:message] || inactive_url_msg) unless status
+ end
+
+end
diff --git a/app/validators/url_format_validator.rb b/app/validators/url_format_validator.rb
new file mode 100755
index 0000000..4020ba0
--- /dev/null
+++ b/app/validators/url_format_validator.rb
@@ -0,0 +1,20 @@
+# Validate format of URLs
+class UrlFormatValidator < ActiveModel::EachValidator
+
+ # will be validated with active it if works
+ # just do a fast and dirty check.
+ def validate_each(record,attribute,value)
+ return true if (value.blank? && options[:allow_blank])
+ # http (optional s) :// domain . tld (optional port) / anything
+ regexp = /^https?:\/\/[_a-z\d\-]+\.[._a-z\d\-]+(:\d+)?\/?.+/i
+ unless value.match regexp
+ record.errors.add(attribute, options[:message] || :invalid)
+ end
+
+ begin
+ Addressable::URI.heuristic_parse(value)
+ rescue Addressable::URI::InvalidURIError
+ record.errors.add(attribute, options[:message] || :invalid)
+ end
+ end
+end
diff --git a/app/views/abuse_reports/new.html.erb b/app/views/abuse_reports/new.html.erb
new file mode 100755
index 0000000..2c1b79f
--- /dev/null
+++ b/app/views/abuse_reports/new.html.erb
@@ -0,0 +1,17 @@
+
+
<%= t(".heading.page_title") %>
+<%= error_messages_for :abuse_report %>
+
+
+
+
<%= t("a11y.navigation") %>
+<%= render "home/tos_navigation" %>
+
+
+
+
<%= t(".page_content_landmark") %>
+
+Please email me at femslashclub@proton.me with the subject ABUSE REPORT: [subject of your report.] Thanks!
+
+
+
diff --git a/app/views/admin/_admin_nav.html.erb b/app/views/admin/_admin_nav.html.erb
new file mode 100755
index 0000000..e705ea4
--- /dev/null
+++ b/app/views/admin/_admin_nav.html.erb
@@ -0,0 +1,41 @@
+
+<% end %>
+
+
+
+
diff --git a/app/views/admin/admin_users/_user_creations_summary.html.erb b/app/views/admin/admin_users/_user_creations_summary.html.erb
new file mode 100755
index 0000000..a69164a
--- /dev/null
+++ b/app/views/admin/admin_users/_user_creations_summary.html.erb
@@ -0,0 +1,21 @@
+<% unless @user.works.empty? %>
+ <%# Checking @user.items.empty? rather than @items.empty? allows us to display
+# an empty listbox with pagination if the admin manually enters the wrong URL.
+# This is consistent with pagination on other site pages. %>
+
+<% end %>
+
+<% unless @user.comments.empty? %>
+ <%# We use comments rather than comment as the class because .comment:after
+# create a clear that causes wonky styling when there are only a few
+# comments. %>
+
+<% end %>
diff --git a/app/views/admin/admin_users/_user_form.html.erb b/app/views/admin/admin_users/_user_form.html.erb
new file mode 100755
index 0000000..addc627
--- /dev/null
+++ b/app/views/admin/admin_users/_user_form.html.erb
@@ -0,0 +1,25 @@
+
+
+<%= form_tag action: "update", controller: "admin/admin_users" do %>
+ <%= hidden_field_tag :query, params[:query] %>
+ <%= hidden_field_tag :role, params[:role] %>
+ <%= hidden_field_tag "id", user.login %>
+ <% # HACK: We need the user param to be present so we can remove all roles.
+ # However, if we simply submit the form with all roles unchecked, the user
+ # param is stripped. Therefore, we need a placeholder. %>
+ <%= hidden_field_tag "user[roles][]", "", disabled: !admin_can_update_user_roles? %>
+
\ No newline at end of file
diff --git a/app/views/admin/skins/index.html.erb b/app/views/admin/skins/index.html.erb
new file mode 100755
index 0000000..26abe59
--- /dev/null
+++ b/app/views/admin/skins/index.html.erb
@@ -0,0 +1,71 @@
+
+ <% end %>
+<% end %>
+
diff --git a/app/views/admin_mailer/send_spam_alert.html.erb b/app/views/admin_mailer/send_spam_alert.html.erb
new file mode 100755
index 0000000..7bf03af
--- /dev/null
+++ b/app/views/admin_mailer/send_spam_alert.html.erb
@@ -0,0 +1,20 @@
+<% content_for :message do %>
+
+
The following accounts have a suspicious level of traffic:
+ <% @users.each do |user| %>
+ <% info = @spam[user.id] %>
+ User <%= style_link(user.login, user_works_url(user)) %> has a score of
+ <%= info["score"] %>
+
+ <% Work.where(id: info["work_ids"]).each do |work| %>
+
+ <%= style_link(work.title, work_url(work)) %>
+ <% if work.spam? %>
+ (<%= ts('Flagged as spam') %>)
+ <% end %>
+
+ <% end %>
+
+ <% end %>
+
+<% end %>
diff --git a/app/views/admin_mailer/send_spam_alert.text.erb b/app/views/admin_mailer/send_spam_alert.text.erb
new file mode 100755
index 0000000..85cba84
--- /dev/null
+++ b/app/views/admin_mailer/send_spam_alert.text.erb
@@ -0,0 +1,14 @@
+<% content_for :message do %>
+
+ The following accounts have a suspicious level of traffic:
+
+ <% @users.each do |user| %>
+ <% info = @spam[user.id] %>
+ User <%= user.login %> (<%= user_works_url(user) %>) has a score of <%= info["score"] %>
+
+ <% Work.where(id: info["work_ids"]).each do |work| %>
+ * <%= work.title %> (<%= work_url(work) %>) <% if work.spam? %>(<%= ts('Flagged as spam') %>)<% end %>
+ <% end %>
+
+ <% end %>
+<% end %>
diff --git a/app/views/admin_mailer/set_password_notification.html.erb b/app/views/admin_mailer/set_password_notification.html.erb
new file mode 100755
index 0000000..3c630a4
--- /dev/null
+++ b/app/views/admin_mailer/set_password_notification.html.erb
@@ -0,0 +1,8 @@
+<% content_for :message do %>
+
+ <%= live_validation_for_field('content',
+ :maximum_length => ArchiveConfig.CONTENT_MAX, :minimum_length => ArchiveConfig.CONTENT_MIN,
+ :tooLongMessage => ts("We salute your ambition! But sadly the content must be less than %{max} letters long.", :max => ArchiveConfig.CONTENT_MAX.to_s),
+ :tooShortMessage => ts("Brevity is the soul of wit, but your content does have to be at least %{min} letters long.", :min => ArchiveConfig.CONTENT_MIN.to_s),
+ :failureMessage => ts("You need to post some content here."))
+ %>
+ <%= generate_countdown_html("content", ArchiveConfig.CONTENT_MAX) %>
+
+ <%= form.text_field :title %>
+ <%= live_validation_for_field(field_id(form, "title").to_sym, failureMessage: ts("Please enter a category name.")) %>
+
+
+
+
+ <% # TODO: If you have the form set up to create 3 questions and you only fill in 2, you get an error instead of the totally empty section being ignored. This is the same with requests/offers in challenge signups. %>
+
+
+
<%= ts("Questions") %>
+
+ <% form.object.questions.each_with_index do |question, index| %>
+ <%= form.fields_for :questions, question do |question_form| %>
+ <%= render "question_answer_fields", form: question_form, index: index %>
+ <% end %>
+ <% end %>
+ <% if Globalize.locale.to_s == "en" %>
+
+ <%= ts("The FAQs are currently being updated and translated by our volunteers. This is a work in progress and not all information will be up to date or available in languages other than English at this time. If your language doesn't list all FAQs yet, please consult the English list and check back later for updates.") %>
+
+
+ <%= ts("Some commonly asked questions about the Archive are answered here.
+ Questions and answers about our Terms of Service can be found in the FAQ Section 2.") %>.
+ <%= ts("You may also like to check out our") %> <%= link_to ts("Known Issues"), known_issues_path %>.
+ <%= ts("If you need more help, please ") %> <%= link_to ts("contact Support"), feedbacks_path %>.
+
+
+<% if @archive_faqs.blank? %>
+
<%= ts("We're sorry, there are currently no entries in the FAQ System.") %>
+ <%= form.text_area :content %>
+ <%= live_validation_for_field(field_id(form, "content").to_sym,
+ minimum_length: ArchiveConfig.CONTENT_MIN,
+ failureMessage: ts("Please enter an answer that is at least %{min} characters long.", min: ArchiveConfig.CONTENT_MIN.to_s)) %>
+
+<% end %>
+
\ No newline at end of file
diff --git a/app/views/archive_faqs/edit.html.erb b/app/views/archive_faqs/edit.html.erb
new file mode 100755
index 0000000..fa065f3
--- /dev/null
+++ b/app/views/archive_faqs/edit.html.erb
@@ -0,0 +1,14 @@
+
+ <% # Bookmarks of deleted items need a div because they can still be edited. %>
+
+ <% else %>
+
+ <% bookmark_count = bookmarkable.public_bookmark_count %>
+
+ <% # If this bookmark does not belong to the current user, let them save a new copy or edit their existing copy %>
+ <% if logged_in? && !is_author_of?(bookmark) %>
+
diff --git a/app/views/bookmarks/_bookmark_form.html.erb b/app/views/bookmarks/_bookmark_form.html.erb
new file mode 100755
index 0000000..05e1e9b
--- /dev/null
+++ b/app/views/bookmarks/_bookmark_form.html.erb
@@ -0,0 +1,110 @@
+<%
+# This renders the bookmark form based on whatever kind of object we are bookmarking
+# we need bookmarkable, action (create or update), and bookmark if it exists
+# if in_page is true then we assume that this is embedded within another page (eg the work page)
+# if dynamic is true then this has been rendered via ajax
+%>
+
+ <% end %>
+
+ <%# When the user views their own bookmark blurb, they are warned about seeing their bookmark in a modded collection %>
+ <% unless bookmark.collections.blank? %>
+ <% bookmark.collections.each do |modded| %>
+ <% if modded.moderated? && !bookmark.approved_collections.include?(modded) && is_author_of?(bookmark) %>
+
<%= ts("The collection %{title} is currently moderated. Your bookmark must be approved by the collection maintainers before being listed there.", title: modded.title) %>
+ <% end %>
+ <% end %>
+ <% unless bookmark.approved_collections.empty? && !is_author_of?(bookmark) %>
+
diff --git a/app/views/bookmarks/_bookmarklet.html.erb b/app/views/bookmarks/_bookmarklet.html.erb
new file mode 100755
index 0000000..e2d8689
--- /dev/null
+++ b/app/views/bookmarks/_bookmarklet.html.erb
@@ -0,0 +1 @@
+
Bookmark external works with the AO3 External Bookmarklet. This is a simple bookmarklet that should work in any browser, if you have JavaScript enabled. Just right-click and select Bookmark This Link (or Bookmark Link).
diff --git a/app/views/bookmarks/_bookmarks.html.erb b/app/views/bookmarks/_bookmarks.html.erb
new file mode 100755
index 0000000..75f197a
--- /dev/null
+++ b/app/views/bookmarks/_bookmarks.html.erb
@@ -0,0 +1,21 @@
+<% # show blurb for whatever is bookmarked if we're looking at a single bookmarked object %>
+<% if params[:work_id] %>
+ <%= render 'works/work_blurb', :work => @bookmarkable %>
+<% elsif params[:external_work_id] %>
+ <%= render 'external_works/blurb', :external_work => @bookmarkable %>
+<% elsif params[:series_id] %>
+ <%= render 'series/series_blurb', :series => @bookmarkable %>
+<% end %>
+
+<% if @bookmarkable %>
+ <% # show only partial view for each bookmark since the bookmarked object is already shown above %>
+ <%= render partial: 'bookmarks/bookmark_blurb_short', collection: @bookmarks, as: :bookmark %>
+
+<% elsif @bookmarkable_items %>
+ <% # Display a list of bookmarkables, with several bookmarks under each %>
+ <%= render partial: 'bookmarks/bookmarkable_blurb', collection: @bookmarkable_items, as: :bookmarkable %>
+<% else %>
+ <% # Standard bookmarks index with full blurb %>
+ <%= render partial: 'bookmarks/bookmark_blurb', collection: @bookmarks, as: :bookmark %>
+
+<% end %>
diff --git a/app/views/bookmarks/_external_work_fields.html.erb b/app/views/bookmarks/_external_work_fields.html.erb
new file mode 100755
index 0000000..2663ec0
--- /dev/null
+++ b/app/views/bookmarks/_external_work_fields.html.erb
@@ -0,0 +1,119 @@
+
+
+
+
<%= ew.label :url, ts("URL") + "*" %>
+
+ <% if params[:url_from_external].present? %>
+ <%= ew.text_field :url, autocomplete_options("external_work", data: { autocomplete_token_limit: 1 }, value: params[:url_from_external]) %>
+ <% else %>
+ <%= ew.text_field :url, autocomplete_options("external_work", data: { autocomplete_token_limit: 1 }) %>
+ <% end %>
+ <%= hidden_field "fetched", "", id: "fetched" %>
+ <%= content_for :footer_js do %>
+ <%= javascript_tag do %>
+ <% if params[:url_from_external].present? %>
+ // The blur event isn't triggered if we start with a URL from the
+ // External Bookmarklet, so instead we grab the info on the initial
+ // page load. This only fires when we're using the bookmarklet, not
+ // when we're thrown back to the form due to an error -- we don't
+ // want to override information the user has put in.
+ $j(document).ready(function() {
+ loadExternalWorkInfoForBookmark();
+ });
+ <% else %>
+ // Fill in the external work information once we have chosen a URL
+ // from the autocomplete field.
+ $j("#bookmark-form").on("blur", "#external_work_url_autocomplete", function() {
+ loadExternalWorkInfoForBookmark();
+ });
+ <% end %>
+ function loadExternalWorkInfoForBookmark() {
+ $j.ajax({
+ type: "get",
+ data: "external_work_url=" + $j("#external_work_url").val(),
+ dataType: "script",
+ url: "<%= url_for(controller: "external_works", action: "fetch", only_path: true) %>"
+ });
+ }
+ <% end %>
+ <% end %>
+
+ <%= ts("If this URL has been bookmarked before, the work information will be filled in automatically.") %>
+
+ <%= ts("Creator's Tags (comma separated, %{max} characters per tag). Only a fandom is required. Fandom, relationship, and character tags must not add up to more than %{limit}. Category and rating tags do not count toward this limit.",
+ limit: ArchiveConfig.USER_DEFINED_TAGS_MAX,
+ max: ArchiveConfig.TAG_MAX) %>
+
+ <% %w(rating archive_warning category fandom character relationship freeform tag).each do |tag_type| %>
+
+ <% # For accessibility, include filter action (e.g. Include) as landmark text %>
+ <% # The space needs to be in the landmark to keep the text aligned %>
+ <%= ts("%{filter_action} %{tag_type}",
+ filter_action: filter_action.titleize,
+ tag_type: tag_type_label_name(tag_type).pluralize
+ ).html_safe %>
+
+ <% if @bookmark.bookmarkable.nil? %>
+ <%= ts('Are you sure you want to delete your bookmark of this deleted work?').html_safe %>
+ <% else %>
+ <%= ts('Are you sure you want to delete your bookmark of "%{bookmarkable}"?', :bookmarkable => @bookmark.bookmarkable.title).html_safe %>
+ <% end %>
+
+
+ <%= f.submit ts("Yes, Delete Bookmark") %>
+
+<% end %>
+
diff --git a/app/views/bookmarks/edit.html.erb b/app/views/bookmarks/edit.html.erb
new file mode 100755
index 0000000..0dd9e4e
--- /dev/null
+++ b/app/views/bookmarks/edit.html.erb
@@ -0,0 +1,22 @@
+
+
+ <% if @bookmarkable %>
+ <%= (ts("Editing bookmark for ") + link_to(@bookmarkable.title, @bookmarkable)).html_safe %>
+ <% else %>
+ <%= ts("Editing bookmark") %>
+ <% end %>
+
+ <% end %>
+ <% if @facets.present? %>
+ <% # Filters button for narrow screens jumps to filters when JavaScript is disabled and opens filters when JavaScript is enabled %>
+
<%= ts("The more options you allow here, the more complicated your sign-up form will be.") %>
+
<%= ts("If you keep to one request and one offer, automated matching will be easier! A single request can include multiple fandoms. Unless you want to do fairly complicated requests, you probably don't need more than one request/offer.") %>
+
<%= ts('You only need to set the "Allowed" number if you want more allowed than required.') %>
+<% if logged_in? %>
+ <% # signup form or link %>
+ <% if (@challenge_signup = ChallengeSignup.in_collection(@collection).by_user(current_user).first) %>
+
<%= ts("The more options you allow here, the more complicated your sign-up form will be.") %>
+
<%= ts('If you set the "Required" value higher than the corresponding "Allowed" value, we will
+ automatically assume that you want the allowed value to be the same as the required value. You only need to manually
+ set the "Allowed" number if you want more allowed than required.') %>
+
+
+<%= render "challenge/shared/challenge_form_confirm_delete" %>
+
diff --git a/app/views/challenge/prompt_meme/edit.html.erb b/app/views/challenge/prompt_meme/edit.html.erb
new file mode 100755
index 0000000..abfcba8
--- /dev/null
+++ b/app/views/challenge/prompt_meme/edit.html.erb
@@ -0,0 +1 @@
+<%= render :partial => "prompt_meme_form" %>
diff --git a/app/views/challenge/prompt_meme/manage.html.erb b/app/views/challenge/prompt_meme/manage.html.erb
new file mode 100755
index 0000000..e69de29
diff --git a/app/views/challenge/prompt_meme/new.html.erb b/app/views/challenge/prompt_meme/new.html.erb
new file mode 100755
index 0000000..abfcba8
--- /dev/null
+++ b/app/views/challenge/prompt_meme/new.html.erb
@@ -0,0 +1 @@
+<%= render :partial => "prompt_meme_form" %>
diff --git a/app/views/challenge/prompt_meme/show.html.erb b/app/views/challenge/prompt_meme/show.html.erb
new file mode 100755
index 0000000..e69de29
diff --git a/app/views/challenge/shared/_challenge_form_confirm_delete.html.erb b/app/views/challenge/shared/_challenge_form_confirm_delete.html.erb
new file mode 100755
index 0000000..96e8ca4
--- /dev/null
+++ b/app/views/challenge/shared/_challenge_form_confirm_delete.html.erb
@@ -0,0 +1,14 @@
+<%= form_for(@collection, :url =>
+ if @collection.prompt_meme?
+ collection_prompt_meme_path(@collection)
+ else
+ collection_gift_exchange_path(@collection)
+ end,
+ :html => { :method => :delete }) do |f| %>
+
+ <%= ts("Are you sure you want to delete the challenge from the collection %{title}? All sign-ups, assignments, and settings will be lost. (Works and bookmarks will remain in the collection.)", title: @collection.title).html_safe %>
+
+
+ <%= f.submit ts("Yes, Delete Challenge") %>
+
+<% end %>
diff --git a/app/views/challenge/shared/_challenge_form_delete.html.erb b/app/views/challenge/shared/_challenge_form_delete.html.erb
new file mode 100755
index 0000000..2f2b697
--- /dev/null
+++ b/app/views/challenge/shared/_challenge_form_delete.html.erb
@@ -0,0 +1,8 @@
+<%= link_to ts("Delete Challenge"),
+ if @collection.prompt_meme?
+ confirm_delete_collection_prompt_meme_path(@collection)
+ else
+ confirm_delete_collection_gift_exchange_path(@collection)
+ end,
+ data: { confirm: ts("Are you sure you want to delete the challenge from this collection? All sign-ups, assignments, and settings will be lost. (Works and bookmarks will remain in the collection.)") }
+%>
diff --git a/app/views/challenge/shared/_challenge_form_instructions.html.erb b/app/views/challenge/shared/_challenge_form_instructions.html.erb
new file mode 100755
index 0000000..57afc8e
--- /dev/null
+++ b/app/views/challenge/shared/_challenge_form_instructions.html.erb
@@ -0,0 +1,44 @@
+
+
+
<%= ts("Sign-up Instructions") %>
+
+ <%= ts("Explain to your members how you want them to sign up.") %> <%= allowed_html_instructions %>
+
\ No newline at end of file
diff --git a/app/views/challenge/shared/_challenge_form_schedule.html.erb b/app/views/challenge/shared/_challenge_form_schedule.html.erb
new file mode 100755
index 0000000..3b20046
--- /dev/null
+++ b/app/views/challenge/shared/_challenge_form_schedule.html.erb
@@ -0,0 +1,50 @@
+
+
+
<%= ts("Schedule") %>
+
+
+ <%= ts("Collection maintainers can sign up while sign-up is closed. Nobody else will
+ see the sign-up links until you mark your collection open. In this way you can go through the steps and make sure your sign-up form looks just as you want before you launch.") %>
+
+
<%= ts("Dates don't do anything right now; you need to manually open and close sign-up.") %>
+ <%= signup_count = @collection.signups.count %>
+ <% if signup_count < 6 %>
+ <%= ts("Too few sign-ups to display names") %>
+ <% else %>
+ <% num_to_show = 20 # arbitrary number for how many names to list %>
+
+ <% @collection.signups.includes(:pseud => :user).collect(&:pseud).compact.each_with_index do |pseud, index| %>
+ <% if index == num_to_show %>
+
+ <%= content_for :footer_js do %>
+ <%= javascript_tag do %>
+ $j(document).ready(function(){
+ $j('#show_signups').click(function() {
+ $j('#more_signups').show();
+ $j('#hide_signups').show();
+ $j(this).hide();
+ });
+ $j('#hide_signups').click(function() {
+ $j('#more_signups').hide();
+ $j('#show_signups').show();
+ $j(this).hide();
+ });
+ })
+ <% end %>
+ <% end %>
+ <% end %>
+ <% end %>
+
diff --git a/app/views/challenge/shared/_challenge_navigation_user.html.erb b/app/views/challenge/shared/_challenge_navigation_user.html.erb
new file mode 100755
index 0000000..b282d1a
--- /dev/null
+++ b/app/views/challenge/shared/_challenge_navigation_user.html.erb
@@ -0,0 +1,40 @@
+<% # added to the navigation controls for the collection. enclose items in list elements. @collection is defined here but @challenge may not be. %>
+<% collection ||= @collection %>
+
+<% # Start logged-in challenge logic %>
+<% if logged_in? && collection.challenge %>
+
+ <% # Start open sign-ups logic %>
+ <% if collection.challenge.signup_open %>
+ <% # Start user's sign-up options logic %>
+ <% if (@challenge_signup = ChallengeSignup.in_collection(collection).by_user(current_user).first) %>
+
+ <%= link_to ts("Cancel Sign-up"),
+ collection_signup_path(collection, @challenge_signup),
+ data: {confirm: ts("Are you sure you want to cancel your sign-up? All sign-up information will be lost.")},
+ :method => :delete %>
+
+ <% end %>
+ <% # End user's sign-up options logic %>
+ <% end %>
+ <% # End open sign-ups logic %>
+
+ <% # Start membership logic %>
+ <% if !collection.user_is_owner?(current_user) && collection.moderated? %>
+
+ <% if (@participant ||= collection.get_participants_for_user(current_user).first) %>
+ <%= link_to ts("Leave"), collection_participant_path(collection, @participant),
+ data: {confirm: ts('Are you certain you want to leave this collection?')},
+ :method => :delete %>
+ <% else %>
+ <%= button_to ts("Join"), join_collection_participants_path(collection), method: :post %>
+ <% end %>
+
+ <% end %>
+ <% # End membership logic %>
+
+<% end %>
+<% # End logged-in challenge logic %>
diff --git a/app/views/challenge/shared/_challenge_requests.html.erb b/app/views/challenge/shared/_challenge_requests.html.erb
new file mode 100755
index 0000000..d79e1fe
--- /dev/null
+++ b/app/views/challenge/shared/_challenge_requests.html.erb
@@ -0,0 +1,40 @@
+
+
<%= ts("Prompts for %{collection}", :collection => @collection.title) %>
+
+
+
+
+ <% if @collection.user_is_maintainer?(current_user) %>
+
+ <% break %>
+ <% end %>
+ <% end %>
+ <% end %>
+ <% unless @collection.prompts.where(:anonymous => true).exists? %>
+
<%= sort_link ts("Prompter"), :prompter %>
+ <% end %>
+
<%= sort_link ts("Date"), :created_at %>
+
+
+
+<%= will_paginate @requests %>
+
+
+
+ <% @requests.each do |request| %>
+ <% # here we render each prompt as a blurb %>
+ <%= render "prompts/prompt_blurb", :prompt => request %>
+ <% end %>
+
+
+
+<%= will_paginate @requests %>
diff --git a/app/views/challenge/shared/_challenge_signups.html.erb b/app/views/challenge/shared/_challenge_signups.html.erb
new file mode 100755
index 0000000..ef3a13d
--- /dev/null
+++ b/app/views/challenge/shared/_challenge_signups.html.erb
@@ -0,0 +1,31 @@
+<%
+ # The purpose of this page is to display challenge signups for review by the moderator
+ # It should not be user-facing for most challenges
+%>
+
+
<%= ts("Sign-ups for %{collection}", :collection => @collection.title) %>
+
+
+<% @challenge_signups.each do |challenge_signup| %>
+
+ <% # the person who signed up, linking to their full individual signup, and a way to email them %>
+ <%= link_to challenge_signup.pseud.name, collection_signup_path(@collection, challenge_signup) %>
+ <%= mailto_link challenge_signup.pseud.user, :subject => "[#{h(@collection.title)}] Message from Collection Maintainer" %>
+
+ <% # %>
+
+ <% # %>
+
+ <% # %>
+
+
+ <% # %>
+
\ No newline at end of file
diff --git a/app/views/challenge_assignments/_assignment_blurb.html.erb b/app/views/challenge_assignments/_assignment_blurb.html.erb
new file mode 100755
index 0000000..e4d9772
--- /dev/null
+++ b/app/views/challenge_assignments/_assignment_blurb.html.erb
@@ -0,0 +1,70 @@
+<% # expects "assignment" %>
+
+ <% if @collection && @collection.user_is_maintainer?(current_user) %>
+ <% # we're listing assignments for a mod by the assigned writer %>
+ <%= challenge_assignment_byline(assignment) %> <%= challenge_assignment_email(assignment) %>
+ <% else %>
+ <% # we're listing assignments for a user by the recipient %>
+ <%= link_to assignment.collection.title, collection_assignment_path(assignment.collection, assignment) %>
+ <%= ts("for") %> <%= link_to assignment.request_byline, collection_assignment_path(assignment.collection, assignment) %>
+ <% end %>
+
+
+ <% # link to the work %>
+ <% if assignment.creation %>
+ <%= link_to (assignment.creation.try(:title) || assignment.creation.class.name), assignment.creation, :class => "work" %>
+ <% end %>
+
+ <% if @collection && @collection.user_is_maintainer?(current_user) %>
+ <% # for a mod: list recipient (and check in case there are some people without recipients) %>
+ <% if assignment.request_signup %>
+ <%= ts("for") %> <%= link_to assignment.request_byline, collection_assignment_path(@collection, assignment) %>
+ <% else %>
+ <%= ts("No Recipient") %>
+ <% end %>
+ <% end %>
+
+
+
+
<%= ts("Status:") %>
+
+ <% # status can be: unposted (no creation), unapproved (creation but it hasn't been approved), complete %>
+ <% if !assignment.posted? %>
+ <%= ts("Unposted") %>
+ <% elsif !assignment.fulfilled? %>
+ <%= ts("Unapproved") %>
+ <% else %>
+ <%= ts("Complete!") %>
+ <% end %>
+
+
+ <% if assignment.creation %>
+
<%= ts("Posted:") %>
+
<%= assignment.creation.published_at %>
+
+ <% if assignment.creation.respond_to?(:word_count) %>
+
+ <%= button_to ts("Default All Incomplete"),
+ default_all_collection_assignments_path(@collection),
+ data: { confirm: ts("Are you sure? This will mark all unposted or unapproved assignments in the challenge as defaulting.") },
+ method: :patch %>
+
+
+ <%= link_to ts("Purge Assignments"),
+ confirm_purge_collection_assignments_path(@collection),
+ data: { confirm: ts("This will delete ALL assignments so you can edit and send them over. Please do not do this unless you absolutely must!") },
+ role: "button" %>
+
+ <%= submit_fieldset %>
+ <% end %>
+ <% end %>
+
+
+ <%= will_paginate @assignments %>
+<% end %>
+
diff --git a/app/views/challenge_assignments/_maintainer_index_defaulted.html.erb b/app/views/challenge_assignments/_maintainer_index_defaulted.html.erb
new file mode 100755
index 0000000..4a1a86d
--- /dev/null
+++ b/app/views/challenge_assignments/_maintainer_index_defaulted.html.erb
@@ -0,0 +1,22 @@
+<% # list assignments which were defaulted-on and which need or have a pinch hitter assigned %>
+
+
diff --git a/app/views/challenge_assignments/_maintainer_index_fulfilled.html.erb b/app/views/challenge_assignments/_maintainer_index_fulfilled.html.erb
new file mode 100755
index 0000000..44a1768
--- /dev/null
+++ b/app/views/challenge_assignments/_maintainer_index_fulfilled.html.erb
@@ -0,0 +1,6 @@
+<% # this just shows completed and approved assignments %>
+
+ <% @assignments.each do |assignment| %>
+ <%= render "assignment_blurb", :assignment => assignment %>
+ <% end %>
+
diff --git a/app/views/challenge_assignments/_maintainer_index_unfulfilled.html.erb b/app/views/challenge_assignments/_maintainer_index_unfulfilled.html.erb
new file mode 100755
index 0000000..41ec991
--- /dev/null
+++ b/app/views/challenge_assignments/_maintainer_index_unfulfilled.html.erb
@@ -0,0 +1,47 @@
+<% # list assignments which have not yet been fulfilled %>
+
+ <%= ts("Are you sure you want to purge all assignments for
+ %{collection_title}? This cannot be undone. Please only do
+ this if you absolutely must!",
+ collection_title: @collection.title).html_safe %>
+
+ <% end %>
+
+
+ <% if @collection.user_is_maintainer?(current_user) %>
+
+
<%= link_to ts("Delete"), collection_claim_path(claim.collection, claim), data: {confirm: ts('Are you sure you want to delete this claim?')}, :method => :delete %>
+
+ <% end %>
+
diff --git a/app/views/challenge_claims/_user_index.html.erb b/app/views/challenge_claims/_user_index.html.erb
new file mode 100755
index 0000000..a7f628f
--- /dev/null
+++ b/app/views/challenge_claims/_user_index.html.erb
@@ -0,0 +1,34 @@
+
+
<%= ts("My Claims") %> <% if @collection %><%= ts("in") %> <%= link_to(@collection.title, @collection) %><% end %>
<%= ts('Looking for assignments you were given for a gift exchange? Try') %> <%= link_to ts("My Assignments"), user_assignments_path(@user) %>
+<% end %>
+
+
+
+ <% @claims.each do |claim| %>
+ <%= render "prompts/prompt_blurb", :prompt => claim.request_prompt, :claim => claim, :suppress_claims => !params[:posted] %>
+ <% end %>
+
+
+
+
+<%= will_paginate @claims %>
\ No newline at end of file
diff --git a/app/views/challenge_claims/index.html.erb b/app/views/challenge_claims/index.html.erb
new file mode 100755
index 0000000..2a53685
--- /dev/null
+++ b/app/views/challenge_claims/index.html.erb
@@ -0,0 +1,5 @@
+<% if @user || params[:for_user] || (@challenge && !@challenge.user_allowed_to_see_claims?(current_user)) %>
+ <%= render "user_index" %>
+<% elsif @challenge && @challenge.user_allowed_to_see_claims?(current_user) %>
+ <%= render "maintainer_index" %>
+<% end %>
diff --git a/app/views/challenge_requests/index.html.erb b/app/views/challenge_requests/index.html.erb
new file mode 100755
index 0000000..403c6b5
--- /dev/null
+++ b/app/views/challenge_requests/index.html.erb
@@ -0,0 +1,5 @@
+<% @challenge ||= @collection.challenge %>
+
+ <% # render differently based on the challenge %>
+ <%= render "challenge/#{challenge_class_name(@collection)}/challenge_requests" %>
+
diff --git a/app/views/challenge_signups/_show_offers.html.erb b/app/views/challenge_signups/_show_offers.html.erb
new file mode 100755
index 0000000..0f8e965
--- /dev/null
+++ b/app/views/challenge_signups/_show_offers.html.erb
@@ -0,0 +1,24 @@
+<% unless challenge_signup.offers.empty? %>
+
+
+
+
+ <%= ts("Offers") %>
+
+ <% if @challenge_assignment %>
+ <%= ts("by %{offerer}", :offerer => @challenge_assignment.offer_byline) %>
+ <% user = @challenge_assignment.try(:offer_signup).try(:pseud).try(:user) %>
+ <% if user && @challenge.user_allowed_to_see_assignments?(current_user) %>
+ <%= mailto_link user, :subject => "[#{h(@collection.title)}] Message from Collection Maintainer" %>
+ <% end %>
+ <% end %>
+
+
+
+ <% challenge_signup.offers.each_with_index do |offer, index| %>
+ <%= render "prompts/prompt_blurb", :prompt => offer, :index => index %>
+ <% end %>
+
+
+
+<% end %>
diff --git a/app/views/challenge_signups/_show_requests.html.erb b/app/views/challenge_signups/_show_requests.html.erb
new file mode 100755
index 0000000..f7dcfb2
--- /dev/null
+++ b/app/views/challenge_signups/_show_requests.html.erb
@@ -0,0 +1,24 @@
+<% unless challenge_signup.requests.empty? %>
+
+
+
+
+ <%= ts("Requests") %>
+
+ <% if @challenge_assignment %>
+ <%= ts("by %{requester}", :requester => @challenge_assignment.request_byline) %>
+ <% user = @challenge_assignment.try(:request_signup).try(:pseud).try(:user) %>
+ <% if user && @challenge.user_allowed_to_see_assignments?(current_user) %>
+ <%= mailto_link user, :subject => "[#{h(@collection.title)}] Message from Collection Maintainer" %>
+ <% end %>
+ <% end %>
+
+
+
+ <% challenge_signup.requests.each_with_index do |request, index| %>
+ <%= render "prompts/prompt_blurb", :prompt => request, :index => index %>
+ <% end %>
+
+
+
+<% end %>
diff --git a/app/views/challenge_signups/_signup_controls.html.erb b/app/views/challenge_signups/_signup_controls.html.erb
new file mode 100755
index 0000000..4f7f282
--- /dev/null
+++ b/app/views/challenge_signups/_signup_controls.html.erb
@@ -0,0 +1,48 @@
+<% # requires 'challenge_signup' local %>
+<% # to make the code more readable: %>
+<% collection = challenge_signup.collection %>
+<% challenge = collection.challenge %>
+<% user = challenge_signup.pseud.user %>
+
+<% if challenge.signup_open || (!challenge.signup_open && collection.user_is_maintainer?(current_user)) || collection.challenge_type == "PromptMeme" %>
+
" role="menu">
+ <% # The edit and delete links shouldn't show on the challenge index for a prompt meme unless you are owner or mod, but should show on user index %>
+ <% unless (controller.action_name == "index" && @user.nil? && collection.challenge_type == "PromptMeme" && (current_user.nil? || (current_user && !collection.user_is_maintainer?(current_user)))) %>
+ <% # The edit link should show up only for the user who actually signed up while signups are open or for the owner or mod once signups are closed or always on a prompt meme %>
+ <% if (challenge.signup_open && user == current_user) || (!challenge.signup_open && collection.user_is_maintainer?(current_user)) || (collection.challenge_type == "PromptMeme" && collection.user_is_maintainer?(current_user)) %>
+
+ <% # Do not warn if matching has not run; OTHERWISE warn that matching will need to be rerun %>
+ <% if collection.potential_matches.count == 0 %>
+ <%= link_to(ts('Edit Sign-up'), edit_collection_signup_path(collection, challenge_signup)) %>
+ <% else %>
+ <%= link_to(ts('Edit Sign-up'), edit_collection_signup_path(collection, challenge_signup),
+ data: {confirm: ts("Note: Potential matches will need to be regenerated after your edits.")}) %>
+ <% end %>
+
+ <% end %>
+
+ <% # The add prompt link should show up if signups are open and the current user owns the signup and is on the signup show page for a prompt meme and they haven't hit the max number of prompts %>
+ <% if (challenge.signup_open && user == current_user && controller.action_name == "show" && collection.challenge_type == "PromptMeme" && challenge_signup.requests.count < challenge.requests_num_allowed) %>
+
+ <% end %>
+
+ <% # The delete link should show up if signups are open and the current user owns the prompt, OR if the current user is a maintainer, AND only if the signup has more than the minimum number of prompts %>
+ <% if (challenge.signup_open && user == current_user) || (collection.user_is_maintainer?(current_user)) %>
+
+ <% # Warn about prompt loss if matching has not run; OTHERWISE warn that matching will need to be rerun %>
+ <% if collection.potential_matches.count == 0 %>
+ <%= link_to ts("Delete Sign-up"),
+ confirm_delete_collection_signup_path(collection, challenge_signup),
+ data: {confirm: ts("Are you sure? All prompts in this sign-up will be lost.")} %>
+ <% else %>
+ <%= link_to ts("Delete Sign-up"),
+ confirm_delete_collection_signup_path(collection, challenge_signup),
+ data: {confirm: ts("Are you sure? Potential matches will need to be regenerated afterwards.")} %>
+ <% end %>
+
+ <% end %>
+ <% end %>
+
+<% end %>
diff --git a/app/views/challenge_signups/_signup_form.html.erb b/app/views/challenge_signups/_signup_form.html.erb
new file mode 100755
index 0000000..616c55a
--- /dev/null
+++ b/app/views/challenge_signups/_signup_form.html.erb
@@ -0,0 +1,83 @@
+<% types = @challenge.class::PROMPT_TYPES.select {|t| @challenge.allowed(t) > @challenge.required(t)} %>
+<% unless types.empty? %>
+
+<% end %>
+
+<%= form_for([@collection, @challenge_signup], :url => (@challenge_signup.new_record? ? collection_signups_path(@collection) : collection_signup_path(@collection))) do |signup_form| %>
+ <% if !@current_user&.preference&.allow_gifts? %>
+
+ <% end %>
+ <% if @collection.challenge_type == "GiftExchange" %>
+
<%= ts("Challenge maintainers will have access to the email address associated with your AO3 account for the purpose of communicating with you about the challenge.") %>
+ <% end %>
+
<%= ts("* Required information") %>
+ <% unless @challenge_signup.errors.empty? %>
+
+
<%= ts("There were some problems with this submission. Please correct the mistakes below.") %>
<%= ts("Are you sure you want to delete the sign-up for %{person}?", :person => @challenge_signup.pseud.byline).html_safe %>
+ <% if @collection.potential_matches.count == 0 %>
+ <%= ts("All prompts in this sign-up will be lost.") %>
+ <% else %>
+ <%= ts("Potential matches will need to be regenerated afterwards.") %>
+ <% end %>
+
+
+ <%= f.submit ts("Yes, Delete Sign-up") %>
+
+<% end %>
+
diff --git a/app/views/challenge_signups/edit.html.erb b/app/views/challenge_signups/edit.html.erb
new file mode 100755
index 0000000..accd35e
--- /dev/null
+++ b/app/views/challenge_signups/edit.html.erb
@@ -0,0 +1,10 @@
+
+
<%= ts("Edit Sign-up for %{title}", :title => @collection.title) %>
+
+
+
+
+
+
+<%= render "signup_form" %>
+
diff --git a/app/views/challenge_signups/index.html.erb b/app/views/challenge_signups/index.html.erb
new file mode 100755
index 0000000..24326e5
--- /dev/null
+++ b/app/views/challenge_signups/index.html.erb
@@ -0,0 +1,30 @@
+<% if @collection && @collection.challenge %>
+ <% # signups for a given challenge %>
+
+ <% # render differently based on the challenge %>
+ <%= render "challenge/#{challenge_class_name(@collection)}/challenge_signups" %>
+
+<% elsif @user %>
+ <% # view in user dashboard %>
+
+
+
<%= ts("Challenge Sign-ups for %{user}", :user => @user.login) %>
+
+
+
+
+
+
+
+ <% @challenge_signups.each do |challenge_signup| %>
+
+
+<% end %>
diff --git a/app/views/challenge_signups/index.xls.erb b/app/views/challenge_signups/index.xls.erb
new file mode 100755
index 0000000..baadc3e
--- /dev/null
+++ b/app/views/challenge_signups/index.xls.erb
@@ -0,0 +1,7 @@
+
+<% if @collection && @collection.challenge %>
+ <% @template_format = 'html' %>
+ <%= render :partial => "challenge/#{challenge_class_name(@collection)}/challenge_signups" %>
+<% end %>
\ No newline at end of file
diff --git a/app/views/challenge_signups/new.html.erb b/app/views/challenge_signups/new.html.erb
new file mode 100755
index 0000000..24ccf03
--- /dev/null
+++ b/app/views/challenge_signups/new.html.erb
@@ -0,0 +1,10 @@
+
+
<%= ts("Sign Up for %{title}", :title => @collection.title) %>
+
+
+
+
+
+
+<%= render "signup_form" %>
+
diff --git a/app/views/challenge_signups/show.html.erb b/app/views/challenge_signups/show.html.erb
new file mode 100755
index 0000000..c2bc778
--- /dev/null
+++ b/app/views/challenge_signups/show.html.erb
@@ -0,0 +1,13 @@
+<% # Only gift exchanges link to this page, but you can manually enter collections/name/signups/# to view for prompt memes %>
+
+
<%= ts("Sign-up for %{person}", :person => @challenge_signup.pseud.byline) %>
<%= ts("Sign-up Summary for %{collection}", :collection => @collection.title) %>
+
+
+ <%= ts('The summary is being generated. Please try again in a few minutes.') %>
+
+<% end %>
diff --git a/app/views/chapters/_chapter.html.erb b/app/views/chapters/_chapter.html.erb
new file mode 100755
index 0000000..f4f31e3
--- /dev/null
+++ b/app/views/chapters/_chapter.html.erb
@@ -0,0 +1,85 @@
+
+<% chapter_id = "chapter-#{chapter.position.to_s}" %>
+
+ <% unless chapter.posted? || @preview_mode %>
+
+ <%= ts("This chapter is a draft and hasn't been posted yet!") %>
+
+ <% end %>
+ <% if chapter.user_has_creator_invite?(current_user) %>
+
+ <%= ts("You've been invited to become a co-creator of this chapter. To accept or reject the request, visit your %{creator_requests_page}.",
+ creator_requests_page: link_to(ts("Co-Creator Requests page"), user_creatorships_path(current_user))).html_safe %>
+
+ <% end %>
+
+ <% if logged_in? && is_author_of?(@work) && !@preview_mode%>
+ <%= render "chapters/chapter_management", chapter: chapter, work: @work %>
+ <% end %>
+
+<%# Both Entire Work and Chapter by Chapter mode write to and read from this cache. Only content needed for both modes should be included here. %>
+<% cache_if(!@preview_mode, "#{@work.cache_key}/#{chapter.cache_key}-show-v7", skip_digest: true) do %>
+
+
+ <%= f.text_area :content, id: "content", class: "mce-editor observe_textlength large" %>
+ <%= live_validation_for_field("content",
+ maximum_length: ArchiveConfig.CONTENT_MAX_DISPLAYED,
+ minimum_length: ArchiveConfig.CONTENT_MIN,
+ tooLongMessage: ts("We salute your ambition! But sadly each chapter of a work must be less than %{max} characters long.", max: ArchiveConfig.CONTENT_MAX_DISPLAYED.to_s),
+ tooShortMessage: ts("Brevity is the soul of wit, but your content does have to be at least %{min} characters long.", min: ArchiveConfig.CONTENT_MIN.to_s),
+ failureMessage: ts("Brevity is the soul of wit, but your content does have to be at least %{min} characters long.", min: ArchiveConfig.CONTENT_MIN.to_s))
+ %>
+ <%= generate_countdown_html("content", ArchiveConfig.CONTENT_MAX_DISPLAYED) %>
+
<%= ts("Are you sure you want to delete %{chapter_number} of %{work_title}? This will delete all comments on the chapter as well and cannot be undone!", :chapter_number => @chapter.chapter_header, :work_title => @work.title).html_safe %>
+
+
+ <%= f.submit ts("Yes, Delete Chapter") %>
+
+<% end %>
+
diff --git a/app/views/chapters/edit.html.erb b/app/views/chapters/edit.html.erb
new file mode 100755
index 0000000..7c4850c
--- /dev/null
+++ b/app/views/chapters/edit.html.erb
@@ -0,0 +1,28 @@
+
+
+
<%= ts("Edit Chapter") %>
+
+<%= error_messages_for :chapter %>
+
+
+
+
+
+
+ <%= link_to ts("Delete Chapter"), [:confirm_delete, @work, @chapter],
+ data: { confirm: ts("Are you sure you want to delete this chapter? This will delete all comments on the chapter as well and cannot be undone!") } %>
+
+ <%= button_to ts("Remove Me As Chapter Co-Creator"),
+ remove_user_creatorship_chapter_path(@chapter),
+ confirm: ts("Are you sure you want to remove yourself as a co-creator of this chapter?"),
+ method: :patch %>
+
+ <%= link_to ts("Remove Me As Chapter Co-Creator"),
+ remove_user_creatorship_chapter_path(chapter),
+ data: { confirm: ts("Are you sure you want to remove yourself as a co-creator of this chapter?") },
+ method: :patch %>
+
+
+<% end %>
diff --git a/app/views/chapters/show.html.erb b/app/views/chapters/show.html.erb
new file mode 100755
index 0000000..3bdbc74
--- /dev/null
+++ b/app/views/chapters/show.html.erb
@@ -0,0 +1,69 @@
+
+<% if @work.unrevealed? %>
+ <%= render "works/work_unrevealed_notice" %>
+<% end %>
+<% if @work.user_has_creator_invite?(current_user) %>
+
+ <%= ts("You've been invited to become a co-creator of this work. To accept or reject the request, visit your %{creator_requests_page}.",
+ creator_requests_page: link_to(ts("Co-Creator Requests page"), user_creatorships_path(current_user))).html_safe %>
+
+<% end %>
+
+
+
+<% if policy(@work).show_admin_options? %>
+ <%= render "admin/admin_options", item: @work %>
+<% end %>
+
+
+<% if !@work.unrevealed? || logged_in_as_admin? || can_access_unrevealed_work(@work, current_user) %>
+
+
+ <% end %>
+ <%
+ # when in the works controller, such as for a non-multi-chapter work, form_for does not set work_id
+ # so the create action of the collection_items controller fails to find the targetted work
+ # hence the klutzy workaround with setting the _id manually in the url, below
+ %>
+ <%= form_for([item, @collection_item], :url => { (item.class.name.foreign_key).to_sym => item.id, :controller => 'collection_items', :action => :create}) do |form| %>
+
+
+ <% if collection_item.item %>
+ <% # An item can be in multiple collections. Including the collection name in the id prevents multiple items from having the same id. %>
+ <% id = "blurb_#{collection_item.item_type}_#{collection_item.item_id}_#{collection_item.collection.name}" %>
+
+ <% statuses = %i[unreviewed_by_collection unreviewed_by_user rejected_by_collection rejected_by_user approved] %>
+ <% known_params = statuses.include?(params[:status]&.to_sym) %>
+ <% statuses.each do |status| %>
+ <% if @user && status == :unreviewed_by_user %>
+ <% # Default and fallback page for user. If no known_params are present, this is current. %>
+
<%= span_if_current t(".navigation.#{status}"), user_collection_items_path(@user, status: status), params[:status]&.to_sym == status %>
+ <% elsif @collection && status == :unreviewed_by_collection %>
+ <% # Default and fallback page for collection. If no known_params are present, this is current. %>
+
+<% end %>
\ No newline at end of file
diff --git a/app/views/collection_profile/show.html.erb b/app/views/collection_profile/show.html.erb
new file mode 100755
index 0000000..730805a
--- /dev/null
+++ b/app/views/collection_profile/show.html.erb
@@ -0,0 +1,112 @@
+
<%= link_to ts('Delete Collection'),
+ confirm_delete_collection_path(@collection),
+ data: {confirm: ts('Are you sure you want to delete this collection? All collection settings will be lost. (Works will not be deleted.)')} %>
+
+ <% end %>
+
+ <% end %>
+
+
+
diff --git a/app/views/collections/_bookmarks_module.html.erb b/app/views/collections/_bookmarks_module.html.erb
new file mode 100755
index 0000000..c85c52f
--- /dev/null
+++ b/app/views/collections/_bookmarks_module.html.erb
@@ -0,0 +1,17 @@
+
+<% end %>
\ No newline at end of file
diff --git a/app/views/collections/_challenge_list_top_navigation.html.erb b/app/views/collections/_challenge_list_top_navigation.html.erb
new file mode 100755
index 0000000..5e499ce
--- /dev/null
+++ b/app/views/collections/_challenge_list_top_navigation.html.erb
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/app/views/collections/_collection_blurb.html.erb b/app/views/collections/_collection_blurb.html.erb
new file mode 100755
index 0000000..4895305
--- /dev/null
+++ b/app/views/collections/_collection_blurb.html.erb
@@ -0,0 +1,87 @@
+
+
+ <% logged_in = (logged_in_as_admin? || logged_in?) ? "logged-in" : "logged-out" %>
+ <% # remember to update collection_sweeper if you change this key %>
+ <% cache("collection-blurb-#{logged_in}-#{collection.id}-v4", expires_in: ArchiveConfig.MINUTES_UNTIL_COLLECTION_BLURBS_EXPIRE.minutes, skip_digest: true) do %>
+
+ <% end %>
+ <% if collection.challenge && collection.challenge.signup_open && logged_in? %>
+ <%= render "challenge/#{challenge_class_name(collection)}/challenge_navigation_user", :collection => collection %>
+ <% end %>
+ <% if !collection.user_is_owner?(current_user) && collection.moderated? && !(collection.challenge && collection.challenge.signup_open) %>
+
+ <% if (participant = collection.get_participants_for_user(current_user).first) %>
+ <%= link_to ts("Leave"), collection_participant_path(collection, participant),
+ data: {confirm: ts('Are you certain you want to leave this collection?')},
+ :method => :delete %>
+ <% end %>
+
+ <% end %>
+
+
diff --git a/app/views/collections/_collection_form_delete.html.erb b/app/views/collections/_collection_form_delete.html.erb
new file mode 100755
index 0000000..eb9c2b8
--- /dev/null
+++ b/app/views/collections/_collection_form_delete.html.erb
@@ -0,0 +1,6 @@
+
+ <% if @collection.user_is_owner?(current_user) %>
+
<%= link_to ts("Delete Collection"), confirm_delete_collection_path(@collection),
+ data: {confirm: ts('Are you sure you want to delete this collection? All collection settings will be lost. (Works will not be deleted.)')} %>
+ <% end %>
+
diff --git a/app/views/collections/_collection_profile_navigation.html.erb b/app/views/collections/_collection_profile_navigation.html.erb
new file mode 100755
index 0000000..8beca1b
--- /dev/null
+++ b/app/views/collections/_collection_profile_navigation.html.erb
@@ -0,0 +1,14 @@
+
<%= ts("Navigation") %>
+<% if show_collection_profile_navigation(@collection, section) %>
+
+ <%= ts("%{minimum} to %{maximum} characters (A-Z, a-z, _, 0-9 only), no spaces, cannot begin or end with underscore (_)",
+ minimum: ArchiveConfig.TITLE_MIN,
+ maximum: ArchiveConfig.TITLE_MAX) %>
+
<%= ts("As a challenge owner, you may have access to challenge participants' email addresses.") %>
+
<%= ts("Use of those email addresses for any purpose other than running the challenge will lead to the termination of your account.") %>
+
+
+
+
+ <% end %>
+
+ <% @collection.build_collection_profile unless @collection.collection_profile %>
+ <%= collection_form.fields_for :collection_profile do |profile_form| %>
+
+
+
<%= allowed_html_instructions %>
+
+ <%= ts("Tip: if this is a subcollection or challenge, you don't need to repeat yourself: fields left blank will copy from your parent collection.") %>
+
<%= link_to ts("Post to Collection"), new_collection_work_path(@collection) %>
+ <% end %>
+ <% end %>
+
+ <% if @collection.challenge # add custom navigation for the specific challenge if there is one %>
+ <%= render "challenge/#{challenge_class_name(@collection)}/challenge_navigation_user" %>
+ <% end %>
+
+ <% unless @collection.challenge || @collection.user_is_owner?(current_user) %>
+ <% if (@participant ||= @collection.get_participants_for_user(current_user).first) %>
+
<%= link_to ts("Leave"), collection_participant_path(@collection, @participant),
+ data: { confirm: ts("Are you certain you want to leave this collection?") },
+ method: :delete %>
<%= ts("Are you sure you want to delete the collection %{collection_name}? All collection settings will be lost. (Works will not be deleted.)", :collection_name => @collection.title).html_safe %>
+
+ <%= f.submit ts("Yes, Delete Collection") %>
+
+<% end %>
+
diff --git a/app/views/collections/edit.html.erb b/app/views/collections/edit.html.erb
new file mode 100755
index 0000000..68b3220
--- /dev/null
+++ b/app/views/collections/edit.html.erb
@@ -0,0 +1,13 @@
+
+
+ <% if @collections.empty? %>
+ <%= ts("Sorry, there were no collections found.") %>
+ <% else %>
+ <%= search_header @collections, @query, ts("Collection") %>
+ <% end %>
+
+
+
+
+
<%= ts("Navigation") %>
+
+ <% # Collections and Open Challenges links unless a logged in user viewing own collections page %>
+ <% unless logged_in? && @user && @user == current_user %>
+
+ <% end %>
+ <% if logged_in? %>
+ <% # Logged in user on own collections index gets links for user Collections and Manage Collection Items %>
+ <% if @user && @user == current_user %>
+
+ <% end %>
+ <% # Logged in collection maintainer on own collection gets link for New Subcollection and all other logged in users get New Collection link %>
+ <% if @collection && !@collection.parent && @collection.user_is_maintainer?(current_user) %>
+
+ <% end %>
+ <% end %>
+ <% if @sort_and_filter %>
+ <% # Filters button for narrow screens jumps to filters when JavaScript is disabled and opens filters when JavaScript is enabled %>
+
<%= ts("Only registered users can post, so you don't need to worry about spam: you can leave your collection unmoderated. You can always reject works afterwards if there") %> <%= ts("is") %> <%= ts("a mistaken submission.") %>
+
<%= ts("The best way to set up a regular challenge (e.g., an annual challenge like Yuletide,
+ or a weekly one like sga_flashfic) is to create a closed parent collection and then add a new, open, subcollection for each challenge.") %>
+
+
+ <%= ts("If you limit membership for each challenge (e.g., for a gift exchange), people can sign
+ up for each subcollection separately. If you just want the whole thing moderated, have people sign up as members of the parent collection; they'll then be able to post in every subcollection.") %>
+
+
diff --git a/app/views/comments/_comment_actions.html.erb b/app/views/comments/_comment_actions.html.erb
new file mode 100755
index 0000000..aecccb0
--- /dev/null
+++ b/app/views/comments/_comment_actions.html.erb
@@ -0,0 +1,76 @@
+
<%= ts("Comment Actions") %>
+
+
style="display:none;"<% end %>>
+ <% # The effect is "Frozen" replaces "Reply." We can't do that in the helper
+ # method for the reply link because that would prevent "Frozen" from
+ # appearing on the Unreviewed Comments page for works with moderated
+ # comments. %>
+ <% if comment.iced? %>
+
<%= frozen_comment_indicator %>
+ <% end %>
+ <% if can_reply_to_comment?(comment) %>
+
+
+<% if can_reply_to_comment?(comment) %>
+ <% # This is where the reply-to box will be added when "Reply" is hit, if the user has JavaScript. %>
+ <% # If not, we will render the comment form if this is the comment we are replying to. %>
+ <% if focused_on_comment(comment) %>
+
\ No newline at end of file
diff --git a/app/views/comments/_single_comment.html.erb b/app/views/comments/_single_comment.html.erb
new file mode 100755
index 0000000..c87a82a
--- /dev/null
+++ b/app/views/comments/_single_comment.html.erb
@@ -0,0 +1,78 @@
+
+
+ <% end %>
+ <%= render "comments/comment_actions", comment: single_comment %>
+ <% end %>
+ <% end %>
+
+
diff --git a/app/views/comments/add_comment_reply.js.erb b/app/views/comments/add_comment_reply.js.erb
new file mode 100755
index 0000000..e705f81
--- /dev/null
+++ b/app/views/comments/add_comment_reply.js.erb
@@ -0,0 +1,5 @@
+/* hide redundant navigation */
+$j("#navigation_for_comment_<%= "#{@commentable.id}" %>").hide();
+/* render the form into the spot for it on the page */
+$j("#add_comment_reply_placeholder_<%= "#{@commentable.id}" %>").html("<%= escape_javascript(render :partial => "comments/comment_form", :locals => {:comment => @comment, :commentable => @commentable, :button_name => ts("Comment")}) %>");
+$j("#add_comment_reply_placeholder_<%= "#{@commentable.id}" %>").show();
diff --git a/app/views/comments/cancel_comment_delete.js.erb b/app/views/comments/cancel_comment_delete.js.erb
new file mode 100755
index 0000000..f753499
--- /dev/null
+++ b/app/views/comments/cancel_comment_delete.js.erb
@@ -0,0 +1,5 @@
+/* hide, then remove the delete confirmation form */
+$j("#delete_comment_placeholder_<%= "#{@comment.id}" %>").slideUp();
+$j("#delete_comment_placeholder_<%= "#{@comment.id}" %>").html("");
+/* unhide the navigation */
+$j("#navigation_for_comment_<%= "#{@comment.id}" %>").show();
diff --git a/app/views/comments/cancel_comment_edit.js.erb b/app/views/comments/cancel_comment_edit.js.erb
new file mode 100755
index 0000000..9a1d6a0
--- /dev/null
+++ b/app/views/comments/cancel_comment_edit.js.erb
@@ -0,0 +1,11 @@
+<% comment_field_id = "#comment_#{@comment.id}" %>
+/* roll up the comment form */
+$j("<%= comment_field_id %>").slideUp();
+/* render the comment again
+ must use replaceWith() rather than html() because the single_comment partial already contains the comment li
+ and we would end up with li within li for the same comment
+ */
+$j("<%= comment_field_id %>").replaceWith("<%= escape_javascript(render :partial => "comments/single_comment",
+ :locals => {:single_comment => @comment, :commentable => @comment.commentable}) %>");
+/* roll down the comment */
+$j("<%= comment_field_id %>").slideDown();
diff --git a/app/views/comments/cancel_comment_reply.js.erb b/app/views/comments/cancel_comment_reply.js.erb
new file mode 100755
index 0000000..37e68b0
--- /dev/null
+++ b/app/views/comments/cancel_comment_reply.js.erb
@@ -0,0 +1,5 @@
+/* unhide the original navigation */
+$j("#navigation_for_comment_<%= "#{@commentable.id}" %>").show();
+/* get rid of the comment form */
+$j("#add_comment_reply_placeholder_<%= "#{@commentable.id}" %>").slideUp();
+$j("#add_comment_reply_placeholder_<%= "#{@commentable.id}" %>").html("");
diff --git a/app/views/comments/delete_comment.js.erb b/app/views/comments/delete_comment.js.erb
new file mode 100755
index 0000000..1efdaff
--- /dev/null
+++ b/app/views/comments/delete_comment.js.erb
@@ -0,0 +1,5 @@
+/* hide redundant navigation */
+$j("#navigation_for_comment_<%= "#{@comment.id}" %>").hide();
+/* render the delete confirmation form into its spot, then make it visible */
+$j("#delete_comment_placeholder_<%= "#{@comment.id}" %>").html("<%= escape_javascript(render :partial => "comments/confirm_delete", :locals => {:comment => @comment}) %>");
+$j("#delete_comment_placeholder_<%= "#{@comment.id}" %>").slideDown();
diff --git a/app/views/comments/edit.html.erb b/app/views/comments/edit.html.erb
new file mode 100755
index 0000000..0bd7dd7
--- /dev/null
+++ b/app/views/comments/edit.html.erb
@@ -0,0 +1,21 @@
+
+
+
diff --git a/app/views/comments/edit.js.erb b/app/views/comments/edit.js.erb
new file mode 100755
index 0000000..7551ccd
--- /dev/null
+++ b/app/views/comments/edit.js.erb
@@ -0,0 +1,8 @@
+<% comment_field_id = "#comment_#{@comment.id}" %>
+/* roll up the original comment */
+$j("<%= comment_field_id %>").slideUp();
+/* render the comment form in its place */
+$j("<%= comment_field_id %>").html("<%= escape_javascript(render partial: "comments/comment_form",
+ locals: { comment: @comment, commentable: @comment.commentable, button_name: t(".update"), show_errors: true }) %>");
+/* roll down the comment form */
+$j("<%= comment_field_id %>").slideDown();
diff --git a/app/views/comments/hide_comments.js.erb b/app/views/comments/hide_comments.js.erb
new file mode 100755
index 0000000..2d14bd4
--- /dev/null
+++ b/app/views/comments/hide_comments.js.erb
@@ -0,0 +1,7 @@
+/* replace the top "Hide Comments" link on works with "Comments" */
+$j("#show_comments_link_top").html("<%= escape_javascript(show_hide_comments_link(@commentable)) %>");
+/* replace the "Hide Comments (#) link with "Comments (#)" */
+$j("#show_comments_link").html("<%= escape_javascript(show_hide_comments_link(@commentable, :show_count => true)) %>");
+/* roll up, then remove the comments */
+$j("#comments_placeholder").slideUp();
+$j("#comments_placeholder").html("");
diff --git a/app/views/comments/index.html.erb b/app/views/comments/index.html.erb
new file mode 100755
index 0000000..93b0294
--- /dev/null
+++ b/app/views/comments/index.html.erb
@@ -0,0 +1,10 @@
+
+
+<% end %>
+
+
+
+
diff --git a/app/views/comments/update.js.erb b/app/views/comments/update.js.erb
new file mode 100755
index 0000000..59440ba
--- /dev/null
+++ b/app/views/comments/update.js.erb
@@ -0,0 +1,11 @@
+<% comment_field_id = "#comment_#{@comment.id}" %>
+/* roll up the comment form */
+$j("<%= comment_field_id %>").slideUp();
+/* render the updated comment
+ must use replaceWith() rather than html() because the single_comment partial already contains the comment li
+ and we would end up with li within li for the same comment
+ */
+$j("<%= comment_field_id %>").replaceWith("<%= escape_javascript(render :partial => "comments/single_comment",
+ :locals => {:single_comment => @comment, :commentable => @comment.commentable}) %>");
+/* roll down the updated comment */
+$j("<%= comment_field_id %>").slideDown();
diff --git a/app/views/creatorships/show.html.erb b/app/views/creatorships/show.html.erb
new file mode 100755
index 0000000..e513b36
--- /dev/null
+++ b/app/views/creatorships/show.html.erb
@@ -0,0 +1,63 @@
+
+
diff --git a/app/views/downloads/_download_chapter.html.erb b/app/views/downloads/_download_chapter.html.erb
new file mode 100755
index 0000000..55ae51d
--- /dev/null
+++ b/app/views/downloads/_download_chapter.html.erb
@@ -0,0 +1,39 @@
+<% @chapter = chapter if defined?(chapter) %>
+
+
+
<%= @chapter.chapter_title.html_safe %>
+ <% if (!@chapter.pseuds.blank? && (@chapter.pseuds != @work.pseuds) && (!@work.anonymous?)) %>
+ <%# only display byline if different from the main byline %>
+
+ <% if @chapters.size > 1 %>
+ <% for chapter in @chapters %>
+ <%= render "downloads/download_chapter", chapter: chapter %>
+ <% end %>
+ <%# Use elsif so we don't error when there are no chapters. %>
+ <% elsif @chapters.size == 1 %>
+
<%= @work.title %>
+
+ <%= raw sanitize_field(@chapters.first, :content) %>
+
You do not have permission to access the requested file on this server.
diff --git a/app/views/errors/404.html.erb b/app/views/errors/404.html.erb
new file mode 100755
index 0000000..1571cad
--- /dev/null
+++ b/app/views/errors/404.html.erb
@@ -0,0 +1,8 @@
+
Error 404
+
The page you were looking for doesn't exist.
+<% if defined? @message %>
+
+ <%= @message %>
+
+<% end %>
+
You may have mistyped the address or the page may have been deleted.
diff --git a/app/views/errors/422.html.erb b/app/views/errors/422.html.erb
new file mode 100755
index 0000000..1a12605
--- /dev/null
+++ b/app/views/errors/422.html.erb
@@ -0,0 +1,3 @@
+
Error 422
+
The change you wanted was rejected.
+
Maybe you tried to change something you didn't have access to.
diff --git a/app/views/errors/500.html.erb b/app/views/errors/500.html.erb
new file mode 100755
index 0000000..669f044
--- /dev/null
+++ b/app/views/errors/500.html.erb
@@ -0,0 +1,3 @@
+
Error 500
+
We're sorry, but something went wrong.
+
If you are receiving this error repeatedly, please <%= link_to ts('contact Support'), new_feedback_report_path %>. In the form, please include a link to the page you're trying to reach and how you're trying to reach this page.
diff --git a/app/views/errors/auth_error.html.erb b/app/views/errors/auth_error.html.erb
new file mode 100755
index 0000000..47956f3
--- /dev/null
+++ b/app/views/errors/auth_error.html.erb
@@ -0,0 +1,2 @@
+
Session Expired
+
Your current session has expired and we can't authenticate your request. Try logging in again, refreshing the page, or clearing your cache if you continue to experience problems.
diff --git a/app/views/errors/timeout_error.html.erb b/app/views/errors/timeout_error.html.erb
new file mode 100755
index 0000000..81df9fb
--- /dev/null
+++ b/app/views/errors/timeout_error.html.erb
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/app/views/external_authors/_external_author_navigation.html.erb b/app/views/external_authors/_external_author_navigation.html.erb
new file mode 100755
index 0000000..bc4fa6d
--- /dev/null
+++ b/app/views/external_authors/_external_author_navigation.html.erb
@@ -0,0 +1,16 @@
+
diff --git a/app/views/external_authors/claim.html.erb b/app/views/external_authors/claim.html.erb
new file mode 100755
index 0000000..6a8d00f
--- /dev/null
+++ b/app/views/external_authors/claim.html.erb
@@ -0,0 +1,121 @@
+
<%= ts("Claiming Your Imported Works") %>
+
+
+ <%= ts("An archive including some of your work(s) has been moved to the Archive of Our Own. Please let us know what you'd
+ like us to do with them. If you do nothing, the work(s) will remain attached to the archivist account.") %>
+
+
+
+ <%= ts("If you would like to edit the work(s), or handle different work(s) separately, please claim them first.
+ You can then use the Edit Works feature on your personal archive home page to orphan, delete, or edit them either
+ in groups or one at a time. (You will be able to delete the account if you wish after editing.)") %>
+
+
+
+ <%= ts("If you want to take another look at your work(s) before you make your decision, but don’t have an Archive
+ account,") %>
+ <%= link_to ts("contact Open Doors"), "https://opendoors.transformativeworks.org/en/contact-open-doors/" %>
+ <%= ts("with your pseud and the titles of your work(s) and we will send you a copy.") %>
+
+
+
+
<%= ts("Claim All Works") %>
+
+ <% if logged_in? %>
+
+ <%= ts("Claim your works with your logged-in account. If you would like to claim
+ these works under a different account (or create a new account to claim them with), log out and reload this
+ page first.") %>
+
+
<%= button_to ts("Add these works to my currently-logged-in account"), complete_claim_path(invitation_token: @invitation.token) %>
+ <% else %>
+
+ <%= ts("We invite you to join our beta and create an account! The works will automatically be added
+ to your account and you will have full control over them.") %>
+
+
+ <%= ts("If you already have an archive account, log in and reload this page, and we'll assign you the works.") %>
+ <%= button_to ts("Sign me up and give me my works!"), signup_path(invitation_token: @invitation.token), method: :get %>
+
+ <% end %>
+
+
+
+
<%= ts("My Imported Works") %>
+
+ <% @external_author.works.each do |work| %>
+ <%= render "works/work_blurb", work: work %>
+ <% end %>
+
+
+ <% if logged_in? %>
+ <%= button_to ts("Add these works to my currently-logged-in account"), complete_claim_path(invitation_token: @invitation.token) %>
+ <% else %>
+ <%= button_to ts("Sign me up and give me my works!"), signup_path(invitation_token: @invitation.token), method: :get %>
+ <% end %>
+
+
+
+
<%= ts("Other Options") %>
+<%= form_for @external_author do |f| %>
+
+
+
<%= ts("Actions")%>
+
+
+ <%= radio_button_tag "imported_stories", "nothing" %>
+ <%= label_tag "imported_stories_nothing", ts("Leave my works in the care of the archivist.") %>
+
+
+
+
+ <%= hidden_field_tag :invitation_token, @invitation.token %>
+ <%= radio_button_tag "imported_stories", "orphan" %>
+ <%= label_tag "imported_stories_orphan", ts("Orphan my works and take my email address off them, but keep my name.") %>
+ <%= link_to ts("View orphaning FAQ"), archive_faq_path("17") %>
+
+
+ <%= check_box_tag "remove_pseud" %>
+ <%= label_tag "remove_pseud", ts("Assign my works to the AO3 orphan_account, removing both my name and email address.") %>
+
+
+
+
+ <%= radio_button_tag "imported_stories", "delete" %>
+ <%= label_tag "imported_stories_delete", ts("Please remove my works from the archive entirely.") %>
+
+
+ <%= f.check_box :do_not_import %>
+ <%= f.label :do_not_import, ts("From now on, do not import works with this email address.") %>
+
+
+
<%= ts("Notifications") %>
+
+
+ <%= f.check_box :do_not_email %>
+ <%= f.label :do_not_email, ts("Do not email me in the future when works are imported with this email address.") %>
+
+
+
+
+ <%= ts("Important: If you tick \"Do not email me in future\" but do not tick \"Do not import works\", works may be imported without you being told. If that's not what you want, turn off importing as well as emails.") %>
+
+ <%= submit_fieldset(f) %>
+
+
+<% end %>
+
+
+
<%= ts("Wait, wait, go over this again?") %>
+
+
<%= ts("If you do nothing, the work(s) will remain attached to the archivist account.") %>
+ <% if logged_in? %>
+
<%= ts("You can claim your works.") %>
+ <% else %>
+
<%= ts("You can create an account and claim your works (if you already have an account, log in to claim them)") %>
+ <% end %>
+
<%= ts("You can also choose to delete or orphan your works.") %>
+
<%= ts("Orphaning keeps them in the archive so future fans can still enjoy them, but with your contact information and/or your name removed.") %>
+
<%= ts("You can also tell us not to email you or import works with this email address in the future.") %>
+
+
diff --git a/app/views/external_authors/edit.html.erb b/app/views/external_authors/edit.html.erb
new file mode 100755
index 0000000..c6ec48e
--- /dev/null
+++ b/app/views/external_authors/edit.html.erb
@@ -0,0 +1,6 @@
+
+
+ <% if policy(external_work).show_admin_options? %>
+ <%= render "admin/admin_options", item: external_work %>
+ <% end %>
+
+
diff --git a/app/views/external_works/_work_module.html.erb b/app/views/external_works/_work_module.html.erb
new file mode 100755
index 0000000..2d195fb
--- /dev/null
+++ b/app/views/external_works/_work_module.html.erb
@@ -0,0 +1,57 @@
+
+
+
+
+ <% # so bookmarks go to the external work page, not directly to the external link %>
+ <% if external_work ||= @bookmarkable %>
+ <%= link_to bookmarkable.title, bookmarkable %>
+ <% else %>
+ <%= link_to external_work.title, external_work.url %>
+ <% end %>
+ <%= ts("by") %>
+ <%= byline(external_work) %>
+
+<% elsif !@user %>
+ <%= render 'gifts/gift_search' %>
+<% end %>
+
+
+<% if @works.respond_to?(:total_pages) %>
+ <%= will_paginate @works %>
+<% end %>
+
+
+
<%= ts("List of Gifts") %>
+
+ <% for work in @works %>
+ <%= render 'gifts/gift_blurb', work: work, gift: work %>
+ <% end %>
+
+
+<% if @works.respond_to?(:total_pages) %>
+ <%= will_paginate @works %>
+<% end %>
\ No newline at end of file
diff --git a/app/views/hit_count/_include.html.erb b/app/views/hit_count/_include.html.erb
new file mode 100755
index 0000000..3307e12
--- /dev/null
+++ b/app/views/hit_count/_include.html.erb
@@ -0,0 +1,19 @@
+<% unless RedisHitCounter.prevent_hits?(@work) || current_user&.is_author_of?(@work) %>
+ <% content_for :footer_js do %>
+
+ <% end %>
+<% end %>
diff --git a/app/views/home/_content.html.erb b/app/views/home/_content.html.erb
new file mode 100755
index 0000000..be899fd
--- /dev/null
+++ b/app/views/home/_content.html.erb
@@ -0,0 +1,21 @@
+<%# IMPORTANT: Also update current_tos_version in application_controller %>
+<%# To ensure proper formatting, this must always be rendered inside an element
+ with the userstuff class. The userstuff element must be inside an element with
+ the classes "docs system". %>
+
We're pretty chill here at Sunset. We allow most forms of content, with the following exceptions:
+1. Explicit RPF (real person fiction) about real people, alive or not, under the age of 18 at present or in the work;
+and 2. any visual artistic depictions of characters under the age of 14 that appear hyperrealistic or could be mistaken as real. If you're not sure, you can email me at femslashclub@proton.me.
+
+
+The only other content rule is that all works posted to Sunset must be F/F, NB/F, or NB/NB. As of October 2025, we allow gender/cisswaps of M/F, M/M, or M/NB to the archive categories, but for those works, please use the freeform tag "Canon Gender Swap". You can also optionally specify what the swap is, e.g. Canon Gender Swap - M/M to F/F, M/M/F to F/F/NB, or whatever applies.
+
+
+Thank you!
+
+
+Oh yeah, important stuff here: SUNSET IS AN 18+ ARCHIVE. Ao3 may be 13+; we are not. PLEASE do not join or attempt to join after September 2025 if you are under 18.
+
+
+
+
diff --git a/app/views/home/_dmca.html.erb b/app/views/home/_dmca.html.erb
new file mode 100755
index 0000000..978d039
--- /dev/null
+++ b/app/views/home/_dmca.html.erb
@@ -0,0 +1,188 @@
+
<%= ts("Sunset is a project dedicated to hosting femslash (F/F, F/NB, NB/NB) fanworks.") %>
+
+
+
+ <% if !AdminSetting.current.invite_from_queue_enabled? && AdminSetting.current.creation_requires_invite? %>
+
+ <%= ts("Joining the Archive currently requires an invitation; however, we
+ are not accepting new invitation requests at this time. Please check
+ the %{news} for more information, or if you have already requested
+ an invitation, you can %{status}.",
+ news: link_to("\"Invitations\" tag on Sunset News",
+ admin_posts_path(tag: 143)),
+ status: link_to("check your position on the waiting list",
+ status_invite_requests_path)
+ ).html_safe %>
+
+ <% end %>
+
+
<%= ts("With an %{short_name} account, you can:", short_name: ArchiveConfig.APP_SHORT_NAME) %>
+
+
<%= ts("Share your own fanworks") %>
+
<%= ts("Get notified when your favorite works, series, or users update") %>
+
<%= ts("Participate in challenges") %>
+
<%= ts("Keep track of works you've visited and works you want to check out later") %>
diff --git a/app/views/home/_privacy.html.erb b/app/views/home/_privacy.html.erb
new file mode 100755
index 0000000..066d28a
--- /dev/null
+++ b/app/views/home/_privacy.html.erb
@@ -0,0 +1,236 @@
+<%# IMPORTANT: Also update current_tos_version in application_controller %>
+<%# To ensure proper formatting, this must always be rendered inside an element
+ with the userstuff class. The userstuff element must be inside an element with
+ the classes "docs system". %>
+
diff --git a/app/views/home/_tos.html.erb b/app/views/home/_tos.html.erb
new file mode 100755
index 0000000..20d6c9d
--- /dev/null
+++ b/app/views/home/_tos.html.erb
@@ -0,0 +1,26 @@
+<%# IMPORTANT: Also update current_tos_version in application_controller %>
+<%# To ensure proper formatting, this must always be rendered inside an element
+ with the userstuff class. The userstuff element must be inside an element with
+ the classes "docs system". %>
+
In addition to our content policy, which is explained more on its own page but outlined briefly here:
+
+
+
1. NO RPF (real person fiction) about real people, alive or not, under the age of 18 at present or in the work;
+and 2. any visual artistic depictions of characters under the age of 14 that appear hyperrealistic or could be mistaken as real. If you're not sure, you can email me at symphonicstarship(@)gmail.com.
+
+2. All works posted to Sunset must be femslash. We use a loose definition of femslash that includes unaligned and feminine aligned nonbinary folks here; we leave the decision up to you when it comes to the inclusion of these characters, as long as you'd feel comfortable including them in the term femslash.
+
+
+
+ 3. All users as of September 28, 2025 must be 18+.
+
+
We also ask...
+
1. You do not harass others. Ever. Over ships, over characters, over beliefs. If you have any issues with a member, email me at femslashclub@proton.me.
+2. You understand that we will not take down a work for anything other than works written with intentional spite or bigotry, or works that break our content policy.
+3. You wait to request an invitation until you are eighteen years of age or older.
+4. You do not use Sunset to distribute links to illegal content (including pirated content).
+5. No AI generated stuff, even in user icons.
+6. Please do NOT post Ko-fi or Patreon or any sort of financial donation links here. I can get in legal trouble and then no more femslash archive.
+
+ Sunset is a website established in 2025 dedicated to hosting F/F, NB/F and NB/NB fanworks. It holds pro-freedom of fiction, queer and trans inclusive, anti-bigotry and generally compassionate ideals. We're here to have fun and love fictional characters. Sunset is run by one person, Agnes the Alien, who dreamed this up in zher bedroom. Please be nice to zher!
+ <% end %>
+Or, try a new author...
+ <% if @random_user %>
+ <%= link_to @random_user.login, user_path(@random_user) %>
+<% else %>
+ No authors available
+<% end %>
+ <%= render 'fandoms' %>
+
+ <% end %>
+
+ <% if @homepage.admin_posts.present? %>
+ <%= render 'news_module', admin_posts: @homepage.admin_posts %>
+ <% end %>
+
+ <% if logged_in? && @homepage.readings.present? %>
+
+ <% end %>
+
+ <% if logged_in? && @homepage.inbox_comments.present? %>
+ <%= render 'inbox_module', inbox_comments: @homepage.inbox_comments %>
+ <% end %>
+
+
diff --git a/app/views/home/lost_cookie.html.erb b/app/views/home/lost_cookie.html.erb
new file mode 100755
index 0000000..13c5c41
--- /dev/null
+++ b/app/views/home/lost_cookie.html.erb
@@ -0,0 +1,5 @@
+
<%= ts('Forced Logout') %>
+
+
+
<%= ts("You have been automatically logged out to protect your privacy. Please make sure that your browser is set to accept cookies from archiveofourown.org and delete any existing Archive cookies. If you continue to have problems logging in, please try using the Log In page. If you still have problems after trying that, please contact Support.".html_safe) %>
+
diff --git a/app/views/home/privacy.html.erb b/app/views/home/privacy.html.erb
new file mode 100755
index 0000000..bb9a551
--- /dev/null
+++ b/app/views/home/privacy.html.erb
@@ -0,0 +1,20 @@
+
+
<%= t(".page_heading") %>
+
+
+
+
<%= t("a11y.navigation") %>
+<%= render "tos_navigation" %>
+
+
+
+
<%= t(".page_content_landmark") %>
+
+ I don't collect any data besides IP addresses and emails, and I will NEVER upload your information elsewhere. The OTWArchive software collects IP addresses and emails by design.
If you would like your work data to be removed, please email me. However, any tags you use will remain on the archive.
diff --git a/app/views/home/site_pages.html.erb b/app/views/home/site_pages.html.erb
new file mode 100755
index 0000000..12b66b9
--- /dev/null
+++ b/app/views/home/site_pages.html.erb
@@ -0,0 +1,41 @@
+
+
+
Archive Pages
+
+
+ The following links will take you to all of the possible pages in the archive.
+ These are only links! You will still need the appropriate permissions to actually see the pages!
+ If you have trouble, try logging in as the test user or test admin accounts. (Those will only work on webdev or test, FYI.)
+
+
+
+ If any of these pages do not work at all (ie if you get a 404 or a notice that no action matches), please notify a senior
+ coder, since that probably means it is an unused route that should get cleaned up to improve performance.
+
+
+
+ Where an existing object is required to see a form (for instance when looking at a nested form),
+ we will attempt to make the link using one that you (that is, the user you are currently logged in as) can access.
+ If that doesn't work, you may need to manually edit the URL to put in the ID of an object
+ that you can see, or make a new object.
+
+ There were problems linking to the following pages, either because we couldn't find an object to use or because it's a
+ weird kind of page. You can manually fix and paste these routes in to see those pages.
+
\ No newline at end of file
diff --git a/app/views/home/tos.html.erb b/app/views/home/tos.html.erb
new file mode 100755
index 0000000..0145d1c
--- /dev/null
+++ b/app/views/home/tos.html.erb
@@ -0,0 +1,25 @@
+<%# IMPORTANT: Also update current_tos_version in application_controller %>
+<%# To ensure proper formatting, this must always be rendered inside an element
+ with the userstuff class. The userstuff element must be inside an element with
+ the classes "docs system". %>
+
A reminder of our content policy:
+We're pretty chill here at Sunset. We allow most forms of content, with the following exceptions:
+1. Explicitly sexual RPF (real person fiction) about real people, alive or not, under the age of 18 at present or in the work;
+and 2. any visual artistic depictions of characters under the age of 14 that appear hyperrealistic or could be mistaken as real. If you're not sure, you can email me at femslashclub@proton.me.
+
+
+The only other content rule is that all works posted to Sunset must be F/F, NB/F, or NB/NB. As of October 2025, we allow gender/cisswaps of M/F, M/M, or M/NB to the archive categories, but for those works, please use the freeform tag "Canon Gender Swap". Thanks to the community for feedback on this. You can also optionally specify what the swap is, e.g. Canon Gender Swap - M/M to F/F, M/M/F to F/F/NB, or whatever applies.
+
+Thank you!
+
+
+
We also ask...
+
1. You do not harass others. Ever. Over ships, over characters, over beliefs. If you have any issues with a member, email me at femslashclub@proton.me.
+2. You understand that we will not take down a work for anything other than works written with the intention to harass another Sunset user, or works that break our content policy.
+3. You wait to request an invitation until you are eighteen years of age or older.
+4. You do not use Sunset to distribute links to illegal content (including pirated content).
+5. No AI generated stuff, even in user icons.
+6. Please do NOT post Ko-fi or Patreon or any sort of financial donation links here. I can get in legal trouble and then no more archive.
+
Why does the Archive of Our Own (AO3) have a goal of maximum inclusiveness of fanwork content?
+
AO3 was founded partly in response to a growing trend of fanworks being removed from websites that had previously allowed them. Commercial entities have frequently permitted fanworks in the early stage of their existence in order to expand their userbase, only to later prohibit certain types of fanworks from being shared on their platform. This pattern has been observed numerous times throughout fandom history. We want AO3 to be a non-commercial space where creators are able to permanently preserve and freely share as many of their fanworks as possible.
+
What does it mean for AO3's Terms of Service (TOS) to be governed by the laws of New York? What if I am a resident of a different state or country?
+
AO3 operates under the jurisdiction of Manhattan, New York in the United States. As such, the interactions between AO3 and its users, as well as the definitions in the TOS, will comply with U.S. legislative interpretations. If you reside in a different state or country, it is your responsibility – not AO3's – to know about and follow the laws in your local jurisdiction. For example, if certain content on AO3 is restricted under your local laws, it is not AO3's duty to delete that content for you; instead, it is your responsibility to avoid accessing that content.
+
What is an "implied warranty of merchantability"?
+
An implied warranty of merchantability is a legal agreement between a seller and a buyer that goods will be reasonably fit for the general purpose for which they are sold. For example, if you buy a toaster, it should be able to toast bread. It doesn't have to be perfect, but it should function as a typical toaster would.
+
This warranty is governed in the United States by the Uniform Commercial Code (UCC), which allows sellers to "disclaim" it. Disclaiming a warranty cancels the promise, which means the buyer takes on the risk that the product may not work as expected. By disclaiming this warranty about AO3, we are saying that you cannot hold us legally responsible if AO3 does not work as expected (in other words, you can't sue us).
+
What is an "implied warranty of fitness for a particular purpose"?
+
An implied warranty of fitness for a particular purpose is a legal agreement that exists when a buyer relies upon the seller to provide goods to fit a specific request. For example, if you ask a salesperson for a pair of boots to hike in the snow, and they sell you boots based on that request, they are making a promise (warranty) that those boots will be good for hiking in the snow. The seller has to know two things: 1) the buyer has a specific need, and 2) the buyer is relying on their recommendation. If both of those conditions are met, this warranty applies.
+
This warranty is governed in the United States by the Uniform Commercial Code (UCC). Like the implied warranty of merchantability, sellers can disclaim it, thereby shifting the risk back to the buyer. By disclaiming this warranty about AO3, we are saying that you cannot hold us legally responsible if AO3 does not suit the purpose you wanted to use it for, even if we know why you wanted to use it and you are relying on our recommendation to use it. For example, if you want to use AO3 to post your fanworks, but you find that you don't like AO3's posting interface and prefer to use another fanwork site, you can't sue us because our site doesn't meet your needs.
+
Why are you talking about buyers and sellers? Are you selling things?
+
No, AO3 is and always will be free to use. We don't sell anything. We don't sell products to you, and we also don't sell advertisement space or user data to third parties. AO3 is run by the Organization for Transformative Works (OTW), a non-profit which is funded by donations, not sales or advertisements. However, U.S. law regarding services and contracts generally assumes that one party is a buyer and one party is a seller, even if the service being "sold" is free (as in our case). We use this standard language to make sure that we aren't promising you something that we can't provide.
+
What counts as an official statement from the OTW?
+
Official statements are communications made by volunteers while they are fulfilling their formal volunteering responsibilities. These can include news posts and comments from official accounts.
+
Comments from official accounts are only official OTW statements when the volunteer is both acting in their official OTW role and providing information about the OTW or any of its projects or policies. For example, a news post moderator using an official account to reply to a question about a news post is acting in an official capacity; however, a volunteer who is using an official account to reply to a comment on a "Five Things" post about them (which is about that volunteer's personal opinions) is not acting in an official capacity.
+
What do you mean by "a worldwide, royalty-free, nonexclusive license"?
+
This means that we can make the content you post on AO3 available to other people who use AO3, without paying you. We will never charge for access to AO3 or otherwise sell your content. You can also put your content on other sites if you want, or remove it from AO3 if you no longer want it here.
This refers to how your work is displayed on the site, not how it is written, drawn, or otherwise created. For example, we may display portions of your content on some pages of the site, such as by showing your work's summary and tags in search results. We may also make changes to the formatting or display of your content in order to adapt to the technical requirements of different networks or devices, or to improve accessibility. For example, we may automatically convert HTML tags to our standard forms (for example, changing <bold> html tags to <strong>) or allow you to use nonstandard fonts and formatting while providing an alternate format for accessibility.
+
What are the rules for removing and retaining content on various parts of AO3 that are not fully controlled by the original poster?
The creator(s) of the work may freeze or delete comments on their work at any time and for any reason. They may also enable comment moderation and choose to leave comments unreviewed, or mark guest comments as spam.
+
Registered users can delete their own comments at any time. Guest users cannot delete their own comments.
+
The Policy & Abuse committee may delete comments in situations where a violation of the Terms of Service has occurred.
+
+
+
Comments on an official AO3 or OTW post: Comments on official AO3 or OTW posts may be frozen, hidden, marked as spam, or deleted in accordance with the OTW News Post Moderation Policy.
+
+
How can I check if my country is under a comprehensive trade embargo by the US?
+
The U.S. Office of Foreign Assets Control (OFAC) provides up-to-date details regarding all current embargoes (including but by no means limited to comprehensive trade embargoes) on their website. Princeton University has a list of countries that are comprehensively sanctioned by OFAC.
+
Under what circumstances would you suspend an account for an invalid email address?
+
If we need to communicate with you to resolve an Abuse report, we will email you at the address associated with your AO3 account. If you do not check your email, or if you cannot receive emails because your email address is inaccurate or invalid, then we will have to resolve the complaint without your input. A sufficient number of sustained Abuse reports that fail to be delivered ("bounce") or do not receive a response could lead to permanent suspension.
+
If our emails to you repeatedly bounce, then we may suspend your account because we need to be able to communicate with you if necessary. In that situation, you can log in and get your account reinstated by associating it with a working email address. Then, if necessary, you can deal with whatever problem led to the Abuse report in the first place.
+
Having an invalid email address will not necessarily cause you to be suspended. However, if you violated the Terms of Service in other ways, those violations will not be excused just because you didn't receive our emails. It is your responsibility to ensure your email address is accurate and messages can be delivered to it. Repeated violations of the Terms of Service may result in a temporary suspension or a permanent ban, regardless of whether or not you were able to receive our emails warning you about the violations.
+
If we send a routine email about general site policies and it bounces, that will not lead to account suspension, but whatever policies we announce will still apply to all account owners. We will only suspend accounts with invalid email addresses when individual Policy & Abuse–related communications bounce.
+
Why did you license the Terms of Service under the Creative Commons Attribution 4.0 International License? What does that mean?
+
Creative Commons licenses allow people to use others' works under certain predefined conditions. The Creative Commons Attribution 4.0 International License (CC BY 4.0) permits you to use material from our Terms of Service (including the Content Policy and Privacy Policy) for any purpose, as long as you meet all of the following requirements:
+
+
Give appropriate credit: You need to say that the material was created by us and provide a link to the CC license.
+
Indicate if changes were made: If you changed something from our original material, say so when crediting us.
+
Don't suggest we endorse your use: While you're free to adapt our material for your own use, you can't claim that we reviewed or authorized your specific work.
+
Don't impose additional restrictions: You may not apply legal terms or technological measures that restrict others from doing anything that this license permits.
+
+
An example of appropriate attribution would be: "This work uses material from AO3's Terms of Service, which was released under a CC BY 4.0 license."
+
Does AO3's Terms of Service use material or inspiration from documents by other people?
+
Material in AO3's Terms of Service has been drawn from imeem and NearlyFreeSpeech.NET.
Why are children under the age of 13 not permitted to have an AO3 account or upload content?
+
In the United States, the Children's Online Privacy Protection Act (COPPA) governs the collection of personal information, including usernames and email addresses, from children under 13. Because the Organization for Transformative Works is a non-profit and does not sell any data, COPPA does not apply to AO3; however, we adhere to the same restriction as a matter of policy.
+
Why are children under the age of sixteen (16) who are residents/citizens of certain countries not permitted to have an account or upload content?
+
In some countries in Europe, the General Data Protection Regulation (GDPR) governs the collection and processing of personal data, including email addresses and IP addresses, as well as certain uses of cookies. Other countries may have similar data privacy laws. The age at which someone can consent to the collection of personal data without written permission from their parent or legal guardian may be higher than 13, depending on their country of residence. We do not want to store the type of detailed personal information about users that would be required to verify and accept such permission. Therefore, children who wish to create an account or upload content to AO3 must meet their country's minimum age requirements to legally consent to personal data collection without written permission.
+
What happens to an account or its content if the account owner is reported for violating the Age Policy?
+
If the Policy & Abuse committee determines that a violation of the Age Policy has occurred, the account will be suspended and content on the account may be removed. If the content is not removed, the suspended user or their parent or guardian can contact the Policy & Abuse committee to request deletion of the content associated with the account.
+
If you were previously suspended because of your age and you are now old enough to have an account, you may contact the Policy & Abuse committee to regain access to your account.
+
Why are children in the EU not allowed to ask their parent or legal guardian to upload content for them? Does this apply to children elsewhere, such as in the UK or the EEA?
+
AO3 adheres to the GDPR's requirements for handling the content and information of children within the GDPR's jurisdiction. Accordingly, the parents or legal guardians of children within the European Union are not allowed to upload their child's content under their own (the parent or guardian's) account.
+
This restriction does not apply to children in the United Kingdom or the European Economic Area (unless the child is also a resident or citizen of an EU country).
How do I report a violation of the Terms of Service (TOS)?
+
Please contact the Policy & Abuse committee. You must provide your email address and a direct link to the specific content you want to report. In the subject and description of your report, please briefly describe the content you are reporting, explain why you are reporting it, and include any additional links or other details that could help us investigate the violation. If you don't provide this information in your report, we may not be able to investigate or act upon your complaint.
+
If you are reporting a specific comment or comment thread, you can get the direct link by selecting the "Thread" button on the comment and copying the URL of that page.
+
If you are reporting multiple works or comments posted by the same user, please compile all relevant links and other information into a single report, rather than reporting each link individually.
+
If you wish to report content posted by multiple unrelated users (such as two different works by different people in the same fandom), please submit separate reports for each user.
+
What language should I select when submitting a report?
+
In general, if you are not comfortable with reading and writing in English, you should use the language you are most fluent in. If you are fluent in English and you are reporting something written in another language, you can either select English or choose the language of the content you are reporting.
+
Why aren't Digital Millennium Copyright Act (DMCA) notices covered by the Abuse Policy? What's the difference between a DMCA notice and an Abuse report?
+
A DMCA takedown notice is a legal mechanism that a copyright owner can use to request removal of their copyrighted material from a hosting site. The takedown notice must meet certain legal requirements. For example, you must declare under penalty of perjury that you are the copyright owner or are legally authorized to act on their behalf. As such, DMCA notices are assessed by the Legal committee, and valid notices may be forwarded to the user who posted the work. We reserve the right to make public all DMCA notices that we receive, though some information may be redacted for privacy. DMCA notices are not subject to the procedures described in the Abuse Policy, nor are they governed by the Policy & Abuse confidentiality policy.
+
Abuse reports, on the other hand, are held to a very high standard of confidentiality. They can be about any violation of the Terms of Service, including copyright infringement, harassment, commercial promotion, etc. Abuse reports are evaluated by the Policy & Abuse committee, who will never publish the details of a report or reveal any information about who submitted it.
+
Please do not submit an Abuse report if you intend to file a DMCA notice. Submitting both an Abuse report and a DMCA notice about the same content may delay the processing of both requests.
+
Can I submit an Abuse report even if I don't have an AO3 account?
+
Yes, but your report will be screened by our automated spam filters. If your report is rejected as spam, try using a different email address or removing extra links from your report description. Please also enter a valid email address so that we can contact you to request any additional evidence.
+
Reports from registered users are not subject to the spam filter, so long as you are logged in and the email address entered into the form is the one associated with your AO3 account (this will be prefilled for you).
+
Will I receive notification that my complaint was resolved? How long will it take?
+
We will make a reasonable attempt to accommodate a complainant's reply preferences. However, we may choose not to reply to complaints at our discretion, particularly if it is a non-urgent matter or if the complainant submits frequent, duplicate, or baseless reports.
+
Whether or not we reply to the complainant, our volunteers do evaluate all reports and act upon them as necessary. Complaints will generally be prioritized based on urgency, but because the Policy & Abuse committee is a small team of volunteers, we cannot guarantee any particular timeframe for the resolution of a complaint.
+
Do you monitor content on AO3 for violations of the Terms of Service?
+
No. We do not prescreen content on AO3, nor do we review content that has not been reported. If you believe you have encountered content that violates our Terms of Service, you will need to submit an Abuse report.
+
Please refrain from seeking out works that are in violation of the Terms of Service for the sole purpose of reporting or mass-reporting them. We investigate every report we receive, so submitting duplicate reports will only serve to delay the processing of the original complaint.
+
I'm not sure whether something is against the rules. What happens if I report something that doesn't violate the Terms of Service?
+
You can submit a report even if you aren't sure something is a violation. If the Policy & Abuse committee determines that what you reported is not in violation, you will receive an email letting you know and explaining why that type of content is allowed on AO3.
+
If you repeatedly submit reports about similar non-violating material, your subsequent reports may not receive a reply.
+
How do you determine whether content needs to be removed?
+
Abuse reports are reviewed by humans, not algorithms or bots. After a report is submitted, the Policy & Abuse committee reviews the reported content and independently evaluates whether or not it complies with our Terms of Service. If we determine that the content is in violation of the Terms of Service, only then do we take action to resolve the matter.
+
Would a work be removed if enough people reported it? What if someone repeatedly or intentionally submits complaints about something that doesn't violate the Terms of Service?
+
Multiple complaints about the same issue increases the time it takes for us to investigate, but we don't make decisions based on how many times something is reported. Mass reporting will not change whether or not the reported content is in violation of the Terms of Service, nor will it cause an issue to be addressed faster.
+
You don't need to worry that anyone's works will be taken down due to a baseless report or a mass-reporting campaign. We will only contact the subject of a complaint if they have in fact violated the Terms of Service. If we receive a report about something that isn't a violation, we will let the reporter know and close the report. If someone attempts to abuse our reporting system, such as by intentionally submitting baseless complaints, we may consider that harassment and take appropriate action.
+
What if the content I reported was deleted or edited before the Policy & Abuse team can investigate?
+
We appreciate good-faith attempts to resolve disputes, and in most such cases will close the complaint with no further action. However, we reserve the right to consider individual circumstances, including whether the poster has engaged in a pattern of such conduct. In such cases, if we verify that the original content violated the TOS, we may still decide to warn or suspend the original poster.
+
The instructions on the Policy & Abuse form say to include the username of the person I'm reporting. What if I want to report a guest comment or an anonymous or orphaned work?
+
You can mention in your report that the content was posted anonymously or orphaned. Content that violates the Terms of Service will be removed regardless of whether the original poster's name is publicly displayed. Penalties may be applied to the accounts of users responsible for posting violating content.
+
If I submit an Abuse report, will the subject be told who reported them?
+
In general, no. Abuse reports are kept strictly confidential. We do our best not to reveal any information about the identity of a complainant (such as a username), though in some circumstances it may be impossible to keep the source of the report completely anonymous. We do not ever disclose information that would be sufficient to identify a person in the physical world, such as an email address or legal name. For more information, please refer to the Policy & Abuse Confidentiality Policy.
+
Complaints can be submitted anonymously, but an email address is required. If you do not provide a valid email address and the complaint requires follow-up, we may be unable to take action.
+
Will I be informed if an Abuse report is filed about me or my work?
+
In general, the Policy & Abuse committee will only contact the subject of a complaint if there appears to be a violation of the Terms of Service, or if the team needs more information to resolve the issue.
+
If someone files an invalid report against you, we will inform them that their complaint has not been upheld. You will not be told about the complaint and no action will be taken against your account or content.
All users are responsible for following the Terms of Service, and all users have the right to file a complaint if they witness someone violating the Terms of Service.
+
The Policy & Abuse committee will not uphold a complaint without investigating and confirming that a violation of the Terms of Service has occurred. This means that if the Policy & Abuse committee contacts you, their investigation has independently concluded that you have violated the Terms of Service.
+
What happens if a report is made about me and the Policy & Abuse committee determines that the complaint is valid?
+
The Policy & Abuse committee will send an email to the address associated with your AO3 account. That email will explain what the violating content or behavior is, where it occurred, and (if applicable) what you need to do to resolve it. If you cannot locate the email notifying you of your violation (please check your spam folder), you can submit an Abuse report and we will resend the original email to you.
+
What happens when a complaint is upheld depends greatly on the severity of the violation. For very minor issues, such as tag miscategorizations, we will simply ask you to fix the problem on your work. Violations of other portions of the Content Policy may result in content being temporarily hidden or permanently deleted, and/or a penalty being applied to your account.
+
Will I be notified if my work is hidden or deleted, or if I get suspended?
+
If your work is hidden, you'll receive an automatic email informing you that it's been hidden and providing you with a direct link to the work. You must be logged in to your account to access the hidden work.
+
If your work is deleted, you'll receive an automatic email informing you that it's been deleted. A copy of the work will be attached to that email.
+
In addition, the Policy & Abuse committee will also separately email you to explain why your work was hidden or deleted. If you've received an automatic "your work was hidden" or "your work was deleted" notification without also receiving an explanatory email from the Policy & Abuse committee, please check your spam folder. If you still can't find it, please contact the Policy & Abuse committee to let us know that you didn't receive our explanation.
+
If you are suspended, the email from the Policy & Abuse committee will inform you why you are suspended and how long your suspension will last. You will be further reminded automatically by the site if you attempt to post, edit, or delete content during the suspension period. If you were asked to edit or delete violating content on your account, you must wait until your suspension has ended in order to do so. You will not receive an email notification when your suspension is over.
+
I received an email from the Policy & Abuse committee, but I don't agree with or understand their decision. How do I appeal?
+
If you were contacted about something you did that is in violation of the AO3 Terms of Service, you can appeal the decision or request clarification by replying directly to the original email. If you cannot locate the email notifying you of your violation (please check your spam folder), you can submit an Abuse report, but please do not submit multiple appeals before receiving a response to the first one. Submitting multiple appeals will delay the processing of your appeal, because it creates more paperwork for us to handle before we can respond to you.
+
If you submitted a complaint and were told that the subject of your complaint is not in violation of the Terms of Service, then you can appeal the decision by replying directly to that email.
+
At least one Policy & Abuse administrator who was not previously involved with the original investigation will evaluate all information provided in an appeal to determine whether or not the appeal should be granted. Additional reviewers may be involved at the discretion of the Policy & Abuse committee. Please note that it may take some time to process your appeal and inform you of the result. The Policy & Abuse committee's decisions are final.
+
Are there any appeals that you will not grant?
+
In general, we do not email users or respond to complaints until after we have investigated the reported content and determined whether or not a violation of the Terms of Service has occurred. In order to appeal successfully, you will need to provide evidence demonstrating that our original decision was incorrect or did not adhere to the Terms of Service. If you only tell us "I want to appeal", then you haven't provided enough information for us to overturn our original ruling.
+
If you are notified that your content was removed in order to resolve a lawsuit or mitigate other liability, an appeal is unlikely to succeed. These cases are extremely rare, and are thoroughly discussed and reviewed at multiple levels before any action is taken.
+
If I disagree with the Policy & Abuse committee's decision, can I appeal to someone else, like the Support committee or the OTW Board?
+
No. If your appeal to the Policy & Abuse committee was rejected, you cannot appeal to any other committee. The Policy & Abuse committee is the final authority on TOS violations. To protect user privacy, other committees such as the Support committee and the OTW Board of Directors do not have information about Policy & Abuse cases. If you file an appeal with a different committee, they will simply forward the complaint to the Policy & Abuse committee or tell you to contact Policy & Abuse directly.
+
I was given a deadline to edit or delete my work, and I have done so. What happens next?
+
After the deadline, a member of the Policy & Abuse committee will review your work. The following situations may occur:
+
+
If you have already deleted your work, then there is no further action you need to take.
+
If you sufficiently edited your work, we will verify your edits. If your work was hidden, we will unhide the work. You will not receive a notification when your work is unhidden.
+
If you didn't sufficiently edit your work, and the work was not already hidden, we may hide the work. Please review the original email you received from us and contact us promptly if you don't understand what further edits you need to make. Failure to make all required edits may result in the deletion of your work.
+
If your work was hidden and you did not edit or delete all violating content, we will delete the work. You will automatically be emailed a copy of the work.
+
+
As a general rule, we will not review content in advance of any stated deadlines. While we strive to review content promptly after the deadline, the Policy & Abuse committee is composed entirely of volunteers. We therefore cannot guarantee a timeframe in which your content will be reviewed.
+
I was given a deadline to edit or delete my work, but I'm not going to make it in time. Can I have an extension?
+
If you need an extension on a deadline, please reply to the email the Policy & Abuse committee sent you. The Policy & Abuse committee will accommodate requests for extensions, within reason. Note that any hidden content will usually remain hidden during any extensions.
+
My work was removed by the Policy & Abuse committee. Can I repost it?
+
Works that have been hidden or deleted due to violations of the Terms of Service may not be reposted as-is. If you don't understand why your work was removed, do not re-upload the work. Instead, contact the Policy & Abuse committee to request clarification.
+
If you know what the original problem with the work was, you may be able to edit the work and upload a non-violating version. However, if you have not sufficiently edited your work and the new work is still in violation, you may be reported again. Violating the Terms of Service in a manner similar to a previous violation is grounds for suspension.
+
What if the violating content was posted years ago?
+
Content that is in violation of the Terms of Service may be reported and removed regardless of how long it has been since it was posted. The user responsible for uploading the violating content may be warned or suspended, subject to the discretion of the Policy & Abuse committee.
+
What do the different penalties mean?
+
Penalties are issued by the Policy & Abuse committee as a result of violating the Terms of Service. They are defined as follows:
+
+
+
Warning: A warning is a formal notification to a user who has posted content that violates the TOS. Warnings do not affect the function of the user's account, but they are a permanent administrative record and a reminder not to repeat the behavior. At the discretion of the Policy & Abuse committee, a warning may be issued as a result of minor or unintentional violations of the TOS. A user who has previously received a warning and who violates the TOS again, especially in the same or a similar manner, is likely to incur a suspension.
+
+
+
Temporary Suspension: A temporary suspension is a time-limited restriction on the uploading of new content and creation of new accounts. During this time, the suspended user cannot upload new content, nor can they edit or delete content uploaded prior to the suspension. The duration of each suspension is subject to the discretion of the Policy & Abuse committee. A user who has previously been suspended and who violates the TOS again, especially in the same or a similar manner, may incur a longer temporary suspension or a permanent ban.
+
+
+
Permanent Suspension: A permanent suspension is a permanent ban on the uploading of new content and creation of new accounts. Permanently suspended users retain the right to remove, but not edit, content uploaded prior to their suspension.
+
+
+
What happens to a user's works or other content when they are temporarily suspended?
+
In general, non-violating content is not removed from the site when a user is suspended. If a user is temporarily suspended, then they will be able to edit or delete their content after their suspension is over. Otherwise, suspended users who wish to delete their fanworks may contact the Policy & Abuse committee to have this done for them.
+
A user who has been temporarily suspended is not permitted to upload new content while they are suspended. Any new content uploaded to AO3 during the suspension would automatically be in violation. Such content may be removed and/or the alternate account(s) may be suspended. The duration of the original suspension may also be extended at the discretion of the Policy & Abuse committee.
+
After the suspension has ended, the user will have full access to their account(s) again.
+
What happens to a user's works or other content when they are permanently suspended/banned?
+
Permanent suspension doesn't delete someone's account or content. In general, any content that doesn't violate the Content Policy or other parts of the Terms of Service will remain on the account unless the user deletes it themselves or requests that the content be deleted by the Policy & Abuse committee.
+
A user who has been permanently suspended is not permitted to create a new account or upload new content to AO3. Any new content or accounts created by a permanently suspended user would automatically be in violation. The content may be removed and/or the alternate account(s) may be permanently suspended.
+
What sort of things would lead to each type of penalty?
+
It's impossible to define everything in advance. We are most concerned with people who are actively and deliberately hostile to the community. Small and honest mistakes are likely to result in warnings, especially on a first offense. More serious or deliberate violations of the Terms of Service may justify temporary suspension on a first or subsequent offense. Repeated and/or particularly severe TOS violations may result in permanent suspension.
+
What constrains the Policy & Abuse committee's discretion?
+
We are committed to building a community that welcomes anyone with a willingness to learn the rules while also safeguarding against those who intentionally violate them. Our discretion is aimed at that objective. We strive to handle all Abuse reports consistently, no matter which volunteer is doing the work. Procedurally, appeals undergo review by multiple Policy & Abuse committee members, and we require consensus or majority vote for major decisions. Our internal decision-making processes are designed to build in checks on individual discretion without trying to resolve every possible situation in advance.
+
What happens if someone who's a friend of someone on the Policy & Abuse committee is involved in a complaint?
+
We expect the members of the Policy & Abuse committee to behave professionally, even though the Organization for Transformative Works is an all-volunteer organization. We take the responsibilities of serving on the Policy & Abuse committee seriously, and a member of the team with a personal relationship to any party in a complaint is expected to recuse themselves entirely from the case, and, of course, to maintain our standards of confidentiality at all times. Failure to do so is grounds for dismissal from the Policy & Abuse committee.
+
AO3 is still being actively developed. How will the Abuse Policy apply to planned future features?
+
Since new features may be added at any time, the Abuse Policy only applies to active features. As we develop features, we will strive to be transparent and communicate with users about our policies as much as possible.
The Policy & Abuse committee will investigate each report and independently determine whether a violation of the Terms of Service has occurred. Penalties may be applied to the accounts of users responsible for posting violating content. For more information, please refer to the Abuse Policy and Procedures FAQ.
One common challenge was that platforms would unilaterally remove content without warning. The decisions were often shaped by the platform owner's preferences or determined by what was more friendly to advertisers. This meant that platforms frequently banned explicit sexual content.
+
Such prohibitions were often disproportionately enforced against minorities and marginalized groups. For example, fanworks featuring LGBT+ characters were (and still are) more likely to be reported and removed for having "sexual content". This is due to societal bias: a story featuring a romantic relationship between two women would be considered more sexual or adult than one with an equivalent relationship between a man and a woman. Such works would either be required to have a higher rating or were often removed entirely.
+
Biased enforcement of content rules has been shown to occur even when the purpose of the rule is to push back against discrimination. For example, rules intended to reduce racial hate speech on social media often end up being disproportionately enforced against racial minorities speaking out against racism or discussing their own lived experiences. To date, the problem of unbiased content moderation hasn't been solved by any large internet site.
+
Challenges such as these led AO3 to adopt a policy that welcomes all forms of fictional, transformative fanwork content. Our mission is to host transformative fanworks without making judgments based on morality or personal preferences. If it's a fictional fanwork that is legal to post in the United States, then it is welcome on AO3. This approach is intended to reduce the risk that content will be removed as a result of cultural or personal bias against marginalized communities.
+
We recognize that there are works on AO3 that contain or depict bigotry and objectionable content. However, we are dedicated to safeguarding all fanworks, without consideration of any work's individual merits or how we personally feel about it. We will not remove works from AO3 simply because someone believes they are offensive or objectionable.
+
All users who would like to avoid encountering particular types of content are recommended to make use of our filters and muting features.
+
Why does AO3 allow fanworks about things that are illegal in real life?
+
AO3's Terms of Service are designed to comply with United States law. It is legal in the U.S. to create and share fictional content about murder, theft, assault, or other such crimes. It is also generally legal in the U.S. to create and share fictional content about topics such as child sexual abuse, rape, incest, or bestiality. AO3 allows users to post and access fiction about all of these topics.
+
In accordance with U.S. law, AO3 prohibits Child Sexual Abuse Material (sexually explicit photorealistic images of real children). However, stories and non-photorealistic artwork are allowed, both under U.S. law and on AO3. Fiction about real people is still fiction, and therefore it is allowed on AO3.
+
Depending on your country of residence or citizenship, the laws that apply to you may be more restrictive than those of the United States. All users are responsible for following the laws that apply to them. If certain content on AO3 is illegal for you to access, then you should ensure you carefully observe all relevant ratings and warnings, and avoid opening any work that indicates it may contain such content.
+
What does banning "sexually explicit or suggestive photographic or photorealistic images of real children" mean, particularly for works featuring sexual content with underage characters?
+
Sexually explicit photographs, videos, and other photorealistic images of children (also known as Child Sexual Abuse Material, or CSAM) are prohibited in the United States and on AO3. Users who embed, link to, solicit, distribute, or otherwise provide access to such material will be banned and reported to the appropriate authorities.
+
Stories and non-photorealistic artwork (such as drawings or cartoons) that depict sexual activity involving characters under the age of eighteen are allowed, provided that the works are properly rated and carry the appropriate Archive Warning. However, photographic or photorealistic images of humans may not be used to illustrate works featuring underage sexual content. This includes (but is not limited to) photographs of children, porn gifs, photo manipulations, computer-generated or "AI" images, or other linked or embedded images that could potentially be mistaken for photographs of real humans.
+
We understand that not all photorealistic images of humans are actually documenting the real-life abuse of a child or derived from illegal material, but we decided to use a guideline that can be uniformly applied without relying on subjective judgment. If the work appears to feature underage sexual content (as indicated by the "Underage Sex" Archive Warning or other contextual markers present in the work's tags, notes, or text), then the Policy & Abuse committee may require all photographic or photorealistic images of humans, regardless of age, to be removed from the work.
+
Does AO3 screen for quality?
+
No. We welcome creators and fanworks of all skill levels, and we will never remove a work on account of its quality, grammar, spelling, or punctuation.
+
How can I avoid works that contain content I don't want to encounter?
+
When browsing a tag, you can use the Filters sidebar to filter out works. If you are on a mobile device, select the "Filters" button at the top of the page to bring up this sidebar. Adding tags under the Exclude section will remove all works that use an excluded tag. For example, you can exclude any ratings and Archive Warnings that you don't want to encounter (make sure to also exclude the non-specific Rating and Archive Warning tags). You can also exclude other types of tags that users have chosen to add to their works.
+
You can use the Search within results box to filter works using keywords. This searches the metadata (title, summary, tags, and beginning/end notes) of a work, but not the chapter notes or body text. You can also use the following symbols to refine your search:
+
+
A minus sign (-) in front of a word (or a phrase in quotes) in the "Search within results" box will filter out all works that have the word anywhere in their metadata.
+
An asterisk (*) before or after your search term will allow you to look for partial matches.
+
If your search term has multiple words, using straight double quotation marks ("like this") on either side of your search term will allow you to search for the exact phrase. Single and/or curly “smart” quotation marks will not work.
+
+
For example, if you enter -sex* into the "Search within results" box, your results will exclude any works with metadata that contains words beginning with "sex" (including "sex", "sexual", "sexy", etc).
+
Once you have a search setup that works for you, you can bookmark the page in your browser in order to return to it later. If there's content you want to avoid on AO3, we recommend using filter keywords and browser bookmarks to exclude that content from what you encounter while browsing.
How can I avoid encountering works or other content by a specific user? What about anonymous or orphaned works?
+
Mute a specific user
+
If you want to avoid all content by a specific user, you should mute the user. To mute a user, go to their dashboard by following the link in their username. Then select the "Mute" button in the top-right corner, and confirm that you want to mute them on the next page. Muting a user means you will no longer be shown their works, series, bookmarks, or comments while browsing AO3. Please note that muting is a separate function from blocking.
+
Mute a specific work
+
If you want to hide a specific work (for example, a specific anonymous or orphaned work), you can mute it with a site skin. To do so, create a site skin and add .work-000 { display: none !important; } to it, replacing 000 with the ID of the work you want to mute. The work ID number can be found in the work's URL immediately after /works/. For example, 000 would be the work ID of https://archiveofourown.org/works/000/chapters/123. Make sure to apply your site skin after you've created it. You can contact the Support committee if you have any problems using site skins.
+
How can I prevent a user from commenting on my works or interacting with me?
+
Block a registered user
+
If you want a registered user to stop interacting with you, you can block the user. To block a user, select the "Block" button on any of the user's comments or on their user profile or dashboard, then confirm that you want to block them on the next page. Blocking a user means they cannot comment on or kudos your works, reply to your comments elsewhere, or give you gifts outside of a Challenge. Please note that blocking is a separate function from muting.
To prevent guest users from replying to your comments on other users' works, go to your Preferences page and enable "Do not allow guests to reply to my comments on news posts or other users' works (you can still control the comment settings for your works separately)". Remember to select the "Update" button at the bottom of the page to save any changes to your preferences.
A retelling of an existing story from another character's point of view
+
An original fantasy story about a modern person traveling to medieval times
+
An alternative version of a published novel in which there's a zombie apocalypse
+
The supporting text for an original adventure for a tabletop roleplaying game
+
A fannish essay about vampire biology, or the same essay in audio or video form
+
Short clips of footage from existing sources, edited over a song to make an argument or tell a story
+
Artwork (such as a drawing) of an iconic scene from a book, movie, or TV show
+
A comic about the romantic adventures of a playful thief
+
Photographs of a crocheted (amigurumi) character you made
+
+
What if what I want to post isn't similar to one of the examples listed above?
+
In general, you can post any non-commercial, non-ephemeral, transformative content you created that is fannish in nature. If you're uncertain if your work can be posted on AO3, you can always contact the Policy & Abuse committee to ask.
+
How will "ephemeral" be defined?
+
Ephemeral content is material that exists primarily to share someone's impressions, reactions, or feelings about a current event, fandom, or trend. If the content contains limited analytical or interpretive content, or lacks any artistic material, it is likely to be classified as ephemeral. Some examples of ephemeral content include live reactions, announcements about upcoming fanworks, and requests for prompts.
+
While it may benefit general fannish history to keep a record of such moments, AO3 is not intended to host all content that is fannish in nature. This type of content is often better suited for social media, personal sites, or blogging services. The Organization for Transformative Works also runs Fanlore, a wiki about fanworks and fan communities, including fandom trends and current events.
+
Please use your best judgment. Our general policy is to defer to creators in cases of doubt; however, the Policy & Abuse committee has final discretion in determining ephemerality.
+
Can I post original fiction?
+
Yes. Original works are allowed, unless the work would be in violation of some other part of the Terms of Service.
+
Our vision of AO3 is for all fanworks, including those beyond traditional fanfiction, fanart, and fanvids. Original stories and artwork, including those imported as part of an Open Doors project, are permitted. Some examples of original fiction that we host include original slash, anthropomorphic works, and Regency romance. However, works intended for commercial publication are not suitable for AO3. The Policy & Abuse committee has final discretion in maintaining AO3's focus on non-commercial fannish works.
+
We generally presume that, by posting the work to AO3, the creator is making a statement that they believe it's a fanwork. As such, original work will be allowed to remain unless the work is in violation of some other part of the Terms of Service, such as our plagiarism or non-commercialization policies.
+
Can I post nonfiction?
+
Fannish nonfiction, which includes what is called "meta" by some fans, is allowed. However, it must still be fannish in some way and contain some kind of analytical or creative content. In addition, as an Archive whose goal is preservation, we want permanent, non-ephemeral content. If the content is meant to be ephemeral, such as a liveblog of episode reactions, it should be posted on a social media account rather than on AO3.
What falls within the definition of fannish nonfiction?
+
Examples of fannish nonfiction allowed on AO3 include:
+
+
Discussions of fannish tropes
+
Essays designed to entice other people into a fandom
+
Commentary on fandoms
+
Documentaries or podcasts about fandom
+
Explanations of the creative process behind one or more fanworks
+
Tutorials for creating fanworks
+
Guides for fan-created gaming campaigns
+
Detailed analyses of multiple fanworks
+
Essays on characters' narrative arcs in canon
+
Comparisons of the film and comics versions of a source
+
+
This isn't an exhaustive list – fannish nonfiction may take many other forms.
+
We will generally defer to the creator's characterization of a work as fannish nonfiction as long as it has a reasonably perceptible fannish connection, either to a specific source or to fandom in general, and takes the form of an independent, non-ephemeral commentary. However, not all nonfiction falls within our mandate. Please consider what isn't fannish nonfiction before posting your work. While we acknowledge the complexity of certain cases that may fall on the boundaries of categories, setting limits is necessary to maintain AO3's manageability for our dedicated volunteers and users.
+
What are some examples of non-fanwork content that should not be posted as works on AO3?
+
The examples are potentially limitless, but here are some examples that do not fall under AO3's definition of fannish fiction or nonfiction and should not be posted as a work:
+
+
ephemeral content (including personal journal or diary entries, reactions, or blog posts)
+
episode transcripts, reposted canon material, and other non-transformative fandom content
+
primarily autobiographical or non-fandom-related essays (for example, your science, math, or philosophy homework, even if it contains a reference to a fannish source)
+
lists of names, titles, or statistics (such as information about a character's name, age, pronouns, and personality traits) that contain little to no other analytical, narrative, or descriptive content
+
discussions of specific fandom-related events (such as conventions or debates over particular incidents), which are considered more appropriate for Fanlore
+
general statements, questions, or complaints about a person or group
+
suggestions that other fans contact the creator through email or other social networks
announcements, placeholders, or updates about future, existing, or deleted works
+
an explanation of why a work was removed from AO3
+
+
Works that incorporate fannish content in clearly bad faith are not fanworks. For example, a work primarily composed of fic search requests is not a fanwork even if there are a few sentences of fandom-specific content.
+
In general, we presume good faith on the part of our users, and ask that you do the same for the fans who make up our Support and Policy & Abuse committees. The Policy & Abuse committee will exercise its discretion, which is final, in the service of maintaining AO3 as a place focused on non-ephemeral fanworks.
+
How will you draw the line between fanworks and non-fanworks?
+
The presumption is that a work is a fanwork, but if it's clear from context (summary, tags, notes, etc.) that it's not, it may be removed for violating the Content Policy. The Policy & Abuse committee will consider many factors when determining if something is a fanwork, such as whether the reported content is transformative, ephemeral, or fannish in nature.
+
Additionally, original works that are not based on a specific media source (canon) are considered fanworks. Please see Can I post original fiction? for more detail.
+
Can I post a "placeholder" work to tell other fans that I intend to post my story soon?
+
Placeholders are not allowed on AO3. This includes works in progress that do not have any story content, works where only the summary and tags have been posted, and lists of ship names, character profiles, or ideas for what you plan to publish. You can create a draft to edit your tags or preview your work before posting it publicly, but please don't post the work unless you have at least one chapter of your fanwork that is ready to be shared with other people.
+
Can I post fancastings for my story as a separate work? What about character notes or profiles?
+
A work that is simply a collection of actors' photos paired with character names would not be considered a fanwork, nor would a work that only consists of statistics or summaries of canon elements (such as what you might find on a fannish wiki). However, if you include more in-depth analytical, descriptive, or narrative content, then we would likely consider that fannish analysis (meta), which is allowed. An example would be an extended analysis of the character traits that led you to choose that particular fancasting.
+
If you would like to post fancastings, statistics, or a summary about your own fanwork, you can include it in the fanwork's notes or post it as an extra chapter inside the fanwork.
No. The incorrect quotes meme format involves a collection of quotes where the names have been substituted with the names from a different source. While the amount of quoted text is relatively small, replacing names and minor rewording of quotations is not sufficiently transformative to make the resulting content a fanwork.
+
Can I post non-textual works (such as fanart, fanvids, or podfic)?
Can I post "directors' cut" or "commentary" versions of my own fanworks?
+
We consider those versions of your fanworks, so you may post them as you would any other fanwork. We suggest that you distinguish them from non-commentary versions, for example by adding "[Directors' Cut]" in the title or tagging them to indicate the difference between the original and the "DVD-style" version.
+
Can I post announcements or status updates as separate works? What about if I post it as a chapter of my fanwork?
+
No, you cannot post announcements or other blog-style updates as separate works. Status updates and other author notes are considered ephemeral content. If you want to discuss a fanwork you've posted or plan to post on AO3, we suggest including such information on your profile page or in the notes or comment sections of your existing fanworks.
+
In general, adding several announcement chapters to an existing fanwork will not cause your entire work to become a non-fanwork. However, if you want to talk extensively about your works or personal life, then we recommend linking to a social media site in the notes of your fanworks instead of posting announcement chapters.
+
Can I post roleplay ads?
+
No. Requests or calls for roleplay partners are not fanworks. We encourage you to seek and advertise for roleplaying partners or servers on your preferred social media platform(s) instead.
+
I am looking for a particular story. Can I post a work explaining what I'm looking for?
+
No. Please use our search functions for this rather than creating a separate work. You can use our Work Search or Bookmark Search, or select a particular tag and then use the Filters sidebar to further refine the results.
+
Should you find that this is not sufficient to locate the work you are seeking, your preferred internet search engine (such as Google or DuckDuckGo) may be able to search for distinctive phrases within the work text itself. You can perform an AO3-specific sitewide search by adding the search term site:archiveofourown.org, which will limit your results to pages on AO3.
+
If you require assistance from other users, we advise seeking out fandom-specific or pairing-specific fic-finding communities on social media platforms such as Tumblr, Reddit, or Discord, whose members will be more than happy to help you locate the works you are looking for.
+
I have an idea that other people might want to write. Can I post my prompt or challenge as a work?
+
No. Please create a prompt meme to offer suggestions or challenges to other people, rather than posting a work.
+
Does that mean I can't write a short story scene or snippet and suggest that others continue where I left off?
+
A short, distinct piece (such as a drabble or vignette) would be considered a fanwork. If you have written a full scene or outlined the plot in enough detail that it could count as a fanwork in and of itself, that would also be allowed.
+
However, if you only have a handful of sentences or bullet points and there isn't much plot or characterization, that may be considered a non-fanwork placeholder or prompt. If your primary reason for posting the work is to offer ideas or suggestions for other people, please create a prompt meme instead of posting a work.
+
I want other people to give me prompts or requests for fanworks they'd like to see. Can I post a work so that they'll have a place to do that?
+
No, you cannot post a work that is only a request for other people to give you prompts. Please create a prompt meme instead. You can share the link to your prompt signup form on social media or in the notes of your fanworks.
+
Can I post a letter to someone I've been anonymously matched with for a challenge?
+
No. Since this content is designed to be ephemeral (it is directed at a particular person for a particular event), please do not create a separate work for it. If the challenge is hosted on AO3, please put your letter in the optional details for the challenge. If you want to share your general preferences such as your favorite fandoms or tropes, you can put that information in your profile.
+
Can I create a list of recommendations or a list of works that use certain tropes? If I want to include commentary on the fanworks I am recommending, would that count as meta?
+
Posting a "rec list" (one or more recommendations as a work) may be a violation of our non-fanwork policy. Please use our bookmark feature for this purpose instead. Bookmarks can include commentary, be marked as recommendations, and organized with tags or into collections.
+
Criticism of a fanwork is permitted in the tags or notes of a bookmark and will not be considered harassment. However, no matter its location on the site, all commentary must comply with our other policies, including our harassment policy.
+
The difference between a recommendation versus a general meta-discussion or analysis of a fanwork is determined through several factors, such as whether the content is ephemeral in nature or if it contains analytical or interpretive content. A work is more likely to be a non-fanwork if it's just a list of titles and summaries (such as a "Top 10 List" or "Recs for Fluff Fics") or if it's similar to a product review (for example, "This is the best slow burn fic in the fandom and here's why you should read it"). If the work contains extended commentary or analysis about the nature of the recommended work, it is more likely to be considered a fanwork and allowed on AO3.
+
Please use your judgment on the best way to categorize such commentary.
+
Are there any limits to what I can use AO3 bookmarks for?
+
On AO3, bookmarks are intended for organizing and recommending fanworks hosted on any site (not just AO3). For example, you can bookmark fanfic from FanFiction.Net, fanart on DeviantArt, fanvids on YouTube, or meta posts on Tumblr. However, you should not create a bookmark for something that isn't a fanwork. Bookmarking a news article, meme, or reaction gif would be in violation of our non-fanwork policy because those aren't fanworks.
+
In addition, please keep in mind that bookmarks are also subject to all of our general content rules, including our policies against harassment, commercial promotion, and copyright infringement. For example, you cannot use bookmarks to link to illegally distributed copies of copyrighted material.
+
As offsite ("external") bookmarks are described by users, their actual content may differ from the descriptions offered. Please exercise caution when following any links away from AO3, including external bookmarks.
The Organization for Transformative Works is committed to the defense and protection of fans and fanworks from commercial exploitation and legal challenges. AO3 was created to give fan creators a non-commercial space to share their works.
+
It is part of AO3's mission to remain a non-commercial space, so all forms of commercial promotion and activities are prohibited. AO3 isn't the right place for offering merchandise or requesting donations, whether for yourself or others. We enforce the non-commercialization policy strictly.
+
What kinds of things are considered "promotion, solicitation, and advertisement of commercial products or activities"?
providing a "tip jar", bank information, or other method for people to give you money
+
offering paid commissions or other content in exchange for money, gift cards, or similar
+
stating that a fanwork was created as a result of a donation or paid commission
+
encouraging donations to a person or cause
+
listing potential benefits of a paid membership or subscription
+
posting free previews for paywalled content
+
advertising a paid service or product (linking to an item's product page, suggesting that others purchase an item, etc.)
+
selling merchandise, even if the merchandise is fannish in nature
+
discussing the sale of the creator's other works, even if that paid content is not itself hosted on AO3
+
creating or promoting an app or website that charges money to access works posted on AO3
+
+
In general, if a financial transaction is involved, you cannot discuss it on AO3. Commercial activity is prohibited regardless of the reason why the commercial activity is occurring.
+
What do you mean by "commercial platform"?
+
A commercial platform is a site whose primary purpose is to facilitate the exchange of money. This includes online storefronts as well as tipping, patronage, subscription, and crowdfunding services. Linking, discussing, or referencing someone's presence (including your own) on a commercial platform is prohibited.
+
Examples of commercial platforms include, but are by no means limited to:
+
+
Amazon, Etsy, Redbubble, and other online storefronts
+
Patreon, Ko-fi, and other patronage, tip-jar, or subscription services
+
Kickstarter, GoFundMe, and other crowdfunding services
+
PayPal, Venmo, and other money transfer services
+
+
As stated, this list is non-exhaustive. If the primary function of the platform involves allowing someone to give someone else money, then you should not advertise your presence on it in any way.
+
What do you mean by "monetization features of a non-commercial platform"?
+
Sometimes sites mainly dedicated to some other purpose (such as social media or image/audio/video hosting) will also have features or subsections of their platform dedicated to monetization. For example, DeviantArt is primarily a free-to-use art gallery and social media website, but it does have some specific sections of its platform that are commercial in nature, such as DeviantArt's Shop, Commissions, Premium Galleries, and Core Memberships. In such cases, you are allowed to link to content on the "free" portion of the website, so long as the page you are linking to is non-commercial in nature. However, linking to or mentioning the monetized content is not allowed.
+
Can I link to my Tumblr, Discord, Linktree, Wordpress, or other social media account or personal website? What if my profile or pinned post on that site has a link to a commercial platform?
+
In general, linking to your social media accounts or personal website is fine, even if you sometimes post about commercial activities on that site. However, you may not link to accounts, posts, sites, or pages that reference commercial activity in the URL, or that are primarily commercial in nature (such as a Carrd that lists your published novels and explains where to buy them). In addition, you may not provide instructions anywhere on AO3 for finding your commercial content elsewhere (for example, stating that information about your paid commissions is available on a specific webpage even if you don't link directly to that page).
+
I am a published author. Can I let people know what my pen name is or what my books' titles are?
+
Stating in general terms that you have written a book or providing your pen name is fine, even if you use that pen name for commercial works. However, you may not use AO3 to promote your commercial works, tell people where they can find or buy those works, or otherwise advertise your commercial works in a manner that could encourage others to seek out and purchase the works.
+
Can I ask for donations or tips on my account profile or my original works?
+
No. Our non-commercialization policy applies everywhere on AO3, including user profiles, comments, fanworks, and works in the "Original Work" fandom. Asking for donations or tips is considered engaging in commercial activities and is not allowed anywhere on the site.
+
Can I post the first chapter of my published original novel on AO3?
+
Posting a preview or advertisement for a commercial work is not allowed. This includes uploading only a "snippet" to promote a larger paid work, as well as removing significant portions of a fanwork that has been "pulled to publish" professionally. Even if it contains some fannish content, sharing excerpts intended to promote or sell paid content is prohibited.
+
I post all of my fanworks on AO3 for free, but I also have paid supporters on another site who get new chapters a week early. Can I let readers know that these "early access" chapters exist, if I don't explain how or where they can subscribe?
+
No. If you provide an "early access" service in exchange for money, you cannot reference it on AO3. This includes stating that additional "bonus content" is available elsewhere, regardless of whether you plan to make the content available to the public in the future. Advertising paywalled content is not allowed even if you don't provide instructions, include links, or name a specific commercial platform.
+
The stories posted on my Patreon are free for anyone to read, even if they're not one of my Patrons. Can I link to one of these stories from AO3?
+
No. Sites like Patreon are considered commercial platforms because their primary purpose is to enable creators to receive funding from their fans. Linking to commercial platforms is not allowed, even if some content on the platform is free.
+
I would like to thank one of my clients or patrons (someone who supported me monetarily). Can I acknowledge them in the notes of my work?
+
You may not indicate anywhere on AO3 that other people are financially supporting you due to your fanworks. This includes stating that they paid for a commission, donated money to you or others, or are a patron or paid subscriber. However, you are allowed to name someone in a non-commercial manner, such as by crediting them for a prompt or by using the Gift feature to dedicate your work to them.
+
This creator's work is amazing! Can I leave a comment telling them they should set up a tipping or subscription service, publish their work commercially, or otherwise get paid for it?
+
No. Our non-commercialization policy applies to the entirety of AO3, including the comments section of other users' works. Encouraging other users to engage in commercial activity is prohibited.
+
I paid someone else to create a story, artwork, or podfic. Can I post or link to their work, or suggest that other people commission them too?
+
You may not encourage commercial activity on behalf of someone else, which includes encouraging people to purchase a commission. If you are embedding commissioned images, audio, or videos, you must ensure that there are no ads, links, or watermarks for any commercial platforms. In addition, you may only upload someone else's work if you have their explicit permission to do so.
+
I take paid commissions on another site. Can I post the fanworks I create on AO3 and say that they were commissioned? Can I invite other people to commission me?
+
Offering paid commissions is not allowed. This includes posting links to pricelists or payment request forms. However, you are allowed to post fanworks that were created upon request and credit the person who made the request. If you do so, you must not indicate that you received payment for the commission or that you are available to create other paid commissions. Because not all commissioned fanworks were created for pay, we do permit usage of the word "commission" as long as there is no indication that a monetary transaction was involved in the creation of the work.
+
Can I post a work that was created for a charity drive or auction?
+
Yes. AO3 will host fanworks of any origin, including fanworks created in response to charity events or other challenges. A link to a charity to explain the origin of a fanwork is appropriate, but please do not link directly to any fundraising sites or pages. You may state that a work was produced for a particular charity event, project, or other entity, as long as you do not mention donating, bidding, or any specific contribution amounts or donation platforms.
+
Does that mean I can't ask people to support a charity or non-profit organization?
+
You are allowed to link to a charity's website, encourage people to learn more about the charity, or explain why you believe in its mission. However, you may not link directly to the charity's donation form, promote their fundraisers, or request that people donate to them.
+
Can I post a work that was originally part of a for-sale or charity zine, and if so, can I name the zine?
+
Yes. However, you may not encourage users to buy the zine or its merchandise, such as by linking to an advertisement or to a sales or orders page.
+
What if a zine or other merchandise is available on an optional "pay what you want" basis? On sites like Gumroad, it's possible to access or download the content completely for free.
+
Links to product purchase pages are not allowed, even if payment is optional. If the content is hosted on a non-commercial site that doesn't offer payment options, you can link to that site instead.
+
I created merch for one of my fanworks. I'd like to hold a giveaway and send it to one of my readers for the cost of shipping. Since I'm not making money off of it, can I advertise this on AO3?
+
No. Since this involves an exchange of money, it is considered a commercial activity regardless of whether you personally make a profit.
+
I bought some fan merch. Can I post an image of what I bought and talk about it in my work notes or comments?
+
In general, yes. However, you cannot encourage other users to go and buy it themselves. This includes providing links or directions to the place where you purchased the merchandise. You may want to take your own photographs in order to avoid linking to the product page.
+
I've created a mobile app with features that make AO3 easier to navigate and use. Can I charge people to use my app?
+
No. It is a violation of the Terms of Service to charge users money for access to content on AO3. That includes, for example, operating an app that restricts access to works on AO3 behind a paywall, or copying works from AO3 to sell.
+
Can I talk about prohibited commercial activities on AO3 if I don't include any direct links or name any commercial platforms?
+
No. Both direct and indirect references to commercial platforms or activities are not allowed.
+
Do the rules against commercial promotion mean that I can't write fanworks that reference real-world businesses or feature characters engaging in commercial activities?
+
No. You are allowed to create fanworks in which the characters engage in or reference commercial activities as part of the fictional story. For example, you could create a fanwork in which one character is an OnlyFans creator and another subscribes to them or buys their merchandise. However, you cannot link to or otherwise promote any real-world subscription, merchandise, or other commercial activity.
+
Please keep in mind that all Abuse reports are reviewed by the human volunteers on our Policy & Abuse committee. In general, we presume good faith on the part of our users. However, if we conclude that someone is deliberately trying to circumvent our rules by having fictional characters discuss commercial activities, then the fanwork may be deemed commercial in nature and unacceptable to post on AO3.
What makes a fanwork "transformative"? Why is a "transformative work" not a copyright violation?
+
Copyright protects an individual's expression of an idea, not the idea itself. "Expression" refers to the work created, such as the wording of a paragraph in a book, while an "idea" covers general plots or tropes. For example, posting a transcript of a movie without permission constitutes copyright infringement, as it replicates a significant part of the original work (the spoken dialogue) exactly. In comparison, a transformative fanwork reframes existing material in a unique manner, such as retelling a superhero movie from the perspective of civilians.
+
The Supreme Court of the United States has explained transformative use as "add[ing] something new, with a further purpose or different character, altering the first [work] with new expression, meaning, or message." Essentially, by significantly reinterpreting the original material, the creator of a transformative work makes a new, distinct creation that does not require the copyright owner's permission to create or share.
+
Do I own the copyright for my fanworks?
+
Yes. AO3 maintains that fanworks are transformative and that a fanwork's creator owns the rights to the expressions in their work that are unique to them. A fanwork creator holds the rights to their own content, just the same as any professional author, artist, or other creator.
+
May I post someone else's fanworks, giving them credit?
+
You may only post someone else's fanworks if they granted you permission to do so. If you do not have the creator's permission, you cannot post their work. Including a disclaimer that the work is not yours or crediting the original creator is not sufficient. Posting and then anonymizing or orphaning the work is also not allowed. Please use our bookmark feature to share other people's fanworks instead of reposting their works.
+
If you do have the creator's permission, you can upload their fanwork as long as you provide appropriate credit. For example, you could add the creator's name and include links to the work on the original site and to the place where they gave you permission.
+
If you created or moderate a fanwork archive or mailing list, you may be eligible to become an authorized Open Doors archivist and import your archive to AO3. The Open Doors committee will work with approved moderators to contact and fully credit the original creators of the fanworks, giving the creators as much control over their fanworks as possible.
+
Can I post a translation or a podfic of someone else's work?
+
Translations and podfics can be posted only if you have permission from the copyright owner (usually the creator or publisher) of the original work. If you do not have permission, then you may not post your work.
+
Under United States copyright law, translations and audiobooks are considered "derivative" works – not transformative works. Derivative works cannot be posted without the copyright holder's consent. AO3 adheres to U.S. law, so if you want to post a translation or podfic of a fanwork, you need the fanwork creator's permission.
Can I post a conversion or adaptation of someone else's work? This is a type of work where the original content is modified slightly to fit a different fandom, ship, character, or format.
+
While you are welcome to create a fanwork that is based on or inspired by another work, you may not take someone else's work and only make minor changes to it (such as swapping out the names, changing the formatting, or rewording the original text). Unless the fanwork creator or copyright owner granted you permission to modify, convert, or adapt their work in this manner, posting this kind of work is a violation of our Terms of Service, even if you provide credit.
+
Can I post a sequel, prequel, or continuation of another fanwork? What about a recursive fic that's completely different from the original fanwork?
+
Yes. Fanworks based on other fanworks are also transformative, and are allowed. You can use the "Inspired By" feature to link to the original creator's work. Unlike with reposts, conversions, podfics, and translations, you don't need permission to create a recursive fanwork. However, you cannot include large excerpts from the original work unless the original creator gave you permission to do so.
+
What if the original creator deleted or orphaned their work, or posted it anonymously?
+
Even if someone deletes or orphans their work, or posts it anonymously, they still hold the copyright to their own work. If the original creator has chosen to remove their fanwork from the internet, or cannot be contacted, then please respect their decision. You cannot post, convert, podfic, or translate someone else's work without their explicit permission, even if you credit them or disclaim credit for yourself.
+
How can I obtain permission to post, convert, podfic, or translate someone else's work?
+
Some fanwork creators may list "blanket permission" statements in their profile or in the tags or notes of their work. An example of a blanket permission statement would be "Anyone can translate my works so long as they provide me credit and link back to the original here on AO3." In such cases, you have permission if and only if you meet the conditions of the permission statement.
+
If a creator doesn't have a blanket permission statement, then you can try to contact them directly and ask. This could be by commenting on their work or asking them via social media or email (if they have shared that information publicly). If the creator responds and gives you permission, then you're good to go. If they refuse or do not respond, then you do not have their permission and you may not post the work.
+
Can I post a transcript of a movie or a TV show?
+
Scripts of movies, TV shows, and plays are subject to copyright, just like published books, songs, poems, photographs, artwork, and other works. Transcribing and posting the content of a copyrighted work is not allowed. However, you may include a limited number of short quotes in your fanwork.
+
Can I post "reaction fic" or "MST3K or DVD-commentary–style" versions of other works? This is a type of fanwork where characters read/watch another work (such as the original canon or a popular fanwork) and "live react" to scenes or dialogue.
+
If you have permission from the copyright owner or if the work is in the public domain, then your fanwork can include as many quotes from the original work as you like.
+
If the work is still under copyright and you don't have permission from the copyright owner, then you can describe or paraphrase scenes to allude to what the characters are reacting to and include timestamps, page numbers, or occasional short quotes from the original. However, you cannot quote or otherwise reproduce large amounts of dialogue, lyrics, text, or other copyrighted material. If someone could access a substantial portion of the original material by skipping over your additions, your work likely violates our Terms of Service.
+
May I post the full lyrics of a song or an entire poem that isn't in the public domain without the copyright holder's permission?
+
No. Posting large portions of text from a song or poem is not allowed, unless it is in the public domain or you have the copyright owner's permission. You are only allowed to quote a limited amount of material from a copyrighted work, even if the lyrics or stanzas are broken up by your own original text. You can link to a licensed site such as Genius or Poetry Foundation instead.
+
Can I post a fanvid that uses a full song without the copyright owner's permission?
+
Fanvids where the audio is an entire song are allowed, so long as you have added or remixed a substantial number of visuals to accompany the song such that the video becomes a transformative work. For example, simply posting the text of the song's lyrics to accompany the song would not be considered transformative. A video consisting of an entire song accompanied by two or three long, unedited clips from a movie would also not be considered a transformative work.
+
Can I post character playlists or fanmixes?
+
A "fanmix" is a thematic compilation of songs curated by a fan to reflect a character, chapter, setting, theme, or other element of a work. If you wish to post character playlists or fanmixes, you may use licensed streaming sites such as Spotify, 8tracks, or YouTube. You may not provide links that could deliver individual music file downloads (such as a single zipped file containing music files) unless you have the right to distribute downloads (for example, a fan song or filk you composed and sang, a work in the public domain, or a Creative Commons-licensed download). Please also keep in mind that you may quote lyrics as part of a transformative work, but you may not reproduce the entire song text unless you have the right to do so. In addition, if you just post a list of song titles without including any way to listen to the songs, then your work may not be considered a transformative fanwork.
+
Can I embed someone else's artwork or photos to accompany a work that I wrote, or post someone else's story to accompany the art I created for it?
+
If you have the artist's or writer's permission, then you may embed or upload their work alongside your own as long as you credit them appropriately. If you do not have the creator's permission, you cannot repost their work, even if you credit them. We recommend that you instead provide a link to wherever the original creator has chosen to host it.
+
How do these rules apply to works that are in the public domain?
+
If a work is in the public domain, then it is no longer copyrighted. In this case, you can use as many excerpts from it in your fanwork as you like.
+
However, a public domain work is not in and of itself a fanwork. You cannot simply upload a public domain work to AO3 or make minor alterations such as replacing names or synonyms. You need to add your own content in order for your work to be considered a fanwork.
+
If you create your own fan translation or podfic of a public domain work, you can post your work on AO3. However, you can't repost someone else's translation or podfic without their explicit permission. Translations and audio recordings have their own copyright separate from the source work's. The translator or podficcer has ownership over their own specific translation or recording, even if the underlying work is in the public domain.
+
If I want to file a plagiarism or copyright infringement complaint, is there anything in particular I should include in my report?
+
Yes – please make sure you provide a link to the original work as well as a link to the infringing work in your report description. If you don't tell us what the original source is and which work is infringing upon it, then we may not be able to uphold your complaint.
+
If you want to report multiple instances of plagiarism or copyright infringement by a single user, please submit only one report rather than reporting each link individually. We'll be able to process your complaint faster if you take the time to compile all relevant links, excerpts, and other information into a single report and correctly pair each infringing work with the original source.
+
If you are reporting multiple works by different users, please submit a separate report for each user.
+
Can I submit a plagiarism complaint even if I am not the original creator whose work was stolen?
+
In general, yes, as long as you include a link to the alleged source work. If we cannot compare the two works, we will not be able to uphold your complaint. However, in some cases, we may require a report or other evidence from the original creator.
+
Can I submit a plagiarism or copyright infringement complaint even if the original work is not hosted on AO3?
+
Yes. However, such reports must include a link to the alleged source work. If we cannot compare the two works, we will not be able to uphold a complaint of plagiarism or copyright infringement.
+
Do you have a Digital Millennium Copyright Act (DMCA) notice and takedown policy?
DMCA takedowns and counternotices are not handled by the Policy & Abuse committee. Please do not submit both an Abuse report and a DMCA notice about the same content, as this may delay the processing of both requests.
+
What happens if someone reposts my fanwork to another site without my permission?
+
The Organization for Transformative Works does not hold the copyright to your fanworks, so we cannot contact other sites or organizations on your behalf. Because you are the copyright owner, you will have to contact the other site yourself to request that they remove the unauthorized copy of your work.
I posted a guest comment that contained information about me. Can you delete it?
+
If the comment contained personal information sufficient to identify you in the real world (such as a full legal name, an email address, or a phone number) then you can contact the Policy & Abuse committee to request its removal. However, we will not remove guest comments simply because they contain a first name, username, or online handle.
+
I orphaned a work, and later realized that it contained my personal information. Can you delete it?
+
We will not delete orphaned works that don't violate our Terms of Service. However, if you left identifying information on an orphaned work (such as your name, email address, or social media account) and you would no longer like that information to be public, then you can contact the Support committee to request the removal of that specific information from the work.
+
What does banning "impersonation" mean? Does this mean I can't post first-person real-person fiction (RPF)?
+
We do not permit users to misrepresent themselves as another person or entity (such as a corporation or government agency), particularly in order to deceive others, violate our Terms of Service, or otherwise cause harm.
+
Roleplay is permitted when the assumption of such a persona is clearly disclosed (such as in a user profile or in another manner appropriate under the circumstances) and it doesn't otherwise violate the Terms of Service, including the harassment policy. Fiction (including RPF in first-person format) clearly marked as such will not be considered impersonation. Please consult our RPF policy for further information.
+
Can I use a celebrity name as a pseudonym, or is that impersonation?
+
In general, you can use a joke celebrity pseudonym, or roleplay as a celebrity, so long as you clearly disclaim that you are not actually that person.
+
What do you mean by banning impersonation of a "function"? Does this mean I can't have fake Tumblr or TikTok messages in my fanwork?
+
You're allowed to mimic functions inside fictional content, so long as the content is clearly fictional – fiction is not impersonation. However, if you're posting content outside of a fictional context, then you can't do so in an impersonating manner. For example, using the username "orphaned_account" is not allowed, as that could cause users to confuse your user page with AO3's official orphan_account.
Does the harassment policy cover everyone, or just AO3 users?
+
Both AO3 users and non-users can complain about harassment. The line between user and non-user can be blurry, so our policy covers both. However, writing RPF (real-person fiction) never constitutes harassment in and of itself, even if the content is objectionable. Please refer to our RPF policy for more information.
+
Does the harassment policy apply to every part of AO3?
+
Yes. This includes works, tags, comments, usernames, pseuds, profiles, icons, and every other type of content that can be submitted to, hosted on, or embedded on AO3, now or in the future. The harassment policy applies to everything a user does on AO3 and all communications with AO3 volunteers.
+
The use of any tool or feature could constitute harassment if it's being used to create a hostile environment. When investigating harassment, we will consider relevant context. For example, someone who has a username, pseud, or icon that is negative towards an individual or group could harass those people by leaving comments on their works, even if that same username, pseud, or icon would not be harassing in other contexts.
+
Is criticizing fanworks allowed? Is criticism a violation of the harassment policy?
+
Criticism is allowed on AO3 and is not considered harassment. This includes negative commentary in comments, bookmark notes, bookmark tags, and other locations. Criticizing a work is not a personal attack against the person who created that work, nor against people who enjoy that work. Issuing personal attacks or threats against other users for creating or enjoying a work is harassment and is not allowed.
+
How does the harassment policy apply to comments?
+
Criticism of a fanwork, even harsh criticism, is not by itself harassment. All creators have control over the comments on their works, and can delete comments or block users for any reason.
+
Calling a creator evil, wishing them harm, and repeatedly posting negative commentary in a manner designed to be seen by the creator (such as by posting multiple negative comments on their work) are potential examples of harassment.
+
Is it harassment if someone deletes my comment? My comment wasn't criticizing or attacking them.
+
No. Fanwork creators have control over the comments posted on their work. They can remove, freeze, moderate, or restrict comments as they please. This is never considered to be harassment.
+
Is it harassment if someone blocks me? They had no reason to do it.
+
No. Being blocked by someone is never harassment. If you are blocked by someone or if they told you to leave them alone, you are expected to stop interacting with them. Attempting to interact with someone after they have blocked you may be considered harassment.
+
Somebody bookmarked my fanwork and added a note to it that is really negative about my work. Is that harassment?
+
Criticism of a fanwork, even harsh criticism, is not by itself harassment. We do not consider criticism of a fanwork to be a personal attack against its creator. However, if the bookmark note includes harassing elements such as personal attacks or threats towards the creator of the work, then that would be considered harassment.
+
There's a person in my fandom who is harassing other people. Can I post a work, chapter, comment, or author's note to inform other people in my fandom who they are and that they should stay away?
+
No. Creating or sharing a "callout post" about another individual, or encouraging other users to shun them, is harassing behavior. We encourage you to mute and block anyone you don't want to interact with. If you witness or experience harassment on AO3, you should contact the Policy & Abuse committee. If you witness or experience harassment on a different platform, you should contact the moderators or Trust & Safety team for that platform.
+
Someone in my fandom is posting really disgusting content that is against the boundaries of the canon creators. Can I comment on their work and ask them to stop?
+
We recommend that you avoid engaging with content that you do not like. If you encounter content on AO3 that you find upsetting or disturbing, you should navigate away from the content and use filters or muting to avoid encountering it again. Users are allowed to post fanworks about any topic, regardless of how offensive it is to other users or if the canon creators would approve. If the content doesn't violate a specific clause of our Terms of Service, the fanwork is allowed – we do not remove content for offensiveness. In addition, be aware that joining in on group bullying to force someone to remove their works is likely to be deemed harassment.
+
There are some people in my fandom who ship things I think are disgusting or dangerous. I don't want them to comment on my works. Is it harassment if I tell them to stay away?
+
If you don't want people to comment on your works, then you should block them. You are allowed to make polite requests that other groups of fans do not interact with you. If you insult those people, or threaten them in any way, you are in violation of our harassment policy.
+
What if the tag I want to use is technically threatening, but it's actually a joke? I promise I'm not serious!
+
You may not threaten other groups of fans. There is no exception for jokes or memes.
+
Is it harassment if someone reports me for violating the Terms of Service?
+
As an AO3 user, it is your responsibility to ensure that you are following our Terms of Service. If we receive a report about you, you will only be told about it if our investigation reveals that you did in fact violate our Terms of Service. In such cases, we will let you know about your violation regardless of who reported it.
+
While malicious reporting is harassment, such reports are rarely about content that actually violates the Terms of Service. Most reports about violating content are submitted by users who happen to encounter such content during the course of their normal browsing. We do not generally consider a valid report to be harassment of the subject of the report.
Are there any rules about what I can choose as my username?
+
Yes. Aside from technical limitations on your username, all of the regular content rules also apply to usernames. This means that (for example) you can't choose a username that impersonates someone else, or that violates our commercial promotion or harassment policies. Because usernames are highly visible content, we enforce our policies very strictly on usernames.
+
Can I reuse a username that belonged to a deleted account?
+
Yes, it is possible that a username that is no longer used by its original user will be available to you.
+
Someone is using a username on AO3 that I've used on a different site. Can you make them stop or make them give me the username?
+
Usernames on AO3 aren't reserved, even if you've used that name before on AO3 or another site. In general, we won't consider the mere existence of a similar or identical username to be impersonation. If another AO3 account is already using your desired username, then you can create a pseud with that name, but you can't make the other person give up their username. However, if someone starts claiming to be you on AO3, or otherwise starts behaving in a harassing manner, then you can submit an Abuse report.
+
Why are the rules for user icons more restrictive than the general content rules?
+
User icons appear on pages that don't have rating filters, such as user profiles and comments. This means that other users have little ability to avoid encountering them. Therefore, user icons operate under more restrictive rules than rated, tagged, and warned-for content.
+
The user icon policy is not the general fanart policy. Although AO3 does not have native media hosting, images and other file types can be embedded in a work. For more information about what is allowed in fanart, please refer to Can I embed explicit images in my fanworks?
AO3 was designed to be a permanent home for all transformative fanworks. We will not remove content from AO3 solely because it contains explicit, offensive, or upsetting material, as long as it doesn't violate any other part of the Content Policy (such as the harassment policy or the non-fanwork policy).
+
We allow content of any rating, and all kinds of fictional topics. Users are responsible for reading and heeding the ratings and warnings provided by the creator. If there is a type of content that you don't want to encounter, you should avoid opening any work tagged or rated to indicate that it may contain such content. Risk-averse users should keep in mind that not all content will carry full warnings. If you want to know more about a particular fanwork, you may also want to consult the bookmarks that people other than the creator have used to categorize it.
+
What kind of ratings or warnings must be present on works?
+
AO3 has a Ratings system and an Archive Warnings system. These provide basic information about the intensity and type of content that may be present in a work. The only rating or warning information AO3 requires is listed within these two systems. Creators may add more information in the summaries, notes, or Additional tags of their works, but they are not required to do so.
+
If a creator doesn't want to put a specific Rating and/or Archive Warning on their works, then they can opt out of one or both systems by applying a non-specific Rating and/or Archive Warning.
Non-specific tags indicate that the creator has chosen not to use a more specific tag in that field. Any user who wants to avoid a particular rating and/or type of content should also avoid any work labeled with a non-specific Rating and/or Archive Warning. The non-specific Rating tag is "Not Rated", and the non-specific Archive Warning is the "Creator Chose Not To Use Archive Warnings" label. (Tags are also sometimes referred to as labels.)
+
What is the Ratings system?
+
AO3 has five different rating tags that creators can apply to their works:
+
+
General Audiences: The content should be suitable for all ages.
+
Teen and Up Audiences: The content may be inappropriate for audiences under 13.
+
Mature: The content may contain adult themes (sex, violence, etc.) that aren't as graphic as Explicit-rated content.
+
Explicit: The content may contain explicit adult themes, such as detailed sex scenes, graphic violence, etc.
+
Not Rated: This non-specific rating tag means that the creator has chosen not to rate their work. It may contain content that is general, explicit, or anything in between.
+
+
If a creator doesn't want to apply a specific rating to their work, they are always welcome to use the non-specific "Not Rated" label.
+
Are there any rules about how I must rate my work?
+
If your work contains graphic or detailed sex, violence, gore, or other adult content, then you may not rate it "General" or "Teen". Whether you choose to rate the work "Mature", "Explicit", or "Not Rated" is up to you.
+
If I don't choose a rating, what's the default?
+
The default rating is the non-specific "Not Rated" tag.
+
What does the "Not Rated" label mean?
+
"Not Rated" means that the work may contain content of any rating – the creator has declined to choose a specific rating. A "Not Rated" work could contain anything from a very fluffy coffeeshop meet-cute to extremely graphic sexual content.
+
What's the difference between "General" and "Teen"?
+
This is left entirely up to the creator's judgment. People disagree passionately about the nature and explicitness of content to which younger audiences should be exposed. Therefore, the creator's discretion to choose between "General" and "Teen" is absolute: we will not mediate any disputes about those decisions. Instead, we encourage creators to consider community norms, whether fandom-specific or more general (such as how you'd expect a video game or movie with similar content to be rated), when selecting a rating.
+
What's the difference between "Teen" and "Mature"?
+
If the work contains graphic adult content, it should not be rated "General" or "Teen". In response to valid complaints about a misleading rating, the Policy & Abuse committee may redesignate a fanwork marked "General" or "Teen" to "Not Rated", but in other cases, we will defer to the creator's decision. In general, we will not recategorize a fanwork in response to a complaint when the content at issue is a reference or is otherwise not graphic.
+
What's the difference between "Mature" and "Explicit"?
+
This is left entirely up to the creator's judgment. Both of these ratings require a user to accept the adult content warning and agree to access adult content in order to access the work. The creator's discretion to choose between "Mature" and "Explicit" is absolute: we will not mediate any disputes about those decisions. Instead, we encourage creators to consider community norms, whether fandom-specific or more general (such as how you'd expect a video game or movie with similar content to be rated), when selecting a rating.
+
There's an explicit sex scene in one chapter of my work, but all of the other chapters are really fluffy material that would be suitable for general audiences. Which rating should I choose?
+
Individual chapters of a work cannot be rated separately, so the rating chosen should be sufficient to cover the highest-intensity content in the work. If you do not want to use a rating of "Mature" or "Explicit" in such a situation, then you can always opt out of the Ratings system by labeling your work as "Not Rated".
+
Does the gender or sexual identity of a character matter when determining what rating to use?
+
No. The Policy & Abuse committee will not treat works differently on account of the genders or sexual identities of the characters or the types of relationships featured in the work. Please also note that in general, the creator's choice of rating is presumed to be appropriate.
+
Do you ever require a rating change on works rated "Mature" or "Explicit"?
+
No. Choosing a rating of "Mature" or "Explicit" is always acceptable. We will not require that ratings be lowered, even if there is no intense or explicit content in the work.
+
If I want to avoid explicit fanworks, what ratings should I exclude in my search?
+
To avoid all fanworks that may contain explicit content, you should exclude or filter out the "Mature", "Explicit", and "Not Rated" labels.
+
What is the Archive Warnings system?
+
The Archive Warnings system consists of several warning tags, which creators are able to choose from when posting their fanwork. At least one of the following options must be chosen before a work can be posted:
+
+
Graphic Depictions of Violence: The work may contain detailed descriptions of gore or graphic violence.
+
Major Character Death: The work may include the death of a character who is prominently featured.
+
Rape/Non-Con: The work may contain non-consensual sexual activity.
+
Underage Sex: The work may contain descriptions or depictions of sexual activity involving characters under the age of eighteen.
No Archive Warnings Apply: If this warning tag is used in the absence of other Archive Warnings, it means that the work does not contain detailed content pertaining to any of the four specific Archive Warnings. However, the work may briefly reference an Archive Warning topic and/or contain other intense or unpleasant content that is not covered by any of the Archive Warnings.
+
+
If a creator doesn't want to apply a specific Archive Warning to their work (for example, if they wish to avoid giving spoilers), they are always welcome to use the non-specific "Creator Chose Not To Use Archive Warnings" label.
+
Are there any rules about applying Archive Warnings to my work?
+
If your work contains graphic depictions of violence, major character death, depictions of rape or non-consensual sex, or depictions of underage sexual activity, then you must use either the relevant specific Archive Warning or the non-specific "Creator Chose Not To Use Archive Warnings" label.
+
Why is the number of Archive Warning tags so limited?
+
We wished to limit the number and type of Archive Warnings so that they could be easily used by creators from a wide variety of fandoms, and so that each Archive Warning could be fairly and consistently enforced by our all-volunteer Policy & Abuse committee. We decided that we could not reasonably expect fair enforcement of a rule requiring warnings for concepts beyond those listed in the Archive Warnings.
+
Something that I consider really upsetting or unpleasant is not on the list of Archive Warnings.
+
Because the Archive Warnings policy is deliberately minimal, this may be the case. We encourage you to use the Additional tags, summaries, and user-provided bookmarks and recommendations to screen for fanworks you'll enjoy, and you may wish to comment on a creator's work when you feel that further tags would be desirable. Please be respectful when you do, and keep in mind that they may choose not to add such extra tags.
+
My work includes content that I want to warn other users about, but it's something that's not on the list of Archive Warnings. How can I warn them?
+
Creators can add additional information about what their works contain in the Additional tags field. You can also add this type of information to the notes or summary of your work, including the notes and summaries on individual chapters. Doing so is entirely voluntary: the Policy & Abuse committee will not enforce the presence of any warnings outside of the Archive Warnings.
+
If I don't choose an Archive Warning, what's the default?
+
There is no default Archive Warning, so creators have to choose one or more of the Archive Warning options. A creator can opt out of using a specific Archive Warning by selecting "Creator Chose Not To Use Archive Warnings" instead.
+
What does the "Creator Chose Not To Use Archive Warnings" label mean?
+
"Creator Chose Not To Use Archive Warnings" means that the creator has opted out of using the Archive Warnings system. A work with this warning may or may not contain content covered by any of the specific Archive Warnings. Users who access a work labeled "Creator Chose Not To Use Archive Warnings" proceed entirely at their own risk, regardless of any other tags on the work.
+
Can I include multiple Archive Warnings?
+
Yes, you can. For example, you could select both "Major Character Death" and "Underage Sex" if a fanwork contains both elements, or "Creator Chose Not To Use Archive Warnings" and "Underage Sex" if you want to disclose the underage sexual content but don't want to say whether the work contains major character death.
+
Can a work have both "No Archive Warnings Apply" and one or more other Archive Warnings?
+
Yes. The "No Archive Warnings Apply" label can be added to a work regardless of any other Archive Warnings, so it only has meaning when no other Archive Warnings are present on the work.
+
If you would like to avoid encountering works with content pertaining to a specific Archive Warning when browsing AO3, then you should exclude the specific Archive Warning as well as the "Creator Chose Not To Use Archive Warnings" label.
+
If a story has only a brief reference to an Archive Warning topic, am I required to use either that warning or the "Creator Chose Not To Use Archive Warnings" label? Or can I still choose "No Archive Warnings Apply" if I think that's a better description?
+
This is the kind of decision that is up to the creator's discretion. In general, we will not recategorize a fanwork in response to a complaint when the content at issue is a reference or is otherwise not portrayed in explicit detail.
+
What does the "Underage Sex" Archive Warning mean?
+
Underage Sex refers to descriptions or depictions of sexual activity involving characters under the age of eighteen (18). In general, we rely on creators to use their judgment about the line between reference and description or depiction. Sexual activity does not include dating activities such as kissing; but again, we rely on creators to use their judgment about what is generally understood to be sexual activity. Creators may always specify the age of the characters in their work.
+
Why is "underage" defined as "under 18"?
+
Though there is no international consensus, there is a trend to focus on 18 as an important age for depictions of sexual activity. Thus, we decided that 18 would be helpful for the maximum number of users, including audiences as well as creators, though we recognize that no solution is perfect for everyone. We encourage creators and bookmarkers to be more specific in tags or notes where this would be useful to potential audiences.
+
What if the age of consent in my local jurisdiction is something other than 18, or if the age of majority in the fictional setting is under 18?
+
The "Underage Sex" warning on AO3 is used for fanworks depicting sexual activity involving humans under 18 as measured in Earth years, regardless of the fictional setting or users' local laws.
+
What about robots, computer simulations, elves, aliens, vampires who are three hundred years old but were turned into vampires at age 12, etc.?
+
The primary use of the "Underage Sex" warning is to identify fanworks depicting sexual activity involving humans under the age of 18 as measured in Earth years. Please use your judgment for other situations. If the fanwork does not include a depiction of sexual activity with a human under 18 years old, then we will not generally consider it "underage sex", though creators may use the Archive Warning if they feel it accurately represents their intent. As always, we encourage creators and bookmarkers to be more specific in tags or summaries where this would be useful to potential audiences.
+
What about when a fanwork isn't set during the canon timeline and doesn't specify the characters' ages?
+
If the characters' ages in the fanwork are ambiguous, then we will assume the characters have been "aged up", even if they are underage in the canon setting.
+
Is "Rape/Non-Con" or "Creator Chose Not To Use Archive Warnings" required for works featuring adult/minor relationships? In real life, that would be considered statutory rape in many jurisdictions, regardless of whether the underage participant was willing.
+
No. Archive Warnings apply to the fictional content depicted in the work, so a work featuring a sexual relationship between an adult and a minor would need to use the "Underage Sex" Archive Warning. However, unless a character is clearly depicted in the work as unwilling to engage in sexual activity, then the "Rape/Non-Con" warning (or the "Creator Chose Not To Use Archive Warnings" label) is not required, regardless of the age of any of the characters.
+
If consent is unclear or dubious, is the "Rape/Non-Con" or "Creator Chose Not To Use Archive Warnings" label needed?
+
The primary use of the "Rape/Non-Con" warning is to identify fanworks depicting characters who are clearly unwilling or otherwise forced to engage in sexual activities. Please use your judgment for other situations. When a fanwork features unclear or dubious consent ("dubcon"), we will defer to the creator's decision on how to categorize their work.
+
When is the "Major Character Death" warning needed? What makes a character "major"?
+
If a character has a significant presence in the fanwork, and they die during the course of the story, then the work would require the "Major Character Death" warning (or the "Creator Chose Not To Use Archive Warnings" label). This warning is also required when the fanwork is focused on the character's death, even if it happened prior to the start of the work. It doesn't matter whether the character is a main character or a side character in canon – it's what is in the fanwork that counts. For example, even if a character has only one line in canon, a fanwork that is primarily about that character's funeral and how much their partner misses them would merit this warning.
+
If a major character dies in my fanwork but later returns to life, does the "Major Character Death" warning apply?
+
If the character returns to life or is revealed to not be dead within the same fanwork, then you don't need to apply the "Major Character Death" warning (or the "Creator Chose Not To Use Archive Warnings" label) for this character.
+
Keep in mind that Archive Warnings apply to the entirety of an individual fanwork's posted content, not to any drafts or sequels. If you appear to have killed off a significant character in a specific work, but plan for them to return in a future chapter or sequel, we suggest using "Creator Chose Not To Use Archive Warnings" as the content currently posted does feature character death.
+
What about vampires, zombies, sentient robots, and other characters that aren't "alive"?
+
Please use your best judgment for characters who are or become undead, mechanized, or otherwise non-human. If the character is generally still able to think or act in a somewhat human fashion throughout the course of the story, then the "Major Character Death" warning is not needed. When in doubt, we will defer to the fanwork creator's discretion about whether a character has meaningfully died.
+
What if a major character's death is ambiguous or unclear?
+
This is the kind of decision that is up to the creator's discretion. In general, we will not recategorize a fanwork in response to a complaint when the content at issue is a reference or is otherwise not graphic.
+
Will you ever require that an Archive Warning be removed?
+
No. The presence of an Archive Warning indicates that the work may contain such content, but it is not a guarantee. This includes works marked with "No Archive Warnings Apply". If this warning is accompanied by another Archive Warning (including the non-specific "Creator Chose Not To Use Archive Warnings" label), then the other warning has precedence.
+
What's the difference between ratings and warnings?
+
Ratings are a measure of the intensity of overall content. Warnings refer to a specific type of content that is present.
+
For example, a work may be rated Explicit because it describes an extended, violent torture scene, or because it has a detailed depiction of consensual sex – or both. The rating tells you only that there may be adult content, and does not inform you what type of adult content it is. A warning indicates the work may contain an "onscreen" depiction of a specific type of content, and does not inform you of the level of detail in which it is described. For example, a work rated "Teen" might also carry the "Major Character Death" warning.
+
What sort of rating and warning information am I required to provide for my fanworks?
+
Some users may prefer not to read works with particular ratings and/or warnings, while others may search out works with those same ratings and/or warnings. Our goal is to provide the maximum amount of control and flexibility possible for all users of AO3, both creators and audiences alike, so that each user can customize their own experience. Creators can choose to provide a specific rating and/or Archive Warning(s), or they can choose to opt out of providing a specific rating and/or Archive Warning by using "Not Rated" and/or "Creator Chose Not To Use Archive Warnings", respectively.
+
Can I use "Not Rated" but not "Creator Chose Not To Use Archive Warnings," or vice versa?
+
Yes, absolutely. For example, you could use "Not Rated" and "Rape/Non-Con" for a work that has an explicit rape scene. Or you could rate a work "General" but choose not to warn about the main character dying by using "Creator Chose Not To Use Archive Warnings".
+
How do the ratings and warnings policies apply to embedded images, videos, etc.?
+
When making rating/warning decisions, creators should take into account any content within the work, including embedded images and videos. As with all other content, creators' decisions are presumed reasonable, and using "Not Rated" or "Creator Chose Not To Use Archive Warnings" will always be sufficient.
+
Can I embed explicit images in my fanworks?
+
Explicit drawings and other non-photorealistic artwork are generally allowed to be embedded in a work, as long as the work's rating and warnings appropriately describe both the embedded and the textual content. If you are using other people's images to illustrate your fanwork, you must embed from a source authorized by the creator and provide appropriate credit.
+
You may not embed sexually explicit or suggestive photographic or photorealistic images in works that contain underage sexual content or any other contextual indication (such as in the tags, notes, or text of the work) that the characters may be under the age of 18. This includes (but is not limited to) porn gifs, photo manipulations, and computer-generated or "AI" images.
+
How explicit or graphic can the summaries and tags on my fanworks be?
+
Explicit or graphic content in a summary or tag does not violate the Content Policy, as long as the work is appropriately rated and warned. Please use your judgment about what will best identify and describe your fanworks. The summary and tags are part of your work, and your choice of rating and warnings must reflect all parts of your work.
+
Do AO3 personnel prescreen works as they're uploaded to ensure that they comply with the ratings and warnings policies?
+
No. We will only review a work that has been reported to us for potentially violating the ratings or warnings policies.
+
What's the consequence of a violation of the ratings or warnings policies?
+
Please see the Mandatory Tags policy for details. If we uphold a complaint, we will ask the creator to amend the rating or warning as necessary. If the creator fails to do so, the Policy & Abuse committee may add the non-specific "Not Rated" and/or "Creator Chose Not To Use Archive Warnings" labels as appropriate. In general, a fanwork will not be removed from AO3 merely for not being correctly labeled. However, repeated or deliberate mislabeling may result in additional consequences.
+
What do you mean by "not all works will carry full warnings"?
+
Some creators prefer not to assign specific ratings or warnings to their works, or may not be certain if some content in their work "counts" for a required Archive Warning. Our policy aims to enable creators to opt in or out of using specific ratings or warnings while still allowing users to filter out unwanted content. Works for which creators have opted out of providing specific ratings and/or warnings will be labeled "Not Rated" and/or "Creator Chose Not To Use Archive Warnings". These options aid users in decision-making, albeit with more limited detail than a specific rating or warning tag would.
+
What type of rating and warning information will be provided when I am browsing fanworks?
+
Users who wish to avoid works labeled with specific ratings or Archive Warnings can filter them out or exclude them from searches. Users trying to avoid all possibility of encountering specific ratings or warnings should also avoid works marked as "Not Rated" or "Creator Chose Not To Use Archive Warnings" in the same way, because these works may contain content pertaining to any rating or warning, respectively.
+
Beyond the Archive Warnings, Additional tags can also be used to filter and/or search for works. Users who wish to screen works for other users may also add tags, notes, or recommendations to their bookmarks to warn other users about the subject matter contained in the bookmarked works. This can serve as an extra source of information for users who are trying to determine whether or not to access a work. However, please be aware that Additional tags are not mandatory.
+
If a user does not want to see Archive Warnings and/or Additional tags (for example, if they wish to avoid potential spoilers), then they can hide those tags in their preferences. Doing so will not hide any works that carry warning labels, only the warning labels themselves.
+
All users are responsible for reading and heeding the warnings provided by the creator. Risk-averse users should keep in mind that our ratings and warnings policies are deliberately minimal, and not all content will carry full warnings. If you want to know more about a particular fanwork, you may also wish to consult the bookmarks that people other than the creator have used to organize it.
+
If I'm not logged in, what can I see?
+
You can see the summary and tags (including Archive Warnings) of any work whose creator has not restricted access to AO3 users only. In addition, you can access any unrestricted work rated "General" or "Teen" without logging in or clicking anything else. For the other ratings ("Mature", "Explicit", and "Not Rated"), you will be asked to agree that you are willing to see adult content before you can access the work.
The tags on your work must meet the following criteria:
+
+
Rating: If the work contains graphic adult content, you must rate the work as either "Mature" or "Explicit", or use the "Not Rated" tag.
+
Archive Warnings: If the work contains depictions of content described by one of the four specific Archive Warnings, you must apply that Archive Warning tag or the "Creator Chose Not To Use Archive Warnings" tag.
+
Fandoms: The work may only use fandom tags that directly relate to content currently present in the work.
If you wish to provide other users with more information about your work, you are welcome to do so using the non-mandatory tag fields (such as Additional tags), your work summary, and/or the notes of your work.
+
What is a non-specific tag?
+
Some mandatory fields may have non-specific tags, such as "Not Rated", "Creator Chose Not To Use Archive Warnings", or "Unspecified Fandom". These tags indicate that the creator has deliberately chosen not to provide more specific information in the mandatory tag field. For example, sometimes creators aren't certain whether a specific tag would apply, or want to avoid using a specific tag because they believe it would reveal spoilers. Using a non-specific tag is always sufficient tagging for that tag field. The Policy & Abuse committee will not require any work that bears a non-specific tag to have more tags added to that field. Users who wish to avoid certain types of content should avoid all works that use non-specific tags.
+
What should I do if no language tag exists for the primary language used in my work?
+
If the language is one that you or someone else created, you can use the "Uncategorized Constructed Language" tag. If the language is not a constructed language ("conlang"), please contact the Support committee.
+
Will you require an incorrect language tag to be changed?
+
In general, we will assume good faith from creators. However, if it is clear that the language tag used does not apply to the work, then we may update the work's language tag.
+
In order to report a work that uses an incorrect language tag, please contact the Support committee and make sure to include a link to the work in your report.
+
In what circumstances will you remove a fandom tag from a work?
+
The Policy & Abuse committee may remove a fandom tag when there is no relationship between the particular fandom itself and the work. For example, a fanwork that discusses vampire physiology and uses only examples from Buffy the Vampire Slayer and The Vampire Diaries should not add in fifteen additional fandom tags simply because those fandoms also feature vampires.
+
Note that we will apply this rule restrictively. We will not intervene in cases of disagreement over, for example, whether a movie-based work can use the fandom tag for a comic when there are both movie and comics versions of a source. This is the kind of decision a creator is best suited to make and falls within our policy of deference to the creator.
+
If you believe a work's tags are misleading, then we encourage direct, polite conversation with the creator. However, if the work has fandom tags that aren't represented in the work, then you can report that to the Policy & Abuse committee.
+
This work is about a fandom that the creator hasn't tagged. Will you require them to add the fandom tag to their work?
+
No, we will not require that a creator add a specific fandom tag to their work. However, we may apply the non-specific "Unspecified Fandom" tag if the creator has not applied any suitable fandom tags themselves. If you would like to avoid encountering a particular work, then we recommend that you mute the creator or the work.
+
Why do some tags have "RPF" on the end? What's the difference between a fandom tag with RPF and without?
+
RPF stands for Real Person Fiction. "RPF" is often used to distinguish fandom tags intended for fanworks about a canon's real-life creators (actors, directors, voice actors, authors, etc.), as opposed to fanworks about the fictional characters that appear in any books, movies, or other canons.
+
When you are posting your work, you may find that entering the name of a canon into the fandom tag field results in two canonical tags appearing in the autocomplete, one with "RPF" at the end and one without. If your work is about the real people who created the canon, rather than about the fictional canon itself, then you should use the RPF fandom tag for your work. If your work is about the fictional characters or universe, you should use the non-RPF fandom tag instead.
+
In some cases, RPF fandoms and works don't have fandom tags with "RPF" at the end. For example, canonical fandom tags about musicians or bands may consist of the names of those individuals or groups. Fandom tags that consist of the name(s) of real people are also considered RPF fandom tags and can be used on works about those people.
+
For more information about RPF and non-RPF tags, please refer to the Tags FAQ. The Policy & Abuse committee may remove fandom tags from your work if you've used a non-RPF tag for RPF content, or vice versa.
+
Am I allowed to use an RPF fandom tag if my work is about the reader, or if it contains a self-insert character?
+
You should not use RPF fandom tags unless your work is about the real people who contributed to the creation of the fictional canon. If the reader or self-insert character is interacting with the fictional characters from the canon and not with the canon's writers, actors, etc., then the work is not RPF and should only be tagged with the non-RPF fandom tag.
+
I'm posting a work that will contain content for a particular fandom in a future chapter that I haven't posted yet. Can I use that fandom tag to advertise the upcoming content of my work?
+
No. To use a specific fandom tag, the work must currently contain fanwork content pertaining to that fandom. Once you post a chapter featuring characters from or set in the universe of that fandom, you may add the fandom tag to the work. Until then, you can use the notes, summary, or Additional tags to let other users know about your plans.
+
A work is appearing in my fandom's tag even though it's not tagged with or about my fandom. I think the tag the creator used was linked to my fandom by mistake. Can I report that?
+
Our volunteer tag wranglers connect tags to each other to help users locate fanworks. However, at times this can lead to unexpected search results. If you believe that a tag has been incorrectly wrangled, you can contact the Support committee with a brief explanation of why these tags shouldn't be connected together.
+
I want to report a work that has a wrong language tag and inapplicable fandom tags. Who should I report it to?
+
If the language tag is the only miscategorization on the work, you may report it to the Support committee. If there is any other type of miscategorization or violation of the Terms of Service (for example, if the work is not a fanwork or is incorrectly rated), you should report it to the Policy & Abuse committee instead, whether or not there is an incorrect language tag on the work.
+
What if a work has an incorrect category, relationship, character, or additional tag?
+
The Policy & Abuse committee will only evaluate the accuracy of tags in mandatory fields. We will not add, edit, or remove incorrect category, relationship, character, or additional tags. Our resources are limited and it would be challenging to impartially and fairly establish or enforce accuracy rules for these types of tags. However, our general policies against harassment and spam apply to all tags, as they do to any content posted on AO3.
+
Can other users add tags to my fanworks? How does that work?
+
The only tags displayed on the fanwork itself will be the tags that the creator added to it. If another user bookmarks the work, they may add tags to the bookmark. Users can search for bookmarks with Tag Search or Bookmark Search. Bookmark tags are also visible when viewing a user's public bookmarks. However, bookmark tags will not impact or appear in results when using Work Search or browsing works listings.
+
Someone has added a tag I hate to a bookmark of one of my fanworks!
+
We recommend that you mute the bookmarker. In general, tags and notes on bookmarks can be positive or negative. Like any other content, tags are subject to the Content Policy, so if the tag violates our harassment, personal information, or other policies, please report it. However, criticism of a fanwork is not considered harassment in and of itself. Bookmark tags and notes will not automatically be displayed on fanworks, in order to allow you to avoid them.
+
Where can I find more information about tags?
+
Please check out our Tags FAQ for more information about how tags function and how they are generally used.
What's this about the spam filter? Can I be permanently suspended if I fail the filter?
+
You shouldn't encounter the spam filter if you're logged in. If we discover accounts created solely to post spam, we will permanently suspend those accounts, but such cases are always reviewed by members of the Policy & Abuse committee. If you're a human being reading this FAQ, you shouldn't worry about the automated spam-control measures.
+
What do you mean by "conduct that threatens the technical integrity of AO3"?
+
Basically, we mean attempts to hack the site or deliberately exploit a code vulnerability in order to engage in destructive behavior on AO3. Spreading viruses or other unwanted programs, redirecting users to spam sites, or trying to undermine or evade compliance with our Terms of Service through technological means are all examples of attempting to interfere with or threaten the technical integrity of the site.
+
Does that mean I can't have nifty formatting in my work?
+
No, this is just a security policy. You will be able to have plenty of nifty formatting, but not everything imaginable. As a practical security matter, we do not allow JavaScript in works posted on AO3, and only allow a limited subset of HTML and CSS. This is because there is no secure way to allow people to start uploading unfiltered code. For content that uses a lot of custom code, we recommend hosting it on your own website. You can make a bookmark or post a simpler version on AO3 and link to the fancier version in the notes.
+
Do you have a policy on bots or scraping? These are ways of extracting information from or indexing websites.
+
The use of bots or scraping for spam, commercial promotion, or other purposes that violate our policies is forbidden. This includes scraping AO3 for the purposes of obtaining material for commercial generative AI or creating an app that hosts or paywalls AO3 content.
+
The use of bots or scraping for purposes that do not violate our policies is generally allowed. However, we reserve the right to implement robots.txt or other protocols limiting what bots can do, or to notify you and ask you to discontinue if a bot or scraping program is causing problems for the site. At our discretion, we may also ban specific bots or other programs from accessing AO3.
Cookies may be required to facilitate and customize your site experience, such as by allowing you to log in to your AO3 account. If you do not accept cookies, you may not be able to use the site. There are many ways to remove both browser history and cookies, and we encourage people who are concerned about privacy to investigate broader solutions. For example, most internet browsers can be set to clear your private data automatically between sessions.
+
Which AO3 features collect, process, retain, and/or display my content or personal information, and how do they use it?
We may collect, process, retain, and display your content to the general public, including any personal information you've included in that content, when you use the following AO3 features:
+
+
Post or edit a work or chapter, when the work is available to the general public
+
Create or edit a work skin that is applied to a work that is available to the general public
+
Edit information about a series, when any work in that series is available to the general public
+
Create or edit a bookmark, when the bookmark is not marked private
+
Choose a username or pseud
+
Edit your profile page
+
Edit the description for one of your pseuds
+
Upload an icon
+
Give kudos, both when you are logged in and when you are not logged in
+
Create or edit information about a Challenge or other type of Collection
Post or edit a comment, when you comment on a news post or on a work that is available to the general public
+
+
We also collect, process, and retain personal information that is associated with the content, including your IP address. We may display this information to AO3 administrators for the purposes of managing the site and enforcing the Terms of Service.
+
Display to other AO3 users
+
We may collect, process, retain, and display your content to other AO3 users, including any personal information you've included in that content, when you use the following AO3 features:
Post or edit a comment, when you comment on a work that is available to other AO3 users
+
+
We also collect, process, and retain personal information that is associated with the content, including your IP address. We may display this information to AO3 administrators for the purposes of managing the site and enforcing the Terms of Service.
+
Display to yourself and/or to AO3 administrators
+
We may collect, process, retain, and display your content and preferences to yourself and/or to AO3 administrators, including any personal information you've included in that content, when you use the following AO3 features:
+
+
Request an account invitation, whether or not you choose to create an account
+
Create an account
+
Save a work in draft form
+
Create or edit a private bookmark
+
Update your preferences
+
Mark a work for later
+
Favorite a tag
+
Block a user
+
Mute a user
+
Create or edit a site skin
+
Create or edit a work skin, even without applying it to a work
+
+
We also collect, process, and retain personal information that is associated with the content, including your IP address. We may display this information to AO3 administrators for the purposes of managing the site and enforcing the Terms of Service.
+
Why do AO3 features need to collect, process, retain, and/or display my content or information in the ways that they do?
+
Because you want your content or information to be available to other people
+
AO3 is a site for fans to share fanworks. When you post a work and set it to be available to the general public, you are agreeing to make that content available to everyone. Similarly, the purpose of using other public-facing AO3 features is because you want that content to be available to the public. When you post a work and set it to be available to registered AO3 users only, you are agreeing to make that content available to those users.
+
Similarly, other AO3 features or content may be accessible by all registered AO3 users or only accessible to specific registered AO3 users (such as the co-creator(s) of a work or the maintainer(s) of a collection). The purpose of using these features is because you want that content to be available to those people. For example, if you're collaborating with someone else on a draft work where you have added them as a co-creator, then you want your co-creator to be able to access the draft. If you have submitted a work to a collection, then you have chosen to submit content to a part of AO3 that the collection maintainer controls. In all cases, we need to collect, process, and retain certain information about you and your content in order to make it available to those people.
+
Because you want your content or information to be associated with your identity
+
Usernames and pseudonyms allow you to develop your account as a fannish identity on AO3. When you take actions while using a username or pseud (as opposed to not being logged in, or not using a pseud), it's because you want your username or pseud to be associated with those actions and/or the content that you've uploaded. In order to connect your actions and content to your AO3 account and/or pseud, we need to collect, process, and retain the associated personal information about your account, including the text you entered for your username or pseud.
+
Because you want your content or information to be available to yourself
+
Some of our features are designed to let you upload content that you want to access later. For example, when you create a draft work or a private bookmark, use the "Mark for Later" feature, or favorite a tag, your purpose is to be able to return to them later. In order to allow you to do so, we need to collect, process, and retain the content that you provided and the associated information.
+
To allow you to customize your AO3 experience
+
Several of our features are designed to let you customize the way AO3 is displayed to you or the ways in which other users can interact with you. We need the data collected by these features in order to customize your AO3 experience in the ways you've requested. For example, we need access to your blocklist in order to ensure that users you have blocked can't leave comments or kudos on your works or reply to your comments; and we need access to your mutelist to ensure that you won't be shown works, bookmarks, comments, or series by users you've muted. Similarly, when you update your preferences, we need to collect, process, and retain the content and/or information that you provided in order to implement those preferences for you.
+
To operate, maintain, and protect AO3
+
In order to be able to enforce the Terms of Service, ensure we are compliant with applicable legislation, and handle any legal matters that may arise, AO3 administrators (such as the Policy & Abuse committee, the Legal committee, and the OTW Board of Directors) may need to view content that was uploaded, or the associated data, even if that content is not visible to the general public or to other AO3 users.
+
We also need the data collected by each feature to internally manage AO3. For example, we want to count only one view of a URL per cookie session on the "Hit" count for a work. Temporarily collecting, processing, and retaining the IP addresses of users who leave kudos while logged out permits us to conduct internal management of kudos and avoid duplication of kudos, without requiring you to log in and associate your username with the kudos. Our systems and administrators need the data to manage our services, prevent abuse or spam, and maintain the integrity of AO3.
+
To assist you with using AO3
+
It's possible you may need assistance with your account. In such cases, the Support committee or other AO3 administrators may need to look at your content or associated data in order to troubleshoot a problem you're having, assist you with using a particular AO3 feature, or diagnose or solve a possible bug. For example, if you're having difficulty with not receiving emails about comments on your works, an administrator may need to check whether your Preferences are set correctly.
+
What happens if I change the privacy settings on my work?
+
When you post or edit a work on AO3, you can choose to restrict access to other logged-in users only. You can change this setting at any time. If the work was originally accessible to AO3 users only, and you (or a co-creator) update the work to make it accessible to the general public, then the work and any content associated with the work (such as bookmarks, comments, or series) will become accessible to the general public at that time. Similarly, if your work was previously set to be accessible to the general public, and you update the work to make it accessible to AO3 users only, then the content associated with the work will no longer be accessible to the general public.
+
If you have a series where all works in the series are only accessible to other AO3 users, and you (or a series co-creator) add to the series a work that is accessible to the general public, then the series content will also become accessible to the general public at that time. This applies to the series content only, and not to specific works in the series, which are all controlled separately.
+
You (or a co-creator) can add your work to a collection that is maintained by yourself and/or someone else. Adding your work to an anonymous and/or unrevealed collection will limit some information about and/or access to the work to yourself and to the collection maintainers. The collection maintainers can change the collection settings or remove your work from their collection at any time. You can remove your work from a collection at any time.
+
What information can a co-creator access? What privacy settings can they change?
+
When you invite a co-creator to a work you've uploaded to AO3, you are giving them the ability to edit or delete the work, add or remove the work from a series or collection, or delete any of its comments at any time, regardless of whether the work has been posted or is still in draft form. Any co-creators of a work can change the privacy settings for that work at any time, without notifying you. If the co-creators of a work have different account preferences regarding their works' accessibility or permissions, the work will adhere to the least restrictive preference. For example, if one co-creator allows their works to be invited to collections, and another co-creator does not, it will be possible to invite the co-created work to a collection.
+
When you invite a co-creator to a series you've created, you are giving them the ability to edit or delete the series (but not any works in the series, unless they are also a co-creator of the work(s) in question). If all works in the series are only accessible to other AO3 users, and your co-creator adds to the series a work that is accessible to the general public, then the series content will also become accessible to the general public at that time. This applies to the series content only, and not to specific works in the series, which are all controlled separately.
+
We recommend that you do not invite anyone as a co-creator of your work unless you are comfortable with giving that person equal access to and control over that work and the content and settings associated with it. You cannot remove a co-creator from your work after you have invited them. Co-creators can remove themselves from a work at any time.
+
What information can a challenge or collection maintainer access? What privacy settings can they change?
+
If you add your work to a collection, the owners and moderators of the collection ("collection maintainers") will be able to access (but not edit or delete) the content of the work. They will also know the usernames and pseuds of all creators associated with the work, even if the work is in a collection marked as anonymous or unrevealed. You can remove your work from a collection at any time. A collection maintainer can remove a work from their collection at any time, or remove the "anonymous" and/or "unrevealed" status provided by their collection, without notifying you. When this occurs, the creators' usernames and/or the content of the work will become accessible either to other AO3 users or to the general public, depending on the work settings.
+
A maintainer of a Gift Exchange challenge can access sign-ups as well as the usernames and email addresses that participants use to sign up. This is in case the maintainer needs to communicate with the exchange's participants. Maintainers of other types of collections do not have access to other users' email addresses.
+
Please note that using this information in any way other than to manage the collection or challenge is a violation of our Terms of Service and may result in the permanent suspension of the maintainer's account.
+
If a new feature is introduced, how will it handle my content and personal information?
+
If the feature is designed to display content to other users or to the general public, then when you choose to use that feature, your content may be displayed to other users or to the general public. Any personal information or other data we collect will be used to operate that feature and other integrated features on AO3, maintain AO3, and prevent spam and abuse of the feature or AO3. Administrators may have access to any content uploaded with the feature, and associated data, for these purposes.
+
AO3 is committed to protecting the privacy of our users. We will never sell your personal information, data, or other content. If you have a specific question about how your content and/or information is collected or used, please contact the Policy & Abuse committee.
What information do you sell, trade, or rent to third parties?
+
None. We do not and will not sell, trade, or rent information, including your personal information.
+
What legal rights do I have under data privacy laws?
+
If you are located in the European Union, the United Kingdom, or parts of the United States, you may have rights regarding your personal information or personal data that qualifies as such under the laws of your jurisdiction.
+
As provided by the laws of your applicable jurisdiction, these rights may include:
+
+
The right to know what personal information we have collected about you
The right to request that your personal information be provided in portable form
+
The right to not be discriminated against for exercising your legal rights
+
The right to not be subject to a decision that would affect your legal rights based solely on automated processing of your personal information
+
+
What lawful grounds does AO3 rely upon to process personal information from users in the EU and the UK?
+
We rely on the following lawful grounds to process personal information from users in the EU and the UK:
+
+
It is necessary for the performance of a contract with you
+
Our or a third party's legitimate business interest
+
Your consent
+
+
If we are processing your personal information based on the grounds that you consented to it, you have the right to withdraw your consent for such processing at any time. Withdrawing your consent does not affect the lawfulness of consent-based processing that occurred prior to when your consent was withdrawn.
+
These protections are not limited specifically to users from the EU and the UK. We limit our data processing in this way for all users. If you have questions regarding the protection of your personal information, you can contact the Policy & Abuse committee.
+
How do I exercise my rights under data privacy laws such as the General Data Protection Regulation (GDPR) or the California Consumer Privacy Act (CCPA)?
+
To exercise the rights available to you as a data subject or consumer under applicable data privacy laws, contact the Policy & Abuse committee. In order for us to confirm your request, the email address you enter must be the one associated with your account (if applicable) and you must be able to send and receive emails from that address. In the subject and description of your request, please specify which data privacy law (GDPR, CCPA, etc.) applies to you, and clearly state what kind of data request you are making (for example, you can request a copy of your personal information). We may require you to submit additional personal information necessary to verify your identity and status as a data subject. Repeated requests within a 1-year timeframe may incur a fee.
+
Please note that you may be able to exercise some of these rights without our intervention. For example, if you are a registered user, you can access and update certain personal information via your account preferences.
+
What is the procedure for requesting details about the information you sell, trade, or rent to third parties for direct marketing purposes under the California Consumer Privacy Act (CCPA)?
+
The CCPA only applies to for-profit entities. Because the Organization for Transformative Works is a non-profit, we are not required to have such a procedure. More importantly, we do not have such a procedure because we do not sell, trade, or rent information to third parties for any reason, including for direct marketing purposes.
+
Do you recognize and comply with Do Not Track signals or opt-out preference signals?
+
Some web browsers, mobile applications, and operating systems allow users to signal their preferences regarding the tracking of their personal information. These are known as Do Not Track (DNT) signals or opt-out preference signals. AO3 does not respond to DNT or opt-out preference signals because we do not use, sell, or share your personal information for targeted advertising purposes. In addition, we collect only the minimum data necessary to operate the site and/or that you have consented to provide to us. Without this minimum data, you can't access the site.
+
If a standard for online tracking is adopted that we must follow in the future, we will inform you about that practice in a revised version of the Privacy Policy.
+
+<%= flash_div :comment_error, :comment_notice %>
+
+
+
+<% # Filters button for narrow screens jumps to filters when JavaScript is disabled and opens filters when JavaScript is enabled %>
+
Sorry, you have no unsent invitations right now. <%= link_to ts('Request invitations'), new_user_invite_request_path %>
+ <% else %>
+
You have <%= @user.invitations.unsent.count.to_s %> open invitations and <%= @user.invitations.unredeemed.count.to_s %> that have been sent but not yet used.
+ <%= form_tag invite_friend_user_invitations_path(@user) do %>
+
Please put a little bit about you and a statement affirming you agree with anti harassment & anti-censorship ideals in the field below. Requests without at least something in this field will be deleted without warning.
+ <%= t(".already_requested_html", check_waitlist_position_link: link_to(t(".check_waitlist_position"), status_invite_requests_path)) %>
+ <%= t(".waiting_list_count", count: InviteRequest.count) %>
+ <%= t(".invitation_send_rate",
+ queue_invitation_number: t(".invitation_number", count: AdminSetting.current.invite_from_queue_number),
+ queue_frequency: t(".frequency", count: AdminSetting.current.invite_from_queue_frequency)) %>
+ You will get your invite within 1-3 days of submitting your request. The queue is set to several decades from now to allow for manual reviewing of invites; it won't actually take that long!
+ <%= f.text_area :content, :class => "mce-editor observe_textlength", :id => "content" %>
+ <%= live_validation_for_field('content',
+ :maximum_length => ArchiveConfig.CONTENT_MAX, :minimum_length => ArchiveConfig.CONTENT_MIN,
+ :tooLongMessage => ts("We salute your ambition! But sadly the content must be less than %{max} letters long.", :max => ArchiveConfig.CONTENT_MAX.to_s),
+ :tooShortMessage => ts("Brevity is the soul of wit, but your content does have to be at least %{min} letters long.", :min => ArchiveConfig.CONTENT_MIN.to_s),
+ :failureMessage => ts("You need to post some content here."))
+ %>
+ <%= generate_countdown_html("content", ArchiveConfig.CONTENT_MAX) %>
+
+ <% if has_user_kudos %>
+ <%= kudos_user_links(commentable, kudos, showing_more: false) %>
+ <% end %>
+ <% if guest_kudos_count > 0 %>
+ <% if has_user_kudos %>
+ <%= ts(" as well as ") %>
+ <% end %>
+ <%= ts(pluralize(guest_kudos_count, "guest")) %>
+ <% end %>
+ <%= ts(" left kudos on this work!") %>
+
\ No newline at end of file
diff --git a/app/views/locales/edit.html.erb b/app/views/locales/edit.html.erb
new file mode 100755
index 0000000..30d0e1d
--- /dev/null
+++ b/app/views/locales/edit.html.erb
@@ -0,0 +1,13 @@
+
+
+<% end %>
+
diff --git a/app/views/orphans/_choose_pseud.html.erb b/app/views/orphans/_choose_pseud.html.erb
new file mode 100755
index 0000000..f2294ac
--- /dev/null
+++ b/app/views/orphans/_choose_pseud.html.erb
@@ -0,0 +1,8 @@
+
+ <%= radio_button_tag 'use_default', 'true', true %>
+ <%= label_tag 'use_default_true', ts('Take my pseud off as well') %>
+
+
+ <%= radio_button_tag 'use_default', 'false' %>
+ <%= label_tag 'use_default_false', ts('Leave a copy of my pseud on') %>
+
diff --git a/app/views/orphans/_orphan_pseud.html.erb b/app/views/orphans/_orphan_pseud.html.erb
new file mode 100755
index 0000000..9de5848
--- /dev/null
+++ b/app/views/orphans/_orphan_pseud.html.erb
@@ -0,0 +1,43 @@
+
+<% works = pseud.works.to_a %>
+
<%= ts("Orphan All Works by %{name}", name: pseud.name) %>
+
+
+ <%= ts("Orphaning all works by %{name} will", name: pseud.name)%>
+ <%= ts("permanently")%>
+ <%= ts("remove the pseud %{name} from the following work(s), their chapters, associated series, and any feedback replies you may have left on them.", name: pseud.name) %>
+
+
+
+ <%= ts("Unless another one of your pseuds is listed as a creator on the work(s) below, orphaning them will remove them from your account and re-attach them to the specially created orphan_account. Please note that this is")%>
+ <%= ts("permanent and irreversible.")%>
+ <%= ts("You are giving up control over the work(s),")%>
+ <%= ts("including the ability to edit or delete them.")%>
+
+
+<%= render "works/work_abbreviated_list", works: works %>
+
+
+ <%= ts("Are you")%>
+ <%= ts("really")%>
+ <%= ts("sure you want to do this?")%>
+
+<% end %>
+
diff --git a/app/views/orphans/_orphan_series.html.erb b/app/views/orphans/_orphan_series.html.erb
new file mode 100755
index 0000000..8687e8b
--- /dev/null
+++ b/app/views/orphans/_orphan_series.html.erb
@@ -0,0 +1,24 @@
+
+
<%= ts("Orphan Series") %>
+
+
+ <%= ts('Are you sure you want to permanently remove all identifying data from your series "%{title}", its works and chapters, and any feedback replies you may have left on them?',
+:title => series.title).html_safe %>
+
+<% end %>
+
+
+
+
diff --git a/app/views/orphans/_orphan_user.html.erb b/app/views/orphans/_orphan_user.html.erb
new file mode 100755
index 0000000..7816fbf
--- /dev/null
+++ b/app/views/orphans/_orphan_user.html.erb
@@ -0,0 +1,33 @@
+
+<% works = user.works.to_a %>
+
<%= ts("Orphan All Works") %>
+
+
<%= t(".orphaning_works_message_html") %>
+
+
<%= t(".orphaning_bylines_only_message_html") %>
+
+
+ <%= ts("Orphaning a work removes it from your account and re-attaches it to the specially created orphan_account. Please note that this is")%>
+ <%= ts("permanent and irreversible.")%>
+ <%= ts("You are giving up control over the work,")%>
+ <%= ts("including the ability to edit or delete it.")%>
+
+
+<%= render "works/work_abbreviated_list", works: works %>
+
+
+ <%= ts("Are you")%>
+ <%= ts("really")%>
+ <%= ts("sure you want to do this?")%>
+
+
+<%= form_tag orphans_path do %>
+
+ <% works.each do |work| %>
+ <%= hidden_field_tag "work_ids[]", work.id, id: "work_ids_#{work.id}" %>
+ <% end %>
+
+
+ <%= render "orphans/choose_pseud" %>
+
<%= submit_tag ts("Yes, I'm sure") %>
+<% end %>
diff --git a/app/views/orphans/_orphan_work.html.erb b/app/views/orphans/_orphan_work.html.erb
new file mode 100755
index 0000000..7bb70e3
--- /dev/null
+++ b/app/views/orphans/_orphan_work.html.erb
@@ -0,0 +1,43 @@
+
+
<%= ts('Orphan Works') %>
+
+
+ <%= ts("Orphaning will")%>
+ <%= ts("permanently")%>
+ <%= ts("remove all identifying data from the following work(s), their chapters, associated series, and any feedback replies you may have left on them.") %>
+
+
+
+ <%= ts("Orphaning a work removes it from your account and re-attaches it to the specially created orphan_account. Please note that this is")%>
+ <%= ts("permanent and irreversible.")%>
+ <%= ts("You are giving up control over the work,")%>
+ <%= ts("including the ability to edit or delete it.")%>
+
+
+<%= render "works/work_abbreviated_list", :works => works %>
+
+
+ <%= ts("Are you")%>
+ <%= ts("really")%>
+ <%= ts("sure you want to do this?")%>
+
<%= link_to ts("Delete"), confirm_delete_tag_set_path(@tag_set),
+ data: {confirm: ts('Are you certain you want to delete this Tag Set? This will delete all nominations and tag set associations as well!')} %>
+ <% end %>
+ <% unless current_page?(tag_set_path(@tag_set)) %>
+
<%= link_to ts("Back To %{title}", :title => @tag_set.title), tag_set_path(@tag_set) %>
+ <% end %>
+
<%= link_to ts("All Tag Sets"), tag_sets_path %>
+
+<% end %>
diff --git a/app/views/owned_tag_sets/_show_fandoms_by_media.html.erb b/app/views/owned_tag_sets/_show_fandoms_by_media.html.erb
new file mode 100755
index 0000000..c38ce22
--- /dev/null
+++ b/app/views/owned_tag_sets/_show_fandoms_by_media.html.erb
@@ -0,0 +1,47 @@
+<% @tag_hash[:fandom].keys.each do |media| %>
+
+
+ <% unless @tag_hash[:character] || @tag_hash[:relationship] %>
+ <% # we're just doing fandoms -- pop them into li and move on! %>
+ <%= @tag_hash[:fandom][media].map {|fandom| content_tag(:li, fandom)}.join("\n").html_safe %>
+ <% else %>
+ <% # some fandoms have chars or rels underneath %>
+ <% @tag_hash[:fandom][media].each do |fandom| %>
+ <% # test to make sure we've actually got chars or rels for this individual fandom %>
+ <% has_characters = (@tag_hash[:character] && @tag_hash[:character][fandom]) ? @tag_hash[:character][fandom].size : 0 %>
+ <% has_relationships = (@tag_hash[:relationship] && @tag_hash[:relationship][fandom]) ? @tag_hash[:relationship][fandom].size : 0 %>
+ <%
+ # this gets called a ton if there are a lot of fandoms so not doing it in a partial to save on time, although
+ # as a result it's duplicated in show_tag_set_tags :(
+ %>
+
+ <% if (has_relationships + has_characters) > 0 %>
+
+<% end %>
diff --git a/app/views/owned_tag_sets/_show_tag_set_associations.html.erb b/app/views/owned_tag_sets/_show_tag_set_associations.html.erb
new file mode 100755
index 0000000..fc26737
--- /dev/null
+++ b/app/views/owned_tag_sets/_show_tag_set_associations.html.erb
@@ -0,0 +1,15 @@
+<% if @associations && !@associations.empty? %>
+
<%= ts("Tag Set Associations") %> (<%= @associations.count %>)
+
+
+ <% @associations.group(:parent_tag_id).joins(:tag, :parent_tag).
+ select("parent_tags_tag_set_associations.name as parent, group_concat(tags.name ORDER BY tags.name) as children").each do |assoc| %>
+
+ <% end %>
+
+<% end %>
\ No newline at end of file
diff --git a/app/views/owned_tag_sets/_show_tag_set_tags.html.erb b/app/views/owned_tag_sets/_show_tag_set_tags.html.erb
new file mode 100755
index 0000000..bd73e4a
--- /dev/null
+++ b/app/views/owned_tag_sets/_show_tag_set_tags.html.erb
@@ -0,0 +1,70 @@
+
+ <% # first show any ratings, categories, or warnings, since there will only be a small number if any %>
+ <% TagSet::TAGS_AS_CHECKBOXES.each do |tag_type| %>
+ <% if @tag_set.has_type?(tag_type.classify) %>
+ <%= render "show_tags_in_single_list", :tag_type => tag_type.classify %>
+ <% end %>
+ <% end %>
+
+ <% if @tag_hash[:fandom] %>
+ <% # we're doing the fandoms by media and any char/rel tags will be nested %>
+ <%= render "show_fandoms_by_media" %>
+ <% end %>
+
+ <% if @tag_hash[:character] || @tag_hash[:relationship] %>
+ <% # if there was a fandom display, check for any that weren't shown in the fandom display %>
+
+ <% if @tag_hash[:fandom] %>
+ <% @unassociated_chars.reject! {|c| @character_seen[c]} %>
+ <% @unassociated_rels.reject! {|r| @relationship_seen[r]} %>
+ <% unless @unassociated_chars.empty? && @unassociated_rels.empty? %>
+
<%= ts("The following characters and relationships don't seem to be associated with any fandom in the tagset. You might need to add the fandom, or set up associations for them.") %>
+ <% end %>
+
+ <% else %>
+
+ <% @fandom_keys_from_other_tags.each do |fandom| %>
+ <%
+ # this gets called a ton if there are a lot of fandoms so not doing it in a partial for performance, although
+ # as a result it's mostly duplicated in show_fandoms_by_media :(
+ %>
+ <% has_characters = (@tag_hash[:character] && @tag_hash[:character][fandom]) ? @tag_hash[:character][fandom].size : 0 %>
+ <% has_relationships = (@tag_hash[:relationship] && @tag_hash[:relationship][fandom]) ? @tag_hash[:relationship][fandom].size : 0 %>
+
+ <% if (has_relationships + has_characters) > 0 %>
+
+ <% end %>
+
+ <% end %>
+ <% end %>
+
+ <% # finally, freeform tags, if necessary broken up into alphabetical lists %>
+ <%= render "show_tags_by_alpha", :tag_type => "freeform" %>
+
diff --git a/app/views/owned_tag_sets/_show_tags_alpha_listbox.html.erb b/app/views/owned_tag_sets/_show_tags_alpha_listbox.html.erb
new file mode 100755
index 0000000..99773ae
--- /dev/null
+++ b/app/views/owned_tag_sets/_show_tags_alpha_listbox.html.erb
@@ -0,0 +1,36 @@
+<% # expects locals tag_type, tagname_list, and to be enclosed inside an ol/ul; can also pass itemcount to alter the number of items in a group %>
+<%
+ # too many tags to throw at the user!
+ # this gets a little tricky: here's what this code does
+ # every time we see a new letter at the start of a tagname, we want to start a new heading + inner ol
+ # we only want one heading for 0-9
+ # we start with that one, but we might not see any tags with a number, so we only display
+ # the heading if we actually see one
+ # once we've opened a new heading and inner ol if we're going to, we just show the tagname
+ # in a list item
+%>
+<% itemcount ||= 30 %>
+
<%= ts('Tag sets are used for running a challenge.') %>
+
<%= ts('"Visible" tag sets are shown to all users.') %>
+
<%= ts('"Usable" tag sets can be used by others in their challenges.') %>
+
<%= ts('Tag sets that are open to nominations can take nominations from the public.') %>
+
<%= ts('Tag names have to be unique. If necessary the archive may add on the tag type. (For instance, if you entered a character "Firefly", you\'d see "Firefly - Character" in your tag set instead since the tag Firefly is already used for the show.') %>
+ <% unless associations_for_remove.empty? %>
+ <%= render "tag_set_associations_remove", :form => tag_set_form %>
+ <% end %>
+ <% unless @tag_set.new_record? || @child_tags_in_set.empty? || @parent_tags_in_set.empty? %>
+ <%
+ # this next div is used to hold the last id for adding new associations via javascript
+ # because we don't generate an initial tag set association form, we have to put one of these into the upper level form
+ # with the right id to start from
+ %>
+ <% associations_for_add.each do |assoc| %>
+ <%= tag_set_form.fields_for :tag_set_associations, assoc do |assoc_form| %>
+ <%= render "tag_set_association_fields", :form => assoc_form %>
+ <% end %>
+ <% end %>
+
+
+ <%= submit_fieldset tag_set_form %>
+
+<% end %>
diff --git a/app/views/owned_tag_sets/_tag_set_form_management.html.erb b/app/views/owned_tag_sets/_tag_set_form_management.html.erb
new file mode 100755
index 0000000..a92756c
--- /dev/null
+++ b/app/views/owned_tag_sets/_tag_set_form_management.html.erb
@@ -0,0 +1,63 @@
+<% # implied opening fieldset tag %>
+
+ <%= ts("To add or remove an owner or moderator, enter their name. If they are already on the list they will be removed; if not, they will be added.
+ You can't remove the sole owner of a tag set.") %>
+
<%= ts("If you allow both fandoms and characters/relationships in the same tag set,
+ the number of characters/relationships is per fandom.").html_safe %>
+
<%= ts("If that's not what you want, you
+ can have users nominate fandoms in one tag set, and characters/relationships in another tag set. Then use both tag sets in your challenge settings.").html_safe %>
+
+
+ <% TagSet::TAG_TYPES_INITIALIZABLE.each do |tag_type| %>
+
\ No newline at end of file
diff --git a/app/views/owned_tag_sets/confirm_delete.html.erb b/app/views/owned_tag_sets/confirm_delete.html.erb
new file mode 100755
index 0000000..af9b12f
--- /dev/null
+++ b/app/views/owned_tag_sets/confirm_delete.html.erb
@@ -0,0 +1,12 @@
+
+
<%= ts("Are you certain you want to delete the %{tagset_title} Tag Set? This will delete all nominations and Tag Set associations as well!", :tagset_title => @tag_set.title).html_safe %>
+
+ <%= f.submit ts("Yes, Delete Tag Set") %>
+ <%= link_to ts("Back to the Tag Set"), tag_set_path(@tag_set) %>
+
+<% end %>
+
diff --git a/app/views/owned_tag_sets/edit.html.erb b/app/views/owned_tag_sets/edit.html.erb
new file mode 100755
index 0000000..48b3052
--- /dev/null
+++ b/app/views/owned_tag_sets/edit.html.erb
@@ -0,0 +1,7 @@
+
+
\ No newline at end of file
diff --git a/app/views/owned_tag_sets/index.html.erb b/app/views/owned_tag_sets/index.html.erb
new file mode 100755
index 0000000..44c78cc
--- /dev/null
+++ b/app/views/owned_tag_sets/index.html.erb
@@ -0,0 +1,54 @@
+
+
<%= link_to ts('New Tag Set'), new_tag_set_path %>
+
<%= link_to ts('Tags'), tags_path %>
+ <% end %>
+
+
+
+
+ <% if @tag_sets.empty? %>
+
<%= ts('No tag sets found.') %>
+ <% else %>
+ <% if @tag_sets.respond_to?(:total_pages) %>
+ <%= will_paginate @tag_sets %>
+ <% end %>
+
+
<%= ts('Listing Tag Sets') %>
+
+ <% @tag_sets.each do |tag_set| %>
+ <%= render 'tag_set_blurb', :tag_set => tag_set %>
+ <% end %>
+
+
+ <% if @tag_sets.respond_to?(:total_pages) %>
+ <%= will_paginate @tag_sets %>
+ <% end %>
+ <% end %>
+
+
\ No newline at end of file
diff --git a/app/views/owned_tag_sets/new.html.erb b/app/views/owned_tag_sets/new.html.erb
new file mode 100755
index 0000000..e4de6ba
--- /dev/null
+++ b/app/views/owned_tag_sets/new.html.erb
@@ -0,0 +1,6 @@
+
+
+
<%= ts("Create A Tag Set") %>
+
+ <%= render "tag_set_form" %>
+
\ No newline at end of file
diff --git a/app/views/owned_tag_sets/show.html.erb b/app/views/owned_tag_sets/show.html.erb
new file mode 100755
index 0000000..9c63f21
--- /dev/null
+++ b/app/views/owned_tag_sets/show.html.erb
@@ -0,0 +1,65 @@
+
<%= ts("The moderators have chosen not to make the tags in this set visible to the public (possibly while nominations are underway).") %>
+ <% end %>
+
+
+
diff --git a/app/views/owned_tag_sets/show_options.html.erb b/app/views/owned_tag_sets/show_options.html.erb
new file mode 100755
index 0000000..c9a09c1
--- /dev/null
+++ b/app/views/owned_tag_sets/show_options.html.erb
@@ -0,0 +1,24 @@
+<% # ids of tag sets whose tags are being shown %>
+<% ids = @tag_set_ids.join("-") %>
+
+<% # the show tag sets' most recent updated_at %>
+<% newest_updated_at = @tag_sets.collect(&:updated_at).sort.last %>
+
+<% # cache key for all of the sets' tags, which expires when the number of tags changes or when the most recent updated_at changes %>
+<% tags_cache_key = @tags.cache_key %>
+
+<% cache("tagsets_#{ids}_#{newest_updated_at}_#{tags_cache_key}", skip_digest: true) do %>
+
+ <% else %>
+
+ <%= render "show_tags_alpha_listbox",
+ tagname_list: @tags.pluck(:name), tag_type: @tag_type %>
+
+ <% end %>
+<% end %>
diff --git a/app/views/owned_tag_sets/wrangle.html.erb b/app/views/owned_tag_sets/wrangle.html.erb
new file mode 100755
index 0000000..28a62d3
--- /dev/null
+++ b/app/views/owned_tag_sets/wrangle.html.erb
@@ -0,0 +1,4 @@
+<%
+ # this page will for wranglers: it will show them the noncanonical tags and associations in this tag set, which they can decide
+ # whether or not to make canonical.
+%>
\ No newline at end of file
diff --git a/app/views/people/_author_blurb.html.erb b/app/views/people/_author_blurb.html.erb
new file mode 100755
index 0000000..b94922b
--- /dev/null
+++ b/app/views/people/_author_blurb.html.erb
@@ -0,0 +1,25 @@
+
+
+ <% for person in @people %>
+ <% unless person.nil? %>
+ <%= render :partial => 'people/author_blurb', :locals => {:author => person} %>
+ <% end %>
+ <% end %>
+
+ <%= will_paginate @people %>
+<% end %>
diff --git a/app/views/potential_match_settings/_potential_match_settings_form.html.erb b/app/views/potential_match_settings/_potential_match_settings_form.html.erb
new file mode 100755
index 0000000..7614879
--- /dev/null
+++ b/app/views/potential_match_settings/_potential_match_settings_form.html.erb
@@ -0,0 +1,52 @@
+
+<% @challenge.build_potential_match_settings unless @challenge.potential_match_settings %>
+<% potential_match_settings = @challenge.potential_match_settings %>
+
+<%= challenge_form.fields_for(:potential_match_settings) do |match_settings_form| %>
+
+
+
+
<%= ts("Minimum Number to Match") %>
+
+ <%= ts("This means the") %> <%= ts("minimum") %> <%= ts("level of matching needed for an offer to match a request.
+ Any matches above the minimum required number will be used to rank the quality of potential matches.") %>
+
+
+
+ <%= ts("If you change these
+ settings") %> <%= ts("after") %> <%= ts("you generate potential matches,
+ you will need to") %> <%= ts("regenerate potential matches") %> <%= ts("before your changes show.") %>
+
+
+ <% unless @assignments.is_a?(Array) %>
+ <% # if these are bad assignments with errors then it's an array and can't be paginated %>
+ <%= will_paginate @assignments %>
+ <% end %>
+ <% end %>
+ <%= render "potential_matches/match_navigation" %>
+<% end %>
\ No newline at end of file
diff --git a/app/views/potential_matches/_in_progress.html.erb b/app/views/potential_matches/_in_progress.html.erb
new file mode 100755
index 0000000..d6b97a6
--- /dev/null
+++ b/app/views/potential_matches/_in_progress.html.erb
@@ -0,0 +1,16 @@
+
+ <%= ts("The archive is generating potential matches for this challenge. This can take a long time,
+ especially for a large challenge! We'll send an email to the collection maintainers when
+ all potential matches have been generated. ") %>
+
+
+
+ <%= ts("If the progress value below doesn't change when you reload the page
+ after about 10 minutes, there may have been a problem. Please check again after an hour, and then contact archive support.") %>
+
+
+<%= button_to ts("Cancel Potential Match Generation"), cancel_generate_collection_potential_matches_path(@collection), method: :post %>
diff --git a/app/views/potential_matches/_invalid_signups.html.erb b/app/views/potential_matches/_invalid_signups.html.erb
new file mode 100755
index 0000000..dd226b5
--- /dev/null
+++ b/app/views/potential_matches/_invalid_signups.html.erb
@@ -0,0 +1,15 @@
+
<%= ts("Invalid Sign-ups Found!") %>
+
+ <%= ts("Your challenge has the following invalid sign-ups. They may be duplicates or have
+ too-many or too-few prompts. You can review and edit or delete them, or contact the user
+ if you aren't sure what they wanted.") %>
+
+
+ <%= ts("When you are finished, try to generate potential matches again.") %>
+
diff --git a/app/views/potential_matches/_match_navigation.html.erb b/app/views/potential_matches/_match_navigation.html.erb
new file mode 100755
index 0000000..9baaec0
--- /dev/null
+++ b/app/views/potential_matches/_match_navigation.html.erb
@@ -0,0 +1,29 @@
+<% if !@collection.challenge.signup_open %>
+
+
+ <% if @collection.potential_matches.empty? %>
+
+ <% if !@settings || @settings.no_match_required? %>
+ <%= button_to ts("Generate Potential Matches"), generate_collection_potential_matches_path(@collection),
+ data: { confirm: ts("Your challenge settings don't require any matching. Assignments will be completely random. Is this what you want?") },
+ method: :post %>
+ <% else %>
+ <%= button_to ts("Generate Potential Matches"), generate_collection_potential_matches_path(@collection), method: :post %>
+ <% end %>
+
+ <% else %>
+
<%= submit_tag ts("Save Assignment Changes") %>
+
<%= button_to ts("Regenerate Assignments"), generate_collection_assignments_path(@collection),
+ data: { confirm: ts("Are you sure? This will delete all current assignments.") },
+ method: :post %>
+
<%= button_to ts("Regenerate All Potential Matches"), generate_collection_potential_matches_path(@collection),
+ data: { confirm: ts("Are you sure? This will delete all current assignments and potential matches.") },
+ method: :post %>
+
+ <%= button_to ts("Send Assignments"), send_out_collection_assignments_path(@collection),
+ data: { confirm: ts("Are you sure? This will send out the currently saved assignments to all participants!") },
+ method: :post %>
+ <% end %>
+
+
+<% end %>
diff --git a/app/views/potential_matches/_no_match_required.html.erb b/app/views/potential_matches/_no_match_required.html.erb
new file mode 100755
index 0000000..e079563
--- /dev/null
+++ b/app/views/potential_matches/_no_match_required.html.erb
@@ -0,0 +1,8 @@
+
+ <%= ts("Your challenge doesn't have any match settings defined, so the matching
+ will be purely random.
+ (You can adjust the assignments by hand afterwards, though!)
+ If you want participants matched up on the tags they signed up with,
+ please update your ") %>
+ <%= link_to ts("challenge match settings"), edit_collection_gift_exchange_path(@collection, :anchor => 'match_settings') %>.
+
diff --git a/app/views/potential_matches/_no_potential_givers.html.erb b/app/views/potential_matches/_no_potential_givers.html.erb
new file mode 100755
index 0000000..cff5e25
--- /dev/null
+++ b/app/views/potential_matches/_no_potential_givers.html.erb
@@ -0,0 +1,30 @@
+<% if @assignments_with_no_potential_givers.present? %>
+
+
<%= ts("Participants with No Potential Givers") %>
+
+ <% @assignments_with_no_potential_givers.each do |assignment| %>
+
<%= ts("All participants have at least one potential recipient!")%>
+<% end %>
diff --git a/app/views/potential_matches/_potential_matches.html.erb b/app/views/potential_matches/_potential_matches.html.erb
new file mode 100755
index 0000000..827246e
--- /dev/null
+++ b/app/views/potential_matches/_potential_matches.html.erb
@@ -0,0 +1,71 @@
+
+ <%=ts("Reviewing Assignments") %>
+ <% if params[:no_potential_recipients] %>
+ <%=ts("With No Potential Recipients") %>
+ <% elsif params[:no_potential_givers] %>
+ <%=ts("With No Potential Givers") %>
+ <% elsif params[:no_recipient] %>
+ <%=ts("With No Recipient Assigned") %>
+ <% elsif params[:no_giver] %>
+ <%=ts("With No Giver Assigned") %>
+ <% elsif params[:dup_giver] %>
+ <%=ts("With The Same Giver Assigned More Than Once") %>
+ <% elsif params[:dup_recipient] %>
+ <%=ts("With The Same Recipient Assigned More Than Once") %>
+ <% end %>
+ <%= link_to_help "challenge-matching-assignments" %>
+
+
+
+ <% if params[:no_potential_recipients] %>
+ <%=ts("No one wants what they're offering! You must edit their signup before you can assign them to give to anyone.") %>
+ <% elsif params[:no_potential_givers] %>
+ <%=ts("No one can give what they want! You must either edit their signup or write-in a volunteer giver.") %>
+ <% elsif params[:no_recipient] %>
+ <%=ts("You can double-assign recipients or shuffle around the completed assignments to match everyone off.") %>
+ <% elsif params[:no_giver] %>
+ <%=ts("You can write-in givers or shuffle around the completed assignments to match everyone off.") %>
+ <% elsif params[:dup_giver] %>
+ <%=ts("These may not be a problem as long as the givers are willing to get more than one assignment,
+ but you may want to put their name into the write-in field for all but their one official assignment.") %>
+ <% elsif params[:dup_recipient] %>
+ <%=ts("These are most likely not a problem: these recipients will simply get more than one gift.") %>
+ <% else %>
+
+ <% end %>
+
+
+
+
+
<%= ts("Assignments:") %>
+ <% if @assignments_with_no_potential_recipients.present? %>
+
+
+
+<% if params[:no_potential_recipients] %>
+ <%= render "no_potential_recipients" %>
+<% elsif params[:no_potential_givers] %>
+ <%= render "no_potential_givers" %>
+<% else %>
+ <%= render "potential_matches/assignments" %>
+<% end %>
\ No newline at end of file
diff --git a/app/views/potential_matches/index.html.erb b/app/views/potential_matches/index.html.erb
new file mode 100755
index 0000000..f0ef1e1
--- /dev/null
+++ b/app/views/potential_matches/index.html.erb
@@ -0,0 +1,46 @@
+
+
<%= ts('Matching for %{collection_title}', :collection_title => @collection.title) %>
+
+
+
+
+
+
+<% if @challenge.signup_open %>
+
+ <%= ts("You can't generate matches while sign-up is still open. After you have closed sign-ups in Challenge Settings,
+ you will be able to generate potential matches here.") %>
+
+<% elsif @collection.signups.count < 2 %>
+
+ <%= ts("You need at least two people to sign up before you can make assignments.") %>
+
+ <% end %>
+
+
+
+ <% if logged_in? && current_user == @user %>
+
<%= ts("Actions") %>
+
+
<%= link_to ts("Edit My Works"), show_multiple_user_works_path(@user) %>
+
<%= link_to ts("Edit My Profile"), edit_user_path(@user) %>
+
<%= link_to ts("Set My Preferences"), user_preferences_path(@user) %>
+
<%= link_to ts("Manage My Pseuds"), user_pseuds_path(@user) %>
+
<%= link_to ts("Delete My Account"), @user, data: {confirm: ts('This will permanently delete your account and cannot be undone. Are you sure?')}, :method => :delete %>
+ <%= ts("These settings determine what each separate offer can contain. Note these can be entirely different from the request settings!") %>
+
+ <% else %>
+
+
<%= ts("Request Settings") %>
+
+ <%= ts("These settings determine what each separate request can contain, and in particular how many different tags of each kind.") %>
+ <% unless type == "prompt_meme" %><%= ts("If you plan to use automated matching, keep in mind it will be slower the more tags you allow people to request.") %><% end %>
+
+ <% unless type == "prompt_meme" %>
+ <%= ts('Checking "Allow Any?" allows users to select "Any" for that field; this means they will match') %> <%= ts("anything") %> <%= ts("put in that field. This option is safer for offers than requests!") %>
+
<% end %>
+
+ <%= ts('If you allow three prompts and specify that fandom "must be unique", the user will have to choose completely different fandoms
+ for all three prompts -- no overlap allowed. Please explain this in your signup instructions!') %>
+
+ <% end %>
+
+ <% if @challenge.new_record? || !@challenge.collection.prompts.exists? %>
+
<%= ts("These settings can only be changed until prompts are added.") %>
+ <% elsif %>
+
<%= ts("Prompts have been added so these settings can no longer be changed.") %>
+ <%= link_to(ts("Tag sets"), tag_sets_path) %> <%= ts("show what tags can be used in
+ your challenge. You can make your own or use any public tag set. Beware: owners can change the contents of their public tag set without warning.") %>
+
+ <% end %>
+
+<% end %>
diff --git a/app/views/prompts/_prompt_blurb.html.erb b/app/views/prompts/_prompt_blurb.html.erb
new file mode 100755
index 0000000..d483450
--- /dev/null
+++ b/app/views/prompts/_prompt_blurb.html.erb
@@ -0,0 +1,96 @@
+<% # expects "prompt", if index is passed will use that in title if none exists, if suppress_claims is true will ignore %>
+
+
+ <% tag_groups = prompt.tag_groups %>
+
+ <% # eventually we should let mod specify topmost tag type to display -- ie chars and rels for single-fandom meme, freeforms for single-relationship meme %>
+
+ <% end %>
+
+ <% # if prompt has been fulfilled list works %>
+ <% unless prompt.unfulfilled? %>
+
+
<%= ts("Fulfilled By")%>
+
+ <% prompt.fulfilled_claims.map(&:creation).each do |creation| %>
+ <% if creation.is_a?(Work) %>
+ <%= render "works/work_blurb", :work => creation %>
+ <% end %>
+ <% end %>
+
+
+ <% end %>
+ <% end %>
+
diff --git a/app/views/prompts/_prompt_controls.html.erb b/app/views/prompts/_prompt_controls.html.erb
new file mode 100755
index 0000000..32aaba8
--- /dev/null
+++ b/app/views/prompts/_prompt_controls.html.erb
@@ -0,0 +1,56 @@
+<% # requires 'challenge_signup' and 'prompt' locals %>
+<% # to make the code more readable: %>
+<% collection = challenge_signup.collection %>
+<% challenge = collection.challenge %>
+<% user = challenge_signup.pseud.user %>
+
+<% if challenge.signup_open || (!challenge.signup_open && collection.user_is_maintainer?(current_user)) || collection.challenge_type == "PromptMeme" %>
+
+ <% # The edit link should show up if signups are open and the current user owns the prompt, OR if the challenge is a gift exchange and signups are closed and the current user is the maintainer %>
+ <% if (challenge.signup_open && user == current_user) || (collection.challenge_type == "GiftExchange" && !challenge.signup_open && collection.user_is_maintainer?(current_user)) %>
+
+ <% end %>
+
+ <% # The delete link should show up if signups are open and the current user owns the prompt, OR if the current user is a maintainer, AND only if the signup has more than the minimum number of prompts %>
+ <% if ((challenge.signup_open && user == current_user) || (collection.user_is_maintainer?(current_user))) && prompt.can_delete? %>
+
+ <%= link_to ts("Delete Prompt"),
+ collection_prompt_path(collection, prompt),
+ data: { confirm: ts("Are you sure? All information in this prompt will be lost.") },
+ method: :delete %>
+
+ <% end %>
+ <% # Moderators should be able to delete entire sign-ups from the /requests page in PromptMemes. %>
+ <% if collection.user_is_maintainer?(current_user) && collection.challenge_type == "PromptMeme" %>
+
<%= link_to ts("Delete Sign-up"),
+ collection_signup_path(collection, challenge_signup),
+ data: { confirm: ts("Are you sure? All prompts in this sign-up will be lost.") },
+ method: :delete %>
+
+ <% end %>
+
+ <% # The claiming and fulfilling options should show up if the prompt is claimable %>
+ <% if prompt.claimable? %>
+ <% # The drop link should always show up if the user has claimed the prompt and the fulfil link should sometimes show up %>
+ <% if (claim ||= prompt.claim_by(current_user).first) %>
+ <% # The fulfill link should show up if the collection is open OR if the current user is the maintainer %>
+ <% if !collection.closed? || collection.user_is_maintainer?(current_user) %>
+
+<% end %>
diff --git a/app/views/prompts/_prompt_form.html.erb b/app/views/prompts/_prompt_form.html.erb
new file mode 100755
index 0000000..9454d37
--- /dev/null
+++ b/app/views/prompts/_prompt_form.html.erb
@@ -0,0 +1,114 @@
+
+
+ <% # CODE NOTES:
+ # This is meant to be used as a nested form inside other forms, so that multiple prompts can be submitted within a single form.
+ # It is also meant to be used with javascript-based live adding (that is, not with ajax) which means locals will not be re-evaluated when it is added;
+ # keep this in mind when using the add_section code!
+ # It expects a form being passed in as "form"
+ # If the local variable "index" is passed in, that will represent which prompt this is, if there are multiple prompts being submitted
+ # If the local variable "required" is passed in, this prompt is required
+ # See the challenge_signup form for an example of how this is used.
+ %>
+
+ <% index ||= 0 %>
+ <% required ||= false %>
+ <% prompt_label = form.object.class.name %>
+ <% prompt_type = prompt_label.downcase %>
+ <% prompt_types = prompt_type.pluralize %>
+ <% restriction = @challenge.send("#{prompt_type}_restriction") %>
+
+
+ <% end %>
+ <% end %>
+
+ <% if @collection.challenge.respond_to?(:anonymous) %>
+ <% # TODO ANONYMITY REFACTOR: Anonymity should not be based on a type of challenge but on whether the specific challenge ALLOWS anonymity or not,
+ # (currently the prompt memes don't allow a mod to specify no anonymity allowed)
+ # and should probably be done via an Anonymous user like the Orphan user.
+ %>
+
+
+ <% # Pseudocode for this long if
+ # if rating/warning/category
+ # checkboxes from tag set
+ # elsif character/rating and restricted to fandom/tagset
+ # autocomplete based on fandom
+ # else
+ # if anything in tagset
+ # if not too many things
+ # tickyboxes from tagset
+ # else
+ # autocomplete from tagset
+ # end
+ # else
+ # standard autocomplete by tag type
+ # end
+ # end
+ %>
+
+ <% if TagSet::TAGS_AS_CHECKBOXES.include?(tag_type) %>
+ <% # do ratings, warnings, categories as taglists if not already specified %>
+
+ <% else # all other cases %>
+ <% # save the field id to pass for autocompleting based on fandoms in autocomplete if we use autocomplete %>
+ <% @fandom_tag_id = tag_field_id if tag_type == 'fandom' %>
+
+ <% if restriction.has_tags?(tag_type) # tags are specified in the tag set %>
+ <% if restriction.tags(tag_type).count <= ArchiveConfig.MAX_OPTIONS_TO_SHOW %>
+
+ <% # save the field id to pass for autocompleting based on fandoms tickyed instead of autocomplete %>
+ <% @fandom_tag_id += "_checkboxes" if tag_type == 'fandom' %>
+
+ <% # create a scrollable checkboxes section to wrap the tags, see application_helper %>
+
+ <% # TODO: would be nice to improve this for the case of multiple tag sets, currently it links to just an index of all the tag sets for this challenge %>
+ <%= link_to( ts("List %{taglist_size} %{tag_name}", :taglist_size => restriction.tags(tag_type).count, :tag_name => tag_name.pluralize.titleize),
+ show_options_tag_sets_path(:restriction => restriction.id, :tag_type => tag_type), :target => "_blank", :class => "toggle") %>
+
+ <% end %>
+ <% end # if num_allowed > 0 %>
+ <% end # each tag_type %>
+<% end # do tag_set_form %>
diff --git a/app/views/prompts/_prompt_navigation.html.erb b/app/views/prompts/_prompt_navigation.html.erb
new file mode 100755
index 0000000..7e59cea
--- /dev/null
+++ b/app/views/prompts/_prompt_navigation.html.erb
@@ -0,0 +1,10 @@
+
+ <%= link_to ts("Delete Sign-up"),
+ collection_signup_path(@collection, @challenge_signup),
+ data: {confirm: ts("Are you sure? All prompts in this sign-up will be lost.")},
+ :method => :delete %>
+
+
diff --git a/app/views/prompts/edit.html.erb b/app/views/prompts/edit.html.erb
new file mode 100755
index 0000000..a6741c7
--- /dev/null
+++ b/app/views/prompts/edit.html.erb
@@ -0,0 +1,18 @@
+
+
+ <%= error_messages_for @prompt %>
+ <%= render "prompt_form", :form => form, :required => true, :index => @index %>
+ <%= submit_fieldset form %>
+<% end %>
+
diff --git a/app/views/prompts/index.html.erb b/app/views/prompts/index.html.erb
new file mode 100755
index 0000000..c449051
--- /dev/null
+++ b/app/views/prompts/index.html.erb
@@ -0,0 +1,9 @@
+<%
+ # this currently doesn't get called anywhere
+ # presumably this rather than the challenge signups index should be the list of prompts eg in a prompt meme
+%>
+
<%= ts("Prompts") %>
+
+
+ for each render partial _prompt_blurb
+
\ No newline at end of file
diff --git a/app/views/prompts/new.html.erb b/app/views/prompts/new.html.erb
new file mode 100755
index 0000000..9e5314b
--- /dev/null
+++ b/app/views/prompts/new.html.erb
@@ -0,0 +1,16 @@
+
+
<%= ts("New Prompt") %>
+
+
+
+<% if @prompt.challenge_signup %>
+ <%= render "prompt_navigation" %>
+<% end %>
+
+
+
+<%= form_for @prompt, :as => :prompt, :url => collection_prompts_path(@collection), :html => {:method => :post} do |form| %>
+ <%= render "prompt_form", :form => form, :required => true, :index => @index %>
+ <%= submit_fieldset form %>
+<% end %>
+
diff --git a/app/views/prompts/show.html.erb b/app/views/prompts/show.html.erb
new file mode 100755
index 0000000..d0a3955
--- /dev/null
+++ b/app/views/prompts/show.html.erb
@@ -0,0 +1,13 @@
+<% # prompt_form links here when called from prompts/edit %>
+
+
+ <%= label_tag key, ts("There's more than one user with the pseud %{name}. Please choose the one you want:", name: name) %>
+ <%= creator_form.collection_select :coauthors, pseuds, :id, :user_login, { include_blank: true }, { id: key, name: "#{creator_form.object_name}[coauthors][]" } %>
+
+ <% end %>
+
+
+<% end %>
diff --git a/app/views/pseuds/_pseud_blurb.html.erb b/app/views/pseuds/_pseud_blurb.html.erb
new file mode 100755
index 0000000..fa12d9b
--- /dev/null
+++ b/app/views/pseuds/_pseud_blurb.html.erb
@@ -0,0 +1,45 @@
+
+ <%# These links use landmarks so they are distinct for screen readers,
+# e.g., Edit Pseud1, Edit Pseud2. The landmark text " Pseud2" is inside the
+# landmark span, so there's no extra space for sighted users. If your
+# language says "Pseud2 Edit", your landmark text would be "Pseud 2 " and
+# your link string "%{landmark_span}Edit". %>
+
+ <%= link_to ts('Clear History'), clear_user_readings_path(current_user), data: {confirm: ts('Are you sure you want to clear your History and Marked for Later lists? This cannot be undone!')}, :method => :post %>
+
+
+<% end %>
+
+
+
+
+ <%= t(".#{mode}.landmark") %>
+
+<% if logged_in? && @readings.present? %>
+
+ <% @readings.each do |reading| %>
+ <%= render 'reading_blurb', :work => reading.work, :reading => reading %>
+ <% end %>
+
+<% end %>
+
+
+
+<%== pagy_nav @pagy %>
+
diff --git a/app/views/redirect/show.html.erb b/app/views/redirect/show.html.erb
new file mode 100755
index 0000000..35467c9
--- /dev/null
+++ b/app/views/redirect/show.html.erb
@@ -0,0 +1,18 @@
+
Lookup Original Stories
+
+
+ If you are looking for a story that might have been imported into the archive from another URL, you can enter it here and
+ we will try and look it up.
+
+
+<%= form_tag redirect_path, method: :get do %>
+
+
+<% end %>
diff --git a/app/views/related_works/_approve.html.erb b/app/views/related_works/_approve.html.erb
new file mode 100755
index 0000000..ede95a4
--- /dev/null
+++ b/app/views/related_works/_approve.html.erb
@@ -0,0 +1,18 @@
+
<%= ts('Approve Link') %>
+
+
+ <%= ts('Would you like to create a link from your work') %>
+ <%= link_to(related_work.parent.title, related_work.parent) %>
+ <%= ts("to") %>
+ <%= link_to(related_work.work.title, related_work.work) %>
+ <%= ts("by") %>
+ <%= byline(related_work.work) %>?
+
+
+
<%= ts('Nothing will be linked directly from your works without your express approval.') %> <%= ts('You can come back to this page if you change your mind.') %>
+
+<%= form_for(related_work) do |f| %>
+
+ <%= f.submit ts("Yes, link me!") %>
+
+<% end %>
diff --git a/app/views/related_works/_remove.html.erb b/app/views/related_works/_remove.html.erb
new file mode 100755
index 0000000..2875dfa
--- /dev/null
+++ b/app/views/related_works/_remove.html.erb
@@ -0,0 +1,18 @@
+
<%= ts('Remove Link') %>
+
+
+ <%= ts('Would you like to remove the link from your work') %>
+ <%= link_to(related_work.parent.title, related_work.parent) %>
+ <%= ts("to") %>
+ <%= link_to(related_work.work.title, related_work.work) %>
+ <%= ts("by") %>
+ <%= byline(related_work.work) %>?
+
+
+
<%= ts('(You can come back to this page if you change your mind.)') %>
+
+<%= form_for(related_work) do |f| %>
+
+ <%= f.submit ts("Remove link") %>
+
+<% end %>
diff --git a/app/views/related_works/index.html.erb b/app/views/related_works/index.html.erb
new file mode 100755
index 0000000..14898dd
--- /dev/null
+++ b/app/views/related_works/index.html.erb
@@ -0,0 +1,156 @@
+
+<% if @user %>
+
<%= ts("Translations of %{user_login}'s works", :user_login => @user.login) %>
+ <% # FRONT END!!!
+ # This page is:
+ # Foo's Related Works
+ # works by foo that someone has translated
+ # works by others that foo has translated
+ # works by foo that inspired other people
+ # works by others that inspired foo
+ # this should be four listboxes
+ %>
+
@user.login) %><% if current_user == @user %><%= ' ' + ts("Also includes management options.") %><% end %>" id="translationsofme">
+
<%= ts("Translations of %{user_login}'s Works", :user_login => @user.login) %>
+
+
+
<%= ts("Translation") %>
+
<%= ts("Original") %>
+
<%= ts("Language") %>
+ <% if current_user == @user %>
+
<%= ts("Modify Link") %>
+ <% end %>
+
+
+
+ <% @translations_of_user.each do |related_work| %>
+ <% if related_work.parent && related_work.work %>
+
<%= link_to ts("Delete Series"), confirm_delete_series_path(@series), data: {confirm: ts('Are you sure you want to delete this series? This will NOT delete the individual works.')} %>
<%= ts('Are you sure you want to delete the series "%{series_title}"? This will not delete the individual works.', :series_title => @series.title).html_safe %>
+
+ <%= f.submit ts("Yes, Delete Series") %>
+
+<% end %>
+
diff --git a/app/views/series/edit.html.erb b/app/views/series/edit.html.erb
new file mode 100755
index 0000000..8abba95
--- /dev/null
+++ b/app/views/series/edit.html.erb
@@ -0,0 +1,82 @@
+
+
+
\ No newline at end of file
diff --git a/app/views/series/show.html.erb b/app/views/series/show.html.erb
new file mode 100755
index 0000000..029c373
--- /dev/null
+++ b/app/views/series/show.html.erb
@@ -0,0 +1,102 @@
+
+<% if @series.user_has_creator_invite?(current_user) %>
+
+ <%= ts("You've been invited to become a co-creator of this series. To accept or reject the request, visit your %{creator_requests_page}.",
+ creator_requests_page: link_to(ts("Co-Creator Requests page"), user_creatorships_path(current_user))).html_safe %>
+
+<% end %>
+
+ <%= @series.title %>
+ <% if @series.restricted %>
+ <%= image_tag("lockblue.png", size: "15x15", alt: "(Restricted)", title: "Restricted", skip_pipeline: true) %>
+ <% end %>
+ <% if @series.hidden_by_admin %>
+ <%= image_tag("lockred.png", size: "15x15", alt: ts("(Hidden by Admin)"),
+ title: ts("Hidden by Administrator"),
+ skip_pipeline: true) %>
+ <% end %>
+
+
+
+<% if policy(@series).show_admin_options? %>
+
+
+<% unless @works.blank? %>
+
+ <%= will_paginate @works %>
+<% end %>
+
+
+
+
diff --git a/app/views/share/_embed_link_bookmark.html.erb b/app/views/share/_embed_link_bookmark.html.erb
new file mode 100755
index 0000000..5676c8d
--- /dev/null
+++ b/app/views/share/_embed_link_bookmark.html.erb
@@ -0,0 +1,4 @@
+<% if item.bookmarkable.is_a?(Work) %>
+<%= render "share/embed_link_work", item: item.bookmarkable %>
+<%= render "share/embed_meta_bookmark", bookmark: item %>
+<% end %>
diff --git a/app/views/share/_embed_link_header.html.erb b/app/views/share/_embed_link_header.html.erb
new file mode 100755
index 0000000..a74fa67
--- /dev/null
+++ b/app/views/share/_embed_link_header.html.erb
@@ -0,0 +1,10 @@
+<% creators = if work.anonymous?
+ ts("Anonymous")
+ else
+ work.pseuds.map { |pseud| link_to(tag.strong(pseud.name), user_url(pseud.user)) }.join(", ")
+ end %>
+
+<%= ts("%{work_link} (%{word_count} words) by %{creators}",
+ work_link: link_to(tag.strong(work.title), work_url(work)),
+ word_count: work.word_count,
+ creators: creators).html_safe %>
diff --git a/app/views/share/_embed_link_work.html.erb b/app/views/share/_embed_link_work.html.erb
new file mode 100755
index 0000000..e834291
--- /dev/null
+++ b/app/views/share/_embed_link_work.html.erb
@@ -0,0 +1,2 @@
+<%= render "share/embed_link_header", work: item %>
+<%= render "share/embed_meta", work: item %>
diff --git a/app/views/share/_embed_meta.html.erb b/app/views/share/_embed_meta.html.erb
new file mode 100755
index 0000000..02f9333
--- /dev/null
+++ b/app/views/share/_embed_meta.html.erb
@@ -0,0 +1,19 @@
+<%= ts("Chapters: ") %>
+<%= chapter_total_display(work) %>
+<%= ts("Fandom: ") %>
+<%= work.tag_groups["Fandom"].map { |fandom| link_to fandom.name, tag_url(fandom) }.join(", ").html_safe %>
+<% [Rating, ArchiveWarning, Relationship, Character, Freeform].each do |klass| %>
+<% if (tags = work.tag_groups[klass.to_s]).present? %>
+<%= ts("#{klass.label_name}: ") %>
+<%= tags.map(&:display_name).join(", ").html_safe %>
+
+<% end %>
+<% end %>
+<% if work.series.exists? %>
+<%= ts("Series: ") %>
+<%= series_list_for_feeds(work).html_safe %>
+<% end %>
+<% if work.summary.present? %>
+<%= ts("Summary: ") %>
+<%= sanitize_field(work, :summary).html_safe %>
+<% end %>
diff --git a/app/views/share/_embed_meta_bookmark.erb b/app/views/share/_embed_meta_bookmark.erb
new file mode 100755
index 0000000..ee24474
--- /dev/null
+++ b/app/views/share/_embed_meta_bookmark.erb
@@ -0,0 +1,10 @@
+<% if bookmark.bookmarkable.is_a?(Work) %>
+<% if bookmark.tags.exists? %>
+<%= ts("Bookmarker's Tags: ").html_safe %>
+<%= bookmark.tags.pluck(:name).join(", ") %>
+<% end %>
+<% if bookmark.bookmarker_notes.present? %>
+<%= ts("Bookmarker's Notes: ").html_safe %>
+<%= raw sanitize_field(bookmark, :bookmarker_notes, image_safety_mode: true) %>
+<% end %>
+<% end %>
diff --git a/app/views/share/_share.html.erb b/app/views/share/_share.html.erb
new file mode 100755
index 0000000..2461817
--- /dev/null
+++ b/app/views/share/_share.html.erb
@@ -0,0 +1,29 @@
+
+
+
<%= ts("Copy and paste the following code to link back to this work (") %><%= ts("CTRL A") %><%= ts("/") %><%= ts("CMD A") %><%= ts(" will select all), or use the Tweet or Tumblr links to share the work on your Twitter or Tumblr account.") %>
\ No newline at end of file
diff --git a/app/views/skins/_form.html.erb b/app/views/skins/_form.html.erb
new file mode 100755
index 0000000..ec7ae9f
--- /dev/null
+++ b/app/views/skins/_form.html.erb
@@ -0,0 +1,99 @@
+
+ <% end %>
+
+ <% # If this is your personal skin, show My links; otherwise, show Public links %>
+ <% if logged_in? && current_user == @skin.author %>
+
+ <% end %>
+
+ <% if logged_in? %>
+ <% # Show Create Work or Site Skin link only on index %>
+ <% unless controller.action_name == 'new' || controller.action_name == 'edit' || controller.action_name == 'show' %>
+
+<%= form_for(@skin, html: { multipart: true, class: "skin verbose post" }) do |f| %>
+
+ <% if params[:wizard] %>
+ <%= render 'wizard_form', f: f %>
+ <% else %>
+ <%= render 'form', f: f %>
+ <% end %>
+<% end %>
diff --git a/app/views/skins/index.html.erb b/app/views/skins/index.html.erb
new file mode 100755
index 0000000..2991aab
--- /dev/null
+++ b/app/views/skins/index.html.erb
@@ -0,0 +1,33 @@
+<% work_skin = params[:skin_type] && params[:skin_type] == 'WorkSkin' %>
+
+
+
<%= @title %>
+<%= render 'skin_navigation' %>
+
+
+ <%= ts('A site skin lets you change the way the Archive is presented when you are logged in to your account. You can use work skins to customize the way your own works are shown to others.') %>
+ <%= link_to_help 'skins-basics' %>
+
+ <%= work_skin ? ts('No work skins here yet!') : ts('No site skins here yet!') %>
+ <% if logged_in? %><%= link_to ts('Why not try making one?'), new_skin_path(skin_type: work_skin ? 'WorkSkin' : 'Skin') %><% end %>
+
+<% else %>
+
+ <% for skin in @skins %>
+
+ <%= render 'skin_module', skin: skin %>
+
+ <% end %>
+
+<% end %>
+
diff --git a/app/views/skins/new.html.erb b/app/views/skins/new.html.erb
new file mode 100755
index 0000000..ffc0382
--- /dev/null
+++ b/app/views/skins/new.html.erb
@@ -0,0 +1,21 @@
+
+
+ <%= ts('This form allows you to create a new site or work skin. Select "Work Skin" or "Site Skin" in the Type option list to choose which type of skin you are creating.') %>
+ <%= link_to_help 'skins-basics' %>
+
+
<%= ts('Create New Skin') %>
+
+<%= form_for @skin, html: { multipart: true, class: "skin verbose post" } do |f| %>
+ <%= render 'form', f: f %>
+<% end %>
+
diff --git a/app/views/skins/new_wizard.html.erb b/app/views/skins/new_wizard.html.erb
new file mode 100755
index 0000000..da04f11
--- /dev/null
+++ b/app/views/skins/new_wizard.html.erb
@@ -0,0 +1,22 @@
+
+
+ <%= ts('This wizard only creates site skins. You can also %{link} which can be used to add styling to works that you post.', link: link_to(ts('create a work skin'), url_for(skin_type: 'WorkSkin'))).html_safe %>
+ <%= link_to_help 'skins-basics' %>
+
+
+
<%= ts('Site Skin Wizard') %>
+
+<%= form_for @skin, html: { class: "wizard skin verbose post" } do |f| %>
+ <%= render 'wizard_form', f: f %>
+<% end %>
+
diff --git a/app/views/skins/show.html.erb b/app/views/skins/show.html.erb
new file mode 100755
index 0000000..8480ef5
--- /dev/null
+++ b/app/views/skins/show.html.erb
@@ -0,0 +1,94 @@
+
+
+
diff --git a/app/views/stats/no_stats.html.erb b/app/views/stats/no_stats.html.erb
new file mode 100755
index 0000000..02968b7
--- /dev/null
+++ b/app/views/stats/no_stats.html.erb
@@ -0,0 +1,3 @@
+
<%= ts("Statistics") %>
+
<%= ts("You currently have no works posted to the Archive. If you add some, you'll find information on this page about hits, kudos, comments, and bookmarks of your works.") %>
+
<%= ts("Users can also see how many subscribers they have, but not the names of their subscribers or identifying information about other users who have viewed or downloaded their works.") %>
\ No newline at end of file
diff --git a/app/views/statuses/edit.html.erb b/app/views/statuses/edit.html.erb
new file mode 100644
index 0000000..e6f7b88
--- /dev/null
+++ b/app/views/statuses/edit.html.erb
@@ -0,0 +1,42 @@
+
+ <% end %>
+
+ <%= submit_fieldset %>
+
+<% end %>
+
+
\ No newline at end of file
diff --git a/app/views/tag_set_nominations/_nomination_form.html.erb b/app/views/tag_set_nominations/_nomination_form.html.erb
new file mode 100755
index 0000000..c37d165
--- /dev/null
+++ b/app/views/tag_set_nominations/_nomination_form.html.erb
@@ -0,0 +1,70 @@
+
+
<%= ts("Tag Nominations for %{title}", :title => @tag_set_nomination.owned_tag_set.title) %>
+
+
+
+
+
<%= link_to h(ts("Back To ")) + @tag_set_nomination.owned_tag_set.title, tag_set_path(@tag_set_nomination.owned_tag_set) %>
+
<%= link_to ts("Tag Sets"), tag_sets_path %>
+
+
+
+
+
+
<%= ts('The autocomplete lists canonical tags for you. Please choose the canonical version of your tag if there is one.') %>
+
<%= ts('The tag set moderators might change or leave out your nominations (sometimes just because a different form of your nomination was included).') %>
+
<%= ts('Nominations are not forever! Don\'t be confused if you come back in a few months and they are gone: they may have been cleaned up.') %>
You can approve (+) or reject (x) nominations, or leave them for later. Once a nomination
+ has been either approved or rejected, it won't appear on this page again.
+
+
If you reject a fandom nomination, we will automatically reject all character and relationship nominations submitted
+ for that fandom. We will not automatically approve character nominations.
+
+
If you approve non-canonical fandoms and characters, you will have to set up associations for them.
+
+
Old (several months) nominations are sometimes cleared from the database. Please review
+ nominations regularly or turn them off.
+
+ <% # if fandoms are being nominated then any character/relationship noms go under fandom %>
+ <% %w(character relationship).each do |tag_type| %>
+ <% if @limit[tag_type] > 0 %>
+ <%= nom_form.fields_for "#{tag_type}_nominations".to_sym do |nom_form2| %>
+
<%= ts("Are you certain you want to delete these nominations?") %>
+
+ <%= f.submit ts("Yes, Delete Tag Set Nominations") %>
+ <%= link_to ts("Back to the Tag Set Nominations"), tag_set_nomination_path(@tag_set, @tag_set_nomination) %>
+
+<% end %>
+
diff --git a/app/views/tag_set_nominations/confirm_destroy_multiple.html.erb b/app/views/tag_set_nominations/confirm_destroy_multiple.html.erb
new file mode 100755
index 0000000..48b3d5c
--- /dev/null
+++ b/app/views/tag_set_nominations/confirm_destroy_multiple.html.erb
@@ -0,0 +1,12 @@
+
+
+ <% end %>
+ <% if (@tag_set.nominated && @tag_set_nomination.unreviewed?) || @tag_set.user_is_moderator?(current_user) %>
+
+ <%= link_to ts("Delete"), confirm_delete_tag_set_nomination_path(@tag_set, @tag_set_nomination),
+ data: {confirm: ts('Are you certain you want to delete these nominations?')} %>
+
+ <% end %>
+
+
+
+
<%= ts("My Nominations for %{title}", :title => @tag_set.title) %>
+
+
+
<%= ts("Created at: ") %>
+
<%= l(@tag_set_nomination.created_at.to_date) %>
+
<%= ts("Status: ") %>
+
+ <% if @tag_set_nomination.unreviewed? %>
+ <%= ts("Not Yet Reviewed") %>
+ <% if @tag_set.nominated %>
+ <%= ts("(may be edited or deleted)") %>
+ <% end %>
+ <% elsif @tag_set_nomination.reviewed? %>
+ <%= ts("Reviewed (may not be edited or deleted)") %>
+ <% else %>
+ <%= ts("Partially Reviewed") %>
+ <% if @tag_set.nominated %>
+ <%= ts("(unreviewed nominations may be edited)") %>
+ <% end %>
+ <% end %>
+
<%= ts("Your lack of fandoms makes the wrangulator sad. Fandoms can be assigned via the") %> <%= link_to ts('tag wranglers page'), tag_wranglers_path %>.
+ <% else %>
+
<%= ts('Fandoms assigned to you via the ') %> <%= link_to ts('tag wranglers page'), tag_wranglers_path %>. <%= ts('The unwrangled count is the number of unwrangled tags which were used on works in the given fandom.') %>
<%= ts("This tag belongs to the %{tag_class} Category.", :tag_class => @tag.class::NAME) %>
+ <% if @tag.canonical %>
+ <%= t(".canonical_html",
+ canonical_tag_link: link_to(t(".canonical_tag"), archive_faq_path("glossary", anchor: "canonicaldef")),
+ filter_works_link: link_to(t(".filter_works"), tag_works_path(@tag)),
+ filter_bookmarks_link: link_to(t(".filter_bookmarks"), tag_bookmarks_path(@tag))) %>
+ <% if @tag.is_a?(Fandom) %>
+ <%= t(".list_fandom_tags_html", fandom_relationship_tags_link: link_to(t(".fandom_relationship_tags"), fandom_path(@tag))) %>
+ <% end %>
+ <% end %>
+
+
+
+ <% if @tag.is_a?(Rating) && @tag.adult %>
+
<%= ts('This tag indicates adult content.') %>
+ <% end %>
+
+ <% if @tag.is_a?(Fandom) %>
+
+
<%= ts('Parent tags (more general)') %>:
+
+ <% (@tag.parents + [@tag.media]).uniq.compact.sort.each do |parent| %>
+
<%= link_to_tag parent %>
+ <% end %>
+
+
+ <% else %>
+ <% unless @tag.parents.blank? %>
+
+
<%= ts('Parent tags (more general)') %>:
+
<%= tag_link_list(@tag.parents.sort) %>
+
+ <% end %>
+ <% end %>
+
+ <% if @tag.merger %>
+
+
<%= ts("Mergers") %>
+
<%= ts("%{tag_name} has been made a synonym of %{tag_synonym_link}. Works and bookmarks tagged with %{tag_name} will show up in %{tag_synonym}'s filter.", :tag_name => @tag.name, :tag_synonym_link => (link_to_tag @tag.merger), :tag_synonym => @tag.merger.name).html_safe %>
<%= span_if_current ts('Unwrangled'), url_for(show: params[:show], status: 'unwrangled'), nil, ts('tags which were used on the same works but haven\'t been wrangled yet') %>
In order to make AO3's rules clearer to our users, we intend to update the AO3 Terms of Service (TOS) later this year. Once this occurs, you will need to agree to the updated TOS in order to continue using AO3.
+
+
Here are the highlights of the changes in the 2024 version of the TOS:
+
+
We've clarified the Content Policy, but we haven't changed what works are or are not allowed. <%= style_bold("If your fanwork was allowed on AO3 before, then it is still allowed.") %>
+
The TOS has been split into three pages (General Principles, Content Policy, and Privacy Policy). This should make it easier to find what you're looking for when you want to know about a specific part of the TOS.
+
We've simplified the language throughout the TOS and removed redundant or overly specific phrases and passages. When longer explanations would help to provide clarity, we've added new questions to the TOS FAQ instead.
+
We've updated the descriptions of how we and our subprocessors collect and process user information (including personal information) in the Privacy Policy.
+
The Abuse Policy has been generalized to provide the AO3 Policy & Abuse committee with greater flexibility to determine how to address TOS violations, while still providing protections for fanworks in accordance with AO3's mission.
+
The "Underage" Archive Warning, which is used for works that depict or describe underage sex, will be renamed to "Underage Sex". This does not change the meaning of this warning or how it is enforced. When the TOS update occurs, <%= style_bold('all works with the "Underage" Archive Warning will be recategorized automatically to display the new "Underage Sex" Archive Warning label instead.') %> If you have a work that carries the "Underage" warning and you don't want it to display the "Underage Sex" label, you can replace it with the "Creator Chose Not to Use Archive Warnings" label at any time.
+
+
You can learn more about the intended changes, access the full draft text, ask questions, and provide public feedback by visiting our <%= style_link(style_bold("news post about the 2024 Terms of Service updates"), admin_post_url(@admin_post)) %>.
+<% end %>
diff --git a/app/views/tos_update_mailer/tos_update_notification.text.erb b/app/views/tos_update_mailer/tos_update_notification.text.erb
new file mode 100755
index 0000000..6465639
--- /dev/null
+++ b/app/views/tos_update_mailer/tos_update_notification.text.erb
@@ -0,0 +1,16 @@
+<% content_for :message do %>
+<%= t("mailer.general.greeting.formal.addressed_html", name: @username) %>
+
+In order to make AO3's rules clearer to our users, we intend to update the AO3 Terms of Service (TOS) later this year. Once this occurs, you will need to agree to the updated TOS in order to continue using AO3.
+
+Here are the highlights of the changes in the 2024 version of the TOS:
+
+ - We've clarified the Content Policy, but we haven't changed what works are or are not allowed. If your fanwork was allowed on AO3 before, then it is still allowed.
+ - The TOS has been split into three pages (General Principles, Content Policy, and Privacy Policy). This should make it easier to find what you're looking for when you want to know about a specific part of the TOS.
+ - We've simplified the language throughout the TOS and removed redundant or overly specific phrases and passages. When longer explanations would help to provide clarity, we've added new questions to the TOS FAQ instead.
+ - We've updated the descriptions of how we and our subprocessors collect and process user information (including personal information) in the Privacy Policy.
+ - The Abuse Policy has been generalized to provide the AO3 Policy & Abuse committee with greater flexibility to determine how to address TOS violations, while still providing protections for fanworks in accordance with AO3's mission.
+ - The "Underage" Archive Warning, which is used for works that depict or describe underage sex, will be renamed to "Underage Sex". This does not change the meaning of this warning or how it is enforced. When the TOS update occurs, all works with the "Underage" Archive Warning will be recategorized automatically to display the new "Underage Sex" Archive Warning label instead. If you have a work that carries the "Underage" warning and you don't want it to display the "Underage Sex" label, you can replace it with the "Creator Chose Not to Use Archive Warnings" label at any time.
+
+You can learn more about the intended changes, access the full draft text, ask questions, and provide public feedback by visiting our news post about the 2024 Terms of Service updates: <%= admin_post_url(@admin_post) %>.
+<% end %>
diff --git a/app/views/troubleshooting/show.html.erb b/app/views/troubleshooting/show.html.erb
new file mode 100755
index 0000000..e86d76c
--- /dev/null
+++ b/app/views/troubleshooting/show.html.erb
@@ -0,0 +1,31 @@
+
+
+ <%= will_paginate @tags %>
+
+ <% end %>
+
+<% end %>
diff --git a/app/views/user_invite_requests/index.html.erb b/app/views/user_invite_requests/index.html.erb
new file mode 100755
index 0000000..144118c
--- /dev/null
+++ b/app/views/user_invite_requests/index.html.erb
@@ -0,0 +1,43 @@
+<% # this page is for administrators only, they cannot navigate to individual user pages %>
+
+
<%= ts("User Invite Requests") %>
+
<%= ts("Enter \"0\" under quantity to deny the request. Clear the box to delay your decision for now.") %>
+
<%= ts("To give a user more invitations without a request, visit their individual invitations page.") %>
diff --git a/app/views/user_invite_requests/new.html.erb b/app/views/user_invite_requests/new.html.erb
new file mode 100755
index 0000000..3c86c94
--- /dev/null
+++ b/app/views/user_invite_requests/new.html.erb
@@ -0,0 +1,26 @@
+
+
<%= ts("Request Invitations") %>
+
+ <%= ts("We don't normally grant more than 3 invitations at a time for personal use, and the maximum you can request through this form is %{max}. Requests are manually reviewed and may take several days to be approved. Please ", :max => ArchiveConfig.MAX_USER_INVITE_REQUEST.to_s) %>
+ <%= link_to ts('contact Support'), new_feedback_report_path %> <%= ts("if you need more than 10 invitations for a challenge, or if your challenge or collection is opening soon.") %>
+
+<% end %>
+
+<% content_for :footer_note do %>
+ <%= collection_footer_note_html(@is_collection_email, @collection) %>
+<% end %>
+
+<% content_for :sent_at do %>
+ <%= l(Time.current) %>
+<% end %>
diff --git a/app/views/user_mailer/potential_match_generation_notification.text.erb b/app/views/user_mailer/potential_match_generation_notification.text.erb
new file mode 100755
index 0000000..61d66eb
--- /dev/null
+++ b/app/views/user_mailer/potential_match_generation_notification.text.erb
@@ -0,0 +1,11 @@
+<% content_for :message do %>
+<%= t(".finished_generating.text", collection_title: @collection.title, collection_url: collection_url(@collection)) %>
+
+<%= t(".matches_available.text", matching_page_url: collection_potential_matches_url(@collection)) %>
+<% end %>
+
+<% content_for :footer_note do %>
+<%= collection_footer_note_text(@is_collection_email, @collection) -%>
+<% end %>
+
+<% content_for :sent_at do %><%= l(Time.current) %><% end %>
diff --git a/app/views/user_mailer/prompter_notification.html.erb b/app/views/user_mailer/prompter_notification.html.erb
new file mode 100755
index 0000000..aad32cf
--- /dev/null
+++ b/app/views/user_mailer/prompter_notification.html.erb
@@ -0,0 +1,29 @@
+<% content_for :message do %>
+
+ A response to your prompt has been posted <% if @collection %>in the <%= style_link(@collection.title, collection_url(@collection)) %> collection <% end %>at the Archive of Our Own!
+
+ <% end %>
+ <% else %>
+ <% if @user.bookmarks.visible.size > ArchiveConfig.NUMBER_OF_ITEMS_VISIBLE_IN_DASHBOARD %>
+
<%= bookmarks_link(@user) %>
+ <% end %>
+ <% end %>
+ <% end %>
+
+ <% end %>
+
+<% if @works.blank? && @series.blank? && @bookmarks.blank? %>
+ <% if current_user == @user %>
+
+ <%= ts("You don't have anything posted under this name yet. ") %>
+ <%= ts("Would you like to ")%>
+ <%= link_to ts("post a new work"), new_work_path %>
+ <%= ts("or maybe")%>
+ <%= link_to ts("a new bookmark"), new_external_work_path %>?
+
+ <% else %>
+
<%= ts("There are no works or bookmarks under this name yet.") %>
+ <% end %>
+<% end %>
+
+<%= content_for :footer_js do %>
+ <%= javascript_tag do %>
+ $j(document).ready(function() {
+ $j("#expand-fandoms").removeClass("hidden").click(function(e) {
+ e.preventDefault();
+ $j(".more").toggleClass("hidden");
+ $j("#collapse-fandoms").toggleClass("hidden");
+ $j(this).toggleClass("hidden");
+ });
+ $j("#collapse-fandoms").click(function(e) {
+ e.preventDefault();
+ $j(".more").toggleClass("hidden");
+ $j("#expand-fandoms").toggleClass("hidden");
+ $j(this).toggleClass("hidden");
+ });
+ });
+ <% end %>
+<% end %>
diff --git a/app/views/users/_edit_header_navigation.html.erb b/app/views/users/_edit_header_navigation.html.erb
new file mode 100755
index 0000000..643714f
--- /dev/null
+++ b/app/views/users/_edit_header_navigation.html.erb
@@ -0,0 +1,7 @@
+
+ <%# We do days_to_purge_unactivated * 7 because it's actually weeks %>
+ <%= t(".important")%>
+ <%= t(".must_activate", count: AdminSetting.current.days_to_purge_unactivated * 7) %>
+
+
+ <%= link_to t(".go_back"), root_path %>
+
+
+
diff --git a/app/views/users/delete_confirmation.html.erb b/app/views/users/delete_confirmation.html.erb
new file mode 100755
index 0000000..903c0bd
--- /dev/null
+++ b/app/views/users/delete_confirmation.html.erb
@@ -0,0 +1,4 @@
+
<%= ts("Account deleted!") %>
+
<%= ts('Your account has been deleted.') %> <%= ts('Thank you for using the Archive, and we hope to see you again someday.') %>
+
+
<%= ts("If you orphaned your works, thank you for your generosity.") %>
\ No newline at end of file
diff --git a/app/views/users/delete_preview.html.erb b/app/views/users/delete_preview.html.erb
new file mode 100755
index 0000000..07d6a8f
--- /dev/null
+++ b/app/views/users/delete_preview.html.erb
@@ -0,0 +1,74 @@
+
+<% end %>
+
diff --git a/app/views/users/sessions/_greeting.html.erb b/app/views/users/sessions/_greeting.html.erb
new file mode 100755
index 0000000..e5688cd
--- /dev/null
+++ b/app/views/users/sessions/_greeting.html.erb
@@ -0,0 +1,60 @@
+
+
diff --git a/app/views/users/sessions/_login.html.erb b/app/views/users/sessions/_login.html.erb
new file mode 100755
index 0000000..3342ba1
--- /dev/null
+++ b/app/views/users/sessions/_login.html.erb
@@ -0,0 +1,3 @@
+
+ <%= render "users/sessions/passwd_small" %>
+
diff --git a/app/views/users/sessions/_passwd.html.erb b/app/views/users/sessions/_passwd.html.erb
new file mode 100755
index 0000000..db6eeb1
--- /dev/null
+++ b/app/views/users/sessions/_passwd.html.erb
@@ -0,0 +1,14 @@
+<%= form_for(User.new, url: new_user_session_path) do |f| %>
+
+
<%= f.label :login, t(".username_or_email") %>
+
<%= f.text_field :login %>
+
<%= f.label :password, t(".password") %>
+
<%= f.password_field :password %>
+
<%= f.label :remember_me, t(".remember_me") %>
+
<%= f.check_box :remember_me %>
+
<%= t(".landmark_submit") %>
+
+ <%= f.submit t(".log_in"), class: "submit" %>
+
+
+<% end %>
diff --git a/app/views/users/sessions/_passwd_small.html.erb b/app/views/users/sessions/_passwd_small.html.erb
new file mode 100755
index 0000000..3ebcdeb
--- /dev/null
+++ b/app/views/users/sessions/_passwd_small.html.erb
@@ -0,0 +1,29 @@
+<%# We need to override the ids to avoid accessibility issues on the new user session page,
+which has a second user session form %>
+<%= form_for(User.new, url: user_session_path, html: { id: "new_user_session_small" }) do |f| %>
+
+ <% %w(rating archive_warning category fandom character relationship freeform).each do |tag_type| %>
+
+ <% # For accessibility, include filter action (e.g. Include) as landmark text %>
+ <% # The space needs to be in the landmark to keep the text aligned %>
+ <%= ts("%{filter_action} %{tag_type}",
+ filter_action: filter_action.titleize,
+ tag_type: tag_type_label_name(tag_type).pluralize
+ ).html_safe %>
+
+ <%= label_tag "parent-options-show", ts("This work is a remix, a translation, a podfic, or was inspired by another work") %>
+ <%= link_to_help "parent-works-help" %>
+
+
+
+ <%= link_to ts("Remove"), related_work,
+ data: { confirm: ts("Are you sure you want to delete the connection to this work?") },
+ method: :delete %>
+
+ <%= ts("Note: Text entered in the posting form is not automatically saved. Always keep a backup copy of your work.").html_safe %>
+
+
+ <% use_tinymce %>
+
+
<%= ts("Work Text") %>*
+ <%= c.text_area :content, value: @chapter.content,
+ class: "mce-editor observe_textlength large", id: "content" %>
+ <%= live_validation_for_field("content",
+ maximum_length: ArchiveConfig.CONTENT_MAX_DISPLAYED,
+ minimum_length: ArchiveConfig.CONTENT_MIN,
+ tooLongMessage: ts("We salute your ambition! But sadly the content must be less than %{max} characters long. (Maybe you want to create a multi-chaptered work?)",
+ max: ArchiveConfig.CONTENT_MAX_DISPLAYED.to_s),
+ tooShortMessage: ts("Brevity is the soul of wit, but your content does have to be at least %{min} characters long.",
+ min: ArchiveConfig.CONTENT_MIN.to_s),
+ failureMessage: ts("Brevity is the soul of wit, but your content does have to be at least %{min} characters long.",
+ min: ArchiveConfig.CONTENT_MIN.to_s))
+ %>
+
+ <%= generate_countdown_html("content", ArchiveConfig.CONTENT_MAX_DISPLAYED) %>
+
+<% end %>
+
+<%= content_for :footer_js do %>
+ <%= javascript_tag do %>
+ $j(document).ready(function(){
+ $j(".toggle_formfield").click(function() {
+ var targetId = $j(this).attr("id").replace("-show", "");
+ toggleFormField(targetId);
+ });
+ })
+ <% end %>
+<% end %>
diff --git a/app/views/works/_work_abbreviated_list.html.erb b/app/views/works/_work_abbreviated_list.html.erb
new file mode 100755
index 0000000..40c236e
--- /dev/null
+++ b/app/views/works/_work_abbreviated_list.html.erb
@@ -0,0 +1,17 @@
+<% # this partial is intended to be used wherever an abbreviated list of works is desired instead of the full listing with blurbs %>
+<% # expects local "works" %>
+
+
+ <% works.each do |work| tag_groups = work.tag_groups %>
+
+ <%= link_to ts("Delete Draft"), confirm_delete_work_path(work), data: { confirm: ts("Are you sure you want to delete this draft?") } %>
+
+ <% end %>
+
+ <% end %>
+
diff --git a/app/views/works/_work_endnotes.html.erb b/app/views/works/_work_endnotes.html.erb
new file mode 100755
index 0000000..835d60d
--- /dev/null
+++ b/app/views/works/_work_endnotes.html.erb
@@ -0,0 +1,6 @@
+
+
+
<%= ts("Notes:") %>
+
<%=raw sanitize_field(@work, :endnotes) %>
+
+
\ No newline at end of file
diff --git a/app/views/works/_work_form_associations_language.html.erb b/app/views/works/_work_form_associations_language.html.erb
new file mode 100755
index 0000000..ae20bac
--- /dev/null
+++ b/app/views/works/_work_form_associations_language.html.erb
@@ -0,0 +1,11 @@
+<% # expects local variable f %>
+
diff --git a/app/views/works/_work_form_pseuds.html.erb b/app/views/works/_work_form_pseuds.html.erb
new file mode 100755
index 0000000..872ef30
--- /dev/null
+++ b/app/views/works/_work_form_pseuds.html.erb
@@ -0,0 +1,23 @@
+<% # expects 'form' and 'works' which can be 1 or more works %>
+<% # used exclusively for the edit_multiple works view %>
+
+
+<% has_cocreators = (works.flat_map(&:pseuds).uniq - current_user.pseuds).any? %>
+<% has_multiple_pseuds = current_user.pseuds.length > 1 %>
+<% if has_multiple_pseuds || has_cocreators %>
+
diff --git a/app/views/works/_work_form_tags.html.erb b/app/views/works/_work_form_tags.html.erb
new file mode 100755
index 0000000..cd8f003
--- /dev/null
+++ b/app/views/works/_work_form_tags.html.erb
@@ -0,0 +1,120 @@
+
+
+
<%= ts('Tags') %>
+
+
+ <%= ts("Tags are comma separated, %{max} characters per tag. Fandom, relationship, character, and additional tags must not add up to more than %{limit}. Archive warning, category, and rating tags do not count toward this limit.",
+ limit: ArchiveConfig.USER_DEFINED_TAGS_MAX,
+ max: ArchiveConfig.TAG_MAX) %>
+
+ <% end %>
+
+ <% if show_work_notes?(@work) %>
+ <%= render "works/work_header_notes" %>
+ <% end %>
+
+ <% end %>
+
+
+ <% # NOTE: the div id="workskin" is DELIBERATELY not closed in this file! %>
diff --git a/app/views/works/_work_header_navigation.html.erb b/app/views/works/_work_header_navigation.html.erb
new file mode 100755
index 0000000..454d442
--- /dev/null
+++ b/app/views/works/_work_header_navigation.html.erb
@@ -0,0 +1,122 @@
+
+
<%= ts("Actions") %>
+
+ <% if logged_in_as_admin? || permit?("tag_wrangler") %>
+
<%= link_to ts("Chapter by Chapter"), [@work, @chapter] %>
+ <% end %>
+
+
+ <% # for users, bookmark and mark to read later %>
+ <% if current_user.is_a?(User) %>
+ <% @bookmark ||= bookmark_if_exists(@work) %>
+
+ <% # If in single chapter view, show comments for the chapter; otherwise, show the comments for the entire work %>
+ <% @previous_chapter || @next_chapter ? commentable = @chapter : commentable = @work %>
+ <% if commentable.count_visible_comments > 0 %>
+ <%= show_hide_comments_link(commentable) %>
+ <% else %>
+ <%= link_to ts("Comments"), "#comments" %>
+ <% end %>
+
+
+ <% # for author or admin, review comments if moderating them %>
+ <% if (logged_in_as_admin? || (current_user && current_user.is_author_of?(@work))) && @work.find_all_comments.unreviewed_only.exists? %>
+
+
diff --git a/app/views/works/_work_header_notes.html.erb b/app/views/works/_work_header_notes.html.erb
new file mode 100755
index 0000000..a81d38d
--- /dev/null
+++ b/app/views/works/_work_header_notes.html.erb
@@ -0,0 +1,79 @@
+
+
<%= ts("Notes:") %>
+
+ <%# We don't want the ul tag hanging around when it's empty (i.e. when there are no recipients, parent works, or claims) but we can't skip this entire section because line 25 generates the link to child works that appear under any notes. We repeat this logic on line 63 to close the ul. %>
+ <% if show_associations?(@work) %>
+
+ <% end %>
+ <%# dedication %>
+ <% if @work.gifts.not_rejected.exists? %>
+
<%= ts("For") %> <%= recipients_link(@work) %>.
+ <% end %>
+
+ <%# translations %>
+ <%# i18n-tasks-use t("works.work_header_notes.translated_to.restricted_html") %>
+ <%# i18n-tasks-use t("works.work_header_notes.translated_to.revealed_html") %>
+ <%# i18n-tasks-use t("works.work_header_notes.translated_to.unrevealed_html") %>
+ <% for related_work in @work.approved_related_works %>
+ <% if related_work.translation %>
+
+
+<% end %>
diff --git a/app/views/works/_work_series_links.html.erb b/app/views/works/_work_series_links.html.erb
new file mode 100755
index 0000000..85145ac
--- /dev/null
+++ b/app/views/works/_work_series_links.html.erb
@@ -0,0 +1,10 @@
+
+
+
<%= ts('Series this work belongs to:') %>
+
+ <% series_data_for_work(@work).each do |series_info| %>
+
<%= series_info %>
+ <% end %>
+
+
+
\ No newline at end of file
diff --git a/app/views/works/_work_unrevealed_notice.erb b/app/views/works/_work_unrevealed_notice.erb
new file mode 100755
index 0000000..f61799e
--- /dev/null
+++ b/app/views/works/_work_unrevealed_notice.erb
@@ -0,0 +1,12 @@
+
+ <%= ts("This work is part of an ongoing challenge and will be revealed soon!") %>
+ <% if @work.user_is_owner_or_invited?(current_user) %>
+ <%= ts("You can find details here: ") %>
+ <%= show_collections_data(@work.collections.unrevealed) %>
+ <% else %>
+ <% if @work.approved_collections.unrevealed.present? %>
+ <%= ts("You can find details here: ") %>
+ <%= show_collections_data(@work.approved_collections.unrevealed) %>
+ <% end %>
+ <% end %>
+
diff --git a/app/views/works/collected.html.erb b/app/views/works/collected.html.erb
new file mode 100755
index 0000000..bab2507
--- /dev/null
+++ b/app/views/works/collected.html.erb
@@ -0,0 +1,43 @@
+
+
+ <% if @facets.present? %>
+ <% # Filters button for narrow screens jumps to filters when JavaScript is disabled and opens filters when JavaScript is enabled %>
+
+ <%= ts('Are you sure you want to delete "%{work_title}" permanently? This cannot be undone.', :work_title => @work.title).html_safe %>
+ <% if @work.posted? %>
+ <%= ts("All bookmarks, comments, and kudos will be lost. If you just want to remove your association with the work, you could Orphan it instead.") %>
+ <% end %>
+
+ <%= ts("Are you sure you want to delete these works PERMANENTLY?
+ If you just want to remove your association with the work, you could orphan it instead.") %>
+ <% @works.each_with_index do |work, index| %><%= hidden_field_tag "work_ids[]", work.id, :id => "work_ids_#{index}" %><% end %>
+
+
+
+
<%= submit_tag ts("Yes, Delete Works") %>
+
<%= link_to ts("Orphan Works Instead"), new_orphan_path(:work_ids => @works.collect(&:id)) %>
+ <%= ts("Please note:") %>
+ <%= ts("Unposted drafts are only saved for a month from the day they are first created, and then deleted from the Archive.") %>
+
+
+
+
+<%= will_paginate @works %>
+
+
+
+
+ <% for work in @works %>
+ <%= render 'work_blurb', :work => work %>
+ <% end %>
+
<%= button_to ts("Remove Me As Co-Creator"),
+ remove_user_creatorship_work_path(@work),
+ data: { confirm: "This will remove you from all chapters as well. Are you sure?" },
+ method: :patch %>
<%= link_to ts("Delete Work"), confirm_delete_work_path(@work), data: {confirm: ts("Are you sure you want to delete this work? This will destroy all comments and kudos on this work as well and CANNOT BE UNDONE!")} %>
+ <%= ts("Your edits will") %> <%= ts("replace") %> <%= ts("the existing values!
+ (If you leave a field blank it will remain unchanged.)") %>
+
+
+
<%= submit_tag ts("Update All Works"), data: { confirm: ts("Are you sure? Remember this will replace all existing values!") } %>
+
+<% end %>
+
diff --git a/app/views/works/edit_tags.html.erb b/app/views/works/edit_tags.html.erb
new file mode 100755
index 0000000..5fd622d
--- /dev/null
+++ b/app/views/works/edit_tags.html.erb
@@ -0,0 +1,46 @@
+
+
+ <% if logged_in_as_admin? %>
+ <%= ts('Edit Work Tags and Language for %{title}', title: @work.title).html_safe %>
+ <% else %>
+ <%= ts('Edit Work Tags for %{title}', title: @work.title).html_safe %>
+ <% end %>
+
+ <% end %>
+ <% if @facets.present? %>
+ <%# Filters button for narrow screens jumps to filters when JavaScript is disabled and opens filters when JavaScript is enabled %>
+
+ <%= radio_button_tag "import_multiple", "chapters" %>
+ <%= label_tag "import_multiple_chapters",
+ ts("Chapters in a single work (limit of %{max})",
+ max: ArchiveConfig.IMPORT_MAX_CHAPTERS) %>
+
+
+
+ <% if current_user.archivist %>
+
<%= label_tag ts("Archiving for others") %>
+
+
+
+
<%= check_box_tag "importing_for_others" %>
+
<%= label_tag "importing_for_others", ts("Import for others ONLY with permission") %>
+ <%= check_box_tag "override_tags", 1, false, class: "toggle_formfield", id: "override_tags-options-show" %>
+ <%= label_tag "override_tags-options-show",
+ ts("Set the following tags and/or notes on all works, overriding whatever the importer finds in the content.") %>
+
+
+ <%= radio_button_tag "detect_tags", true, true, id: "detect_tags_true" %>
+ <%= label_tag "detect_tags_true",
+ ts("Use values extracted from the content for blank fields if possible") %>
+
+
+ <%= radio_button_tag "detect_tags", false, false, id: "detect_tags_false" %>
+ <%= label_tag "detect_tags_false",
+ ts("Do not use values extracted from the content at all; use Archive defaults for blank fields") %>
+
+<% end %>
+<% if @work.unrevealed? %>
+ <%= render "works/work_unrevealed_notice" %>
+<% end %>
+<% if @work.user_has_creator_invite?(current_user) %>
+
+ <%= ts("You've been invited to become a co-creator of this work. To accept or reject the request, visit your %{creator_requests_page}.",
+ creator_requests_page: link_to(ts("Co-Creator Requests page"), user_creatorships_path(current_user))).html_safe %>
+
+<% end %>
\ No newline at end of file
diff --git a/app/views/wrangling_guidelines/manage.html.erb b/app/views/wrangling_guidelines/manage.html.erb
new file mode 100755
index 0000000..7a9b1a8
--- /dev/null
+++ b/app/views/wrangling_guidelines/manage.html.erb
@@ -0,0 +1,11 @@
+
+
<%= ts('Manage Wrangling Guidelines') %>
+
+
+
+<%= render 'admin/admin_nav' %>
+
+
+
+<%= render 'wrangling_guideline_order' %>
+
\ No newline at end of file
diff --git a/app/views/wrangling_guidelines/new.html.erb b/app/views/wrangling_guidelines/new.html.erb
new file mode 100755
index 0000000..9710f14
--- /dev/null
+++ b/app/views/wrangling_guidelines/new.html.erb
@@ -0,0 +1,11 @@
+
+
<%= ts('Create New Wrangling Guideline Section') %>
+
+
+
+<%= render 'admin/admin_nav' %>
+
+
+
+<%= render 'wrangling_guideline_form' %>
+
\ No newline at end of file
diff --git a/app/views/wrangling_guidelines/show.html.erb b/app/views/wrangling_guidelines/show.html.erb
new file mode 100755
index 0000000..8288367
--- /dev/null
+++ b/app/views/wrangling_guidelines/show.html.erb
@@ -0,0 +1,22 @@
+
+
+ <%= raw sanitize_field(@wrangling_guideline, :content) %>
+
+
+
diff --git a/bin/brakeman b/bin/brakeman
new file mode 100755
index 0000000..ace1c9b
--- /dev/null
+++ b/bin/brakeman
@@ -0,0 +1,7 @@
+#!/usr/bin/env ruby
+require "rubygems"
+require "bundler/setup"
+
+ARGV.unshift("--ensure-latest")
+
+load Gem.bin_path("brakeman", "brakeman")
diff --git a/bin/bundle b/bin/bundle
new file mode 100755
index 0000000..66e9889
--- /dev/null
+++ b/bin/bundle
@@ -0,0 +1,3 @@
+#!/usr/bin/env ruby
+ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__)
+load Gem.bin_path('bundler', 'bundle')
diff --git a/bin/rails b/bin/rails
new file mode 100755
index 0000000..efc0377
--- /dev/null
+++ b/bin/rails
@@ -0,0 +1,4 @@
+#!/usr/bin/env ruby
+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..4fbf10b
--- /dev/null
+++ b/bin/rake
@@ -0,0 +1,4 @@
+#!/usr/bin/env ruby
+require_relative "../config/boot"
+require "rake"
+Rake.application.run
diff --git a/bin/reload_elastic b/bin/reload_elastic
new file mode 100755
index 0000000..10f728b
--- /dev/null
+++ b/bin/reload_elastic
@@ -0,0 +1,8 @@
+#!/bin/bash
+ export RAILS_ENV=production
+
+ bundle exec rake environment search:index_bookmarks
+ bundle exec rake environment search:index_pseuds
+ bundle exec rake environment search:index_tags
+ bundle exec rake environment search:index_works
+
diff --git a/bin/rubocop b/bin/rubocop
new file mode 100755
index 0000000..40330c0
--- /dev/null
+++ b/bin/rubocop
@@ -0,0 +1,8 @@
+#!/usr/bin/env ruby
+require "rubygems"
+require "bundler/setup"
+
+# explicit rubocop config increases performance slightly while avoiding config confusion.
+ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__))
+
+load Gem.bin_path("rubocop", "rubocop")
diff --git a/bin/setup b/bin/setup
new file mode 100755
index 0000000..84cd631
--- /dev/null
+++ b/bin/setup
@@ -0,0 +1,37 @@
+#!/usr/bin/env ruby
+require "fileutils"
+
+APP_ROOT = File.expand_path("..", __dir__)
+APP_NAME = "otwarchive"
+
+def system!(*args)
+ system(*args, 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"
+
+ # puts "\n== Configuring puma-dev =="
+ # system "ln -nfs #{APP_ROOT} ~/.puma-dev/#{APP_NAME}"
+ # system "curl -Is https://#{APP_NAME}.test/up | head -n 1"
+end
diff --git a/bin/update b/bin/update
new file mode 100755
index 0000000..a8e4462
--- /dev/null
+++ b/bin/update
@@ -0,0 +1,29 @@
+#!/usr/bin/env ruby
+require 'pathname'
+require 'fileutils'
+include FileUtils
+
+# path to your application root.
+APP_ROOT = Pathname.new File.expand_path('../../', __FILE__)
+
+def system!(*args)
+ system(*args) || abort("\n== Command #{args} failed ==")
+end
+
+chdir APP_ROOT do
+ # This script is a way to update your development environment automatically.
+ # Add necessary update steps to this file.
+
+ puts '== Installing dependencies =='
+ system! 'gem install bundler --conservative'
+ system('bundle check') || system!('bundle install')
+
+ puts "\n== Updating database =="
+ system! 'bin/rails db:migrate'
+
+ 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 100755
index 0000000..610a678
--- /dev/null
+++ b/config.ru
@@ -0,0 +1,17 @@
+# This file is used by Rack-based servers to start the application.
+
+require_relative "config/environment"
+require "resque/server"
+require "resque/scheduler/server"
+
+# Set the AUTH env variable to your basic auth password to protect Resque.
+AUTH_PASSWORD = ENV['AUTH']
+if AUTH_PASSWORD
+ Resque::Server.use Rack::Auth::Basic do |username, password|
+ password == AUTH_PASSWORD
+ end
+end
+
+run Rack::URLMap.new \
+ "/" => Otwarchive::Application,
+ "/resque" => Resque::Server
diff --git a/config/application.rb b/config/application.rb
new file mode 100755
index 0000000..6144a86
--- /dev/null
+++ b/config/application.rb
@@ -0,0 +1,153 @@
+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 Otwarchive
+ class Application < Rails::Application
+ app_config = YAML.load_file(Rails.root.join("config/config.yml"))
+ app_config.merge!(YAML.load_file(Rails.root.join("config/local.yml"))) if File.exist?(Rails.root.join("config/local.yml"))
+ ::ArchiveConfig = OpenStruct.new(app_config)
+
+ # 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.load_defaults 7.2
+
+ %w[
+ app/models/challenge_models
+ app/models/feedback_reporters
+ app/models/indexing
+ app/models/potential_matcher
+ app/models/search
+ app/models/tagset_models
+ ].each do |dir|
+ config.eager_load_paths << Rails.root.join(dir)
+ end
+
+ # I18n validation deprecation warning fix
+
+ I18n.config.enforce_available_locales = false
+ I18n.config.available_locales = [
+ :en, :af, :ar, :bg, :bn, :ca, :cs, :cy, :da, :de, :el, :es, :fa, :fi,
+ :fil, :fr, :he, :hi, :hr, :hu, :id, :it, :ja, :ko, :lt, :lv, :mk,
+ :mr, :ms, :nb, :nl, :pl, :"pt-BR", :"pt-PT", :ro, :ru, :scr, :sk, :sl,
+ :sv, :th, :tr, :uk, :vi, :"zh-CN"
+ ]
+
+ # Set Time.zone default to the specified zone and make Active Record auto-convert to this zone.
+ # Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC.
+ config.time_zone = "UTC"
+
+ # The default locale is :en.
+ config.i18n.default_locale = ArchiveConfig.DEFAULT_LOCALE_ISO.to_sym
+
+ # Enable locale fallbacks for I18n (makes lookups for any locale fall back to
+ # the I18n.default_locale when a translation can not be found)
+ config.i18n.fallbacks = true
+
+ # JavaScript files you want as :defaults (application.js is always included).
+ # config.action_view.javascript_expansions[:defaults] = %w(jquery rails)
+
+ # Configure the default encoding used in templates for Ruby 1.9.
+ config.encoding = "utf-8"
+
+ config.action_view.automatically_disable_submit_tag = false
+
+ # Disable dumping schemas after migrations.
+ # This can cause problems since we don't always update versions on merge.
+ # Ideally this would be enabled in dev, but we're not quite ready for that.
+ config.active_record.dump_schema_after_migration = false
+
+ # Allows belongs_to associations to be optional
+ config.active_record.belongs_to_required_by_default = false
+
+ # Keeps updated_at in cache keys
+ config.active_record.cache_versioning = false
+
+ # Setting this to true (the default) breaks series orphaning
+ config.active_record.before_committed_on_all_records = false
+
+ # This class is not allowed by default when upgrading Rails to 6.0.5.1 patch
+ config.active_record.yaml_column_permitted_classes = [
+ ActiveSupport::TimeWithZone,
+ Time,
+ ActiveSupport::TimeZone,
+ BCrypt::Password
+ ]
+
+ # handle errors with custom error pages:
+ config.exceptions_app = self.routes
+
+ # Bring the log under control
+ config.lograge.enabled = true
+
+ # Only send referrer information to ourselves
+ config.action_dispatch.default_headers = {
+ "Content-Security-Policy" => "frame-ancestors 'self'",
+ "Referrer-Policy" => "strict-origin-when-cross-origin",
+ "X-Frame-Options" => "SAMEORIGIN",
+ "X-XSS-Protection" => "1; mode=block",
+ "X-Content-Type-Options" => "nosniff",
+ "X-Permitted-Cross-Domain-Policies" => "none"
+ }
+
+ # Use Resque to run ActiveJobs (including sending delayed mail):
+ config.active_job.queue_adapter = :resque
+
+ # TODO: Remove with Rails 8.0 where this option will be deprecated
+ config.active_job.enqueue_after_transaction_commit = :always
+
+ config.active_model.i18n_customize_full_message = true
+
+ config.action_mailer.default_url_options = { host: ArchiveConfig.APP_HOST, protocol: "https" }
+
+ # Use "mailer" instead of "mailers" as the Resque queue for emails:
+ config.action_mailer.deliver_later_queue_name = :mailer
+
+ config.action_mailer.delivery_method = :smtp
+ config.action_mailer.smtp_settings = {
+ address: ArchiveConfig.SMTP_SERVER,
+ domain: ArchiveConfig.SMTP_DOMAIN,
+ port: ArchiveConfig.SMTP_PORT,
+ enable_starttls_auto: ArchiveConfig.SMTP_ENABLE_STARTTLS_AUTO,
+ enable_starttls: ArchiveConfig.SMTP_ENABLE_STARTTLS,
+ openssl_verify_mode: ArchiveConfig.SMTP_OPENSSL_VERIFY_MODE
+ }
+ if ArchiveConfig.SMTP_AUTHENTICATION
+ config.action_mailer.smtp_settings.merge!({
+ user_name: ArchiveConfig.SMTP_USER,
+ password: ArchiveConfig.SMTP_PASSWORD,
+ authentication: ArchiveConfig.SMTP_AUTHENTICATION
+ })
+ end
+
+ # Disable ActiveStorage things that we don't need and can hit the DB hard
+ config.active_storage.analyzers = []
+ config.active_storage.previewers = []
+
+ # Set ActiveStorage queue name
+ config.active_storage.queues.mirror = :active_storage
+ config.active_storage.queues.preview_image = :active_storage
+ config.active_storage.queues.purge = :active_storage
+ config.active_storage.queues.transform = :active_storage
+
+ config.active_storage.web_image_content_types = %w[image/png image/jpeg image/gif]
+
+ # Do not enable YJIT automatically once we upgrade to Ruby 3.3
+ config.yjit = false
+
+ # Use secret from archive config
+ config.secret_key_base = ArchiveConfig.SESSION_SECRET
+ end
+end
diff --git a/config/boot.rb b/config/boot.rb
new file mode 100755
index 0000000..2820116
--- /dev/null
+++ b/config/boot.rb
@@ -0,0 +1,3 @@
+ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
+
+require "bundler/setup" # Set up gems listed in the Gemfile.
diff --git a/config/brakeman.yml b/config/brakeman.yml
new file mode 100755
index 0000000..28e6a3b
--- /dev/null
+++ b/config/brakeman.yml
@@ -0,0 +1,4 @@
+---
+:safe_methods:
+- :pagy_nav
+- :sanitize_field
diff --git a/config/cable.yml b/config/cable.yml
new file mode 100755
index 0000000..f53bb96
--- /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: otwarchive_production
diff --git a/config/config.yml b/config/config.yml
new file mode 100755
index 0000000..7adcf98
--- /dev/null
+++ b/config/config.yml
@@ -0,0 +1,773 @@
+# WARNING: DO NOT EDIT THIS FILE (unless you are changing the underlying code)
+# Instead, create a file called local.yml in the same place,
+# copy any sections you want to change to it, and edit them there.
+
+# Your secret key for verifying cookie session data integrity.
+# If you change this key, all old sessions will become invalid!
+# Make sure the secret is at least 30 characters and all random,
+# no regular words or you'll be exposed to dictionary attacks.
+
+# do not use the defaults in production!
+SESSION_KEY: '_otwarchive_session'
+SESSION_SECRET: '898f6d0363863ec79d782238cd1c5767636d712cc0d138238bcd5bfc9d2672fb852380050e52c03a0401175d909c09dba48512a119d46b126a84c2dd05716eb5'
+
+DEFAULT_SESSION_LENGTH_IN_WEEKS: 2
+REMEMBERED_SESSION_LENGTH_IN_MONTHS: 3
+
+# This length is hardcoded into the reset_password_instructions email message,
+# defined in config/locales/mailers/en.yml. If you change this value, you should
+# also change the message (and vice versa).
+DAYS_UNTIL_RESET_PASSWORD_LINK_EXPIRES: 7
+
+# This also affects the link included in the admin account creation email.
+DAYS_UNTIL_ADMIN_RESET_PASSWORD_LINK_EXPIRES: 5
+
+# If more than PASSWORD_RESET_LIMIT password reset emails are sent within
+# PASSWORD_RESET_COOLDOWN_HOURS time, then password resets for that user
+# will be prevented until PASSWORD_RESET_COOLDOWN_HOURS after the last
+# password reset.
+PASSWORD_RESET_LIMIT: 3
+PASSWORD_RESET_COOLDOWN_HOURS: 12
+
+# email addresses
+RETURN_ADDRESS: 'do-not-reply@example.org'
+SPAM_ALERT_ADDRESS: 'abuse-discuss@example.org'
+SPAM_THRESHOLD: 15
+ADMIN_ADDRESS: 'admin@example.org'
+TAG_WRANGLER_SUPERVISORS_ADDRESS: 'tagwranglers-personnel@example.org'
+
+# Because the default email addresses are fake,
+# email delivery is turned off by default, even for production.
+# The log file will nonetheless show "Sent mail:"
+#
+# Note that this is independent from whether the scheduled email-sending
+# tasks will be run -- to make changes to that, please see schedule.rb
+# and your deploy file.
+PERFORM_DELIVERIES: false
+
+# email server
+SMTP_SERVER: localhost
+SMTP_PORT: 25
+SMTP_DOMAIN: localhost
+SMTP_OPENSSL_VERIFY_MODE: none
+SMTP_ENABLE_STARTTLS_AUTO: false
+SMTP_ENABLE_STARTTLS: false
+# if required for email authentication
+#SMTP_USER:
+#SMTP_PASSWORD:
+#SMTP_AUTHENTICATION: # :plain, :login or :cram_md5
+
+# Branding
+APP_URL: 'http://www.example.org'
+APP_NAME: 'Example Archive'
+APP_HOST: 'archiveofourown.org'
+APP_SHORT_NAME: 'AO3'
+LOGO: 'logo.png'
+ALT_LOGO: 'Archive of Our Own'
+OTWLOGO: 'OTWLogo.png'
+OTWALT_LOGO: 'OTW Logo:closing the circle of the copyright symbol, it symbolizes our creative engagement with media: participating and not just consuming.'
+REVISION: ''
+ES_URL: 'http://127.0.0.1:9400'
+MEMCACHED_SERVERS: '127.0.0.1:11211'
+ZOHO_URL: 'https://desk.zoho.com'
+
+# tag settings
+DELIMITER_FOR_INPUT: ',' # if you change this, you will need to change
+# validates_format_of :name, in app/models/tag.rb
+DELIMITER_FOR_OUTPUT: ', '
+
+# accounts and invitations
+# these can be overridden in the admin controller
+INVITE_FROM_QUEUE_ENABLED: true
+INVITE_FROM_QUEUE_NUMBER: 10
+# How often INVITE_FROM_QUEUE_NUMBER of invites are sent from the queue, in hours
+INVITE_FROM_QUEUE_FREQUENCY: 12
+
+HOURS_BEFORE_RESEND_INVITATION: 24
+# this is whether or not people without invitations can create accounts
+ACCOUNT_CREATION_ENABLED: false
+DAYS_TO_CONFIRM_EMAIL_CHANGE: 7
+# number of invites users can request
+MAX_USER_INVITE_REQUEST: 10
+# number of accounts users can block
+MAX_BLOCKED_USERS: 2000
+# number of accounts users can mute
+MAX_MUTED_USERS: 2000
+
+# this determines how long we keep processed results like tag set nominations in redis
+DAYS_TO_SAVE_PROCESSED: 56
+
+# validation values -- max/min lengths
+TITLE_MAX: 255
+TITLE_MIN: 1
+SUMMARY_MAX: 1250
+NOTES_MAX: 5000
+COMMENT_MAX: 10000
+TAG_MAX: 150
+CONTENT_MIN: 10
+CONTENT_MAX: 510000
+CONTENT_MAX_DISPLAYED: 500000
+LOGNOTE_MIN: 5
+LOGNOTE_MAX: 1250
+FEEDBACK_SUMMARY_MAX_DISPLAYED: 100
+FEEDBACK_SUMMARY_MAX: 107
+INFO_MAX: 100000
+FAQ_MAX: 200000
+ICON_ALT_MAX: 250
+ICON_COMMENT_MAX: 50
+ICON_SIZE_KB_MAX: 500
+LOGIN_LENGTH_MIN: 3
+LOGIN_LENGTH_MAX: 40
+PASSWORD_LENGTH_MIN: 6
+PASSWORD_LENGTH_MAX: 40
+ADMIN_PASSWORD_LENGTH_MIN: 10
+ADMIN_PASSWORD_LENGTH_MAX: 40
+
+# The maximum number of tags you can add to a collection
+COLLECTION_TAGS_MAX: 10
+
+# The maximum number of user-defined tags you can add to a work or external work
+USER_DEFINED_TAGS_MAX: 75
+
+# max number of tags of each type allowed in challenge prompts (requests/offers)
+# increasing this number can lead to slower automated matching
+PROMPT_TAGS_MAX: 20
+
+# max number of potential matches to generate
+POTENTIAL_MATCHES_MAX: 50
+# min number of potential matches to allow
+POTENTIAL_MATCHES_MIN: 10
+# max percentage of participants to match
+POTENTIAL_MATCHES_PERCENT: 10
+
+# These options control the number of tags we'd ideally like to see in each tag
+# type in a gift exchange, in order to ensure that indexing on that type (to
+# speed up matching) will yield good results. The number scales with the number
+# of signups (using the divisor), but is capped at the maximum. Increasing the
+# maximum (and decreasing the divisor) will cause the preprocessing step to
+# take somewhat longer.
+PREPROCESS_COUNT_TAGS_DIVISOR: 10
+PREPROCESS_COUNT_TAGS_MAX: 10
+
+# These options control how many prompts with "any_#{type}" an exchange can
+# have before we consider that tag type to be difficult to index on.
+PREPROCESS_COUNT_ANY_DIVISOR: 10
+PREPROCESS_COUNT_ANY_MIN: 10
+
+# max number of prompts of each type (offer, request) allowed
+PROMPTS_MAX: 10
+# max for prompt memes (can be much higher than for gift exchanges, as there is no matching)
+PROMPT_MEME_PROMPTS_MAX: 50
+
+
+# max number of works/chapters that can be imported
+IMPORT_MAX_WORKS: 25
+IMPORT_MAX_CHAPTERS: 200
+IMPORT_MAX_WORKS_BY_ARCHIVIST: 200
+
+# max number of abuse reports to accept for a given user profile URL
+ABUSE_REPORTS_PER_USER_MAX: 5
+# max number of abuse reports to accept for a given work URL
+ABUSE_REPORTS_PER_WORK_MAX: 5
+# max number of abuse reports to accept from a given email
+ABUSE_REPORTS_PER_EMAIL_MAX: 5
+
+# number of items in various displays
+ITEMS_PER_PAGE: 25
+# must be less than ITEMS_PER_PAGE above
+NUMBER_OF_ITEMS_VISIBLE_IN_DASHBOARD: 5
+NUMBER_OF_ITEMS_VISIBLE_ON_HOMEPAGE: 3
+COMMENT_THREAD_MAX_DEPTH: 5
+TAGS_IN_CLOUD: 200
+TAG_LIST_LIMIT: 300
+# how many options there should be before we show a scrollable window, and how many there
+# can be before we just use an autocomplete instead
+OPTIONS_TO_SHOW: 3
+MAX_OPTIONS_TO_SHOW: 20
+MAX_KUDOS_TO_SHOW: 50
+MAX_FAVORITE_TAGS: 20
+
+# Used by Rack Attack, periods are measured in seconds
+RATE_LIMIT_NUMBER: 300
+RATE_LIMIT_PERIOD: 300
+RATE_LIMIT_LOGIN_ATTEMPTS: 5
+RATE_LIMIT_LOGIN_PERIOD: 20
+RATE_LIMIT_ADMIN_LOGIN_ATTEMPTS: 10
+RATE_LIMIT_ADMIN_LOGIN_PERIOD: 300
+RATE_LIMIT_PER_NGINX_UPSTREAM:
+ unicorn_elastic:
+ limit: 300
+ period: 300
+ unicorn_elastic_bookmarks:
+ limit: 300
+ period: 300
+RATE_LIMIT_PER_NGINX_UPSTREAM_USER:
+ unicorn_elastic:
+ limit: 300
+ period: 300
+RATE_LIMIT_SAFELIST: ["127.0.0.0/8", "10.0.0.0/8"]
+
+# The number of tags to show on the search page:
+TAGS_PER_SEARCH_PAGE: 50
+
+# When updating tag counts, how many to do in one TagCountUpdateJob:
+TAG_UPDATE_JOB_SIZE: 1000
+
+# When updating tag counts, how many to do in one transaction:
+TAG_UPDATE_BATCH_SIZE: 100
+
+# We only start caching tag counts for tags used more than a certain number of times
+TAGGINGS_COUNT_MIN_CACHE_COUNT: 1000
+
+# For tagging changes, we only reindex tags used less than a certain number of times
+TAGGINGS_COUNT_REINDEX_LIMIT: 1000
+
+# how many signups in a challenge before we move to static summaries generated hourly
+MAX_SIGNUPS_FOR_LIVE_SUMMARY: 20
+
+DOWNLOAD_FORMATS: ['azw3', 'epub', 'mobi', 'pdf', 'html']
+
+# Tag kinds and default tags
+WARNING_CATEGORY_NAME: 'Archive Warning'
+WARNING_DEFAULT_TAG_NAME: 'Choose Not To Use Archive Warnings'
+WARNING_DEFAULT_TAG_DISPLAY_NAME: 'Creator Chose Not To Use Archive Warnings'
+WARNING_NONE_TAG_NAME: 'No Archive Warnings Apply'
+WARNING_NONE_TAG_DISPLAY_NAME: 'No Archive Warnings Apply'
+WARNING_VIOLENCE_TAG_NAME: 'Graphic Depictions Of Violence'
+WARNING_DEATH_TAG_NAME: 'Major Character Death'
+WARNING_NONCON_TAG_NAME: 'Rape/Non-Con'
+WARNING_CHAN_TAG_NAME: 'Underage Sex'
+
+RATING_CATEGORY_NAME: 'Rating'
+RATING_DEFAULT_TAG_NAME: 'Not Rated'
+RATING_EXPLICIT_TAG_NAME: 'Explicit'
+RATING_MATURE_TAG_NAME: 'Mature'
+RATING_TEEN_TAG_NAME: 'Teen And Up Audiences'
+RATING_GENERAL_TAG_NAME: 'General Audiences'
+
+CATEGORY_CATEGORY_NAME: 'Category'
+CATEGORY_GEN_TAG_NAME: 'Gen'
+CATEGORY_HET_TAG_NAME: 'F/M'
+CATEGORY_SLASH_TAG_NAME: 'M/M'
+CATEGORY_FEMSLASH_TAG_NAME: 'F/F'
+CATEGORY_MULTI_TAG_NAME: 'Multi'
+CATEGORY_OTHER_TAG_NAME: 'Other'
+
+MEDIA_CATEGORY_NAME: 'Media'
+MEDIA_NO_TAG_NAME: 'No Media'
+MEDIA_UNCATEGORIZED_NAME: 'Uncategorized Fandoms'
+FANDOM_CATEGORY_NAME: 'Fandom'
+FANDOM_NO_TAG_NAME: 'No Fandom'
+RELATIONSHIP_CATEGORY_NAME: 'Relationship'
+CHARACTER_CATEGORY_NAME: 'Character'
+FREEFORM_CATEGORY_NAME: 'Additional Tags'
+COMMON_CATEGORY_NAME: 'Common'
+BANNED_CATEGORY_NAME: 'Banned'
+
+# DATE TIME FORMAT see http://ruby-doc.org/core/classes/Time.html#M000392
+DEFAULT_DATETIME_FORMAT: '%Y-%m-%d %I:%M%p'
+
+# SEARCH TIPS
+SEARCH_TIPS: ['arthur merlin words>1000 sort:hits', 'words:100', 'buffy gen teen AND "no archive warnings apply"', 'lex m/m (mature OR explicit)',
+ 'hetalia f/f sort:kudos', '"sherlock (tv)" m/m NOT "sherlock holmes/john watson"', 'austen words:10000-50000 sort:title',
+ 'katekyou "alternate universe" sort:>words', '"uchiha sasuke/uzumaki naruto" angst kudos>10'
+]
+
+# This is used to determine how many recent items
+# to cache for recent lookups -- eg if we want to
+# provide a "ten most recent" items to browse.
+# used in CacheFinds module -- include CacheFinds in a model
+# and the methods Model.recent and Model.last(#) will be
+# enabled, cached on production/test environments.
+MAX_RECENT: 20
+
+# This determines the maximum value of (from + size) for searches
+# to all indices (Elasticsearch index setting max_result_window).
+# If a search has more results than this limit, the last page of
+# search results will include a message advising the user to narrow
+# their search or change their sorting options.
+MAX_SEARCH_RESULTS: 100000
+
+# This is used to determine how many works or how many authors have to be
+# present in order for anonymous or mystery works to begin being displayed
+ANONYMOUS_THRESHOLD_COUNT: 10
+
+# This is used to determine how tolerant of changes to be when determining
+# whether a comment should go back into moderation after editing or not
+COMMENT_MODERATION_THRESHOLD: 10
+
+# This is used to determine how tolerant of changes to be when determining
+# whether a comment should be spam checked again when edited
+EDITED_COMMENT_SPAM_CHECK_THRESHOLD: 10
+
+# SANITIZER VERSION
+SANITIZER_VERSION: 4
+
+# parameters that must be natural integers and their default values
+NONZERO_INTEGER_PARAMETERS:
+ page: 1
+ per_page: 25
+
+# Enable image safety mode when sanitizing comments on the following parents for
+# display. This is the parent_type of the comment, not the ultimate_parent,
+# so if you want to add extra sanitization to work comments, use Chapter here.
+# Options: AdminPost, Chapter, Tag.
+PARENTS_WITH_IMAGE_SAFETY_MODE: []
+
+# These fields are encrypted and should not have characters escaped or be treated as HTML
+FIELDS_WITHOUT_SANITIZATION: ["password", "password_check", "password_confirmation"]
+
+# These fields are query fields and are allowed to contain "less than" values
+FIELDS_ALLOWING_LESS_THAN: ["query", "words", "kudos", "hits", "date", "bookmarkable_date", "word_count", "bookmarks_count", "comments_count", "kudos_count", "revised_at"]
+
+# Only the following fields are allowed to have HTML. In others, all HTML tags
+# will be stripped by the "sanitize_ac_params" filter.
+#
+# The exact HTML tags and attributes and protocols allowed are defined in:
+# - config/initializers/gem-plugin_config/sanitizer_config.rb
+# - lib/html_cleaner.rb
+#
+# If you add a new field to this list, you need to:
+#
+# - make sure every model containing the field also has a matching field "[fieldname]_sanitizer_version", e.g.
+# add_column :chapters, :content_sanitizer_version, :integer, default: 0, null: false, limit: 2
+#
+# - make sure the field is NOT permitted for mass updating (by not including it in params.permit calls)
+#
+# - display the field in views using the HtmlCleaner helper, e.g.
+# <%=raw sanitize_field(object, :fieldname) %>
+#
+# This will ensure that the field has been sanitized with the latest version of the sanitizer.
+FIELDS_ALLOWING_HTML: ["about_me", "bookmarker_notes", "comment", "comment_content", "content", "description", "disabled_support_form_text", "endnotes", "faq", "intro", "notes",
+ "rules", "series_notes", "signup_instructions_general", "signup_instructions_offers", "signup_instructions_requests", "summary"]
+
+FIELDS_ALLOWING_HTML_ENTITIES: ["title"]
+
+FIELDS_ALLOWING_MEDIA_EMBEDS: ["content"]
+
+FIELDS_ALLOWING_CSS: ["content", "endnotes", "notes"]
+
+# Domains that are not allowed be used as the src for audio and video HTML tags.
+# Format: ["google.com", "archiveofourown.org"]
+BANNED_MULTIMEDIA_SRCS: []
+
+# Allowed CSS
+#
+# *** IMPORTANT: if you edit these values please also update the
+# skins-creating.html file in public/help ***
+#
+# the following properties and keywords will be ADDED to the default set allowed
+# in user-submitted CSS code, along with:
+# - hex and rgb color values
+# - numeric values specified with percentages or cm|em|ex|in|mm|pc|pt|px|%
+# - image files specified with url(http://image.url/blahblah/whatever.(jpg|png|gif))
+#
+
+# We use this list for properties which can take shorthand values and also have
+# subproperties or variants (ie, because "border" is in here, we allow through:
+# border-right, border-bottom-left-radius, -moz-border-foo, etc)
+# It is slightly less secure so do NOT put any property in here if you aren't
+# sure all variations on it are okay!
+SUPPORTED_CSS_SHORTHAND_PROPERTIES:
+- background
+- border
+- column
+- cue
+- flex
+- font
+- layer-background
+- layout-grid
+- list-style
+- margin
+- marker
+- outline
+- overflow
+- padding
+- page-break
+- pause
+- scrollbar
+- text
+- transform
+- transition
+
+SUPPORTED_CSS_PROPERTIES:
+- -replace
+- -use-link-source
+- accelerator
+- accent-color
+- align-content
+- align-items
+- align-self
+- alignment-adjust
+- alignment-baseline
+- appearance
+- azimuth
+- baseline-shift
+- behavior
+- binding
+- bookmark-label
+- bookmark-level
+- bookmark-target
+- bottom
+- box-align
+- box-direction
+- box-flex
+- box-flex-group
+- box-lines
+- box-orient
+- box-pack
+- box-shadow
+- box-sizing
+- caption-side
+- clear
+- clip
+- color
+- color-profile
+- color-scheme
+- content
+- counter-increment
+- counter-reset
+- crop
+- cue
+- cue-after
+- cue-before
+- cursor
+- direction
+- display
+- dominant-baseline
+- drop-initial-after-adjust
+- drop-initial-after-align
+- drop-initial-before-adjust
+- drop-initial-before-align
+- drop-initial-size
+- drop-initial-value
+- elevation
+- empty-cells
+- filter
+- fit
+- fit-position
+- float
+- float-offset
+- font
+- font-effect
+- font-emphasize
+- font-emphasize-position
+- font-emphasize-style
+- font-family
+- font-size
+- font-size-adjust
+- font-smooth
+- font-stretch
+- font-style
+- font-variant
+- font-weight
+- grid-columns
+- grid-rows
+- hanging-punctuation
+- height
+- hyphenate-after
+- hyphenate-before
+- hyphenate-character
+- hyphenate-lines
+- hyphenate-resource
+- hyphens
+- icon
+- image-orientation
+- image-resolution
+- ime-mode
+- include-source
+- inline-box-align
+- justify-content
+- layout-flow
+- left
+- letter-spacing
+- line-break
+- line-height
+- line-stacking
+- line-stacking-ruby
+- line-stacking-shift
+- line-stacking-strategy
+- mark
+- mark-after
+- mark-before
+- marks
+- marquee-direction
+- marquee-play-count
+- marquee-speed
+- marquee-style
+- max-height
+- max-width
+- min-height
+- min-width
+- move-to
+- nav-down
+- nav-index
+- nav-left
+- nav-right
+- nav-up
+- opacity
+- order
+- orphans
+- page
+- page-policy
+- phonemes
+- pitch
+- pitch-range
+- play-during
+- position
+- presentation-level
+- punctuation-trim
+- quotes
+- rendering-intent
+- resize
+- rest
+- rest-after
+- rest-before
+- richness
+- right
+- rotation
+- rotation-point
+- ruby-align
+- ruby-overhang
+- ruby-position
+- ruby-span
+- size
+- speak
+- speak-header
+- speak-numeral
+- speak-punctuation
+- speech-rate
+- stress
+- string-set
+- tab-side
+- table-layout
+- target
+- target-name
+- target-new
+- target-position
+- top
+- unicode-bibi
+- unicode-bidi
+- user-select
+- vertical-align
+- visibility
+- voice-balance
+- voice-duration
+- voice-family
+- voice-pitch
+- voice-pitch-range
+- voice-rate
+- voice-stress
+- voice-volume
+- volume
+- white-space
+- white-space-collapse
+- widows
+- width
+- word-break
+- word-spacing
+- word-wrap
+- writing-mode
+- z-index
+
+# We allow the !important keyword as well as any string that is just a-z plus
+# dashes, as well as space- or comma-separated lists of such strings. The only
+# keywords you need to specify are keywords that contain other characters.
+#
+# We have the URL_FUNCTION_REGEX in lib/css_cleaner.rb, but including "url" here
+# is necessary to prevent the url() function from being stripped before any
+# value containing it is sanitized. Allowing images in skins should be moved to
+# its own config variable in the future.
+#
+# Do not add other functions here; they are not keywords and are controlled by
+# regular expressions in lib/css_cleaner.rb.
+SUPPORTED_CSS_KEYWORDS: ["!important", "url"]
+
+# if you include "url" in the SUPPORTED_CSS_KEYWORDS, only urls pointing to this
+# kind of resource will be allowed
+SUPPORTED_EXTERNAL_URLS: ["jpg", "jpeg", "png", "gif"]
+
+# variables for Askimet http://akismet.com/
+AKISMET_KEY: '6833ee7298cf'
+AKISMET_NAME: 'http://transformativeworks.org'
+
+# Abuse and Support ticket trackers; you may need to change
+# feedbacks_controller.rb and abuse_reports_controller.rb
+# to properly send to your own ticket tracker.
+#
+# - Register a new client at Zoho's Developer Console, whatever you fill into "Authorized redirect URIs"
+# is ZOHO_REDIRECT_URI.
+# - Upon registering, you receive ZOHO_CLIENT_ID and ZOHO_CLIENT_SECRET.
+# - Upon requesting an "offline" authorization grant, you receive ZOHO_REFRESH_TOKEN.
+ZOHO_CLIENT_ID: ""
+ZOHO_CLIENT_SECRET: ""
+ZOHO_REDIRECT_URI: ""
+ZOHO_REFRESH_TOKEN: ""
+ZOHO_ORG_ID: ""
+ABUSE_ZOHO_DEPARTMENT_ID: ""
+SUPPORT_ZOHO_DEPARTMENT_ID: ""
+
+DEFAULT_LANGUAGE_SHORT: 'en'
+DEFAULT_LANGUAGE_NAME: 'English'
+DEFAULT_LOCALE_ISO: 'en'
+DEFAULT_LOCALE_NAME: 'English (US)'
+
+# Allow orphaninig
+ORPHANING_ALLOWED: true
+
+# Help directory, must be in the public folder.
+# Create subfolders for each supported language
+# (eg, /help/fr/ contains the French versions of the help)
+HELP_DIRECTORY: '/help'
+
+# production caching
+#PRODUCTION_CACHE: "memory"
+PRODUCTION_CACHE: "memcache"
+
+# TTL in seconds for cached work and bookmark counts on user/pseud dashboards.
+# Default to 20 minutes.
+SECONDS_UNTIL_DASHBOARD_COUNTS_EXPIRE: 1200
+
+# how many cache pages to expire for a tag/collection/pseud when a work is updated or deleted
+PAGES_TO_CACHE: 5
+
+# Turn this off in local.yml to keep the development footer/profiling code from running
+DEVELOPMENT_PROFILING_ENABLED: true
+
+
+# These are the actions logged in the user history - the log_items table
+ACTION_ACTIVATE: 0
+ACTION_ADD_ROLE: 1
+ACTION_REMOVE_ROLE: 2
+ACTION_SUSPEND: 3
+ACTION_UNSUSPEND: 4
+ACTION_BAN: 5
+ACTION_WARN: 6
+ACTION_RENAME: 7
+ACTION_PASSWORD_CHANGE: 8
+ACTION_NEW_EMAIL: 9
+ACTION_TROUBLESHOOT: 10
+ACTION_NOTE: 11
+ACTION_ADD_FNOK: 12
+ACTION_REMOVE_FNOK: 13
+ACTION_ADDED_AS_FNOK: 14
+ACTION_REMOVED_AS_FNOK: 15
+ACTION_PASSWORD_RESET: 16
+
+
+# Elasticsearch index prefix
+# If you're using webdev or another shared environment, change this to include
+# your username (e.g. ao3_testy) to ensure all users have separate search
+# indexes
+ELASTICSEARCH_PREFIX: 'ao3'
+
+
+# List of Unicode scripts where spaces are not used as separators, and
+# character counts should be used for word counts.
+# Check if a script is supported by the version of Ruby we use:
+# https://ruby-doc.org/core/Regexp.html#class-Regexp-label-Character+Properties
+CHARACTER_COUNT_SCRIPTS: ["Han", "Hiragana", "Katakana", "Thai"]
+
+
+# Cache durations for work and bookmark searches (in seconds)
+SECONDS_UNTIL_WORK_INDEX_EXPIRE: 1800
+SECONDS_UNTIL_BOOKMARK_INDEX_EXPIRE: 1800
+
+# Cache duration for commentable kudos list of names
+MINUTES_UNTIL_COMMENTABLE_KUDOS_LISTS_EXPIRE: 60
+
+# Cache duration for comment counts:
+SECONDS_UNTIL_COMMENT_COUNTS_EXPIRE: 3600
+
+# Cache duration for various collection-related counts (# of works, # of
+# bookmarks, # of fandoms):
+SECONDS_UNTIL_COLLECTION_COUNTS_EXPIRE: 300
+
+# Cache durations for work and bookmark searches (in minutes)
+MINUTES_UNTIL_COLLECTION_BLURBS_EXPIRE: 120
+
+# Job size used for the HitCountUpdateJob class:
+HIT_COUNT_JOB_SIZE: 1000
+
+# Batch size used for the HitCountUpdateJob class:
+HIT_COUNT_BATCH_SIZE: 100
+
+# The hour (in UTC time) that we want the hit counts to rollover at. If someone
+# views the work shortly before this hour and shortly after, it should count as
+# two hits. Used by the RedisHitCounter class.
+HIT_COUNT_ROLLOVER_HOUR: 3
+
+# The batch size for calculating a work's filters from its tags:
+FILTER_UPDATE_BATCH_SIZE: 100
+
+# URLs for which we should not display the proxy notice. URLs from these hosts
+# are allowed in Abuse reports and disallowed in Work imports. Alphabetical by
+# environment.
+PERMITTED_HOSTS: [
+ # Production
+ "104.153.64.122",
+ "208.85.241.152",
+ "208.85.241.157",
+ "ao3.org",
+ "archiveofourown.com",
+ "archiveofourown.net",
+ "archiveofourown.org",
+ "download.archiveofourown.org",
+ "insecure.archiveofourown.org",
+ "secure.archiveofourown.org",
+ "www.ao3.org",
+ "www.archiveofourown.com",
+ "www.archiveofourown.net",
+ "www.archiveofourown.org",
+ # Staging
+ "insecure-test.archiveofourown.org",
+ "test.archiveofourown.org",
+ "testdownload.archiveofourown.org"
+]
+
+USER_RENAME_LIMIT_DAYS: 7
+
+# The number of readings to include in a single ReadingsJob:
+READING_JOB_SIZE: 5000
+
+# The number of readings to change in a single transaction:
+READING_BATCH_SIZE: 100
+
+# The number of work IDs to include in a single StatCounterJob:
+STAT_COUNTER_JOB_SIZE: 1000
+
+# The number of works to fetch from the database in one query when updating stat counters:
+STAT_COUNTER_BATCH_SIZE: 100
+
+# Number of hours to retain the original creator for works that have been
+# orphaned.
+ORIGINAL_CREATOR_TTL_HOURS: 72
+
+# The maximum number of tags to provide in a tag wrangling report.
+WRANGLING_REPORT_LIMIT: 1000
+
+# The number of days after which an Admin Post should allow comments.
+# After this window, all comments are disabled. Setting this value to
+# something below 1 -- or commenting it out -- will turn off comment disabling.
+ADMIN_POST_COMMENTING_EXPIRATION_DAYS: 14
+
+# Usernames in this list are not allowed to avoid potential confusion (like
+# a user who has the username 'admin', for example).
+FORBIDDEN_USERNAMES: []
+
+# The arguments to pass to pt-online-schema-change:
+PERCONA_ARGS: >
+ --chunk-size=5k
+ --max-flow-ctl 0
+ --pause-file /tmp/pauseme
+ --max-load Threads_running=15
+ --critical-load Threads_running=100
+ --set-vars innodb_lock_wait_timeout=2
+ --alter-foreign-keys-method=rebuild_constraints
+ --no-check-unique-key-change
+
+# How many shards to have in elastic indexes.
+# Production may have more than 5 for these
+BOOKMARKABLE_SHARDS: 5
+WORKS_SHARDS: 5
+USER_SHARDS: 5
diff --git a/config/cucumber.yml b/config/cucumber.yml
new file mode 100755
index 0000000..ef879fe
--- /dev/null
+++ b/config/cucumber.yml
@@ -0,0 +1,11 @@
+<%
+rerun = File.file?('rerun.txt') ? IO.read('rerun.txt') : ""
+rerun = rerun.strip.gsub /\s/, ' '
+rerun_opts = rerun.empty? ? "--format #{ENV['CUCUMBER_FORMAT'] || 'progress'} features" : "--format #{ENV['CUCUMBER_FORMAT'] || 'pretty'} #{rerun}"
+retry_opts = "--retry #{ENV['CUCUMBER_RETRY'] || '0'} --no-strict-flaky"
+std_opts = "--format #{ENV['CUCUMBER_FORMAT'] || 'pretty'} --strict --tags 'not @wip' #{retry_opts} --color"
+%>
+default: <%= std_opts %> features
+wip: --tags @wip:3 --wip features
+rerun: <%= rerun_opts %> --format rerun --out rerun.txt --strict --tags 'not @wip'
+html_report: --format progress --format html --out=features/features_report.html
diff --git a/config/database.example b/config/database.example
new file mode 100755
index 0000000..7681ef5
--- /dev/null
+++ b/config/database.example
@@ -0,0 +1,45 @@
+defaults: &defaults
+ adapter: mysql2
+ encoding: utf8mb4
+ collation: utf8mb4_unicode_ci
+ username: root
+ password:
+
+development:
+ database: otwarchive_development
+ <<: *defaults
+
+# 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:
+ database: otwarchive_test
+ <<: *defaults
+
+production:
+ database: otwarchive_production
+ <<: *defaults
+
+#
+# The following is an example of how to set up the db for sqlite3
+#
+#
+## SQLite version 3.x
+## gem install sqlite3-ruby (not necessary on OS X Leopard)
+#development:
+# adapter: sqlite3
+# database: db/development.sqlite3
+# timeout: 5000
+
+## 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:
+# adapter: sqlite3
+# database: db/test.sqlite3
+# timeout: 5000
+
+#production:
+# adapter: sqlite3
+# database: db/production.sqlite3
+# timeout: 5000
diff --git a/config/deploy.rb b/config/deploy.rb
new file mode 100755
index 0000000..f164ec9
--- /dev/null
+++ b/config/deploy.rb
@@ -0,0 +1,140 @@
+# BACKGROUND:
+# To describe the idea here -- these are capistrano "recipes" which are a bit like rake tasks
+# You wrap all the fiddly systems scripts and things that you need to do for a deploy into these nice neat little individual tasks
+# and then you can chain the tasks together
+#
+# when you run "cap deploy:migrate" let's say, all the things you've told to run before or after it go automatically
+# eg this line in deploy/production.rb:
+# before "deploy:migrate", "production_only:backup_db"
+# says, if I run "cap deploy:migrate production" then before doing any of the actual work of the deploy,
+# run the task called "production_only:backup_db" which is defined in deploy.rb
+#
+# namespace :production_only do
+# # Back up the production database
+# task :backup_db, roles: :db do
+# run "/static/bin/backup_database.sh &"
+# end
+# end
+#
+# which says, run this script backup_database.sh
+# and run it on the machine that has the ":db" role
+#
+# The roles are defined in each of deploy/production.rb and deploy/staging.rb,
+# and can be set differently for whichever system you are deploying to.
+#
+# Several tasks run automatically based on behind-the-scenes magic
+#
+require "./config/boot"
+require "active_support/core_ext/hash/keys"
+
+# takes care of the bundle install tasks
+require 'bundler/capistrano'
+
+# deploy to different environments with tags
+require 'capistrano/ext/multistage'
+set :stages, %w[staging production]
+set :default_stage, "staging"
+#require 'capistrano/gitflow_version'
+
+# use rvm
+require "rvm/capistrano"
+set :rvm_ruby_string, ENV['GEM_HOME'].gsub(/.*\//, "")
+set :rvm_type, :user
+
+# user settings
+set :user, "ao3app"
+set :auth_methods, "publickey"
+#ssh_options[:verbose] = :debug
+ssh_options[:auth_methods] = %w(publickey)
+set :use_sudo, false
+default_run_options[:shell] = '/bin/bash'
+
+# basic settings
+set :application, "otwarchive"
+set :deploy_to, "/home/ao3app/app"
+set :keep_releases, 4
+
+set :mail_to, "otw-coders@transformativeworks.org otw-testers@transformativeworks.org"
+
+# git settings
+set :scm, :git
+set :repository, "https://github.com/otwcode/otwarchive.git"
+set :deploy_via, :remote_cache
+
+set :servers, -> { YAML.load_file(File.join(__dir__, "servers.yml")).deep_symbolize_keys[fetch(:stage)] }
+
+# overwrite default capistrano deploy tasks
+namespace :deploy do
+ desc "Restart the unicorns"
+ task :restart do
+ find_servers(roles: [:app, :web]).each do |server|
+ puts "restart on #{server.host}"
+ run "cd ~/app/current ; bundle exec rake skins:cache_chooser_skins RAILS_ENV=#{rails_env}", hosts: server.host
+ run "/home/ao3app/bin/unicorns_reload", hosts: server.host
+ end
+ end
+
+ desc "Restart the resque workers"
+ task :restart_workers, roles: :workers do
+ run "/home/ao3app/bin/workers_reload"
+ end
+
+ desc "Restart the schedulers"
+ task :restart_schedulers, roles: :schedulers do
+ run "/home/ao3app/bin/scheduler_reload"
+ end
+
+ desc "Get the config files"
+ task :update_configs, roles: [:app, :web, :workers, :schedulers] do
+ run "/home/ao3app/bin/create_links_on_install"
+ end
+
+ desc "Update the web-related whenever tasks"
+ task :update_cron_web, roles: :web do
+ # run "bundle exec whenever --update-crontab web -f config/schedule_web.rb"
+ run "echo cron entries are currently managed by hand"
+ end
+
+ # This should only be one machine
+ desc "update the crontab for whatever machine should run the scheduled tasks"
+ task :update_cron, roles: :app, only: {primary: true} do
+ # run "bundle exec whenever --update-crontab #{application}"
+ run "echo cron entries are currently managed by hand"
+ end
+
+ # Needs to run on web (front-end) servers, but they must also have rails installed
+ desc "Re-caches the site skins"
+ task :reload_site_skins do
+ find_servers(roles: :web).each do |server|
+ puts "Caching skins on #{server.host}"
+ run "cd ~/app/current ; bundle exec rake skins:cache_chooser_skins RAILS_ENV=#{rails_env} ; cd ~/app ; rm web_old ; ln -f -s `readlink -f current` web_new ; mv web web_old ; mv web_new web", hosts: server.host
+ sleep (10)
+ end
+ end
+end
+
+# ORDER OF EVENTS
+# Calling "cap deploy" runs:
+# deploy:update which runs:
+# deploy:update_code
+# deploy:symlink
+# deploy:restart
+#
+# Calling "cap deploy:migrations" inserts the task "deploy:migrate" before deploy:symlink
+#
+
+# after and before task triggers that should run on both staging and production
+#before "deploy:migrate", "deploy:web:disable"
+#after "deploy:migrate", "extras:run_after_tasks"
+
+#before "deploy:symlink", "deploy:web:enable_new"
+#after "deploy:symlink", "extras:update_revision"
+
+after "deploy:restart", "deploy:update_cron"
+after "deploy:restart", "deploy:update_cron_web"
+#after "deploy:restart", "extras:restart_delayed_jobs"
+#after "deploy:restart", "deploy:cleanup"
+
+after "deploy:restart", "deploy:restart_workers"
+after "deploy:restart", "deploy:restart_schedulers"
+after "deploy:symlink", "deploy:update_configs"
diff --git a/config/deploy/production.rb b/config/deploy/production.rb
new file mode 100755
index 0000000..bd6a52d
--- /dev/null
+++ b/config/deploy/production.rb
@@ -0,0 +1,56 @@
+# ORDER OF EVENTS
+# Calling "cap deploy" runs:
+# deploy:update which runs:
+# deploy:update_code
+# deploy:symlink
+# deploy:restart
+#
+# Calling "cap deploy:migrations" inserts the task "deploy:migrate" before deploy:symlink
+
+require "capistrano/gitflow_version"
+
+fetch(:servers).each do |s|
+ server s[:host], *s[:roles], s[:options] || {}
+end
+
+# our tasks which are production specific
+namespace :production_only do
+ desc "Set up production robots.txt file"
+ task :update_robots, roles: :web do
+ run "cp #{release_path}/public/robots.public.txt #{release_path}/public/robots.txt"
+ end
+
+ desc "Send out 'Archive deployed' notification"
+ task :notify_testers do
+ system "echo 'Archive deployed' | mail -s 'Archive deployed' #{mail_to}"
+ end
+
+ desc "Rebalance nginx and squid"
+ task :rebalance_unicorns, roles: :web do
+ logger.info "Rebalancing in a minute"
+ sleep(60)
+ run "/usr/bin/sudo /var/cfengine/files/scripts/rebalance"
+ logger.info "Rebalancing complete"
+ end
+
+ desc "Update the crontab on the primary app machine"
+ task :update_cron_email, roles: :app, only: {primary: true} do
+ # run "bundle exec whenever --update-crontab production -f config/schedule_production.rb"
+ end
+end
+
+#before "deploy:update_code", "production_only:git_in_home"
+#after "deploy:update_code", "production_only:update_public", "production_only:update_tag_feeds", "production_only:update_configs"
+
+#before "deploy:migrate", "production_only:backup_db"
+
+after "deploy:restart", "production_only:update_cron_email"
+
+after "deploy:update_code", "production_only:update_robots"
+after "deploy:restart", "production_only:notify_testers"
+after "deploy:restart", "production_only:rebalance_unicorns"
+
+
+# deploy from clean branch
+set :branch, "deploy"
+set :rails_env, 'production'
diff --git a/config/deploy/staging.rb b/config/deploy/staging.rb
new file mode 100755
index 0000000..f626e45
--- /dev/null
+++ b/config/deploy/staging.rb
@@ -0,0 +1,41 @@
+# ORDER OF EVENTS
+# Calling "cap deploy" runs:
+# deploy:update which runs:
+# deploy:update_code
+# deploy:symlink
+# deploy:restart
+#
+# Calling "cap deploy:migrations" inserts the task "deploy:migrate" before deploy:symlink
+
+require "capistrano/gitflow_version"
+
+fetch(:servers).each do |s|
+ server s[:host], *s[:roles], s[:options] || {}
+end
+
+set :rails_env, 'staging'
+
+# our tasks which are staging specific
+namespace :stage_only do
+ desc "Set up staging robots.txt file"
+ task :update_robots, roles: :web do
+ run "cp #{release_path}/public/robots.private.txt #{release_path}/public/robots.txt"
+ end
+
+ desc "Send out 'Testarchive deployed' notification"
+ task :notify_testers do
+ system "echo 'Testarchive deployed' | mail -s 'Testarchive deployed' #{mail_to}"
+ end
+end
+
+#before "deploy:update_code", "stage_only:git_in_home"
+after "deploy:update_code", "stage_only:update_robots"
+
+#before "db:reset_on_stage", "deploy:web:disable"
+# reset the database and clear subscriptions and emails out of it
+#after "db:reset_on_stage", "stage_only:reset_db", "stage_only:clear_subscriptions", "stage_only:clear_emails"
+#after "db:reset_on_stage", "stage_only:reindex_elasticsearch"
+#after "db:reset_on_stage", "deploy:web:enable"
+
+# reload the site skins after each deploy since there may have been CSS changes
+after "deploy:restart", "stage_only:notify_testers"
diff --git a/config/docker/Dockerfile b/config/docker/Dockerfile
new file mode 100755
index 0000000..3a986b8
--- /dev/null
+++ b/config/docker/Dockerfile
@@ -0,0 +1,24 @@
+FROM ruby:3.2.7
+
+# Install additional packages
+RUN apt-get update && \
+ apt-get install -y \
+ calibre \
+ default-mysql-client \
+ libvips \
+ shared-mime-info \
+ zip
+
+# Clean and mount repository at /otwa
+RUN rm -rf /otwa && mkdir -p /otwa
+WORKDIR /otwa
+VOLUME /otwa
+
+# Install ruby packages
+COPY Gemfile .
+COPY Gemfile.lock .
+RUN gem install bundler -v 2.6.3 && bundle install
+
+# Default command to run in a new container
+EXPOSE 3000
+CMD bundle exec rails s -p 3000
diff --git a/config/docker/local.yml b/config/docker/local.yml
new file mode 100755
index 0000000..27e3fcf
--- /dev/null
+++ b/config/docker/local.yml
@@ -0,0 +1,2 @@
+ES_URL: es:9200
+MEMCACHED_SERVERS: mc:11211
diff --git a/config/docker/redis.yml b/config/docker/redis.yml
new file mode 100755
index 0000000..63fef41
--- /dev/null
+++ b/config/docker/redis.yml
@@ -0,0 +1,18 @@
+redis_resque:
+ test: redis:6379
+ development: redis:6379
+redis_kudos:
+ test: redis:6379
+ development: redis:6379
+redis_general:
+ test: redis:6379
+ development: redis:6379
+redis_rollout:
+ test: redis:6379
+ development: redis:6379
+redis_autocomplete:
+ test: redis:6379
+ development: redis:6379
+redis_hits:
+ test: redis:6379
+ development: redis:6379
diff --git a/config/environment.rb b/config/environment.rb
new file mode 100755
index 0000000..cac5315
--- /dev/null
+++ b/config/environment.rb
@@ -0,0 +1,5 @@
+# Load the Rails application.
+require_relative "application"
+
+# Initialize the Rails application.
+Rails.application.initialize!
diff --git a/config/environments/production.rb b/config/environments/production.rb
new file mode 100755
index 0000000..28e4e66
--- /dev/null
+++ b/config/environments/production.rb
@@ -0,0 +1,119 @@
+require "active_support/core_ext/integer/time"
+
+Rails.application.configure do
+ # Settings specified here will take precedence over those in config/application.rb.
+
+ config.hosts = ArchiveConfig.PERMITTED_HOSTS
+
+ # 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 = ENV["RAILS_SERVE_STATIC_FILES"].present?
+
+
+
+ config.serve_static_files = true
+
+
+ # 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
+
+
+
+ # Use a different cache store in production
+ config.cache_store = :mem_cache_store, ArchiveConfig.MEMCACHED_SERVERS,
+ { namespace: "ao3-v#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}",
+ compress: true, pool: { size: 10 } }
+
+ # 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 = false
+
+ # Skip http-to-https redirect for the default health check endpoint.
+ # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } }
+
+ # Use default logging formatter so that PID and timestamp are not suppressed.
+ config.log_formatter = ::Logger::Formatter.new
+
+ if ENV["RAILS_LOG_TO_STDOUT"].present?
+ config.logger = ActiveSupport::Logger.new($stdout)
+ .tap { |logger| logger.formatter = config.log_formatter }
+ .then { |logger| ActiveSupport::TaggedLogging.new(logger) }
+ end
+
+ # 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")
+ config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "debug")
+
+ # 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 = "otwarchive_production"
+
+ # Disable caching for Action Mailer templates even if Action Controller
+ # caching is enabled.
+ 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
+
+ config.middleware.use Rack::Attack
+
+ config.active_storage.service = :local
+
+ # Do not dump schema after migrations.
+ config.active_record.dump_schema_after_migration = false
+
+ # Only use :id for inspections in production.
+ # config.active_record.attributes_for_inspect = [:id]
+
+ # 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/staging.rb b/config/environments/staging.rb
new file mode 100755
index 0000000..e92b4ad
--- /dev/null
+++ b/config/environments/staging.rb
@@ -0,0 +1,80 @@
+Otwarchive::Application.configure do
+ # Settings specified here will take precedence over those in config/environment.rb
+
+ config.hosts = ArchiveConfig.PERMITTED_HOSTS
+
+ # The production environment is meant for finished, "live" apps.
+ # Code is not reloaded between requests
+ config.cache_classes = true
+ 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
+ config.action_mailer.perform_caching = true
+
+ # Specifies the header that your server uses for sending files
+ # config.action_dispatch.x_sendfile_header = "X-Sendfile"
+
+ # For nginx:
+ # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect'
+
+ # If you have no front-end server that supports something like X-Sendfile,
+ # just comment this out and Rails will serve the files
+
+ # Disable IP spoofing protection
+ config.action_dispatch.ip_spoofing_check = false
+
+ # See everything in the log (default is now :debug)
+ # config.log_level = :debug
+ config.log_level = :info
+
+ # Use a different logger for distributed setups
+ # config.logger = SyslogLogger.new
+
+ # Use a different cache store in production
+ config.cache_store = :mem_cache_store, ArchiveConfig.MEMCACHED_SERVERS,
+ { namespace: "ao3-v#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}",
+ compress: true, pool: { size: 5 } }
+
+ # Disable Rails's static asset server
+ # In production, Apache or nginx will already do this
+ config.serve_static_files = false
+
+ # Enable serving of images, stylesheets, and javascripts from an asset server
+ # config.action_controller.asset_host = "http://assets.example.com"
+
+ # Disable delivery errors, bad email addresses will be ignored
+ # config.action_mailer.raise_delivery_errors = false
+
+ # Enable mailer previews.
+ config.action_mailer.show_previews = true
+
+ # Enable threaded mode
+ # config.threadsafe!
+
+ # Send deprecation notices to registered listeners
+ config.active_support.deprecation = :notify
+
+ # Make it clear we are on staging
+ config.rack_dev_mark.enable = true
+ config.rack_dev_mark.theme = [:title, Rack::DevMark::Theme::GithubForkRibbon.new(position: "left", fixed: "true", color: "orange")]
+
+ config.after_initialize do
+ Bullet.enable = true
+ Bullet.bullet_logger = true
+ Bullet.add_footer = false
+ Bullet.console = true
+ Bullet.rails_logger = true
+ Bullet.counter_cache_enable = false
+ end
+
+ # Store uploaded files in AWS S3, proxied so we can cache them.
+ config.active_storage.service = :s3
+ config.active_storage.resolve_model_to_route = :rails_storage_proxy
+
+ config.middleware.use Rack::Attack
+
+ # Disable dumping schemas after migrations.
+ config.active_record.dump_schema_after_migration = false
+end
diff --git a/config/environments/test.rb b/config/environments/test.rb
new file mode 100755
index 0000000..b7395ff
--- /dev/null
+++ b/config/environments/test.rb
@@ -0,0 +1,74 @@
+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.headers = { "Cache-Control" => "public, max-age=#{1.hour.to_i}" }
+
+ # Show full error reports and enable caching.
+ config.consider_all_requests_local = true
+ config.action_controller.perform_caching = true
+ config.action_controller.page_cache_directory = Rails.root.join("public/test_cache")
+
+ config.action_mailer.perform_caching = true
+
+ config.cache_store = :mem_cache_store, ArchiveConfig.MEMCACHED_SERVERS,
+ { namespace: "ao3-v2-test", compress: true, pool: { size: 10 }, raise_errors: true }
+
+ # 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
+
+ # 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
+
+ # Inline ActiveJob when testing:
+ config.active_job.queue_adapter = :inline
+
+ # Print deprecation notices to the stderr.
+ config.active_support.deprecation = :stderr
+
+ # Configure strong parameters to raise an exception if an unpermitted attribute is used
+ config.action_controller.action_on_unpermitted_parameters = :raise
+
+ # Make sure that we don't have a host mismatch:
+ config.action_controller.default_url_options = { host: "www.example.com", port: nil }
+ config.action_mailer.default_url_options = config.action_controller.default_url_options.merge(protocol: "https")
+
+ # 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/gh-actions/database.yml b/config/gh-actions/database.yml
new file mode 100755
index 0000000..af5a14b
--- /dev/null
+++ b/config/gh-actions/database.yml
@@ -0,0 +1,11 @@
+test:
+ host: 127.0.0.1
+ port: 3306
+ adapter: mysql2
+ database: otwarchive_test
+ username: root
+ password: password
+ encoding: utf8mb4
+ collation: utf8mb4_unicode_ci
+ variables:
+ sql_mode: STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION
diff --git a/config/gh-actions/local.yml b/config/gh-actions/local.yml
new file mode 100755
index 0000000..8990cb0
--- /dev/null
+++ b/config/gh-actions/local.yml
@@ -0,0 +1,2 @@
+ES_URL: 'http://127.0.0.1:9200'
+BCRYPT_COST: 4
diff --git a/config/gh-actions/redis.yml b/config/gh-actions/redis.yml
new file mode 100755
index 0000000..fd21268
--- /dev/null
+++ b/config/gh-actions/redis.yml
@@ -0,0 +1,12 @@
+redis_autocomplete:
+ test: localhost:6379
+redis_general:
+ test: localhost:6380
+redis_hits:
+ test: localhost:6381
+redis_kudos:
+ test: localhost:6382
+redis_resque:
+ test: localhost:6383
+redis_rollout:
+ test: localhost:6384
diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml
new file mode 100755
index 0000000..4e82ccc
--- /dev/null
+++ b/config/i18n-tasks.yml
@@ -0,0 +1,277 @@
+# i18n-tasks finds and manages missing and unused translations: https://github.com/glebm/i18n-tasks
+
+# The "main" locale.
+base_locale: en
+## All available locales are inferred from the data by default. Alternatively, specify them explicitly:
+# locales: [es, fr]
+## Reporting locale, default: en. Available: en, ru.
+# internal_locale: en
+
+# Read and write translations.
+data:
+ ## Translations are read from the file system. Supported format: YAML, JSON.
+ ## Provide a custom adapter:
+ # adapter: I18n::Tasks::Data::FileSystem
+
+ # Locale files or `Find.find` patterns where translations are read from:
+ read:
+ - config/locales/**/en.yml
+ - config/locales/phrase-exports/%{locale}.yml
+ # - locales/views/zh-CN.yml # These are also in the phrase exports
+
+ # Locale files to write new keys to, based on a list of key pattern => file rules. Matched from top to bottom:
+ # `i18n-tasks normalize -p` will force move the keys according to these rules
+ write:
+ # - 'config/locales/phrase-exports/%{locale}.yml' # Cannot route by language, 'en' files end up in the wrong place
+ ## For example, write devise and simple form keys to their respective files:
+ # - ['{devise, simple_form}.*', 'config/locales/\1.%{locale}.yml']
+ ## Catch-all default:
+ # - config/locales/%{locale}.yml
+
+ # External locale data (e.g. gems).
+ # This data is not considered unused and is never written to.
+ external:
+ - <%= %x[bundle info rails-i18n --path].chomp %>/rails/locale/%{locale}.yml
+
+ ## Specify the router (see Readme for details). Valid values: conservative_router, pattern_router, or a custom class.
+ # router: conservative_router
+
+ yaml:
+ write:
+ # do not wrap lines at 80 characters
+ line_width: -1
+
+ ## Pretty-print JSON:
+ # json:
+ # write:
+ # indent: ' '
+ # space: ' '
+ # object_nl: "\n"
+ # array_nl: "\n"
+
+# Find translate calls
+search:
+ ## Paths or `Find.find` patterns to search in:
+ # paths:
+ # - app/
+
+ ## Root directories for relative keys resolution.
+ # relative_roots:
+ # - app/controllers
+ # - app/helpers
+ # - app/mailers
+ # - app/presenters
+ # - app/views
+
+ ## Directories where method names which should not be part of a relative key resolution.
+ # By default, if a relative translation is used inside a method, the name of the method will be considered part of the resolved key.
+ # Directories listed here will not consider the name of the method part of the resolved key
+ #
+ # relative_exclude_method_name_paths:
+ # -
+
+ ## Files or `File.fnmatch` patterns to exclude from search. Some files are always excluded regardless of this setting:
+ ## *.jpg *.jpeg *.png *.gif *.svg *.ico *.eot *.otf *.ttf *.woff *.woff2 *.pdf *.css *.sass *.scss *.less
+ ## *.yml *.json *.zip *.tar.gz *.swf *.flv *.mp3 *.wav *.flac *.webm *.mp4 *.ogg *.opus *.webp *.map *.xlsx
+ # exclude:
+ # - app/assets/images
+ # - app/assets/fonts
+ # - app/assets/videos
+ # - app/assets/builds
+
+ ## Alternatively, the only files or `File.fnmatch patterns` to search in `paths`:
+ ## If specified, this settings takes priority over `exclude`, but `exclude` still applies.
+ # only: ["*.rb", "*.html.slim"]
+
+ ## If `strict` is `false`, guess usages such as t("categories.#{category}.title"). The default is `true`.
+ # strict: true
+
+ ## Allows adding ast_matchers for finding translations using the AST-scanners
+ ## The available matchers are:
+ ## - RailsModelMatcher
+ ## Matches ActiveRecord translations like
+ ## User.human_attribute_name(:email) and User.model_name.human
+ ## - DefaultI18nSubjectMatcher
+ ## Matches ActionMailer's default_i18n_subject method
+ ##
+ ## To implement your own, please see `I18n::Tasks::Scanners::AstMatchers::BaseMatcher`.
+ ast_matchers:
+ - 'I18n::Tasks::Scanners::AstMatchers::RailsModelMatcher'
+ - 'I18n::Tasks::Scanners::AstMatchers::DefaultI18nSubjectMatcher'
+
+ ## Multiple scanners can be used. Their results are merged.
+ ## The options specified above are passed down to each scanner. Per-scanner options can be specified as well.
+ ## See this example of a custom scanner: https://github.com/glebm/i18n-tasks/wiki/A-custom-scanner-example
+
+## Translation Services
+# translation:
+# # Google Translate
+# # Get an API key and set billing info at https://code.google.com/apis/console to use Google Translate
+# google_translate_api_key: "AbC-dEf5"
+# # DeepL Pro Translate
+# # Get an API key and subscription at https://www.deepl.com/pro to use DeepL Pro
+# deepl_api_key: "48E92789-57A3-466A-9959-1A1A1A1A1A1A"
+# # deepl_host: "https://api.deepl.com"
+# # deepl_version: "v2"
+
+## Do not consider these keys missing:
+ignore_missing:
+ # File: app/helpers/mailer_helper.rb
+ - comment_mailer.subject_for_commentable.subject.{chapter,other,tag}
+ # File: app/mailers/comment_mailer.rb
+ - mailer_helper.content_for_commentable_html.chapter.{titled,untitled}
+ - mailer_helper.content_for_commentable_html.content.{chapter,other,tag}.html
+ - mailer_helper.content_for_commentable_text.content.{other,tag}.text
+ - mailer_helper.content_for_commentable_text.content.chapter.{titled_text,untitled_text}
+ ## All of the following keys are using default values defined in the respective .rb files
+ ## TODO: Move the default values to the .yml files
+ # File: app/controllers/admin/admin_invitations_controller.rb
+ - invites_created # should be admin.admin_invitations.grant_invites_to_users.invites_created
+ - no_email # should be admin.admin_invitations.create.no_email
+ - sent # should be admin.admin_invitations.create.sent
+ - user_not_found # should be admin.admin_invitations.find.user_not_found
+ # File: app/controllers/challenge_assignments_controller.rb
+ - challenge_assignments.assignments_not_sent
+ - challenge_assignments.assignments_sent
+ - challenge_assignments.no_challenge
+ - challenge_assignments.signup_open
+ # File: app/controllers/challenges_controller.rb
+ - challenge.no_collection # should be challenges.no_collection
+ - challenges.no_challenge
+ # File: app/controllers/collection_items_controller.rb
+ - collection_items.create.invitation_not_sent # should not be using lazy lookup
+ # File: app/controllers/collection_participants_controller.rb
+ - applied_to_join_collection # should be collection_participants.applied_to_join_collection
+ - no_collection # should be collection_participants.no_collection
+ - collection_participants.accepted_invite
+ - collection_participants.destroy
+ - collection_participants.no_invitation
+ - collection_participants.update_success
+ # File: app/controllers/external_authors_controller.rb
+ - external_author_claimed # should be external_authors.complete_claim.external_author_claimed
+ # Files: app/controllers/external_works_controller.rb and app/controllers/languages_controller.rb
+ - successfully_updated # should be languages.update.successfully_updated
+ # File: app/controllers/feedbacks_controller.rb
+ - failure_send # should be feedbacks.create.failure_send
+ - successfully_sent # should be feedbacks.create.successfully_sent
+ # Files: app/controllers/languages_controller.rb and app/controllers/locales_controller.rb
+ - successfully_added # should be languages.create.successfully_added and locales.create.successfully_added
+ # File: app/views/admin/admin_invitations/find.html.erb
+ - admin.admin_invitations.find.find_email
+ - admin.admin_invitations.find.find_token
+ - admin.admin_invitations.find.find_user_name
+ # File: app/views/admin/admin_invitations/new.html.erb
+ - admin.admin_invitations.new.email_address
+ - admin.admin_invitations.new.invite_user
+ - admin.admin_invitations.new.submit
+ # File: app/views/admin/skins/_navigation.html.erb
+ - skins.approval_queue
+ - skins.approved_skins
+ - skins.rejected_skins
+ # File: app/views/comments/show.html.erb
+ - comments.show.comment_on
+ # File: app/views/external_authors/_external_author_name.html.erb
+ - external_authors.external_author_name.label_external_author_name
+ # File: app/views/external_authors/edit.html.erb
+ - external_authors.edit.back
+ - external_authors.edit.edit_external_author
+ # File: app/views/gifts/_gift_search.html.erb
+ - gifts.gift_search.forms.gift_search
+ - gifts.gift_search.gifts.recipient_field
+ # File: app/views/invitations/index.html.erb
+ - invitations.index.choose_invite
+ - invitations.index.email address # should be invitations.index.email_address
+ - invitations.index.submit_invite
+ # File: app/views/languages/_form.html.erb
+ - languages.form.abuse_support_available
+ - languages.form.create
+ - languages.form.name
+ - languages.form.required_notice
+ - languages.form.short
+ - languages.form.sortable_name
+ - languages.form.support_available
+ - languages.form.update
+ # File: app/views/languages/edit.html.erb
+ - languages.edit.edit_language
+ # File: app/views/languages/new.html.erb
+ - languages.new.new_language
+ # File: app/views/languages/show.html.erb
+ - languages.show.work_count
+ # File: app/views/locales/_locale_form.html.erb
+ - locales.locale_form.actions_heading
+ - locales.locale_form.actions_legend
+ - locales.locale_form.create_button
+ - locales.locale_form.edit_button
+ - locales.locale_form.enable_email
+ - locales.locale_form.enable_interface
+ - locales.locale_form.iso
+ - locales.locale_form.language
+ - locales.locale_form.locale_heading
+ - locales.locale_form.locale_legend
+ - locales.locale_form.name
+ - locales.locale_form.required_notice
+ # File: app/views/locales/_navigation.html.erb
+ - locales.navigation.link_to_index
+ - locales.navigation.link_to_new
+ # File: app/views/locales/edit.html.erb
+ - locales.edit.edit_locale
+ # File: app/views/locales/index.html.erb
+ - locales.index.locale_table_caption
+ - locales.index.locale_table_summary
+ - locales.index.supported_locales
+ # File: app/views/locales/new.html.erb
+ - locales.new.add_new_locale
+ # File: app/views/orphans/index.html.erb
+ - orphans.index.orphaned_works
+ # File: app/views/orphans/new.html.erb
+ - orphans.new.links.cancel
+ - orphans.new.orphans_about
+ # File: app/views/pseuds/edit.html.erb
+ - pseuds.edit.forms.update
+ # File: app/views/pseuds/show.html.erb
+ - pseuds.show.edit_link
+ - pseuds.show.index_link
+ # File: app/views/series/manage.html.erb
+ - series.manage.manage_series
+
+## Consider these keys used:
+ignore_unused:
+ - activerecord.attributes.*
+ - activerecord.errors.models.*
+ - activerecord.models.*
+ - devise.*
+ - errors.messages.*
+ - errors.attributes.ticket_number.{closed_ticket,invalid_department,required}
+ - attributes.ticket_number
+
+## Exclude these keys from the `i18n-tasks eq-base' report:
+# ignore_eq_base:
+# all:
+# - common.ok
+# fr,es:
+# - common.brand
+
+## Exclude these keys from the `i18n-tasks check-consistent-interpolations` report:
+# ignore_inconsistent_interpolations:
+# - 'activerecord.attributes.*'
+
+## Exclude these keys from the newlines test task:
+# ignore_newlines:
+# - user_mailer.signup_notification.*
+
+## Ignore these keys completely:
+# ignore:
+# - kaminari.*
+
+## Sometimes, it isn't possible for i18n-tasks to match the key correctly,
+## e.g. in case of a relative key defined in a helper method.
+## In these cases you can use the built-in PatternMapper to map patterns to keys, e.g.:
+#
+# <%# I18n::Tasks.add_scanner 'I18n::Tasks::Scanners::PatternMapper',
+# only: %w(*.html.haml *.html.slim),
+# patterns: [['= title\b', '.page_title']] %>
+#
+# The PatternMapper can also match key literals via a special %{key} interpolation, e.g.:
+#
+# <%# I18n::Tasks.add_scanner 'I18n::Tasks::Scanners::PatternMapper',
+# patterns: [['\bSpree\.t[( ]\s*%{key}', 'spree.%{key}']] %>
diff --git a/config/initializers/active_record_log_subscriber.rb b/config/initializers/active_record_log_subscriber.rb
new file mode 100755
index 0000000..6b07081
--- /dev/null
+++ b/config/initializers/active_record_log_subscriber.rb
@@ -0,0 +1,16 @@
+module LogQuerySource
+ def debug(*args, &block)
+ return unless super
+
+ backtrace = Rails.backtrace_cleaner.clean caller
+
+ relevant_caller_line = backtrace.detect do |caller_line|
+ !caller_line.include?('/initializers/')
+ end
+
+ if relevant_caller_line
+ logger.debug(" ↳ #{ relevant_caller_line.sub("#{ Rails.root }/", '') }")
+ end
+ end
+end
+ActiveRecord::LogSubscriber.send :prepend, LogQuerySource
diff --git a/config/initializers/active_record_query_trace.rb b/config/initializers/active_record_query_trace.rb
new file mode 100755
index 0000000..04f0e0a
--- /dev/null
+++ b/config/initializers/active_record_query_trace.rb
@@ -0,0 +1,5 @@
+if Rails.env.development?
+ ActiveRecordQueryTrace.enabled = true
+ ActiveRecordQueryTrace.ignore_cached_queries = true
+ ActiveRecordQueryTrace.colorize = :light_purple
+end
diff --git a/config/initializers/active_record_schema_ignore_tables.rb b/config/initializers/active_record_schema_ignore_tables.rb
new file mode 100755
index 0000000..60e89a7
--- /dev/null
+++ b/config/initializers/active_record_schema_ignore_tables.rb
@@ -0,0 +1,4 @@
+ActiveRecord::SchemaDumper.ignore_tables += [
+ # MySQL/MariaDB internals
+ "innodb_monitor"
+]
diff --git a/config/initializers/application_controller_renderer.rb b/config/initializers/application_controller_renderer.rb
new file mode 100755
index 0000000..89d2efa
--- /dev/null
+++ b/config/initializers/application_controller_renderer.rb
@@ -0,0 +1,8 @@
+# Be sure to restart your server when you modify this file.
+
+# ActiveSupport::Reloader.to_prepare do
+# ApplicationController.renderer.defaults.merge!(
+# http_host: 'example.org',
+# https: false
+# )
+# end
diff --git a/config/initializers/archive_config/locale.rb b/config/initializers/archive_config/locale.rb
new file mode 100755
index 0000000..8508c0e
--- /dev/null
+++ b/config/initializers/archive_config/locale.rb
@@ -0,0 +1,16 @@
+Rails.application.config.after_initialize do
+ ActiveRecord::Base.connection
+ # try to set language and locale using models (which use Archive Config)
+ Language.default
+ Locale.default
+rescue ActiveRecord::NoDatabaseError
+ # No database, this happens when we run rake db:create
+rescue ActiveRecord::ConnectionNotEstablished
+ # no database connection
+rescue
+ # ArchiveConfig didn't work, try to set it manually
+ if Language.table_exists? && Locale.table_exists?
+ language = Language.find_or_create_by(short: "en", name: "English")
+ Locale.set_base_locale(iso: "en", name: "English (US)", language_id: language.id)
+ end
+end
diff --git a/config/initializers/archive_config/settings_for_admin.rb b/config/initializers/archive_config/settings_for_admin.rb
new file mode 100755
index 0000000..f5f9e29
--- /dev/null
+++ b/config/initializers/archive_config/settings_for_admin.rb
@@ -0,0 +1,8 @@
+Rails.application.config.after_initialize do
+ # If we have no database, fall through to rescue
+ ActiveRecord::Base.connection
+ AdminSetting.default if AdminSetting.table_exists?
+rescue ActiveRecord::ConnectionNotEstablished
+rescue ActiveRecord::NoDatabaseError
+ # This happens if we are running rake db:create
+end
diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb
new file mode 100755
index 0000000..68fd3a0
--- /dev/null
+++ b/config/initializers/assets.rb
@@ -0,0 +1,7 @@
+# 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
diff --git a/config/initializers/backtrace_silencers.rb b/config/initializers/backtrace_silencers.rb
new file mode 100755
index 0000000..33699c3
--- /dev/null
+++ b/config/initializers/backtrace_silencers.rb
@@ -0,0 +1,8 @@
+# Be sure to restart your server when you modify this file.
+
+# You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces.
+# Rails.backtrace_cleaner.add_silencer { |line| /my_noisy_library/.match?(line) }
+
+# You can also remove all the silencers if you're trying to debug a problem that might stem from framework code
+# by setting BACKTRACE=1 before calling your invocation, like "BACKTRACE=1 ./bin/rails runner 'MyClass.perform'".
+Rails.backtrace_cleaner.remove_silencers! if ENV["BACKTRACE"]
diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb
new file mode 100755
index 0000000..b3076b3
--- /dev/null
+++ b/config/initializers/content_security_policy.rb
@@ -0,0 +1,25 @@
+# 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/cookies_serializer.rb b/config/initializers/cookies_serializer.rb
new file mode 100755
index 0000000..f51a497
--- /dev/null
+++ b/config/initializers/cookies_serializer.rb
@@ -0,0 +1,5 @@
+# Be sure to restart your server when you modify this file.
+
+# Specify a serializer for the signed and encrypted cookie jars.
+# Valid options are :json, :marshal, and :hybrid.
+Rails.application.config.action_dispatch.cookies_serializer = :hybrid
diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb
new file mode 100755
index 0000000..0c5dd99
--- /dev/null
+++ b/config/initializers/cors.rb
@@ -0,0 +1,16 @@
+# Be sure to restart your server when you modify this file.
+
+# Avoid CORS issues when API is called from the frontend app.
+# Handle Cross-Origin Resource Sharing (CORS) in order to accept cross-origin Ajax requests.
+
+# Read more: https://github.com/cyu/rack-cors
+
+# Rails.application.config.middleware.insert_before 0, Rack::Cors do
+# allow do
+# origins "example.com"
+#
+# resource "*",
+# headers: :any,
+# methods: [:get, :post, :put, :patch, :delete, :options, :head]
+# end
+# end
diff --git a/config/initializers/departure.rb b/config/initializers/departure.rb
new file mode 100755
index 0000000..f201a56
--- /dev/null
+++ b/config/initializers/departure.rb
@@ -0,0 +1,10 @@
+Departure.configure do |config|
+ # Disable departure by default. To use pt-online-schema-change for a
+ # migration, call
+ # uses_departure! if Rails.env.staging? || Rails.env.production?
+ # in the migration file.
+ config.enabled_by_default = false
+
+ # Set the arguments based on the config file:
+ config.global_percona_args = ArchiveConfig.PERCONA_ARGS.squish
+end
diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb
new file mode 100755
index 0000000..194b760
--- /dev/null
+++ b/config/initializers/devise.rb
@@ -0,0 +1,272 @@
+# Use this hook to configure devise mailer, warden hooks and so forth.
+# Many of these configuration options can be set straight in your model.
+Devise.setup do |config|
+ # The secret key used by Devise. Devise uses this key to generate
+ # random tokens. Changing this key will render invalid all existing
+ # confirmation, reset password and unlock tokens in the database.
+ # Devise will use the `secret_key_base` on Rails 4+ applications as its `secret_key`
+ # by default. You can change it below and use your own secret key.
+ config.secret_key = ArchiveConfig.SESSION_SECRET
+
+ # ==> Mailer Configuration
+ # Configure the e-mail address which will be shown in Devise::Mailer,
+ # note that it will be overwritten if you use your own mailer class
+ # with default "from" parameter.
+ config.mailer_sender = "Archive of Our Own <#{ArchiveConfig.RETURN_ADDRESS}>"
+
+ # Configure the class responsible to send e-mails.
+ config.mailer = 'ArchiveDeviseMailer'
+
+ # ==> ORM configuration
+ # Load and configure the ORM. Supports :active_record (default) and
+ # :mongoid (bson_ext recommended) by default. Other ORMs may be
+ # available as additional gems.
+ require 'devise/orm/active_record'
+
+ # ==> Configuration for any authentication mechanism
+ # Configure which keys are used when authenticating a user. The default is
+ # just :email. You can configure it to use [:username, :subdomain], so for
+ # authenticating a user, both parameters are required. Remember that those
+ # parameters are used only when authenticating and not when retrieving from
+ # session. If you need permissions, you should implement that in a before filter.
+ # You can also supply a hash where the value is a boolean determining whether
+ # or not authentication should be aborted when the value is not present.
+ config.authentication_keys = [:login]
+
+ # Configure parameters from the request object used for authentication. Each entry
+ # given should be a request method and it will automatically be passed to the
+ # find_for_authentication method and considered in your model lookup. For instance,
+ # if you set :request_keys to [:subdomain], :subdomain will be used on authentication.
+ # The same considerations mentioned for authentication_keys also apply to request_keys.
+ # config.request_keys = []
+
+ # Configure which authentication keys should be case-insensitive.
+ # These keys will be downcased upon creating or modifying a user and when used
+ # to authenticate or find a user. Default is :email.
+ config.case_insensitive_keys = []
+
+ # Configure which authentication keys should have whitespace stripped.
+ # These keys will have whitespace before and after removed upon creating or
+ # modifying a user and when used to authenticate or find a user. Default is :email.
+ config.strip_whitespace_keys = [:login]
+
+ # Tell if authentication through request.params is enabled. True by default.
+ # It can be set to an array that will enable params authentication only for the
+ # given strategies, for example, `config.params_authenticatable = [:database]` will
+ # enable it only for database (email + password) authentication.
+ # config.params_authenticatable = true
+
+ # Tell if authentication through HTTP Auth is enabled. False by default.
+ # It can be set to an array that will enable http authentication only for the
+ # given strategies, for example, `config.http_authenticatable = [:database]` will
+ # enable it only for database authentication. The supported strategies are:
+ # :database = Support basic authentication with authentication key + password
+ # config.http_authenticatable = false
+
+ # If 401 status code should be returned for AJAX requests. True by default.
+ # config.http_authenticatable_on_xhr = true
+
+ # The realm used in Http Basic Authentication. 'Application' by default.
+ # config.http_authentication_realm = 'Application'
+
+ # It will change confirmation, password recovery and other workflows
+ # to behave the same regardless if the e-mail provided was right or wrong.
+ # Does not affect registerable.
+ # config.paranoid = true
+
+ # By default Devise will store the user in session. You can skip storage for
+ # particular strategies by setting this option.
+ # Notice that if you are skipping storage for all authentication paths, you
+ # may want to disable generating routes to Devise's sessions controller by
+ # passing skip: :sessions to `devise_for` in your config/routes.rb
+ config.skip_session_storage = [:http_auth]
+
+ # By default, Devise cleans up the CSRF token on authentication to
+ # avoid CSRF token fixation attacks. This means that, when using AJAX
+ # requests for sign in and sign up, you need to get a new CSRF token
+ # from the server. You can disable this option at your own risk.
+ # config.clean_up_csrf_token_on_authentication = true
+
+ # ==> Configuration for :database_authenticatable
+ # For bcrypt, this is the cost for hashing the password and defaults to 10. If
+ # using other encryptors, it sets how many times you want the password re-encrypted.
+ #
+ # Limiting the stretches to just one in testing will increase the performance of
+ # your test suite dramatically. However, it is STRONGLY RECOMMENDED to not use
+ # a value less than 10 in other environments. Note that, for bcrypt (the default
+ # encryptor), the cost increases exponentially with the number of stretches (e.g.
+ # a value of 20 is already extremely slow: approx. 60 seconds for 1 calculation).
+ config.stretches = Rails.env.test? ? 1 : 14
+
+ # Setup a pepper to generate the encrypted password.
+ # config.pepper = '70ca884cc885f6ea790b81e61162e634afadc5121775443df4cb3d1e3d76d74b5c226af89d64b69c000ad9b42f1998c49ef2758ddbbab01483eded4723d7ef97'
+
+ # Send a notification email when the user's password is changed
+ config.send_password_change_notification = true
+
+ # ==> Configuration for :confirmable
+ # A period that the user is allowed to access the website even without
+ # confirming their account. For instance, if set to 2.days, the user will be
+ # able to access the website for two days without confirming their account,
+ # access will be blocked just in the third day. Default is 0.days, meaning
+ # the user cannot access the website without confirming their account.
+ # config.allow_unconfirmed_access_for = 2.days
+
+ # A period that the user is allowed to confirm their account before their
+ # token becomes invalid. For example, if set to 3.days, the user can confirm
+ # their account within 3 days after the mail was sent, but on the fourth day
+ # their account can't be confirmed with the token any more.
+ # Default is nil, meaning there is no restriction on how long a user can take
+ # before confirming their account.
+ config.confirm_within = ArchiveConfig.DAYS_TO_CONFIRM_EMAIL_CHANGE.days
+
+ # If true, requires any email changes to be confirmed (exactly the same way as
+ # initial account confirmation) to be applied. Requires additional unconfirmed_email
+ # db field (see migrations). Until confirmed, new email is stored in
+ # unconfirmed_email column, and copied to email column on successful confirmation.
+ config.reconfirmable = true
+
+ # Defines which key will be used when confirming an account
+ # config.confirmation_keys = [:email]
+
+ # ==> Configuration for :rememberable
+ # The time the user will be remembered without asking for credentials again.
+ config.remember_for = ArchiveConfig.REMEMBERED_SESSION_LENGTH_IN_MONTHS.months
+
+ # Invalidates all the remember me tokens when the user signs out.
+ config.expire_all_remember_me_on_sign_out = true
+
+ # If true, extends the user's remember period when remembered via cookie.
+ # config.extend_remember_period = false
+
+ # Options to be passed to the created cookie. For instance, you can set
+ # secure: true in order to force SSL only cookies.
+ # config.rememberable_options = {}
+
+ # ==> Configuration for :validatable
+ # Range for password length.
+ config.password_length = ArchiveConfig.PASSWORD_LENGTH_MIN..ArchiveConfig.PASSWORD_LENGTH_MAX
+
+ # Email regex used to validate email formats. It simply asserts that
+ # one (and only one) @ exists in the given string. This is mainly
+ # to give user feedback and not to assert the e-mail validity.
+ # config.email_regexp = /\A[^@]+@[^@]+\z/
+
+ # ==> Configuration for :timeoutable
+ # The time you want to timeout the user session without activity. After this
+ # time the user will be asked for credentials again. Default is 30 minutes.
+ # config.timeout_in = 30.minutes
+
+ # ==> Configuration for :lockable
+ # Defines which strategy will be used to lock an account.
+ # :failed_attempts = Locks an account after a number of failed attempts to sign in.
+ # :none = No lock strategy. You should handle locking by yourself.
+ # config.lock_strategy = :failed_attempts
+
+ # Defines which key will be used when locking and unlocking an account
+ # config.unlock_keys = [:login]
+
+ # Defines which strategy will be used to unlock an account.
+ # :email = Sends an unlock link to the user email
+ # :time = Re-enables login after a certain amount of time (see :unlock_in below)
+ # :both = Enables both strategies
+ # :none = No unlock strategy. You should handle unlocking by yourself.
+ # config.unlock_strategy = :both
+ config.unlock_strategy = :time
+
+ # Number of authentication tries before locking an account if lock_strategy
+ # is failed attempts.
+ # config.maximum_attempts = 20
+ config.maximum_attempts = 50
+
+ # Time interval to unlock the account if :time is enabled as unlock_strategy.
+ # config.unlock_in = 1.hour
+ config.unlock_in = 5.minutes
+
+ # Warn on the last attempt before the account is locked.
+ # config.last_attempt_warning = true
+
+ # ==> Configuration for :recoverable
+ #
+ # Defines which key will be used when recovering the password for an account
+ config.reset_password_keys = [:login]
+
+ # Time interval you can reset your password with a reset password key.
+ # Don't put a too small interval or your users won't have the time to
+ # change their passwords.
+ config.reset_password_within = ArchiveConfig.DAYS_UNTIL_RESET_PASSWORD_LINK_EXPIRES.days
+
+ # When set to false, does not sign a user in automatically after their password is
+ # reset. Defaults to true, so a user is signed in automatically after a reset.
+ # config.sign_in_after_reset_password = true
+
+ # ==> Configuration for :encryptable
+ # Allow you to use another encryption algorithm besides bcrypt (default). You can use
+ # :sha1, :sha512 or encryptors from others authentication tools as :clearance_sha1,
+ # :authlogic_sha512 (then you should set stretches above to 20 for default behavior)
+ # and :restful_authentication_sha1 (then you should set stretches to 10, and copy
+ # REST_AUTH_SITE_KEY to pepper).
+ #
+ # Require the `devise-encryptable` gem when using anything other than bcrypt
+ # config.encryptor = :sha512
+ #config.encryptor = :authlogic_sha512
+
+ # ==> Scopes configuration
+ # Turn scoped views on. Before rendering "sessions/new", it will first check for
+ # "users/sessions/new". It's turned off by default because it's slower if you
+ # are using only default views.
+ # config.scoped_views = false
+ config.scoped_views = true # We use scoped views because we have 2 devise models
+
+ # Configure the default scope given to Warden. By default it's the first
+ # devise role declared in your routes (usually :user).
+ # config.default_scope = :user
+ config.default_scope = :admin
+
+ # Set this configuration to false if you want /users/sign_out to sign out
+ # only the current scope. By default, Devise signs out all scopes.
+ config.sign_out_all_scopes = true
+
+ # ==> Navigation configuration
+ # Lists the formats that should be treated as navigational. Formats like
+ # :html, should redirect to the sign in page when the user does not have
+ # access, but formats like :xml or :json, should return 401.
+ #
+ # If you have any extra navigational formats, like :iphone or :mobile, you
+ # should add them to the navigational formats lists.
+ #
+ # The "*/*" below is required to match Internet Explorer requests.
+ # config.navigational_formats = ['*/*', :html]
+
+ # The default HTTP method used to sign out a resource. Default is :delete.
+ config.sign_out_via = :delete
+
+ # ==> OmniAuth
+ # Add a new OmniAuth provider. Check the wiki for more information on setting
+ # up on your models and hooks.
+ # config.omniauth :github, 'APP_ID', 'APP_SECRET', scope: 'user,public_repo'
+
+ # ==> Warden configuration
+ # If you want to use other strategies, that are not supported by Devise, or
+ # change the failure app, you can configure them inside the config.warden block.
+ #
+ config.warden do |manager|
+ manager.failure_app = DeviseFailureMessageOptions
+ # manager.intercept_401 = false
+ # manager.default_strategies(scope: :user).unshift :some_external_strategy
+ end
+
+ # ==> Mountable engine configurations
+ # When using Devise inside an engine, let's call it `MyEngine`, and this engine
+ # is mountable, there are some extra configurations to be taken into account.
+ # The following options are available, assuming the engine is mounted as:
+ #
+ # mount MyEngine, at: '/my_engine'
+ #
+ # The router that invoked `devise_for`, in the example above, would be:
+ # config.router_name = :my_engine
+ #
+ # When using OmniAuth, Devise cannot automatically set OmniAuth path,
+ # so you need to do it manually. For the users scope, it would be:
+ # config.omniauth_path_prefix = '/my_engine/users/auth'
+end
diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb
new file mode 100755
index 0000000..63b1295
--- /dev/null
+++ b/config/initializers/filter_parameter_logging.rb
@@ -0,0 +1,13 @@
+# 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 += [
+ :content, :passw, :terms_of_service_non_production, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn
+]
+
+# IMPORTANT! Rails.application.config.filter_parameters must be set *above* in this file
+# Ensure filter_attributes is always set. Without this, it is brittle.
+# See https://github.com/rails/rails/issues/48704
+ActiveRecord::Base.filter_attributes += Rails.application.config.filter_parameters
diff --git a/config/initializers/gem-plugin_config/acts_as_commentable.rb b/config/initializers/gem-plugin_config/acts_as_commentable.rb
new file mode 100755
index 0000000..c68da0e
--- /dev/null
+++ b/config/initializers/gem-plugin_config/acts_as_commentable.rb
@@ -0,0 +1,3 @@
+require "acts_as_commentable/commentable"
+
+ActiveRecord::Base.include ActsAsCommentable::Commentable
diff --git a/config/initializers/gem-plugin_config/elasticsearch.rb b/config/initializers/gem-plugin_config/elasticsearch.rb
new file mode 100755
index 0000000..524ea7c
--- /dev/null
+++ b/config/initializers/gem-plugin_config/elasticsearch.rb
@@ -0,0 +1 @@
+$elasticsearch = Elasticsearch::Client.new host: ArchiveConfig.ES_URL
diff --git a/config/initializers/gem-plugin_config/escape_utils_config.rb b/config/initializers/gem-plugin_config/escape_utils_config.rb
new file mode 100755
index 0000000..6c23695
--- /dev/null
+++ b/config/initializers/gem-plugin_config/escape_utils_config.rb
@@ -0,0 +1,16 @@
+# This will apparently speed up our html escaping by a LOT and also get rid of pesky UTF-8 error messages in cucumber tests
+# See http://openhood.com/rack/ruby/2010/07/15/rack-test-warning/
+# and also http://github.com/brianmario/escape_utils
+require "escape_utils/html/rack"
+
+# don't escape erb files - it creates hideous html and doesn't parse properly in URI during testing
+#require "escape_utils/html/erb"
+
+module Rack
+ module Utils
+ def escape(s)
+ EscapeUtils.escape_url(s)
+ end
+ end
+end
+
diff --git a/config/initializers/gem-plugin_config/pagy.rb b/config/initializers/gem-plugin_config/pagy.rb
new file mode 100755
index 0000000..62acb36
--- /dev/null
+++ b/config/initializers/gem-plugin_config/pagy.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+# https://ddnexus.github.io/pagy/docs/extras/i18n/
+require "pagy/extras/i18n"
+
+# See https://ddnexus.github.io/pagy/docs/api/pagy#variables
+Pagy::DEFAULT[:limit] = ArchiveConfig.ITEMS_PER_PAGE
+Pagy::DEFAULT[:size] = 9
+
+Pagy::DEFAULT.freeze
diff --git a/config/initializers/gem-plugin_config/permit_yo_config.rb b/config/initializers/gem-plugin_config/permit_yo_config.rb
new file mode 100755
index 0000000..4e984b2
--- /dev/null
+++ b/config/initializers/gem-plugin_config/permit_yo_config.rb
@@ -0,0 +1,34 @@
+# Authorization plugin configuration goes here
+#
+# See http://github.com/ianterrell/permityo/tree/ Settings section for details on what can be configured,
+# although most defaults are sensible.
+
+module Otwarchive
+ class Application < Rails::Application
+
+ # Which flash key we stick error messages into
+ config.permit_yo.require_user_flash = :error
+ config.permit_yo.permission_denied_flash = :error
+
+ # Where users get redirected if they are not currently logged in
+ config.permit_yo.require_user_redirection = {controller: :user_sessions, action: :new}
+ end
+end
+
+module PermitYo
+ module Default
+ module UserExtensions
+ module InstanceMethods
+
+ # Determine if the current model has a particular role
+ # depends on the model having a relationship with roles! (eg, has_and_belongs_to_many :roles)
+ def has_role?(role_name)
+ return self.roles.any? { |role| role.name == role_name.to_s } if self.roles.loaded?
+
+ role = Role.find_by(name: role_name)
+ self.roles.include?(role)
+ end
+ end
+ end
+ end
+end
diff --git a/config/initializers/gem-plugin_config/redis.rb b/config/initializers/gem-plugin_config/redis.rb
new file mode 100755
index 0000000..a373aa0
--- /dev/null
+++ b/config/initializers/gem-plugin_config/redis.rb
@@ -0,0 +1,35 @@
+require "redis_test_setup"
+include RedisTestSetup # rubocop:disable Style/MixinUsage
+
+rails_root = ENV["RAILS_ROOT"] || "#{File.dirname(__FILE__)}/../../.."
+rails_env = (ENV["RAILS_ENV"] || "development").to_sym
+
+# https://gist.github.com/441072
+start_redis!(rails_root, :cucumber) if rails_env == :test && !(ENV["CI"] || ENV["DOCKER"])
+
+redis_configs = YAML.load_file("#{rails_root}/config/redis.yml", symbolize_names: true)
+redis_configs.each_pair do |name, redis_config|
+ redis_options = {}
+ if redis_config[rails_env].is_a?(Hash)
+ # example:
+ # redis_kudos:
+ # development
+ # name: redis_kudos
+ # sentinels:
+ # - host: 127.0.0.1
+ # port: 26379
+ # - host: 127.0.0.1
+ # port: 26380
+ redis_options = redis_config[rails_env]
+ else
+ redis_host, redis_port = redis_config[rails_env].split(":")
+ redis_options[:host] = redis_host
+ redis_options[:port] = redis_port
+ end
+ redis_connection = Redis.new(redis_options)
+ if ENV["DEV_USER"]
+ namespaced_redis = Redis::Namespace.new(ENV["DEV_USER"], redis: redis_connection)
+ redis_connection = namespaced_redis
+ end
+ Object.const_set(name.upcase, redis_connection)
+end
diff --git a/config/initializers/gem-plugin_config/resque.rb b/config/initializers/gem-plugin_config/resque.rb
new file mode 100755
index 0000000..6506289
--- /dev/null
+++ b/config/initializers/gem-plugin_config/resque.rb
@@ -0,0 +1,14 @@
+require "resque"
+
+rails_root = ENV["RAILS_ROOT"] || "#{File.dirname(__FILE__)}/../../.."
+rails_env = (ENV["RAILS_ENV"] || "development").to_sym
+
+redis_configs = YAML.load_file("#{rails_root}/config/redis.yml", symbolize_names: true)
+Resque.redis = redis_configs[:redis_resque][rails_env]
+
+# in-process performing of jobs (for testing) doesn't require a redis server
+Resque.inline = ENV["RAILS_ENV"] == "test"
+
+Resque.after_fork do
+ Resque.redis.client.reconnect
+end
diff --git a/config/initializers/gem-plugin_config/sanitizer_config.rb b/config/initializers/gem-plugin_config/sanitizer_config.rb
new file mode 100755
index 0000000..5f94296
--- /dev/null
+++ b/config/initializers/gem-plugin_config/sanitizer_config.rb
@@ -0,0 +1,71 @@
+# Sanitize: http://github.com/rgrove/sanitize.git
+class Sanitize
+ # This defines the configuration we use for HTML tags and attributes allowed in the archive.
+ module Config
+ ARCHIVE = freeze_config(
+ elements: %w[
+ a abbr acronym address b big blockquote br caption center cite code col
+ colgroup details figcaption figure dd del dfn div dl dt em h1 h2 h3 h4 h5 h6 hr
+ i img ins kbd li ol p pre q rp rt ruby s samp small span strike strong
+ sub summary sup table tbody td tfoot th thead tr tt u ul var
+ ],
+ attributes: {
+ all: %w[align title dir],
+ "a" => %w[href name],
+ "blockquote" => %w[cite],
+ "col" => %w[span width],
+ "colgroup" => %w[span width],
+ "details" => %w[open],
+ "hr" => %w[align width],
+ "img" => %w[align alt border height src width],
+ "ol" => %w[start type],
+ "q" => %w[cite],
+ "table" => %w[border summary width],
+ "td" => %w[abbr axis colspan height rowspan width],
+ "th" => %w[abbr axis colspan height rowspan scope width],
+ "ul" => %w[type]
+ },
+
+ add_attributes: {
+ "a" => { "rel" => "nofollow" }
+ },
+
+ protocols: {
+ "a" => { "href" => ["ftp", "http", "https", "mailto", :relative] },
+ "blockquote" => { "cite" => ["http", "https", :relative] },
+ "img" => { "src" => ["http", "https"] },
+ "q" => { "cite" => ["http", "https", :relative] }
+ },
+
+ # TODO: This can be removed once we upgrade sanitizer gem, AO3-5801
+ # I would leave the tests we added in AO3-5974 though.
+ remove_contents: %w[iframe math noembed noframes noscript plaintext script style svg xmp]
+ )
+
+ CLASS_ATTRIBUTE = freeze_config(
+ # see in the Transformers section for what classes we strip
+ attributes: {
+ all: ARCHIVE[:attributes][:all] + ["class"]
+ }
+ )
+
+ CSS_ALLOWED = freeze_config(merge(ARCHIVE, CLASS_ATTRIBUTE))
+
+ # On details elements, force boolean attribute "open" to have
+ # value "open", if it exists
+ OPEN_ATTRIBUTE_TRANSFORMER = lambda do |env|
+ return unless env[:node_name] == "details"
+
+ env[:node]["open"] = "open" if env[:node].has_attribute?("open")
+ end
+
+ # On img elements, convert relative paths to absolute:
+ RELATIVE_IMAGE_PATH_TRANSFORMER = lambda do |env|
+ return unless env[:node_name] == "img" && env[:node]["src"]
+
+ env[:node]["src"] = URI.join(ArchiveConfig.APP_URL, env[:node]["src"])
+ rescue URI::InvalidURIError
+ # do nothing, the sanitizer will handle it
+ end
+ end
+end
diff --git a/config/initializers/gem-plugin_config/webmock.rb b/config/initializers/gem-plugin_config/webmock.rb
new file mode 100755
index 0000000..2b2f493
--- /dev/null
+++ b/config/initializers/gem-plugin_config/webmock.rb
@@ -0,0 +1,6 @@
+if Rails.env.test?
+ # net_http_connect_on_start: https://stackoverflow.com/questions/59632283/chromedriver-capybara-too-many-open-files-socket2-for-127-0-0-1-port-951
+ # limited to localhost: https://github.com/bblimke/webmock/issues/955#issuecomment-1373962511
+ WebMock.allow_net_connect!(net_http_connect_on_start: %w[127.0.0.1 localhost])
+ WebMock.enable!
+end
diff --git a/config/initializers/gem-plugin_config/will_paginate_config.rb b/config/initializers/gem-plugin_config/will_paginate_config.rb
new file mode 100755
index 0000000..a24be48
--- /dev/null
+++ b/config/initializers/gem-plugin_config/will_paginate_config.rb
@@ -0,0 +1,2 @@
+require 'will_paginate/array'
+WillPaginate.per_page = ArchiveConfig.ITEMS_PER_PAGE
diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb
new file mode 100755
index 0000000..1674594
--- /dev/null
+++ b/config/initializers/inflections.rb
@@ -0,0 +1,17 @@
+# 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.irregular "Media", "Media"
+# 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/mime_types.rb b/config/initializers/mime_types.rb
new file mode 100755
index 0000000..4fac756
--- /dev/null
+++ b/config/initializers/mime_types.rb
@@ -0,0 +1,9 @@
+# for azw3 files
+Marcel::MimeType.extend "application/x-mobi8-ebook", extensions: %w[azw3]
+Mime::Type.register "application/x-mobi8-ebook", :azw3
+# for epub files
+Marcel::MimeType.extend "application/epub", extensions: %w[epub]
+Mime::Type.register "application/epub", :epub
+
+# for mobi type (already present in marcel types)
+Mime::Type.register "application/x-mobipocket-ebook", :mobi
diff --git a/config/initializers/monkeypatches/akismetor.rb b/config/initializers/monkeypatches/akismetor.rb
new file mode 100755
index 0000000..0dae9c8
--- /dev/null
+++ b/config/initializers/monkeypatches/akismetor.rb
@@ -0,0 +1,10 @@
+# Patch Akismetor, which is not Ruby 3 compatible, as it uses URI.escape:
+# https://github.com/freerobby/akismetor/blob/1cef6c0e237dd69b3f6ae1ee8d719c0862cd77e4/lib/akismetor.rb#L53-L56
+#
+# TODO: AO3-6454 Remove this gem and handle the API calls ourselves.
+
+class Akismetor
+ def attributes_for_post
+ URI.encode_www_form(attributes)
+ end
+end
diff --git a/config/initializers/monkeypatches/deliver_after_commit.rb b/config/initializers/monkeypatches/deliver_after_commit.rb
new file mode 100755
index 0000000..d289ce0
--- /dev/null
+++ b/config/initializers/monkeypatches/deliver_after_commit.rb
@@ -0,0 +1,9 @@
+module ActionMailer
+ class MessageDelivery
+ include AfterCommitEverywhere
+
+ def deliver_after_commit
+ after_commit { deliver_later }
+ end
+ end
+end
diff --git a/config/initializers/monkeypatches/fix_phraseapp.rb b/config/initializers/monkeypatches/fix_phraseapp.rb
new file mode 100755
index 0000000..8f68902
--- /dev/null
+++ b/config/initializers/monkeypatches/fix_phraseapp.rb
@@ -0,0 +1,16 @@
+module FixPhraseapp
+ def translate(*args)
+ if to_be_translated_without_phraseapp?(args)
+ kw_args = args.last.is_a?(Hash) ? args.pop : {}
+ I18n.translate_without_phraseapp(*args, **kw_args)
+ else
+ phraseapp_delegate_for(args)
+ end
+ end
+end
+
+if PhraseApp::VERSION == "1.6.0"
+ PhraseApp::InContextEditor::BackendService.prepend(FixPhraseapp)
+else
+ puts "WARNING: The monkeypatch #{__FILE__} was written for version 1.6.0 of the phraseapp-in-context-editor-ruby gem, but you are running #{PhraseApp::VERSION}. Please update or remove the monkeypatch."
+end
diff --git a/config/initializers/monkeypatches/mailers_controller.rb b/config/initializers/monkeypatches/mailers_controller.rb
new file mode 100755
index 0000000..ca8f941
--- /dev/null
+++ b/config/initializers/monkeypatches/mailers_controller.rb
@@ -0,0 +1,11 @@
+module MailersController
+ extend ActiveSupport::Concern
+
+ included do
+ # Hide the dev mark in mailer previews.
+ skip_rack_dev_mark
+ end
+end
+Rails.application.config.after_initialize do
+ ::Rails::MailersController.include MailersController
+end
diff --git a/config/initializers/monkeypatches/override_default_form_field_sizes.rb b/config/initializers/monkeypatches/override_default_form_field_sizes.rb
new file mode 100755
index 0000000..6142e53
--- /dev/null
+++ b/config/initializers/monkeypatches/override_default_form_field_sizes.rb
@@ -0,0 +1,24 @@
+# Rails default form helpers text_field etc use default sizes. Rails default form helpers text_field_tag etc do NOT. WHY.
+# Here we trash all the default options.
+#
+# We also add the css class "text" to all text input fields because we need a class on them to be
+# able to target them in ie6, alas.
+
+# InstanceTag no longer exists in Rails 4
+# module ActionView::Helpers
+# class InstanceTag
+# remove_const :DEFAULT_TEXT_AREA_OPTIONS
+# DEFAULT_TEXT_AREA_OPTIONS = { }
+#
+# remove_const :DEFAULT_FIELD_OPTIONS
+# DEFAULT_FIELD_OPTIONS = { "class" => "text" }
+# end
+# end
+
+
+module ActionView::Helpers::FormTagHelper
+ alias_method :text_field_tag_original, :text_field_tag
+ def text_field_tag(name, value = nil, options = {})
+ text_field_tag_original(name, value, {"class" => "text"}.merge(options))
+ end
+end
diff --git a/config/initializers/monkeypatches/textarea_convert_html_to_newlines.rb b/config/initializers/monkeypatches/textarea_convert_html_to_newlines.rb
new file mode 100755
index 0000000..e74fbf5
--- /dev/null
+++ b/config/initializers/monkeypatches/textarea_convert_html_to_newlines.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+# modifying to_text_area_tag and text_area_tag to strip paragraph/br tags
+# and convert them back into newlines for editing purposes
+module ActionView
+ module Helpers
+ module Tags
+ class TextArea
+ # added method to yank
and tags and replace with newlines
+ # this needs to reverse "add_paragraph_tags_to_text" from our html_cleaner library
+ # TODO: this is not fully working as of 2025 (probably for years)
+ def strip_html_breaks(content, name="")
+ return "" if content.blank?
+ if name =~ /content/
+ # might be using RTE, preserve all paragraphs as they are
+ content.gsub(/\s* \s*/, " \n").
+ gsub(/\s*
]*>\s* \s*<\/p>\s*/, "\n\n\n").
+ gsub(/\s*(
]*>.*?<\/p>)\s*/m, "\n\n" + '\1').
+ strip
+ else
+ # no RTE, so clean up paragraphs unless they have qualifiers
+ content = content.gsub(/\s* \s*/, " \n").
+ gsub(/\s*
]*>\s* \s*<\/p>\s*/, "\n\n\n")
+
+ if content.match(/\s*(
]*>(.*?)<\/p>\s*/m, "\n\n" + '\1').
+ strip
+ end
+ end
+ end
+
+ def render
+ options = @options.stringify_keys
+ add_default_name_and_id(options)
+
+ if size = options.delete("size")
+ options["cols"], options["rows"] = size.split("x") if size.respond_to?(:split)
+ end
+
+ content = options.delete("value") { value_before_type_cast }
+ content = strip_html_breaks(content, options['name'])
+
+ content_tag("textarea", content, options)
+ end
+ end
+ end
+ end
+end
diff --git a/config/initializers/monkeypatches/translate_string.rb b/config/initializers/monkeypatches/translate_string.rb
new file mode 100755
index 0000000..fbd81a6
--- /dev/null
+++ b/config/initializers/monkeypatches/translate_string.rb
@@ -0,0 +1,78 @@
+module I18n
+ class << self
+ # Formats a string. Used to mark strings that should eventually be
+ # translated with I18n, but aren't at the moment.
+ #
+ # Deprecated.
+ def translate_string(str, **options)
+ str % options
+ end
+
+ alias :ts :translate_string
+ end
+end
+
+module AbstractController
+ module Translation
+ def translate_string(str, **options)
+ I18n.translate_string(str, **options)
+ end
+ alias :ts :translate_string
+ end
+end
+
+
+module ActiveRecord #:nodoc:
+ class Base
+ def translate_string(str, **options)
+ begin
+ ActiveRecord::Base.connection
+ I18n.translate_string(str, **options)
+ rescue StandardError
+ str || ""
+ end
+ end
+
+ alias :ts :translate_string
+
+ class << Base
+ def translate_string(str, **options)
+ begin
+ ActiveRecord::Base.connection
+ I18n.translate_string(str, **options)
+ rescue StandardError
+ str || ""
+ end
+ end
+
+ alias :ts :translate_string
+ end
+ end
+end
+
+module ActionMailer #:nodoc:
+ class Base
+ def translate_string(str, **options)
+ begin
+ ActiveRecord::Base.connection
+ I18n.translate_string(str, **options)
+ rescue StandardError
+ str || ""
+ end
+ end
+
+ alias :ts :translate_string
+ end
+end
+
+module ActionView
+ module Helpers
+ module TranslationHelper
+ def translate_string(str, **options)
+ str % options
+ end
+
+ alias :ts :translate_string
+ end
+ end
+end
diff --git a/config/initializers/permissions_policy.rb b/config/initializers/permissions_policy.rb
new file mode 100755
index 0000000..7db3b95
--- /dev/null
+++ b/config/initializers/permissions_policy.rb
@@ -0,0 +1,13 @@
+# 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/initializers/phraseapp_in_context_editor.rb b/config/initializers/phraseapp_in_context_editor.rb
new file mode 100755
index 0000000..96163b8
--- /dev/null
+++ b/config/initializers/phraseapp_in_context_editor.rb
@@ -0,0 +1,31 @@
+PhraseApp::InContextEditor.configure do |config|
+ # Enable or disable the In-Context-Editor in general
+ if ENV['AO3_PHRASE_APP'] == 'true' || ArchiveConfig.PHRASEAPP_ENABLE == 'true' then
+ config.enabled = true
+ else
+ config.enabled = false
+ end
+
+ # Fetch your project id after creating your first project
+ # in Translation Center.
+ # You can find the project id in your project settings
+ # page (https://phraseapp.com/projects)
+ config.project_id = "47f2a1b0cf81df327878c6d89cee7af3"
+
+ # You can create and manage access tokens in your profile settings
+ # in Translation Center or via the Authorizations API
+ # (http://docs.phraseapp.com/api/v2/authorizations/).
+ config.access_token = ArchiveConfig.PHRASEAPP_TOKEN
+
+ # Configure an array of key names that should not be handled
+ # by the In-Context-Editor.
+ config.ignored_keys = ["number.*", "breadcrumb.*"]
+
+ # PhraseApp uses decorators to generate a unique identification key
+ # in context of your document. However, this might result in conflicts
+ # with other libraries (e.g. client-side template engines) that use a similar syntax.
+ # If you encounter this problem, you might want to change this decorator pattern.
+ # More information: http://docs.phraseapp.com/guides/in-context-editor/configure/
+ # config.prefix = "{{__"
+ # config.suffix = "__}}"
+end
diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb
new file mode 100755
index 0000000..8ed35d5
--- /dev/null
+++ b/config/initializers/rack_attack.rb
@@ -0,0 +1,127 @@
+class Rack::Attack
+
+ # The following is a useful resource.
+ # https://www.driftingruby.com/episodes/rails-api-throttling-with-rack-attack
+ #
+ ### Configure Cache ###
+
+ # If you don't want to use Rails.cache (Rack::Attack's default), then
+ # configure it here.
+ #
+ # Note: The store is only used for throttling (not blocklisting and
+ # safelisting). It must implement .increment and .write like
+ # ActiveSupport::Cache::Store
+
+ # Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new
+
+ ### Throttle Spammy Clients ###
+
+ # If we fail to unmask the remote IP for a request, the
+ # frontends will pass the internal network (10.0.0.0/8) to the
+ # unicorns. We need to ensure that we don't block these requests.
+
+ ArchiveConfig.RATE_LIMIT_SAFELIST.each do |ip|
+ Rack::Attack.safelist_ip(ip)
+ end
+
+ # If any single client IP is making tons of requests, then they're
+ # probably malicious or a poorly-configured scraper. Either way, they
+ # don't deserve to hog all of the app server's CPU. Cut them off!
+ #
+ # Note: If you're serving assets through rack, those requests may be
+ # counted by rack-attack and this throttle may be activated too
+ # quickly. If so, enable the condition to exclude them from tracking.
+ #
+
+ # This stanza allows us to limit by user which backend is selected by nginx.
+
+ ArchiveConfig.RATE_LIMIT_PER_NGINX_UPSTREAM_USER.each do |k, v|
+ throttle("req/#{k}/user", limit: v["limit"], period: v["period"]) do |req|
+ req.env["HTTP_X_AO3_SESSION_USER"] if req.env["HTTP_X_UNICORNS"] == k && req.env["HTTP_X_AO3_SESSION_USER"].present?
+ end
+ end
+
+ # This stanza allows us to limit by ip which backend is selected by nginx.
+
+ ArchiveConfig.RATE_LIMIT_PER_NGINX_UPSTREAM.each do |k, v|
+ throttle("req/#{k}/ip", limit: v["limit"], period: v["period"]) do |req|
+ req.ip if req.env["HTTP_X_UNICORNS"] == k
+ end
+ end
+
+ # Throttle all requests by IP (60rpm)
+ #
+ # Key: "rack::attack:#{Time.now.to_i/:period}:req/ip:#{req.ip}"
+ limit = ArchiveConfig.RATE_LIMIT_NUMBER
+ period = ArchiveConfig.RATE_LIMIT_PERIOD
+ throttle('req/ip', limit: limit, period: period) do |req|
+ req.ip
+ end
+
+ ### Prevent Brute-Force Login Attacks ###
+
+ # The most common brute-force login attack is a brute-force password
+ # attack where an attacker simply tries a large number of emails and
+ # passwords to see if any credentials match.
+ #
+ # Another common method of attack is to use a swarm of computers with
+ # different IPs to try brute-forcing a password for a specific account.
+
+ login_limit = ArchiveConfig.RATE_LIMIT_LOGIN_ATTEMPTS
+ login_period = ArchiveConfig.RATE_LIMIT_LOGIN_PERIOD
+
+ # Throttle POST requests to /users/login by IP address
+ #
+ # Key: "rack::attack:#{Time.now.to_i/:period}:logins/ip:#{req.ip}"
+ throttle("logins/ip", limit: login_limit, period: login_period) do |req|
+ req.ip if req.path == "/users/login" && req.post?
+ end
+
+ # Throttle POST requests to /users/login by login param (username or email)
+ #
+ # Key: "rack::attack:#{Time.now.to_i/:period}:logins/email:#{login}"
+ #
+ # Note: This creates a problem where a malicious user could intentionally
+ # throttle logins for another user and force their login requests to be
+ # denied, but that's not very common and shouldn't happen to you. (Knock
+ # on wood!)
+ throttle("logins/email", limit: login_limit, period: login_period) do |req|
+ req.params.dig("user", "login").presence if req.path == "/users/login" && req.post?
+ end
+
+ ### Add Rate Limits for Admin Login ###
+ admin_login_limit = ArchiveConfig.RATE_LIMIT_ADMIN_LOGIN_ATTEMPTS
+ admin_login_period = ArchiveConfig.RATE_LIMIT_ADMIN_LOGIN_PERIOD
+
+ # Throttle POST requests to /admin/login by IP address
+ #
+ # Key: "rack::attack:#{Time.now.to_i/:period}:admin_logins/ip:#{req.ip}"
+ throttle("admin_logins/ip", limit: admin_login_limit, period: admin_login_period) do |req|
+ req.ip if req.path == "/admin/login" && req.post?
+ end
+
+ # Throttle POST requests to /admin/login by login param (user name or email)
+ #
+ # Key: "rack::attack:#{Time.now.to_i/:period}:admin_logins/email:#{login}"
+ throttle("admin_logins/email", limit: admin_login_limit, period: admin_login_period) do |req|
+ req.params.dig("admin", "login").presence if req.path == "/admin/login" && req.post?
+ end
+
+ # Add Retry-After response header to let polite clients know
+ # how many seconds they should wait before trying again
+ Rack::Attack.throttled_response_retry_after_header = true
+
+ ### Custom Throttle Response ###
+
+ # By default, Rack::Attack returns an HTTP 429 for throttled responses,
+ # which is just fine.
+ #
+ # If you want to return 503 so that the attacker might be fooled into
+ # believing that they've successfully broken your app (or you just want to
+ # customize the response), then uncomment these lines.
+ # self.throttled_response = lambda do |env|
+ # [ 503, # status
+ # {}, # headers
+ # ['']] # body
+ # end
+end
diff --git a/config/initializers/rollout_init.rb b/config/initializers/rollout_init.rb
new file mode 100755
index 0000000..ab2451b
--- /dev/null
+++ b/config/initializers/rollout_init.rb
@@ -0,0 +1 @@
+$rollout = Rollout.new(REDIS_ROLLOUT)
diff --git a/config/initializers/sentry.rb b/config/initializers/sentry.rb
new file mode 100755
index 0000000..fb597dc
--- /dev/null
+++ b/config/initializers/sentry.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+if Rails.env.production? || Rails.env.staging?
+ Sentry.init do |config|
+ # get breadcrumbs from logs
+ config.breadcrumbs_logger = [:active_support_logger, :http_logger]
+
+ # enable tracing
+ config.traces_sampler = lambda do |sampling_context|
+ next sampling_context[:parent_sampled] unless sampling_context[:parent_sampled].nil?
+
+ rack_env = sampling_context[:env] || {}
+ rate_from_nginx = Float(rack_env["HTTP_X_SENTRY_RATE"], exception: false)
+ return rate_from_nginx if rate_from_nginx
+ return 0.01 if Rails.env.production?
+ return 1.00 if Rails.env.staging?
+
+ # Default to off for other environments when no override is present
+ 0.0
+ end
+
+ # enable profiling
+ # this is relative to traces_sample_rate
+ config.profiles_sample_rate = 1.0
+
+ config.environment = Rails.env
+ config.release = ArchiveConfig.REVISION.to_s
+ end
+end
diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb
new file mode 100755
index 0000000..9f523a0
--- /dev/null
+++ b/config/initializers/session_store.rb
@@ -0,0 +1,18 @@
+class ActionDispatch::Session::ForceSignedCookieStore < ActionDispatch::Session::CookieStore
+ private
+
+ # Override the cookie_jar method to use signed cookies
+ # regardless of whether a secret_key_base has been set
+ def cookie_jar(request)
+ request.cookie_jar.signed
+ end
+end
+
+# Be sure to restart your server when you modify this file.
+
+Otwarchive::Application.config.session_store :force_signed_cookie_store, key: '_otwarchive_session', expire_after: ArchiveConfig.DEFAULT_SESSION_LENGTH_IN_WEEKS.weeks
+
+# Use the database for sessions instead of the cookie-based default,
+# which shouldn't be used to store highly confidential information
+# (create the session table with "rake db:sessions:create")
+# Otwarchive::Application.config.session_store :active_record_store
diff --git a/config/initializers/wrap_parameters.rb b/config/initializers/wrap_parameters.rb
new file mode 100755
index 0000000..bbfc396
--- /dev/null
+++ b/config/initializers/wrap_parameters.rb
@@ -0,0 +1,14 @@
+# Be sure to restart your server when you modify this file.
+
+# This file contains settings for ActionController::ParamsWrapper which
+# is enabled by default.
+
+# Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array.
+ActiveSupport.on_load(:action_controller) do
+ wrap_parameters format: [:json]
+end
+
+# To enable root element in JSON for ActiveRecord objects.
+# ActiveSupport.on_load(:active_record) do
+# self.include_root_in_json = true
+# end
diff --git a/config/locales/README.md b/config/locales/README.md
new file mode 100755
index 0000000..bd1ddf2
--- /dev/null
+++ b/config/locales/README.md
@@ -0,0 +1,17 @@
+## `phrase-exports/`
+
+These files are updated by Phrase and should not be edited. Refer to `.phrase.yml`.
+
+## `rails-i18n/`
+
+We use locale files, pluralization and transliteration rules from the installed
+gem [rails-i18n](https://github.com/svenfuchs/rails-i18n).
+
+This folder contains patches to rails-i18n.
+
+We [manually install](https://github.com/svenfuchs/rails-i18n#manual-installation)
+the following locales from rails-i18n version 7.0.3 in order to rename them.
+Aside from the locale name, the files should not be edited.
+
+- "mr-IN" to "mr"
+- "tl" to "fil"
diff --git a/config/locales/controllers/en.yml b/config/locales/controllers/en.yml
new file mode 100755
index 0000000..cf246d3
--- /dev/null
+++ b/config/locales/controllers/en.yml
@@ -0,0 +1,283 @@
+---
+en:
+ admin:
+ access:
+ action_access_denied: Sorry, only an authorized admin can do that.
+ not_admin_denied: Please log in as admin
+ page_access_denied: Sorry, only an authorized admin can access the page you were trying to reach.
+ admin_invitations:
+ find:
+ user_not_found: No results were found. Try another search.
+ invite_from_queue:
+ success:
+ one: "%{count} person from the invite queue is being invited."
+ other: "%{count} people from the invite queue are being invited."
+ admin_users:
+ confirm_delete_user_creations:
+ page_title: "%{login} - Confirm Deletion of User Creations"
+ destroy_user_creations:
+ success: All creations by user %{login} have been deleted.
+ user_creations:
+ confirm_remove_pseud:
+ must_have_orphan_pseuds: Sorry, this action is only available for works by orphan_account pseuds.
+ remove_pseud:
+ must_select_pseud: You must select which orphan_account pseud to remove.
+ success:
+ one: Successfully removed pseud %{pseuds} from this work.
+ other: Successfully removed pseuds %{pseuds} from this work.
+ application:
+ access_denied:
+ access_denied:
+ logged_in: Sorry, you don't have permission to access the page you were trying to reach.
+ archive_faqs:
+ create:
+ success: Archive FAQ was successfully created.
+ default_locale_only: Sorry, this action is only available for English FAQs.
+ update:
+ success: Archive FAQ was successfully updated.
+ update_positions:
+ success: Archive FAQs order was successfully updated.
+ blocked:
+ users:
+ create:
+ blocked: You have blocked the user %{name}.
+ destroy:
+ unblocked: You have unblocked the user %{name}.
+ challenge_assignments:
+ validation:
+ not_owner: You aren't the owner of that assignment.
+ chapters:
+ destroy:
+ only_chapter: You can't delete the only chapter in your work. If you want to delete the work, choose "Delete Work".
+ draft_flash:
+ posted_work: This is a draft chapter in a posted work. It will be kept unless the work is deleted.
+ unposted_work_html: This is a draft chapter in an unposted work. The work will be scheduled for deletion on %{deletion_date}.
+ show:
+ anonymous: Anonymous
+ chapter_position: " - Chapter %{position}"
+ multifandom: Multifandom
+ unrevealed: Mystery Work
+ unspecified_fandom: No fandom specified
+ collection_items:
+ create:
+ invited: invited
+ invited_to_collections_html: This work has been %{invited_link} to your collection (%{collection_title}).
+ collection_participants:
+ update:
+ failure: Couldn't update %{participant}.
+ validation:
+ owners_required: You can't remove the only owner!
+ collection_profile:
+ show:
+ page_title: "%{collection_title} - Profile"
+ collections:
+ index:
+ subcollections_page_title: "%{collection_title} - Subcollections"
+ comments:
+ check_blocked:
+ parent: Sorry, you have been blocked by one or more of this work's creators.
+ reply: Sorry, you have been blocked by that user.
+ check_frozen:
+ error: Sorry, you cannot reply to a frozen comment.
+ check_guest_replies_preference:
+ error: Sorry, this user doesn't allow non-Archive users to reply to their comments.
+ check_hidden_by_admin:
+ error: Sorry, you cannot reply to a hidden comment.
+ check_not_replying_to_spam:
+ error: Sorry, you can't reply to a comment that has been marked as spam.
+ check_permission_to_edit:
+ error:
+ frozen: Frozen comments cannot be edited.
+ create:
+ success:
+ moderated:
+ admin_post: Your comment was received! It will appear publicly after it has been approved.
+ work: Your comment was received! It will appear publicly after the work creator has approved it.
+ not_moderated: Comment created!
+ freeze:
+ error: Sorry, that comment thread could not be frozen.
+ permission_denied: Sorry, you don't have permission to freeze that comment thread.
+ success: Comment thread successfully frozen!
+ hide:
+ error: Sorry, that comment could not be hidden.
+ permission_denied: Sorry, you don't have permission to hide that comment.
+ success: Comment successfully hidden!
+ unfreeze:
+ error: Sorry, that comment thread could not be unfrozen.
+ permission_denied: Sorry, you don't have permission to unfreeze that comment thread.
+ success: Comment thread successfully unfrozen!
+ unhide:
+ error: Sorry, that comment could not be unhidden.
+ permission_denied: Sorry, you don't have permission to unhide that comment.
+ success: Comment successfully unhidden!
+ errors:
+ auth_error:
+ browser_title: Auth Error
+ timeout_error:
+ browser_title: Timeout Error
+ external_works:
+ update:
+ successfully_updated: External work was successfully updated.
+ fandoms:
+ index:
+ choose_media: Please choose a media category to start browsing fandoms.
+ collection_page_title: "%{collection_title} - Fandoms"
+ home:
+ content:
+ page_title: Content Policy
+ privacy:
+ page_title: Privacy Policy
+ tos:
+ page_title: Terms of Service
+ tos_faq:
+ page_title: Terms of Service FAQ
+ inbox:
+ show:
+ page_title: "%{user} - Inbox"
+ update:
+ must_select_item: Please select at least one comment first.
+ success: Inbox successfully updated.
+ invite_requests:
+ create:
+ queue_disabled:
+ closed: New invitation requests are currently closed.
+ html: "%{closed_bold} For more information, please check the %{news_link}."
+ news: '"Invitations" tag on AO3 News'
+ success: You've been added to our queue! Yay! We estimate that you'll receive an invitation around %{date}. We strongly recommend that you add %{return_address} to your address book to prevent the invitation email from getting blocked as spam by your email provider.
+ index:
+ page_title: Invitation Requests
+ resend:
+ not_found: Could not find an invitation associated with that email.
+ not_yet: You cannot resend an invitation that was sent in the last %{count} hours.
+ success: Invitation resent to %{email}.
+ status:
+ browser_title: Invitation Request Status
+ kudos:
+ create:
+ success: Thank you for leaving kudos!
+ languages:
+ cannot_edit_default: Sorry, you can't edit the default language.
+ successfully_added: Language was successfully added.
+ successfully_updated: Language was successfully updated.
+ media:
+ index:
+ browser_title: Fandoms
+ muted:
+ users:
+ create:
+ muted: You have muted the user %{name}.
+ destroy:
+ unmuted: You have unmuted the user %{name}.
+ people:
+ index:
+ collection_page_title: "%{collection_title} - People"
+ profile:
+ show:
+ page_title: "%{username} - Profile"
+ pseuds:
+ create:
+ already_have_pseud_with_name: You already have a pseud with that name.
+ successfully_created: Pseud was successfully created.
+ destroy:
+ cannot_delete_default: You cannot delete your default pseudonym, sorry!
+ cannot_delete_matching_username: You cannot delete the pseud matching your username, sorry!
+ not_deleted: The pseud was not deleted.
+ successfully_deleted: The pseud was successfully deleted.
+ update:
+ successfully_updated: Pseud was successfully updated.
+ questions:
+ not_found: Sorry, we couldn't find the FAQ you were looking for.
+ update_positions:
+ success: Question order has been successfully updated.
+ readings:
+ clear:
+ error: There were problems deleting your history. Please try again later.
+ success: Your history is now cleared.
+ index:
+ history_page_title: History
+ marked_for_later_page_title: Marked for Later
+ related_works:
+ index:
+ page_title: "%{login} - Related Works"
+ series:
+ index:
+ page_title: "%{username} - Series"
+ show:
+ anonymous: Anonymous
+ unrevealed_series: Mystery Series
+ subscriptions:
+ delete_all:
+ error: There were problems deleting your subscriptions.
+ success: Your subscriptions have been deleted.
+ index:
+ page_title: "%{username} - Subscriptions"
+ subscription_type_page_title: "%{username} - %{subscription_type} Subscriptions"
+ tag_wranglings:
+ index:
+ page_subtitle: fandoms
+ tags:
+ index:
+ collection_page_title: "%{collection_title} - Tags"
+ users:
+ change_email:
+ page_title: Change Email
+ change_password:
+ page_title: Change Password
+ change_username:
+ page_title: Change Username
+ changed_password:
+ blank_password: You must enter your old password.
+ wrong_password: Your old password was incorrect.
+ changed_username:
+ admin:
+ successfully_updated: Username has been successfully updated.
+ new_username_must_be_different: Your new username must be different from your current username.
+ user:
+ incorrect_password: Your password was incorrect
+ successfully_updated: Your username has been successfully updated.
+ confirm_change_email:
+ blank_email: You must enter a new email address.
+ blank_password: You must enter your password.
+ browser_title: Confirm New Email
+ contact_support: contact Support
+ nonmatching_email: The email addresses you entered do not match. Please try again.
+ same_as_current: Your new email address must be different from your current email.
+ wrong_password_html: Your password was incorrect. Please try again or log out and reset your password via the link on the login form. If you are still having trouble, %{contact_support_link} for help.
+ contact_abuse: contact our Policy & Abuse team
+ passwords:
+ create:
+ contact_abuse: contact our Policy & Abuse team
+ reset_blocked_html: Password resets are disabled for that user. For more information, please %{contact_abuse_link}.
+ reset_cooldown_html: You cannot reset your password at this time. Please try again after %{reset_available_time}.
+ send_cooldown_period:
+ one: After that, you will need to wait %{count} hour before requesting another reset.
+ other: After that, you will need to wait %{count} hours before requesting another reset.
+ send_instructions: Check your email for instructions on how to reset your password. %{send_times_remaining} %{send_cooldown_period}
+ send_times_remaining:
+ one: You may reset your password %{count} more time.
+ other: You may reset your password %{count} more times.
+ user_not_found: We couldn't find an account with that email address or username. Please try again.
+ reconfirm_email:
+ access_denied:
+ logged_in: You are not logged in to the account whose email you are trying to change. Please log out and try again.
+ invalid_token: This email confirmation link is invalid or expired. Please check your email for the correct link or submit the email change form again.
+ success: Your email has been successfully updated.
+ registrations:
+ create:
+ page_title: Account Created
+ new:
+ page_title: Create Account
+ status:
+ ban_notice_html: Your account has been banned. You are not permitted to post or edit content on Sunset. Please check your email or %{contact_abuse_link} for more information.
+ suspension_notice_html: Your account has been suspended until %{suspended_until}. You cannot post, edit, or delete content until your suspension has ended. Please check your email or %{contact_abuse_link} for more information.
+ works:
+ drafts:
+ page_title: "%{username} - Drafts"
+ show:
+ page_title:
+ unrevealed: Mystery Work
+ wrangling_guidelines:
+ create: Wrangling Guideline was successfully created.
+ delete: Wrangling Guideline was successfully deleted.
+ reorder: Wrangling Guidelines order was successfully updated.
+ update: Wrangling Guideline was successfully updated.
diff --git a/config/locales/devise/en.yml b/config/locales/devise/en.yml
new file mode 100755
index 0000000..745f8e8
--- /dev/null
+++ b/config/locales/devise/en.yml
@@ -0,0 +1,73 @@
+---
+en:
+ devise:
+ confirmations:
+ confirmed: Your email address has been successfully confirmed.
+ send_instructions: You will receive an email with instructions for how to confirm your email address in a few minutes.
+ send_paranoid_instructions: If your email address exists in our database, you will receive an email with instructions for how to confirm your email address in a few minutes.
+ failure:
+ admin:
+ already_authenticated: You are already signed in.
+ inactive: Your account is not activated yet.
+ invalid: The password or admin username you entered doesn't match our records.
+ last_attempt: You have one more attempt before your account is locked.
+ locked: Your account is locked.
+ not_found_in_database: The password or admin username you entered doesn't match our records.
+ timeout: Your session expired. Please sign in again to continue.
+ unauthenticated: You need to sign in or sign up before continuing.
+ unconfirmed: You have to confirm your email address before continuing.
+ user:
+ already_authenticated: You are already signed in.
+ inactive: You'll need to activate your account before you can log in. Please check your email or contact support.
+ invalid: The password or username you entered doesn't match our records. Please try again or reset your password. If you still can't log in, please visit Problems When Logging In for help.
+ last_attempt: You have one more attempt before your account is locked.
+ locked: Your account has been locked for 5 minutes due to too many failed login attempts.
+ not_found_in_database: The password or username you entered doesn't match our records. Please try again or reset your password. If you still can't log in, please visit Problems When Logging In for help.
+ timeout: Your session expired. Please sign in again to continue.
+ unauthenticated: You need to sign in or sign up before continuing.
+ unconfirmed: You have to activate your account before continuing. Please check your email for the activation link.
+ mailer:
+ confirmation_instructions:
+ subject: Confirmation instructions
+ password_change:
+ subject: Password Changed
+ reset_password_instructions:
+ subject: "[%{app_name}] Generated reset password link"
+ unlock_instructions:
+ subject: Unlock instructions
+ omniauth_callbacks:
+ failure: Could not authenticate you from %{kind} because "%{reason}".
+ success: Successfully authenticated from %{kind} account.
+ passwords:
+ no_token: You can't access this page without coming from a password reset email. If you do come from a password reset email, please make sure you used the full URL provided.
+ send_instructions: Check your email for instructions on how to reset your password.
+ send_paranoid_instructions: If your email address exists in our database, you will receive a password recovery link at your email address in a few minutes.
+ updated: Your password has been changed successfully. You are now signed in.
+ updated_not_active: Your password has been changed successfully.
+ registrations:
+ destroyed: Bye! Your account has been successfully cancelled. We hope to see you again soon.
+ signed_up: Welcome! You have signed up successfully.
+ signed_up_but_inactive: You have signed up successfully. However, we could not sign you in because your account is not yet activated.
+ signed_up_but_locked: You have signed up successfully. However, we could not sign you in because your account is locked.
+ signed_up_but_unconfirmed: A message with a confirmation link has been sent to your email address. Please follow the link to activate your account.
+ update_needs_confirmation: You updated your account successfully, but we need to verify your new email address. Please check your email and follow the confirm link to confirm your new email address.
+ updated: Your account has been updated successfully.
+ sessions:
+ already_signed_out: Successfully logged out.
+ signed_in: Successfully logged in.
+ signed_out: Successfully logged out.
+ unlocks:
+ send_instructions: You will receive an email with instructions for how to unlock your account in a few minutes.
+ send_paranoid_instructions: If your account exists, you will receive an email with instructions for how to unlock it in a few minutes.
+ unlocked: Your account has been unlocked successfully. Please sign in to continue.
+ errors:
+ messages:
+ already_confirmed: was already confirmed, please try signing in
+ confirmation_period_expired: needs to be confirmed within %{period}, please request a new one
+ expired: has expired, please request a new one
+ not_found: not found
+ not_locked: was not locked
+ not_saved:
+ one: '1 error prohibited this %{resource} from being saved:'
+ other: "%{count} errors prohibited this %{resource} from being saved:"
+ pwned_password: appears in data breaches from other sites and is no longer secure. For your protection, please select a stronger, unique password.
diff --git a/config/locales/helpers/en.yml b/config/locales/helpers/en.yml
new file mode 100755
index 0000000..21aa667
--- /dev/null
+++ b/config/locales/helpers/en.yml
@@ -0,0 +1,54 @@
+---
+en:
+ collections_helper:
+ collection_item_approval_options:
+ collection:
+ approved: Approved by collection moderators
+ rejected: Rejected by collection moderators
+ unreviewed: Unreviewed by collection moderators
+ user:
+ bookmark:
+ approved: Approved by bookmarker
+ rejected: Rejected by bookmarker
+ unreviewed: Unreviewed by bookmarker
+ work:
+ approved: Approved by creator
+ rejected: Rejected by creator
+ unreviewed: Unreviewed by creator
+ collection_item_approval_options_label:
+ collection: Collection approval status
+ user:
+ bookmark: Bookmarker approval status
+ work: Creator approval status
+ comments_helper:
+ chapter_link_html: Chapter %{position}
+ comment_link_with_commentable_name:
+ on_admin_post_html: Comment on the news post %{title}
+ on_tag_html: Comment on the tag %{name}
+ on_unknown: Comment on unknown item
+ on_work_html: Comment on the work %{title}
+ inbox_helper:
+ comment_link_with_chapter_number: Chapter %{position} of %{title}
+ users_helper:
+ log:
+ ban: Suspended Permanently
+ email_change: Email Changed
+ fnok:
+ has_added: 'Fannish Next of Kin Added: %{user_id}'
+ has_removed: 'Fannish Next of Kin Removed: %{user_id}'
+ was_added: 'Added as Fannish Next of Kin for: %{user_id}'
+ was_removed: 'Removed as Fannish Next of Kin for: %{user_id}'
+ lift_suspension: Suspension Lifted
+ note: Note Added
+ password_change: Password Changed
+ password_reset: Password Reset
+ rename: Username Changed
+ role_added: 'Role Added: '
+ role_removed: 'Role Removed: '
+ suspended: 'Suspended until '
+ troubleshot: Account Troubleshot
+ validated: Account Validated
+ warn: Warned
+ works_helper:
+ mark_as_read_button: Mark as Read
+ mark_for_later_button: Mark for Later
diff --git a/config/locales/mailers/en.yml b/config/locales/mailers/en.yml
new file mode 100755
index 0000000..7dd6e00
--- /dev/null
+++ b/config/locales/mailers/en.yml
@@ -0,0 +1,700 @@
+---
+en:
+ admin:
+ mailer:
+ password_change:
+ changed: The password for your %{app_name} admin account was changed on %{date}.
+ did_not_make_change: If you did not make this change, someone else may have access to your %{app_name} admin account or your OTW email. Please let AD&T and VolCom know immediately so they can take steps to secure your accounts.
+ made_change:
+ html: If you made this change, you can %{login_link} or %{reset_password_link} if you've forgotten it.
+ login: log in with your new password
+ reset_password: reset your password
+ text: If you made this change, you can log in with your new password (%{login_url}) or reset your password if you've forgotten it (%{reset_password_url}).
+ secure_again:
+ html: When your accounts are secure again, please %{reset_password_link}.
+ reset_password: reset your admin password
+ text: 'When your accounts are secure again, please reset your admin password: %{reset_password_url}.'
+ subject: "[%{app_name}] Your admin password has been changed"
+ reset_password_instructions:
+ expiration:
+ one: If you do not use this link to reset your password within %{count} day, it will expire, and you will have to request a new one.
+ other: If you do not use this link to reset your password within %{count} days, it will expire, and you will have to request a new one.
+ intro: 'Someone has requested a password reset for your account. You can change your account password by following the link below and entering your new password:'
+ link_title_html: Change my password.
+ subject: "[%{app_name}] Reset your admin password"
+ unrequested: If you did not request this password reset, you may ignore this email and your previous password will continue to work.
+ admin_mailer:
+ set_password_notification:
+ created: Your AO3 admin account has been created.
+ expiration:
+ one: 'The link to set your password is good for %{count} day. If it no longer works, you can request a password reset and use the link that will be emailed to you instead: %{request_reset_url}.'
+ other: 'The link to set your password is good for %{count} days. If it no longer works, you can request a password reset and use the link that will be emailed to you instead: %{request_reset_url}.'
+ expiration_html:
+ one: The link to set your password is good for %{count} day. If it no longer works, you can %{request_reset_link} and use the link that will be emailed to you instead.
+ other: The link to set your password is good for %{count} days. If it no longer works, you can %{request_reset_link} and use the link that will be emailed to you instead.
+ finish: 'Please follow this link to set your password so you can log in: %{set_password_url}.'
+ finish_html: Please %{set_password_link} so you can log in.
+ request_reset: request a password reset
+ set_password: follow this link to set your password
+ subject: "[%{app_name}] Your AO3 admin account"
+ url: Admin login URL
+ username: Admin username
+ comment_mailer:
+ comment_notification:
+ chapter:
+ titled: 'Chapter %{position}: %{title}'
+ untitled: Chapter %{position}
+ content:
+ chapter:
+ html: "%{pseud_link} left the following comment on %{chapter_link} of %{work_link}:"
+ titled_text: "%{pseud} left the following comment on Chapter %{chapter_position}: %{chapter_title} of %{work} (%{chapter_url}):"
+ untitled_text: "%{pseud} left the following comment on Chapter %{chapter_position} of %{work} (%{chapter_url}):"
+ other:
+ html: "%{pseud_link} left the following comment on %{commentable_link}:"
+ text: "%{pseud} left the following comment on %{title} (%{commentable_url}):"
+ tag:
+ html: "%{pseud_link} left the following comment on the tag %{tag_link}:"
+ text: "%{pseud} left the following comment on the tag %{tag} (%{tag_url}):"
+ subject:
+ chapter: "[%{app_name}] Comment on Chapter %{position} of %{title}"
+ other: "[%{app_name}] Comment on %{title}"
+ tag: "[%{app_name}] Comment on the tag %{name}"
+ comment_reply_notification:
+ chapter:
+ titled: 'Chapter %{position}: %{title}'
+ untitled: Chapter %{position}
+ content:
+ chapter:
+ html: "%{pseud_link} replied to your comment on %{chapter_link} of %{work_link}:"
+ titled_text: "%{pseud} replied to your comment on Chapter %{chapter_position}: %{chapter_title} of %{work} (%{chapter_url}):"
+ untitled_text: "%{pseud} replied to your comment on Chapter %{chapter_position} of %{work} (%{chapter_url}):"
+ other:
+ html: "%{pseud_link} replied to your comment on %{commentable_link}:"
+ text: "%{pseud} replied to your comment on %{title} (%{commentable_url}):"
+ tag:
+ html: "%{pseud_link} replied to your comment on the tag %{tag_link}:"
+ text: "%{pseud} replied to your comment on the tag %{tag} (%{tag_url}):"
+ subject:
+ chapter: "[%{app_name}] Reply to your comment on Chapter %{position} of %{title}"
+ other: "[%{app_name}] Reply to your comment on %{title}"
+ tag: "[%{app_name}] Reply to your comment on the tag %{name}"
+ comment_reply_sent_notification:
+ chapter:
+ titled: 'Chapter %{position}: %{title}'
+ untitled: Chapter %{position}
+ content:
+ chapter:
+ html: 'You replied to a comment on %{chapter_link} of %{work_link}:'
+ titled_text: 'You replied to a comment on Chapter %{chapter_position}: %{chapter_title} of %{work} (%{chapter_url}):'
+ untitled_text: 'You replied to a comment on Chapter %{chapter_position} of %{work} (%{chapter_url}):'
+ other:
+ html: 'You replied to a comment on %{commentable_link}:'
+ text: 'You replied to a comment on %{title} (%{commentable_url}):'
+ tag:
+ html: 'You replied to a comment on the tag %{tag_link}:'
+ text: 'You replied to a comment on the tag %{tag} (%{tag_url}):'
+ subject:
+ chapter: "[%{app_name}] Reply you left to a comment on Chapter %{position} of %{title}"
+ other: "[%{app_name}] Reply you left to a comment on %{title}"
+ tag: "[%{app_name}] Reply you left to a comment on the tag %{name}"
+ comment_sent_notification:
+ chapter:
+ titled: 'Chapter %{position}: %{title}'
+ untitled: Chapter %{position}
+ content:
+ chapter:
+ html: 'You left the following comment on %{chapter_link} of %{work_link}:'
+ titled_text: 'You left the following comment on Chapter %{chapter_position}: %{chapter_title} of %{work} (%{chapter_url}):'
+ untitled_text: 'You left the following comment on Chapter %{chapter_position} of %{work} (%{chapter_url}):'
+ other:
+ html: 'You left the following comment on %{commentable_link}:'
+ text: 'You left the following comment on %{title} (%{commentable_url}):'
+ tag:
+ html: 'You left the following comment on the tag %{tag_link}:'
+ text: 'You left the following comment on the tag %{tag} (%{tag_url}):'
+ subject:
+ chapter: "[%{app_name}] Comment you left on Chapter %{position} of %{title}"
+ other: "[%{app_name}] Comment you left on %{title}"
+ tag: "[%{app_name}] Comment you left on the tag %{name}"
+ edited_comment_notification:
+ chapter:
+ titled: 'Chapter %{position}: %{title}'
+ untitled: Chapter %{position}
+ content:
+ chapter:
+ html: "%{pseud_link} edited the following comment on %{chapter_link} of %{work_link}:"
+ titled_text: "%{pseud} edited the following comment on Chapter %{chapter_position}: %{chapter_title} of %{work} (%{chapter_url}):"
+ untitled_text: "%{pseud} edited the following comment on Chapter %{chapter_position} of %{work} (%{chapter_url}):"
+ other:
+ html: "%{pseud_link} edited the following comment on %{commentable_link}:"
+ text: "%{pseud} edited the following comment on %{title} (%{commentable_url}):"
+ tag:
+ html: "%{pseud_link} edited the following comment on the tag %{tag_link}:"
+ text: "%{pseud} edited the following comment on the tag %{tag} (%{tag_url}):"
+ subject:
+ chapter: "[%{app_name}] Edited comment on Chapter %{position} of %{title}"
+ other: "[%{app_name}] Edited comment on %{title}"
+ tag: "[%{app_name}] Edited comment on the tag %{name}"
+ edited_comment_reply_notification:
+ chapter:
+ titled: 'Chapter %{position}: %{title}'
+ untitled: Chapter %{position}
+ content:
+ chapter:
+ html: "%{pseud_link} edited their reply to your comment on %{chapter_link} of %{work_link}:"
+ titled_text: "%{pseud} edited their reply to your comment on Chapter %{chapter_position}: %{chapter_title} of %{work} (%{chapter_url}):"
+ untitled_text: "%{pseud} edited their reply to your comment on Chapter %{chapter_position} of %{work} (%{chapter_url}):"
+ other:
+ html: "%{pseud_link} edited their reply to your comment on %{commentable_link}:"
+ text: "%{pseud} edited their reply to your comment on %{title} (%{commentable_url}):"
+ tag:
+ html: "%{pseud_link} edited their reply to your comment on the tag %{tag_link}:"
+ text: "%{pseud} edited their reply to your comment on the tag %{tag} (%{tag_url}):"
+ subject:
+ chapter: "[%{app_name}] Edited reply to your comment on Chapter %{position} of %{title}"
+ other: "[%{app_name}] Edited reply to your comment on %{title}"
+ tag: "[%{app_name}] Edited reply to your comment on the tag %{name}"
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ one: a guest
+ other: "%{count} guests"
+ left_kudos:
+ html:
+ one: "%{givers_list} left kudos on %{commentable_link}."
+ other: "%{givers_list} left kudos on %{commentable_link}."
+ text:
+ one: '%{givers_list} left kudos on "%{commentable_title}" (%{commentable_url}).'
+ other: '%{givers_list} left kudos on "%{commentable_title}" (%{commentable_url}).'
+ single_guest:
+ giver: A guest
+ html: "%{giver} left kudos on %{commentable_link}."
+ text: A guest left kudos on "%{commentable_title}" (%{commentable_url}).
+ subject: "[%{app_name}] You've got kudos!"
+ mailer:
+ collections:
+ why_collection_email:
+ html: You're receiving this email because your email address has been listed as the collection email for the collection %{collection_link}.
+ text: You're receiving this email because your email address has been listed as the collection email for the collection "%{collection_title}" (%{collection_url}).
+ why_maintainer:
+ html: You're receiving this email because you are an owner or moderator of the collection %{collection_link}.
+ text: You're receiving this email because you are an owner or moderator of the collection "%{collection_title}" (%{collection_url}).
+ general:
+ closing:
+ formal: Sincerely,
+ informal: Best,
+ creation:
+ link_with_word_count: "%{creation_link} (%{word_count})"
+ title_with_chapter_number: Chapter %{position} of %{title}
+ title_with_word_count: '"%{creation_title}" (%{word_count})'
+ word_count:
+ one: "%{count} word"
+ other: "%{count} words"
+ footer:
+ about:
+ html: The Sunset is a fan-run and fan-supported archive that relies on %{your_donations_link}.
+ text: 'The Sunset is a fan-run and fan-supported archive that relies on your donations: %{your_donations_url}.'
+ your_donations: your donations
+ sent_at: Sent at %{sent_at}.
+ why_policy_abuse:
+ contact_policy_abuse: contact Policy & Abuse
+ html: If you don't understand why you received this email, please %{contact_policy_abuse_link}.
+ text: 'If you don''t understand why you received this email, please contact Policy & Abuse: %{contact_policy_abuse_url}.'
+ why_support:
+ contact_support: contact Support
+ html: If you don't understand why you received this email, please %{contact_support_link}.
+ text: 'If you don''t understand why you received this email, please contact Support: %{contact_support_url}.'
+ greeting:
+ formal:
+ addressed_html: Hi %{name},
+ unaddressed: Hi,
+ informal:
+ addressed_html: Hi, %{name}!
+ unaddressed: Hi!
+ introductory: Hello from the Archive of Our Own!
+ tag_wrangler_supervisors: Tag Wrangler Supervisors
+ metadata_label_indicator: ": "
+ signature:
+ abuse_team: The AO3 Policy & Abuse team
+ app_short_name: AO3
+ open_doors: The Open Doors team
+ parent_org: Organization for Transformative Works
+ support: The AO3 Support team
+ roles:
+ anonymous_creator: Anonymous Creator
+ commenter_name:
+ html: "%{name} %{role_with_parens}"
+ text: "%{name} %{role_with_parens}"
+ guest_with_parens: "(Guest)"
+ official_with_parens: "(Official)"
+ registered_with_parens: "(Registered User)"
+ tag_wrangling_supervisor_mailer:
+ wrangler_username_change_notification:
+ name_changed:
+ html: The wrangler %{old_username} has changed their name to %{new_username}.
+ subject: "[%{app_name}] Wrangler name change"
+ user_mailer:
+ abuse_report:
+ copy:
+ comment: Description of the content or issue
+ intro: 'Here is a copy of your report for your reference:'
+ summary: Terms of Service violation
+ url: URL of the reported page
+ report_received: The Policy & Abuse team has received your report, and our volunteers will investigate as soon as they can. Because our team is small and we receive thousands of reports each month, it may take some time for us to get to your report.
+ resubmission: Please do not resubmit this report unless you have additional information that you did not include in your original report. Submitting multiple reports about the same issue may delay our response.
+ subject: "[%{app_name}] Abuse - %{summary}"
+ thank_you: Thank you for your patience.
+ admin_deleted_work_notification:
+ bye: Attached is a copy of your work for your reference.
+ contact_abuse: contact our Policy & Abuse team
+ deleted:
+ html: Your work %{title} was deleted from the Archive by a site admin.
+ text: Your work "%{title}" was deleted from the Archive by a site admin.
+ import_project:
+ html: If your work was part of an import project managed by our Open Doors team, please %{opendoors_link} with any further questions.
+ text: If your work was part of an import project managed by our Open Doors team, please contact Open Doors (%{opendoors_link}) with any further questions.
+ opendoors: contact Open Doors
+ subject: "[%{app_name}] Your work has been deleted by an admin"
+ tos_violation:
+ html: If it's possible your work violated the Archive's Terms of Service, please %{contact_abuse_link}.
+ text: If it's possible your work violated the Archive's Terms of Service, please contact our Policy & Abuse team (%{contact_abuse_url}).
+ admin_hidden_work_notification:
+ access:
+ one: While your work is hidden, you will still be able to access it through the link provided above, but it won't be available to other users or listed on your works page.
+ other: While your works are hidden, you will still be able to access them through the links provided above, but they won't be available to other users or listed on your works page.
+ check_email:
+ one: Please check your email, including your spam folder, as the Policy & Abuse committee may have already contacted you to explain why your work was hidden.
+ other: Please check your email, including your spam folder, as the Policy & Abuse committee may have already contacted you to explain why your works were hidden.
+ contact_abuse: contact the Policy & Abuse committee
+ content_policy: Content Policy
+ help:
+ html:
+ one: If you don't know why your work was hidden, and you have not received a separate email explaining why, please %{contact_abuse_link} directly.
+ other: If you don't know why your works were hidden, and you have not received a separate email explaining why, please %{contact_abuse_link} directly.
+ text:
+ one: 'If you don''t know why your work was hidden, and you have not received a separate email explaining why, please contact the Policy & Abuse committee directly: %{contact_abuse_url}.'
+ other: 'If you don''t know why your works were hidden, and you have not received a separate email explaining why, please contact the Policy & Abuse committee directly: %{contact_abuse_url}.'
+ hidden_multiple: "%{count} of your works have been hidden by the Policy & Abuse committee:"
+ hidden_one:
+ html: Your work %{title} has been hidden by the Policy & Abuse committee.
+ text: Your work "%{title}" (%{work_url}) has been hidden by the Policy & Abuse committee.
+ subject:
+ one: "[%{app_name}] Your work has been hidden by the Policy & Abuse committee"
+ other: "[%{app_name}] Your works have been hidden by the Policy & Abuse committee"
+ tos: AO3 Terms of Service
+ tos_faq: Terms of Service FAQ
+ tos_violation:
+ html:
+ one: If your work was hidden due to being in violation of the %{tos_link} (including the %{content_policy_link}), you will be required to take action to correct the violation. Failure to bring your work into compliance with the Terms of Service may result in your work being deleted from AO3. For more information, please refer to the %{tos_faq_link}.
+ other: If your works were hidden due to being in violation of the %{tos_link} (including the %{content_policy_link}), you will be required to take action to correct the violations. Failure to bring your works into compliance with the Terms of Service may result in your works being deleted from AO3. For more information, please refer to the %{tos_faq_link}.
+ text:
+ one: 'If your work was hidden due to being in violation of the AO3 Terms of Service (%{tos_url}), including the Content Policy (%{content_policy_url}), you will be required to take action to correct the violation. Failure to bring your work into compliance with the Terms of Service may result in your work being deleted from AO3. For more information, please refer to the Terms of Service FAQ: %{tos_faq_url}.'
+ other: 'If your works were hidden due to being in violation of the AO3 Terms of Service (%{tos_url}), including the Content Policy (%{content_policy_url}), you will be required to take action to correct the violations. Failure to bring your works into compliance with the Terms of Service may result in your works being deleted from AO3. For more information, please refer to the Terms of Service FAQ: %{tos_faq_url}.'
+ work_info: '- "%{title}" (%{work_url})'
+ admin_spam_work_notification:
+ contact_abuse: contact our Policy & Abuse Team
+ flagged_as_spam:
+ html: Your work %{work_link} has been flagged by our automated system as spam and hidden until it can be reviewed by our Policy & Abuse team. While the work is hidden it can only be accessed by you and AO3 site admins.
+ text: Your work "%{work_title}" (%{work_url}) has been flagged by our automated system as spam and hidden until it can be reviewed by our Abuse team. While the work is hidden it can only be accessed by you and AO3 site admins.
+ future: If we determine that your work is not spam, we'll unhide it. Other users will then be able to access and leave feedback on it as usual. Please note that we do not screen works for other kinds of violations at this time. If your work is reported to us for a different reason in the future, that will be investigated separately.
+ questions:
+ html: If you have any questions, please %{contact_abuse_link}.
+ text: If you have any questions, please contact our Policy & Abuse team (%{contact_abuse_url}).
+ subject: "[%{app_name}] Your work was hidden as spam"
+ anonymous_or_unrevealed_notification:
+ anonymous_info: Anonymous works are included in tag listings, but not on your works page. On the work, your username will be replaced with "Anonymous."
+ anonymous_unrevealed_info: The collection maintainers may later reveal your work but leave it anonymous. People who subscribe to you will not be notified of this change. Your work will be included in tag listings, but not on your works page. On the work, your username will be replaced with "Anonymous."
+ changed_status:
+ anonymous:
+ html: The collection maintainers of %{collection_link} have changed the status of your work %{work_link} to anonymous.
+ text: The collection maintainers of "%{collection_title}" (%{collection_url}) have changed the status of your work "%{work_title}" (%{work_url}) to anonymous.
+ anonymous_unrevealed:
+ html: The collection maintainers of %{collection_link} have changed the status of your work %{work_link} to anonymous and unrevealed.
+ text: The collection maintainers of "%{collection_title}" (%{collection_url}) have changed the status of your work "%{work_title}" (%{work_url}) to anonymous and unrevealed.
+ unrevealed:
+ html: The collection maintainers of %{collection_link} have changed the status of your work %{work_link} to unrevealed.
+ text: The collection maintainers of "%{collection_title}" (%{collection_url}) have changed the status of your work "%{work_title}" (%{work_url}) to unrevealed.
+ collection_items_link_text: Approved Collection Items page
+ do_not_want:
+ anonymous:
+ html: If you do not want your work to be anonymous, please visit your %{collection_items_link} to remove it from this collection.
+ text: 'If you do not want your work to be anonymous, please visit your Approved Collection Items page to remove it from this collection: %{collection_items_url}'
+ anonymous_unrevealed:
+ html: If you do not want your work to be anonymous and unrevealed, please visit your %{collection_items_link} to remove it from this collection.
+ text: 'If you do not want your work to be anonymous and unrevealed, please visit your Approved Collection Items page to remove it from this collection: %{collection_items_url}'
+ unrevealed:
+ html: If you do not want your work to be unrevealed, please visit your %{collection_items_link} to remove it from this collection.
+ text: 'If you do not want your work to be unrevealed, please visit your Approved Collection Items page to remove it from this collection: %{collection_items_url}'
+ faq_link_text: Collections FAQ
+ more_info:
+ html: For more information, visit our %{faq_link}.
+ text: 'For more information, visit our Collections FAQ: %{faq_url}'
+ subject:
+ anonymous: "[%{app_name}] Your work was made anonymous"
+ anonymous_unrevealed: "[%{app_name}] Your work was made anonymous and unrevealed"
+ unrevealed: "[%{app_name}] Your work was made unrevealed"
+ unrevealed_info: Unrevealed works are not included in tag listings or on your works page. Anyone who follows a link to the work will receive a notice that it is currently unrevealed, and they will be unable to access its content.
+ archivist_added_to_collection_notification:
+ approved_collection_items_page: Approved Collection Items page
+ archivist_notice: Because the collection maintainers are acting in their official capacity as Open Doors archivists, they are allowed to add your work to this collection, even if you have collection invitations disabled. Archivists will only add a work to a collection if it was hosted on an imported archive.
+ removal_instructions:
+ html: If you would like to remove your work from this collection, please visit your %{approved_items_link}.
+ text: 'If you would like to remove your work from this collection, please visit your Approved Collection Items page: %{approved_items_url}.'
+ subject: "[%{app_name}][%{collection_title}] An Open Doors archivist has added your work to a collection"
+ work_added:
+ html: The collection maintainers of %{collection_link} have added your work %{work_link} to their collection!
+ text: The collection maintainers of "%{collection_title}" (%{collection_url}) have added your work "%{work_title}" (%{work_url}) to their collection!
+ challenge_assignment_notification:
+ any: Any
+ assignment:
+ html: You have been assigned the following request in the %{link} challenge at Sunset!
+ text: You have been assigned the following request in the "%{collection_title}" challenge (%{collection_url}) at Sunset!
+ description: Description
+ due: This assignment is due at
+ footer:
+ challenge_profile: the challenge profile page
+ html: You're receiving this email because you signed up for the %{title} challenge. For more information about this challenge and contact information for the moderators, please visit %{challenge_profile_link}.
+ text: You're receiving this email because you signed up for the %{title} challenge (%{url}). For more information about this challenge and contact information for the moderators, please visit %{challenge_profile_url}.
+ look_up:
+ html: You can look up this assignment from %{your_assignments_link}.
+ text: You can look up this assignment from your Assignments page at %{your_assignments_url}.
+ your_assignments: your Assignments page
+ optional_tags: Optional Tags
+ prompt_url: Prompt URL
+ prompts: 'Prompts:'
+ recipient: Recipient
+ recipient_missing: 'None: contact a moderator for help!'
+ subject: "[%{app_name}][%{collection_title}] Your assignment!"
+ change_email:
+ confirm_change:
+ html:
+ one: If you made this request, check your email at %{unconfirmed_email} within %{count} day to confirm your email change. If you do not receive a confirmation email, or if you have other issues, please %{contact_support_link}.
+ other: If you made this request, check your email at %{unconfirmed_email} within %{count} days to confirm your email change. If you do not receive a confirmation email, or if you have other issues, please %{contact_support_link}.
+ text:
+ one: 'If you made this request, check your email at %{unconfirmed_email} within %{count} day to confirm your email change. If you do not receive a confirmation email, or if you have other issues, please contact Support: %{contact_support_url}.'
+ other: 'If you made this request, check your email at %{unconfirmed_email} within %{count} days to confirm your email change. If you do not receive a confirmation email, or if you have other issues, please contact Support: %{contact_support_url}.'
+ contact_policy_abuse: contact Policy & Abuse
+ contact_support: contact Support
+ email_change_form: submit the email change form
+ made_request: Someone has made a request to change the email address associated with your %{app_name} account.
+ not_made_request:
+ html: If you did not make this request, someone else may have access to your %{app_name} account. Please %{reset_password_link} or %{contact_policy_abuse_link}.
+ text: 'If you did not make this request, someone else may have access to your %{app_name} account. Please reset your password now (%{reset_password_url}) or contact Policy & Abuse: %{contact_policy_abuse_url}.'
+ reset_password: reset your password now
+ subject: "[%{app_name}] Email change request"
+ wrong_email:
+ html: If you entered the wrong email address, you will need to %{email_change_form_link} again.
+ text: 'If you entered the wrong email address, you will need to submit the email change form again: %{email_change_form_url}.'
+ change_username:
+ change_cooldown_html:
+ one: "%{app_name} usernames can only be changed once per day. You will be able to change your username again on %{date}."
+ other: "%{app_name} usernames can only be changed once every %{count} days. You will be able to change your username again on %{date}."
+ changed_html: The username for your %{app_name} account %{old_username} has been changed to %{new_username}.
+ contact_policy_abuse: contact Policy & Abuse
+ contact_support: contact Support
+ did_not_make_change:
+ html: If you did not make this change, someone else may have access to your %{app_name} account. Please %{reset_password_link} or %{contact_policy_abuse_link}.
+ text: 'If you did not make this change, someone else may have access to your %{app_name} account. Please reset your password now (%{reset_password_url}) or contact Policy & Abuse: %{contact_policy_abuse_url}.'
+ effects:
+ html: You can %{refer_faq_link} to learn how changing your username will affect your account. If you experience technical issues, such as seeing your old username on your works or bookmarks after a week, please %{contact_support_link}.
+ text: 'You can refer to our FAQ to learn how changing your username will affect your account: %{refer_faq_url}. If you experience technical issues, such as seeing your old username on your works or bookmarks after a week, please contact Support: %{contact_support_url}.'
+ refer_faq: refer to our FAQ
+ reset_password: reset your password now
+ subject: "[%{app_name}] Your username has been changed"
+ claim_notification:
+ access:
+ contact_support: contact AO3 Support
+ html: Depending on the archive, your works may have been imported restricted to registered users only (to keep them out of Google searches). If this is the case, the works will only be accessible by logged-in users unless you choose to make them fully visible. For help unlocking, orphaning, or deleting your works, please %{contact_support_link}.
+ text: Depending on the archive, your works may have been imported restricted to registered users only (to keep them out of Google searches). If this is the case, the works will only be accessible by logged-in users unless you choose to make them fully visible. For help unlocking, orphaning, or deleting your works, please contact AO3 Support at %{support_url}.
+ email_tips: If you're contacting us, please add email addresses from @transformativeworks.org to your list of safe contacts and check your spam folders for our reply.
+ introduction:
+ ao3_name: Sunset
+ html: You're receiving this e-mail because you had works in a fanworks archive that has been imported by %{open_doors_name_link} into the %{app_link} (AO3). Because this e-mail address is connected to one registered on the imported archive, the associated fanworks (listed below) have been automatically added to your AO3 account.
+ open_doors_name: Open Doors
+ text: You're receiving this e-mail because you had works in a fanworks archive that has been imported by Open Doors (%{open_doors_url}) into the Archive of Our Own (AO3 - %{app_url}). Because this e-mail address is connected to one registered on the imported archive, the associated fanworks (listed below) have been automatically added to your AO3 account.
+ mistake:
+ contact_open_doors: contact Open Doors
+ html: If this is a mistake and these are not your works, please don't delete them! Please just %{contact_open_doors_link} and we will sort it out.
+ text: If this is a mistake and these are not your works, please don't delete them! Please just contact Open Doors (%{open_doors_url}) and we will sort it out.
+ more_info:
+ ao3_news: AO3 News
+ contact_support: contact AO3 Support
+ faq_page: FAQ page
+ html: You can read announcements about recent archive moves at %{ao3_news_link}, and find additional information on Open Doors' %{faq_page_link} or %{tutorial_page_link}. For any questions not answered in the FAQ, tutorials, or this e-mail, please %{contact_support_link}.
+ text: You can read announcements about recent archive moves at AO3 News (%{news_url}), and find additional information on Open Doors' FAQ page (%{open_doors_faq_url}) or tutorials page (%{open_doors_tutorial_url}). For any questions not answered in the FAQ, tutorials, or this e-mail, please contact Support at %{support_url}.
+ tutorial_page: tutorial page
+ other_works:
+ contact_open_doors: contact Open Doors
+ html: If you had other works on the imported archive under an e-mail address you can no longer access, please %{contact_open_doors_link} with any information that can help verify your identity.
+ text: If you had other works on the imported archive under an e-mail address you can no longer access, please contact Open Doors with any information that can help verify your identity.
+ questions:
+ contact_support: contact AO3 Support
+ html: For other inquiries, please %{contact_support_link}.
+ text: For other inquiries, please contact AO3 Support at %{support_url}.
+ redirects:
+ html: To preserve rec lists and bookmarks, the imported archive's web addresses may redirect to the imported copy of these works for a limited time (check the announcement post for your archive to be sure). If you've already uploaded a copy of these works and you did %{negation} use the import from URL feature, there will be two copies of the same work on the AO3.
+ negation: NOT
+ subject: "[%{app_name}] Works uploaded"
+ update_redirect:
+ contact_open_doors: contact Open Doors
+ html: If you would like Open Doors to update the redirect to point to your pre-existing work, please delete the imported copy, and %{contact_open_doors_link} with your AO3 account name, your account name on the imported archive, and the title and URL of the fanwork you would like the redirect to point to. (If you have multiple works you would like to change the redirects for, you can list these in one email.)
+ text: If you would like Open Doors to update the redirect to point to your pre-existing work, please delete the imported copy, and contact Open Doors at %{open_doors_url} with your AO3 account name, your account name on the imported archive, and the title and URL of the fanwork you would like the redirect to point to. (If you have multiple works you would like to change the redirects for, you can list these in one email.)
+ work_info:
+ html: "%{work_link} (%{fandom})"
+ text:
+ no_fandom: '- "%{work_title}" (%{work_url})'
+ with_fandom: '- "%{work_title}" (%{work_url}) (%{fandom})'
+ works_by: 'These works were written under the e-mail: %{email}'
+ collection_notification:
+ assignments_sent:
+ complete: All assignments have now been sent out.
+ subject: Assignments sent
+ challenge_default:
+ complete: 'Signed-up participant %{offer_byline} has defaulted on their assignment for %{request_byline}. You may want to assign a pinch hitter on the collection assignments page: %{assignments_page_url}'
+ subject: Challenge default by %{offer_byline}
+ html:
+ received_message: 'You have received a message about your collection %{collection_link}:'
+ text:
+ received_message: 'You have received a message about your collection "%{collection_title}" (%{collection_url}):'
+ creatorship_notification:
+ explanation: When you're a co-creator on a work, you can be added to new chapters regardless of your co-creation settings. You will also be added to any series the work is added to.
+ html:
+ creation: "%{creation_link} by %{pseud_links}"
+ edit_chapter: edit the chapter
+ edit_series: edit the series
+ remove_chapter: If you've been added in error or don't want to be listed as a creator, you can %{edit_chapter_link} to remove yourself as creator.
+ remove_series: If you've been added in error or don't want to be listed as a creator, you can %{edit_series_link} to remove yourself as creator.
+ intro_chapter: 'The user %{adding_user} has listed your pseud %{pseud} as a co-creator on the following chapter:'
+ intro_series: 'The user %{adding_user} has listed your pseud %{pseud} as a co-creator on the following series:'
+ subject: "[%{app_name}] You've been added as a co-creator"
+ text:
+ creation: "%{title} (%{url}) by %{pseuds}"
+ remove_chapter: 'If you''ve been added in error or don''t want to be listed as a creator, you can edit the chapter to remove yourself as creator: %{url}'
+ remove_series: 'If you''ve been added in error or don''t want to be listed as a creator, you can edit the series to remove yourself as creator: %{url}'
+ creatorship_notification_archivist:
+ explanation: Because they are acting in their official capacity as an Open Doors archivist, they are allowed to add you without a request, even if you have co-creation disabled.
+ html:
+ creation: "%{creation_link} by %{pseud_links}"
+ edit_chapter: edit the chapter
+ edit_series: edit the series
+ edit_work: edit the work
+ remove_chapter: If you've been added in error or don't want to be listed as a creator, you can %{edit_chapter_link} to remove yourself as creator.
+ remove_series: If you've been added in error or don't want to be listed as a creator, you can %{edit_series_link} to remove yourself as creator.
+ remove_work: If you've been added in error or don't want to be listed as a creator, you can %{edit_work_link} to remove yourself as creator.
+ intro_chapter: 'The user %{archivist} has added your pseud %{pseud} as a co-creator on the following chapter:'
+ intro_series: 'The user %{archivist} has added your pseud %{pseud} as a co-creator on the following series:'
+ intro_work: 'The user %{archivist} has added your pseud %{pseud} as a co-creator on the following work:'
+ subject: "[%{app_name}] An archivist has added you as a co-creator"
+ text:
+ creation: "%{title} (%{url}) by %{pseuds}"
+ remove_chapter: 'If you''ve been added in error or don''t want to be listed as a creator, you can edit the chapter to remove yourself as creator: %{url}'
+ remove_series: 'If you''ve been added in error or don''t want to be listed as a creator, you can edit the series to remove yourself as creator: %{url}'
+ remove_work: 'If you''ve been added in error or don''t want to be listed as a creator, you can edit the work to remove yourself as creator: %{url}'
+ creatorship_request:
+ html:
+ creation: "%{creation_link} by %{pseud_links}"
+ instructions: You can accept or reject this request on your %{page_name} page.
+ page_name: Co-Creator Requests
+ intro_chapter: 'The user %{inviting_user} has invited your pseud %{pseud} to be listed as a co-creator on the following chapter:'
+ intro_series: 'The user %{inviting_user} has invited your pseud %{pseud} to be listed as a co-creator on the following series:'
+ intro_work: 'The user %{inviting_user} has invited your pseud %{pseud} to be listed as a co-creator on the following work:'
+ subject: "[%{app_name}] You've received a request to be a co-creator"
+ text:
+ creation: "%{title} (%{url}) by %{pseuds}"
+ instructions: 'You can accept or reject this request on your Co-Creator Requests page: %{url}'
+ delete_work_notification:
+ attachment: Attached is a copy of your work for your reference.
+ deleted_other:
+ html: Your work %{title} was deleted at the request of %{pseud}.
+ text: Your work "%{title}" was deleted at the request of %{pseud}.
+ deleted_yourself:
+ html: Your work %{title} was deleted at your request.
+ text: Your work "%{title}" was deleted at your request.
+ questions:
+ html: If you have questions, please %{support}.
+ text: If you have questions, please %{support} (%{url}).
+ subject: "[%{app_name}] Your work has been deleted"
+ support: contact Support
+ feedback:
+ additional_ticket: If you have additional questions or information, do not hesitate to send in another ticket.
+ introduction: 'We''re working hard to reply to everyone, and we''ll respond to you as soon as we can. Your communication is greatly valued, and it will be reviewed and answered by our volunteer Support team. In the meantime, here is a copy of the information you submitted through the Technical Support and Feedback form:'
+ subject: "[%{app_name}] Support - %{summary}"
+ invalid_signup_notification:
+ found_invalid:
+ html: We have found some invalid sign-ups in your gift exchange %{collection_link}. Potential matches can't be generated until these are cleaned up.
+ text: We have found some invalid sign-ups in your gift exchange "%{collection_title}" (%{collection_url}). Potential matches can't be generated until these are cleaned up.
+ see_details:
+ challenge_matching_help: the challenge matching help
+ html: Invalid sign-ups may be duplicates, or may not meet the requirements you've set for your challenge. Unfortunately, there is no automatic way to fix them, so you will have to manually edit or delete them. For more details, refer to %{challenge_matching_help_link}.
+ text: 'Invalid sign-ups may be duplicates, or may not meet the requirements you''ve set for your challenge. Unfortunately, there is no automatic way to fix them, so you will have to manually edit or delete them. For more details, refer to the challenge matching help: %{challenge_matching_help_url}.'
+ signup: 'Signup #%{signup_id}'
+ signups_here: 'Here are the invalid sign-ups:'
+ subject: "[%{app_name}][%{collection_title}] Invalid sign-ups found"
+ invitation:
+ been_invited: You've been invited to join Sunset!
+ features: With an account, you can post fanworks, use bookmarks to keep track of works you enjoyed, receive subscription emails when your favorite creators or works update, customize the way the site looks for you, and more!
+ has_invited: "%{user_name} has invited you to join the Archive of Our Own!"
+ html:
+ about: Sunset is a free, noncommercial archive built by and for fans.
+ activation_support: After you sign up, you'll receive an account activation email. If you do not receive this email after 48 hours, please %{support_link}.
+ faq: For more information, please check %{faq_link}.
+ faq_link_text: our FAQ
+ invitation_link_text: follow this link to sign up
+ join: If you'd like to join us, please %{invitation_link}.
+ otw_link_text: Organization for Transformative Works
+ support_link_text: contact Support
+ subject: "[%{app_name}] Invitation"
+ text:
+ about: Sunset is a free, noncommercial archive built by and for fans. Our servers are owned by our parent nonprofit, the Organization for Transformative Works (%{otw_url}), which works to protect fan rights and preserve fanworks. We welcome all kinds of fanworks, including fanfiction, fanart, fanvids, and podfic from any fandom.
+ activation_support: 'After you sign up, you''ll receive an account activation email. If you do not receive this email after 48 hours, please contact Support: %{support_url}.'
+ faq: 'For more information, please check our FAQ: %{faq_url}.'
+ join: 'If you''d like to join us, please follow this link to sign up: %{invitation_url}.'
+ invitation_to_claim:
+ access:
+ html: Depending on the archive, your works may have been imported restricted to registered users only (to keep them out of Google searches). If this is the case, the works will only be accessible by logged-in users unless you choose to make them fully visible. For help unlocking, orphaning, or deleting your works, please %{contact_support_link}.
+ text: Depending on the archive, your works may have been imported restricted to registered users only (to keep them out of Google searches). If this is the case, the works will only be accessible by logged-in users unless you choose to make them fully visible. For help unlocking, orphaning, or deleting your works, please contact AO3 Support.
+ claim_or_remove:
+ html: Claim or remove your works here.
+ text: 'Claim or remove your works here: %{claim_url}'
+ email_tips: If you're contacting us, please add email addresses from @transformativeworks.org to your list of safe contacts and check your spam folders for our reply.
+ html:
+ ao3_news: AO3 News
+ contact_open_doors: contact Open Doors
+ contact_support: contact AO3 Support
+ faq_page: FAQ page
+ open_doors_name: Open Doors
+ tutorial_page: tutorial page
+ introduction:
+ html: You're receiving this e-mail because an archive has recently been imported by %{open_doors_name_link} into the %{app_link} (%{app_short_name}), and we believe that the following fanworks belong to you. We'd like to give you a chance to claim (or delete/orphan) these works if you want to. And if you don't already have an account under a different e-mail, we'd like to invite you aboard!
+ text: You're receiving this e-mail because an archive has recently been imported by Open Doors (%{open_doors_link}) into the %{app_name} (%{app_short_name} - %{app_url}), and we believe that the following fanworks belong to you. We'd like to give you a chance to claim (or delete/orphan) these works if you want to. And if you don't already have an account under a different e-mail, we'd like to invite you aboard!
+ mistake:
+ html: If this is a mistake and these are not your works, please don't delete them! Please just %{contact_open_doors_link} and we will sort it out.
+ text: If this is a mistake and these are not your works, please don't delete them! Please just contact Open Doors (%{open_doors_link}) and we will sort it out.
+ more_info:
+ html: You can read announcements about recent archive moves at %{ao3_news_link}, and find additional information on Open Doors' %{faq_page_link} or %{tutorial_page_link}. For any questions not answered in the FAQ, tutorials, or this e-mail, please %{contact_support_link}.
+ text: You can read announcements about recent archive moves at AO3 News (%{news_link}), and find additional information on Open Doors' FAQ page (%{open_doors_faq_link}) or tutorials page (%{open_doors_tutorial_link}). For any questions not answered in the FAQ, tutorials, or this e-mail, please contact Support at %{support_link}.
+ other_works:
+ html: If you had other works on the imported archive under an e-mail address you can no longer access, please %{contact_open_doors_link} with any information that can help verify your identity.
+ text: If you had other works on the imported archive under an e-mail address you can no longer access, please contact Open Doors with any information that can help verify your identity.
+ questions:
+ html: For other inquiries, please %{contact_support_link}.
+ text: For other inquiries, please contact AO3 Support at %{support_link}.
+ redirects: To preserve rec lists and bookmarks, the imported archive's web addresses may redirect to the imported copy of these works for a limited time (check the announcement post for your archive to be sure). If you've already uploaded a copy of these works and you did NOT use the import from URL feature, there will be two copies of the same work on the archive.
+ subject: "[%{app_name}] Invitation to claim works"
+ unwanted:
+ html: If these works do belong to you, but you don't want them, you can orphan (so that they remain on the AO3, but with your name removed) or delete them (so that they are entirely removed from the AO3). You do not need to add these works to any account in order to orphan or delete them--you can do this directly from the claim link above. (For assistance, please %{contact_support_link}.)
+ text: If these works do belong to you, but you don't want them, you can orphan (so that they remain on the AO3, but with your name removed) or delete them (so that they are entirely removed from the AO3). You do not need to add these works to any account in order to orphan or delete them--you can do this directly from the claim link above. (For assistance, please contact Support at %{support_link}.)
+ update_redirect:
+ html: If you would like Open Doors to update the redirect to point to your pre-existing work, please delete the imported copy, and %{contact_open_doors_link} with your AO3 account name, your account name on the imported archive, and the title and URL of the fanwork you would like the redirect to point to. (If you have multiple works you would like to change the redirects for, you can list these in one email.)
+ text: If you would like Open Doors to update the redirect to point to your pre-existing work, please delete the imported copy, and contact Open Doors at %{open_doors_link} with your AO3 account name, your account name on the imported archive, and the title and URL of the fanwork you would like the redirect to point to. (If you have multiple works you would like to change the redirects for, you can list these in one email.)
+ uploaded_list: 'The works uploaded include:'
+ work_info:
+ no_fandom: '- "%{work_title}" (%{work_url})'
+ with_fandom: '- "%{work_title}" (%{work_url}) (%{fandom})'
+ invite_increase_notification:
+ body:
+ html:
+ one: We just wanted to let you know that you have %{count} new invitation, which can be used to create a new account at the Archive. You can invite a friend at %{invitation_page_link}.
+ other: We just wanted to let you know that you have %{count} new invitations, which can be used to create new accounts at the Archive. You can invite a friend at %{invitation_page_link}.
+ text:
+ one: We just wanted to let you know that you have %{count} new invitation, which can be used to create a new account at the Archive. You can invite a friend at your invitations page (%{invitation_page_url}).
+ other: We just wanted to let you know that you have %{count} new invitations, which can be used to create new accounts at the Archive. You can invite a friend at your invitations page (%{invitation_page_url}).
+ invitation_page_link_text: your invitations page
+ subject: "[%{app_name}] New invitations"
+ invite_request_declined:
+ main:
+ one: We regret to inform you that your request for a new invitation cannot be fulfilled at this time.
+ other: We regret to inform you that your request for %{count} new invitations cannot be fulfilled at this time.
+ reason: 'Your request was:'
+ subject: "[%{app_name}] Additional invitation request declined"
+ invited_to_collection_notification:
+ approve:
+ collection_items_page: Collection Items page
+ html: To approve or reject this request, please visit your %{collection_items_page_link}.
+ text: 'To approve or reject this request, please visit your Collection Items page: %{collection_items_page_url}.'
+ subject: "[%{app_name}][%{collection_title}] Request to include work in a collection"
+ would_like_to_include:
+ html: The collection maintainers of %{collection_link} would like to include your work %{work_link} in their collection!
+ text: The collection maintainers of "%{collection_title}" (%{collection_url}) would like to include your work "%{work_title}" (%{work_url}) in their collection!
+ potential_match_generation_notification:
+ finished_generating:
+ html: We have finished generating potential assignments for your gift exchange %{collection_link}.
+ text: We have finished generating potential assignments for your gift exchange "%{collection_title}" (%{collection_url}).
+ matches_available:
+ html: The potential matches and set assignments are available on its %{matching_page_link}.
+ matching_page: Matching page
+ text: 'The potential matches and set assignments are available on its Matching page: %{matching_page_url}.'
+ subject: "[%{app_name}][%{collection_title}] Potential assignment generation complete"
+ recipient_notification:
+ collection:
+ html: A gift work has been posted for you in the %{collection_link} collection at the Archive of Our Own!
+ text: A gift work has been posted for you in the "%{collection_title}" collection (%{collection_url}) at the Archive of Our Own!
+ no_collection: A gift work has been posted for you at the Archive of Our Own!
+ subject:
+ collection: "[%{app_name}][%{collection_title}] A gift work for you from %{collection_title}"
+ no_collection: "[%{app_name}] A gift work for you"
+ related_work_notification:
+ anonymous: an anonymous creator
+ approve:
+ html: Approve relationship and link back to this work from your work
+ text: 'Approve relationship and link back to this work from your work: %{related_work_url}'
+ can_come_back: "(You can come back to this page if you change your mind.)"
+ subject: "[%{app_name}] Related work notification"
+ work_listed:
+ html: Your work %{work_link} was listed as the inspiration of %{related_work_link} by %{work_creators}.
+ text: Your work "%{work_title}" (%{work_url}) was listed as the inspiration of "%{related_work_title}" (%{related_work_url}) by %{work_creators}.
+ signup_notification:
+ activate:
+ html: Please %{activate_account_link}.
+ text: 'Please follow this link to activate your account: %{activate_account_url}'
+ activate_your_account: follow this link to activate your account
+ admin_posts: AO3 News
+ bye: We hope you enjoy using the Archive.
+ contact_support: contact Support
+ faq: FAQ
+ features:
+ html: Once your account is up and running, you can post your fanworks, set up email subscriptions to let you know when your favorite creators or works have updated, set preferences to customize the way the site looks and works for you, keep track of the works you've accessed on the Archive via your history, and much more.
+ text: Once your account is up and running, you can post your fanworks, set up email subscriptions to let you know when your favorite creators or works have updated, set preferences to customize the way the site looks and works for you, keep track of the works you've accessed on the Archive via your history, and much more.
+ information:
+ html: There's lots of information and advice on how to use the Archive in our %{faq_link}. You'll find the latest news about site developments on %{admin_posts_link}. If you need more help, run into a bug, or have questions or comments, please %{contact_support_link}, who are always happy to help out.
+ text: 'There''s lots of information and advice on how to use the Archive in our FAQ at %{faq_url}. You''ll find the latest news about site developments on AO3 News at %{admin_posts_url}. If you need more help, run into a bug, or have questions or comments, please contact Support, who are always happy to help out: %{contact_support_url}.'
+ subject: "[%{app_name}] Activate your account"
+ welcome: Welcome to the Archive of Our Own, %{login}!
+ users:
+ mailer:
+ confirmation_instructions:
+ confirm_by:
+ html: If you don't confirm your request by %{date}, the link in this email will expire and you will need to %{email_change_form_link} again.
+ text: 'If you don''t confirm your request by %{date}, the link in this email will expire and you will need to submit the email change form again: %{email_change_form_url}.'
+ confirm_email_change: confirm your email change
+ did_not_make_request: If you did not make this request, you can safely ignore and delete this email. Someone else may have entered your email address by mistake.
+ email_change_form: submit the email change form
+ made_request:
+ html:
+ one: If you made this request, please log in and %{confirm_email_change_link} within %{count} day.
+ other: If you made this request, please log in and %{confirm_email_change_link} within %{count} days.
+ text:
+ one: 'If you made this request, please log in and confirm your email change within %{count} day: %{confirm_email_change_url}.'
+ other: 'If you made this request, please log in and confirm your email change within %{count} days: %{confirm_email_change_url}.'
+ request_to_change_email_html: Someone has made a request to change the email address associated with the %{app_name} account %{username} to this email address.
+ subject: "[%{app_name}] Confirm your email change"
+ password_change:
+ changed: The password for your %{app_name} account was changed on %{date}.
+ did_not_make_change:
+ contact_policy_abuse: contact Policy & Abuse
+ html: If you did not make this change, someone else may have access to your %{app_name} account. Please %{reset_password_link} or %{contact_policy_abuse_link}.
+ reset_password: reset your password now
+ text: 'If you did not make this change, someone else may have access to your %{app_name} account. Please reset your password now (%{reset_password_url}) or contact Policy & Abuse: %{contact_policy_abuse_url}.'
+ made_change:
+ contact_support: contact Support
+ html: If you made this change, you can %{login_link} or %{reset_password_link} if you've forgotten it. If you experience technical issues, please %{contact_support_link}.
+ login: log in with your new password
+ reset_password: reset your password
+ text: 'If you made this change, you can log in with your new password (%{login_url}) or reset your password if you''ve forgotten it (%{reset_password_url}). If you experience technical issues, please contact Support: %{contact_support_url}'
+ subject: "[%{app_name}] Your password has been changed"
+ reset_password_instructions:
+ expiration: If you do not use this link to reset your password within a week, it will expire, and you will have to request a new one.
+ intro: 'Someone has requested a password reset for your account. You can change your account password by following the link below and entering your new password:'
+ link_title: Change my password.
+ subject: "[%{app_name}] Reset your password"
+ unrequested: If you did not request this password reset, you may ignore this email and your previous password will continue to work.
diff --git a/config/locales/mailers/en.yml.save b/config/locales/mailers/en.yml.save
new file mode 100755
index 0000000..1e88dff
--- /dev/null
+++ b/config/locales/mailers/en.yml.save
@@ -0,0 +1,678 @@
+---
+en:
+ admin:
+ mailer:
+ password_change:
+ changed: The password for your %{app_name} admin account was changed on %{date}.
+ did_not_make_change: If you did not make this change, someone else may have access to your %{app_name} admin account or your OTW email. Please let AD&T and VolCom know immediately so they can take steps to secure your accounts.
+ made_change:
+ html: If you made this change, you can %{login_link} or %{reset_password_link} if you'v login: log in with your new password
+ reset_password: reset your password
+ text: If you made this change, you can log in with your new password (%{login_url}) or reset your password if you've forgotten it (%{reset_password_url}).
+ secure_again:
+ html: When your accounts are secure again, please %{reset_password_link}.
+ reset_password: reset your admin password
+ text: 'When your accounts are secure again, please reset your admin password: %{reset_password_url}.'
+ subject: "[%{app_name}] Your admin password has been changed"
+ reset_password_instructions:
+ expiration:
+ one: If you do not use this link to reset your password within %{count} day, it will expire, and you will have to request a new one.
+ other: If you do not use this link to reset your password within %{count} days, it will expire, and you will have to request a new one.
+ intro: 'Someone has requested a password reset for your account. You can change your account password by following the link below and entering your new password:'
+ link_title_html: Change my password.
+ subject: "[%{app_name}] Reset your admin password"
+ unrequested: If you did not request this password reset, you may ignore this email and your previous password will continue to work.
+ admin_mailer:
+ set_password_notification:
+ created: Your AO3 admin account has been created.
+ expiration:
+ one: 'The link to set your password is good for %{count} day. If it no longer works, you can request a password reset and use the link that will be emailed to you instead: %{request_reset_url}.'
+ other: 'The link to set your password is good for %{count} days. If it no longer works, you can request a password reset and use the link that will be emailed to you instead: %{request_reset_url}.'
+ expiration_html:
+ one: The link to set your password is good for %{count} day. If it no longer works, you can %{request_reset_link} and use the link that will be emailed to you instead.
+ other: The link to set your password is good for %{count} days. If it no longer works, you can %{request_reset_link} and use the link that will be emailed to you instead.
+ finish: 'Please follow this link to set your password so you can log in: %{set_password_url}.'
+ finish_html: Please %{set_password_link} so you can log in.
+ request_reset: request a password reset
+ set_password: follow this link to set your password
+ subject: "[%{app_name}] Your AO3 admin account"
+ url: Admin login URL
+ username: Admin username
+ comment_mailer:
+ comment_notification:
+ chapter:
+ titled: 'Chapter %{position}: %{title}'
+ untitled: Chapter %{position}
+ content:
+ chapter:
+ html: "%{pseud_link} left the following comment on %{chapter_link} of %{work_link}:"
+ titled_text: "%{pseud} left the following comment on Chapter %{chapter_position}: %{chapter_title} of %{work} (%{chapter_url}):"
+ untitled_text: "%{pseud} left the following comment on Chapter %{chapter_position} of %{work} (%{chapter_url}):"
+ other:
+ html: "%{pseud_link} left the following comment on %{commentable_link}:"
+ text: "%{pseud} left the following comment on %{title} (%{commentable_url}):"
+ tag:
+ html: "%{pseud_link} left the following comment on the tag %{tag_link}:"
+ text: "%{pseud} left the following comment on the tag %{tag} (%{tag_url}):"
+ subject:
+ chapter: "[%{app_name}] Comment on Chapter %{position} of %{title}"
+ other: "[%{app_name}] Comment on %{title}"
+ tag: "[%{app_name}] Comment on the tag %{name}"
+ comment_reply_notification:
+ chapter:
+ titled: 'Chapter %{position}: %{title}'
+ untitled: Chapter %{position}
+ content:
+ chapter:
+ html: "%{pseud_link} replied to your comment on %{chapter_link} of %{work_link}:"
+ titled_text: "%{pseud} replied to your comment on Chapter %{chapter_position}: %{chapter_title} of %{work} (%{chapter_url}):"
+ untitled_text: "%{pseud} replied to your comment on Chapter %{chapter_position} of %{work} (%{chapter_url}):"
+ other:
+ html: "%{pseud_link} replied to your comment on %{commentable_link}:"
+ text: "%{pseud} replied to your comment on %{title} (%{commentable_url}):"
+ tag:
+ html: "%{pseud_link} replied to your comment on the tag %{tag_link}:"
+ text: "%{pseud} replied to your comment on the tag %{tag} (%{tag_url}):"
+ subject:
+ chapter: "[%{app_name}] Reply to your comment on Chapter %{position} of %{title}"
+ other: "[%{app_name}] Reply to your comment on %{title}"
+ tag: "[%{app_name}] Reply to your comment on the tag %{name}"
+ comment_reply_sent_notification:
+ chapter:
+ titled: 'Chapter %{position}: %{title}'
+ untitled: Chapter %{position}
+ content:
+ chapter:
+ html: 'You replied to a comment on %{chapter_link} of %{work_link}:'
+ titled_text: 'You replied to a comment on Chapter %{chapter_position}: %{chapter_title} of %{work} (%{chapter_url}):'
+ untitled_text: 'You replied to a comment on Chapter %{chapter_position} of %{work} (%{chapter_url}):'
+ other:
+ html: 'You replied to a comment on %{commentable_link}:'
+ text: 'You replied to a comment on %{title} (%{commentable_url}):'
+ tag:
+ html: 'You replied to a comment on the tag %{tag_link}:'
+ text: 'You replied to a comment on the tag %{tag} (%{tag_url}):'
+ subject:
+ chapter: "[%{app_name}] Reply you left to a comment on Chapter %{position} of %{title}"
+ other: "[%{app_name}] Reply you left to a comment on %{title}"
+ tag: "[%{app_name}] Reply you left to a comment on the tag %{name}"
+ comment_sent_notification:
+ chapter:
+ titled: 'Chapter %{position}: %{title}'
+ untitled: Chapter %{position}
+ content:
+ chapter:
+ html: 'You left the following comment on %{chapter_link} of %{work_link}:'
+ titled_text: 'You left the following comment on Chapter %{chapter_position}: %{chapter_title} of %{work} (%{chapter_url}):'
+ untitled_text: 'You left the following comment on Chapter %{chapter_position} of %{work} (%{chapter_url}):'
+ other:
+ html: 'You left the following comment on %{commentable_link}:'
+ text: 'You left the following comment on %{title} (%{commentable_url}):'
+ tag:
+ html: 'You left the following comment on the tag %{tag_link}:'
+ text: 'You left the following comment on the tag %{tag} (%{tag_url}):'
+ subject:
+ chapter: "[%{app_name}] Comment you left on Chapter %{position} of %{title}"
+ other: "[%{app_name}] Comment you left on %{title}"
+ tag: "[%{app_name}] Comment you left on the tag %{name}"
+ edited_comment_notification:
+ chapter:
+ titled: 'Chapter %{position}: %{title}'
+ untitled: Chapter %{position}
+ content:
+ chapter:
+ html: "%{pseud_link} edited the following comment on %{chapter_link} of %{work_link}:"
+ titled_text: "%{pseud} edited the following comment on Chapter %{chapter_position}: %{chapter_title} of %{work} (%{chapter_url}):"
+ untitled_text: "%{pseud} edited the following comment on Chapter %{chapter_position} of %{work} (%{chapter_url}):"
+ other:
+ html: "%{pseud_link} edited the following comment on %{commentable_link}:"
+ text: "%{pseud} edited the following comment on %{title} (%{commentable_url}):"
+ tag:
+ html: "%{pseud_link} edited the following comment on the tag %{tag_link}:"
+ text: "%{pseud} edited the following comment on the tag %{tag} (%{tag_url}):"
+ subject:
+ chapter: "[%{app_name}] Edited comment on Chapter %{position} of %{title}"
+ other: "[%{app_name}] Edited comment on %{title}"
+ tag: "[%{app_name}] Edited comment on the tag %{name}"
+ edited_comment_reply_notification:
+ chapter:
+ titled: 'Chapter %{position}: %{title}'
+ untitled: Chapter %{position}
+ content:
+ chapter:
+ html: "%{pseud_link} edited their reply to your comment on %{chapter_link} of %{work_link}:"
+ titled_text: "%{pseud} edited their reply to your comment on Chapter %{chapter_position}: %{chapter_title} of %{work} (%{chapter_url}):"
+ untitled_text: "%{pseud} edited their reply to your comment on Chapter %{chapter_position} of %{work} (%{chapter_url}):"
+ other:
+ html: "%{pseud_link} edited their reply to your comment on %{commentable_link}:"
+ text: "%{pseud} edited their reply to your comment on %{title} (%{commentable_url}):"
+ tag:
+ html: "%{pseud_link} edited their reply to your comment on the tag %{tag_link}:"
+ text: "%{pseud} edited their reply to your comment on the tag %{tag} (%{tag_url}):"
+ subject:
+ chapter: "[%{app_name}] Edited reply to your comment on Chapter %{position} of %{title}"
+ other: "[%{app_name}] Edited reply to your comment on %{title}"
+ tag: "[%{app_name}] Edited reply to your comment on the tag %{name}"
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ one: a guest
+ other: "%{count} guests"
+ left_kudos:
+ html:
+ one: "%{givers_list} left kudos on %{commentable_link}."
+ other: "%{givers_list} left kudos on %{commentable_link}."
+ text:
+ one: '%{givers_list} left kudos on "%{commentable_title}" (%{commentable_url}).'
+ other: '%{givers_list} left kudos on "%{commentable_title}" (%{commentable_url}).'
+ single_guest:
+ giver: A guest
+ html: "%{giver} left kudos on %{commentable_link}."
+ text: A guest left kudos on "%{commentable_title}" (%{commentable_url}).
+ subject: "[%{app_name}] You've got kudos!"
+ mailer:
+ collections:
+ why_collection_email:
+ html: You're receiving this email because your email address has been listed as the collection email for the collection %{collection_link}.
+ text: You're receiving this email because your email address has been listed as the collection email for the collection "%{collection_title}" (%{collection_url}).
+ why_maintainer:
+ html: You're receiving this email because you are an owner or moderator of the collection %{collection_link}.
+ text: You're receiving this email because you are an owner or moderator of the collection "%{collection_title}" (%{collection_url}).
+ general:
+ closing:
+ formal: Sincerely,
+ informal: Best,
+ creation:
+ link_with_word_count: "%{creation_link} (%{word_count})"
+ title_with_chapter_number: Chapter %{position} of %{title}
+ title_with_word_count: '"%{creation_title}" (%{word_count})'
+ word_count:
+ one: "%{count} word"
+ other: "%{count} words"
+ footer:
+ about:
+ html: Sunset is a fan-run and fan-supported archive that relies on %{your_donations_link}.
+ text: 'Sunset is a fan-run and fan-supported archive that relies on your donations: %{your_donations_url}.'
+ your_donations: your donations
+ sent_at: Sent at %{sent_at}.
+ why_policy_abuse:
+ contact_policy_abuse: contact Policy & Abuse
+ html: If you don't understand why you received this email, please %{contact_policy_abuse_link}.
+ text: 'If you don''t understand why you received this email, please contact Policy & Abuse: %{contact_policy_abuse_url}.'
+ why_support:
+ contact_support: contact Support
+ html: If you don't understand why you received this email, please %{contact_support_link}.
+ text: 'If you don''t understand why you received this email, please contact Support: %{contact_support_url}.'
+ greeting:
+ formal:
+ addressed_html: Hi %{name},
+ unaddressed: Hi,
+ informal:
+ addressed_html: Hi, %{name}!
+ unaddressed: Hi!
+ introductory: Hello from the Sunset!
+ tag_wrangler_supervisors: Tag Wrangler Supervisors
+ metadata_label_indicator: ": "
+ signature:
+ abuse_team: The Sunset Policy & Abuse team
+ app_short_name: Sunset
+ open_doors: The Open Doors team
+ parent_org: Sunset
+ support: The Sunset Support team
+ roles:
+ anonymous_creator: Anonymous Creator
+ commenter_name:
+ html: "%{name} %{role_with_parens}"
+ text: "%{name} %{role_with_parens}"
+ guest_with_parens: "(Guest)"
+ official_with_parens: "(Official)"
+ registered_with_parens: "(Registered User)"
+ tag_wrangling_supervisor_mailer:
+ wrangler_username_change_notification:
+ name_changed:
+ html: The wrangler %{old_username} has changed their name to %{new_username}.
+ subject: "[%{app_name}] Wrangler name change"
+ user_mailer:
+ abuse_report:
+ copy:
+ comment: Description of the content or issue
+ intro: 'Here is a copy of your report for your reference:'
+ summary: Terms of Service violation
+ url: URL of the reported page
+ report_received: The Policy & Abuse team has received your report, and our volunteers will investigate as soon as they can. Because our team is small and we receive thousands of reports each month, it may take some time for us to get to your report.
+ resubmission: Please do not resubmit this report unless you have additional information that you did not include in your original report. Submitting multiple reports about the same issue may delay our response.
+ subject: "[%{app_name}] Abuse - %{summary}"
+ thank_you: Thank you for your patience.
+ admin_deleted_work_notification:
+ bye: Attached is a copy of your work for your reference.
+ contact_abuse: contact our Policy & Abuse team
+ deleted:
+ html: Your work %{title} was deleted from the Archive by a site admin.
+ text: Your work "%{title}" was deleted from the Archive by a site admin.
+ import_project:
+ html: If your work was part of an import project managed by our Open Doors team, please %{opendoors_link} with any further questions.
+ text: If your work was part of an import project managed by our Open Doors team, please contact Open Doors (%{opendoors_link}) with any further questions.
+ opendoors: contact Open Doors
+ subject: "[%{app_name}] Your work has been deleted by an admin"
+ tos_violation:
+ html: If it's possible your work violated the Archive's Terms of Service, please %{contact_abuse_link}.
+ text: If it's possible your work violated the Archive's Terms of Service, please contact our Policy & Abuse team (%{contact_abuse_url}).
+ admin_hidden_work_notification:
+ access:
+ one: While your work is hidden, you will still be able to access it through the link provided above, but it won't be available to other users or listed on your works page.
+ other: While your works are hidden, you will still be able to access them through the links provided above, but they won't be available to other users or listed on your works page.
+ check_email:
+ one: Please check your email, including your spam folder, as the Policy & Abuse committee may have already contacted you to explain why your work was hidden.
+ other: Please check your email, including your spam folder, as the Policy & Abuse committee may have already contacted you to explain why your works were hidden.
+ contact_abuse: contact the Policy & Abuse committee
+ content_policy: Content Policy
+ help:
+ html:
+ one: If you don't know why your work was hidden, and you have not received a separate email explaining why, please %{contact_abuse_link} directly.
+ other: If you don't know why your works were hidden, and you have not received a separate email explaining why, please %{contact_abuse_link} directly.
+ text:
+ one: 'If you don''t know why your work was hidden, and you have not received a separate email explaining why, please contact the Policy & Abuse committee directly: %{contact_abuse_url}.'
+ other: 'If you don''t know why your works were hidden, and you have not received a separate email explaining why, please contact the Policy & Abuse committee directly: %{contact_abuse_url}.'
+ hidden_multiple: "%{count} of your works have been hidden by the Policy & Abuse committee:"
+ hidden_one:
+ html: Your work %{title} has been hidden by the Policy & Abuse committee.
+ text: Your work "%{title}" (%{work_url}) has been hidden by the Policy & Abuse committee.
+ subject:
+ one: "[%{app_name}] Your work has been hidden by the Policy & Abuse committee"
+ other: "[%{app_name}] Your works have been hidden by the Policy & Abuse committee"
+ tos: Sunset Terms of Service
+ tos_faq: Terms of Service FAQ
+ tos_violation:
+ html:
+ one: If your work was hidden due to being in violation of the %{tos_link} (including the %{content_policy_link}), you will be required to take action to correct the violation. Failure to bring your work into compliance with the Terms of Service may result in your work being deleted from AO3. For more information, please refer to the %{tos_faq_link}.
+ other: If your works were hidden due to being in violation of the %{tos_link} (including the %{content_policy_link}), you will be required to take action to correct the violations. Failure to bring your works into compliance with the Terms of Service may result in your works being deleted from AO3. For more information, please refer to the %{tos_faq_link}.
+ text:
+ one: 'If your work was hidden due to being in violation of the AO3 Terms of Service (%{tos_url}), including the Content Policy (%{content_policy_url}), you will be required to take action to correct the violation. Failure to bring your work into compliance with the Terms of Service may result in your work being deleted from AO3. For more information, please refer to the Terms of Service FAQ: %{tos_faq_url}.'
+ other: 'If your works were hidden due to being in violation of the AO3 Terms of Service (%{tos_url}), including the Content Policy (%{content_policy_url}), you will be required to take action to correct the violations. Failure to bring your works into compliance with the Terms of Service may result in your works being deleted from AO3. For more information, please refer to the Terms of Service FAQ: %{tos_faq_url}.'
+ work_info: '- "%{title}" (%{work_url})'
+ admin_spam_work_notification:
+ contact_abuse: contact our Policy & Abuse Team
+ flagged_as_spam:
+ html: Your work %{work_link} has been flagged by our automated system as spam and hidden until it can be reviewed by our Policy & Abuse team. While the work is hidden it can only be accessed by you and AO3 site admins.
+ text: Your work "%{work_title}" (%{work_url}) has been flagged by our automated system as spam and hidden until it can be reviewed by our Abuse team. While the work is hidden it can only be accessed by you and AO3 site admins.
+ future: If we determine that your work is not spam, we'll unhide it. Other users will then be able to access and leave feedback on it as usual. Please note that we do not screen works for other kinds of violations at this time. If your work is reported to us for a different reason in the future, that will be investigated separately.
+ questions:
+ html: If you have any questions, please %{contact_abuse_link}.
+ text: If you have any questions, please contact our Policy & Abuse team (%{contact_abuse_url}).
+ subject: "[%{app_name}] Your work was hidden as spam"
+ anonymous_or_unrevealed_notification:
+ anonymous_info: Anonymous works are included in tag listings, but not on your works page. On the work, your username will be replaced with "Anonymous."
+ anonymous_unrevealed_info: The collection maintainers may later reveal your work but leave it anonymous. People who subscribe to you will not be notified of this change. Your work will be included in tag listings, but not on your works page. On the work, your username will be replaced with "Anonymous."
+ changed_status:
+ anonymous:
+ html: The collection maintainers of %{collection_link} have changed the status of your work %{work_link} to anonymous.
+ text: The collection maintainers of "%{collection_title}" (%{collection_url}) have changed the status of your work "%{work_title}" (%{work_url}) to anonymous.
+ anonymous_unrevealed:
+ html: The collection maintainers of %{collection_link} have changed the status of your work %{work_link} to anonymous and unrevealed.
+ text: The collection maintainers of "%{collection_title}" (%{collection_url}) have changed the status of your work "%{work_title}" (%{work_url}) to anonymous and unrevealed.
+ unrevealed:
+ html: The collection maintainers of %{collection_link} have changed the status of your work %{work_link} to unrevealed.
+ text: The collection maintainers of "%{collection_title}" (%{collection_url}) have changed the status of your work "%{work_title}" (%{work_url}) to unrevealed.
+ collection_items_link_text: Approved Collection Items page
+ do_not_want:
+ anonymous:
+ html: If you do not want your work to be anonymous, please visit your %{collection_items_link} to remove it from this collection.
+ text: 'If you do not want your work to be anonymous, please visit your Approved Collection Items page to remove it from this collection: %{collection_items_url}'
+ anonymous_unrevealed:
+ html: If you do not want your work to be anonymous and unrevealed, please visit your %{collection_items_link} to remove it from this collection.
+ text: 'If you do not want your work to be anonymous and unrevealed, please visit your Approved Collection Items page to remove it from this collection: %{collection_items_url}'
+ unrevealed:
+ html: If you do not want your work to be unrevealed, please visit your %{collection_items_link} to remove it from this collection.
+ text: 'If you do not want your work to be unrevealed, please visit your Approved Collection Items page to remove it from this collection: %{collection_items_url}'
+ faq_link_text: Collections FAQ
+ more_info:
+ html: For more information, visit our %{faq_link}.
+ text: 'For more information, visit our Collections FAQ: %{faq_url}'
+ subject:
+ anonymous: "[%{app_name}] Your work was made anonymous"
+ anonymous_unrevealed: "[%{app_name}] Your work was made anonymous and unrevealed"
+ unrevealed: "[%{app_name}] Your work was made unrevealed"
+ unrevealed_info: Unrevealed works are not included in tag listings or on your works page. Anyone who follows a link to the work will receive a notice that it is currently unrevealed, and they will be unable to access its content.
+ archivist_added_to_collection_notification:
+ approved_collection_items_page: Approved Collection Items page
+ archivist_notice: Because the collection maintainers are acting in their official capacity as Open Doors archivists, they are allowed to add your work to this collection, even if you have collection invitations disabled. Archivists will only add a work to a collection if it was hosted on an imported archive.
+ removal_instructions:
+ html: If you would like to remove your work from this collection, please visit your %{approved_items_link}.
+ text: 'If you would like to remove your work from this collection, please visit your Approved Collection Items page: %{approved_items_url}.'
+ subject: "[%{app_name}][%{collection_title}] An Open Doors archivist has added your work to a collection"
+ work_added:
+ html: The collection maintainers of %{collection_link} have added your work %{work_link} to their collection!
+ text: The collection maintainers of "%{collection_title}" (%{collection_url}) have added your work "%{work_title}" (%{work_url}) to their collection!
+ challenge_assignment_notification:
+ any: Any
+ assignment:
+ html: You have been assigned the following request in the %{link} challenge at Sunset!
+ text: You have been assigned the following request in the "%{collection_title}" challenge (%{collection_url}) at Sunset!
+ description: Description
+ due: This assignment is due at
+ footer:
+ challenge_profile: the challenge profile page
+ html: You're receiving this email because you signed up for the %{title} challenge. For more information about this challenge and contact information for the moderators, please visit %{challenge_profile_link}.
+ text: You're receiving this email because you signed up for the %{title} challenge (%{url}). For more information about this challenge and contact information for the moderators, please visit %{challenge_profile_url}.
+ look_up:
+ html: You can look up this assignment from %{your_assignments_link}.
+ text: You can look up this assignment from your Assignments page at %{your_assignments_url}.
+ your_assignments: your Assignments page
+ optional_tags: Optional Tags
+ prompt_url: Prompt URL
+ prompts: 'Prompts:'
+ recipient: Recipient
+ recipient_missing: 'None: contact a moderator for help!'
+ subject: "[%{app_name}][%{collection_title}] Your assignment!"
+ change_email:
+ confirm_change:
+ html:
+ one: If you made this request, check your email at %{unconfirmed_email} within %{count} day to confirm your email change. If you do not receive a confirmation email, or if you have other issues, please %{contact_support_link}.
+ other: If you made this request, check your email at %{unconfirmed_email} within %{count} days to confirm your email change. If you do not receive a confirmation email, or if you have other issues, please %{contact_support_link}.
+ text:
+ one: 'If you made this request, check your email at %{unconfirmed_email} within %{count} day to confirm your email change. If you do not receive a confirmation email, or if you have other issues, please contact Support: %{contact_support_url}.'
+ other: 'If you made this request, check your email at %{unconfirmed_email} within %{count} days to confirm your email change. If you do not receive a confirmation email, or if you have other issues, please contact Support: %{contact_support_url}.'
+ contact_policy_abuse: contact Policy & Abuse
+ contact_support: contact Support
+ email_change_form: submit the email change form
+ made_request: Someone has made a request to change the email address associated with your %{app_name} account.
+ not_made_request:
+ html: If you did not make this request, someone else may have access to your %{app_name} account. Please %{reset_password_link} or %{contact_policy_abuse_link}.
+ text: 'If you did not make this request, someone else may have access to your %{app_name} account. Please reset your password now (%{reset_password_url}) or contact Policy & Abuse: %{contact_policy_abuse_url}.'
+ reset_password: reset your password now
+ subject: "[%{app_name}] Email change request"
+ wrong_email:
+ html: If you entered the wrong email address, you will need to %{email_change_form_link} again.
+ text: 'If you entered the wrong email address, you will need to submit the email change form again: %{email_change_form_url}.'
+ change_username:
+ change_cooldown_html:
+ one: "%{app_name} usernames can only be changed once per day. You will be able to change your username again on %{date}."
+ other: "%{app_name} usernames can only be changed once every %{count} days. You will be able to change your username again on %{date}."
+ changed_html: The username for your %{app_name} account %{old_username} has been changed to %{new_username}.
+ contact_policy_abuse: contact Policy & Abuse
+ contact_support: contact Support
+ did_not_make_change:
+ html: If you did not make this change, someone else may have access to your %{app_name} account. Please %{reset_password_link} or %{contact_policy_abuse_link}.
+ text: 'If you did not make this change, someone else may have access to your %{app_name} account. Please reset your password now (%{reset_password_url}) or contact Policy & Abuse: %{contact_policy_abuse_url}.'
+ effects:
+ html: You can %{refer_faq_link} to learn how changing your username will affect your account. If you experience technical issues, such as seeing your old username on your works or bookmarks after a week, please %{contact_support_link}.
+ text: 'You can refer to our FAQ to learn how changing your username will affect your account: %{refer_faq_url}. If you experience technical issues, such as seeing your old username on your works or bookmarks after a week, please contact Support: %{contact_support_url}.'
+ refer_faq: refer to our FAQ
+ reset_password: reset your password now
+ subject: "[%{app_name}] Your username has been changed"
+ claim_notification:
+ access:
+ contact_support: contact Sunset Support
+ html: Depending on the archive, your works may have been imported restricted to registered users only (to keep them out of Google searches). If this is the case, the works will only be accessible by logged-in users unless you choose to make them fully visible. For help unlocking, orphaning, or deleting your works, please %{contact_support_link}.
+ text: Depending on the archive, your works may have been imported restricted to registered users only (to keep them out of Google searches). If this is the case, the works will only be accessible by logged-in users unless you choose to make them fully visible. For help unlocking, orphaning, or deleting your works, please contact AO3 Support at %{support_url}.
+ email_tips: If you're contacting us, please add email addresses from @transformativeworks.org to your list of safe contacts and check your spam folders for our reply.
+ introduction:
+ ao3_name: Sunset
+ html: You're receiving this e-mail because you had works in a fanworks archive that has been imported by %{open_doors_name_link} into the %{app_link} (AO3). Because this e-mail address is connected to one registered on the imported archive, the associated fanworks (listed below) have been automatically added to your AO3 account.
+ open_doors_name: Open Doors
+ text: You're receiving this e-mail because you had works in a fanworks archive that has been imported by Open Doors (%{open_doors_url}) into the Archive of Our Own (AO3 - %{app_url}). Because this e-mail address is connected to one registered on the imported archive, the associated fanworks (listed below) have been automatically added to your AO3 account.
+ mistake:
+ contact_open_doors: contact Open Doors
+ html: If this is a mistake and these are not your works, please don't delete them! Please just %{contact_open_doors_link} and we will sort it out.
+ text: If this is a mistake and these are not your works, please don't delete them! Please just contact Open Doors (%{open_doors_url}) and we will sort it out.
+ more_info:
+ ao3_news: Sunset News
+ contact_support: contact Sunset Support
+ faq_page: FAQ page
+ html: You can read announcements about recent archive moves at %{ao3_news_link}, and find additional information on Open Doors' %{faq_page_link} or %{tutorial_page_link}. For any questions not answered in the FAQ, tutorials, or this e-mail, please %{contact_support_link}.
+ text: You can read announcements about recent archive moves at AO3 News (%{news_url}), and find additional information on Open Doors' FAQ page (%{open_doors_faq_url}) or tutorials page (%{open_doors_tutorial_url}). For any questions not answered in the FAQ, tutorials, or this e-mail, please contact Support at %{support_url}.
+ tutorial_page: tutorial page
+ other_works:
+ contact_open_doors: contact Open Doors
+ html: If you had other works on the imported archive under an e-mail address you can no longer access, please %{contact_open_doors_link} with any information that can help verify your identity.
+ text: If you had other works on the imported archive under an e-mail address you can no longer access, please contact Open Doors with any information that can help verify your identity.
+ questions:
+ contact_support: contact Sunset Support
+ html: For other inquiries, please %{contact_support_link}.
+ text: For other inquiries, please contact AO3 Support at %{support_url}.
+ redirects:
+ html: To preserve rec lists and bookmarks, the imported archive's web addresses may redirect to the imported copy of these works for a limited time (check the announcement post for your archive to be sure). If you've already uploaded a copy of these works and you did %{negation} use the import from URL feature, there will be two copies of the same work on the AO3.
+ negation: NOT
+ subject: "[%{app_name}] Works uploaded"
+ update_redirect:
+ contact_open_doors: contact Open Doors
+ html: If you would like Open Doors to update the redirect to point to your pre-existing work, please delete the imported copy, and %{contact_open_doors_link} with your AO3 account name, your account name on the imported archive, and the title and URL of the fanwork you would like the redirect to point to. (If you have multiple works you would like to change the redirects for, you can list these in one email.)
+ text: If you would like Open Doors to update the redirect to point to your pre-existing work, please delete the imported copy, and contact Open Doors at %{open_doors_url} with your AO3 account name, your account name on the imported archive, and the title and URL of the fanwork you would like the redirect to point to. (If you have multiple works you would like to change the redirects for, you can list these in one email.)
+ work_info:
+ html: "%{work_link} (%{fandom})"
+ text:
+ no_fandom: '- "%{work_title}" (%{work_url})'
+ with_fandom: '- "%{work_title}" (%{work_url}) (%{fandom})'
+ works_by: 'These works were written under the e-mail: %{email}'
+ collection_notification:
+ assignments_sent:
+ complete: All assignments have now been sent out.
+ subject: Assignments sent
+ challenge_default:
+ complete: 'Signed-up participant %{offer_byline} has defaulted on their assignment for %{request_byline}. You may want to assign a pinch hitter on the collection assignments page: %{assignments_page_url}'
+ subject: Challenge default by %{offer_byline}
+ html:
+ received_message: 'You have received a message about your collection %{collection_link}:'
+ text:
+ received_message: 'You have received a message about your collection "%{collection_title}" (%{collection_url}):'
+ creatorship_notification:
+ explanation: When you're a co-creator on a work, you can be added to new chapters regardless of your co-creation settings. You will also be added to any series the work is added to.
+ html:
+ creation: "%{creation_link} by %{pseud_links}"
+ edit_chapter: edit the chapter
+ edit_series: edit the series
+ remove_chapter: If you've been added in error or don't want to be listed as a creator, you can %{edit_chapter_link} to remove yourself as creator.
+ remove_series: If you've been added in error or don't want to be listed as a creator, you can %{edit_series_link} to remove yourself as creator.
+ intro_chapter: 'The user %{adding_user} has listed your pseud %{pseud} as a co-creator on the following chapter:'
+ intro_series: 'The user %{adding_user} has listed your pseud %{pseud} as a co-creator on the following series:'
+ subject: "[%{app_name}] You've been added as a co-creator"
+ text:
+ creation: "%{title} (%{url}) by %{pseuds}"
+ remove_chapter: 'If you''ve been added in error or don''t want to be listed as a creator, you can edit the chapter to remove yourself as creator: %{url}'
+ remove_series: 'If you''ve been added in error or don''t want to be listed as a creator, you can edit the series to remove yourself as creator: %{url}'
+ creatorship_notification_archivist:
+ explanation: Because they are acting in their official capacity as an Open Doors archivist, they are allowed to add you without a request, even if you have co-creation disabled.
+ html:
+ creation: "%{creation_link} by %{pseud_links}"
+ edit_chapter: edit the chapter
+ edit_series: edit the series
+ edit_work: edit the work
+ remove_chapter: If you've been added in error or don't want to be listed as a creator, you can %{edit_chapter_link} to remove yourself as creator.
+ remove_series: If you've been added in error or don't want to be listed as a creator, you can %{edit_series_link} to remove yourself as creator.
+ remove_work: If you've been added in error or don't want to be listed as a creator, you can %{edit_work_link} to remove yourself as creator.
+ intro_chapter: 'The user %{archivist} has added your pseud %{pseud} as a co-creator on the following chapter:'
+ intro_series: 'The user %{archivist} has added your pseud %{pseud} as a co-creator on the following series:'
+ intro_work: 'The user %{archivist} has added your pseud %{pseud} as a co-creator on the following work:'
+ subject: "[%{app_name}] An archivist has added you as a co-creator"
+ text:
+ creation: "%{title} (%{url}) by %{pseuds}"
+ remove_chapter: 'If you''ve been added in error or don''t want to be listed as a creator, you can edit the chapter to remove yourself as creator: %{url}'
+ remove_series: 'If you''ve been added in error or don''t want to be listed as a creator, you can edit the series to remove yourself as creator: %{url}'
+ remove_work: 'If you''ve been added in error or don''t want to be listed as a creator, you can edit the work to remove yourself as creator: %{url}'
+ creatorship_request:
+ html:
+ creation: "%{creation_link} by %{pseud_links}"
+ instructions: You can accept or reject this request on your %{page_name} page.
+ page_name: Co-Creator Requests
+ intro_chapter: 'The user %{inviting_user} has invited your pseud %{pseud} to be listed as a co-creator on the following chapter:'
+ intro_series: 'The user %{inviting_user} has invited your pseud %{pseud} to be listed as a co-creator on the following series:'
+ intro_work: 'The user %{inviting_user} has invited your pseud %{pseud} to be listed as a co-creator on the following work:'
+ subject: "[%{app_name}] You've received a request to be a co-creator"
+ text:
+ creation: "%{title} (%{url}) by %{pseuds}"
+ instructions: 'You can accept or reject this request on your Co-Creator Requests page: %{url}'
+ delete_work_notification:
+ attachment: Attached is a copy of your work for your reference.
+ deleted_other:
+ html: Your work %{title} was deleted at the request of %{pseud}.
+ text: Your work "%{title}" was deleted at the request of %{pseud}.
+ deleted_yourself:
+ html: Your work %{title} was deleted at your request.
+ text: Your work "%{title}" was deleted at your request.
+ questions:
+ html: If you have questions, please %{support}.
+ text: If you have questions, please %{support} (%{url}).
+ subject: "[%{app_name}] Your work has been deleted"
+ support: contact Support
+ feedback:
+ additional_ticket: If you have additional questions or information, do not hesitate to send in another ticket.
+ introduction: 'We''re working hard to reply to everyone, and we''ll respond to you as soon as we can. Your communication is greatly valued, and it will be reviewed and answered by our volunteer Support team. In the meantime, here is a copy of the information you submitted through the Technical Support and Feedback form:'
+ subject: "[%{app_name}] Support - %{summary}"
+ invalid_signup_notification:
+ found_invalid:
+ html: We have found some invalid sign-ups in your gift exchange %{collection_link}. Potential matches can't be generated until these are cleaned up.
+ text: We have found some invalid sign-ups in your gift exchange "%{collection_title}" (%{collection_url}). Potential matches can't be generated until these are cleaned up.
+ see_details:
+ challenge_matching_help: the challenge matching help
+ html: Invalid sign-ups may be duplicates, or may not meet the requirements you've set for your challenge. Unfortunately, there is no automatic way to fix them, so you will have to manually edit or delete them. For more details, refer to %{challenge_matching_help_link}.
+ text: 'Invalid sign-ups may be duplicates, or may not meet the requirements you''ve set for your challenge. Unfortunately, there is no automatic way to fix them, so you will have to manually edit or delete them. For more details, refer to the challenge matching help: %{challenge_matching_help_url}.'
+ signup: 'Signup #%{signup_id}'
+ signups_here: 'Here are the invalid sign-ups:'
+ subject: "[%{app_name}][%{collection_title}] Invalid sign-ups found"
+ invitation:
+ been_invited: You've been invited to join Sunset!
+ features: With an account, you can post fanworks, use bookmarks to keep track of works you enjoyed, receive subscription emails when your favorite creators or works update, customize the way the site looks for you, and more!
+ has_invited: "%{user_name} has invited you to join Sunset!"
+ html:
+ about: Sunset is a free, noncommercial femslash archive built by and for fans. Our servers are owned by our parent nonprofit, the %{otw_link}, which works to protect fan rights and preserve fanworks. We welcome all kinds of fanworks, including fanfiction, fanart, fanvids, and podfic from any fandom.
+ activation_support: After you sign up, you'll receive an account activation email. If you do not receive this email after 48 hours, please email femslashclub@proton.me again.
+ faq: For more information, please check %{faq_link}.
+ faq_link_text: our FAQ
+ invitation_link_text: follow this link to sign up
+ join: 'If you''d like to join us, please follow this link to sign up: %{invitation_url}.'
+ otw_link_text: Sunset
+ support_link_text: contact Support
+ subject: "[%{app_name}] Invitation"
+ text:
+ about: Sunset is a free, noncommercial archive built by and for fans.
+ activation_support: 'After you sign up, you''ll receive an account activation email. If you do not receive this email after 48 hours, please contact Agnes by email, listed on the index or about page.'
+ faq: 'For more information, please check our FAQ: %{faq_url}.'
+ join: 'If you''d like to join us, please follow this link to sign up: %{invitation_url}.'
+ invitation_to_claim:
+ access:
+ html: Depending on the archive, your works may have been imported restricted to registered users only (to keep them out of Google searches). If this is the case, the works will only be accessible by logged-in users unless you choose to make them fully visible. For help unlocking, orphaning, or deleting your works, please %{contact_support_link}.
+ text: Depending on the archive, your works may have been imported restricted to registered users only (to keep them out of Google searches). If this is the case, the works will only be accessible by logged-in users unless you choose to make them fully visible. For help unlocking, orphaning, or deleting your works, please contact AO3 Support.
+ claim_or_remove:
+ html: Claim or remove your works here.
+ text: 'Claim or remove your works here: %{claim_url}'
+ email_tips: If you're contacting us, please add email addresses from @transformativeworks.org to your list of safe contacts and check your spam folders for our reply.
+ html:
+ ao3_news: Sunset News
+ contact_open_doors: contact Open Doors
+ contact_support: contact AO3 Support
+ faq_page: FAQ page
+ open_doors_name: Open Doors
+ tutorial_page: tutorial page
+ introduction:
+ html: You're receiving this e-mail because an archive has recently been imported by %{open_doors_name_link} into the %{app_link} (%{app_short_name}), and we believe that the following fanworks belong to you. We'd like to give you a chance to claim (or delete/orphan) these works if you want to. And if you don't already have an account under a different e-mail, we'd like to invite you aboard!
+ text: You're receiving this e-mail because an archive has recently been imported by Open Doors (%{open_doors_link}) into the %{app_name} (%{app_short_name} - %{app_url}), and we believe that the following fanworks belong to you. We'd like to give you a chance to claim (or delete/orphan) these works if you want to. And if you don't already have an account under a different e-mail, we'd like to invite you aboard!
+ mistake:
+ html: If this is a mistake and these are not your works, please don't delete them! Please just %{contact_open_doors_link} and we will sort it out.
+ text: If this is a mistake and these are not your works, please don't delete them! Please just contact Open Doors (%{open_doors_link}) and we will sort it out.
+ more_info:
+ html: You can read announcements about recent archive moves at %{ao3_news_link}, and find additional information on Open Doors' %{faq_page_link} or %{tutorial_page_link}. For any questions not answered in the FAQ, tutorials, or this e-mail, please %{contact_support_link}.
+ text: You can read announcements about recent archive moves at AO3 News (%{news_link}), and find additional information on Open Doors' FAQ page (%{open_doors_faq_link}) or tutorials page (%{open_doors_tutorial_link}). For any questions not answered in the FAQ, tutorials, or this e-mail, please contact Support at %{support_link}.
+ other_works:
+ html: If you had other works on the imported archive under an e-mail address you can no longer access, please %{contact_open_doors_link} with any information that can help verify your identity.
+ text: If you had other works on the imported archive under an e-mail address you can no longer access, please contact Open Doors with any information that can help verify your identity.
+ questions:
+ html: For other inquiries, please %{contact_support_link}.
+ text: For other inquiries, please contact AO3 Support at %{support_link}.
+ redirects: To preserve rec lists and bookmarks, the imported archive's web addresses may redirect to the imported copy of these works for a limited time (check the announcement post for your archive to be sure). If you've already uploaded a copy of these works and you did NOT use the import from URL feature, there will be two copies of the same work on the archive.
+ subject: "[%{app_name}] Invitation to claim works"
+ unwanted:
+ html: If these works do belong to you, but you don't want them, you can orphan (so that they remain on the AO3, but with your name removed) or delete them (so that they are entirely removed from the AO3). You do not need to add these works to any account in order to orphan or delete them--you can do this directly from the claim link above. (For assistance, please %{contact_support_link}.)
+ text: If these works do belong to you, but you don't want them, you can orphan (so that they remain on the AO3, but with your name removed) or delete them (so that they are entirely removed from the AO3). You do not need to add these works to any account in order to orphan or delete them--you can do this directly from the claim link above. (For assistance, please contact Support at %{support_link}.)
+ update_redirect:
+ html: If you would like Open Doors to update the redirect to point to your pre-existing work, please delete the imported copy, and %{contact_open_doors_link} with your AO3 account name, your account name on the imported archive, and the title and URL of the fanwork you would like the redirect to point to. (If you have multiple works you would like to change the redirects for, you can list these in one email.)
+ text: If you would like Open Doors to update the redirect to point to your pre-existing work, please delete the imported copy, and contact Open Doors at %{open_doors_link} with your AO3 account name, your account name on the imported archive, and the title and URL of the fanwork you would like the redirect to point to. (If you have multiple works you would like to change the redirects for, you can list these in one email.)
+ uploaded_list: 'The works uploaded include:'
+ work_info:
+ no_fandom: '- "%{work_title}" (%{work_url})'
+ with_fandom: '- "%{work_title}" (%{work_url}) (%{fandom})'
+ invite_increase_notification:
+ body:
+ html:
+ one: We just wanted to let you know that you have %{count} new invitation, which can be used to create a new account at the Archive. You can invite a friend at %{invitation_page_link}.
+ other: We just wanted to let you know that you have %{count} new invitations, which can be used to create new accounts at the Archive. You can invite a friend at %{invitation_page_link}.
+ text:
+ one: We just wanted to let you know that you have %{count} new invitation, which can be used to create a new account at the Archive. You can invite a friend at your invitations page (%{invitation_page_url}).
+ other: We just wanted to let you know that you have %{count} new invitations, which can be used to create new accounts at the Archive. You can invite a friend at your invitations page (%{invitation_page_url}).
+ invitation_page_link_text: your invitations page
+ subject: "[%{app_name}] New invitations"
+ invite_request_declined:
+ main:
+ one: We regret to inform you that your request for a new invitation cannot be fulfilled at this time.
+ other: We regret to inform you that your request for %{count} new invitations cannot be fulfilled at this time.
+ reason: 'Your request was:'
+ subject: "[%{app_name}] Additional invitation request declined"
+ invited_to_collection_notification:
+ approve:
+ collection_items_page: Collection Items page
+ html: To approve or reject this request, please visit your %{collection_items_page_link}.
+ text: 'To approve or reject this request, please visit your Collection Items page: %{collection_items_page_url}.'
+ subject: "[%{app_name}][%{collection_title}] Request to include work in a collection"
+ would_like_to_include:
+ html: The collection maintainers of %{collection_link} would like to include your work %{work_link} in their collection!
+ text: The collection maintainers of "%{collection_title}" (%{collection_url}) would like to include your work "%{work_title}" (%{work_url}) in their collection!
+ potential_match_generation_notification:
+ finished_generating:
+ html: We have finished generating potential assignments for your gift exchange %{collection_link}.
+ text: We have finished generating potential assignments for your gift exchange "%{collection_title}" (%{collection_url}).
+ matches_available:
+ html: The potential matches and set assignments are available on its %{matching_page_link}.
+ matching_page: Matching page
+ text: 'The potential matches and set assignments are available on its Matching page: %{matching_page_url}.'
+ subject: "[%{app_name}][%{collection_title}] Potential assignment generation complete"
+ recipient_notification:
+ collection:
+ html: A gift work has been posted for you in the %{collection_link} collection at Sunset!
+ text: A gift work has been posted for you in the "%{collection_title}" collection (%{collection_url}) at Sunset!
+ no_collection: A gift work has been posted for you at Sunset!
+ subject:
+ collection: "[%{app_name}][%{collection_title}] A gift work for you from %{collection_title}"
+ no_collection: "[%{app_name}] A gift work for you"
+ related_work_notification:
+ anonymous: an anonymous creator
+ approve:
+ html: Approve relationship and link back to this work from your work
+ text: 'Approve relationship and link back to this work from your work: %{related_work_url}'
+ can_come_back: "(You can come back to this page if you change your mind.)"
+ subject: "[%{app_name}] Related work notification"
+ work_listed:
+ html: Your work %{work_link} was listed as the inspiration of %{related_work_link} by %{work_creators}.
+ text: Your work "%{work_title}" (%{work_url}) was listed as the inspiration of "%{related_work_title}" (%{related_work_url}) by %{work_creators}.
+ signup_notification:
+ activate:
+ html: Please %{activate_account_link}.
+ text: 'Please follow this link to activate your account: %{activate_account_url}'
+ activate_your_account: follow this link to activate your account
+ admin_posts: Sunset News
+ bye: We hope you enjoy using the Archive.
+ contact_support: contact Support
+ faq: FAQ
+ features:
+ html: Once your account is up and running, you can post your fanworks, set up email subscriptions to let you know when your favorite creators or works have updated, set preferences to customize the way the site looks and works for you, keep track of the works you've accessed on the Archive via your history, and much more.
+ text: Once your account is up and running, you can post your fanworks, set up email subscriptions to let you know when your favorite creators or works have updated, set preferences to customize the way the site looks and works for you, keep track of the works you've accessed on the Archive via your history, and much more.
+ information:
+ html: There's lots of information and advice on how to use the Archive in our %{faq_link}. You'll find the latest news about site developments on %{admin_posts_link}. If you need more help, run into a bug, or have questions or comments, please %{contact_support_link}, who are always happy to help out.
+ text: 'There''s lots of information and advice on how to use the Archive in our FAQ at %{faq_url}. You''ll find the latest news about site developments on AO3 News at %{admin_posts_url}. If you need more help, run into a bug, or have questions or comments, please contact Support, who are always happy to help out: %{contact_support_url}.'
+ subject: "[%{app_name}] Activate your account"
+ welcome: Welcome to Sunset, %{login}!
+ users:
+ mailer:
+ confirmation_instructions:
+ confirm_by:
+ html: If you don't confirm your request by %{date}, the link in this email will expire and you will need to %{email_change_form_link} again.
+ text: 'If you don''t confirm your request by %{date}, the link in this email will expire and you will need to submit the email change form again: %{email_change_form_url}.'
+ confirm_email_change: confirm your email change
+ did_not_make_request: If you did not make this request, you can safely ignore and delete this email. Someone else may have entered your email address by mistake.
+ email_change_form: submit the email change form
+ made_request:
+ html:
+ one: If you made this request, please log in and %{confirm_email_change_link} within %{count} day.
+ other: If you made this request, please log in and %{confirm_email_change_link} within %{count} days.
+ text:
+ one: 'If you made this request, please log in and confirm your email change within %{count} day: %{confirm_email_change_url}.'
+ other: 'If you made this request, please log in and confirm your email change within %{count} days: %{confirm_email_change_url}.'
+ request_to_change_email_html: Someone has made a request to change the email address associated with
diff --git a/config/locales/models/en.yml b/config/locales/models/en.yml
new file mode 100755
index 0000000..5561de8
--- /dev/null
+++ b/config/locales/models/en.yml
@@ -0,0 +1,293 @@
+---
+en:
+ activerecord:
+ attributes:
+ admin/role:
+ board: Board
+ board_assistants_team: Board Assistants Team
+ communications: Communications
+ development_and_membership: Development & Membership
+ docs: AO3 Docs
+ elections: Elections
+ legal: Legal
+ open_doors: Open Doors
+ policy_and_abuse: Policy & Abuse
+ superadmin: Super admin
+ support: Support
+ tag_wrangling: Tag Wrangling
+ translation: Translation
+ challenge_signup/offers:
+ url: Offer URL
+ challenge_signup/requests:
+ url: Request URL
+ chapters/creatorships:
+ base: 'Invalid creator:'
+ pseud_id: Pseud
+ creatorships:
+ base: 'Invalid creator:'
+ pseud_id: Pseud
+ external_work:
+ author: Creator
+ user_defined_tags_count: Fandom, relationship, and character tags
+ gift_exchange:
+ offers_num_allowed: Number of offers allowed per sign-up
+ offers_num_required: Number of offers required per sign-up
+ requests_num_allowed: Number of requests allowed per sign-up
+ requests_num_required: Number of requests required per sign-up
+ language:
+ short: Abbreviation
+ meta_tagging:
+ meta_tag: Metatag
+ meta_tag_id: Metatag
+ sub_tag: Subtag
+ sub_tag_id: Subtag
+ offer:
+ url: URL
+ request:
+ url: URL
+ role:
+ archivist: Archivist
+ no_resets: No Resets
+ official: Official
+ opendoors: Open Doors
+ protected_user: Protected User
+ tag_wrangler: Tag Wrangler
+ translator: Translator
+ series/creatorships:
+ base: 'Invalid creator:'
+ pseud_id: Pseud
+ skin/skin_parents:
+ parent_skin: Parent skin
+ user:
+ login: Username
+ work:
+ chapter_total_display: Chapters
+ summary: Summary
+ user_defined_tags_count: Fandom, relationship, character, and additional tags
+ word_count: Words
+ work/chapters:
+ base: 'Invalid chapter:'
+ content: Content
+ work/creatorships:
+ base: 'Invalid creator:'
+ pseud_id: Pseud
+ work/parent_work_relationships/parent:
+ author: The author of a parent work outside the archive
+ title: The title of a parent work outside the archive
+ url: Parent work URL
+ errors:
+ messages:
+ forbidden: "%{value} is not allowed"
+ numeric_with_optional_hash: 'may begin with an # and otherwise contain only numbers.'
+ models:
+ abuse_report:
+ attributes:
+ url:
+ not_on_archive: does not appear to be on this site.
+ block:
+ attributes:
+ blocked:
+ blank: Sorry, we couldn't find a user matching that name.
+ limit: Sorry, you have blocked too many accounts.
+ official: Sorry, you can't block an official user.
+ self: Sorry, you can't block yourself.
+ blocked_id:
+ taken: You have already blocked that user.
+ format: "%{message}"
+ bookmark:
+ attributes:
+ pseud:
+ required: can't be blank
+ comment:
+ attributes:
+ comment_content:
+ duplicate_comment: "^You've already left this comment here. (It may not appear right away for performance reasons.)"
+ commentable:
+ format: "%{message}"
+ guest_replies_off: Sorry, this user doesn't allow non-Archive users to reply to their comments.
+ user:
+ blocked_comment: Sorry, you have been blocked by one or more of this work's creators.
+ blocked_reply: Sorry, you have been blocked by that user.
+ format: "%{message}"
+ spam: This comment looks like spam to our system, sorry! Please try again.
+ creatorship:
+ attributes:
+ pseud_id:
+ taken: is already listed as a creator.
+ external_work:
+ attributes:
+ user_defined_tags_count:
+ at_most: must not add up to more than %{count}. You have entered %{value} of these tags, so you must remove %{diff} of them.
+ invitation:
+ attributes:
+ base:
+ format: "%{message}"
+ notification_could_not_be_sent: 'Notification email could not be sent: %{error}'
+ invite_request:
+ attributes:
+ email:
+ blocked_email: has been blocked at the owner's request. That means it can't be used for invitations. Please check the address to make sure it's yours to use and contact Sunset Support if you have any questions.
+ email_in_use: is already being used by an account holder.
+ kudo:
+ attributes:
+ commentable:
+ author_on_own_work: You can't leave kudos on your own work.
+ blank: What did you want to leave kudos on?
+ guest_on_restricted: You can't leave guest kudos on a restricted work.
+ user_is_banned: You cannot leave kudos while your account is banned.
+ user_is_suspended: You cannot leave kudos while your account is suspended.
+ commentable_type:
+ inclusion: What did you want to leave kudos on?
+ user:
+ archivist: Please log out of your archivist account!
+ blocked: Sorry, you have been blocked by one or more of this work's creators.
+ official: Please log out of your official account!
+ format: "%{message}"
+ taken: You have already left kudos here. :)
+ mute:
+ attributes:
+ muted:
+ blank: Sorry, we couldn't find a user matching that name.
+ limit: Sorry, you have muted too many accounts.
+ official: Sorry, you can't mute an official user.
+ self: Sorry, you can't mute yourself.
+ muted_id:
+ taken: You have already muted that user.
+ format: "%{message}"
+ prompt:
+ tags_not_in_fandom: "^These %{tag_label} tags in your %{prompt_type} are not in the selected fandom(s), %{fandom}: %{taglist} (Your moderator may be able to fix this.)"
+ related_work:
+ attributes:
+ parent:
+ blank: The work you listed as an inspiration does not seem to exist.
+ not_work: Only a link to a work can be listed as an inspiration.
+ protected: You can't use the related works function to cite works by the protected user %{login}.
+ format: "%{message}"
+ skin:
+ archive_in_title: Sorry, titles including the word 'Archive' are reserved for official skins.
+ attributes:
+ title:
+ taken: must be unique
+ banned_property: We don't currently allow the CSS property %{property} -- please notify Support if you think this is an error.
+ banned_value_for_property: "%{property} in %{selectors} cannot have the value %{value}, sorry!"
+ font_face: We don't allow the @font-face feature.
+ invalid_custom_property_name: The %{property} custom property in %{selectors} has an invalid name. Names can contain lowercase letters (a-z) in the English alphabet, numerals zero to nine (0-9), dashes (-), and underscores (_).
+ invalid_media: We don't currently support the media type %{media}, sorry! If we should, please let Support know.
+ no_public_preview: You need to upload a screencap if you want to share your skin.
+ no_rules_for_selectors: There don't seem to be any rules for %{selectors}.
+ no_valid_css: We couldn't find any valid CSS rules in that code.
+ no_valid_css_for_selectors: The code for %{selectors} doesn't seem to be a valid CSS rule.
+ work_skin_banned_value_for_property: The %{property} property in %{selectors} cannot have the value %{value} in work skins, sorry!
+ work_skin_custom_properties: Custom properties are not allowed in work skins.
+ work_skin_var: The var() function is not allowed in work skins.
+ skin/skin_parents:
+ attributes:
+ base:
+ format: "%{message}"
+ skin_parent:
+ attributes:
+ base:
+ format: "%{message}"
+ site_parent: You can't use %{title} as a parent unless replacing the default archive skin.
+ subscription:
+ attributes:
+ subscribable:
+ blank: The item you tried to subscribe to does not exist. It may have been deleted.
+ format: "%{message}"
+ user:
+ attributes:
+ age_over_13:
+ accepted: Sorry, you have to be over 18!
+ format: "%{message}"
+ data_processing:
+ accepted: Sorry, you need to consent to the processing of your personal data in order to sign up.
+ format: "%{message}"
+ email:
+ taken: "^This email is already associated with another account. Please try again with a different email address."
+ login:
+ admin_must_use_default: must use the default. Please contact your chairs to use something else.
+ changed_too_recently:
+ one: can only be changed once per day. You last changed your username on %{renamed_at}.
+ other: can only be changed once every %{count} days. You last changed your username on %{renamed_at}.
+ invalid: must be %{min_login} to %{max_login} characters (A-Z, a-z, _, 0-9 only), no spaces, cannot begin or end with underscore (_).
+ password_confirmation:
+ confirmation: doesn't match new password.
+ terms_of_service:
+ accepted: Sorry, you need to accept the Terms of Service in order to sign up.
+ format: "%{message}"
+ work:
+ attributes:
+ user_defined_tags_count:
+ at_most: must not add up to more than %{count}. Your work has %{value} of these tags, so you must remove %{diff} of them.
+ blocked_gifts: "%{byline} does not accept gifts."
+ blocked_your_gifts: "%{byline} does not accept gifts from you."
+ work/parent_work_relationships:
+ format: "%{message}"
+ models:
+ admin_blacklisted_email:
+ one: Banned Email
+ other: Banned Emails
+ archive_warning:
+ one: Warning
+ other: Warnings
+ bookmark:
+ one: Bookmark
+ other: Bookmarks
+ category:
+ one: Category
+ other: Categories
+ chapter:
+ one: Chapter
+ other: Chapters
+ character:
+ one: Character
+ other: Characters
+ comment: Comment
+ fandom:
+ one: Fandom
+ other: Fandoms
+ freeform:
+ one: Additional Tag
+ other: Additional Tags
+ pseud: Pseud
+ rating:
+ one: Rating
+ other: Ratings
+ related_work:
+ one: Related Work
+ other: Related Works
+ relationship:
+ one: Relationship
+ other: Relationships
+ series:
+ one: Series
+ other: Series
+ tag:
+ one: Tag
+ other: Tags
+ work:
+ one: Work
+ other: Works
+ attributes:
+ ticket_number: Ticket ID
+ challenge_assignment:
+ offer_byline:
+ none: "- none -"
+ pinch_hitter: "%{pinch_hitter_byline}* (pinch hitter)"
+ request_byline:
+ none: "- None -"
+ errors:
+ attributes:
+ icon:
+ invalid_format: content type is invalid
+ too_large: file size must be less than %{maximum_size}
+ ticket_number:
+ closed_ticket: must not be closed.
+ invalid_department: must be in your department.
+ required: must exist and not be spam.
+ url:
+ invalid: does not appear to be a valid URL.
+ story_parser:
+ on_archive: URL is for a work on the Archive. Please bookmark it directly instead.
+ subscriptions:
+ deleted: Deleted item
diff --git a/config/locales/phrase-exports/README.md b/config/locales/phrase-exports/README.md
new file mode 100755
index 0000000..b7427dc
--- /dev/null
+++ b/config/locales/phrase-exports/README.md
@@ -0,0 +1 @@
+These files are automatically generated by Phrase and should not be edited.
diff --git a/config/locales/phrase-exports/af.yml b/config/locales/phrase-exports/af.yml
new file mode 100755
index 0000000..7084d26
--- /dev/null
+++ b/config/locales/phrase-exports/af.yml
@@ -0,0 +1,6 @@
+---
+af:
+ mailer:
+ general:
+ creation:
+ title_with_chapter_number: Hoofstuk %{position} van %{title}
diff --git a/config/locales/phrase-exports/ar.yml b/config/locales/phrase-exports/ar.yml
new file mode 100755
index 0000000..9c6628e
--- /dev/null
+++ b/config/locales/phrase-exports/ar.yml
@@ -0,0 +1,547 @@
+---
+ar:
+ activerecord:
+ attributes:
+ archive_warning:
+ name_with_colon:
+ few: 'التحذيرات:'
+ many: 'التحذيرات:'
+ one: 'التحذير:'
+ other: 'التحذيرات:'
+ two: 'التحذيران:'
+ category:
+ name_with_colon:
+ few: 'الفئات:'
+ many: 'الفئات:'
+ one: 'الفئة:'
+ other: 'الفئات:'
+ two: 'الفئتان:'
+ character:
+ name_with_colon:
+ few: 'الشخصيات:'
+ many: 'الشخصيات:'
+ one: 'الشخصية:'
+ other: 'الشخصيات:'
+ two: 'الشخصيتان:'
+ fandom:
+ name_with_colon:
+ few: 'الفاندومات:'
+ many: 'الفاندومات:'
+ one: 'الفاندوم:'
+ other: 'الفاندومات:'
+ two: 'الفاندومان:'
+ freeform:
+ name_with_colon:
+ few: 'وسوم إضافية:'
+ many: 'وسوم إضافية:'
+ one: 'وسم إضافي:'
+ other: 'وسوم إضافية:'
+ two: 'وسمان إضافيان:'
+ rating:
+ name_with_colon: 'التصنيف:'
+ relationship:
+ name_with_colon:
+ few: 'العلاقات:'
+ many: 'العلاقات:'
+ one: 'العلاقة:'
+ other: 'العلاقات:'
+ two: 'العلاقتان:'
+ work:
+ summary: المُلخص
+ models:
+ archive_warning:
+ few: التحذيرات
+ many: التحذيرات
+ one: التحذير
+ other: التحذيرات
+ two: التحذيران
+ category:
+ few: الفئات
+ many: الفئات
+ one: الفئة
+ other: الفئات
+ two: الفئتان
+ chapter:
+ few: الفصول
+ many: الفصول
+ one: الفصل
+ other: الفصول
+ two: الفصلان
+ character:
+ few: الشخصيات
+ many: الشخصيات
+ one: الشخصية
+ other: الشخصيات
+ two: الشخصيتان
+ fandom:
+ few: الفاندومات
+ many: الفاندومات
+ one: الفاندوم
+ other: الفاندومات
+ two: الفاندومان
+ freeform:
+ few: وسوم إضافية
+ many: وسوم إضافية
+ one: وسم إضافي
+ other: وسوم إضافية
+ two: وسمان إضافيان
+ rating:
+ few: التصنيفات
+ many: التصنيفات
+ one: التصنيف
+ other: التصنيفات
+ two: التصنيفان
+ relationship:
+ few: العلاقات
+ many: العلاقات
+ one: العلاقة
+ other: العلاقات
+ two: العلاقتان
+ series:
+ few: السلاسل
+ many: السلاسل
+ one: السلسلة
+ other: السلاسل
+ two: السلسلتان
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ few: "%{count} ضيوف"
+ many: "%{count} ضيفاً"
+ one: ضيف
+ other: "%{count} ضيف"
+ two: ضيفين
+ left_kudos:
+ html:
+ few: حصلت على علامات امتياز للعمل %{commentable_link} من %{givers_list}.
+ many: حصلت على علامات امتياز للعمل %{commentable_link} من %{givers_list}.
+ one: حصلت على علامة امتياز للعمل %{commentable_link} من %{givers_list}.
+ other: حصلت على علامات امتياز للعمل %{commentable_link} من %{givers_list}.
+ two: حصلت على علامتي امتياز للعمل %{commentable_link} من %{givers_list}.
+ text:
+ few: حصلت على علامات امتياز للعمل %{commentable_title} (%{commentable_url})
+ من %{givers_list}.
+ many: حصلت على علامات امتياز للعمل %{commentable_title} (%{commentable_url})
+ من %{givers_list}.
+ one: حصلت على علامة امتياز للعمل %{commentable_title} (%{commentable_url})
+ من %{givers_list}.
+ other: حصلت على علامات امتياز للعمل %{commentable_title} (%{commentable_url})
+ من %{givers_list}.
+ two: حصلت على علامتي امتياز للعمل %{commentable_title} (%{commentable_url})
+ من %{givers_list}.
+ single_guest:
+ giver: ضيف
+ html: حصلت على علامة امتياز للعمل %{commentable_link} من %{giver}.
+ text: حصلت على علامة امتياز للعمل %{commentable_title} (%{commentable_url})
+ من ضيف.
+ subject: "[%{app_name}] حصلت على علامة امتياز!"
+ mailer:
+ general:
+ closing:
+ formal: بكل إخلاص وتقدير،
+ informal: مع التحية،
+ creation:
+ link_with_word_count: "%{creation_link} (%{word_count})"
+ title_with_chapter_number: الفصل %{position} من %{title}
+ title_with_word_count: '"%{creation_title}" (%{word_count})'
+ word_count:
+ few: "%{count} كلمات"
+ many: "%{count} كلمة"
+ one: كلمة
+ other: "%{count} كلمة"
+ two: كلمتان
+ footer:
+ general:
+ about:
+ html: AO3 هو أرشيف يدار من قبل المعجبين ويدعم من قبلهم أيضاً ويعتمد استمراره
+ على %{donate_link}.
+ text: 'AO3 هو أرشيف يدار من قبل المعجبين ويدعم من قبلهم أيضاً ويعتمد استمراره
+ على تبرعاتكم: %{donate_url}.'
+ html:
+ donate_link_text: تبرعاتك
+ support_link_text: تواصل مع لجنة الدعم
+ unwanted_email:
+ html: في حال تلقيك هذه الرسالة بالخطأ، رجاءاً %{support_link}.
+ text: في حال تلقيك هذه الرسالة بالخطأ، رجاءاً تواصل مع لجنة الدعم على
+ الرابط %{support_url}.
+ sent_at: أرسل في %{sent_at}.
+ greeting:
+ informal:
+ unaddressed: مرحباً!
+ introductory: مرحباً من Archive of Our Own – AO3 (الأرشيف من جانبنا)
+ metadata_label_indicator: ": "
+ signature:
+ app_short_name: AO3
+ open_doors: فريق Open Doors (الأبواب المفتوحة)
+ parent_org: Organization for Transformative Works – OTW (منظمة الأعمال التحويلية)
+ support: فريق لجنة الدعم في AO3
+ users:
+ mailer:
+ reset_password_instructions:
+ expiration: إذا ذا لم تستخدم الرابط الملحق لإعادة تعيين كلمة السر الخاصة بك
+ خلال أسبوع واحد، فستنتهي صلاحيته، وستحتاج إلى طلب رابط جديد.
+ intro: 'طلب أحدهم إعادة تعيين كلمة سر حسابك. يمكنك إعادة تعيين كلمة السر لحسابك
+ باتّباع الرابط التالي وإدخال كلمة السر الجديدة:'
+ link_title: إعادة تعيين كلمة السر الخاصة بي.
+ subject: إعادة تعيين كلمة السر الخاصة بك [%{app_name}]
+ unrequested: إذا لم ترسل هذا الطلب لإعادة تعيين كلمة السر، فيمكنك تجاهل هذا
+ البريد الإلكتروني والاستمرار باستعمال كلمة السر القديمة.
+ user_mailer:
+ admin_deleted_work_notification:
+ bye: تم إلحاق نسخة من عملك كَمَرجِع.
+ contact_abuse: التواصل مع لجنة السياسات والتعسفات
+ deleted:
+ html: تم حذف عملك %{title} من الأرشيف من قبل أحد المشرفين.
+ text: تم حذف عملك "%{title}" من الأرشيف من قبل أحد المشرفين.
+ html:
+ tos_violation: في حال انتهك عملك أحد شروط الخدمة للأرشيف، نرجو منك %{contact_abuse_link}.
+ import_project:
+ html: في حال كان عملك جزءاً من أحد مشاريع الاستيراد التي يديرها فريق الأبواب
+ المفتوحة، نرجو منك %{opendoors_link} وطرح كافة تساؤلاتك.
+ text: في حال كان عملك جزءاً من أحد مشاريع الاستيراد التي يديرها فريق الأبواب
+ المفتوحة، نرجو منك التواصل مع لجنة الأبواب المفتوحة (%{opendoors_link})
+ وطرح كافة تساؤلاتك.
+ opendoors: التواصل مع لجنة الأبواب المفتوحة
+ subject: "[%{app_name}] تم حذف عملك من قبل أحد المشرفين"
+ text:
+ tos_violation: في حال انتهك عملك أحد شروط الخدمة للأرشيف، نرجو منك التواصل
+ مع لجنة السياسات والتعسفات (%{contact_abuse_url}).
+ admin_hidden_work_notification:
+ access: أثناء بقاء عملك مخفياً، ستظل قادرًا على الوصول إليه من خلال الرابط المتاح
+ أعلاه، لكنه لن يتم إدراجه في صفحة اعمالك، ولن يكون متاحًا لمستخدمي AO3 الآخرين.
+ check_email: يرجى التحقق من بريدك الإلكتروني، بما في ذلك مجلد البريد العشوائي،
+ حيث قد يكون فريق السياسات والتعسفات اتصل بك ليشرح لك سبب إخفاء عملك.
+ contact_abuse: تواصل مع لجنة السياسات والتعسفات
+ html:
+ help: إذا لم تكن متأكدًا من سبب إخفاء عملك، ولم تتلق مزيدًا من المعلومات بخصوص
+ هذا الأمر، رجاءً %{contact_abuse_link} مباشرةً.
+ hidden: عملك %{title} تم اخفائه من قبل فريق السياسات والتعسفات ولم يعد متاحاً
+ إلى العامة.
+ tos_violation: إذا تم إخفاء عملك بسبب انتهاك %{tos_link} الخاصة بـ AO3، فستتم
+ مطالبتك باتخاذ إجراء لتصحيح الانتهاك. قد يؤدي عدم تعديل عملك ليتوافق مع
+ شروط الخدمة إلى حذف عملك من AO3.
+ subject: "[%{app_name}] تم إخفاء عملك من قبل فريق السياسات والتعسفات"
+ text:
+ help: إذا لم تكن متأكدًا من سبب إخفاء عملك، ولم تتلق مزيدًا من المعلومات بخصوص
+ هذا الأمر، رجاءً %{contact_abuse_url} مباشرةً.
+ hidden: عملك "%{title}" (%{work_url}) تم اخفائه من قبل فريق السياسات والتعسفات
+ ولم يعد متاحاً إلى العامة.
+ tos_violation: إذا تم إخفاء عملك بسبب انتهاك %{tos_url} الخاصة بـ AO3، فستتم
+ مطالبتك باتخاذ إجراء لتصحيح الانتهاك. قد يؤدي عدم تعديل عملك ليتوافق مع
+ شروط الخدمة إلى حذف عملك من AO3.
+ tos: شروط الخدمة
+ anonymous_or_unrevealed_notification:
+ anonymous_info: يتم تضمين الأعمال المجهولة المصدر في قوائم الوسوم، ولكن ليس
+ في صفحة الأعمال الخاصة بك. في العمل، سيتم استبدال اسم المستخدم الخاص بك بكلمة
+ "Anonymous" (مجهول المصدر).
+ anonymous_unrevealed_info: قد يكشف مشرفو المجموعة لاحقًا عن عملك لكن سيتركونه
+ مجهول المصدر. لن يُنبه الأشخاص الذين يشتركون في صفحتك بهذا التغيير. بمجرد
+ الكشف عنه، سيُضمن عملك في قوائم الوسوم، ولكن ليس في صفحة عملك. في العمل، سيتم
+ استبدال اسم المستخدم الخاص بك بكلمة "Anonymous" (مجهول).
+ changed_status:
+ anonymous:
+ html: لقد غير مشرفو المجموعة %{collection_link} حالة عملك %{work_link} إلى
+ مجهول المصدر.
+ text: لقد غير مشرفو المجموعة "%{collection_title}" (%{collection_url}) حالة
+ عملك "%{work_title}" (%{work_url}) إلى مجهول المصدر.
+ anonymous_unrevealed:
+ html: لقد غير مشرفو المجموعة %{collection_link} حالة عملك %{work_link} إلى
+ مجهول المصدر وغير مكشوف.
+ text: لقد غير مشرفو المجموعة "%{collection_title}" (%{collection_url}) حالة
+ عملك "%{work_title}" (%{work_url}) إلى مجهول المصدر وغير مكشوف.
+ unrevealed:
+ html: لقد غير مشرفو المجموعة %{collection_link} حالة عملك %{work_link} إلى
+ غير مكشوف.
+ text: لقد غير مشرفو المجموعة "%{collection_title}" (%{collection_url}) حالة
+ عملك "%{work_title}" (%{work_url}) إلى غير مكشوف.
+ collection_items_link_text: صفحة Approved Collection Items (عناصر المجموعة المعتمدة)
+ do_not_want:
+ anonymous:
+ html: إذا كنت لا تريد أن يكون عملك مجهول المصدر، يرجى زيارة %{collection_items_link}
+ لإزالته من هذه المجموعة.
+ text: 'إذا كنت لا تريد أن يكون عملك مجهول المصدر، يرجى زيارة صفحة Approved
+ Collection Items (عناصر المجموعة المعتمدة) لإزالته من هذه المجموعة: %{collection_items_url}'
+ anonymous_unrevealed:
+ html: إذا كنت لا تريد أن يكون عملك مجهول المصدر وغير مكشوف، يرجى زيارة %{collection_items_link}
+ لإزالته من هذه المجموعة.
+ text: 'إذا كنت لا تريد أن يكون عملك مجهول المصدر وغير مكشوفًا، يرجى زيارة
+ صفحة Approved Collection Items (عناصر المجموعة المعتمدة) لإزالته من هذه
+ المجموعة: %{collection_items_url}'
+ unrevealed:
+ html: إذا كنت لا تريد أن يكون عملك غير مكشوفًا، يرجى زيارة %{collection_items_link}
+ لإزالته من هذه المجموعة.
+ text: 'إذا كنت لا تريد أن يكون عملك غير مكشوفًا، يرجى زيارة صفحة Approved
+ Collection Items (عناصر المجموعة المعتمدة) لإزالته من هذه المجموعة: %{collection_items_url}'
+ faq_link_text: الأسئلة المتداولة حول المجموعات
+ more_info:
+ html: للمزيد من المعلومات، تفضل بزيارة %{faq_link}.
+ text: 'للمزيد من المعلومات، تفضل بزيارة الأسئلة المتداولة حول المجموعات: %{faq_url}'
+ subject:
+ anonymous: "[%{app_name}] أصبح عملك مجهول المصدر"
+ anonymous_unrevealed: "[%{app_name}] أصبح عملك مجهول المصدر وغير مكشوف"
+ unrevealed: "[%{app_name}] أصبح عملك غير مكشوف"
+ unrevealed_info: لا يتم تضمين الأعمال غير المكشوفة في قوائم الوسوم أو في صفحة
+ الأعمال الخاصة بك. سيتلقى أي شخص يزور رابطًا للعمل إشعارًا بأنه لم يتم الكشف
+ عنه حاليًا، ولن يتمكن من الوصول إلى محتواه.
+ challenge_assignment_notification:
+ any: أي من الموجود
+ assignment:
+ html: تم تعيينك للطلب الآتي في تحدي %{link} على AO3!
+ description: 'الوصف:'
+ due: 'موعد تسليم المهمة:'
+ html:
+ footer: إنك تتلقى هذا البريد لانك اشتركت في تحدي %{title}. للمزيد من المعلومات
+ حول هذا التحدي ولمعلومات التواصل مع المشرفين، قم بزيارة %{footer_link}.
+ footer_link: صفحة التحدي الرئيسية
+ look_up: يمكنك الوصول إلى مهمتك من خلال %{link}.
+ look_up_link: صفحة Assignments (المهمات) الخاصة بك
+ optional_tags: 'وسوم اختيارية:'
+ prompts: 'المحفزات:'
+ prompt_url: 'رابط المحفز:'
+ recipient: 'المتلقي:'
+ recipient_missing: 'لا يوجد: تواصل مع أحد المشرفين للحصول على المساعدة!'
+ subject: "[%{app_name}][%{collection_title}] مهمّتك!"
+ text:
+ assignment: تم تعيينك للطلب الآتي في تحدي %{collection_title} (%{collection_url})
+ على الأرشيف من جانبنا (AO3)!
+ footer: إنك تتلقى هذا البريد لأنك اشتركت في تحدي %{title} (%{url}). للمزيد
+ من المعلومات حول هذا التحدي ولمعلومات التواصل مع المشرفين، قم بزيارة %{profile_url}.
+ look_up: يمكنك الوصول إلى مهمتك من خلال صفحة Assignments (المهمات) الخاصة
+ بك على %{link}.
+ change_email:
+ changed:
+ html: "%{login}، تم تغيير البريد الالكتروني المرتبط بحسابك إلى %{email}"
+ text: "%{login}، تم تغيير البريد الالكتروني المرتبط بحسابك إلى %{email}"
+ subject: "[%{app_name}] تم تغيير البريد الالكتروني"
+ collection_notification:
+ assignments_sent:
+ complete: تم ارسال جميع المهام.
+ subject: تم ارسال المهام
+ html:
+ received_message: 'تلقيت رسالة بخصوص مجموعتك %{collection_link}:'
+ text:
+ received_message: 'تلقيت رسالة بخصوص مجموعتك "%{collection_title}"(%{collection_url}):'
+ creatorship_notification:
+ explanation: عندما تكون منتجاً مشاركاً لعمل ما، يمكن إضافتك إلى فصول جديدة بغض
+ النظر عن إعداداتك كمنتج مشارك. كما ستتم إضافتك إلى أية سلسلة يضاف العمل إليها.
+ html:
+ creation: "%{creation_link} بقلم %{pseud_links}"
+ edit_chapter: تحرير الفصل
+ edit_series: تحرير السلسلة
+ remove_chapter: إذا تمت إضافتك بالخطأ أو إن لم تكن ترغب في تسجيل اسمك كمنتج،
+ يمكنك %{edit_chapter_link} لإزالة نفسك من قائمة المنتجين.
+ remove_series: إذا تمت إضافتك بالخطأ أو إن لم تكن ترغب في تسجيل اسمك كمنتج،
+ يمكنك %{edit_series_link} لإزالة نفسك من قائمة المنتجين.
+ intro_chapter: 'أضاف المستخدم %{adding_user} اسمك المستعار %{pseud} إلى قائمة
+ المنتجين المشاركين للفصل التالي:'
+ intro_series: 'أضاف المستخدم %{adding_user} اسمك المستعار %{pseud} إلى قائمة
+ المنتجين المشاركين للسلسلة التالية:'
+ subject: "[%{app_name}] إشعار منتج مشارك"
+ text:
+ creation: "%{title} (%{url}) بقلم %{pseuds}"
+ remove_chapter: 'إذا تمت إضافتك بالخطأ أو إن لم تكن ترغب في تسجيل اسمك كمنتج،
+ يمكنك تحرير الفصل لإزالة نفسك من قائمة المنتجين: %{url}'
+ remove_series: 'إذا تمت إضافتك بالخطأ أو إن لم تكن ترغب في تسجيل اسمك كمنتج،
+ يمكنك تحرير السلسلة لإزالة نفسك من قائمة المنتجين: %{url}'
+ creatorship_notification_archivist:
+ explanation: بما أنه يعمل بصفته الرسمية كمؤرشف لمشروع الأبواب المفتوحة، يمكنه
+ اضافتك دون دعوة، حتى إن عطّلت خاصية المنتج المشارك.
+ html:
+ creation: "%{creation_link} من إنتاج %{pseud_links}"
+ edit_chapter: تحرير الفصل
+ edit_series: تحرير السلسلة
+ edit_work: تحرير العمل
+ remove_chapter: إن تمت إضافتك بالخطأ، أو إن لم ترغب في إدراج اسمك المستعار
+ كمنتج، باستطاعتك %{edit_chapter_link} لإزالة نفسك من قائمة المنتجين.
+ remove_series: إن تمت إضافتك بالخطأ، أو إن لم ترغب في إدراج اسمك المستعار
+ كمنتج، باستطاعتك %{edit_series_link} لإزالة نفسك من قائمة المنتجين.
+ remove_work: إن تمت إضافتك بالخطأ، أو إن لم ترغب في إدراج اسمك المستعار كمنتج،
+ باستطاعتك %{edit_work_link} لإزالة نفسك من قائمة المنتجين.
+ intro_chapter: 'أضاف المستخدم %{archivist} اسمك المستعار %{pseud} كمنتج مشارك
+ للفصل التالي:'
+ intro_series: 'أضاف المستخدم %{archivist} اسمك المستعار %{pseud} كمنتج مشارك
+ للسلسلة التالية:'
+ intro_work: 'أضاف المستخدم %{archivist} اسمك المستعار %{pseud} كمنتج مشارك للعمل
+ التالي:'
+ subject: "[%{app_name}] إشعار منتج مشارك من أمين الأرشيف المستورد"
+ text:
+ creation: "%{title} (%{url}) من إنتاج %{pseuds}"
+ remove_chapter: 'إن تمت إضافتك بالخطأ، أو إن لم ترغب في إدراج اسمك المستعار
+ كمنتج، باستطاعتك تحرير الفصل لإزالة نفسك من قائمة المنتجين: %{url}'
+ remove_series: 'إن تمت إضافتك بالخطأ، أو إن لم ترغب في إدراج اسمك المستعار
+ كمنتج، باستطاعتك تحرير السلسلة لإزالة نفسك من قائمة المنتجين: %{url}'
+ remove_work: 'إن تمت إضافتك بالخطأ، أو إن لم ترغب في إدراج اسمك المستعار كمنتج،
+ باستطاعتك تحرير العمل لإزالة نفسك من قائمة المنتجين: %{url}'
+ creatorship_request:
+ html:
+ creation: "%{creation_link} من قبل %{pseud_links}"
+ instructions: يمكنك قبول هذا الطلب أو رفضه في صفحة %{page_name} الخاصة بك.
+ page_name: Co-Creator Requests (طلبات المنتج المشارك)
+ intro_chapter: 'قام المستخدم %{inviting_user} بدعوة اسمك المستعار %{pseud} لكي
+ يضاف كمنتج مشارك على الفصل التالي:'
+ intro_series: 'قام المستخدم %{inviting_user} بدعوة اسمك المستعار %{pseud} لكي
+ يضاف كمنتج مشارك على السلسلة التالية:'
+ intro_work: 'قام المستخدم %{inviting_user} بدعوة اسمك المستعار %{pseud} لكي
+ يضاف كمنتج مشارك على العمل التالي:'
+ subject: "[%{app_name}] طلب منتج مشارك"
+ text:
+ creation: "%{title} (%{url}) من قبل %{pseuds}"
+ instructions: 'يمكنك قبول هذا الطلب أو رفضه في صفحة Co-Creator Requests (طلبات
+ المنتج المشارك) الخاصة بك: %{url}'
+ delete_work_notification:
+ attachment: تم إرفاق نسخة من عملك كمرجِع.
+ deleted_other:
+ html: تم حذف عملك %{title} بناءاً على طلب %{pseud}.
+ text: تم حذف عملك "%{title}" بناءاً على طلب %{pseud}.
+ deleted_yourself:
+ html: تم حذف عملك %{title} بناءاً على طلبك.
+ text: تم حذف عملك "%{title}" بناءاً على طلبك.
+ questions:
+ html: إن كان لديك أيّة تساؤلات، نرجو منك %{support}.
+ text: إن كان لديك أيّة تساؤلات، نرجو منك %{support} (%{url}).
+ subject: "[%{app_name}] تم حذف عملك"
+ support: التواصل مع لجنة الدعم
+ invitation_to_claim:
+ access:
+ text: إعتماداً على الأرشيف المستورد، أعمالك قد تكون حملت بشكل محصور بالمستخدمين
+ المسجلين فقط (لكي لا تظهر على بحث قوقل). في هذه الحالة، ستكون الأعمال متاحة
+ فقط للمستخدمين المسجلين إلا إذا أردت جعلها مرئية للجميع. للمساعدة في فتح
+ أعمالك، الإستغناء عنها أو حذفها، رجاءً تواصل مع فريق دعم AO3.
+ claim_or_remove:
+ html: املك أو احذف أعمالك هنا.
+ text: 'املك أو احذف أعمالك هنا: %{claim_url}'
+ email_tips: إذا تواصلت معنا، رجاءً اضف عناوين البريد الإلكتروني من @transformativeworks.org
+ إلى اللائحة المسموحة وتفقد ردنا ضمن ملف الرسائل غير المرغوب بها.
+ html:
+ ao3_news: أخبار AO3
+ contact_open_doors: تواصل مع لجنة الأبواب المفتوحة
+ contact_support: تواصل مع فريق دعم AO3
+ faq_page: صفحة الأسئلة المتداولة
+ tutorial_page: صفحة الدليل التوجيهي
+ introduction:
+ text: إنك تستلم هذا البريد الالكتروني بسبب أرشيف تم استيراده مؤخراً من قبل
+ Open Doors (مشروع الأبواب المفتوحة) (%{open_doors_link}) إلى %{app_name}
+ (%{app_short_name} - %{app_url})، ونحن نعتقد أن أعمال المعجبين التالية هي
+ من أعمالك. نود أن نمنحك الفرصة لامتلاك (أو حذف/الإستغناء عن) هذه الأعمال
+ إذا أردت. وإذا كنت لا تمتلك حساباً تحت بريد إلكتروني آخر، ندعوك للانضمام
+ لـ AO3!
+ mistake:
+ text: إذا كان هناك خطأ وهذه ليست أعمالك، رجاءً لا تقم بحذفها! رجاءً فقط تواصل
+ مع لجنة الأبواب المفتوحة (%{open_doors_link}) وسنصلح الأمر.
+ more_info:
+ text: تستطيع قراءة الإعلانات عن أحدث الاستيرادات إلى الأرشيف على أخبار AO3
+ (%{news_link})، وتستطيع العثور على معلومات إضافية عن الأبواب المفتوحة من
+ خلال صفحة الأسئلة المتداولة (%{open_doors_faq_link}) أو صفحة الدليل التوجيهي
+ (%{open_doors_tutorial_link}). لأي أسئلة لم يتم الإجابة عنها في الاسئلة
+ المتداولة، الدليل التوجيهي أو هذا البريد الإلكتروني، رجاءً تواصل مع فريق
+ دعم AO3 على %{support_link}.
+ other_works:
+ text: إذا كانت لديك أعمال أخرى على الأرشيف المستورد تحت عنوان بريد إلكتروني
+ لم يعد بإمكانك أن تصله، رجاءً تواصل مع لجنة الأبواب المفتوحة بأي معلومات
+ يمكنها أن تساعد في التحقق من هويتك.
+ questions:
+ text: لإستفسرات أخرى، رجاءً تواصل مع فريق دعم AO3 على %{support_link}.
+ redirects: للحفاظ على الإشارات المرجعية ولوائح الأعمال الموصى بها، قد تعيد عناوين
+ الويب للأرشيف الذي تم استيراده التوجيه إلى النسخة المستوردة من هذه الأعمال
+ لفترة محدودة (تفقد منشور الإعلان لأرشيفك للتأكد). إذا حملت مسبقاً نسخة من
+ هذه الأعمال ولم تقم بإستخدام ميزة الإستيراد من عنوان الويب، سيكون هناك نسختان
+ من العمل نفسه على الأرشيف.
+ subject: "[%{app_name}] دعوة لامتلاك الأعمال"
+ unwanted:
+ text: إذا كانت هذه الأعمال حقاً ملكك، ولكنك لا تريدها، يمكنك الإستغناء عنها
+ (لكي تبقى على AO3، ولكن مع إزالة إسمك عنها) أو حذفها (لكي تحذف تماماً من
+ AO3). لا تحتاج إلى إضافة هذه الأعمال إلى أي حساب من أجل أن تستغني عنها أو
+ تحذفها. تستطيع القيام بهذا مباشرةً من الرابط أعلاه. (لطلب المساعدة، رجاءً
+ تواصل مع فريق دعم AO3 على %{support_link}.)
+ update_redirect:
+ text: إذا أردت من الأبواب المفتوحة أن تحدِّث إعادة التوجيه ليذهب إلى عملك
+ الموجود مسبقاً، رجاءً احذف النسخة المستوردة وتواصل مع لجنة الأبواب المفتوحة
+ على %{open_doors_link} بإستخدام إسم حسابك على AO3، وإسم حسابك على الأرشيف
+ المستورد، وعنوان العمل الذي تريد إعادة التوجيه إليه بالإضافة إلى عنوان الويب
+ (URL) التابع لهذا العمل. (إذا كانت لديك أعمال متعددة تود أن تغير إعادة التوجيه
+ لها، يمكنك إدراجها كلها في بريد إلكتروني واحد.)
+ uploaded_list: 'الأعمال المحمّلة تشمل:'
+ invite_increase_notification:
+ html:
+ body:
+ few: نود إعلامك بأنه لديك %{count} دعوات جديدة يمكن استخدامها لإنشاء حسابات
+ جديدة في AO3. بإمكانك دعوة صديق من خلال %{invitation_page_link}.
+ many: نود إعلامك بأنه لديك %{count} دعوة جديدة يمكن استخدامها لإنشاء حسابات
+ جديدة في AO3. بإمكانك دعوة صديق من خلال %{invitation_page_link}.
+ one: نود إعلامك بأنه لديك دعوة جديدة يمكن استخدامها لإنشاء حساب جديد في
+ AO3. بإمكانك دعوة صديق من خلال %{invitation_page_link}.
+ other: نود إعلامك بأنه لديك %{count} دعوة جديدة يمكن استخدامها لإنشاء حسابات
+ جديدة في AO3. بإمكانك دعوة صديق من خلال %{invitation_page_link}.
+ two: نود إعلامك بأنه لديك دعوتان جديدتان يمكن استخدامها لإنشاء حسابان جديدان
+ في AO3. بإمكانك دعوة صديق من خلال %{invitation_page_link}.
+ invitation_page_link_text: صفحة Invitations (دعواتك)
+ subject: "[%{app_name}] دعوات جديدة"
+ text:
+ body:
+ few: نود إعلامك بأنه لديك %{count} دعوات جديدة يمكن استخدامها لإنشاء حسابات
+ جديدة في AO3. بإمكانك دعوة صديق من خلال صفحة Invitations (دعواتك) (%{invitation_page_url}).
+ many: نود إعلامك بأنه لديك %{count} دعوة جديدة يمكن استخدامها لإنشاء حسابات
+ جديدة في AO3. بإمكانك دعوة صديق من خلال صفحة Invitations (دعواتك) (%{invitation_page_url}).
+ one: نود إعلامك بأنه لديك دعوة جديدة يمكن استخدامها لإنشاء حساب جديد في
+ AO3. بإمكانك دعوة صديق من خلال صفحة Invitations (دعواتك) (%{invitation_page_url}).
+ other: نود إعلامك بأنه لديك %{count} دعوة جديدة يمكن استخدامها لإنشاء حسابات
+ جديدة في AO3. بإمكانك دعوة صديق من خلال صفحة Invitations (دعواتك) (%{invitation_page_url}).
+ two: نود إعلامك بأنه لديك دعوتان جديدتان يمكن استخدامها لإنشاء حسابان جديدان
+ في AO3. بإمكانك دعوة صديق من خلال صفحة Invitations (دعواتك) (%{invitation_page_url}).
+ invite_request_declined:
+ main:
+ few: نأسف لإبلاغك أنه لا يمكن تلبية طلبك للحصول على %{count} دعوات جديدة في
+ هذا الوقت.
+ many: نأسف لإبلاغك أنه لا يمكن تلبية طلبك للحصول على %{count} دعوة جديدة في
+ هذا الوقت.
+ one: نأسف لإبلاغك أنه لا يمكن تلبية طلبك للحصول على دعوة جديدة في هذا الوقت.
+ other: نأسف لإبلاغك أنه لا يمكن تلبية طلبك للحصول على %{count} دعوة جديدة
+ في هذا الوقت.
+ two: نأسف لإبلاغك أنه لا يمكن تلبية طلبك للحصول على دعوتين جديدتين في هذا
+ الوقت.
+ zero: 'كان طلبك:'
+ reason: 'كان طلبك:'
+ subject: "[%{app_name}] طلب دعوة إضافية مرفوض"
+ recipient_notification:
+ html:
+ collection: لقد نُشر عملٌ مُهدى إليك في مجموعة %{collection_link} على AO3!
+ no_collection: لقد نُشر عملٌ مُهدى إليك في AO3!
+ subject:
+ collection: "[%{app_name}][%{collection_title}] عمل مُهدى إليك من %{collection_title}"
+ no_collection: "[%{app_name}] عمل مُهدى إليك"
+ text:
+ collection: لقد نُشر عملٌ مُهدى إليك في مجموعة "%{collection_title}" (%{collection_url})
+ على AO3!
+ signup_notification:
+ activate:
+ html: رجاءاً %{activate_account_link}.
+ text: " رجاءاً اتبع هذا الرابط لتفعيل حسابك: %{activate_account_url}"
+ activate_your_account: اتبع هذا الرابط لتفعيل حسابك
+ admin_posts: أخبار AO3
+ bye: نأمل أن تستمتع باستخدام الأرشيف.
+ contact_support: تواصل مع لجنة الدعم
+ faq: قائمة الأسئلة المتداولة
+ features:
+ html: بعد تفعيل حسابك، يمكنك البدء بنشر أعمالك، إعداد اشتراكات البريد الإلكتروني
+ لإعلامك عندما يقوم مبدعوك المفضلون بنشر أعمال جديدة أو عندما يتم تحديث عمل
+ تقوم بمتابعته، كما باستطاعتك تعيين تفضيلاتك لتخصيص طريقة ظهور الموقع وعمله
+ بالنسبة لك، ويمكنك متابعة الأعمال التي قمت بزيارتها سابقاً عن طريق السجل
+ خاصتك، والعديد من الخواص الأخرى.
+ text: بعد تفعيل حسابك، يمكنك البدء بنشر أعمالك، إعداد اشتراكات البريد الإلكتروني
+ لإعلامك عندما يقوم مبدعوك المفضلون بنشر أعمال جديدة أو عندما يتم تحديث عمل
+ تقوم بمتابعته، كما باستطاعتك تعيين تفضيلاتك لتخصيص طريقة ظهور الموقع وعمله
+ بالنسبة لك، ويمكنك متابعة الأعمال التي قمت بزيارتها سابقاً عن طريق السجل
+ خاصتك، والعديد من الخواص الأخرى.
+ information:
+ html: هناك الكثير من المعلومات والعديد من النصائح حول كيفية استخدام الموقع
+ في %{faq_link}. ستجد آخر الأخبار عن تطورات الموقع في %{admin_posts_link}.
+ إذا كنت بحاجة إلى المزيد من المساعدة، أو واجهتك مشكلة، أو كان لديك بعض الأسئلة
+ والتعليقات، نرجو منك %{contact_support_link}، الذين سيكونون سعداء دائماً
+ بتقديم العون.
+ text: 'هناك الكثير من المعلومات والعديد من النصائح حول كيفية استخدام الموقع
+ في قائمة الأسئلة المتداولة التي تجدها هنا: %{faq_url}. ستجد آخر الأخبار
+ عن تطورات الموقع على أخبار AO3 في المشاركات من مسؤولين الأرشيف التي تجدها
+ هنا: %{admin_posts_url}. إذا كنت بحاجة إلى المزيد من المساعدة، أو واجهتك
+ مشكلة، أو كان لديك بعض الأسئلة والتعليقات، نرجو منك التواصل مع فريق الدعم،
+ الذين سيكونون سعداء دائماً بتقديم العون: %{contact_support_url}.'
+ welcome: أهلا بك في Archive of Our Own، %{login}!
diff --git a/config/locales/phrase-exports/bg.yml b/config/locales/phrase-exports/bg.yml
new file mode 100755
index 0000000..539791d
--- /dev/null
+++ b/config/locales/phrase-exports/bg.yml
@@ -0,0 +1,527 @@
+---
+bg:
+ activerecord:
+ attributes:
+ archive_warning:
+ name_with_colon:
+ one: 'Предупреждение:'
+ other: 'Предупреждения:'
+ category:
+ name_with_colon:
+ one: 'Категория:'
+ other: 'Категории:'
+ character:
+ name_with_colon:
+ one: 'Персонаж:'
+ other: 'Персонажи:'
+ fandom:
+ name_with_colon:
+ one: 'Фендъм:'
+ other: 'Фендъм:'
+ freeform:
+ name_with_colon:
+ one: 'Допълнителен таг:'
+ other: 'Допълнителни тагове:'
+ rating:
+ name_with_colon: 'Възрастова категория:'
+ relationship:
+ name_with_colon:
+ one: 'Oтношения:'
+ other: 'Oтношения:'
+ work:
+ chapter_total_display: Глави
+ summary: Резюме
+ models:
+ archive_warning:
+ one: Предупреждение
+ other: Предупреждения
+ category:
+ one: Категория
+ other: Категории
+ chapter:
+ one: Глава
+ other: Глави
+ character:
+ one: Персонаж
+ other: Персонажи
+ fandom:
+ one: Фендъм
+ other: Фендъм
+ freeform:
+ one: Допълнителен таг
+ other: Допълнителни тагове
+ rating:
+ one: Възрастова категория
+ other: Възрастови категории
+ relationship:
+ one: Oтношения
+ other: Oтношения
+ series:
+ one: Поредица
+ other: Поредици
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ one: един гост
+ other: "%{count} гости"
+ subject: "[%{app_name}] Имаш кудос!"
+ mailer:
+ general:
+ closing:
+ formal: С уважение,
+ informal: Поздрави,
+ creation:
+ link_with_word_count: "%{creation_link} (%{word_count})"
+ title_with_chapter_number: Глава %{position} от %{title}
+ title_with_word_count: '"%{creation_title}" (%{word_count})'
+ word_count:
+ one: "%{count} дума"
+ other: "%{count} думи"
+ footer:
+ general:
+ about:
+ html: AO3 е архив, който се управлява и поддържа от фенове и разчита на
+ %{donate_link}.
+ text: 'AO3 е архив, който се управлява и поддържа от фенове и разчита
+ на вашите дарения: %{donate_url}.'
+ html:
+ donate_link_text: вашите дарения
+ support_link_text: свържи се с екип Поддръжка
+ unwanted_email:
+ html: Ако си получил/а това съобщение по погрешка, моля, %{support_link}.
+ text: Ако си получил/а това съобщение по погрешка, моля, свържи се с екип
+ Поддръжка на %{support_url}.
+ sent_at: Изпратено в %{sent_at}.
+ greeting:
+ informal:
+ unaddressed: Здравей!
+ introductory: Привет от Archive of Our Own – AO3 (Нашият архив)!
+ metadata_label_indicator: ":"
+ signature:
+ abuse_team: Екип Политики и нарушения на АО3
+ app_short_name: AO3
+ open_doors: Екипът на Open Doors (Отворени врати)
+ parent_org: Organization for Transformative Works – OTW (Организация за преобразуващи
+ произведения)
+ support: Екип Поддръжка на AO3
+ users:
+ mailer:
+ reset_password_instructions:
+ expiration: Ако в рамките на една седмица не използваш този линк, той ще стане
+ невалиден и ще трябва да заявиш промяната наново.
+ intro: 'Някой е заявил нулиране на паролата за твоя профил. Можеш да смениш
+ паролата на профила си като кликнеш на долния линк и въведеш нова парола:'
+ link_title: Смяна на моята парола.
+ subject: "[%{app_name}] Смяна на твоята парола"
+ unrequested: Ако тази заявка не е направена от теб, може да игнорираш този
+ имейл, а досегашната ти парола ще остане валидна.
+ user_mailer:
+ admin_deleted_work_notification:
+ bye: Прилагаме копие от произведението ти за информация.
+ contact_abuse: свържи се с нашия комитет Политики и нарушения
+ deleted:
+ html: Твоето произведение %{title} беше изтрито от AO3 от администратор на
+ сайта.
+ text: Твоето произведение "%{title}" беше изтрито от AO3 от администратор
+ на сайта.
+ html:
+ tos_violation: Ако е възможно произведението ти да е нарушило Правилата за
+ ползване на AO3, моля, %{contact_abuse_link}.
+ import_project:
+ html: Ако това произведение е било част от проект за присъединяване на творби
+ към АО3, който се управлява от екипа на Open Doors (Отворени врати), моля,
+ %{opendoors_link}, ако имаш допълнителни въпроси.
+ text: Ако това произведение е било част от проект за присъединяване на творби
+ към АО3, който се управлява от екипа на Open Doors (Отворени врати), моля,
+ свържи се с Open Doors (%{opendoors_link}), ако имаш допълнителни въпроси.
+ opendoors: свържи се с Open Doors
+ subject: "[%{app_name}] Произведението ти беше изтрито от администратор"
+ text:
+ tos_violation: Ако е възможно произведението ти да е нарушило Правилата за
+ ползване на AO3, моля, свържи се с нашия комитет Политики и нарушения (%{contact_abuse_url}).
+ admin_hidden_work_notification:
+ access: Когато произведението ти е скрито, ти все още имаш достъп до него чрез
+ горния линк, но то няма да се показва на страницата с твоите произведения
+ и няма да бъде достъпно за други потребители в АО3.
+ check_email: Моля да провериш своя имейл, включително Спам папката, защото е
+ възможно екип Политики и нарушения вече да са се опитали да се свържат с теб,
+ за да обяснят причината, поради която произведението ти е било скрито.
+ contact_abuse: се свържи с екип Политики и нарушения
+ html:
+ help: Ако не си сигурен/а защо произведението ти е било скрито и не сме се
+ свързали с теб с по-нататъшна информация по въпроса, моля, директно %{contact_abuse_link}.
+ hidden: Твоето произведение %{title} беше скрито от екип Политики и нарушения
+ и вече не е публично достъпно.
+ tos_violation: Ако произведението ти е било скрито поради нарушение на %{tos_link}
+ на АО3, ще се наложи да поправиш нарушението. Ако не приведеш творбата си
+ в съответствие с Правилата за ползване, може да се стигне до окончателното
+ ѝ изтриване от АО3.
+ subject: "[%{app_name}] Произведението ти беше скрито от екип Политики и нарушения"
+ text:
+ help: 'Ако не си сигурен/а защо произведението ти е било скрито и не сме се
+ свързали с теб с по-нататъшна информация по въпроса, моля, директно се свържи
+ с екип Политики и нарушения: %{contact_abuse_url}.'
+ hidden: Произведението ти "%{title}" (%{work_url}) беше скрито от екип Политики
+ и нарушения и вече не е публично достъпно.
+ tos_violation: Ако произведението ти е било скрито поради нарушение на Правилата
+ за ползване на АО3 (%{tos_url}), ще се наложи да поправиш нарушението. Ако
+ не приведеш творбата си в съответствие с Правилата за ползване, може да
+ се стигне до окончателното ѝ изтриване от АО3.
+ tos: Правилата за ползване
+ anonymous_or_unrevealed_notification:
+ anonymous_info: Анонимните произведения се появяват в списъците на тагове, но
+ не и на страницата с всичките ти творби. В самото произведение твоето потребителско
+ име е заменено с "Anonymous" (Анонимен).
+ anonymous_unrevealed_info: По-късно организаторите на колекцията може да разкрият
+ произведението ти, но да го оставят анонимно. Хората, които са се абонирали
+ за теб като автор, няма да бъдат уведомени за тази промяна. След като бъде
+ разкрито, произведението ти ще се показва в списъците на тагове, но не и на
+ страницата с всичките ти творби. В самото произведение твоето потребителско
+ име ще бъде заменено с "Anonymous" (Анонимен).
+ changed_status:
+ anonymous:
+ html: Организаторите на колекцията %{collection_link} промениха статуса
+ на твоето произведение %{work_link} на анонимно.
+ text: Организаторите на колекцията "%{collection_title}" (%{collection_url})
+ промениха статуса на твоето произведение "%{work_title}" (%{work_url})
+ на анонимно
+ anonymous_unrevealed:
+ html: Организаторите на колекцията %{collection_link} промениха статуса
+ на твоето произведение %{work_link} на анонимно и неразкрито.
+ text: Организаторите на колекцията "%{collection_title}" (%{collection_url})
+ промениха статуса на твоето произведене "%{work_title}" (%{work_url})
+ на анонимно и неразкрито.
+ unrevealed:
+ html: Организаторите на колекцията %{collection_link} промениха статуса
+ на твоето произведение %{work_link} на неразкрито.
+ text: Организаторите на колекцията "%{collection_title}" (%{collection_url})
+ промениха статуса на твоето произведене "%{work_title}" (%{work_url})
+ на неразкрито.
+ collection_items_link_text: страницата Approved Collection Items (Одобрено съдържание
+ в колекции)
+ do_not_want:
+ anonymous:
+ html: Ако не искаш произведението ти да е анонимно, моля, отиди на %{collection_items_link},
+ за да го премахнеш от тази колекция.
+ text: 'Ако не искаш произведението ти да е анонимно, моля, отиди на страницата
+ Approved Collection Items (Одобрено съдържание в колекции), за да го премахнеш
+ от тази колекция: %{collection_items_url}'
+ anonymous_unrevealed:
+ html: Ако не искаш произведението ти да е анонимно и неразкрито, моля, отиди
+ на %{collection_items_link}, за да го премахнеш от тази колекция.
+ text: 'Ако не искаш произведението ти да е анонимно и неразкрито, моля,
+ отиди на страницата Approved Collection Items (Одобрено съдържание в колекции),
+ за да го премахнеш от тази колекция: %{collection_items_url}'
+ unrevealed:
+ html: Ако не искаш произведението ти да е неразкрито, моля, отиди на %{collection_items_link},
+ за да го премахнеш от тази колекция.
+ text: 'Ако не искаш произведението ти да е неразкрито, моля, отиди на страницата
+ Approved Collection Items (Одобрено съдържание в колекции), за да го премахнеш
+ от тази колекция: %{collection_items_url}'
+ faq_link_text: ЧЗВ за колекциите
+ more_info:
+ html: За повече информация, виж %{faq_link}.
+ text: 'За повече информация, виж ЧЗВ за колекциите: %{faq_url}'
+ subject:
+ anonymous: "[%{app_name}] Произведението ти беше променено на анонимно"
+ anonymous_unrevealed: "[%{app_name}] Произведението ти беше променено на анонимно
+ и неразкрито"
+ unrevealed: "[%{app_name}] Произведението ти беше променено на неразкрито"
+ unrevealed_info: Неразкритите произведения не се появяват нито в списъците на
+ тагове, нито на страницата с всичките ти творби. Всеки, който последва линка
+ към произведението, ще получи съобщение, че то все още не е разкрито и няма
+ да има достъп до съдържанието му.
+ archivist_added_to_collection_notification:
+ approved_collection_items_page: страница Approved Collection Items (Одобрено
+ съдържание на колекция)
+ archivist_notice: Тъй като модераторите на колекцията действат в официалното
+ си качество на архивисти в Open Doors (Отворени врати), те могат да добавят
+ твоето произведение към тази колекция дори когато опцията за получаване на
+ покани за колекции е изключена в твоите настройки. Архивистите добавят произведения
+ към колекции само ако те са били част от импортиран архив.
+ removal_instructions:
+ html: Ако желаеш да премахнеш творбата си от тази колекция, моля, посети твоята
+ %{approved_items_link}.
+ text: 'Ако желаеш да премахнеш творбата си от тази колекция, моля, посети
+ твоята страница Approved Collection Items (Одобрено съдържание на колекция):
+ %{approved_items_url}.'
+ subject: "[%{app_name}][%{collection_title}] Архивист на Open Doors (Отворени
+ врати) е добавил твоето произведение към колекция"
+ work_added:
+ html: Доброволците, които поддържат %{collection_link}, са добавили твоето
+ произведение %{work_link} към тяхната колекция!
+ text: Доброволците, поддържащи "%{collection_title}" (%{collection_url}),
+ са добавили твоето произведение "%{work_title}" (%{work_url}) към тяхната
+ колекция!
+ challenge_assignment_notification:
+ any: Всички
+ assignment:
+ html: Възложена ти е следната заявка от предизвикателството %{link} в АО3!
+ description: 'Описание:'
+ due: 'Краен срок за изпълнение на задачата:'
+ html:
+ footer: Получаваш този имейл във връзка с твоята регистрация в предизвикателството
+ %{title}. За повече информация относно предизвикателството и за това как
+ да се свържеш с модераторите, моля, виж %{footer_link}.
+ footer_link: страницата на предизвикателството
+ look_up: Можеш да намериш тази задача на %{link}.
+ look_up_link: страницата на твоите Assignments (Задачи)
+ optional_tags: 'Тагове по избор:'
+ prompts: 'Идеи:'
+ prompt_url: 'URL на идея:'
+ recipient: 'Получател:'
+ recipient_missing: 'Няма получател: свържи се с модератор за помощ!'
+ subject: "[%{app_name}][%{collection_title}] Твоята задача!"
+ text:
+ assignment: Възложена ти е следната заявка от предизвикателството "%{collection_title}"
+ (%{collection_url}) в АО3!
+ footer: Получаваш този имейл във връзка с твоята регистрация в предизвикателството
+ %{title} (%{url}). За повече информация относно предизвикателството и за
+ това как да се свържеш с модераторите, моля виж %{profile_url}.
+ look_up: Можеш да намериш тази задача на страницата на твоите Assignments
+ (Задачи) на %{link}.
+ change_email:
+ changed:
+ html: "%{login}, имейлът, който използваш за профила си, беше променен на
+ %{email}"
+ text: "%{login}, имейлът, който използваш за профила си, беше променен на
+ %{email}"
+ subject: "[%{app_name}] Промяна на имейл"
+ claim_notification:
+ access:
+ contact_support: свържи се с АО3 Поддръжка
+ html: В зависимост от архива твоите произведения може да са били добавени
+ с ограничение за достъп само от регистрирани потребители (за да не се появяват
+ като резултати при търсене в Гугъл). В този случай тези твои произведения
+ ще бъдат достъпни само за потребителите, които са влезли в профила си, освен
+ ако не решиш да ги направиш напълно достъпни. Ако се нуждаеш от помощ, за
+ да отключиш, изоставиш или изтриеш произведение, моля, %{contact_support_link}.
+ text: В зависимост от архива твоите произведения може да са били добавени
+ с ограничение за достъп само от регистрирани потребители (за да не се появяват
+ като резултати при търсене в Гугъл). В този случай тези твои произведения
+ ще бъдат достъпни само за потребителите, които са влезли в профила си, освен
+ ако не решиш да ги направиш напълно достъпни. Ако се нуждаеш от помощ, за
+ да отключиш, изоставиш или изтриеш произведение, моля, свържи се с АО3 Поддръжка
+ чрез %{support_url}.
+ email_tips: При връзка с нас, моля, добави имейл адресите от @transformativeworks.org
+ към твоя списък с безопасни контакти и провери спам папката си за нашия отговор.
+ introduction:
+ ao3_name: Archive of Our Own – AO3 (Нашият архив)
+ html: Получаваш този имейл, защото има твои произведения във фенски архив,
+ който е бил добавен от %{open_doors_name_link} към %{app_link}. Тъй като
+ този имейл адрес е свързан с имейла, регистриран в импортирания архив, съответните
+ фенски произведения (изредени по-долу) са автоматично добавени към твоя
+ АО3 профил.
+ open_doors_name: Open Doors (Отворени врати)
+ text: 'Получаваш този имейл, защото има твои произведения във фенски архив,
+ който е бил добавен от Open Doors (Отворени врати) (%{open_doors_url}) към
+ Archive of Our Own – AO3 (Нашият архив): %{app_url}. Тъй като този имейл
+ адрес е свързан с имейла, регистриран в импортирания архив, съответните
+ фенски произведения (изредени по-долу) са автоматично добавени към твоя
+ АО3 профил.'
+ mistake:
+ contact_open_doors: свържи се с Отворени врати
+ html: Ако това е грешка и тези произведения не са твои, моля не ги изтривай!
+ Моля, %{contact_open_doors_link} и ние ще се погрижим.
+ text: Ако това е грешка и тези произведения не са твои, моля не ги изтривай!
+ Моля, свържи се с Отворени врати (%{open_doors_url}) и ние ще се погрижим.
+ more_info:
+ ao3_news: АО3 Новини
+ contact_support: свържи се с АО3 Поддръжка
+ faq_page: страницата с ЧЗВ
+ html: Можеш да намериш новини за наскоро преместените архиви в %{ao3_news_link},
+ както и да намериш допълнителна информация на %{faq_page_link} или %{tutorial_page_link}
+ на Отворени врати. Ако не намираш отговор в ЧЗВ, ръководствата или в този
+ имейл, моля, %{contact_support_link}.
+ text: Можеш да намериш новини за наскоро преместените архиви в АО3 Новини
+ (%{news_url}), както и да намериш допълнителна информация на страницата
+ с ЧЗВ (%{open_doors_faq_url}) или страницата с ръководства (%{open_doors_tutorial_url})
+ на Отворени врати. Ако не намираш отговор в ЧЗВ, ръководствата или в този
+ имейл, моля, свържи се с АО3 Поддръжка чрез %{support_url}.
+ tutorial_page: страницата с ръководства
+ other_works:
+ contact_open_doors: свържи се с Отворени врати
+ html: Ако в импортирания архив има и други твои произведения, но свързани
+ с друг имейл, до който вече нямаш достъп, моля, %{contact_open_doors_link},
+ като предоставиш всякаква информация, която може да потвърди самоличността
+ ти.
+ text: Ако в импортирания архив има и други твои произведения, но свързани
+ с друг имейл, до който вече нямаш достъп, моля, свържи се с Отворени врати,
+ като предоставиш всякаква информация, която може да потвърди самоличността
+ ти.
+ questions:
+ contact_support: свържи се с АО3 Поддръжка
+ html: При други въпроси, моля, %{contact_support_link}.
+ text: При други въпроси, моля, свържи се с АО3 Поддръжка чрез %{support_url}.
+ redirects:
+ html: За да се запазят препоръките и отметките, за кратък период от време
+ уеб адресите на импортирания архив може да пренасочват към импортираните
+ копия на тези произведения (провери публикацията със съобщението за твоя
+ архив). Ако копия на тези произведения вече са били качени от теб и %{negation}
+ е използвана опцията за импортиране чрез URL, в АО3 ще има две копия на
+ едно и също произведение.
+ subject: "[%{app_name}] Качени произведения"
+ update_redirect:
+ contact_open_doors: свържи с Отворени врати
+ html: Ако искаш Отворени врати да промени линковете, така че те да пренасочват
+ към вече съществуващото ти произведение, моля, изтрий импортираното копие
+ и се %{contact_open_doors_link}, като споменеш името на твоя АО3 профил,
+ името на профила ти в импортирания архив, както и заглавието и URL на фен-произведението,
+ към което искаш да води пренасочващият линк. (Ако искаш да промениш пренасочващите
+ линкове на няколко произведения, може да ги изредиш в един имейл.)
+ text: Ако искаш Отворени врати да промени линковете, така че те да пренасочват
+ към вече съществуващото ти произведение, моля, изтрий импортираното копие
+ и се свържи с Отворени врати чрез %{open_doors_url}, като споменеш името
+ на твоя АО3 профил, името на профила ти в импортирания архив, както и заглавието
+ и URL на фен-произведението, към което искаш да води пренасочващият линк.
+ (Ако искаш да промениш пренасочващите линкове на няколко произведения, може
+ да ги изредиш в един имейл.)
+ works_by: 'Тези произведения са свързани с този имейл: %{email}'
+ work_info:
+ html: "%{work_link} (%{fandom})"
+ text:
+ no_fandom: "- %{work_title} %{work_url}"
+ with_fandom: "- %{work_title} %{work_url} (%{fandom})"
+ collection_notification:
+ assignments_sent:
+ complete: Всички задачи са изпратени.
+ subject: Задачите са изпратени
+ html:
+ received_message: 'Имаш съобщение във връзка с твоята колекция %{collection_link}:'
+ text:
+ received_message: 'Имаш съобщение във връзка с твоята колекция "%{collection_title}"
+ (%{collection_url}):'
+ creatorship_notification:
+ explanation: Когато си съавтор на произведение, други потребители могат да те
+ добавят като съавтор и на новите глави, независимо от настройките ти за съавторство.
+ Това те прави и съавтор на всяка поредица, към която е добавено това произведение.
+ html:
+ creation: "%{creation_link} от %{pseud_links}"
+ edit_chapter: да редактираш главата
+ edit_series: да редактираш поредицата
+ remove_chapter: Ако са те добавили по погрешка или не желаеш да фигурираш
+ като създател, можеш %{edit_chapter_link}, за да премахнеш името си като
+ създател.
+ remove_series: Ако са те добавили по погрешка или не желаеш да фигурираш като
+ създател, можеш %{edit_series_link}, за да премахнеш името си като създател.
+ intro_chapter: 'Потребителят %{adding_user} е посочил псевдонима ти %{pseud}
+ като съавтор на следната глава:'
+ intro_series: 'Потребителят %{adding_user} е посочил псевдонима ти %{pseud}
+ като съавтор на следната поредица:'
+ subject: "[%{app_name}] Уведомление за съавторство"
+ text:
+ creation: "%{title} (%{url}) от %{pseuds}"
+ remove_chapter: 'Ако са те добавили по погрешка или не желаеш да фигурираш
+ като създател, можеш да редактираш главата, за да премахнеш името си като
+ създател: %{url}'
+ remove_series: 'Ако са те добавили по погрешка или не желаеш да фигурираш
+ като създател, можеш да редактираш поредицата, за да премахнеш името си
+ като създател: %{url}'
+ creatorship_notification_archivist:
+ explanation: В качеството си на архивист на Open Doors (Отворени врати), този
+ потребител има правото да те добавя като съавтор и без заявка, дори ако функцията
+ за съавторство е изключена в твоите настройки.
+ html:
+ creation: "%{creation_link} от %{pseud_links}"
+ edit_chapter: да редактираш главата
+ edit_series: да редактираш серията
+ edit_work: да редактираш произведението
+ remove_chapter: Ако са те добавили по погрешка или не желаеш да фигурираш
+ като създател, можеш %{edit_chapter_link}, за да премахнеш името си като
+ създател.
+ remove_series: Ако са те добавили по погрешка или не желаеш да фигурираш като
+ създател, можеш %{edit_series_link}, за да премахнеш името си като създател.
+ remove_work: Ако са те добавили по погрешка или не желаеш да фигурираш като
+ създател, можеш %{edit_work_link}, за да премахнеш името си като създател.
+ intro_chapter: 'Потребителят %{archivist} добави псевдонима ти %{pseud} като
+ съавтор на следната глава:'
+ intro_series: 'Потребителят %{archivist} добави псевдонима ти %{pseud} като
+ съавтор на следната серия:'
+ intro_work: 'Потребителят %{archivist} добави псевдонима ти %{pseud} като съавтор
+ на следното произведение:'
+ subject: "[%{app_name}] Уведомление за съавторство от архивист"
+ text:
+ creation: "%{title} (%{url}) от %{pseuds}"
+ remove_chapter: 'Ако са те добавили по погрешка или не желаеш да фигурираш
+ като създател, можеш да редактираш главата, за да премахнеш името си като
+ създател: %{url}'
+ remove_series: 'Ако са те добавили по погрешка или не желаеш да фигурираш
+ като създател, можеш да редактираш серията, за да премахнеш името си като
+ създател: %{url}'
+ remove_work: 'Ако са те добавили по погрешка или не желаеш да фигурираш като
+ създател, можеш да редактираш произведението, за да премахнеш името си като
+ създател: %{url}'
+ creatorship_request:
+ html:
+ creation: "%{creation_link} от %{pseud_links}"
+ instructions: Можеш да приемеш или откажеш тази покана на твоята страница
+ %{page_name}.
+ page_name: Co-Creator Requests (Покани за съавторство)
+ intro_chapter: 'Имаш покана от потребителя %{inviting_user} да добавиш псевдонима
+ си %{pseud} като съавтор на следната глава:'
+ intro_series: 'Имаш покана от потребителя %{inviting_user} да добавиш псевдонима
+ си %{pseud} като съавтор на следната серия:'
+ intro_work: 'Имаш покана от потребителя %{inviting_user} да добавиш псевдонима
+ си %{pseud} като съавтор на следното произведение:'
+ subject: "[%{app_name}] Покана за съавторство"
+ text:
+ creation: "%{title} (%{url}) от %{pseuds}"
+ instructions: Можеш да приемеш или откажеш тази покана на твоята страница
+ Co-Creator Requests (Покани за съавторство) %{url}
+ delete_work_notification:
+ attachment: Прилагаме копие от произведението ти за информация.
+ deleted_other:
+ html: Твоята творба %{title} беше изтрита по заявка на %{pseud}.
+ text: Твоята творба "%{title}" беше изтрита по заявка на %{pseud}.
+ deleted_yourself:
+ html: Твоята творба %{title} беше изтрита по твоя заявка.
+ text: Твоята творба "%{title}" беше изтрита по твоя заявка.
+ questions:
+ html: Ако имаш въпроси, моля, %{support}.
+ text: Ако имаш въпроси, моля, %{support} (%{url}).
+ subject: "[%{app_name}] Твоята творба беше изтрита"
+ support: свържи се с екип Поддръжка
+ invitation:
+ been_invited: Имаш покана да се присъединиш към нашия бета проект!
+ has_invited: "%{user_name} те покани да се присъединиш към нашия бета проект!"
+ html:
+ faq_link_text: нашите Често Задавани Въпроси
+ subject: "[%{app_name}] Покана"
+ invite_increase_notification:
+ html:
+ body:
+ one: Искаме да те информираме, че имаш %{count} нова покана, която може
+ да използваш за създаването на нов акаунт в AO3. Можеш да поканиш приятел
+ през %{invitation_page_link}.
+ other: Искаме да те информираме, че имаш %{count} нови покани, които може
+ да използваш за създаването на нови акаунти в AO3. Можеш да поканиш приятел
+ през %{invitation_page_link}.
+ invitation_page_link_text: твоята страница Invitations (Покани)
+ subject: "[%{app_name}] Нови покани"
+ text:
+ body:
+ one: Искаме да те информираме, че имаш %{count} нова покана, която може
+ да използваш за създаването на нов акаунт в AO3. Можеш да поканиш приятел
+ през твоята страница Invitations (Покани) (%{invitation_page_url}).
+ other: Искаме да те информираме, че имаш %{count} нови покани, които може
+ да използваш за създаването на нови акаунти в AO3. Можеш да поканиш приятел
+ през твоята страница Invitations (Покани) (%{invitation_page_url}).
+ invite_request_declined:
+ main:
+ one: За съжаление твоето искане за нова покана не може да бъде изпълнено в
+ този момент.
+ other: За съжаление твоето искане за %{count} нови покани не може да бъде
+ изпълнено в този момент.
+ reason: 'Твоето искане:'
+ subject: "[%{app_name}] Отхвърлено искане за допълнителен код за покана"
+ recipient_notification:
+ html:
+ collection: Някой е публикувал творба-подарък за теб в колекцията %{collection_link}
+ в AO3!
+ no_collection: Някой е публикувал творба-подарък за теб в AO3!
+ subject:
+ collection: "[%{app_name}][%{collection_title}] Творба-подарък за теб от %{collection_title}"
+ no_collection: "[%{app_name}] Творба-подарък за теб"
+ text:
+ collection: Някой е публикувал творба-подарък за теб в колекцията "%{collection_title}"
+ (%{collection_url}) в AO3!
diff --git a/config/locales/phrase-exports/bn.yml b/config/locales/phrase-exports/bn.yml
new file mode 100755
index 0000000..a1af603
--- /dev/null
+++ b/config/locales/phrase-exports/bn.yml
@@ -0,0 +1,518 @@
+---
+bn:
+ activerecord:
+ attributes:
+ archive_warning:
+ name_with_colon:
+ one: 'সতর্কীকরণ:'
+ other: 'সতর্কীকরণ:'
+ category:
+ name_with_colon:
+ one: 'শ্রেণী:'
+ other: 'শ্রেণী:'
+ character:
+ name_with_colon:
+ one: 'চরিত্র:'
+ other: 'চরিত্র:'
+ fandom:
+ name_with_colon:
+ one: 'অনুরাগী দল:'
+ other: 'অনুরাগী দল:'
+ freeform:
+ name_with_colon:
+ one: 'সংযোজিত ট্যাগ:'
+ other: 'সংযোজিত ট্যাগ:'
+ rating:
+ name_with_colon: 'রেটিং:'
+ relationship:
+ name_with_colon:
+ one: 'সম্পর্ক:'
+ other: 'সম্পর্ক:'
+ work:
+ chapter_total_display: অধ্যায়
+ summary: সারাংশ
+ models:
+ archive_warning:
+ one: সতর্কীকরণ
+ other: সতর্কীকরণ
+ category:
+ one: শ্রেণী
+ other: শ্রেণী
+ chapter:
+ one: অধ্যায়
+ other: অধ্যায়
+ character:
+ one: চরিত্র
+ other: চরিত্র
+ fandom:
+ one: অনুরাগী দল
+ other: অনুরাগী দল
+ freeform:
+ one: সংযোজিত ট্যাগ
+ other: সংযোজিত ট্যাগ
+ rating:
+ one: রেটিং
+ other: রেটিং
+ relationship:
+ one: সম্পর্ক
+ other: সম্পর্ক
+ series:
+ one: সংকলন
+ other: সংকলন
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ one: একজন অতিথি
+ other: "%{count}জন অতিথি"
+ left_kudos:
+ html:
+ one: "%{givers_list} বাহবা (কুডোস) দিয়ে গেছেন %{commentable_link}।"
+ other: "%{givers_list} বাহবা (কুডোস) দিয়ে গেছেন %{commentable_link}।"
+ text:
+ one: "%{givers_list} বাহবা (কুডোস) দিয়ে গেছেন %{commentable_title} (%{commentable_url})।"
+ other: "%{givers_list} বাহবা (কুডোস) দিয়ে গেছেন %{commentable_title} (%{commentable_url})।"
+ single_guest:
+ giver: একজন অতিথি
+ html: "%{giver} বাহবা (কুডোস) দিয়ে গেছেন %{commentable_link}।"
+ text: একজন অতিথি %{commentable_title} (%{commentable_url}) বাহবা (কুডোস) দিয়ে
+ গেছেন।
+ subject: "[%{app_name}]-তে আপনি বাহবা (কুডোস) পেয়েছেন!"
+ mailer:
+ general:
+ closing:
+ formal: ধন্যবাদান্তে,
+ informal: ভালো থাকবেন,
+ creation:
+ link_with_word_count: "%{creation_link} (%{word_count})"
+ title_with_chapter_number: "%{title}-এর %{position} অধ্যায়"
+ title_with_word_count: '"%{creation_title}" (%{word_count})'
+ word_count:
+ one: "%{count} শব্দ"
+ other: "%{count} শব্দ"
+ footer:
+ general:
+ about:
+ html: AO3 একটি অনুরাগী চালিত এবং অনুরাগী সমর্থিত সংগ্রহশালা যেটি %{donate_link}
+ ওপর নির্ভরশীল।
+ text: 'AO3 একটি অনুরাগী চালিত এবং অনুরাগী সমর্থিত সংগ্রহশালা যেটা আপনার
+ দানের ওপর নির্ভরশীল: %{donate_url}।'
+ html:
+ donate_link_text: আপনার দানের
+ support_link_text: সাহায্য-র সাথে যোগাযোগ করুন
+ unwanted_email:
+ html: এই বার্তাটা যদি আপনি ভুলবশতঃ পেয়ে থাকেন, দয়া করে %{support_link}।
+ text: এই বার্তাটা যদি আপনি ভুলবশতঃ পেয়ে থাকেন, দয়া করে %{support_url}-তে
+ সাহায্য'র সাথে যোগাযোগ করুন।
+ sent_at: "%{sent_at}তে পাঠানো হয়েছে।"
+ greeting:
+ formal_html: প্রিয় %{name},
+ informal:
+ addressed_html: নমস্কার, %{name}!
+ unaddressed: নমস্কার!
+ introductory: Archive of Our Own – AO3(আমাদের নিজস্ব সংগ্রহশালা) থেকে নমস্কার!
+ metadata_label_indicator: ":"
+ signature:
+ abuse_team: AO3 অপব্যবহার নিবারণ সমিতি
+ app_short_name: AO3
+ open_doors: Open Doors (খোলা দরজা) বিভাগ
+ parent_org: Organization for Transformative Works – OTW (রূপান্তরাত্মক কর্মের
+ সংস্থা)
+ support: AO3 সাহায্য বিভাগ
+ users:
+ mailer:
+ reset_password_instructions:
+ expiration: আপনি যদি এক সপ্তাহের মধ্যে আপনার পাসওয়ার্ড রিসেট করার জন্য এই
+ লিঙ্কটি ব্যবহার না করেন, তবে এটির মেয়াদ শেষ হয়ে অকার্যকর হয়ে যাবে, এবং
+ আপনাকে একটি নতুন লিঙ্ক অনুরোধ করতে হবে৷
+ intro: 'কেউ আপনার অ্যাকাউন্টের জন্য একটি পাসওয়ার্ড রিসেট করার অনুরোধ করেছে৷
+ আপনার অ্যাকাউন্টের পাসওয়ার্ড পরিবর্তন করার জন্য, নীচের লিঙ্কটি অনুসরণ করে
+ নতুন পাসওয়ার্ড লিখুন:'
+ link_title: আমার পাসওয়ার্ড পরিবর্তন করব৷
+ subject: "[%{app_name}] নতুন পাসওয়ার্ড সেট করুন"
+ unrequested: আপনি যদি পাসওয়ার্ড বদল করার জন্য অনুরোধ না করে থাকেন, তাহলে
+ আপনি এই ইমেলটিকে উপেক্ষা করতে পারেন এবং আপনার পূর্ববর্তী পাসওয়ার্ড কার্যকর
+ থাকবে৷
+ user_mailer:
+ admin_hidden_work_notification:
+ access: আপনার কর্ম যতক্ষণ লুকানো আছে, আপনি উপরে দেওয়া লিঙ্কের মাধ্যমে এটি দেখতে
+ পারবেন, কিন্তু এটি আপনার কর্ম-পৃষ্ঠায় তালিকাভুক্ত হবে না এবং AO3-এর অন্যান্য
+ ব্যবহারকারীদের জন্য দৃশ্যমান হবে না।
+ check_email: অনুগ্রহ করে আপনার আপনার ইমেল দেখুন, স্প্যাম ফোল্ডারটাও দেখবেন,
+ নীতি ও অপব্যবহার নিবারণ সমিতি হয়তো ইতিমধ্যেই আপনার কাজ কেন লুকিয়ে রাখা হয়েছে
+ তা ব্যাখ্যা করে আপনাকে ইমেল করেছে৷
+ contact_abuse: নীতি ও অপব্যবহার নিবারণ সমিতির সঙ্গে যোগাযোগ করুন
+ html:
+ help: আপনি যদি অনিশ্চিত হন যে আপনার কর্ম কেন লুকানো হয়েছে, এবং আপনি এই বিষয়ে
+ আরো খবর না পেয়ে থাকলে, অনুগ্রহ করে সরাসরি %{contact_abuse_link}।
+ hidden: আপনার কর্ম %{title} নীতি ও অপব্যবহার নিবারণ সমিতি দ্বারা লুকানো হয়েছে
+ এবং আর সর্বজনীনভাবে দৃশ্যমান নেই।
+ tos_violation: AO3-র %{tos_link} লঙ্ঘনের কারণে আপনার কর্ম লুকানো হলে সেই লঙ্ঘন
+ সংশোধন করার জন্য আপনাকে ব্যবস্থা নিতে হবে। পরিষেবা প্রাপ্তির শর্তাবলী মেনে
+ চলতে ব্যর্থ হলে AO3 থেকে আপনার কর্ম মুছে ফেলা হতে পারে।
+ subject: "[%{app_name}] আপনার কর্ম নীতি ও অপব্যবহার নিবারণ সমিতি দ্বারা লুকানো
+ হয়েছে"
+ text:
+ help: 'আপনি যদি অনিশ্চিত হন আপনার কর্ম কেন লুকানো হয়েছে, এবং আপনি এই বিষয়ে
+ আর খবর না পেয়ে থাকেন, তাহলে অনুগ্রহ করে সরাসরি নীতি ও অপব্যবহার নিবারণ
+ সমিতির সাথে যোগাযোগ করুন: %{contact_abuse_url}।'
+ hidden: আপনার কর্ম "%{title}" (%{work_url}) নীতি ও অপব্যবহার নিবারণ সমিতির
+ দ্বারা লুকানো হয়েছে এবং আর সর্বজনীনভাবে দৃশ্যমান নয়৷
+ tos_violation: AO3-র %{tos_url} লঙ্ঘনের কারণে আপনার কর্ম লুকানো হয়ে থাকলে
+ সেই লঙ্ঘন সংশোধন করার জন্য আপনাকে ব্যবস্থা নিতে হবে। পরিষেবা প্রাপ্তির শর্তাবলী
+ মেনে চলতে ব্যর্থ হলে AO3 থেকে আপনার কর্ম মুছে ফেলা হতে পারে।
+ tos: পরিষেবা প্রাপ্তির শর্তাবলী
+ anonymous_or_unrevealed_notification:
+ anonymous_info: অজ্ঞাতনামা কর্ম সংযোজন (ট্যাগ) এর তালিকায় অন্তর্ভুক্ত হবে, কিন্তু
+ আপনার কর্মের পাতায় প্রদর্শিত হবে না। কর্মের ভিতরে আপনার ব্যবহারকারী নাম “Anonymous”(অজ্ঞাতনামা)-তে
+ পরিবর্তিত হবে।
+ anonymous_unrevealed_info: সংকলন রক্ষণাবেক্ষণকারীরা পরে আপনার কর্ম উদ্ঘাটন করতে
+ পারেন, কিন্তু এটিকে অজ্ঞাতনামা রাখতে পারেন। যেসব ব্যক্তিরা আপনার কাছে সাবস্ক্রাইব
+ করবেন, তারা এ বিষয়ে বিজ্ঞপ্তি পাবেন না। উদ্ঘাটন করার পর, আপনার কর্ম সংযোজন
+ (ট্যাগ) এর তালিকায় অন্তর্ভুক্ত হবে, কিন্তু আপনার কর্মের পাতায় প্রদর্শিত হবে
+ না। কর্মে ব্যবহারকারী নাম “Anonymous” (অজ্ঞাতনামা)-তে পরিবর্তিত হবে।
+ changed_status:
+ anonymous:
+ html: "%{collection_link}-এর সংকলন রক্ষণাবেক্ষণকারীরা আপনার কর্ম %{work_link}
+ অজ্ঞাতনামা করেছেন।"
+ text: '"%{collection_title}" (%{collection_url}) -এর সংকলন রক্ষণাবেক্ষণকারীরা
+ আপনার কর্ম "%{work_title}" (%{work_url}) অজ্ঞাতনামা করেছেন।'
+ anonymous_unrevealed:
+ html: "%{collection_link}-এর সংকলন রক্ষণাবেক্ষণকারীরা আপনার কর্ম %{work_link}
+ অজ্ঞাতনামা আর অনুদ্ঘাটিত করেছেন।"
+ text: '"%{collection_title}" (%{collection_url}) -এর সংকলন রক্ষণাবেক্ষণকারীরা
+ আপনার কর্ম "%{work_title}" (%{work_url}) অজ্ঞাতনামা আর অনুদ্ঘাটিত করেছেন।'
+ unrevealed:
+ html: "%{collection_link}-এর সংকলন রক্ষণাবেক্ষণকারীরা আপনার কর্ম %{work_link}
+ অনুদ্ঘাটিত করেছেন।"
+ text: '"%{collection_title}" (%{collection_url}) -এর সংকলন রক্ষণাবেক্ষণকারীরা
+ আপনার কর্ম "%{work_title}" (%{work_url}) অনুদ্ঘাটিত করেছেন।'
+ collection_items_link_text: Approved Collection Items (সংগ্রহে অনুমোদিত আইটেম)
+ পাতা
+ do_not_want:
+ anonymous:
+ html: আপনি যদি আপনার কর্মকে অজ্ঞাতনামা করতে অনিচ্ছুক হন, তবে কর্মটি ওই সংকলন
+ থেকে অপসারণ করার জন্যে অনুগ্রহ করে %{collection_items_link}-তে যান।
+ text: 'আপনি যদি আপনার কর্ম অজ্ঞাতনামা করতে অনিচ্ছুক হন, তবে কর্মটি এই সংকলন:
+ %{collection_items_url} থেকে বাদ দিতে অনুগ্রহ করে আপনার Approved Collection
+ Items (সংগ্রহে অনুমোদিত আইটেম) পাতায় যান।'
+ anonymous_unrevealed:
+ html: আপনি যদি আপনার কর্মকে অজ্ঞাতনামা আর অনুদ্ঘাটিত করতে অনিচ্ছুক হন, তবে
+ কর্মটি ওই সংকলন থেকে অপসারণ করার জন্যে অনুগ্রহ করে %{collection_items_link}-তে
+ যান।
+ text: 'আপনি যদি আপনার কর্ম অজ্ঞাতনামা বা অনুদ্ঘাটিত করতে অনিচ্ছুক হন, তবে
+ কর্মটি এই সংকলন: %{collection_items_url} থেকে বাদ দিতে অনুগ্রহ করে আপনার
+ Approved Collection Items (সংগ্রহে অনুমোদিত আইটেম) পাতায় যান।'
+ unrevealed:
+ html: আপনি যদি আপনার কর্মকে অনুদ্ঘাটিত করতে অনিচ্ছুক হন, তবে কর্মটি ওই সংকলন
+ থেকে অপসারণ করার জন্যে অনুগ্রহ করে %{collection_items_link}-তে যান।
+ text: 'আপনি যদি আপনার কর্ম অনুদ্ঘাটিত করতে অনিচ্ছুক হন, তবে কর্মটি এই সংকলন:
+ %{collection_items_url} থেকে বাদ দিতে অনুগ্রহ করে আপনার Approved Collection
+ Items (সংগ্রহে অনুমোদিত আইটেম) পাতায় যান।'
+ faq_link_text: সংকলন প্রা-জি-প্র
+ more_info:
+ html: আরো তথ্যের জন্যে, আমাদের %{faq_link} দেখুন।
+ text: 'আরো তথ্যের জন্যে, আমাদের সংকলন প্রা-জি-প্র: %{faq_url} দেখুন।'
+ subject:
+ anonymous: "[%{app_name}] আপনার কর্ম অজ্ঞাতনামা করা হয়েছে"
+ anonymous_unrevealed: "[%{app_name}] আপনার কর্ম অজ্ঞাতনামা আর অনুদ্ঘাটিত করা
+ হয়েছে"
+ unrevealed: "[%{app_name}] আপনার কর্ম অনুদ্ঘাটিত করা হয়েছে"
+ unrevealed_info: অনুদ্ঘাটিত কর্ম সংযোজন (ট্যাগ) এর তালিকায় বা আপনার কর্মের পাতায়
+ অন্তর্ভুক্ত হবে না। কেউ এই কর্মের একটি লিঙ্ক অনুসরণ করলে, একটি বিজ্ঞপ্তি পাবে
+ যে এই কর্মটি বর্তমানে অনুদ্ঘাটিত এবং তারা এর বিষয়বস্তু দেখতে পাবে না।
+ archivist_added_to_collection_notification:
+ approved_collection_items_page: Approved Collection Items (সংগ্রহে অনুমোদিত
+ আইটেম) পাতা
+ archivist_notice: সংকলন রক্ষণাবেক্ষণকারীরা তাদের অফিসিয়াল ক্ষমতা থেকে একজন
+ Open Doors (মুক্ত দ্বার) আর্কাইভিস্ট হিসাবে কাজ করছেন, তাই তারা আপনার কর্মকে
+ এই সংকলনে যোগ করতে পারেন, আপনি যদি সংকলনের আমন্ত্রণগুলি নিষ্ক্রিয় করে থাকেন,
+ তাহলেও। আর্কাইভিস্ট শুধুমাত্র একটি সংকলনে একটি কর্ম যোগ করবেন যদি এটি একটি
+ নিয়ে আসা সংগ্রহশালায় হোস্ট করা হয়।
+ removal_instructions:
+ html: আপনি যদি আপনার কর্মকে এই সংকলন থেকে অপসারণ করতে চান, তবে অনুগ্রহ করে
+ %{approved_items_link}-তে যান।
+ text: 'আপনি যদি আপনার কর্মকে এই সংকলন থেকে অপসারণ করতে চান, তবে অনুগ্রহ করে
+ আপনার Approved Collection Items (সংগ্রহে অনুমোদিত আইটেম) পাতায় যান: %{approved_items_url}'
+ subject: "[%{app_name}][%{collection_title}] একজন Open Doors (মুক্ত দ্বার) আর্কাইভিস্ট
+ আপনার কর্মকে একটি সংকলনে যোগ করেছে"
+ work_added:
+ html: "%{collection_link}-এর সংকলন রক্ষণাবেক্ষণকারীরা আপনার কর্ম %{work_link}-কে
+ তাদের সংকলনে যোগ করেছে!"
+ text: '"%{collection_title}" (%{collection_url})-এর সংকলন রক্ষণাবেক্ষণকারীরা
+ আপনার কর্ম "%{work_title}" (%{work_url})-কে তাদের সংকলনে যোগ করেছে!'
+ challenge_assignment_notification:
+ any: যেকোন
+ assignment:
+ html: AO3-তে %{link} চ্যালেঞ্জে আপনাকে নিম্নলিখিত সূত্রটি (প্রম্পট) বরাদ্দ
+ করা হয়েছে!
+ description: 'বর্ণনা:'
+ due: 'এই কর্মটি জমা দিতে হবে:'
+ html:
+ footer: আপনি এই ইমেলটি পাচ্ছেন কারণ আপনি %{title} চ্যালেঞ্জে যোগদান করছেন।
+ এই আহ্বান সম্বন্ধে আরো তথ্য এবং নিয়ামকদের সঙ্গে যোগাযোগের তথ্যের জন্যে,
+ অনুগ্রহ করে %{footer_link}-এ যান।
+ footer_link: আহ্বান নিয়ন্ত্রণ পাতা
+ look_up: আপনি %{link} থেকে এই বরাদ্দ কর্মটি দেখতে পারেন।
+ look_up_link: আপনার Assignments (বরাদ্দ কর্ম)-এর পাতা
+ optional_tags: 'ঐচ্ছিক সংযোজন:'
+ prompts: 'সূত্র:'
+ prompt_url: 'সূত্রের ইউ.আর.এল.:'
+ recipient: 'প্রাপক:'
+ recipient_missing: 'কেউ নেই: সাহায্যের জন্যে একজন নিয়ামকের সঙ্গে যোগাযোগ করুন!'
+ subject: "[%{app_name}][%{collection_title}] আপনার বরাদ্দ কর্ম!"
+ text:
+ assignment: AO3-তে "%{collection_title}" (%{collection_url}) চ্যালেঞ্জে আপনাকে
+ নিম্নলিখিত সূত্রটি বরাদ্দ করা হয়েছে!
+ footer: আপনি এই ইমেলটি পাচ্ছেন কারণ আপনি %{title} আহ্বানে (%{url}) যোগদান
+ করছেন। এই আহ্বান সম্বন্ধে আরো তথ্য এবং নিয়ামকদের সঙ্গে যোগাযোগের তথ্যের
+ জন্যে, অনুগ্রহ করে %{profile_url} যান।
+ look_up: আপনি এই বরাদ্দ কর্মটি আপনার Assignments (বরাদ্দ কর্ম)-এর পাতাতে এখানে
+ %{link} দেখতে পারেন।
+ change_email:
+ changed:
+ html: "%{login}, আপনার অ্যাকাউন্টের সাথে সম্পর্কিত ইমেল অ্যাড্রেস %{email}-
+ টা পরিবর্তন করা হয়েছে"
+ text: "%{login}, আপনার অ্যাকাউন্টের সাথে সম্পর্কিত ইমেল অ্যাড্রেস %{email}-
+ টা পরিবর্তন করা হয়েছে"
+ subject: "[%{app_name}] ইমেল পরিবর্তন করা হয়েছে"
+ claim_notification:
+ access:
+ contact_support: AO3 সহায়তা সমিতির সঙ্গে যোগাযোগ করুন
+ html: সংগ্রহশালার উপর ভিত্তি করে আপনার কর্ম শুধুমাত্র নিবন্ধিত (রেজিস্টার্ড)
+ ব্যবহারকারীদের দ্বারা প্রদর্শন যোগ্য হতে পারে (এটি সংগ্রহশালার কর্মগুলিকে
+ গুগুল সার্চের থেকে সরিয়ে রাখতে করা হয়)। এক্ষেত্রে, আপনি যদি আলাদা করে আপনার
+ কর্মগুলি সর্বসাধারণের কাছে প্রদর্শিত না করেন, তবে কর্মগুলি শুধুমাত্র লগড-ইন
+ ব্যবহারকারিকারীদের দ্বারা প্রদর্শিত হবে। আপনার কর্ম সর্বসাধারণ দ্বারা প্রদর্শন
+ যোগ্য করতে, কর্ম অনাথ করতে, বা কর্ম অপসারণ করতে অনুগ্রহ করে %{contact_support_link}।
+ text: সংগ্রহশালার উপর ভিত্তি করে আপনার কর্ম শুধুমাত্র নিবন্ধিত (রেজিস্টার্ড)
+ ব্যবহারকারীদের দ্বারা প্রদর্শন যোগ্য হতে পারে (এটি সংগ্রহশালার কর্মগুলিকে
+ গুগুল সার্চের থেকে সরিয়ে রাখতে করা হয়)। এক্ষেত্রে, আপনি যদি আলাদা করে আপনার
+ কর্মগুলি সর্বসাধারণের কাছে প্রদর্শিত না করেন, তবে কর্মগুলি শুধুমাত্র লগড-ইন
+ ব্যবহারকারিকারীদের দ্বারা প্রদর্শিত হবে। আপনার কর্ম সর্বসাধারণ দ্বারা প্রদর্শন
+ যোগ্য করতে, কর্ম অনাথ করতে, বা কর্ম অপসারণ করতে অনুগ্রহ করে AO3 সহায়তা সমিতির
+ সঙ্গে যোগাযোগ করুন - %{support_url}।
+ email_tips: আপনি যদি আমাদের সাথে যোগাযোগ করেন তাহলে অনুগ্রহ করে @transformativeworks.org
+ এর ইমেল অ্যাড্রেসগুলি আপনার নিরাপদ অ্যাড্রেসের তালিকায় যোগ করবেন, এবং আমাদের
+ উত্তরটি আপনার স্প্যাম ফোল্ডারেও খুঁজে দেখবেন।
+ introduction:
+ ao3_name: Archive of Our Own – AO3 (আমাদের নিজস্ব সংগ্রহশালা)
+ html: আপনি এই ইমেলটি পেয়েছেন কারণ %{open_doors_name_link} সমিতির দ্বারা %{app_link}-তে
+ ইমপোর্ট করে আনা একটি অনুরাগী সংগ্রহশালাতে আপনার কিছু অনুরাগীকর্ম ছিল। যেহেতু
+ এই ইমেল অ্যাড্রেস ইমপোর্ট করে আনা সংগ্রহশালাটিতে নিবন্ধন ছিল, তাই সংগ্রহশালাটিতে
+ এই ইমেলের সঙ্গে সংযুক্ত থাকা সমস্ত কর্ম (নিচে তালিকাভুক্ত) আপনার AO3 অ্যাকাউন্টে
+ যোগ করা হয়েছে।
+ open_doors_name: Open Doors (মুক্ত দ্বার)
+ text: আপনি এই ইমেলটি পেয়েছেন কারণ Open Doors (মুক্ত দ্বার সমিতি) (%{open_doors_url})
+ দ্বারা Archive of Our Own – AO3 (আমাদের নিজস্ব সংগ্রহশালা)-তে %{app_url}
+ ইমপোর্ট করে আনা একটি অনুরাগী সংগ্রহশালাতে আপনার কিছু অনুরাগীকর্ম ছিল। যেহেতু
+ এই ইমেল অ্যাড্রেস ইমপোর্ট করে আনা সংগ্রহশালাটিতে নিবন্ধন ছিল, তাই সংগ্রহশালাটিতে
+ এই ইমেলের সঙ্গে সংযুক্ত থাকা সমস্ত কর্ম (নিচে তালিকাভুক্ত) আপনার AO3 অ্যাকাউন্টে
+ যোগ করা হয়েছে।
+ mistake:
+ contact_open_doors: মুক্ত দ্বারের সঙ্গে যোগাযোগ করুন
+ html: এখানে যদি কোন ভুল হয়ে থাকে এবং এই কর্মগুলি আপনার না হয়, তবে অনুগ্রহ
+ করে এগুলো অপসারণ (ডিলিট) করবেন না! দয়া করে %{contact_open_doors_link}, তাহলে
+ আমরা এই ব্যাপারটি দেখে ঠিক করে নেব।
+ text: এখানে যদি কোন ভুল হয়ে থাকে এবং এই কর্মগুলি আপনার না হয়, তবে অনুগ্রহ
+ করে এগুলো অপসারণ (ডিলিট) করবেন না! দয়া করে মুক্ত দ্বারের সঙ্গে যোগাযোগ করুন
+ (%{open_doors_url}), তাহলে আমরা এই ব্যাপারটি দেখে ঠিক করে নেব।
+ more_info:
+ ao3_news: AO3 সংবাদ
+ contact_support: AO3 সহায়তা সমিতির সঙ্গে যোগাযোগ করুন
+ faq_page: প্র-জি-প্র (FAQ) পাতা
+ html: সাম্প্রতিক সংগ্রহশালা ইমপোর্ট বিষয়ক ঘোষণা আপনি %{ao3_news_link}-এ পড়তে
+ পারেন, এবং এ বিষয়ে আরো তথ্যে মুক্ত দ্বারের %{faq_page_link} অথবা %{tutorial_page_link}
+ পাবেন। অন্য যেকোন প্রশ্ন যার উত্তর প্র-জি-প্র, পাঠগুলিতে, বা এই ইমেলে নেই,
+ সে বিষয়ে উত্তরের জন্য অনুগ্রহ করে %{contact_support_link}।
+ text: সাম্প্রতিক সংগ্রহশালা ইমপোর্ট বিষয়ক ঘোষণা আপনি AO3 সংবাদে (%{news_url})
+ পড়তে পারেন, এবং এ বিষয়ে আরো তথ্যে মুক্ত দ্বারের প্র-জি-প্র পাতাতে (%{open_doors_faq_url})
+ অথবা পাঠ (টিউটোরিয়াল) (%{open_doors_tutorial_url}) পাতাতে পাবেন। অন্য যেকোন
+ প্রশ্ন যার উত্তর প্র-জি-প্র, পাঠগুলিতে, বা এই ইমেলে নেই, সে বিষয়ে উত্তরের
+ জন্য অনুগ্রহ করে সহায়তা সমতির সঙ্গে যোগাযোগ করুন - %{support_url}।
+ tutorial_page: পাঠ (টিউটোরিয়াল) পাতা
+ other_works:
+ contact_open_doors: মুক্ত দ্বারের সঙ্গে যোগাযোগ করুন
+ html: ইমপোর্ট করে আনা সংগ্রহশালাতে আপনার যদি অন্য কোন কর্ম সংযুক্ত হয়ে থাকে
+ এমন একটি ইমেল অ্যাড্রেসের সঙ্গে যেটিতে আপনি বর্তমানে প্রবেশ করতে অক্ষম,
+ তাহলে অনুগ্রহ করে যেকোন তথ্য যা আপনার পরিচয় নিশ্চিত করতে পারবে, তা নিয়ে
+ %{contact_open_doors_link}।
+ text: ইমপোর্ট করে আনা সংগ্রহশালাতে আপনার যদি অন্য কোন কর্ম সংযুক্ত হয়ে থাকে
+ এমন একটি ইমেল অ্যাড্রেসের সঙ্গে যেটিতে আপনি বর্তমানে প্রবেশ করতে অক্ষম,
+ তাহলে অনুগ্রহ করে যেকোন তথ্য যা আপনার পরিচয় নিশ্চিত করতে পারবে তা নিয়ে
+ মুক্ত দ্বারের সঙ্গে যোগাযোগ করুন।
+ questions:
+ contact_support: AO3 সহায়তা সমিতির সঙ্গে যোগাযোগ করুন
+ html: আরো তথ্যের জন্য অনুগ্রহ করে %{contact_support_link}।
+ text: আরো তথ্যের জন্য অনুগ্রহ করে AO3 সহায়তা সমিতির সঙ্গে যোগাযোগ করুন - %{support_url}।
+ redirects:
+ html: রেক তালিকা এবং পুস্তকচিহ্ন সংরক্ষণ করার জন্য, ইমপোর্ট করে আনা সংগ্রহশালার
+ ওয়েব অ্যাড্রেসগুলি কিছু সময়ের জন্য সেই অনুরাগীকর্মের ইমপোর্ট করে আনা প্রতিলিপিতে
+ রিডিরেক্ট করতে পারে (এ বিষয়ে তথ্যের জন্যে আপনার সংগ্রহশালা সম্বন্ধীয় ঘোষণার
+ প্রকাশনটি পরে দেখুন)। আপনি যদি এই কর্মগুলির একটি প্রতিলিপি এর আগেই আপলোড
+ করে থাকেন ও যদি 'URL দ্বারা করে ইমপোর্ট করুন' ব্যবহার %{negation} করে থাকেন
+ , তবে AO3-তে একই কর্মের দুটি প্রতিলিপি থেকে যাবে।
+ subject: "[%{app_name}] কর্ম আপলোড হয়েছে"
+ update_redirect:
+ contact_open_doors: মুক্ত দ্বারের সঙ্গে যোগাযোগ করুন
+ html: আপনি যদি চান যে মুক্ত দ্বার এই রিডাইরেক্টটি আপনার আগে প্রকাশিত অনুরাগীকর্মের
+ সঙ্গে যুক্ত করুক, তাহলে অনুগ্রহ করে কর্মটির ইমপোর্ট করে আনা প্রতিলিপিটি
+ অপসারণ করুন, এবং আপনার AO3 অ্যাকাউন্ট এর নাম, ইমপোর্ট করে আনা সংগ্রহশালাটিতে
+ আপনার অ্যাকাউন্ট এর নাম, আর যে অনুরাগীকর্মে আপনি এই রিডাইরেক্টটি কার্যকর
+ করতে চান সেটির URL নিয়ে %{contact_open_doors_link}। (আপনার যদি একাধিক কর্ম
+ থাকে যেগুলির রিডাইরেক্টগুলি আপনি পরিবর্তন করতে চান, তাহলে সেই সব কর্মগুলি
+ আপনি একটি ইমেলেই তালিকা করে লিখে দিতে পারেন।)
+ text: আপনি যদি চান যে মুক্ত দ্বার এই রিডাইরেক্টটি আপনার আগে প্রকাশিত অনুরাগীকর্মের
+ সঙ্গে যুক্ত করুক, তাহলে অনুগ্রহ করে কর্মটির ইমপোর্ট করে আনা প্রতিলিপিটি
+ অপসারণ করুন, এবং আপনার AO3 অ্যাকাউন্ট এর নাম, ইমপোর্ট করে আনা সংগ্রহশালাটিতে
+ আপনার অ্যাকাউন্ট এর নাম, আর যে অনুরাগীকর্মে আপনি এই রিডাইরেক্টটি কার্যকর
+ করতে চান সেটির URL নিয়ে মুক্ত দ্বারের সঙ্গে যোগাযোগ করুন - %{open_doors_url}।
+ (আপনার যদি একাধিক কর্ম থাকে যেগুলির রিডাইরেক্টগুলি আপনি পরিবর্তন করতে চান,
+ তাহলে সেই সব কর্মগুলি আপনি একটি ইমেলেই তালিকা করে লিখে দিতে পারেন।)
+ works_by: 'এই কর্মগুলি এই ইমেলের সঙ্গে সংযুক্তিতে লেখা হয়েছিল: %{email}'
+ work_info:
+ html: "%{work_link} (%{fandom})"
+ text:
+ no_fandom: "- %{work_title} %{work_url}"
+ with_fandom: "- %{work_title} %{work_url} (%{fandom})"
+ collection_notification:
+ assignments_sent:
+ complete: সব অ্যাসাইনমেন্ট পাঠানো হয়ে গেছে।
+ subject: অ্যাসাইনমেন্ট পাঠানো হয়েছে
+ html:
+ received_message: 'আপনার সংগ্রহ সম্পর্কে আপনি একটি বার্তা পেয়েছেন %{collection_link}:'
+ text:
+ received_message: 'আপনার সংগ্রহ সম্পর্কে আপনি একটি বার্তা পেয়েছেন "%{collection_title}"
+ (%{collection_url}):'
+ creatorship_notification:
+ explanation: আপনি যখন একটা কর্মের সহ-স্রষ্টা হন, আপনি আপনার সহ-সৃষ্টি সেটিং
+ এ যাই থাক না কেন, আপনি নতুন অধ্যায়ে যুক্ত হতে পারেন। কোনো সিরিজে কর্মটা যুক্ত
+ হলে আপনিও সেই সিরিজে যুক্ত হবেন।
+ html:
+ creation: "%{pseud_links} দ্বারা %{creation_link}"
+ edit_chapter: অধ্যায় সম্পাদন করুন
+ edit_series: সিরিজটা সম্পাদন করুন
+ remove_chapter: আপনাকে যদি ভুলবশতঃ যোগ করা হয়ে থাকে বা আপনি স্রষ্টা হিসেবে
+ তালিকাভুক্ত হতে না চান, আপনি %{edit_chapter_link} স্রষ্টা তালিকা থেকে নিজেকে
+ সরিয়ে ফেলতে পারেন।
+ remove_series: আপনাকে যদি ভুলবশতঃ যোগ করা হয়ে থাকে বা আপনি স্রষ্টা হিসেবে
+ তালিকাভুক্ত হতে না চান, আপনি %{edit_series_link} স্রষ্টা তালিকা থেকে নিজেকে
+ সরিয়ে ফেলতে পারেন।
+ intro_chapter: 'ব্যবহারকারী %{adding_user} আপনার ছদ্মনাম %{pseud} নিম্নোক্ত
+ অধ্যায়ের জন্য সহ-স্রষ্টা হিসেবে তালিকাভুক্ত করেছেন:'
+ intro_series: 'ব্যবহারকারী %{adding_user} আপনার ছদ্মনাম %{pseud} নিম্নোক্ত সিরিজের
+ জন্য সহ-স্রষ্টা হিসেবে তালিকাভুক্ত করেছেন:'
+ subject: "[%{app_name}] সহ-স্রষ্টার বিজ্ঞপ্তি"
+ text:
+ creation: "%{pseuds} দ্বারা %{title} (%{url})"
+ remove_chapter: 'আপনাকে যদি ভুলবশতঃ যোগ করা হয়ে থাকে বা আপনি স্রষ্টা হিসেবে
+ তালিকাভুক্ত হতে না চান, আপনি অধ্যায়টা সম্পাদন করে স্রষ্টা তালিকা থেকে নিজেকে
+ সরিয়ে ফেলতে পারেন: %{url}'
+ remove_series: 'আপনাকে যদি ভুলবশতঃ যোগ করা হয়ে থাকে বা আপনি স্রষ্টা হিসেবে
+ তালিকাভুক্ত হতে না চান, আপনি সিরিজটা সম্পাদন করে স্রষ্টা তালিকা থেকে নিজেকে
+ সরিয়ে ফেলতে পারেন: %{url}'
+ creatorship_notification_archivist:
+ explanation: যেহেতু তারা তাদের অফিসিয়াল ক্ষমতা থেকে, একজন Open Doors (খোলা
+ দরজা) আর্কাইভিস্ট হিসাবে কাজ করছেন, তারা আপনাকে অনুরোধ ছাড়াই যোগ করতে পারেন,
+ আপনি যদি সহ-সৃষ্টি নিষ্ক্রিয় করে থাকেন তাহলেও।
+ html:
+ creation: "%{pseud_links} দ্বারা %{creation_link}"
+ edit_chapter: অধ্যায় সম্পাদনা
+ edit_series: সিরিজ সম্পাদনা
+ edit_work: কাজ সম্পাদনা
+ remove_chapter: আপনি যদি ভুল করে যুক্ত হয়ে থাকেন বা একজন স্রষ্টা হিসেবে তালিকাভুক্ত
+ হতে না চান, তাহলে আপনি নিজেকে স্রষ্টা হিসেবে অপসারিত %{edit_chapter_link}
+ করতে পারেন।
+ remove_series: আপনি যদি ভুল করে যুক্ত হয়ে থাকেন বা একজন স্রষ্টা হিসেবে তালিকাভুক্ত
+ হতে না চান, তাহলে আপনি নিজেকে স্রষ্টা হিসেবে অপসারিত %{edit_series_link}
+ করতে পারেন।
+ remove_work: আপনি যদি ভুল করে যুক্ত হয়ে থাকেন বা একজন স্রষ্টা হিসেবে তালিকাভুক্ত
+ হতে চান না , তাহলে আপনি নিজেকে স্রষ্টাহিসেবে অপসারিত %{edit_work_link} করতে
+ পারেন।
+ intro_chapter: 'ব্যবহারকারী %{archivist} নিম্নলিখিত অধ্যায় সহ-স্রষ্টা হিসাবে
+ আপনার ছদ্মনাম %{pseud} যুক্ত করেছেন:'
+ intro_series: 'ব্যবহারকারী %{archivist} নিম্নলিখিত সিরিজে সহ-স্রষ্টা হিসাবে
+ আপনার ছদ্মনাম %{pseud} যুক্ত করেছেন:'
+ intro_work: 'ব্যবহারকারী %{archivist} নিম্নলিখিত কাজের সহ-স্রষ্টা হিসাবে আপনার
+ ছদ্মনাম %{pseud} যুক্ত করেছেন:'
+ subject: "[%{app_name}] আর্কাইভিস্ট সহ-স্রষ্টা বিজ্ঞপ্তি"
+ text:
+ creation: "%{pseuds} দ্বারা %{title} (%{url})"
+ remove_chapter: 'আপনি যদি ভুল করে যুক্ত হয়ে থাকেন বা একজন স্রষ্টা হিসেবে
+ তালিকাভুক্ত হতে না চান, তাহলে আপনি নিজেকে স্রষ্টা হিসেবে অপসারিত করার জন্য
+ অধ্যায় সম্পাদনা করতে পারেন: %{url}'
+ remove_series: 'আপনি যদি ভুল করে যুক্ত হয়ে থাকেন বা একজন স্রষ্টা হিসেবে তালিকাভুক্ত
+ হতে না চান, তাহলে আপনি নিজেকে স্রষ্টা হিসেবে সরিয়ে দিতে সিরিজ সম্পাদনা
+ করতে পারেন: %{url}'
+ remove_work: 'আপনি যদি ভুল করে যুক্ত হয়ে থাকেন বা একজন স্রষ্টা হিসেবে তালিকাভুক্ত
+ হতে না চান, তাহলে আপনি নিজেকে স্রষ্টা হিসেবে সরিয়ে দিতে কাজ সম্পাদনা করতে
+ পারেন: %{url}'
+ creatorship_request:
+ html:
+ creation: "%{pseud_links} দ্বারা %{creation_link}"
+ instructions: আপনি আপনার %{page_name} পৃষ্ঠায় এই অনুরোধ গ্রহণ বা প্রত্যাখ্যান
+ করতে পারেন।
+ page_name: Co-Creator Requests (সহ-নির্মাতা আমন্ত্রণ)
+ intro_chapter: 'ব্যবহারকারী %{inviting_user} আপনার ছদ্মনাম %{pseud}-কে নিম্নলিখিত
+ অধ্যায়ের সহ-নির্মাতা হিসাবে তালিকাভুক্ত করার জন্য আমন্ত্রণ জানিয়েছেন:'
+ intro_series: 'ব্যবহারকারী %{inviting_user} আপনার ছদ্মনাম %{pseud}-কে নিম্নলিখিত
+ সিরিজের সহ-নির্মাতা হিসাবে তালিকাভুক্ত করার জন্য আমন্ত্রণ জানিয়েছেন:'
+ intro_work: 'ব্যবহারকারী %{inviting_user} আপনার ছদ্মনামকে %{pseud} নিম্নলিখিত
+ কাজের সহ-নির্মাতা হিসাবে তালিকাভুক্ত করার জন্য আমন্ত্রণ জানিয়েছেন:'
+ subject: "[%{app_name}] সহ-নির্মাতা আমন্ত্রণ"
+ text:
+ creation: "%{pseuds} দ্বারা %{title} (%{url})"
+ instructions: 'আপনি আপনার Co-Creator Requests (সহ-নির্মাতা আমন্ত্রণ) পৃষ্ঠায়
+ এই অনুরোধ গ্রহণ বা প্রত্যাখ্যান করতে পারেন: %{url}'
+ delete_work_notification:
+ attachment: আপনার কর্মের একটা প্রতিলিপি এই ইমেলের সাথে আপনার তথ্যের জন্য যুক্ত
+ করা আছে।
+ deleted_other:
+ html: আপনার কর্ম %{title} %{pseud} এর অনুরোধে বিনষ্ট (ডিলিট) করা হয়েছে।
+ text: আপনার কর্ম "%{title}" %{pseud} এর অনুরোধে বিনষ্ট (ডিলিট) করা হয়েছে।
+ deleted_yourself:
+ html: আপনার কর্ম %{title} আপনারই অনুরোধে বিনষ্ট (ডিলিট) করা হয়েছে।
+ text: আপনার কর্ম "%{title}" আপনারই অনুরোধে বিনষ্ট (ডিলিট) করা হয়েছে।
+ questions:
+ html: আপনার যদি কোনো প্রশ্ন থাকে, দয়া করে %{support}।
+ text: আপনার যদি কোনো প্রশ্ন থাকে, দয়া করে %{support} (%{url})।
+ subject: "[%{app_name}] আপনার কর্ম বিনষ্ট (ডিলিট) করা হয়েছে।"
+ support: সহায়তা সমিতির সঙ্গে যোগাযোগ করুন।
+ invite_increase_notification:
+ html:
+ body:
+ one: আমরা আপনাকে জানাতে চাই যে আপনার কাছে %{count}টা নতুন আমন্ত্রণ রয়েছে,
+ যা ব্যবহার করে AO3তে একটা নতুন অ্যাকাউন্ট তৈরি করা যাবে৷ আপনি %{invitation_page_link}তে
+ একজন বন্ধুকে আমন্ত্রণ জানাতে পারেন৷
+ other: আমরা আপনাকে জানাতে চাই যে আপনার কাছে %{count}টি নতুন আমন্ত্রণ রয়েছে,
+ যেগুলি ব্যবহার করে AO3তে নতুন অ্যাকাউন্ট তৈরি করা যাবে৷ আপনি %{invitation_page_link}তে
+ একজন বন্ধুকে আমন্ত্রণ জানাতে পারেন৷
+ invitation_page_link_text: আপনার Invitations (আমন্ত্রণ) পৃষ্ঠা
+ subject: "[%{app_name}] নতুন আমন্ত্রণ"
+ text:
+ body:
+ one: আমরা আপনাকে জানাতে চাই যে আপনার কাছে %{count}টা নতুন আমন্ত্রণ রয়েছে,
+ যা ব্যবহার করে AO3তে একটা নতুন অ্যাকাউন্ট তৈরি করা যাবে৷ আপনি (%{invitation_page_url})তে
+ একজন বন্ধুকে আমন্ত্রণ জানাতে পারেন৷
+ other: আমরা আপনাকে জানাতে চাই যে আপনার কাছে %{count}টি নতুন আমন্ত্রণ রয়েছে,
+ যেগুলি ব্যবহার করে AO3তে নতুন অ্যাকাউন্ট তৈরি করা যাবে৷ আপনি (%{invitation_page_url})তে
+ একজন বন্ধুকে আমন্ত্রণ জানাতে পারেন৷
+ invite_request_declined:
+ main:
+ one: আমরা দুঃখিত যে আপনার নতুন নিমন্ত্রনপত্রের জন্যে অনুরোধ এই সময় পূরণ করা
+ সম্ভব নয়।
+ other: আমরা দুঃখিত যে আপনার %{count}-টি নতুন নিমন্ত্রনপত্রের জন্যে অনুরোধ
+ এই সময় পূরণ করা সম্ভব নয়।
+ reason: 'আপনার অনুরোধ ছিল:'
+ subject: "[%{app_name}] সংযোজিত নিমন্ত্রণপত্রের অনুরোধ প্রত্যাখ্যান করা হয়েছে"
+ recipient_notification:
+ html:
+ collection: AO3–তে %{collection_link} সংকলনে আপনার জন্য একটি উপহার কর্ম প্রকাশ
+ করা হয়েছে!
+ no_collection: AO3-তে আপনার জন্য একটি উপহার কর্ম প্রকাশিত হয়েছে!
+ subject:
+ collection: "[%{app_name}][%{collection_title}] আপনার জন্য %{collection_title}
+ থেকে একটি উপহার কর্ম"
+ no_collection: "[%{app_name}] আপনার জন্য একটি উপহার কর্ম"
+ text:
+ collection: আপনার জন্য একটি উপহার কর্ম (%{collection_url}) AO3-তে "%{collection_title}"
+ সংকলনে প্রকাশ করা হয়েছে!
diff --git a/config/locales/phrase-exports/ca.yml b/config/locales/phrase-exports/ca.yml
new file mode 100755
index 0000000..1e677e3
--- /dev/null
+++ b/config/locales/phrase-exports/ca.yml
@@ -0,0 +1,628 @@
+---
+ca:
+ activerecord:
+ attributes:
+ archive_warning:
+ name_with_colon:
+ one: 'Advertiment:'
+ other: 'Advertiments:'
+ category:
+ name_with_colon:
+ one: 'Categoria:'
+ other: 'Categories:'
+ character:
+ name_with_colon:
+ one: 'Personatge:'
+ other: 'Personatges:'
+ fandom:
+ name_with_colon:
+ one: 'Fàndom:'
+ other: 'Fàndoms:'
+ freeform:
+ name_with_colon:
+ one: 'Etiqueta addicional:'
+ other: 'Etiquetes addicionals:'
+ rating:
+ name_with_colon: 'Classificació:'
+ relationship:
+ name_with_colon:
+ one: 'Relació:'
+ other: 'Relacions:'
+ work:
+ chapter_total_display: Capítols
+ summary: Resum
+ models:
+ archive_warning:
+ one: Advertiment
+ other: Advertiments
+ category:
+ one: Categoria
+ other: Categories
+ chapter:
+ one: Capítol
+ other: Capítols
+ character:
+ one: Personatge
+ other: Personatges
+ fandom:
+ one: Fàndom
+ other: Fàndoms
+ freeform:
+ one: Etiqueta addicional
+ other: Etiquetes addicionals
+ rating:
+ one: Classificació
+ other: Classificacions
+ relationship:
+ one: Relació
+ other: Relacions
+ series:
+ one: Sèrie
+ other: Sèries
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ one: 1 convidat/da
+ other: "%{count} convidats/des"
+ left_kudos:
+ html:
+ one: "%{givers_list} ha deixat kudos a %{commentable_link}."
+ other: "%{givers_list} han deixat kudos a %{commentable_link}."
+ text:
+ one: "%{givers_list} ha deixat kudos a %{commentable_title} (%{commentable_url})."
+ other: "%{givers_list} han deixat kudos a %{commentable_title} (%{commentable_url})."
+ single_guest:
+ giver: Un/a convidat/da
+ html: "%{giver} ha deixat kudos a %{commentable_link}."
+ text: Un/a convidat/da ha deixat kudos a %{commentable_title} (%{commentable_url}).
+ subject: "[%{app_name}] T'han deixat kudos!"
+ mailer:
+ general:
+ closing:
+ formal: Cordialment,
+ informal: Salutacions,
+ creation:
+ link_with_word_count: "%{creation_link} (%{word_count})"
+ title_with_chapter_number: Capítol %{position} de %{title}
+ title_with_word_count: '"%{creation_title}" (%{word_count})'
+ word_count:
+ one: "%{count} paraula"
+ other: "%{count} paraules"
+ footer:
+ general:
+ about:
+ html: L'AO3 és un arxiu gestionat i mantingut per fans que sobreviu gràcies
+ a %{donate_link}.
+ text: 'L''AO3 és un arxiu gestionat i mantingut per fans que sobreviu
+ gràcies a les vostres donacions: %{donate_url}.'
+ html:
+ donate_link_text: les vostres donacions
+ support_link_text: contacta amb Suport
+ unwanted_email:
+ html: Si has rebut aquest missatge per error, %{support_link}.
+ text: Si has rebut aquest missatge per error, contacta amb Suport a %{support_url}.
+ sent_at: Enviat el %{sent_at}.
+ greeting:
+ formal_html: Estimat/da %{name},
+ informal:
+ addressed_html: Hola, %{name}!
+ unaddressed: Hola!
+ introductory: Hola de part de l'Archive of Our Own – AO3 (Un Arxiu Propi)!
+ metadata_label_indicator: ":"
+ signature:
+ abuse_team: L'equip de Polítiques i Prevenció de l'Abús d'AO3
+ app_short_name: AO3
+ open_doors: L'equip d'Open Doors (Projecte de Portes Obertes)
+ parent_org: Organization for Transformative Works – OTW (Organització per
+ a les Obres Transformatives)
+ support: L'equip de Suport de l'AO3
+ users:
+ mailer:
+ reset_password_instructions:
+ expiration: Si no fas servir l'enllaç per canviar la teva contrasenya en una
+ setmana, aquest expirarà, i n'hauràs de sol·licitar un de nou.
+ intro: 'Algú ha sol·licitat un canvi de contrasenya pel teu compte. Pots canviar
+ la contrasenya associada al teu compte clicant a l''enllaç a continuació
+ i introduïnt la teva nova contrasenya:'
+ link_title: Canvia la meva contrasenya.
+ subject: "[%{app_name}] Canvia la teva contrasenya"
+ unrequested: Si no has sol·licitat un canvi de contrasenya, pots ignorar aquest
+ correu electrònic i la teva contrasenya anterior continuarà funcionant.
+ user_mailer:
+ admin_deleted_work_notification:
+ bye: S'adjunta una còpia de la teva obra com a referència.
+ contact_abuse: el Comitè de Polítiques i Prevenció de l'Abús
+ deleted:
+ html: La teva obra %{title} ha estat eliminada de l'AO3 per un/a administrador/a
+ del lloc web.
+ text: La teva obra "%{title}" ha estat eliminada de l'AO3 per un/a administrador/a
+ del lloc web.
+ html:
+ tos_violation: Si és possible que la teva obra hagi violat els Termes i condicions
+ de servei de l'AO3, posa't en contacte amb %{contact_abuse_link}.
+ import_project:
+ html: Si la teva obra formava part d'un projecte importat per l'equip d'Open
+ Doors (Projecte de Portes Obertes), contacta amb %{opendoors_link} si tens
+ qualsevol altra pregunta.
+ text: Si la teva obra formava part d'un projecte importat per l'equip d'Open
+ Doors (Projecte de Portes Obertes), contacta amb Open Doors (%{opendoors_link})
+ si tens qualsevol altra pregunta.
+ opendoors: Open Doors
+ subject: "[%{app_name}] La teva obra ha estat eliminada per un/a administrador/a"
+ text:
+ tos_violation: Si és possible que la teva obra violés els Termes i condicions
+ de servei de l'AO3, posa't en contacte amb el Comitè de Polítiques i Prevenció
+ de l'Abús (%{contact_abuse_url}).
+ admin_hidden_work_notification:
+ access: Mentre la teva obra estigui oculta, podràs seguir accedint-hi mitjançant
+ l'enllaç proporcionat a dalt, però ja no estarà llistada a la teva pàgina
+ d'obres i ja no estarà disponible per als altres usuaris/àries de l'AO3.
+ check_email: Revisa el teu correu electrònic, inclosa la teva carpeta de correu
+ brossa, ja que és possible que l'equip de Polítiques i Prevenció de l'Abús
+ d'AO3 ja s'hagi posat en contacte amb tu per explicar-te per què s'ha ocultat
+ la teva obra.
+ contact_abuse: contacta amb Polítiques i Prevenció de l'Abús d'AO3
+ html:
+ help: Si no tens clar per què ha estat ocultada la teva obra i no has rebut
+ cap comunicació al respecte, %{contact_abuse_link} directament.
+ hidden: La teva obra %{title} ha estat ocultada per l'equip de Polítiques
+ i Prevenció de l'Abús d'AO3 i ja no és d'accés públic.
+ tos_violation: Si la teva obra ha estat ocultada degut a una violació dels
+ %{tos_link} de l'AO3, hauràs d'emprendre les mesures exigides per corregir
+ la infracció. L'incompliment dels Termes i condicions de servei pot comportar
+ que la teva obra sigui eliminada de l'AO3.
+ subject: "[%{app_name}] La teva obra ha estat ocultada per l'equip de Polítiques
+ i Prevenció de l'Abús d'AO3"
+ text:
+ help: 'Si no tens clar per què ha estat ocultada la teva obra i no has rebut
+ cap comunicació al respecte, contacta amb Polítiques i Prevenció de l''Abús
+ d''AO3 directament: %{contact_abuse_url}'
+ hidden: La teva obra "%{title}" (%{work_url}) ha estat ocultada per l'equip
+ de Polítiques i Prevenció de l'Abús d'AO3 i ja no és d'accés públic.
+ tos_violation: Si la teva obra ha estat ocultada degut a una violació dels
+ Termes i condicions de servei (%{tos_url}) de l'AO3, hauràs d'emprendre
+ les mesures exigides per corregir la infracció. L'incompliment dels Termes
+ i condicions de servei pot comportar que la teva obra sigui eliminada de
+ l'AO3.
+ tos: Termes i condicions de servei
+ anonymous_or_unrevealed_notification:
+ anonymous_info: Les obres anònimes s'inclouen a les llistes d'etiquetes però
+ no a la pàgina de les teves obres. Dins l'obra, el teu nom d'usuària o usuari
+ quedarà substituït per "Anonymous" (Anònim/a).
+ anonymous_unrevealed_info: Més endavant, els encarregats de la col·lecció poden
+ tornar a mostrar la teva obra però deixar-la com a anònima. La gent que tens
+ subscrita no serà notificada d'aquest canvi. Una vegada revelada, la teva
+ obra quedarà inclosa a les llistes d'etiquetes però no a la teva pàgina d'obres.
+ Dins l'obra, el teu nom d'usuària o usuari quedarà substituït per "Anonymous"
+ (Anònim/a).
+ changed_status:
+ anonymous:
+ html: Les persones encarregades de la col·lecció %{collection_link} han
+ canviat la condició de la teva obra %{work_link} a anònima.
+ text: Les persones encarregades de la col·lecció "%{collection_title}" (%{collection_url})
+ han canviat l'estat de la teva obra "%{work_title}" (%{work_url}) a anònima.
+ anonymous_unrevealed:
+ html: Les persones encarregades de la col·lecció %{collection_link} han
+ canviat l'estat de la teva obra %{work_link} a anònima i oculta.
+ text: Les persones encarregades de la col·lecció "%{collection_title}" (%{collection_url})
+ han canviat l'estat de la teva obra "%{work_title}" (%{work_url}) a anònima
+ i oculta.
+ unrevealed:
+ html: Les persones encarregades de la col·lecció %{collection_link} han
+ canviat l'estat de la teva obra %{work_link} a oculta.
+ text: Les persones encarregades de la col·lecció "%{collection_title}" (%{collection_url})
+ han canviat l'estat de la teva obra "%{work_title}" (%{work_url}) a oculta.
+ collection_items_link_text: Pàgina d'Approved Collection Items (Elements aprovats
+ de la col·lecció)
+ do_not_want:
+ anonymous:
+ html: Si no vols que la teva obra sigui anònima, visita la %{collection_items_link}
+ per eliminar-la d'aquesta col·lecció.
+ text: 'Si no vols que la teva obra sigui anònima, visita la pàgina d''Approved
+ Collection Items (Elements aprovats de la col·lecció) per eliminar-la
+ d''aquesta col·lecció: %{collection_items_url}'
+ anonymous_unrevealed:
+ html: Si no vols que la teva obra sigui anònima ni oculta, visita la %{collection_items_link}
+ per eliminar-la d'aquesta col·lecció.
+ text: 'Si no vols que la teva obra sigui anònima ni oculta, visita la pàgina
+ d''Approved Collection Items (Elements aprovats de la col·lecció) per
+ eliminar-la d''aquesta col·lecció: %{collection_items_url}'
+ unrevealed:
+ html: Si no vols que la teva obra sigui oculta, visita la %{collection_items_link}
+ per eliminar-la d'aquesta col·lecció.
+ text: 'Si no vols que la teva obra sigui oculta, visita la pàgina d''Approved
+ Collection Items (Elements aprovats de la col·lecció) per eliminar-la
+ d''aquesta col·lecció: %{collection_items_url}'
+ faq_link_text: Preguntes freqüents sobre col·leccions
+ more_info:
+ html: Per a més informació, visita les %{faq_link}.
+ text: 'Per a més informació, consulta les Preguntes freqüents sobre Col·leccions:
+ %{faq_url}'
+ subject:
+ anonymous: "[%{app_name}] La teva obra s'ha canviat a anònima"
+ anonymous_unrevealed: "[%{app_name}] La teva obra s'ha canviat a anònima i
+ oculta"
+ unrevealed: "[%{app_name}] La teva obra s'ha canviat a oculta"
+ unrevealed_info: Les obres ocultes no apareixen a les llistes d'etiquetes ni
+ a la teva pàgina d'obres. Qualsevol persona que obri un enllaç a l'obra rebrà
+ una notificació informant que de moment és oculta i no podran accedir al seu
+ contingut.
+ archivist_added_to_collection_notification:
+ approved_collection_items_page: pàgina d'Approved Collection Items (Elements
+ de col·lecció aprovats)
+ archivist_notice: Com que les persones responsables de la col·lecció actuen
+ oficialment com a arxivistes d'Open Doors (Projecte de Portes Obertes), poden
+ afegir la teva obra a aquesta col·lecció encara que tinguis les invitacions
+ a col·leccions desactivades. Els i les arxivistes només afegeixen a col·leccions
+ obres que s'allotjaven a un arxiu importat.
+ removal_instructions:
+ html: Si vols retirar la teva obra d'aquesta col·lecció, ves a la teva %{approved_items_link}.
+ text: 'Si vols retirar la teva obra d''aquesta col·lecció, ves a la teva pàgina
+ d''Approved Collection Items (Elements de col·lecció aprovats): %{approved_items_url}.'
+ subject: "[%{app_name}][%{collection_title}] Un/a arxivista d'Open Doors (Projecte
+ de Portes Obertes) ha afegit la teva obra a una col·lecció"
+ work_added:
+ html: Les persones responsables de %{collection_link} han afegit la teva obra
+ %{work_link} a la seva col·lecció!
+ text: Les persones responsables de "%{collection_title}" (%{collection_url})
+ han afegit la teva obra "%{work_title}" (%{work_url}) a la seva col·lecció!
+ challenge_assignment_notification:
+ any: Qualsevol
+ assignment:
+ html: Se t'ha assignat la següent sol·licitud del repte %{link} a l'AO3!
+ description: 'Descripció:'
+ due: 'Aquesta tasca venç el:'
+ html:
+ footer: Estàs rebent aquest correu electrònic perquè et vas inscriure al repte
+ %{title}. Per a més informació sobre aquest repte i informació de contacte
+ dels moderadors o moderadores, visita %{footer_link}.
+ footer_link: la pàgina de perfil del repte
+ look_up: Pots consultar aquesta tasca a %{link}.
+ look_up_link: la teva pàgina d'Assignments (Tasques)
+ optional_tags: 'Etiquetes opcionals:'
+ prompts: 'Premisses:'
+ prompt_url: 'URL de la premissa:'
+ recipient: 'Destinatari o destinatària:'
+ recipient_missing: 'Cap: posa''t en contacte amb un moderador o una moderadora
+ per obtenir ajuda!'
+ subject: "[%{app_name}][%{collection_title}] La teva tasca!"
+ text:
+ assignment: Se t'ha assignat la següent sol·licitud del repte (%{collection_url})
+ "%{collection_title}" a l'AO3!
+ footer: Estàs rebent aquest correu electrònic perquè et vas inscriure al repte
+ %{title} (%{url}). Per a més informació sobre aquest repte i informació
+ de contacte dels moderadors o moderadores, visita %{profile_url}.
+ look_up: Pots consultar aquesta tasca a la teva pàgina d'Assignments (Tasques)
+ a %{link}.
+ change_email:
+ changed:
+ html: "%{login}, l'adreça electrònica associada amb aquest compte ha canviat
+ a %{email}"
+ text: "%{login}, l'adreça electrònica associada amb aquest compte ha canviat
+ a %{email}"
+ subject: "[%{app_name}] Canvi d'adreça electrònica"
+ claim_notification:
+ access:
+ contact_support: contacta amb el Suport de l'AO3
+ html: Depenent de l'arxiu, les teues obres poden haver estat importades de
+ manera restringida, amb la qual cosa tan sols els usuaris i les usuàries
+ registrades podran veure-les (per tal que no apareguen a les cerques de
+ Google). Si aquest és el cas, les obres només seran accessibles per als
+ usuaris i usuàries que han iniciat la sessió llevat que les faces visibles
+ per a tothom. Si necessites ajuda desbloquejant, deixant òrfena o esborrant
+ alguna de les teues obres, %{contact_support_link}.
+ text: Depenent de l'arxiu, les teues obres poden haver estat importades de
+ manera restringida, amb la qual cosa tan sols els usuaris i les usuàries
+ registrades podran veure-les (per tal que no apareguen a les cerques de
+ Google). Si aquest és el cas, les obres només seran accessibles per als
+ usuaris i usuàries que han iniciat la sessió llevat que les faces visibles
+ per a tothom. Si necessites ajuda desbloquejant, deixant òrfena o esborrant
+ alguna de les teues obres, contacta amb el Suport de l'AO3 a %{support_url}.
+ email_tips: Si ens contactes, recomanem que afiges les adreces de correu electrònic
+ de @transformativeworks.org a la teua llista de contactes segurs i que revises
+ la safata de correu brossa.
+ introduction:
+ ao3_name: Archive of Our Own – AO3 (Un Arxiu Propi)
+ html: Has rebut aquest missatge de correu perquè tens obres en un arxiu d'obres
+ de fans que ha estat importat per %{open_doors_name_link} a l'%{app_link}.
+ Com que aquesta adreça de correu està connectada a una registrada a l'arxiu
+ importat, les obres associades (que es mostren a continuació) s'han afegit
+ automàticament al teu compte de l'AO3.
+ open_doors_name: Open Doors (Projecte de Portes Obertes)
+ text: 'Has rebut aquest missatge de correu perquè tens obres en un arxiu d''obres
+ de fans que ha estat importat per Open Doors (Projecte de Portes Obertes)
+ (%{open_doors_url}) a l''Archive of Our Own – AO3 (Un Arxiu Propi): %{app_url}.
+ Com que aquesta adreça de correu està connectada a una registrada a l''arxiu
+ importat, les obres associades (que es mostren a continuació) s''han afegit
+ automàticament al teu compte de l''AO3.'
+ mistake:
+ contact_open_doors: Contacta amb Open Doors
+ html: Si hi ha hagut algun error i aquestes no són les teues obres, no les
+ esborres! %{contact_open_doors_link} i ho arreglarem.
+ text: Si hi ha hagut algun error i aquestes no són les teues obres, no les
+ esborres! Contacta amb Open Doors (%{open_doors_url}) i ho arreglarem.
+ more_info:
+ ao3_news: Notícies de l'AO3
+ contact_support: contacta amb el Suport de l'AO3
+ faq_page: pàgina de Preguntes freqüents
+ html: Pots llegir notícies sobre importacions recents a les %{ao3_news_link},
+ i trobar informació addicional sobre Open Doors a la %{faq_page_link} o
+ la %{tutorial_page_link}. Si tens cap dubte que no haja estat resolt a les
+ Preguntes freqüents, els tutorials, o aquest correu, %{contact_support_link}.
+ text: Pots llegir notícies sobre importacions recents a les Notícies de l'AO3
+ (%{news_url}), i trobar informació addicional sobre Open Doors a la pàgina
+ de Preguntes freqüents (%{open_doors_faq_url}) o la pàgina de tutorials
+ (%{open_doors_tutorial_url}). Si tens cap dubte que no haja estat resolt
+ a les Preguntes freqüents, els tutorials, o aquest correu, contacta amb
+ el Suport de l'AO3 a %{support_url}.
+ tutorial_page: pàgina de tutorials
+ other_works:
+ contact_open_doors: contacta amb Open Doors
+ html: Si tens obres a l'arxiu importat que estiguen vinculades a una adreça
+ de correu electrònic a què ja no tens accés, %{contact_open_doors_link}
+ amb qualsevol informació que puga ajudar a verificar la teua identitat.
+ text: Si tens obres a l'arxiu importat que estiguen vinculades a una adreça
+ de correu electrònic a què ja no tens accés, contacta amb Open Doors amb
+ qualsevol informació que puga ajudar a verificar la teua identitat.
+ questions:
+ contact_support: contacta amb el Suport de l'AO3
+ html: Per a altres consultes, %{contact_support_link}.
+ text: Per qualsevol altra consulta, pots contactar amb el Suport de l'AO3
+ a %{support_url}.
+ redirects:
+ html: Per tal de preservar les llistes de recomanacions i els marcadors, les
+ pàgines web de l'arxiu importat poden redirigir a una còpia importada de
+ les obres durant un temps limitat (pots revisar la publicació de la notícia
+ per assegurar-te). Si ja has publicat una còpia d'aquestes obres i %{negation}
+ has utilitzat l'opció d'importar des d'un URL, hi haurà dues còpies de la
+ mateixa obra a l'AO3.
+ subject: "[%{app_name}] Obres publicades"
+ update_redirect:
+ contact_open_doors: contactar amb Open Doors
+ html: Si vols que Open Doors actualitze la redirecció perquè enllace amb les
+ teues obres preexistents, pots esborrar la còpia importada i %{contact_open_doors_link}
+ amb el teu nom d'usuari o usuària de l'AO3 i de l'arxiu importat, i el títol
+ i URL de l'obra a la qual t'agradaria que es redirigira (si tens diverses
+ obres, pots fer una llista de totes en un mateix missatge).
+ text: Si vols que Open Doors actualitze la redirecció perquè enllace amb les
+ teues obres preexistents, pots esborrar la còpia importada i contactar amb
+ Open Doors a %{open_doors_url} incloent el teu nom d'usuari o usuària de
+ l'AO3 i de l'arxiu importat i el títol i URL de l'obra a la qual t'agradaria
+ que es redirigira (si tens diverses obres, pots fer una llista de totes
+ en un mateix missatge).
+ works_by: 'Aquestes obres foren escrites amb el correu electrònic: %{email}'
+ work_info:
+ html: "%{work_link} (%{fandom})"
+ text:
+ no_fandom: "- %{work_title} %{work_url}"
+ with_fandom: "- %{work_title} %{work_url} (%{fandom})"
+ collection_notification:
+ assignments_sent:
+ complete: S'han enviat totes les tasques.
+ subject: Tasques enviades
+ html:
+ received_message: 'Has rebut un missatge sobre la col·lecció %{collection_link}:'
+ text:
+ received_message: 'Has rebut un missatge sobre la col·lecció "%{collection_title}"
+ (%{collection_url}):'
+ creatorship_notification:
+ explanation: Quan ets cocreador/a d'una obra, se't pot afegir a nous capítols
+ independentment de la configuració de la cocreació que tinguis al teu perfil.
+ També se t'afegirà a qualsevol sèrie a la qual s'incorpori la teva obra.
+ html:
+ creation: "%{creation_link} de %{pseud_links}"
+ edit_chapter: editar el capítol
+ edit_series: editar la sèrie
+ remove_chapter: Si t'han afegit per error o no vols que et registrin com a
+ creador/a, pots %{edit_chapter_link} per eliminar-te com a creador/a.
+ remove_series: Si t'han afegit per error o no vols que et registrin com a
+ creador/a, pots %{edit_series_link} per eliminar-te com a creador/a.
+ intro_chapter: 'L''usuari/ària %{adding_user} ha registrat el teu pseudònim
+ %{pseud} com a cocreador/a del següent capítol:'
+ intro_series: 'L''usuari/ària %{adding_user} ha registrat el teu pseudònim %{pseud}
+ com a cocreador/a de la següent sèrie:'
+ subject: "[%{app_name}] Notificació de cocreador/a"
+ text:
+ creation: "%{title} (%{url}) de %{pseuds}"
+ remove_chapter: 'Si t''han afegit per error o no vols que et registrin com
+ a creador/a, pots editar el capítol per eliminar-te com a creador/a: %{url}'
+ remove_series: 'Si t''han afegit per error o no vols que et registrin com
+ a creador/a, pots editar la sèrie per eliminar-te com a creador/a: %{url}'
+ creatorship_notification_archivist:
+ explanation: Actuant sota la seva competència d'arxivista de l'Open Doors (Projecte
+ de Portes Obertes), l'arxivista té permís per afegir-te sense invitació, fins
+ i tot si tens la configuració de co-creació desactivada.
+ html:
+ creation: "%{creation_link} de %{pseud_links}"
+ edit_chapter: editar el capítol
+ edit_series: editar la sèrie
+ edit_work: editar l'obra
+ remove_chapter: Si has estat afegit/da per error o no vols ser registrat/da
+ com a creador/a, pots %{edit_chapter_link} per eliminar-te com a creador/a.
+ remove_series: Si has estat afegit/da per error o no vols ser registrat/da
+ com a creador/a, pots %{edit_series_link} per eliminar-te com a creador/a.
+ remove_work: Si has estat afegit/da per error o no vols ser registrat/da com
+ a creador/a, pots %{edit_work_link} per eliminar-te com a creador/a.
+ intro_chapter: 'L''usuari/ària %{archivist} ha afegit el teu pseudònim %{pseud}
+ com a co-creador/a del capítol següent:'
+ intro_series: 'L''usuari/ària %{archivist} ha afegit el teu pseudònim %{pseud}
+ com a cocreador/a de la sèrie següent:'
+ intro_work: 'L''usuari/ària %{archivist} ha afegit el teu pseudònim %{pseud}
+ com a cocreador/a de la següent obra:'
+ subject: "[%{app_name}] Notificació de cocreador/a de l'arxivista"
+ text:
+ creation: "%{title} (%{url}) de %{pseuds}"
+ remove_chapter: 'Si has estat afegit/da per error o no vols ser registrat/da
+ com a creador/a, pots editar el capítol per eliminar-te com a creador/a:
+ %{url}'
+ remove_series: 'Si has estat afegit/da per error o no vols ser registrat/da
+ com a creador/a, pots editar la sèrie per eliminar-te com a creador/a: %{url}'
+ remove_work: 'Si has estat afegit/da per error o no vols ser registrat/da
+ com a creador/a, pots editar l''obra per eliminar-te com a creador/a: %{url}'
+ creatorship_request:
+ html:
+ creation: "%{creation_link} de %{pseud_links}"
+ instructions: Pots acceptar o rebutjar aquesta petició a la pàgina %{page_name}.
+ page_name: Co-Creator Requests (Peticions de cocreador/a)
+ intro_chapter: 'L''usuari/a %{inviting_user} ha convidat el teu pseudònim %{pseud}
+ a registrar-se com a cocreador/a al següent capítol:'
+ intro_series: 'L''usuari/a %{inviting_user} ha convidat el teu pseudònim %{pseud}
+ a registrar-se com a cocreador/a a la següent sèrie:'
+ intro_work: 'L''usuari/a %{inviting_user} ha convidat el teu pseudònim %{pseud}
+ a registrar-se com a cocreador/a a la següent obra:'
+ subject: "[%{app_name}] Petició de cocreador/a"
+ text:
+ creation: "%{title} (%{url}) de %{pseuds}"
+ instructions: 'Pots acceptar o rebutjar aquesta petició a la pàgina Co-Creator
+ Requests (Peticions de cocreador/a): %{url}'
+ delete_work_notification:
+ attachment: Pots trobar adjunta una còpia de la teva obra com a referència.
+ deleted_other:
+ html: La teva obra %{title} ha estat eliminada a petició de %{pseud}.
+ text: La teva obra "%{title}" ha estat eliminada a petició de %{pseud}.
+ deleted_yourself:
+ html: La teva obra %{title} ha estat eliminada a petició teva.
+ text: La teva obra "%{title}" ha estat eliminada a petició teva.
+ questions:
+ html: Si us plau, si tens cap pregunta %{support}.
+ text: Si us plau, si tens cap pregunta %{support} (%{url}).
+ subject: "[%{app_name}] La teva obra ha estat eliminada"
+ support: contacta amb Suport
+ invitation_to_claim:
+ access:
+ text: Depenent de l’arxiu, les teves obres es poden haver importat de manera
+ restringida només per als usuaris registrats (per evitar que apareguin a
+ les cerques de Google). Si aquest és el cas, les obres només seran accessibles
+ per usuaris que hagin iniciat sessió, a menys que escullis fer-les completament
+ visibles. Si necessites ajuda per desbloquejar, deixar orfes o eliminar
+ les teves obres, contacta amb Suport de l’AO3.
+ claim_or_remove:
+ html: Reclama o elimina les teves obres aquí.
+ text: 'Reclama o elimina les teves obres aquí: %{claim_url}'
+ email_tips: Si t’estàs posant en contacte amb nosaltres; si us plau, aprova
+ les adreces de correu electrònic de @transformativeworks.org i revisa la teva
+ carpeta de correu brossa si no trobes la nostra resposta.
+ html:
+ ao3_news: notícies de l’AO3
+ contact_open_doors: contacta amb l’Open Doors
+ contact_support: contacta amb Suport de l’AO3
+ faq_page: pàgina de preguntes freqüents
+ tutorial_page: pàgina del tutorial
+ introduction:
+ text: Estàs rebent aquest correu perquè un arxiu ha estat importat recentment
+ per l’Open Doors (%{open_doors_link}) a %{app_name} (%{app_short_name} -
+ %{app_url}), i creiem que les obres següents et poden pertànyer. Volem donar-te
+ l’oportunitat de reclamar (o eliminar/deixar orfes) aquestes obres si és
+ el que vols. I en cas que no tinguis ja un compte associat a una altra adreça
+ de correu, ens agradaria convidar-te a l’arxiu!
+ mistake:
+ text: Si això és un error i aquestes obres no són teves, no les esborris,
+ si us plau! Contacta amb l’Open Doors (%{open_doors_link}) i nosaltres ens
+ n’encarregarem.
+ more_info:
+ text: Pots llegir els anuncis sobre trasllats recents a l’arxiu a les notícies
+ de l’AO3 (%{news_link}), i trobar informació addicional a la pàgina de preguntes
+ freqüents de l’Open Doors (%{open_doors_faq_link}) o bé a la pàgina de tutorials
+ (%{open_doors_tutorial_link}). Per a qualsevol pregunta respecte a la qual
+ no vegis resposta a la pàgina de preguntes freqüents, als tutorials o en
+ aquest correu, contacta amb %{support_link}, si us plau.
+ other_works:
+ text: Si tens altres obres a l’arxiu importat enllaçades a una adreça de correu
+ a la qual ja no tens accés, contacta amb l’Open Doors amb qualsevol informació
+ que pugui ajudar a verificar la teva identitat, si us plau.
+ questions:
+ text: Per a qualsevol altre dubte, contacta amb Suport de l’AO3 a %{support_link}.
+ redirects: Per preservar llistes de recomanacions i marcadors, les adreces web
+ dels arxius importats poden redirigir a la còpia importada d’aquestes obres
+ durant un temps limitat (comprova la publicació de l’anunci del trasllat del
+ teu arxiu per assegurar-te’n). Si ja has publicat una còpia d’aquestes obres
+ i NO has fet servir l’eina d’importar des de l’URL, hi haurà dues còpies de
+ la mateixa obra a l’arxiu.
+ subject: "[%{app_name}] Invitació a reclamar obres"
+ unwanted:
+ text: Si aquestes obres són teves però no les vols, les pots deixar orfes
+ (de manera que es mantenen a l’AO3 però el teu nom queda eliminat) o esborrar-les
+ (de manera que quedin completament eliminades de l’AO3). No necessites afegir
+ aquestes obres a cap compte per tal d’abandonar-les o eliminar-les, ho pots
+ fer directament des de l’enllaç de reclamació de més amunt. (Si necessites
+ ajuda, contacta amb Suport a %{support_link}.)
+ update_redirect:
+ text: Si t’agradaria que l’Open Doors actualitzés la redirecció per dirigir-la
+ a la teva obra preexistent; si us plau, esborra la còpia importada i contacta
+ amb l’Open Doors a %{open_doors_link} tot indicant el nom del teu compte
+ a l’AO3, el nom del teu compte a l’arxiu importat i el títol i l’URL de
+ l’obra a la qual voldries efectuar la nova redirecció. (Si vols canviar
+ la redirecció de diverses obres, pots enumerar-les en un sol correu electrònic.)
+ uploaded_list: 'Les obres pujades inclouen:'
+ invite_increase_notification:
+ html:
+ body:
+ one: Tan sols et volíem fer saber que tens %{count} invitació nova, que
+ pots fer servir per crear nous comptes a l'AO3. Pots convidar una amistat
+ a %{invitation_page_link}.
+ other: Tan sols et volíem fer saber que tens %{count} invitacions noves,
+ que pots fer servir per crear nous comptes a l'AO3. Pots convidar una
+ amistat a %{invitation_page_link}.
+ invitation_page_link_text: la teva pàgina d'Invitations (Invitacions)
+ subject: "[%{app_name}] Noves invitacions"
+ text:
+ body:
+ one: Tan sols et volíem fer saber que tens %{count} invitació nova, que
+ pots fer servir per crear nous comptes a l'AO3. Pots convidar una amistat
+ a la teva pàgina d'invitacions (%{invitation_page_url}).
+ other: Tan sols et volíem fer saber que tens %{count} invitacions noves,
+ que pots fer servir per crear nous comptes a l'AO3. Pots convidar una
+ amistat a la teva pàgina d'invitacions (%{invitation_page_url}).
+ invite_request_declined:
+ main:
+ one: Lamentem informar-te que la teva sol·licitud d'una nova invitació no
+ es pot satisfer en aquests moments.
+ other: Lamentem informar-te que la teva sol·licitud de %{count} noves invitacions
+ no es pot satisfer en aquests moments.
+ reason: 'La teva sol·licitud era:'
+ subject: "[%{app_name}] Sol·licitud de codi d'invitació addicional rebutjada"
+ recipient_notification:
+ html:
+ collection: S'ha publicat una obra de regal per a tu a la col·lecció %{collection_link}
+ a l'AO3!
+ no_collection: S'ha publicat una obra de regal per a tu a l'AO3!
+ subject:
+ collection: "[%{app_name}][%{collection_title}] Una obra de regal per a tu
+ de %{collection_title}"
+ no_collection: "[%{app_name}] Una obra de regal per a tu"
+ text:
+ collection: S'ha publicat una obra de regal per a tu a la col·lecció "%{collection_title}"
+ (%{collection_url}) a l'AO3!
+ signup_notification:
+ activate:
+ html: Si us plau %{activate_account_link}.
+ text: 'Segueix aquest enllaç per activar el teu compte: %{activate_account_url}'
+ activate_your_account: segueix aquest enllaç per activar el teu compte
+ admin_posts: Notícies de l’AO3
+ bye: Esperem que estiguis gaudint amb l’Arxiu.
+ contact_support: contacta amb l’equip de Suport
+ faq: pàgina de Preguntes freqüents (FAQ)
+ features:
+ html: Un cop el teu compte estigui activat, podràs publicar les teves obres
+ de fan, configurar les teves subscripcions per a que t’avisem per correu
+ electrònic quan hi hagi cap actualització de les teves obres i creadors
+ o creadores preferides, personalitzar l’aspecte de la pàgina, controlar
+ a quines obres has accedit a l’AO3 fent servir el teu historial, i molt
+ més.
+ text: Un cop el teu compte estigui activat, podràs publicar les teves obres
+ de fan, configurar les teves subscripcions per a que t’avisem per correu
+ electrònic quan hi hagi cap actualització de les teves obres i creadors
+ o creadores preferides, personalitzar l’aspecte de la pàgina, controlar
+ a quines obres has accedit a l’AO3 fent servir el teu historial, i molt
+ més.
+ information:
+ html: Hi ha molta informació i consells sobre com fer servir l’AO3 a %{faq_link}.
+ A %{admin_posts_link} trobaràs les últimes notícies sobre el desenvolupament
+ del lloc web. Si necessites més ajuda, trobes un error o tens preguntes
+ o suggeriments, %{contact_support_link}, on t’ajudaran de bon grat.
+ text: 'Disposes de molta informació i consells sobre com fer servir l’AO3
+ a la pàgina de FAQ %{faq_url}. Trobaràs les últimes notícies sobre el desenvolupament
+ del lloc web a %{admin_posts_url}. Si necessites més ajuda, trobes un error
+ o tens preguntes o suggeriments, contacta amb Suport, on t’ajudaran de bon
+ grat: %{contact_support_url}.'
+ welcome: Benvingut/da a l’Archive of Our Own - AO3, %{login}!
diff --git a/config/locales/phrase-exports/cs.yml b/config/locales/phrase-exports/cs.yml
new file mode 100755
index 0000000..bce1fa5
--- /dev/null
+++ b/config/locales/phrase-exports/cs.yml
@@ -0,0 +1,632 @@
+---
+cs:
+ activerecord:
+ attributes:
+ archive_warning:
+ name_with_colon:
+ few: 'Varování:'
+ one: 'Varování:'
+ other: 'Varování:'
+ category:
+ name_with_colon:
+ few: 'Kategorie:'
+ one: 'Kategorie:'
+ other: 'Kategorií:'
+ character:
+ name_with_colon:
+ few: 'Postavy:'
+ one: 'Postava:'
+ other: 'Postav:'
+ fandom:
+ name_with_colon:
+ few: 'Fandomy:'
+ one: 'Fandom:'
+ other: 'Fandomů:'
+ freeform:
+ name_with_colon:
+ few: 'Vedlejší tagy:'
+ one: 'Vedlejší tag:'
+ other: 'Vedlejších tagů:'
+ rating:
+ name_with_colon: 'Zařazení:'
+ relationship:
+ name_with_colon:
+ few: 'Vztahy:'
+ one: 'Vztah:'
+ other: 'Vztahů:'
+ work:
+ chapter_total_display: Kapitoly
+ summary: Shrnutí
+ models:
+ archive_warning:
+ few: Varování
+ one: Varování
+ other: Varování
+ category:
+ few: Kategorie
+ one: Kategorie
+ other: Kategorií
+ chapter:
+ few: Kapitoly
+ one: Kapitola
+ other: Kapitol
+ character:
+ few: Postavy
+ one: Postava
+ other: Postav
+ fandom:
+ few: Fandomy
+ one: Fandom
+ other: Fandomů
+ freeform:
+ few: Vedlejší tagy
+ one: Vedlejší tag
+ other: Vedlejších tagů
+ rating:
+ few: Zařazení
+ one: Zařazení
+ other: Zařazení
+ relationship:
+ few: Vztahy
+ one: Vztah
+ other: Vztahů
+ series:
+ few: Série
+ one: Série
+ other: Sérií
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ few: "%{count} návštěvníků"
+ many: "%{count} návštěvníků"
+ one: návštěvníka
+ other: "%{count} návštěvníků"
+ left_kudos:
+ html:
+ few: Práce %{commentable_link} dostala kudos od %{givers_list}.
+ one: Práce %{commentable_link} dostala kudos od %{givers_list}.
+ other: Práce %{commentable_link} dostala kudos od %{givers_list}.
+ text:
+ few: Práce %{commentable_title} (%{commentable_url}) dostala kudos od %{givers_list}.
+ one: Práce %{commentable_title} (%{commentable_url}) dostala kudos od %{givers_list}.
+ other: Práce %{commentable_title} (%{commentable_url}) dostala kudos od
+ %{givers_list}.
+ single_guest:
+ giver: návštěvníka
+ html: Práce %{commentable_link} dostala kudos od %{giver}.
+ text: Práce %{commentable_title} (%{commentable_url}) dostala kudos od návštěvníka.
+ subject: "[%{app_name}] Máte nové kudos!"
+ mailer:
+ general:
+ closing:
+ formal: S pozdravem,
+ informal: S pozdravem,
+ creation:
+ link_with_word_count: "%{creation_link} (%{word_count})"
+ title_with_chapter_number: Kapitola %{position} z díla %{title}
+ title_with_word_count: '"%{creation_title}" (%{word_count})'
+ word_count:
+ few: "%{count} slova"
+ one: "%{count} slovo"
+ other: "%{count} slov"
+ footer:
+ general:
+ about:
+ html: AO3 je fanoušky vedený a podporovaný archiv, který spoléhá %{donate_link}.
+ text: 'AO3 je fanoušky vedený a podporovaný archiv, který spoléhá na vaše
+ příspěvky: %{donate_url}.'
+ html:
+ donate_link_text: na vaše příspěvky
+ support_link_text: kontaktujte Podporu
+ unwanted_email:
+ html: Pokud jste obdrželi tuto zprávu omylem, prosím %{support_link}.
+ text: Pokud jste obdrželi tuto zprávu omylem, prosím kontaktujte Podporu
+ na %{support_url}.
+ sent_at: Odesláno v %{sent_at}.
+ greeting:
+ formal_html: Dobrý den, %{name},
+ informal:
+ addressed_html: Ahoj %{name},
+ unaddressed: Ahoj,
+ introductory: Zdravíme z Archive of Our Own – AO3 (Našeho vlastního archivu),
+ metadata_label_indicator: ":"
+ signature:
+ abuse_team: AO3 Dohled na dodržování pravidel a zásad serveru
+ app_short_name: AO3
+ open_doors: The Open Doors team (Tým Otevřených dvěří)
+ parent_org: Organization for Transformative Works – OTW (Společnost pro transformativní
+ tvorbu)
+ support: Podpora AO3
+ users:
+ mailer:
+ reset_password_instructions:
+ expiration: Pokud tento odkaz nepoužijete ke změně hesla do sedmi dnů, vyprší
+ a budete si muset zažádat o nový.
+ intro: 'Byl vznesen požadavek na změnu hesla pro váš účet. Své heslo můžete
+ změnit kliknutím na následující odkaz a vyplněním nového hesla:'
+ link_title: Změnit heslo.
+ subject: "[%{app_name}] Změňte své heslo"
+ unrequested: Pokud jste si změnu hesla nevyžádal/a, můžete tento email ignorovat
+ a vaše stávající heslo zůstane platné.
+ user_mailer:
+ admin_deleted_work_notification:
+ bye: Kopii své práce najdete v příloze.
+ contact_abuse: obraťte se prosím na Dohled na dodržování pravidel a zásad serveru
+ deleted:
+ html: Vaše práce s názvem %{title} byla z Archivu smazána administrátorem.
+ text: Vaše práce s názvem "%{title}" byla z Archivu smazána administrátorem.
+ html:
+ tos_violation: V případě, že je možné, že vaše práce porušila Smluvní podmínky
+ Archivu, %{contact_abuse_link}.
+ import_project:
+ html: V případě, že byla vaše práce součástí projektu Open Doors (Otevřené
+ dveře), %{opendoors_link}.
+ text: V případě, že byla vaše práce součástí projektu Open Doors (Otevřené
+ dveře), obraťte se s případnými dotazy na Open Doors (%{opendoors_link}).
+ opendoors: obraťte se s případnými dotazy na Open Doors
+ subject: "[%{app_name}] Vaše práce byla smazána administrátorem"
+ text:
+ tos_violation: V případě, že je možné, že vaše práce porušila Smluvní podmínky
+ Archivu, obraťte se prosím na Dohled na dodržování pravidel a zásad serveru
+ (%{contact_abuse_url}).
+ admin_hidden_work_notification:
+ access: Na stránku se skrytou prací se dostanete pomocí odkazu přiloženého výše,
+ nebude ale dostupná ostatním uživatelům AO3, a to ani ze stránky s přehledem
+ vašich prací.
+ check_email: Je možné, že vás výbor pro Dohled již kontaktoval ohledně důvodu
+ pro skrytí této práce, zkontrolujte proto prosím svoji e-mailovou schránku,
+ a to včetně složky s nevyžádanou poštou (spam).
+ contact_abuse: kontaktujte prosím výbor pro Dohled
+ html:
+ help: Pokud si nejste jisti, proč byla vaše práce skryta, a neobdrželi jste
+ další informace, na tento e-mail neodpovídejte, ale %{contact_abuse_link}.
+ hidden: Vaše práce %{title} byla skryta výborem pro Dohled na dodržování pravidel
+ a zásad serveru a není proto nadále veřejně dostupná.
+ tos_violation: Pokud byla vaše práce skryta z důvodu porušení %{tos_link}
+ AO3, budete muset práci upravit tak, aby s nimi byla v souladu. Pokud tak
+ neučiníte, hrozí, že bude vaše práce smazána.
+ subject: "[%{app_name}] Vaše práce byla skryta výborem pro Dohled na dodržování
+ pravidel a zásad serveru"
+ text:
+ help: 'Pokud si nejste jisti, proč byla vaše práce skryta, a neobdrželi jste
+ další informace, na tento e-mail neodpovídejte, ale kontaktujte prosím výbor
+ pro Dohled: %{contact_abuse_url}.'
+ hidden: Vaše práce "%{title}" (%{work_url}) byla skryta výborem pro Dohled
+ na dodržování pravidel a zásad serveru, a není proto nadále veřejně dostupná.
+ tos_violation: Pokud byla vaše práce skryta z důvodu porušení smluvních podmínek
+ AO3 (%{tos_url}), budete muset práci upravit tak, aby s nimi byla v souladu.
+ Jestli tak neučiníte, hrozí, že bude vaše práce smazána.
+ tos: smluvních podmínek
+ anonymous_or_unrevealed_notification:
+ anonymous_info: Anonymní práce jsou zahrnuty ve výpisech tagů, ale nejsou uvedeny
+ na stránce s vašimi pracemi. U dané práce bude vaše uživatelské jméno nahrazeno
+ slovem "Anonymous" (Anonymní).
+ anonymous_unrevealed_info: Správci sbírky mohou vaši práci později zveřejnit,
+ ale ponechat ji v anonymitě. Lidé, kteří se přihlásili k odběru vašich prací,
+ nebudou o této změně informováni. Po zveřejnění bude vaše práce zahrnuta ve
+ výpisech tagů, ale nebude uvedena na stránce s vašimi pracemi. Na stránce
+ práce bude vaše uživatelské jméno nahrazeno slovem "Anonymous" (Anonymní).
+ changed_status:
+ anonymous:
+ html: Správci sbírky %{collection_link} změnili status vaší práce %{work_link}
+ na anonymní.
+ text: Správci sbírky %{collection_title}" (%{collection_url}) změnili status
+ vaší práce "%{work_title}" (%{work_url}) na anonymní.
+ anonymous_unrevealed:
+ html: Správci sbírky %{collection_link} změnili status vaší práce %{work_link}
+ na anonymní a nezveřejněný.
+ text: Správci sbírky "%{collection_title}" (%{collection_url}) změnili status
+ vaší práce "%{work_title}" (%{work_url}) na nezveřejněný a anonymní.
+ unrevealed:
+ html: Správci sbírky %{collection_link} změnili stav vaší práce %{work_link}
+ na nezveřejněný.
+ text: Správci sbírky "%{collection_title}" (%{collection_url}) změnili status
+ vaší práce "%{work_title}" (%{work_url}) na nezveřejněný.
+ collection_items_link_text: Approved Collection Items (Schválené položky sbírky)
+ do_not_want:
+ anonymous:
+ html: Pokud nechcete, aby vaše práce byla anonymní, navštivte prosím stránku
+ %{collection_items_link} a odstraňte ji z této sbírky.
+ text: 'Pokud nechcete, aby vaše práce byla anonymní, navštivte prosím stránku
+ Approved Collection Items (Schválené položky sbírky) a odstraňte ji z
+ této sbírky: %{collection_items_url}'
+ anonymous_unrevealed:
+ html: Pokud nechcete, aby vaše práce byla anonymní a nezveřejněná, navštivte
+ prosím stránku %{collection_items_link} a odstraňte ji z této sbírky.
+ text: 'Pokud nechcete, aby vaše práce byla anonymní a nezveřejněná, navštivte
+ prosím stránku Approved Collection Items (Schválené položky sbírky) a
+ odstraňte ji z této sbírky: %{collection_items_url}'
+ unrevealed:
+ html: Pokud nechcete, aby vaše práce byla nezveřejněna, navštivte prosím
+ stránku %{collection_items_link} a odstraňte ji z této sbírky.
+ text: 'Pokud nechcete, aby vaše práce byla nezveřejněna, navštivte prosím
+ stránku Approved Collection Items (Schválené položky sbírky) a odstraňte
+ ji z této sbírky: %{collection_items_url}'
+ faq_link_text: Časté otázky ke sbírkám
+ more_info:
+ html: Pro více informací navštivte stránku %{faq_link}.
+ text: 'Pro více informací navštivte stránku Časté otázky ke sbírkám: %{faq_url}'
+ subject:
+ anonymous: "[%{app_name}] Vaše práce byla anonymizována"
+ anonymous_unrevealed: "[%{app_name}] Vaše práce byla anonymizována a skryta"
+ unrevealed: "[%{app_name}] Vaše práce byla skryta"
+ unrevealed_info: Nezveřejněné práce nejsou zahrnuty ve výpisech tagů ani uvedeny
+ na stránce s vašimi pracemi. Každý, kdo bude následovat odkaz na tuto práci,
+ obdrží oznámení, že práce je v danou chvíli nezveřejněná, a nebude mít přístup
+ k jejímu obsahu.
+ archivist_added_to_collection_notification:
+ approved_collection_items_page: stránku Approved Collection Items (Povolených
+ položek sbírek)
+ archivist_notice: Díky své funkci jakožto archiváři Open Doors (Otevřených dveří)
+ mají správci sbírky právo vaši práci přidat do této sbírky i v případě, že
+ nemáte přijímání pozvánek ke sbírkám povolené. Archiváři přidají práci do
+ sbírky pouze v případě, že byla zveřejněna v importovaném archivu.
+ removal_instructions:
+ html: Pokud chcete svoji práci z této sbírky odebrat, navštivte prosím svoji
+ %{approved_items_link}
+ text: 'Pokud chcete svoji práci z této sbírky odebrat, navštivte prosím svoji
+ stránku Approved Collection Items (Povolených položek sbírek): %{approved_items_url}.'
+ subject: "[%{app_name}][%{collection_title}] Archivář Open Doors (Otevřených
+ dveří) přidal vaši práci do sbírky"
+ work_added:
+ html: Správci sbírky %{collection_link} přidali vaši práci %{work_link} do
+ své sbírky!
+ text: Správci sbírky "%{collection_title}" (%{collection_url}) přidali vaši
+ práci "%{work_title}" (%{work_url}) do své sbírky!
+ challenge_assignment_notification:
+ any: Jakékoliv
+ assignment:
+ html: Byla vám přidělena následující žádost ve výzvě %{link} na AO3!
+ description: 'Popis:'
+ due: 'Termín splnění zadání je do:'
+ html:
+ footer: Tento e-mail jste obdrželi, protože jste se přihlásili do výzvy %{title}.
+ Více informací o této výzvě a kontakty na moderátory najdete na %{footer_link}.
+ footer_link: profilové stránce výzvy
+ look_up: Toto zadání můžete najít na %{link}.
+ look_up_link: své stránce Assignments (Zadání)
+ optional_tags: 'Volitelné tagy:'
+ prompts: 'Náměty:'
+ prompt_url: 'URL námětu:'
+ recipient: 'Příjemce:'
+ recipient_missing: 'Žádný: pro pomoc kontaktujte moderátora!'
+ subject: "[%{app_name}][%{collection_title}] Vaše zadání!"
+ text:
+ assignment: Byla vám přidělena následující žádost ve výzvě "%{collection_title}"
+ (%{collection_url}) na AO3!
+ footer: Tento e-mail jste obdrželi, protože jste se přihlásili do výzvy %{title}
+ (%{url}). Více informací o této výzvě a kontakty na moderátory můžete najít
+ na %{profile_url}.
+ look_up: Toto zadání najdete na stránce Assignments (Zadání) pod odkazem %{link}.
+ change_email:
+ changed:
+ html: "%{login}, email spojený s vaším účtem byl změněn na %{email}"
+ text: "%{login}, email spojený s vaším účtem byl změněn na %{email}"
+ subject: "[%{app_name}] Email byl změněn"
+ claim_notification:
+ access:
+ contact_support: kontaktujte Podporu AO3
+ html: V závislosti na typu archivu mohou být vaše importované práce dostupné
+ pouze registrovaným uživatelům (aby se nedostaly do vyhledávání Google).
+ Pokud se nerozhodnete je zpřístupnit všem uživatelům, budou přístupné pouze
+ přihlášeným uživatelům. Pokud potřebujete pomoct s odemknutím, opuštěním
+ nebo odstraněním vašich prací, %{contact_support_link}.
+ text: 'V závislosti na typu archivu mohou být vaše importované práce dostupné
+ pouze registrovaným uživatelům (aby se nedostaly do vyhledávání Google).
+ Pokud se nerozhodnete je zpřístupnit všem uživatelům, budou přístupné pouze
+ přihlášeným uživatelům. Pokud potřebujete pomoct s odemknutím, opuštěním
+ nebo odstraněním vašich prací, kontaktujte Podporu AO3: %{support_url}.'
+ email_tips: Pokud nás kontaktujete, přidejte si prosím e-mailové adresy @transformativeworks.org
+ do seznamu bezpečných kontaktů a zkontrolujte si složky spam, jestli v nich
+ nenajdete naši odpověď.
+ introduction:
+ ao3_name: Archive of Our Own – AO3 (Našeho vlastního archivu)
+ html: Tento e-mail vám zasíláme, protože jste měli v archivu fanouškovských
+ děl práce, které byly v rámci projektu %{open_doors_name_link} naimportovány
+ do %{app_link}. Vzhledem k tomu, že tato e-mailová adresa je spojena s adresou
+ zaregistrovanou v importovaném archivu, byla související (níže uvedená)
+ fanouškovská díla automaticky přiřazena k vašemu účtu na AO3 (Našem vlatním
+ archivu).
+ open_doors_name: Open Doors (Otevřené dveře)
+ text: 'Tento e-mail vám zasíláme, protože jste měli v archivu fanouškovských
+ děl práce, které byly v rámci projektu Open Doors (Otevřené dveře) (%{open_doors_url})
+ naimportovány do Archive of Our Own – AO3 (Našeho vlastního archivu): %{app_url}.
+ Vzhledem k tomu, že tato e-mailová adresa je spojena s adresou zaregistrovanou
+ v importovaném archivu, byla související (níže uvedená) fanouškovská díla
+ automaticky přiřazena k vašemu účtu na AO3.'
+ mistake:
+ contact_open_doors: kontaktovat projekt Open Doors
+ html: Máte-li pocit, že vám tento e-mail by zaslán omylem a nejedná se o vaše
+ práce, prosím nemažte je! Stačí %{contact_open_doors_link} a my to vyřešíme.
+ text: Máte-li pocit, že vám tento e-mail by zaslán omylem a nejedná se o vaše
+ práce, prosím nemažte je! Stačí kontaktovat Open Doors (%{open_doors_url})
+ a my to vyřešíme.
+ more_info:
+ ao3_news: Novinek AO3
+ contact_support: Podporu AO3
+ faq_page: Často kladené dotazy (FAQ)
+ html: Oznámení o nedávných přesunech v archivu si můžete přečíst na stránkách
+ %{ao3_news_link} a dodatečné informace najdete na stránkách Open Doors %{faq_page_link}
+ nebo %{tutorial_page_link}. Jakékoli otázky, na které nenaleznete odpověď
+ v často kladených dotazech, návodech nebo v tomto e-mailu, prosím směřujte
+ na %{contact_support_link}.
+ text: 'Oznámení o nedávných přesunech archivu si můžete přečíst na stránkách
+ Novinek AO3 (%{news_url}) a dodatečné informace najdete na stránkách Open
+ Doors s často kladenými dotazy (%{open_doors_faq_url}) nebo s návody (%{open_doors_tutorial_url}).
+ Jakékoli otázky, na které nenaleznete odpověď v často kladených dotazech,
+ návodech nebo v tomto e-mailu, prosím směřujte na Podporu AO3: %{support_url}.'
+ tutorial_page: Návody
+ other_works:
+ contact_open_doors: kontaktujte projekt Open Doors
+ html: Pokud jste v importovaném archivu měli další práce vedené pod e-mailovou
+ adresou, ke které již nemáte přístup, prosím %{contact_open_doors_link}
+ s jakoukoli informací, která pomůže k ověření vaší totožnosti.
+ text: Pokud jste v importovaném archivu měli další práce vedené pod e-mailovými
+ adresami, ke kterým již nemáte přístup, prosím kontaktujte projekt Open
+ Doors s jakoukoli informací, která pomůže k ověření vaší totožnosti.
+ questions:
+ contact_support: kontaktujte Podporu AO3
+ html: S dalšími dotazy prosím %{contact_support_link}.
+ text: 'S dalšími dotazy prosím kontaktujte Podporu AO3: %{support_url}.'
+ redirects:
+ html: Z důvodu zachování záložek a seznamů s doporučeními mohou být webové
+ adresy importovaného archivu po omezenou dobu přesměrovány na importovanou
+ kopii těchto prací (pro jistotu zkontrolujte oznámení k vašemu archivu).
+ Pokud jste již nahráli kopii těchto děl a %{negation}použili jste funkci
+ importování z URL, budou na webu AO3 dvě kopie též práce.
+ subject: "[%{app_name}] Nahrané práce"
+ update_redirect:
+ contact_open_doors: kontaktujte projekt Open Doors
+ html: Chcete-li, aby projekt Open Doors aktualizoval přesměrování na vaši
+ již existující práci, smažte prosím importovanou kopii a %{contact_open_doors_link}
+ se jménem vašeho účtu na AO3, vašeho účtu v importovaném archivu a názvem
+ a URL fanouškovské práce, ke které chcete, aby přesměrování vedlo. (Pokud
+ máte více prací, u kterých chcete přesměrování změnit, můžete je uvést do
+ jednoho e-mailu.)
+ text: Chcete-li, aby projekt Open Doors aktualizoval přesměrování na vaši
+ již existující práci, smažte prosím importovanou kopii a kontaktujte projekt
+ Open Doors %{open_doors_url} se jménem vašeho účtu na AO3, vašeho účtu v
+ importovaném archivu a názvem a URL fanouškovské práce, ke které chcete,
+ aby přesměrování vedlo. (Pokud máte více prací, u kterých chcete přesměrování
+ změnit, můžete je uvést do jednoho e-mailu.)
+ works_by: 'Tyto práce byly vedeny pod e-mailovou adresou: %{email}'
+ work_info:
+ html: "%{work_link} (%{fandom})"
+ text:
+ no_fandom: "- %{work_title} %{work_url}"
+ with_fandom: "- %{work_title} %{work_url} (%{fandom})"
+ collection_notification:
+ assignments_sent:
+ complete: Všechna zadání byla rozeslána.
+ subject: Zadání rozeslána
+ html:
+ received_message: 'Máte novou zprávu o vaší sbírce %{collection_link}:'
+ text:
+ received_message: 'Máte novou zprávu o vaší sbírce "%{collection_title}" (%{collection_url}):'
+ creatorship_notification:
+ explanation: Pokud jste spoluautorkou/em práce, můžete být přidány/i jako spoluautorky/ři
+ nových kapitol nehledě na vaše nastavení preferencí ohledně spoluautorství.
+ Budete také přidáni do všech sérií, do nichž bude tato práce přidána.
+ html:
+ creation: "%{creation_link} od %{pseud_links}"
+ edit_chapter: editovat kapitolu
+ edit_series: editovat sérii
+ remove_chapter: Pokud jste byly/i přidány/i omylem nebo nechcete být uváděny/i
+ jako autor/ka, zvolte možnost %{edit_chapter_link}, abyste se autorství
+ zřekly/i.
+ remove_series: Pokud jste byly/i přidány/i omylem nebo nechcete být uváděny/i
+ jako autor/ka, zvolte možnost %{edit_series_link}, abyste se autorství zřekly/i.
+ intro_chapter: 'Uživatel/ka %{adding_user} uvedl/a váš pseudonym %{pseud} jako
+ spoluautorku/a následující kapitoly:'
+ intro_series: 'Uživatel/ka %{adding_user} uvedl/a váš pseudonym %{pseud} jako
+ spoluautorku/a následující série:'
+ subject: "[%{app_name}] Oznámení o spoluautorství"
+ text:
+ creation: "%{title} (%{url}) od %{pseuds}"
+ remove_chapter: 'Pokud jste byly/i přidány/i omylem nebo nechcete být uváděny/i
+ jako autor/ka, můžete kapitolu editovat, abyste se autorství zřekly/i: %{url}'
+ remove_series: 'Pokud jste byly/i přidány/i omylem nebo nechcete být uváděny/i
+ jako autor/ka, můžete sérii editovat, abyste se autorství zřekly/i: %{url}'
+ creatorship_notification_archivist:
+ explanation: Jedná s vlastní pravomocí jakožto archivář Open Doors (Otevřené
+ dveře), a proto vás smí přidat bez požadavku, i když jste pro svůj pseudonym
+ spoluautorství zakázali.
+ html:
+ creation: "%{creation_link} od %{pseud_links}"
+ edit_chapter: upravit kapitolu
+ edit_series: upravit sérii
+ edit_work: upravit práci
+ remove_chapter: Pokud jste byli přidáni omylem nebo nechcete být uváděni jako
+ autor, zvolte možnost %{edit_chapter_link}, abyste se autorství zřekli.
+ remove_series: Pokud jste byli přidáni omylem nebo nechcete být uváděni jako
+ autor, zvolte možnost %{edit_series_link}, abyste se autorství zřekli.
+ remove_work: Pokud jste byli přidáni omylem nebo nechcete být uváděni jako
+ autor, zvolte možnost %{edit_work_link}, abyste se autorství zřekli.
+ intro_chapter: 'Uživatel %{archivist} uvedl váš pseudonym %{pseud} jako spoluautora
+ následující kapitoly:'
+ intro_series: 'Uživatel %{archivist} uvedl váš pseudonym %{pseud} jako spoluautora
+ následující série:'
+ intro_work: 'Uživatel %{archivist} uvedl váš pseudonym %{pseud} jako spoluautora
+ následující práce:'
+ subject: "[%{app_name}] Oznámení archiváře o spoluautorství"
+ text:
+ creation: "%{title} (%{url}) od %{pseuds}"
+ remove_chapter: 'Pokud jste byli přidáni omylem nebo nechcete být uváděni
+ jako autor, můžete kapitolu upravit, abyste se autorství zřekli: %{url}'
+ remove_series: 'Pokud jste byli přidáni omylem nebo nechcete být uváděni jako
+ autor, můžete sérii upravit, abyste se autorství zřekli: %{url}'
+ remove_work: 'Pokud jste byli přidáni omylem nebo nechcete být uváděni jako
+ autor, můžete práci upravit, abyste se autorství zřekli: %{url}'
+ creatorship_request:
+ html:
+ creation: "%{creation_link} od %{pseud_links}"
+ instructions: Tuto pozvánku můžete přijmout nebo odmítnout na stránce %{page_name}.
+ page_name: Co-Creator Requests (Pozvánky ke spoluautorství)
+ intro_chapter: 'Uživatel %{inviting_user} zaslal vašemu pseudonymu %{pseud}
+ pozvánku ke spoluautorství u následující kapitoly:'
+ intro_series: 'Uživatel %{inviting_user} zaslal vašemu pseudonymu %{pseud} pozvánku
+ ke spoluautorství u následující série:'
+ intro_work: 'Uživatel %{inviting_user} zaslal vašemu pseudonymu %{pseud} pozvánku
+ ke spoluautorství u následující práce:'
+ subject: "[%{app_name}] Pozvánka ke spoluautorství"
+ text:
+ creation: "%{title} (%{url}) od %{pseuds}"
+ instructions: 'Tuto pozvánku můžete přijmout nebo odmítnout na stránce Co-Creator
+ Requests (Pozvánky ke spoluautorství): %{url}'
+ delete_work_notification:
+ attachment: Kopii své práce najdete v příloze.
+ deleted_other:
+ html: Vaše práce %{title} byla smazána na žádost %{pseud}.
+ text: Vaše práce "%{title}" byla smazána na žádost %{pseud}.
+ deleted_yourself:
+ html: Vaše práce %{title} byla na vaši žádost smazána.
+ text: Vaše práce "%{title}" byla na vaši žádost smazána.
+ questions:
+ html: Pokud máte nějaké otázky, prosím %{support}.
+ text: Pokud máte nějaké otázky, prosím %{support} (%{url}).
+ subject: "[%{app_name}] Vaše práce byla smazána"
+ support: kontaktujte Podporu
+ invitation_to_claim:
+ access:
+ text: V závislosti na archivu, ze kterého byly vaše práce importovány, mohly
+ být přístupné pouze registrovaným uživatelům (aby byly mimo dosah vyhledávání
+ Google). Pokud je toto váš případ, budou tyto práce dostupné pouze registrovaným
+ uživatelům, pokud se nerozhodnete učinit je plně přístupnými. Pro pomoc
+ se zpřístupňováním, opuštěním nebo mazáním vašich prací, kontaktujte prosím
+ Podporu AO3.
+ claim_or_remove:
+ html: Ke svým pracím se příhlásíte nebo je odstraníte zde.
+ text: 'Své práce si zaberete nebo odstraníte zde: %{claim_url}'
+ email_tips: Pokud se nás rozhodnete kontaktovat, prosím přidejte si e-mailové
+ adresy z domény @transformativeworks.org na svůj seznam povolených adres a
+ zkontrolujte složku spamu pro případ, že by naše odpověď skončila tam.
+ html:
+ ao3_news: AO3 News
+ contact_open_doors: kontaktujte Open Doors
+ contact_support: kontaktujte podporu AO3
+ faq_page: stránce FAQ - často kladených otázek
+ tutorial_page: stránce s návody
+ introduction:
+ text: Dostali jste tento e-mail, protože v rámci projektu Open Doors (Otevřené
+ dveře) (%{open_doors_link}) došlo v nedávné době k importu archivu do %{app_name}
+ (%{app_short_name} - %{app_url}) a my věříme, že následující práce patří
+ vám. Rádi bychom vám umožnili se k těmto pracím přihlásit (nebo je smazat/opustit),
+ pokud máte zájem. A pokud ještě nemáte účet pod jiným emailem, chceme vás
+ tímto pozvat na palubu!
+ mistake:
+ text: Pokud došlo k omylu a toto nejsou vaše práce, prosím, nemažte je! Prosím,
+ kontaktujte Open Doors (%{open_doors_link}) a my se o to postaráme.
+ more_info:
+ text: Oznámení o nedávných přesunech archivu si můžete přečíst na AO3 News
+ (%{news_link}) a další informace najdete na Open Doors' FAQ – stránce často
+ kladených otázek k projektu Otevřené dveře (%{open_doors_faq_link}) nebo
+ na stránce s návody (%{open_doors_tutorial_link}). S jakýmikoliv otázkami
+ nezodpovězenými v FAQ a návodech nebo v tomto e-mailu prosím kontaktujte
+ Podporu na %{support_link}.
+ other_works:
+ text: Pokud jste v tomto importovaném archivu měli další práce uvedené pod
+ e-mailovou adresou, k níž již nemáte přístup, prosím kontaktujte Open Doors
+ s jakoukoliv informací, která by mohla pomoci ověřit vaši identitu.
+ questions:
+ text: S dalšími dotazy prosím kontaktujte Podporu AO3 na %{support_link}.
+ redirects: Za účelem zachování seznamů doporučení a záložek mohou webové adresy
+ importovaného archivu krátkodobě odkazovat na importované kopie těchto prací
+ (abyste se ujistili, zkontrolujte příspěvek s oznámeními o vašem archivu).
+ Pokud jste již nahráli kopii této práce a NEpoužili jste možnost importovat
+ z URL, budou v archivu dvě kopie této práce.
+ subject: "[%{app_name}] Výzva k přihlášení se o vaše práce"
+ unwanted:
+ text: Pokud vám tyto práce opravdu patří, ale nechcete je, můžete je opustit
+ (pak zůstanou na AO3, ale vaše jméno bude odstraněno) nebo je smazat (pak
+ budou z AO3 úplně odstraněny). Pro opuštění nebo smazání nepotřebujete tyto
+ práce přidávat k účtu – můžete tak udělat přímo pod odkazem výše. (Pro pomoc
+ prosím kontaktujte Podporu na %{support_link}.)
+ update_redirect:
+ text: Pokud si přejete, aby projekt Open Doors změnil přesměrování na vaši
+ již existující práci, prosím smažte importovanou kopii, kontaktujte Open
+ Doors pomocí tohoto odkazu %{open_doors_link} a sdělte jim vaše jméno na
+ AO3, vaše jméno na importovaném archivu a název a URL práce, na kterou chcete,
+ aby odkaz přesměrovával. (Pokud si přejete změnit přesměrování pro více
+ prací, můžete je všechny vyjmenovat v jednom emailu.)
+ uploaded_list: 'Mezi nahranými pracemi se nachází:'
+ invite_increase_notification:
+ html:
+ body:
+ few: Rádi bychom vám sdělili, že máte %{count} nové pozvánky. Tyto pozvánky
+ lze využít k vytvoření nových účtů na AO3. Své přátele můžete pozvat na
+ %{invitation_page_link}.
+ one: Rádi bychom vám sdělili, že máte %{count} novou pozvánku, kterou lze
+ využít k vytvoření nových účtů na AO3. Své přátele můžete pozvat na %{invitation_page_link}.
+ other: Rádi bychom vám sdělili, že máte %{count} nových pozvánek. Tyto pozvánky
+ lze využít k vytvoření nových účtů na AO3. Své přátele můžete pozvat na
+ %{invitation_page_link}.
+ invitation_page_link_text: své stránce s pozvánkami
+ subject: "[%{app_name}] Nové pozvánky"
+ text:
+ body:
+ few: Rádi bychom vám sdělili, že máte %{count} nové pozvánky. Tyto pozvánky
+ lze využít k vytvoření nových účtů na AO3. Své přátele můžete pozvat na
+ své stránce s pozvánkami (%{invitation_page_url}).
+ one: Rádi bychom vám sdělili, že máte %{count} novou pozvánku, kterou lze
+ využít k vytvoření nových účtů na AO3. Své přátele můžete pozvat na své
+ stránce s pozvánkami (%{invitation_page_url}).
+ other: Rádi bychom vám sdělili, že máte %{count} nových pozvánek. Tyto pozvánky
+ lze využít k vytvoření nových účtů na AO3. Své přátele můžete pozvat na
+ své stránce s pozvánkami (%{invitation_page_url}).
+ invite_request_declined:
+ main:
+ few: S lítostí vám oznamujeme, že vaší žádosti o %{count} nové pozvánky nemůže
+ být v současné době vyhověno.
+ many: S lítostí vám oznamujeme, že vaší žádosti o %{count} nových pozvánek
+ nemůže být v současné době vyhověno.
+ one: S lítostí vám oznamujeme, že vaší žádosti o novou pozvánku nemůže být
+ v současné době vyhověno.
+ other: S lítostí vám oznamujeme, že vaší žádosti o %{count} nových pozvánek
+ nemůže být v současné době vyhověno.
+ reason: 'Vaše žádost byla:'
+ subject: "[%{app_name}] Žádost o dodatečnou pozvánku zamítnuta"
+ recipient_notification:
+ html:
+ collection: Vám darovaná práce byla zveřejněna ve sbírce %{collection_link}
+ na AO3!
+ no_collection: Na AO3 pro vás byla zveřejněna fanouškovská práce!
+ subject:
+ collection: "[%{app_name}][%{collection_title}] Vám darovaná práce ze sbírky
+ %{collection_title}"
+ no_collection: "[%{app_name}] Fanouškovská práce jako dárek pro vás"
+ text:
+ collection: Ve sbírce "%{collection_title}" (%{collection_url}) na AO3 byla
+ zveřejněna vám darovaná práce!
+ signup_notification:
+ activate:
+ html: Prosím, %{activate_account_link}.
+ text: 'Prosím, aktivujte svůj účet pod tímto odkazem: %{activate_account_url}'
+ activate_your_account: 'Pro aktivaci vašeho účtu klikněte na tento odkaz '
+ admin_posts: Novinky AO3
+ bye: Doufáme, že se vám zde bude líbit.
+ contact_support: Kontaktujte naši Podporu
+ faq: FAQ
+ features:
+ html: Jakmile bude váš účet zprovozněn, budete moci přidávat své fanouškovské
+ práce, nastavit si upozornění na e-mail, které vás upozorní, pokud dojde
+ k aktualizaci u vašeho oblíbeného díla či autora, nastavit si vzhled stránky,
+ zaznamenávat v historii práce, které jste si již v Archivu zobrazili a mnoho
+ dalšího.
+ text: Jakmile bude váš účet zprovozněn, budete moci přidávat své fanouškovské
+ práce, nastavit si upozornění na email, které vám dá vědět v případě, že
+ dojde k aktualizaci u vašeho oblíbeného díla či autora, nastavit si vzhled
+ stránky, zaznamenávat v historii práce, které jste si již na Archivu zobrazili
+ a mnoho dalšího.
+ information:
+ html: Náš %{faq_link} poskytuje ohledně užívání Archivu spousta informací
+ a rad. Pokud vás zajímají novinky spojené s rozvojem stránky, můžete je
+ najít v %{admin_posts_link}. V případě, že budete potřebovat pomoc, narazíte
+ na chybu či budete mít dotaz nebo komentář, %{contact_support_link}, která
+ je vždy připravena a ochotna pomoci.
+ text: Pod odkazem na FAQ %{faq_url} najdete spoustu informací a rad k používání
+ Archivu. Novinky spojené s rozvojem stránky si můžete přečíst na Novinkách
+ AO3 pod odkazem %{admin_posts_url}. V případě, že budete potřebovat pomoc,
+ narazíte na chybu či budete mít dotaz nebo komentář, kontaktujte prosím
+ naši Podporu %{contact_support_url}, která je vždy připravena a ochotna
+ pomoci.
+ welcome: Vítejte v Archive of Our Own (Našem vlastním archivu), %{login}!
diff --git a/config/locales/phrase-exports/cy.yml b/config/locales/phrase-exports/cy.yml
new file mode 100755
index 0000000..2b40a5c
--- /dev/null
+++ b/config/locales/phrase-exports/cy.yml
@@ -0,0 +1,672 @@
+---
+cy:
+ activerecord:
+ attributes:
+ archive_warning:
+ name_with_colon:
+ few: 'Rhybuddion:'
+ many: 'Rhybuddion:'
+ one: 'Rhybudd:'
+ other: 'Rhybuddion:'
+ two: 'Rhybuddion:'
+ category:
+ name_with_colon:
+ few: 'Categorïau:'
+ many: 'Categorïau:'
+ one: 'Categori:'
+ other: 'Categorïau:'
+ two: 'Categorïau:'
+ character:
+ name_with_colon:
+ few: 'Cymeriadau:'
+ many: 'Cymeriadau:'
+ one: 'Cymeriad:'
+ other: 'Cymeriadau:'
+ two: 'Cymeriadau:'
+ fandom:
+ name_with_colon:
+ few: 'Teyrnasoedd:'
+ many: 'Teyrnasoedd:'
+ one: 'Teyrnas:'
+ other: 'Teyrnasoedd:'
+ two: 'Teyrnasoedd:'
+ freeform:
+ name_with_colon:
+ few: 'Tagiau Ychwenegol:'
+ many: 'Tagiau Ychwenegol:'
+ one: 'Tag Ychwanegol:'
+ other: 'Tagiau Ychwenegol:'
+ two: 'Tagiau Ychwenegol:'
+ rating:
+ name_with_colon: 'Gradd:'
+ relationship:
+ name_with_colon:
+ few: 'Perthnasau:'
+ many: 'Perthnasau:'
+ one: 'Perythnas:'
+ other: 'Perthnasau:'
+ two: 'Perthnasau:'
+ work:
+ chapter_total_display: Penodau
+ summary: Crynodeb
+ models:
+ archive_warning:
+ few: Rhybuddion
+ many: Rhybuddion
+ one: Rhybudd
+ other: Rhybuddion
+ two: Rhybuddion
+ category:
+ few: Categorïau
+ many: Categorïau
+ one: Categori
+ other: Categorïau
+ two: Categorïau
+ chapter:
+ few: Penodau
+ many: Penodau
+ one: Pennod
+ other: Penodau
+ two: Penodau
+ character:
+ few: Cymeriadau
+ many: Cymeriadau
+ one: Cymeriad
+ other: Cymeriadau
+ two: Cymeriadau
+ fandom:
+ few: Teyrnasoedd
+ many: Teyrnasoedd
+ one: Teyrnas
+ other: Teyrnasoedd
+ two: Teyrnasoedd
+ freeform:
+ few: Tagiau Ychwanegol
+ many: Tagiau Ychwanegol
+ one: Tag Ychwanegol
+ other: Tagiau Ychwanegol
+ two: Tagiau Ychwanegol
+ rating:
+ few: Graddau
+ many: Graddau
+ one: Gradd
+ other: Graddau
+ two: Graddau
+ relationship:
+ few: Perthnasau
+ many: Perthnasau
+ one: Perythnas
+ other: Perthnasau
+ two: Perthnasau
+ series:
+ few: Cyfresau
+ many: Cyfresau
+ one: Cyfres
+ other: Cyfresau
+ two: Cyfresau
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ few: "%{count} o westeion"
+ many: "%{count} o westeion"
+ one: un westai
+ other: "%{count} o westeion"
+ two: "%{count} o westeion"
+ left_kudos:
+ html:
+ one: Gadawodd %{givers_list} canmol ar %{commentable_link}.
+ other: Gadawodd %{givers_list} canmol ar %{commentable_link}.
+ text:
+ one: Gadawodd %{givers_list} canmol ar %{commentable_title} (%{commentable_url}).
+ other: Gadawodd %{givers_list} canmol ar %{commentable_title} (%{commentable_url}).
+ single_guest:
+ giver: un westai
+ html: Gadawodd %{giver} canmol ar %{commentable_link}.
+ text: Gadawodd un westai canmol ar %{commentable_title} (%{commentable_url}).
+ subject: "[%{app_name}] Mae gennych chi canmol!"
+ mailer:
+ general:
+ closing:
+ formal: Yn gywir,
+ informal: Cofion,
+ creation:
+ link_with_word_count: "%{creation_link} (%{word_count})"
+ title_with_chapter_number: Pennod %{position} o %{title}
+ title_with_word_count: '"%{creation_title}" (%{word_count})'
+ word_count:
+ few: "%{count} o eiriau"
+ many: "%{count} o eiriau"
+ one: "%{count} gair"
+ other: "%{count} o eiriau"
+ two: "%{count} o eiriau"
+ footer:
+ general:
+ about:
+ html: Mae'r "Archive of Our Own - AO3" (Archif Ein Hun) yn archif a chaiff
+ ei rhedeg a'i chefnogi gan ffaniau, sy'n dibynu ar %{donate_link}.
+ text: 'Mae''r "Archive of Our Own" (Archif Ein Hun) yn archif a chaiff
+ ei rhedeg a''i chefnogi gan ffaniau, sy''n dibynu ar eich cyfranniadau:
+ %{donate_url}.'
+ html:
+ donate_link_text: eich cyfranniadau
+ support_link_text: cysylltwch â Chefnogaeth
+ unwanted_email:
+ html: Os rydych chi wedi derbyn y neges hon ar gam, %{support_link}.
+ text: 'Os rydych wedi derbyn y neges hon ar gam, cysylltwch â Chefnogaeth
+ fama: %{support_url}.'
+ sent_at: Anfonwyd at %{sent_at}.
+ greeting:
+ formal_html: I %{name},
+ informal:
+ addressed_html: Helo, %{name}!
+ unaddressed: Helo!
+ introductory: Helo o'r "Archive of Our Own – AO3" (Archif Ein Hun)!
+ metadata_label_indicator: ":"
+ signature:
+ abuse_team: Y tîm Camdriniaeth
+ app_short_name: AO3
+ open_doors: Y tîm "Open Doors" (Drysau Agored)
+ parent_org: '"Organisation for Transformative Works - OTW" (Mudiad Cyfryngau
+ Trawsffurfiadwy)'
+ support: Y tîm Cefnogaeth AO3
+ users:
+ mailer:
+ reset_password_instructions:
+ expiration: Os na ddefnyddiwch y ddolen hon i ailosod eich cyfrinair o fewn
+ wythnos, bydd y ddolen yn dod i ben, a bydd rhaid i chi ofyn am un newydd.
+ intro: 'Mae rhywun yn ceisio ailosod y gyfrinair ar gyfer eich cyfrif. Gallwch
+ newid cyfrinair eich cyfrif trwy ddilyn y ddolen isod a mewnbynnu eich cyfrinair
+ newydd:'
+ link_title: Newid fy nghyfrinair.
+ subject: "[%{app_name}] Ailosod eich gyfrinair"
+ unrequested: Os na wnaethoch chi ceisio ailosod y cyfrinair hwn, gallwch anwybyddu'r
+ e-bost hwn a bydd eich cyfrinair blaenorol yn parhau i weithio.
+ user_mailer:
+ admin_deleted_work_notification:
+ bye: Cysylltwyd gopi o'ch gwaith am eich cofnodion.
+ contact_abuse: cysylltwch â'n Pwyllgor Polisi a Chamdriniaeth
+ deleted:
+ html: Dileüwyd eich gwaith %{title} o'r Archif gan weinyddydd.
+ text: Dileüwyd eich gwaith %{title} o'r Archif gan weinyddydd.
+ html:
+ tos_violation: Os mae'n bosib yr oedd eich gwaith yn torri Telerau'r Archif,
+ %{contact_abuse_link}.
+ import_project:
+ html: Os yr oedd eich gwaith yn rhan o brosiect mewforiad gan "Open Doors"
+ (Drysau Agored), %{opendoors_link} gyda unrhyw holiadau pellach.
+ text: Os yr oedd eich gwaith yn rhan o brosiect mewforiad gan "Open Doors"
+ (Drysau Agored), cysylltwch â Drysau Agored (%{opendoors_link}) gyda unrhyw
+ holiadau pellach.
+ opendoors: cysylltwch â Drysau Agored
+ subject: "[%{app_name}] Dileuwyd eich gwaith gan weinyddydd"
+ text:
+ tos_violation: Os mae'n bosib yr oedd eich gwaith yn torri Telerau'r Archif,
+ cysylltwch âg ein Pwyllgor Polisi a Chamdriniaeth (%{contact_abuse_url}).
+ admin_hidden_work_notification:
+ access: Tra bod eich gwaith wedi'i guddio, gallwch barhau i gael gafael arno
+ trwy'r ddolen uchod, ond ni fydd yn cael ei restru ar eich tudalen waith,
+ nag ar gael i ddefnyddyddion AO3 eraill.
+ check_email: Gwiriwch eich e-bost, os gwelwch yn dda, gan gynnwys eich ffolder
+ sbam, oherwydd efallai bod y tîm eisoes wedi cysylltu â chi i esbonio pam
+ fod eich gwaith wedi'i guddio.
+ contact_abuse: cysylltwch â'r tîm Camdriniaeth
+ html:
+ help: Os ydych yn ansicr pam fod eich gwaith yn gudd, ac nid ydych wedi derbyn
+ cyfathrebu pellach ynghylch y mater hwn, %{contact_abuse_link}.
+ hidden: Mae eich gwaith %{title} wedi'i guddio gan y tîm Camdriniaeth a nid
+ yw'n bellach yn hygyrch i'r cyhoedd.
+ tos_violation: Os cafodd y gwaith ei guddio oherwydd toriad %{tos_link} yr
+ AO3, bydd rhaid i chi gywiro'r toriad. Gall methu â dod â'ch gwaith i gydymffurfio
+ â'r telerau arwain at y gwaith yn cael ei ddileu o'r AO3.
+ subject: "[%{app_name}] Mae eich gwaith wedi'i guddio gan y tîm Camdriniaeth"
+ text:
+ help: 'Os ydych yn ansicr pam fod eich gwaith yn gudd, ac nid ydych wedi derbyn
+ cyfathrebu pellach ynghylch y mater hwn, cysylltwch â''r tîm Camdriniaeth:
+ %{contact_abuse_url}.'
+ hidden: Mae eich gwaith "%{title}" (%{work_url}) wedi'i guddio gan y tîm Camdriniaeth
+ a nid yw'n bellach yn hygyrch i'r cyhoedd.
+ tos_violation: Os cafodd y gwaith ei guddio oherwydd toriad Telerau'r AO3
+ (%{tos_url}), bydd rhaid i chi gywiro'r toriad. Gall methu â dod â'ch gwaith
+ i gydymffurfio â'r telerau arwain at y gwaith yn cael ei ddileu o'r AO3.
+ tos: Telerau
+ anonymous_or_unrevealed_notification:
+ anonymous_info: Mae gwaith dienw yn cael eu cynnwys mewn rhestrau tag, ond nid
+ ar eich tudalen gwaith. Ar y gwaith, bydd eich enw cyfrif yn cael ei ddisodli
+ gan "Anonymous" (Dienw)
+ anonymous_unrevealed_info: Gall y cynhalydd casglu ddatgelu eich gwaith ond
+ byddant yn ei adael yn ddienw. Ni fydd pobl sy'n tanysgrifio i chi yn cael
+ gwybod am y newid. Ar ôl cael ei ddatgelu, bydd eich gwaith yn cael ei gynnwys
+ mewn rhestrau tagiau, ond nid ar eich tudalen waith. Ar y gwaith, bydd eich
+ enw yn cael ei ddisodli gan "Anonymous" (Dienw).
+ changed_status:
+ anonymous:
+ html: Mae cynhalyddion casglu %{collection_link} wedi newid statws eich
+ gwaith %{work_link} i ddienw.
+ text: Mae cynhalydd casglu "%{collection_title}" (%{collection_url}) wedi
+ newid statws eich gwaith "%{work_title}" (%{work_url}) i ddienw.
+ anonymous_unrevealed:
+ html: Mae cynhalydd casglu %{collection_link} wedi newid statws eich gwaith
+ %{work_link} i ddienw ac anamlygadwy.
+ text: Mae cynhalydd casglu "%{collection_title}" (%{collection_url}) wedi
+ newid statws eich gwaith "%{work_title}" (%{work_url}) i ddienw ac anamlygadwy.
+ unrevealed:
+ html: Mae cynhalyddion casglu %{collection_link} wedi newid statws eich
+ gwaith %{work_link} i anamlygadwy.
+ text: Mae cynhalydd casglu "%{collection_title}" (%{collection_url}) wedi
+ newid statws eich gwaith "%{work_title}" (%{work_url}) i anamlygadwy.
+ collection_items_link_text: Tudalen "Approved Collection Items" (Eitemau Casglu
+ Cymeradwyol)
+ do_not_want:
+ anonymous:
+ html: Os nad ydych am i'ch gwaith fod yn ddienw, ewch i'ch %{collection_items_link}
+ i'w dileu o'r casgliad.
+ text: Os nad ydych am i'ch gwaith fod yn ddienw, ewch i'ch tudalen Eitemau
+ Casglu Cymeradwyol i'w dileu o'r casgliad. %{collection_items_url}
+ anonymous_unrevealed:
+ html: Os nad ydych am i'ch gwaith fod yn ddienw ac anamlygadwy, ewch i'ch
+ %{collection_items_link} i'w dileu o'r casgliad.
+ text: Os nad ydych am i'ch gwaith fod yn ddienw ac anamlygadwy, ewch i'ch
+ tudalen Eitemau Casglu Cymeradwyol i'w dileu o'r casgliad. %{collection_items_url}
+ unrevealed:
+ html: Os nad ydych am i'ch gwaith fod yn anamlygadwy, ewch i'ch %{collection_items_link}
+ i'w dileu o'r casgliad.
+ text: Os nad ydych am i'ch gwaith fod yn anamlygadwy, ewch i'ch tudalen
+ Eitemau Casglu Cymeradwyol i'w dileu o'r casgliad. %{collection_items_url}
+ faq_link_text: Holiadau Cyffredin Casgliadau
+ more_info:
+ html: Am mwy o wybodaeth, gwelwch y dudalen %{faq_link}.
+ text: 'Am mwy o wybodaeth, gwelwch y dudalen Holiadau Cyffredin Casgliadau:
+ %{faq_url}'
+ subject:
+ anonymous: "[%{app_name}] Gwnaed eich gwaith yn ddienw"
+ anonymous_unrevealed: "[%{app_name}] Gwnaed eich gwaith yn ddienw ac yn anamlygadwy"
+ unrevealed: "[%{app_name}] Gwnaed eich gwaith yn anamlygadwy"
+ unrevealed_info: Nid yw gwaith anamlygadwy wedi'i gynnwys mewn rhestrau tag
+ neu ar eich tudalen waith. Bydd pobl sy'n dilyn cysylltu i'r gwaith yn derbyn
+ hysbysiad ei fod yn anamlygadwy. Ni fyddant yn gallu cael gafael ar ei gynnwys.
+ archivist_added_to_collection_notification:
+ approved_collection_items_page: Tudalen Approved Collection Items (Eitemau Casglaid
+ Cymeradwy)
+ archivist_notice: Wrth weithdredu yn rhinwedd eu swydd fel archifyddion Open
+ Doors (Drysau Agored), caniateir iddynt ychwanegu eich gwaith at y casgliad
+ hwn, hyd yn oed os oes gennych wahoddiadau casglad wedi'u hanalluogi. Bydd
+ archifwyr yn ychwanegu gwaith at gasgliad dim ond os cafodd ei gynnal ar archif
+ wedi'i fewnforio.
+ removal_instructions:
+ html: Os hoffech dynnu eich gwaith o'r casgliad hwn, gwelwch eich %{approved_items_link}.
+ text: 'Os hoffech dynnu eich gwaith o''r casgliad hwn, gwelwch eich tudalen
+ Approved Collection Items (Eitemau Casglaid Cymeradwy): %{approved_items_url}.'
+ subject: "[%{app_name}][%{collection_title}] Mae archifydd Open Doors (Drysau
+ Agored) wedi ychwanegu eich gwaith at gasgliad."
+ work_added:
+ html: Mae cynhalwyr y casgliad %{collection_link} wedi ychwanegu eich gwaith
+ %{work_link} to at eu casgliad!
+ text: Mae cynhalwyr y casgliad "%{collection_title}" (%{collection_url}) wedi
+ ychwanegu eich gwaith "%{work_title}" (%{work_url}) at eu casgliad!
+ challenge_assignment_notification:
+ any: Unrhyw
+ assignment:
+ html: Mae'r cais hon yn yr her %{link} ar Archif Ein Hun wedi'i aseinio i
+ chi!
+ description: 'Disgrifiad:'
+ due: 'Mae''r aseiniad hwn yn ddyledus am:'
+ html:
+ footer: Rydych chi'n derbyn yr ebost hyn oherwydd wnaethoch cofrestru ar gyfer
+ yr her %{title}. Am fwy o wybodaeth am yr her hon ac am fanylion cyswllt
+ y cymedrolyddion, gwelwch %{footer_link}.
+ footer_link: tudalen proffil yr her
+ look_up: Gallwch weld yr aseiniad hwn ar %{link}.
+ look_up_link: Eich Aseiniadau
+ optional_tags: 'Tagiau Ychwanegol:'
+ prompts: 'Annogion:'
+ prompt_url: 'URL Annogiad:'
+ recipient: 'Derbynydd:'
+ recipient_missing: 'Neb: cysylltwch â chymedrolydd am help!'
+ subject: "[%{app_name}][%{collection_title}] Eich Aseiniad!"
+ text:
+ assignment: Mae'r cais hon yn yr her "%{collection_title}" (%{collection_url})
+ ar Archif Ein Hun wedi'i aseinio i chi!
+ footer: Rydych chi'n derbyn yr ebost hyn oherwydd wnaethoch cofrestru ar gyfer
+ y her %{title} (%{url}). Am fwy o wybodaeth am yr her hon ac am fanylion
+ cyswllt y cymedrolyddion, gwelwch %{profile_url}.
+ look_up: Gallwch edrych ar yr aseiniad hwn o'ch tudalen Assignments (Aseiniadau)
+ ar%{link}.
+ change_email:
+ changed:
+ html: "%{login}, newidwyd yr ebost cysylltiedig i'ch cyfrif i %{email}"
+ text: "%{login}, newidwyd yr ebost cysylltiedig i'ch cyfrif i %{email}"
+ subject: "[%{app_name}] Newidiad Ebost"
+ claim_notification:
+ access:
+ contact_support: cysylltwch â Chefnogaeth AO3
+ html: Yn dibynnu ar yr archif, mae'n bosibl bod eich gweithiau wedi'u mewnforio
+ wedi'u cyfyngu i ddefnyddwyr cofrestredig yn unig (er mwyn eu hatal rhag
+ ymddangos mewn chwiliadau Google). Yn yr achos hwn, dim ond defnyddwyr sydd
+ wedi mewngofnodi fydd yn gallu eu gweld oni bai eich bod yn dewis eu gwneud
+ yn weladwy i bawb. Am help i ddatgloi, anghatrefu neu dileu'ch gwaith, %{contact_support_link}.
+ text: Yn dibynnu ar yr archif, mae'n bosibl bod eich gweithiau wedi'u mewnforio
+ wedi'u cyfyngu i ddefnyddwyr cofrestredig yn unig (er mwyn eu hatal rhag
+ ymddangos mewn chwiliadau Google). Yn yr achos hwn, dim ond defnyddwyr sydd
+ wedi mewngofnodi fydd yn gallu eu gweld oni bai eich bod yn dewis eu gwneud
+ yn weladwy i bawb. Am help i ddatgloi, anghatrefu neu dileu'ch gwaith, cysylltwch
+ â Chefnogaeth at %{support_url}.
+ email_tips: Os cysylltwch â ni, ychwanegwch gyfeiriadau e-bost oddi wrth @transformativeworks.org
+ to i'ch rhestr o gysylltiadau diogel a gwiriwch eich ffolder sothach am ein
+ hateb.
+ introduction:
+ ao3_name: Archive of Our Own – AO3 (Archif Ein Hun)
+ html: Rydych chi'n derbyn yr e-bost hwn oherwydd eich gwaith mewn archif Ffangyfryngau
+ sydd wedi'i fewnforio gan %{open_doors_name_link} i'r %{app_link}. Oherwydd
+ mae'r cyfeiriad e-bost hwn yn gysylltiedig ag un sydd wedi'i gofrestru ar
+ yr archif a fewnforiwyd, mae'r ffanweithiau (a restrir isod) cysylltiedig
+ wedi'u hychwanegu'n awtomatig at eich cyfrif AO3.
+ open_doors_name: Drysau Agored
+ text: 'Rydych chi''n derbyn yr e-bost hwn oherwydd eich gwaith mewn archif
+ Ffangyfryngau sydd wedi''i fewnforio gan Open Doors (Drysau Agored) (%{open_doors_url})
+ i''r Archive of Our Own – AO3 (Archif Ein Hun): %{app_url}. Oherwydd bod
+ y cyfeiriad e-bost hwn yn gysylltiedig ag un sydd wedi''i gofrestru ar yr
+ archif a fewnforiwyd, mae''r ffanweithiau (a restrir isod) cysylltiedig
+ wedi''u hychwanegu''n awtomatig at eich cyfrif AO3.'
+ mistake:
+ contact_open_doors: cysylltwch â Drysau Agored
+ html: Os camgymeriad yw hyn, ac nid yw'r gweithiau hwn yn perthyn i chi, peidiwch
+ â'u dileu os gwelwch yn dda! %{contact_open_doors_link} i ddatrys y sefyllfa.
+ text: Os camgymeriad yw hyn, ac nid yw'r gweithiau hwn yn perthyn i chi, peidiwch
+ â'u dileu os gwelwch yn dda! Cysylltwch â Drysau Agored (%{open_doors_url})
+ i ddatrys y sefyllfa.
+ more_info:
+ ao3_news: Newyddion AO3
+ contact_support: cysylltwch â Chefnogaeth AO3
+ faq_page: Tudalen Holiadau Cyffredin
+ html: Gallwch ddarllen cyhoeddiadau am symudiadau archif diweddar yn %{ao3_news_link},
+ a dod o hyd i ragor o wybodaeth ar y %{faq_page_link} neu'r %{tutorial_page_link}
+ Drysau Agored. Am gwestiynau eraill heb eu hateb yn yr holiadau cyffredin,
+ tiwtorialau, neu'r e-bost hwn, %{contact_support_link}.
+ text: Gallwch ddarllen cyhoeddiadau am symudiadau archif diweddar yn Newyddion
+ AO3 (%{news_url}), a dod o hyd i ragor o wybodaeth ar dudalen holiadau cyffredin
+ (%{open_doors_faq_url}) neu dudalen tiwtorial (%{open_doors_tutorial_url})
+ y Drysau Agored. Am gwestiynau eraill heb eu hateb yn yr holiadau cyffredin,
+ tiwtorialau, neu'r e-bost hwn, cysylltwch â Chefnogaeth at %{support_url}.
+ tutorial_page: dudalen tiwtorial
+ other_works:
+ contact_open_doors: cysylltwch â Drysau Agored
+ html: Os oedd gennych weithiau eraill ar yr archif a fewnforiwyd o dan gyfeiriad
+ e-bost ni allwch gael mynediad iddo fwyach, %{contact_open_doors_link} gydag
+ unrhyw wybodaeth a all wirio pwy ydych.
+ text: Os oedd gennych weithiau eraill ar yr archif a fewnforiwyd o dan gyfeiriad
+ e-bost ni allwch gael mynediad iddo fwyach, cysylltwch â Drysau Agored gydag
+ unrhyw wybodaeth a all wirio pwy ydych.
+ questions:
+ contact_support: cysylltwch â Chefnogaeth AO3
+ html: Am ymholiadau eraill, %{contact_support_link}.
+ text: Am ymholiadau eraill, cysylltwch â Chefnogaeth AO3 at %{support_url}.
+ redirects:
+ html: I gadw rhestrau awgrymu a thudalnodau, gall cyfeiriadau gwe'r archif
+ a fewnforiwyd ailgyfeirio i'r copi o'r gweithiau hyn a fewnforiwyd am gyfnod
+ cyfyngedig (edrychwch ar bostiad cyhoeddiad eich archif i fod yn sicr).
+ Os ydych chi eisoes wedi uwchlwytho copi o'r gweithiau hyn a %{negation}
+ defnyddioch chi'r nodwedd mewnforio o URL, bydd dau gopi o'r un gwaith ar
+ AO3.
+ subject: "[%{app_name}] Gweithiau wedi'u llwytho i fyny"
+ update_redirect:
+ contact_open_doors: cysylltwch â Drysau Agored
+ html: Os hoffech i Ddrysau Agored ddiweddaru'r ailgyfeiriad i dynnu sylw at
+ eich gwaith sy'n bodoli eisoes, dilëwch y copi a fewnforiwyd, ac wedyn %{contact_open_doors_link}
+ gyda'ch enw cyfrif AO3 a'r archif a fewnforiwyd, a theitl ac URL y gwaith
+ yr hoffech i'r ailgyfeiriad gyfeirio ato. (I newid ailgyfeiriadau lluosog,
+ gallwch restr pob gwaith yn un e-bost.)
+ text: Os hoffech i Ddrysau Agored ddiweddaru'r ailgyfeiriad i dynnu sylw at
+ eich gwaith sy'n bodoli eisoes, dilëwch y copi a fewnforiwyd, a chysylltwch
+ â Drysau Agored at %{open_doors_url} gyda'ch enw cyfrif AO3 a'r archif a
+ fewnforiwyd, a theitl ac URL y gwaith yr hoffech i'r ailgyfeiriad gyfeirio
+ ato. (I newid ailgyfeiriadau lluosog, gallwch restr pob gwaith yn un e-bost.)
+ works_by: 'Ysgrifennwyd y gweithiau hyn o dan yr e-bost: %{email}'
+ work_info:
+ html: "%{work_link} (%{fandom})"
+ text:
+ no_fandom: "- %{work_title} %{work_url}"
+ with_fandom: "- %{work_title} %{work_url} (%{fandom})"
+ collection_notification:
+ assignments_sent:
+ complete: Mae pob aseiniad yn awr wedi'i hanfon.
+ subject: Aseiniadau wedi'i hanfon
+ html:
+ received_message: 'Rydych wedi derbyn neges amdan eich casgliad %{collection_link}:'
+ text:
+ received_message: 'Rydych wedi derbyn neges amdan eich casgliad "%{collection_title}"
+ (%{collection_url}):'
+ creatorship_notification:
+ explanation: Pan rydych yn gyd-greüydd ar waith, gallwch cael eich ychwwnegu
+ i benodau newydd waeth beth yw eich gosodiadau cyd-greu. Byddwch hefyd yn
+ cael eich ychwanegu i unrhyw gyfres mae'r gwaith yn cael ei ychwanegu ati.
+ html:
+ creation: "%{creation_link} gan %{pseud_links}"
+ edit_chapter: olygu'r bennod
+ edit_series: olygu'r gyfres
+ remove_chapter: Os ydych wedi cael eich ychwanegu ar gam neu nad ydych chi
+ am gael eich rhestru fel creüydd, gallwch %{edit_chapter_link} i dynnu'ch
+ hun fel creüydd.
+ remove_series: Os ydych wedi cael eich ychwanegu ar gam neu nad ydych chi
+ am gael eich rhestru fel creüydd, gallwch %{edit_series_link}i dynnu'ch
+ hun fel creüydd.
+ intro_chapter: 'Mae''r defnyddydd %{adding_user} wedi rhestru''ch ffugenw %{pseud}
+ fel cyd-greüydd ar y bennod ganlynol:'
+ intro_series: 'Mae''r defnyddydd %{adding_user} wedi rhestru''ch ffugenw %{pseud}
+ fel cyd-greüydd ar y gyfres ganlynol:'
+ subject: "[%{app_name}] Hysbysiad cyd-greüydd"
+ text:
+ creation: "%{title} (%{url}) gan %{pseuds}"
+ remove_chapter: Os ydych wedi cael eich ychwanegu ar gam neu nad ydych chi
+ am gael eich rhestru fel creüydd, gallwch golygu'r bennod ii dynnu'ch hun
+ fel creüydd. %{url}
+ remove_series: 'Os ydych wedi cael eich ychwanegu ar gam neu nad ydych chi
+ am gael eich rhestru fel creüydd, gallwch olygu''r gyfres i dynnu''ch hun
+ fel creüydd: %{url}'
+ creatorship_notification_archivist:
+ explanation: Oherwydd maent yn gweithio yn ei medr fel archifydd "Open Doors"
+ (Drysau Agored), gallant eich ychwanegu heb cais, hyd yn oed os rydych wedi
+ analluogi cyd-greu.
+ html:
+ creation: "%{creation_link} gan %{pseud_links}"
+ edit_chapter: golygu'r bennod
+ edit_series: golygu'r gyfres
+ edit_work: golygu'r gwaith
+ remove_chapter: Os rydych wedi cael eich ychwanegu ar gam, neu na hoffech
+ gael eich rhestru fel creüydd, gallwch %{edit_chapter_link}i dynnu eich
+ hun fel creüydd.
+ remove_series: Os rydych wedi cael eich ychwanegu ar gam, neu na hoffech gael
+ eich rhestru fel creüydd, gallwch %{edit_series_link}i dynnu eich hun fel
+ creüydd.
+ remove_work: Os rydych wedi cael eich ychwanegu ar gam, neu na hoffech gael
+ eich rhestru fel creüydd, gallwch %{edit_work_link}i dynnu eich hun fel
+ creüydd.
+ intro_chapter: 'Mae''r defnyddydd %{archivist} wedi ychwanegu %{pseud} fel cydgreüydd
+ ar y bennod canlynol:'
+ intro_series: 'Mae''r defnyddydd %{archivist} wedi ychwanegu %{pseud} fel cydgreüydd
+ ar y gyfres canlynol:'
+ intro_work: 'Mae''r defnyddydd %{archivist} wedi ychwanegu %{pseud} fel cydgreüydd
+ ar y gwaith canlynol:'
+ subject: "[%{app_name}] Hysbysiad cyd-greüydd archifydd"
+ text:
+ creation: "%{title} (%{url}) gan %{pseuds}"
+ remove_chapter: 'Os rydych wedi cael eich ychwanegu ar gam, neu na hoffech
+ gael eich rhestru fel creüydd, gallwch olygu''r bennod i dynnu eich hun
+ fel creüydd: %{url}'
+ remove_series: 'Os rydych wedi cael eich ychwanegu ar gam, neu na hoffech
+ gael eich rhestru fel creüydd, gallwch olygu''r gyfres i dynnu eich hun
+ fel creüydd: %{url}'
+ remove_work: 'Os rydych wedi cael eich ychwanegu ar gam, neu na hoffech gael
+ eich rhestru fel creüydd, gallwch olygu''r gwaith i dynnu eich hun fel creüydd:
+ %{url}'
+ creatorship_request:
+ html:
+ creation: "%{creation_link} gan %{pseud_links}"
+ instructions: Gallwch dderbyn neu gwrthod y cais yma ar eich tudalen %{page_name}.
+ page_name: '"Co-Creator Requests" (Ceisiau Cyd-Greüydd)'
+ intro_chapter: 'Mae''r defnyddydd %{inviting_user} wedi gwahodd eich ffugenw
+ %{pseud} fel cyd-greüydd ar y bennod ganlynol:'
+ intro_series: 'Mae''r defnyddydd %{inviting_user} wedi gwahodd eich ffugenw
+ %{pseud} fel cyd-greüydd ar y gyfres ganlynol:'
+ intro_work: 'Mae''r defnyddydd %{inviting_user} wedi gwahodd eich ffugenw %{pseud}
+ fel cyd-greüydd ar y gwaith canlynol:'
+ subject: Cais cyd-greüydd [%{app_name}]
+ text:
+ creation: "%{title} (%{url}) gan %{pseuds}"
+ instructions: 'Gallwch dderbyn neu gwrthod y cais yma ar eich tudalen "Co-Creator
+ Requests" (Ceisiau Cyd-Greüydd): %{url}'
+ delete_work_notification:
+ attachment: Cysylltwyd gopi o'ch gwaith am eich cofnodion.
+ deleted_other:
+ html: Caiff eich gwaith %{title} ei dileu yn ôl dymuniad %{pseud}.
+ text: Caiff eich gwaith "%{title}" ei dileu yn ôl dymuniad %{pseud}.
+ deleted_yourself:
+ html: Caiff eich gwaith %{title} ei dileu yn ôl eich dymuniad chi.
+ text: Caiff eich gwaith "%{title}" ei dileu yn ôl eich dymuniad chi.
+ questions:
+ html: Os mae gennych chi holiadau, %{support}.
+ text: Os mae gennych chi holiadau, %{support} (%{url}).
+ subject: "[%{app_name}] Mae eich gwaith wedi cael ei dileu"
+ support: cysylltwch â Chefnogaeth
+ invitation_to_claim:
+ access:
+ text: Yn dibynnu ar yr archif, efallai bod eich gweithiau wedi'u mewnforio
+ wedi'u cyfyngu i ddefnyddwyr cofrestredig yn unig (i'w cadw allan o chwiliadau
+ Gwgl). Os yw hyn yn wir, dim ond defnyddwyr sydd wedi mewngofnodi fydd yn
+ hygyrch i'r gwaith oni bai eich bod yn dewis eu gwneud yn gwbl weladwy.
+ I gael help i ddatgloi, amddifadu, neu ddileu eich gwaith, cysylltwch â
+ Chymorth AO3.
+ claim_or_remove:
+ html: Hawliwch neu ddileu eich gwaith yma.
+ text: 'Hawliwch neu ddileu eich gwaith yma: %{claim_url}'
+ email_tips: Os ydych yn cysylltu â ni, hawliwch gyfeiriadau e-bost o @transformativeworks.org
+ a gwiriwch eich ffolderau sbam am ein hateb os gwelwch yn dda.
+ html:
+ ao3_news: Newyddion AO3
+ contact_open_doors: cysylltu Drysau Agored
+ contact_support: cysylltu cefnogaeth AO3
+ faq_page: tudalen Cwestiynau Cyffredin
+ tutorial_page: tudalen tiwtorial
+ introduction:
+ text: Rydych chi'n derbyn yr e-bost hwn oherwydd bod archif wedi'i mewnforio
+ yn ddiweddar gan Ddrysau Agored (%{open_doors_link}) i'r%{app_name} (%{app_short_name}
+ - %{app_url}), a chredwn fod y ffanwaith canlynol yn perthyn i chi. Hoffem
+ roi cyfle i chi hawlio (neu ddileu/amddifadu) y gweithiau hyn os ydych chi
+ eisiau. Ac os nad oes gennych gyfrif eisoes o dan e-bost gwahanol, hoffem
+ eich gwahodd ar fwrdd!
+ mistake:
+ text: Os yw hwn yn gamgymeriad ac nid eich gweithiau chi yw’'r rhain, peidiwch
+ â'u dileu! Cysylltwch â Drysau Agored (%{open_doors_link}) a byddwn yn ei
+ ddatrys.
+ more_info:
+ text: Gallwch ddarllen cyhoeddiadau am symudiadau archif diweddar yn Newyddion
+ AO3 (%{news_link}), a dod o hyd i wybodaeth ychwanegol ar dudalen Cwestiynau
+ Cyffredin Drysau Agored (%{open_doors_faq_link}) neu dudalen tiwtorialau
+ (%{open_doors_tutorial_link}). Am unrhyw gwestiynau na chawsant eu hateb
+ yn y Cwestiynau Cyffredin, sesiynau tiwtorial, neu'r e-bost hwn, cysylltwch
+ â Chefnogaeth ar %{support_link}.
+ other_works:
+ text: Os oedd gennych weithiau eraill ar yr archif a fewnforiwyd o dan gyfeiriad
+ e-bost na allwch ei gyrchu mwyach, cysylltwch â Drysau Agored gydag unrhyw
+ wybodaeth a all helpu i wirio'ch hunaniaeth.
+ questions:
+ text: Am bob ymholiad arall, cysylltwch â Chefnogaeth AO3 ar %{support_link}.
+ redirects: Er mwyn cadw rhestrau argymhelliad a thudalnodau, gall cyfeiriadau
+ gwe'r archif a fewnforir ailgyfeirio i'r copi a fewnforiwyd o'r gweithiau
+ hyn am gyfnod cyfyngedig (gwiriwch y post cyhoeddi am eich archif i fod yn
+ sicr). Os ydych chi eisoes wedi uwchlwytho copi o'r gweithiau hyn ac NAD wnaethoch
+ chi ddefnyddio'r nodwedd mewnforio o URL, bydd dau gopi o'r un gwaith ar yr
+ archif.
+ subject: "[%{app_name}] Gwahoddiad i hawlio gwaith"
+ unwanted:
+ text: Os yw'r gweithiau hyn yn eiddo i chi, ond nad ydych eu heisiau, gallwch
+ amddifadu (fel eu bod yn aros ar yr AO3, ond gyda'ch enw wedi'i dynnu) neu
+ eu dileu (fel eu bod yn cael eu tynnu o'r AO3 yn llwyr). Nid oes angen i
+ chi ychwanegu'r gweithiau hyn i unrhyw gyfrif er mwyn eu hamddifadu neu
+ eu dileu - gallwch wneud hyn yn uniongyrchol o'r ddolen hawlio uchod. (Am
+ gymorth, cysylltwch â Chefnogaeth ar %{support_link}.)
+ update_redirect:
+ text: Os hoffech i Ddrysau Agored ddiweddaru'r ailgyfeiriad i fynd at eich
+ gwaith sy'n bodoli eisoes, dilëwch y copi a fewnforiwyd, a chysylltwch â
+ Drysau Agored ar %{open_doors_link} gyda'ch enw cyfrif AO3, enw'ch cyfrif
+ ar yr archif a fewnforiwyd, a'r teitl ac URL y ffanwaith yr hoffech i'r
+ ailgyfeiriad fynd ati. (Os oes gennych chi sawl gwaith yr hoffech chi newid
+ yr ailgyfeiriadau ar eu cyfer, gallwch chi restru'r rhain mewn un e-bost.)
+ uploaded_list: 'Mae’r gwaith a lanlwythwyd yn cynnwys:'
+ invite_increase_notification:
+ html:
+ body:
+ one: Hoffem ni gadael i chi gwybod bod gennych chi %{count} wahoddiad newydd,
+ a allwch ddefnyddio i greu cyfrifau newydd ar yr archif. Gallwch wahodd
+ ffrind at %{invitation_page_link}.
+ other: Hoffem ni gadael i chi gwybod bod gennych chi %{count} o wahoddion
+ newydd, a allwch ddefnyddio i greu cyfrifau newydd ar yr archif. Gallwch
+ wahodd ffrind at %{invitation_page_link}.
+ invitation_page_link_text: eich tudalen wahoddiad
+ subject: "[%{app_name}] Gwahoddiadau Newydd"
+ text:
+ body:
+ one: 'Hoffem ni gadael i chi gwybod bod gennych chi %{count} wahoddiad newydd,
+ a allwch ddefnyddio i greu cyfrifau newydd ar yr archif. Gallwch wahodd
+ ffrind at eich tudalen Invitations (Gwahoddiadau): %{invitation_page_url}.'
+ other: Hoffem ni gadael i chi gwybod bod gennych chi %{count} o wahoddion
+ newydd, a allwch ddefnyddio i greu cyfrifau newydd ar yr archif. Gallwch
+ wahodd ffrind at eich tudalen Invitations (Gwahoddiadau)%{invitation_page_url}.
+ invite_request_declined:
+ main:
+ few: Mae'n ddrwg gennym i'ch hysbysu na ellir cyflawni'ch cais am %{count}
+ wahoddiadau newydd ar hyn o bryd.
+ many: Mae'n ddrwg gennym i'ch hysbysu na ellir cyflawni'ch cais am %{count}
+ wahoddiadau newydd ar hyn o bryd.
+ one: Mae'n ddrwg gennym i'ch hysbysu na ellir cyflawni'ch cais am wahoddiad
+ newydd ar hyn o bryd.
+ other: Mae'n ddrwg gennym i'ch hysbysu na ellir cyflawni'ch cais am %{count}
+ wahoddiadau newydd ar hyn o bryd.
+ two: Mae'n ddrwg gennym i'ch hysbysu na ellir cyflawni'ch cais am %{count}
+ wahoddiadau newydd ar hyn o bryd.
+ reason: 'Roedd eich cais yn:'
+ subject: "[%{app_name}] Gwrthodwyd cais cod gwahoddiad ychwanegol"
+ recipient_notification:
+ html:
+ collection: Mae gwaith anrheg weid cael ei phostio yng nghasgliad %{collection_link}
+ ar yr AO3 ar eich cyfer!
+ no_collection: Mae gwaith anrheg weid cael ei phostio ar yr AO3 ar eich cyfer!
+ subject:
+ collection: "[%{app_name}][%{collection_title}] Gwaith anrheg ar eich cyfer
+ o %{collection_title}"
+ no_collection: "[%{app_name}] Gwaith anrheg ar eich cyfer"
+ text:
+ collection: Mae gwaith anrheg weid cael ei phostio yng nghasgliad "%{collection_title}"
+ (%{collection_url}) ar yr AO3 ar eich cyfer!
+ signup_notification:
+ activate:
+ html: "%{activate_account_link}."
+ text: 'Ysgogwch eich cyfrif: %{activate_account_url}'
+ activate_your_account: Ysgogwch eich cyfrif
+ admin_posts: pyst gweinyddydd yr archif
+ bye: Gobeithiym byddwch yn mwynhau defnyddio’r Archif!
+ contact_support: cysylltwch â’n tîm Cefnogaeth
+ faq: tudalen Holiadau Cyffredin
+ features:
+ html: Unwaith mae eich cyfrif wedi’i osod, gallwch postio eich ffanweithiau,
+ gosod tanysgrifiadau ebost i adael i chi gwybod pan mae eich ffanweithiau
+ neu greüydd hoff wedi diweddaru, gosod dewisadau i addasu’r ffordd mae’r
+ wefan yn edrych ac yn gweithio i chi, dilyn y nifer o weithiau rydych wedi’i
+ hedrych ar trwy eich hanes, a llawer fwy.
+ text: Unwaith mae eich cyfrif wedi’i osod, gallwch postio eich ffanweithiau,
+ gosod tanysgrifiadau ebost i adael i chi gwybod pan mae eich ffanweithiau
+ neu greüydd hoff wedi diweddaru, gosod dewisadau i addasu’r ffordd mae’r
+ wefan yn edrych ac yn gweithio i chi, dilyn y nifer o weithiau rydych wedi’i
+ hedrych ar trwy eich hanes, a llawer fwy.
+ information:
+ html: Mae na lwyth o wybodaeth a chefnogaeth ar sut i defnyddio’r archif yn
+ ein %{faq_link}. Gallwch darganfod ein newyddion fwyaf dilys yn ein %{admin_posts_link}.
+ Os mae angen help arnoch, neu rydych wedi darganfod gwall, neu mae gennych
+ chi sylwadau neu holiadau, %{contact_support_link}, a fydd yn hapus i’ch
+ helpu.
+ text: 'Mae na lwyth o wybodaeth a chefnogaeth ar sut i defnyddio’r archif
+ yn ein tudalen Holiadau Cyffredin at %{faq_url}. Gallwch darganfod ein newyddion
+ fwyaf dilys yn pyst gweinyddydd yr archif at %{admin_posts_url}. Os mae
+ angen help arnoch, neu rydych wedi darganfod gwall, neu mae gennych chi
+ sylwadau neu holiadau, cysylltwch âg ein tîm Cefnogaeth, a fydd yn hapus
+ i’ch helpu: %{contact_support_url}.'
+ welcome: Croeso i “Archive of Our Own” (Archif Ein Hun), %{login}!
diff --git a/config/locales/phrase-exports/da.yml b/config/locales/phrase-exports/da.yml
new file mode 100755
index 0000000..099734a
--- /dev/null
+++ b/config/locales/phrase-exports/da.yml
@@ -0,0 +1,616 @@
+---
+da:
+ activerecord:
+ attributes:
+ archive_warning:
+ name_with_colon:
+ one: 'Advarsel:'
+ other: 'Advarsler:'
+ category:
+ name_with_colon:
+ one: 'Kategori:'
+ other: 'Kategorier:'
+ character:
+ name_with_colon:
+ one: 'Karakter:'
+ other: 'Karakterer:'
+ fandom:
+ name_with_colon:
+ one: 'Fandom:'
+ other: 'Fandoms:'
+ freeform:
+ name_with_colon:
+ one: 'Yderligere tag:'
+ other: 'Yderligere tags:'
+ rating:
+ name_with_colon: 'Klassificering:'
+ relationship:
+ name_with_colon:
+ one: 'Forhold:'
+ other: 'Forhold:'
+ work:
+ chapter_total_display: Kapitler
+ summary: Resumé
+ models:
+ archive_warning:
+ one: Advarsel
+ other: Advarsler
+ category:
+ one: Kategori
+ other: Kategorier
+ chapter:
+ one: Kapitel
+ other: Kapitler
+ character:
+ one: Karakter
+ other: Karakterer
+ fandom:
+ one: Fandom
+ other: Fandoms
+ freeform:
+ one: Yderligere tag
+ other: Yderligere tags
+ rating:
+ one: Klassificering
+ other: Klassificeringer
+ relationship:
+ one: Forhold
+ other: Forhold
+ series:
+ one: Serie
+ other: Serier
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ one: en gæst
+ other: "%{count} gæster"
+ left_kudos:
+ html:
+ one: "%{givers_list} efterlod kudos på %{commentable_link}."
+ other: "%{givers_list} efterlod kudos på %{commentable_link}."
+ text:
+ one: "%{givers_list} efterlod kudos på %{commentable_title} (%{commentable_url})."
+ other: "%{givers_list} efterlod kudos på %{commentable_title} (%{commentable_url})."
+ single_guest:
+ giver: En gæst
+ html: "%{giver} efterlod kudos på %{commentable_link}."
+ text: En gæst efterlod kudos på %{commentable_title} (%{commentable_url}).
+ subject: "[%{app_name}] Du har fået kudos!"
+ mailer:
+ general:
+ closing:
+ formal: Med venlig hilsen,
+ informal: Venlig hilsen,
+ creation:
+ link_with_word_count: "%{creation_link} (%{word_count})"
+ title_with_chapter_number: Kapitel %{position} af %{title}
+ title_with_word_count: '"%{creation_title}" (%{word_count})'
+ word_count:
+ one: "%{count} ord"
+ other: "%{count} ord"
+ footer:
+ general:
+ about:
+ html: AO3 er et fan-styret og fan-støttet arkiv, der er afhængig af %{donate_link}.
+ text: 'AO3 er et fan-styret og fan-støttet arkiv, der er afhængig af dine
+ bidrag: %{donate_url}.'
+ html:
+ donate_link_text: dine bidrag
+ support_link_text: kontakte Support
+ unwanted_email:
+ html: Hvis du har modtaget denne besked ved en fejl, bedes du %{support_link}.
+ text: Hvis du har modtaget denne besked ved en fejl, bedes du kontakte
+ Support på %{support_url}.
+ sent_at: Sendt %{sent_at}.
+ greeting:
+ formal_html: Kære %{name},
+ informal:
+ addressed_html: Hej %{name}!
+ unaddressed: Hej!
+ introductory: Hej fra Archive of Our Own – AO3 (Vores Eget Arkiv)!
+ metadata_label_indicator: ":"
+ signature:
+ abuse_team: AO3's politik- og misbrugsteam
+ app_short_name: AO3
+ open_doors: The Open Doors (Åbne døre) team
+ parent_org: Organization for Transformative Works – OTW (Organisationen for
+ Transformative Værker)
+ support: AO3 Support-team
+ users:
+ mailer:
+ reset_password_instructions:
+ expiration: Hvis du ikke benytter dette link til at nulstille dit password
+ inden for en uge, vil det udløbe, og du vil være nødt til at anmode om et
+ nyt.
+ intro: 'Nogen har anmodet om en nulstilling af password for din konto. Du
+ kan ændre dit password ved at følge linket nedenfor og indtaste dit nye
+ password:'
+ link_title: Ændr mit password.
+ subject: "[%{app_name}] Nulstil dit password"
+ unrequested: Hvis du ikke anmodede om nulstilling af password, kan du ignorere
+ denne e-mail, og dit tidligere password vil stadig fungere.
+ user_mailer:
+ admin_deleted_work_notification:
+ bye: Vedhæftet finder du en kopi af dit værk til orientering.
+ contact_abuse: kontakte vores komité for Retningslinjer & Misbrug
+ deleted:
+ html: Dit værk %{title} er blevet slettet fra AO3 af en administrator.
+ text: Dit værk "%{title}" er blevet slettet fra AO3 af en administrator.
+ html:
+ tos_violation: Hvis det er muligt, at dit værk har overtrådt AO3’s Brugsbetingelser,
+ kan du %{contact_abuse_link}.
+ import_project:
+ html: Hvis dit værk var en del af et importeringsprojekt fra Åbne Døre-holdet,
+ kan du %{opendoors_link}, hvis du har spørgsmål.
+ text: Hvis dit værk var en del af et importeringsprojekt fra Åbne Døre-holdet,
+ kan du kontakte Åbne Døre (%{opendoors_link}), hvis du har spørgsmål.
+ opendoors: kontakte Åbne Døre
+ subject: "[%{app_name}] Dit værk er blevet slettet af en administrator"
+ text:
+ tos_violation: Hvis det er muligt, at dit værk har overtrådt AO3’s Brugsbetingelser,
+ kan du kontakte vores komité for Retningslinjer & Misbrug (%{contact_abuse_url}).
+ admin_hidden_work_notification:
+ access: Mens dit værk er skjult, kan du stadig tilgå det via det ovenstående
+ link, men det vil ikke være synligt på din værkside, og det vil ikke være
+ tilgængeligt for andre AO3-brugere.
+ check_email: Tjek venligst din e-mail, inklusive din spammappe, da Retningslinjer
+ & Misbrug-teamet allerede kan have kontaktet dig og forklaret grunden til,
+ at dit værk er blevet skjult.
+ contact_abuse: kontakte Retningslinjer & Misbrug
+ html:
+ help: Hvis du er i tvivl om, hvorfor dit værk er blevet skjult, og du ikke
+ har modtaget nogen beskeder vedrørende dette emne, kan du %{contact_abuse_link}
+ direkte.
+ hidden: Dit værk %{title} er blevet skjult af Retningslinjer & Misbrug-teamet
+ og er ikke længere offentligt tilgængeligt.
+ tos_violation: Hvis dit værk er blevet skjult, fordi det var i strid med AO3's
+ %{tos_link}, er du nødt til først at rette op på forsømmelsen. Hvis du ikke
+ formår at tilpasse dit værk, så det overholder Brugsbetingelserne, kan det
+ i sidste ende resultere i, at dit værk bliver slettet fra AO3.
+ subject: "[%{app_name}] Dit værk er blevet skjult af Retningslinjer & Misbrug-teamet"
+ text:
+ help: 'Hvis du er i tvivl om, hvorfor dit værk er blevet skjult, og du ikke
+ har modtaget nogen beskeder vedrørende dette emne, kan du kontakte Retningslinjer
+ & Misbrug direkte: %{contact_abuse_url}'
+ hidden: Dit værk "%{title}" (%{work_url}) er blevet skjult af Retningslinjer
+ & Misbrug-teamet og er ikke længere offentligt tilgængeligt.
+ tos_violation: Hvis dit værk blev skjult, fordi det var i strid med AO3's
+ Brugsbetingelser (%{tos_url}), er du nødt til først at rette op på forsømmelsen.
+ Hvis du ikke formår at tilpasse dit værk, så det overholder Brugsbetingelserne,
+ kan det i sidste ende resultere i, at dit værk bliver slettet fra AO3.
+ tos: Brugsbetingelser
+ anonymous_or_unrevealed_notification:
+ anonymous_info: Anonyme værker er inkluderet i tag-lister, men ikke på din side
+ for værker. På værket vil dit brugernavn blive erstattet med "Anonymous" (Anonym).
+ anonymous_unrevealed_info: Vedligeholderne vil måske senere afsløre dit værk,
+ men holde det anonymt. Folk, som abonnerer på dig, vil ikke få en notifikation
+ om denne ændring. Når det er afsløret, vil dit værk blive inkluderet i tag-lister,
+ men ikke på din side for værker. På værket vil dit brugernavn blive erstattet
+ med "Anonymous" (Anonym).
+ changed_status:
+ anonymous:
+ html: Vedligeholderne af %{collection_link} har ændret status på dit værk
+ %{work_link} til anonymt.
+ text: Vedligeholderne af "%{collection_title}" (%{collection_url}) har ændret
+ status på dit værk "%{work_title}" (%{work_url}) til anonymt.
+ anonymous_unrevealed:
+ html: Vedligeholderne af %{collection_link} har ændret status på dit værk
+ %{work_link} til anonymt og ikke-offentliggjort.
+ text: Vedligeholderne af "%{collection_title}" (%{collection_url}) har ændret
+ status på dit værk "%{work_title}" (%{work_url}) til anonymt og ikke-offentliggjort.
+ unrevealed:
+ html: Vedligeholderne af %{collection_link} har ændret status på dit værk
+ %{work_link} til ikke-offentliggjort.
+ text: Vedligeholderne af "%{collection_title}" (%{collection_url}) har ændret
+ status på dit værk "%{work_title}" (%{work_url}) til ikke-offentliggjort.
+ collection_items_link_text: Approved Collection Items page (Side for Godkendte
+ Samlingselementer)
+ do_not_want:
+ anonymous:
+ html: Hvis du ikke ønsker, at dit værk forbliver anonymt, besøg din %{collection_items_link}
+ for at fjerne det fra denne samling.
+ text: 'Hvis du ikke ønsker, at dit værk forbliver anonymt, besøg din Approved
+ Collection Items page (Din side for Godkendte Samlingselementer) for at
+ fjerne det fra denne samling: %{collection_items_url}'
+ anonymous_unrevealed:
+ html: Hvis du ikke ønsker, at dit værk forbliver anonymt og ikke-offentliggjort,
+ besøg din %{collection_items_link} for at fjerne det fra denne samling.
+ text: 'Hvis du ikke ønsker, at dit værk forbliver anonymt og ikke-offentliggjort,
+ besøg din Approved Collection Items page (Din side for Godkendte Samlingselementer)
+ for at fjerne det fra denne samling: %{collection_items_url}'
+ unrevealed:
+ html: Hvis du ikke ønsker, at dit værk forbliver ikke-offentliggjort, besøg
+ din %{collection_items_link} for at fjerne det fra denne samling.
+ text: 'Hvis du ikke ønsker, at dit værk forbliver ikke-offentliggjort, besøg
+ din Approved Collection Items page (Din side for Godkendte Samlingselementer)
+ for at fjerne det fra denne samling: %{collection_items_url}'
+ faq_link_text: Samlinger FAQ
+ more_info:
+ html: For mere information besøg vores %{faq_link}.
+ text: 'For mere information besøg vores Samlinger FAQ: %{faq_url}'
+ subject:
+ anonymous: "[%{app_name}] Dit værk er gjort anonymt"
+ anonymous_unrevealed: "[%{app_name}] Dit værk er gjort anonymt og ikke-offentligt."
+ unrevealed: "[%{app_name}] Dit værk blev til et ikke-offentliggjort værk"
+ unrevealed_info: Ikke-offentliggjorte værker er ikke inkluderet i tag-listerne
+ eller på din side for værker. Enhver, som følger et link til værket, vil få
+ en meddelelse om, at det på nuværende tidspunkt ikke er offentliggjort, og
+ de vil ikke være i stand til at få adgang til indholdet.
+ archivist_added_to_collection_notification:
+ approved_collection_items_page: Approved Collection Items (Godkendte værker
+ i en samling)-side
+ archivist_notice: Fordi samlingens vedligeholdere handler i deres officielle
+ rolle som Open Doors (Åbne Døre)-arkivarer, har de tilladelse til at tilføje
+ dit værk til denne samling, selv hvis du har slået invitationer til samlinger
+ fra. Arkivarerne vil kun tilføje et værk til en samling, hvis det har ligget
+ på et importeret arkiv.
+ removal_instructions:
+ html: Hvis du vil fjerne dit værk fra samlingen, kan du gå til din %{approved_items_link}.
+ text: 'Hvis du vil fjerne dit værk fra samlingen, kan du gå til din side for
+ Approved Collection Items (Godkendte genstande i samlinger): %{approved_items_url}.'
+ subject: "[%{app_name}][%{collection_title}] En Open Doors (Åbne Døre)-arkivar
+ har tilføjet dit værk til en samling"
+ work_added:
+ html: Vedligeholderne af samlingen %{collection_link} har tilføjet dit værk
+ %{work_link} til deres samling!
+ text: Vedligeholderne af samlingen "%{collection_title}" (%{collection_url})
+ har tilføjet dit værk "%{work_title}" (%{work_url}) til deres samling!
+ challenge_assignment_notification:
+ any: Hvilken som helst
+ assignment:
+ html: Du er blevet tildelt følgende forespørgsel i %{link}-udfordringen på
+ Archive of Our Own!
+ description: 'Beskrivelse:'
+ due: 'Denne opgave har deadline den:'
+ html:
+ footer: Du modtager denne e-mail, fordi du tilmeldte dig %{title}-udfordringen.
+ For mere information om denne udfordring og moderatorernes kontaktinformation
+ gå til %{footer_link}.
+ footer_link: udfordringens profilside
+ look_up: Du kan se denne opgave på %{link}.
+ look_up_link: din Assignments (Opgaver)-side
+ optional_tags: 'Valgfrie tags:'
+ prompts: 'Inspiration:'
+ prompt_url: 'URL til inspiration:'
+ recipient: 'Modtager:'
+ recipient_missing: 'Ingen: kontakt en moderator for hjælp!'
+ subject: "[%{app_name}][%{collection_title}] Din opgave!"
+ text:
+ assignment: Du er blevet tildelt følgende forespørgsel i "%{collection_title}"
+ (%{collection_url})-udfordringen på Archive of Our Own!
+ footer: Du modtager denne e-mail, fordi du tilmeldte dig %{title}-udfordringen
+ (%{url}). For mere information om denne udfordring og moderatorernes kontaktinformation
+ gå til %{profile_url}.
+ look_up: Du kan se denne opgave på din Assignments (Opgaver)-side på %{link}.
+ change_email:
+ changed:
+ html: "%{login}, e-mailadressen, der er tilknyttet din konto, er blevet ændret
+ til %{email}"
+ text: "%{login}, e-mailadressen, der er tilknyttet din konto, er blevet ændret
+ til %{email}"
+ subject: "[%{app_name}] E-mail ændret"
+ claim_notification:
+ access:
+ contact_support: kontakte AO3 Support
+ html: Afhængig af arkivet kan dine værker være blevet importeret, så de er
+ begrænset til registrerede brugere (for at de ikke vises i Google-søgninger).
+ Hvis det er tilfældet, vil værkerne kun være tilgængelige for brugere, der
+ er logget ind, medmindre du vælger at gøre dem fuldt synlige. For at få
+ hjælp til at låse op for, frasige dig ophavsretten eller slette dine værker
+ kan du %{contact_support_link}.
+ text: Afhængig af arkivet kan dine værker være blevet importeret, så de er
+ begrænset til registrerede brugere (for at de ikke vises i Google-søgninger).
+ Hvis det er tilfældet, vil værkerne kun være tilgængelige for brugere, der
+ er logget ind, medmindre du vælger at gøre dem fuldt synlige. For at få
+ hjælp til at låse op for, frasige dig ophavsretten eller slette dine værker
+ kan du kontakte AO3 Support på %{support_url}.
+ email_tips: Hvis du kontakter os, så tilføj gerne e-mailadresser fra @transformativeworks.org
+ til din liste med kontakter, du har tillid til, og tjek dit spamfilter for
+ vores svar.
+ introduction:
+ ao3_name: Archive of Our Own – AO3 (Vores Eget Arkiv)
+ html: Du modtager denne e-mail, fordi du havde værker på et fanværk-arkiv,
+ der er blevet importeret af %{open_doors_name_link} til %{app_link}. Fordi
+ denne e-mailadresse er forbundet med en e-mail, der er registreret på det
+ importerede arkiv, er de tilknyttede fanværker (listet nedenfor) automatisk
+ blevet tilføjet til din AO3-konto.
+ open_doors_name: Open Doors (Åbne Døre)
+ text: 'Du har modtaget denne e-mail, fordi du havde værker på et fanværksarkiv,
+ der er blevet importeret af Open Doors (Åbne Døre) (%{open_doors_url}) til
+ Archive of Our Own – AO3 (Vores Eget Arkiv): %{app_url}. Fordi denne e-mailadresse
+ er forbundet med en e-mail, der er registreret i det importerede arkiv,
+ er de tilknyttede fanværker (listet nedenfor) automatisk blevet tilføjet
+ til din AO3-konto.'
+ mistake:
+ contact_open_doors: Kontakt Åbne Døre
+ html: Hvis dette er en fejl, og disse værker ikke er dine, skal du ikke slette
+ dem! %{contact_open_doors_link}, så ordner vi det.
+ text: Hvis dette er en fejl, og disse værker ikke er dine, skal du ikke slette
+ dem! Kontakt Åbne Døre (%{open_doors_url}), så ordner vi det.
+ more_info:
+ ao3_news: AO3 Nyheder
+ contact_support: kontakte AO3 Support
+ faq_page: FAQ-side
+ html: Du kan læse annonceringer om nylige arkivflytninger på %{ao3_news_link}
+ og finde yderligere information på Åbne Døres %{faq_page_link} eller %{tutorial_page_link}.
+ Hvis du har yderligere spørgsmål, der ikke er besvaret i FAQ'en, tutorials
+ eller denne e-mail, kan du %{contact_support_link}.
+ text: Du kan læse annonceringer om nylige arkivflytninger på AO3 Nyheder (%{news_url})
+ og finde yderligere information på Åbne Døres FAQ-side (%{open_doors_faq_url})
+ eller tutorials-side (%{open_doors_tutorial_url}). Hvis du har yderligere
+ spørgsmål, der ikke er besvaret i FAQ'en, tutorials eller denne e-mail,
+ kan du kontakte Support på %{support_url}.
+ tutorial_page: tutorial-side
+ other_works:
+ contact_open_doors: kontakt Åbne Døre
+ html: Hvis du havde andre værker på det importerede arkiv, men under en e-mailadresse,
+ du ikke længere kan tilgå, så %{contact_open_doors_link} med enhver information,
+ der kan hjælpe med at bekræfte din identitet.
+ text: Hvis du havde andre værker på det importerede arkiv, men under en e-mailadresse,
+ du ikke længere kan tilgå, så kontakt gerne Åbne Døre med enhver information,
+ der kan hjælpe med at bekræfte din identitet.
+ questions:
+ contact_support: kontakt AO3 Support
+ html: Har du andre forespørgsler, så %{contact_support_link}.
+ text: Har du andre forespørgsler, så kontakt AO3 Support på %{support_url}.
+ redirects:
+ html: Af hensyn til lister med anbefalinger eller bogmærker kan det importerede
+ arkivs hjemmeside i en begrænset periode omdirigere til de importerede kopier
+ af disse værker (tjek annonceringen om dit arkiv for at være sikker). Hvis
+ du allerede har uploadet en kopi af disse værker, og du %{negation} importerede
+ ved at bruge URL-funktionen, vil der være to kopier af det samme værk på
+ AO3.
+ subject: "[%{app_name}] Værker er blevet uploadet"
+ update_redirect:
+ contact_open_doors: kontakte Åbne Døre
+ html: Hvis du ønsker, at Åbne Døre opdaterer omdirigeringen, så den leder
+ til dit allerede eksisterende værk, så skal du slette den importerede kopi
+ og %{contact_open_doors_link} med dit brugernavn på AO3, dit brugernavn
+ på det importerede arkiv samt titel og URL på det fanværk, der skal omdirigeres
+ til. (Hvis du har flere værker, du gerne vil ændre omdirigeringen for, kan
+ du liste dem alle i én e-mail).
+ text: Hvis du ønsker, at Åbne Døre opdaterer omdirigeringen, så den leder
+ til dit allerede eksisterende værk, så skal du slette den importerede kopi
+ og kontakte Åbne Døre på %{open_doors_url} med dit brugernavn på AO3, dit
+ brugernavn på det importerede arkiv samt titel og URL på det fanværk, der
+ skal omdirigeres til. (Hvis du har flere værker, du gerne vil ændre omdirigeringen
+ for, kan du liste dem alle i én e-mail).
+ works_by: 'Disse værker var tilknyttet e-mailen: %{email}'
+ work_info:
+ html: "%{work_link} (%{fandom})"
+ text:
+ no_fandom: "- %{work_title} %{work_url}"
+ with_fandom: "- %{work_title} %{work_url} (%{fandom})"
+ collection_notification:
+ assignments_sent:
+ complete: Alle opgavetildelinger er blevet afsendt.
+ subject: Opgavetildelinger afsendt
+ html:
+ received_message: 'Du har modtaget en besked vedrørende din samling %{collection_link}:'
+ text:
+ received_message: 'Du har modtaget en besked vedrørende din samling "%{collection_title}"
+ (%{collection_url}):'
+ creatorship_notification:
+ explanation: Når du er medskaber på et værk, kan du blive tilføjet til nye kapitler
+ uanset dine indstillinger for at være medskaber. Du vil også blive tilføjet
+ til enhver serie, som værket bliver tilføjet.
+ html:
+ creation: "%{creation_link} af %{pseud_links}"
+ edit_chapter: redigere kapitlet
+ edit_series: redigere serien
+ remove_chapter: Hvis du er blevet tilføjet ved en fejl eller ikke vil stå
+ som skaber, kan du %{edit_chapter_link} for at fjerne dig selv som skaber.
+ remove_series: Hvis du er blevet tilføjet ved en fejl eller ikke vil stå som
+ medskaber, kan du %{edit_series_link} for at fjerne dig selv som skaber.
+ intro_chapter: 'Brugeren %{adding_user} har sat dit pseudonym %{pseud} som medskaber
+ på det følgende kapitel:'
+ intro_series: 'Brugeren %{adding_user} har sat dit pseudonym %{pseud} som medskaber
+ på følgende serie:'
+ subject: "[%{app_name}] Notifikation til medskaber"
+ text:
+ creation: "%{title} (%{url}) af %{pseuds}"
+ remove_chapter: 'Hvis du er blevet tilføjet ved en fejl eller ikke vil stå
+ som skaber, kan du redigere kapitlet for at fjerne dig selv som skaber:
+ %{url}'
+ remove_series: 'Hvis du er blevet tilføjet ved en fejl eller ikke vil stå
+ som skaber, kan du redigere serien for at fjerne dig selv som skaber: %{url}'
+ creatorship_notification_archivist:
+ explanation: Fordi de opererer i en officiel funktion som arkivar for Open Doors
+ (Åbne Døre), har de tilladelse til at tilføje dig uden en anmodning, selvom
+ du har deaktiveret samskabelse.
+ html:
+ creation: "%{creation_link} af %{pseud_links}"
+ edit_chapter: redigere kapitlet
+ edit_series: redigere serien
+ edit_work: redigere værket
+ remove_chapter: Hvis du er blevet tilføjet ved en fejl eller ikke ønsker at
+ være noteret som skaber, kan du %{edit_chapter_link} for at fjerne dig selv
+ som skaber.
+ remove_series: Hvis du er blevet tilføjet ved en fejl eller ikke ønsker at
+ være noteret som skaber, kan du %{edit_series_link} for at fjerne dig selv
+ som skaber.
+ remove_work: Hvis du er blevet tilføjet ved en fejl eller ikke ønsker at være
+ noteret som skaber, kan du %{edit_work_link} for at fjerne dig selv som
+ skaber.
+ intro_chapter: 'Brugeren %{archivist} har tilføjet dit pseudonym %{pseud} som
+ medskaber af følgende kapitel:'
+ intro_series: 'Brugeren %{archivist} har tilføjet dit pseudonym %{pseud} som
+ medskaber af følgende serie:'
+ intro_work: 'Brugeren %{archivist} har tilføjet dit pseudonym %{pseud} som medskaber
+ af følgende værk:'
+ subject: "[%{app_name}] Medskabernotifikation fra arkivar"
+ text:
+ creation: "%{title} (%{url}) af %{pseuds}"
+ remove_chapter: 'Hvis du er blevet tilføjet ved en fejl eller ikke ønsker
+ at være noteret som skaber, kan du redigere kapitlet for at fjerne dig selv
+ som skaber: %{url}'
+ remove_series: 'Hvis du er blevet tilføjet ved en fejl eller ikke ønsker at
+ være noteret som skaber, kan du redigere serien for at fjerne dig selv som
+ skaber: %{url}'
+ remove_work: 'Hvis du er blevet tilføjet ved en fejl eller ikke ønsker at
+ være noteret som skaber, kan du redigere værket for at fjerne dig selv som
+ skaber: %{url}'
+ creatorship_request:
+ html:
+ creation: "%{creation_link} af %{pseud_links}"
+ instructions: Du kan acceptere eller afvise denne anmodning på din side for
+ %{page_name}.
+ page_name: Co-Creator Requests (Medskaberanmodninger)
+ intro_chapter: 'Brugeren %{inviting_user} har inviteret dit pseudonym %{pseud}
+ til at blive listet som medskaber på følgende kapitel:'
+ intro_series: 'Brugeren %{inviting_user} har inviteret dit pseudonym %{pseud}
+ til at blive listet som medskaber på følgende serie:'
+ intro_work: 'Brugeren %{inviting_user} har inviteret dit pseudonym %{pseud}
+ til at blive listet som medskaber på følgende værk:'
+ subject: "[%{app_name}] Anmodning til medskaber"
+ text:
+ creation: "%{title} (%{url}) af %{pseuds}"
+ instructions: 'Du kan acceptere eller afvise denne anmodning på din side for
+ Co-Creator Requests (Medskaberanmodninger): %{url}'
+ delete_work_notification:
+ attachment: Vedhæftet finder du en kopi af dit værk til din orientering.
+ deleted_other:
+ html: Dit værk %{title} er blevet slettet efter anmodning fra %{pseud}.
+ text: Dit værk "%{title}" er blevet slettet efter anmodning fra %{pseud}.
+ deleted_yourself:
+ html: Dit værk %{title} er blevet slettet på din anmodning.
+ text: Dit værk "%{title}" er blevet slettet på din anmodning.
+ questions:
+ html: Hvis du har spørgsmål, kan du %{support}.
+ text: Hvis du har spørgsmål, kan du %{support} (%{url}).
+ subject: "[%{app_name}] Dit værk er blevet slettet"
+ support: kontakte Support
+ invitation_to_claim:
+ access:
+ text: Afhængig af arkivet er dine værker muligvis importeret således, at adgangen
+ er begrænset til registrerede brugere (for at holde dem ude af Google-søgninger).
+ Hvis det er tilfældet, er værkerne kun tilgængelige for brugere, der er
+ logget ind, medmindre du vælger at gøre dem tilgængelige for alle. Har du
+ brug for hjælp til at låse dine værker op, frasige dig ophavsretten eller
+ slette dine værker, kan du kontakte AO3 Support.
+ claim_or_remove:
+ html: Gør krav på, eller fjern dine værker her.
+ text: 'Gør krav på, eller fjern dine værker her: %{claim_url}'
+ email_tips: Hvis du kontakter os, beder vi dig om at whiteliste e-mailadresser
+ fra @transformativeworks.org, og husk at kigge i din spam-mappe for at se
+ om vores svar ligger der.
+ html:
+ ao3_news: AO3 Nyheder
+ contact_open_doors: kontakte Åbne Døre
+ contact_support: kontakte AO3 Support
+ faq_page: FAQ-side
+ tutorial_page: tutorial-side
+ introduction:
+ text: Du modtager denne e-mail, fordi et arkiv for nyligt er blevet importeret
+ af Open Doors (Åbne Døre) (%{open_doors_link}) til %{app_name} (%{app_short_name}
+ - %{app_url}), og vi mener, at følgende fanværker tilhører dig. Vi vil gerne
+ give dig mulighed for at gøre krav på (eller slette/frasige dig ophavsretten
+ på) disse værker, hvis du vil. Og hvis du ikke allerede har en konto på
+ en anden mail, vil vi gerne invitere dig med ombord!
+ mistake:
+ text: Hvis dette er en fejl, og det ikke er dine værker, må du endelig ikke
+ slette dem! Vær i stedet venlig at kontakte Åbne Døre %{open_doors_link},
+ så ordner vi det.
+ more_info:
+ text: Du kan læse meddelelser om nye importeringer af arkiver på AO3 Nyheder
+ (%{news_link}) og finde mere information på Åbne Døres FAQ-side (%{open_doors_faq_link})
+ eller tutorial-side (%{open_doors_tutorial_link}). Har du spørgsmål, der
+ ikke er blevet besvaret i FAQ'en, tutorials eller i denne mail, kan du kontakte
+ Support på %{support_link}.
+ other_works:
+ text: Hvis du havde andre værker på det importerede arkiv under en e-mailadresse,
+ som du ikke længere har adgang til, kan du kontakte Åbne Døre med information,
+ som kan hjælpe med at verificere din identitet.
+ questions:
+ text: For flere spørgsmål kan du kontakte AO3 Support på %{support_link}.
+ redirects: For at bevare anbefalede værker og bogmærker vil det importerede
+ arkivs URL'er muligvis omdirigere til de importerede kopier af disse værker
+ i en begrænset periode (se Åbne Døres meddelelse om dit arkiv for at være
+ sikker). Hvis du allerede har uploadet en kopi af disse værker, og du IKKE
+ brugte importér fra URL-funktionen, vil der være to kopier af det samme værk
+ på AO3.
+ subject: "[%{app_name}] Invitation til at gøre krav på værker"
+ unwanted:
+ text: Hvis disse værker tilhører dig, men du ikke vil gøre krav på dem, kan
+ du frasige dig ophavsretten (så værkerne bliver på AO3, men dit navn bliver
+ fjernet) eller slette dem (så værkerne bliver fjernet helt fra AO3). Du
+ behøver ikke at tilføje disse værker til en konto for at frasige dig ophavsretten
+ eller slette dem – du kan gøre dette med det samme via linket ovenfor. (Har
+ du brug for hjælp, kan du kontakte Support på %{support_link}.)
+ update_redirect:
+ text: Hvis du gerne vil have Åbne Døre til at opdatere omdirigeringen, så
+ den leder til dit eksisterende værk, skal du slette den importerede kopi
+ og kontakte Åbne Døre på %{open_doors_link} med din AO3-konto, din konto
+ på det importerede arkiv samt titlen og URL'et på det fanværk, du gerne
+ vil omdirigere til. (Hvis du har flere værker, hvor du gerne vil ændre omdirigeringen,
+ kan du liste dem alle op i én e-mail.)
+ uploaded_list: 'De uploadede værker inkluderer:'
+ invite_increase_notification:
+ html:
+ body:
+ one: Vi ville bare lige fortælle, at du har %{count} ny invitation, som
+ kan bruges til at oprette en ny konto på AO3. Du kan invitere en ven på
+ %{invitation_page_link}.
+ other: Vi ville bare lige fortælle, at du har %{count} nye invitationer,
+ som kan bruges til at oprette nye konti på AO3. Du kan invitere en ven
+ på %{invitation_page_link}.
+ invitation_page_link_text: din Invitations (Invitationer)-side
+ subject: "[%{app_name}] Nye invitationer"
+ text:
+ body:
+ one: Vi ville bare lige fortælle, at du har %{count} ny invitation, som
+ kan bruges til at oprette en ny konto på AO3. Du kan invitere en ven på
+ din Invitations (invitationer)-side (%{invitation_page_url}).
+ other: Vi ville bare lige fortælle, at du har %{count} nye invitationer,
+ som kan bruges til at oprette nye konti på AO3. Du kan invitere en ven
+ på din Invitations (invitationer)-side (%{invitation_page_url}).
+ invite_request_declined:
+ main:
+ one: Vi må desværre meddele dig, at din anmodning om en ny invitation ikke
+ kan opfyldes på nuværende tidspunkt.
+ other: Vi må desværre meddele dig, at din anmodning om %{count} nye invitationer
+ ikke kan opfyldes på nuværende tidspunkt.
+ reason: 'Din anmodning var:'
+ subject: "[%{app_name}] Anmodning om yderligere invitationer afvist"
+ recipient_notification:
+ html:
+ collection: Et værk er blevet udgivet som gave til dig i %{collection_link}-samlingen
+ på AO3!
+ no_collection: Et værk er blevet udgivet som gave til dig på AO3!
+ subject:
+ collection: "[%{app_name}][%{collection_title}] Et værk i gave til dig fra
+ %{collection_title}"
+ no_collection: "[%{app_name}] Et værk i gave til dig"
+ text:
+ collection: Et værk er blevet udgivet som gave til dig i "%{collection_title}"-samlingen
+ (%{collection_url}) på AO3!
+ signup_notification:
+ activate:
+ html: Venligst %{activate_account_link}.
+ text: 'Følg venligst dette link for at aktivere din konto: %{activate_account_url}'
+ activate_your_account: følg dette link for at aktivere din konto
+ admin_posts: AO3 nyheder
+ bye: Vi håber, at du bliver glad for at bruge AO3.
+ contact_support: kontakte vores Support-team
+ faq: FAQ
+ features:
+ html: Når din konto er sat op, kan du publicere fanværker, oprette e-mailnotifikationer,
+ der fortæller dig, hvornår dine favoritskabere eller -værker opdaterer,
+ indstille hvordan hjemmesiden ser ud og fungerer for dig, holde styr på,
+ hvilke værker du har set på AO3 gennem din historik og meget mere.
+ text: Når din konto er sat op, kan du publicere fanværker, oprette e-mailnotifikationer,
+ der fortæller dig, hvornår dine favoritskabere eller -værker opdaterer,
+ indstille, hvordan hjemmesiden ser ud og fungerer for dig, holde styr på,
+ hvilke værker du har set på AO3 gennem din historik og meget mere.
+ information:
+ html: Der er masser af information om og råd til, hvordan du kan bruge AO3
+ i vores %{faq_link}. Du kan finde de seneste nyheder om hjemmesidens udvikling
+ på %{admin_posts_link}. Hvis du har brug for mere hjælp, løber ind i en
+ fejl eller har spørgsmål eller kommentarer, kan du %{contact_support_link},
+ som altid gerne vil hjælpe.
+ text: 'Der er masser af information om og råd til, hvordan du kan bruge AO3
+ i vores FAQ her %{faq_url}. Du kan finde de seneste nyheder om hjemmesidens
+ udvikling på %{admin_posts_url}. Hvis du har brug for mere hjælp, løber
+ ind i en fejl eller har spørgsmål eller kommentarer, kan du tage fat i vores
+ Support-team, som altid gerne vil hjælpe, ved at: %{contact_support_url}.'
+ welcome: Velkommen til Archive of Our Own, %{login}!
diff --git a/config/locales/phrase-exports/de.yml b/config/locales/phrase-exports/de.yml
new file mode 100755
index 0000000..6a08347
--- /dev/null
+++ b/config/locales/phrase-exports/de.yml
@@ -0,0 +1,644 @@
+---
+de:
+ activerecord:
+ attributes:
+ archive_warning:
+ name_with_colon:
+ one: 'Warnung:'
+ other: 'Warnungen:'
+ category:
+ name_with_colon:
+ one: 'Kategorie:'
+ other: 'Kategorien:'
+ character:
+ name_with_colon:
+ one: 'Charakter:'
+ other: 'Charaktere:'
+ fandom:
+ name_with_colon:
+ one: 'Fandom:'
+ other: 'Fandoms:'
+ freeform:
+ name_with_colon:
+ one: 'Zusätzliches Tag:'
+ other: 'Zusätzliche Tags:'
+ rating:
+ name_with_colon: 'Alterseinstufung:'
+ relationship:
+ name_with_colon:
+ one: 'Beziehung:'
+ other: 'Beziehungen:'
+ work:
+ chapter_total_display: Kapitel
+ summary: Zusammenfassung
+ models:
+ archive_warning:
+ one: Warnung
+ other: Warnungen
+ category:
+ one: Kategorie
+ other: Kategorien
+ chapter:
+ one: Kapitel
+ other: Kapitel
+ character:
+ one: Charakter
+ other: Charaktere
+ fandom:
+ one: Fandom
+ other: Fandoms
+ freeform:
+ one: Zusätzliches Tag
+ other: Zusätzliche Tags
+ rating:
+ one: Alterseinstufung
+ other: Alterseinfstufungen
+ relationship:
+ one: Beziehung
+ other: Beziehungen
+ series:
+ one: Serie
+ other: Serien
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ one: ein Gast
+ other: "%{count} Gäste"
+ left_kudos:
+ html:
+ one: "%{givers_list} hat Kudos für %{commentable_link} hinterlassen."
+ other: "%{givers_list} haben Kudos für %{commentable_link} hinterlassen."
+ text:
+ one: "%{givers_list} hat Kudos für %{commentable_title} (%{commentable_url})
+ hinterlassen."
+ other: "%{givers_list} haben Kudos für %{commentable_title} (%{commentable_url})
+ hinterlassen."
+ single_guest:
+ giver: Ein Gast
+ html: "%{giver} hat Kudos für %{commentable_link} hinterlassen."
+ text: Ein Gast hat Kudos für %{commentable_title} (%{commentable_url}) hinterlassen.
+ subject: "[%{app_name}] Du hast Kudos erhalten!"
+ mailer:
+ general:
+ closing:
+ formal: Mit freundlichen Grüßen
+ informal: Viele Grüße
+ creation:
+ link_with_word_count: "%{creation_link} (%{word_count})"
+ title_with_chapter_number: Kapitel %{position} von %{title}
+ title_with_word_count: '"%{creation_title}" (%{word_count})'
+ word_count:
+ one: "%{count} Wort"
+ other: "%{count} Worte"
+ footer:
+ general:
+ about:
+ html: Das AO3 ist ein Archiv von Fans für Fans, das auf %{donate_link}
+ angewiesen ist.
+ text: 'Das AO3 ist ein Archiv von Fans für Fans, das auf Deine Spenden
+ angewiesen ist: %{donate_url}.'
+ html:
+ donate_link_text: Deine Spenden
+ support_link_text: wende Dich bitte an den Support
+ unwanted_email:
+ html: Solltest Du diese Nachricht irrtümlicherweise erhalten haben, %{support_link}.
+ text: Solltest Du diese Nachricht irrtümlicherweise erhalten haben, wende
+ Dich bitte unter %{support_url} an den Support.
+ sent_at: Gesendet %{sent_at}.
+ greeting:
+ formal_html: Hallo %{name},
+ informal:
+ addressed_html: Hallo %{name}!
+ unaddressed: Hallo!
+ introductory: Hallo vom Archive of Our Own – AO3 (Ein Eigenes Archiv)
+ metadata_label_indicator: ":"
+ signature:
+ abuse_team: Das AO3 "Richtlinien und Missbrauch"-Team
+ app_short_name: AO3
+ open_doors: Das "Open Doors"- (Offene Türen) Team
+ parent_org: Organization for Transformative Works – OTW (Organisation für
+ Transformative Werke)
+ support: Das AO3-Support-Team
+ users:
+ mailer:
+ reset_password_instructions:
+ expiration: Wenn Du den Link zur Passwortzurücksetzung nicht innerhalb der
+ nächsten sieben Tage nutzt, verfällt er und Du musst einen neuen anfordern.
+ intro: 'jemand hat ein neues Passwort für Dein Nutzer*innenkonto angefordert.
+ Du kannst Dein Passwort ändern, indem Du den untenstehenden Link anklickst
+ und Dein neues Passwort eingibst:'
+ link_title: Passwort ändern.
+ subject: "[%{app_name}] Setze Dein Passwort zurück"
+ unrequested: Wenn Du keine Zurücksetzung Deines Passworts beantragt hast,
+ kannst Du diese E-Mail ignorieren und Dein altes Passwort bleibt gültig.
+ user_mailer:
+ admin_deleted_work_notification:
+ bye: Anbei ist eine Kopie deines Werkes als Referenz.
+ contact_abuse: kontaktiere bitte unser “Richtlinien und Missbrauch”-Komitee
+ deleted:
+ html: dein Werk %{title} wurde von einem*r Webadministrator*in des AO3 gelöscht.
+ text: dein Werk "%{title}" wurde von einem*r Webadministrator*in des AO3 gelöscht.
+ html:
+ tos_violation: Wenn dein Werk eventuell die Nutzungsbedingungen des AO3 verletzt
+ hat, %{contact_abuse_link}.
+ import_project:
+ html: Falls dein Werk Teil eines Import-Projektes durch unser Open Doors-
+ (Offene Türen) Komitee war, %{opendoors_link} für alle weiteren Fragen.
+ text: Falls dein Werk Teil eines Import-Projektes durch unser Open Doors-
+ (Offene Türen) Komitee war, kontaktiere bitte Offene Türen (%{opendoors_link})
+ für alle weiteren Fragen.
+ opendoors: kontaktiere bitte das “Offene Türen”-Komitee
+ subject: "[%{app_name}] Dein Werk wurde von einem*r Webadministrator*in gelöscht"
+ text:
+ tos_violation: Wenn dein Werk eventuell die Nutzungsbedingungen des AO3 verletzt
+ hat, kontaktiere bitte unser Richtlinien und Missbrauch-Komitee (%{contact_abuse_url}).
+ admin_hidden_work_notification:
+ access: Obwohl Dein Werk verborgen ist, kannst Du es immer noch über den oben
+ angegebenen Link aufrufen; es wird jedoch weder in der Übersicht Deiner Werke
+ aufgelistet, noch ist es für andere AO3-Nutzer*innen verfügbar.
+ check_email: Bitte überprüfe Deine E-Mails, einschließlich Deines Spam-Ordners,
+ da das “Richtlinien und Missbrauch”-Komitee Dich möglicherweise bereits kontaktiert
+ hat, um zu erklären, warum Dein Werk verborgen wurde.
+ contact_abuse: kontaktiere das “Richtlinien und Missbrauch”-Komitee
+ html:
+ help: Wenn Du Dir nicht sicher bist, warum Dein Werk verborgen wurde und Du
+ keine weitere Mitteilung in dieser Angelegenheit erhalten hast, dann %{contact_abuse_link}
+ bitte direkt.
+ hidden: Dein Werk %{title} wurde vom “Richtlinien und Missbrauch”-Komitee
+ verborgen und ist nicht mehr öffentlich zugänglich.
+ tos_violation: Wenn Dein Werk aufgrund eines Verstoßes gegen die %{tos_link}
+ des AO3 verborgen wurde, musst Du Maßnahmen ergreifen, um den Verstoß zu
+ beheben. Wenn Du Dein Werk nicht in Übereinstimmung mit den Nutzungsbedingungen
+ bringst, wird es möglicherweise aus dem AO3 gelöscht.
+ subject: "[%{app_name}] Dein Werk wurde vom “Richtlinien und Missbrauch”-Komitee
+ verborgen"
+ text:
+ help: 'Wenn Du Dir nicht sicher bist, warum Dein Werk verborgen wurde und
+ Du keine weitere Mitteilung in dieser Angelegenheit erhalten hast, dann
+ kontaktiere das “Richtlinien und Missbrauch”-Komitee bitte direkt: %{contact_abuse_url}.'
+ hidden: Dein Werk “%{title}” (%{work_url}) wurde vom “Richtlinien und Missbrauch”-Komitee
+ verborgen und ist nicht mehr öffentlich zugänglich.
+ tos_violation: Wenn Dein Werk aufgrund eines Verstoßes gegen die Nutzungsbedingungen
+ des AO3 (%{tos_url}) verborgen wurde, musst Du Maßnahmen ergreifen, um den
+ Verstoß zu beheben. Wenn Du Dein Werk nicht in Übereinstimmung mit den Nutzungsbedingungen
+ bringst, wird es möglicherweise aus dem AO3 gelöscht.
+ tos: Nutzungsbedingungen
+ anonymous_or_unrevealed_notification:
+ anonymous_info: Anonyme Werke werden in Tag-Listen angezeigt, nicht jedoch auf
+ Deiner Werke-Seite. In dem Werk wird Dein Benutzer*innenname mit “Anonymous”
+ (Anonym) ersetzt.
+ anonymous_unrevealed_info: Die Betreuer*innen der Sammlung können Dein Werk
+ später enthüllen, aber dabei anonym belassen. Benutzer*innen, die Benachrichtigungen
+ zu Deinen Werken abonniert haben, werden über diese Änderung nicht informiert.
+ Sobald das Werk enthüllt wurde, wird es in Tag-Listen aufgeführt, nicht jedoch
+ auf Deiner Werke-Seite. In dem Werk wird Dein Name durch “Anonymous” (Anonym)
+ ersetzt.
+ changed_status:
+ anonymous:
+ html: die Betreuer*innen von %{collection_link} haben den Status Deines
+ Werks %{work_link} auf anonym gesetzt.
+ text: die Betreuer*innen von "%{collection_title}" (%{collection_url}) haben
+ den Status Deines Werks "%{work_title}" (%{work_url}) auf anonym gesetzt.
+ anonymous_unrevealed:
+ html: die Betreuer*innen von %{collection_link} haben den Status Deines
+ Werks %{work_link} auf anonym und verborgen gesetzt.
+ text: die Betreuer*innen von "%{collection_title}" (%{collection_url}) haben
+ den Status Deines Werks "%{work_title}" (%{work_url}) auf anonym und verborgen
+ gesetzt.
+ unrevealed:
+ html: die Betreuer*innen von %{collection_link} haben den Status Deines
+ Werks %{work_link} auf verborgen gesetzt.
+ text: die Betreuer*innen von "%{collection_title}" (%{collection_url}) haben
+ den Status Deines Werks "%{work_title}" (%{work_url}) auf verborgen gesetzt.
+ collection_items_link_text: Approved Collection Items (Seite für genehmigte
+ Objekte einer Sammlung)
+ do_not_want:
+ anonymous:
+ html: Wenn Du nicht möchtest, dass Dein Werk anonym angezeigt wird, dann
+ besuche bitte die %{collection_items_link} und entferne es aus der Sammlung.
+ text: 'Wenn Du nicht möchtest, dass Dein Werk anonym angezeigt wird, dann
+ besuche bitte Deine Approved Collection Items (Seite für genehmigte Objekte
+ einer Sammlung) und entferne es aus der Sammlung: %{collection_items_url}'
+ anonymous_unrevealed:
+ html: Wenn Du nicht möchtest, dass Dein Werk anonym und verborgen ist, dann
+ besuche bitte die %{collection_items_link} und entferne es aus der Sammlung.
+ text: 'Wenn Du nicht möchtest, dass Dein Werk anonymisiert und verborgen
+ wird, dann besuche bitte Deine Approved Collection Items (Seite für genehmigte
+ Objekte einer Sammlung) und entferne es aus der Sammlung: %{collection_items_url}'
+ unrevealed:
+ html: Wenn Du nicht möchtest, dass Dein Werk verborgen ist, dann besuche
+ bitte die %{collection_items_link} und entferne es aus der Sammlung.
+ text: 'Wenn Du nicht möchtest, dass Dein Werk verborgen wird, dann besuche
+ bitte Deine Approved Collection Items (Seite für genehmigte Objekte einer
+ Sammlung) und entferne es aus der Sammlung: %{collection_items_url}'
+ faq_link_text: Herausforderungen FAQ (Häufige Fragen)
+ more_info:
+ html: Weitere Informationen findest Du in unserer %{faq_link}.
+ text: 'Weitere Informationen findest Du in unseren Herausforderungen FAQ (Häufige
+ Fragen): %{faq_url}'
+ subject:
+ anonymous: "[%{app_name}] Dein Werk wurde anonymisiert"
+ anonymous_unrevealed: "[%{app_name}] Dein Werk wurde anonymisiert und verborgen"
+ unrevealed: "[%{app_name}] Dein Werk wurde verborgen"
+ unrevealed_info: Verborgene Werke werden nicht in Tag-Listen oder auf Deiner
+ Werke-Seite angezeigt. Jemand, der auf den Link zu Deinem Werk klickt, erhält
+ eine Nachricht, dass es zurzeit verborgen ist und kann darauf nicht zugreifen.
+ archivist_added_to_collection_notification:
+ approved_collection_items_page: Seite für Approved Collection Items (Zugelassene
+ Sammlungsgegenstände)
+ archivist_notice: Weil die Sammlungsinhaber*innen in ihrer Eigenschaft als offizielle
+ Open Doors (Offene Türen) Archivar*innen gehandelt haben, ist es ihnen erlaubt,
+ Dein Werk zu dieser Sammlung hinzuzufügen, auch wenn Du Sammlungseinladungen
+ deaktiviert hast. Archivar*innen werden nur Dein Werk zu einer Sammlung hinzufügen,
+ wenn es in einem importierten Archiv gehostet wird.
+ removal_instructions:
+ html: 'Wenn Du Dein Werk von dieser Sammlung entfernen möchtest, verwende
+ diesen Link: %{approved_items_link}.'
+ text: 'Wenn Du Dein Werk aus dieser Sammlung entfernen möchtest, besuche bitte
+ Deine Seite für Approved Collection Items (Zugelassene Sammlungsgegenstände):
+ %{approved_items_url}.'
+ subject: "[%{app_name}][%{collection_title}] Ein*e Open Doors (Offene Türen)
+ Archivar*in hat Dein Werk zu einer Sammlung hinzugefügt."
+ work_added:
+ html: Die Sammlungsinhaber*innen von %{collection_link} haben Dein Werk %{work_link}
+ zu ihrer Sammlung hinzugefügt!
+ text: Die Sammlungsinhaber*innen von "%{collection_title}" (%{collection_url})
+ haben Dein Werk "%{work_title}" (%{work_url}) zu ihrer Sammlung hinzugefügt!
+ challenge_assignment_notification:
+ any: Jegliche
+ assignment:
+ html: Dir wurde folgender Wunsch in der Herausforderung %{link} im AO3 zugewiesen!
+ description: 'Beschreibung:'
+ due: 'Diese Aufgabe ist fällig am:'
+ html:
+ footer: Du erhältst diese E-Mail, weil Du Dich für die Herausforderung %{title}
+ angemeldet hast. Weitere Einzelheiten zu dieser Herausforderung, sowie die
+ Kontaktinformationen der Moderator*innen findest Du auf der %{footer_link}.
+ footer_link: Profilseite der Herausforderung
+ look_up: Du kannst diese Aufgabe auf %{link} anschauen.
+ look_up_link: Deine Assignments (Aufgaben) Seite
+ optional_tags: 'Optionale Tags:'
+ prompts: 'Anregungen:'
+ prompt_url: 'URL der Anregung:'
+ recipient: 'Empfänger*in:'
+ recipient_missing: 'Niemand: bitte eine*n Moderator*in um Hilfe!'
+ subject: "[%{app_name}][%{collection_title}] Deine Aufgabe!"
+ text:
+ assignment: Dir wurde folgender Wunsch in der Herausforderung "%{collection_title}"
+ (%{collection_url}) im AO3 zugewiesen!
+ footer: Du erhältst diese E-Mail, weil Du Dich für die Herausforderung %{title}
+ (%{url}) angemeldet hast. Weitere Einzelheiten zu dieser Herausforderung,
+ sowie Kontaktinformationen der Moderator*innen findest Du unter %{profile_url}.
+ look_up: Du kannst diese Aufgabe auf Deiner Assignments (Aufgaben) Seite unter
+ %{link} ansehen.
+ change_email:
+ changed:
+ html: "%{login}, die mit Deinem Konto verknüpfte E-Mail-Adresse wurde zu %{email}
+ geändert."
+ text: "%{login}, die mit Deinem Konto verknüpfte E-Mail-Adresse wurde zu %{email}
+ geändert."
+ subject: "[%{app_name}] E-Mail-Adresse geändert"
+ claim_notification:
+ access:
+ contact_support: kontaktiere den AO3 Support
+ html: Je nach Archiv kann es sein, dass Deine Werke nur für registrierte Nutzer
+ importiert wurden (um sie aus der Google-Suche herauszuhalten). Wenn dies
+ der Fall ist, sind die Werke nur für eingeloggte Benutzer zugänglich, außer,
+ Du machst sie vollständig sichtbar. Wenn Du Hilfe beim Freischalten, Auswildern
+ oder Löschen Deiner Werke benötigst, %{contact_support_link}.
+ text: Je nach Archiv kann es sein, dass Deine Werke nur für registrierte Nutzer
+ importiert wurden (um sie aus der Google-Suche herauszuhalten). Wenn dies
+ der Fall ist, sind die Werke nur für eingeloggte Benutzer zugänglich, außer,
+ Du machst sie vollständig sichtbar. Wenn Du Hilfe beim Freischalten, Auswildern
+ oder Löschen Deiner Werke benötigst, wende Dich bitte an AO3 Support unter
+ %{support_url}.
+ email_tips: Wenn Du uns kontaktierst, füge bitte E-Mail-Adressen von @transformativeworks.org
+ zur Liste Deiner sicheren Kontakte und prüfe Deinen Spam-Ordner auf unsere
+ Antwort.
+ introduction:
+ ao3_name: Archive of Our Own – AO3 (Ein Eigenes Archiv)
+ html: Du bekommst diese E-Mail, weil Du Werke in einem Fanwerk-Archiv hattest,
+ das von %{open_doors_name_link} in %{app_link} importiert wurde. Weil diese
+ E-Mail-Adresse mit einer im importierten Archiv registrierten Adresse verbunden
+ ist, wurden die zugehörigen Fanwerke (siehe unten) automatisch zu deinem
+ AO3-Konto hinzugefügt.
+ open_doors_name: Open Doors (Offene Türen)
+ text: 'Du bekommst diese E-Mail, weil Du Werke in einem Fanwerke-Archiv hattest,
+ die von Open Doors (Offene Türen) (%{open_doors_url}) ins Archive of Our
+ Own – AO3 (Ein Eigenes Archiv): %{app_url} importiert wurden. Weil diese
+ E-Mail Adresse mit einer im importierten Archiv registrierten Adresse verbunden
+ ist, wurden die zugehörigen Fanwerke (siehe unten) automatisch zu Deinem
+ AO3-Konto hinzugefügt.'
+ mistake:
+ contact_open_doors: kontaktiere Open Doors
+ html: Wenn wir einen Fehler gemacht haben und die Werke nicht Dir gehören,
+ lösch sie bitte nicht! Bitte %{contact_open_doors_link} und wir beheben
+ den Fehler.
+ text: Wenn wir einen Fehler gemacht haben und das nicht Deine Werke sind,
+ lösch sie bitte nicht! Bitte kontaktiere nur Open Doors (%{open_doors_url})
+ und wir werden den Fehler beheben.
+ more_info:
+ ao3_news: AO3-News
+ contact_support: kontaktiere den AO3 Support
+ faq_page: Seite für häufig gestellte Fragen (FAQ)
+ html: Du kannst Ankündigungen über die jüngsten Archivumzüge unter %{ao3_news_link}
+ lesen und zusätzliche Informationen auf der Offene Türen %{faq_page_link}
+ oder %{tutorial_page_link} finden. Für andere Fragen, die nicht in dieser
+ Mail, den FAQ oder den Anleitungen beantwortet werden, %{contact_support_link}.
+ text: Du kannst Ankündigungen über die jüngsten Archivumzüge unter (%{news_url})
+ lesen und zusätzliche Informationen auf der Open Doors Häufig gestellte
+ Fragen (FAQ) Seite (%{open_doors_faq_url}) oder Anleitungs Seite (%{open_doors_tutorial_url}).
+ Für andere Fragen, die nicht in dieser Mail, den FAQ oder den Anleitungen
+ beantwortet werden, kontaktiere bitte den Support unter %{support_url}.
+ tutorial_page: Tutorial-Seite
+ other_works:
+ contact_open_doors: kontaktiere Open Doors
+ html: Wenn Du andere Werke im importierten Archiv unter einer E-Mail-Adresse
+ hattest, die du nicht mehr aufrufen kannst, bitte %{contact_open_doors_link}
+ mit allen Informationen, die zur Überprüfung Deiner Identität beitragen
+ können.
+ text: Wenn Du andere Werke im importierten Archiv unter einer E-Mail Adresse
+ hattest, die Du nicht mehr aufrufen kannst, bitte kontaktiere Open Doors
+ mit allen Informationen, die zur Überprüfung Deiner Identität beitragen
+ können.
+ questions:
+ contact_support: kontaktiere den AO3 Support
+ html: Für andere Anfragen, bitte %{contact_support_link}.
+ text: Bei weiteren Anfragen, kontaktiere bitte den AO3 Support unter %{support_url}.
+ redirects:
+ html: Um Empfehlungslisten und Lesezeichen zu bewahren, werden die Webadressen
+ des importierten Archivs möglicherweise für eine begrenzte Zeit auf die
+ importierte Kopie dieser Werke umgeleitet (überprüfe die Ankündigung Deines
+ Archivs, um sicher zu sein). Wenn Du bereits eine Kopie dieser Werke hochgeladen
+ und %{negation} die Funktion "Importieren von URL" genutzt hast, werden
+ zwei Kopien desselben Werkes auf AO3 existieren.
+ subject: "[%{app_name}] Werke hochgeladen"
+ update_redirect:
+ contact_open_doors: kontaktiere Open Doors
+ html: Wenn Du möchtest, dass Open Doors die Weiterleitung auf Dein bereits
+ vorhandenes Werk aktualisiert, lösche bitte die importierte Kopie und %{contact_open_doors_link}
+ mit Deinem AO3 Kontonamen, Deinem Kontonamen im importieren Archiv sowie
+ dem Titel und der URL des Fanwerks, auf das die Weiterleitung verweisen
+ soll. (Wenn Du bei mehreren Werken die Weiterleitung ändern möchtest, kannst
+ Du diese in einer Mail auflisten.)
+ text: Wenn Du möchtest, dass Open Doors die Weiterleitung auf Dein bereits
+ vorhandenes Werk aktualisiert, lösche bitte die importierte Kopie und kontaktiere
+ Open Doors unter %{open_doors_url} mit Deinem AO3 Kontonamen, Deinem Kontonamen
+ im importieren Archiv sowie dem Titel und der URL des Fanwerks, auf das
+ die Weiterleitung verweisen soll. (Wenn Du bei mehreren Werken die Weiterleitung
+ ändern möchtest, kannst Du diese in einer E-Mail auflisten.)
+ works_by: Diese Werke wurden unter der E-Mail %{email} geschrieben
+ work_info:
+ html: "%{work_link} (%{fandom})"
+ text:
+ no_fandom: "- %{work_title} %{work_url}"
+ with_fandom: "- %{work_title} %{work_url} (%{fandom})"
+ collection_notification:
+ assignments_sent:
+ complete: Alle Aufgaben wurden versendet.
+ subject: Aufgaben versendet
+ html:
+ received_message: 'Du hast eine Nachricht zu Deiner Sammlung %{collection_link}
+ erhalten:'
+ text:
+ received_message: 'Du hast eine Nachricht zu Deiner Sammlung erhalten "%{collection_title}"
+ (%{collection_url}):'
+ creatorship_notification:
+ explanation: Wenn Du Mitschöpfer*in eines Werkes bist, kannst Du zu neuen Kapiteln
+ hinzugefügt werden, unabhängig von Deinen Einstellungen für Mitschöpfungen.
+ Wenn das Werk zu einer Serie hinzugefügt wird, wirst Du zu dieser ebenfalls
+ hinzugefügt.
+ html:
+ creation: "%{creation_link} von %{pseud_links}"
+ edit_chapter: das Kapitel bearbeiten
+ edit_series: die Serie bearbeiten
+ remove_chapter: Wenn Du versehentlich hinzugefügt wurdest oder nicht als Schöpfer*in
+ aufgeführt werden möchtest, kannst Du %{edit_chapter_link}, um Dich selbst
+ als Schöpfer*in zu entfernen.
+ remove_series: Wenn Du versehentlich hinzugefügt wurdest oder nicht als Schöpfer*in
+ aufgeführt werden möchtest, kannst Du %{edit_series_link}, um Dich selbst
+ als Schöpfer*in zu entfernen.
+ intro_chapter: 'Der/die Benutzer*in %{adding_user} hat Dein Pseudonym %{pseud}
+ als Mitschöpfer*in zu folgendem Kapitel hinzugefügt:'
+ intro_series: 'Der/die Benutzer*in %{adding_user} hat Dein Pseudonym %{pseud}
+ als Mitschöpfer*in zur folgenden Serie hinzugefügt:'
+ subject: "[%{app_name}] Mitschöpfer*in-Benachrichtigung"
+ text:
+ creation: "%{title} (%{url}) von %{pseuds}"
+ remove_chapter: 'Wenn Du versehentlich hinzugefügt wurdest oder nicht als
+ Schöpfer*in aufgeführt werden möchtest, kannst Du das Kapitel bearbeiten,
+ um Dich selbst als Schöpfer*in zu entfernen: %{url}'
+ remove_series: 'Wenn Du versehentlich hinzugefügt wurdest oder nicht als Schöpfer*in
+ aufgeführt werden möchtest, kannst Du die Serie bearbeiten, um Dich selbst
+ als Schöpfer*in zu entfernen: %{url}'
+ creatorship_notification_archivist:
+ explanation: In der Funktion als Archivist*in von Open Doors (Offene Türen)
+ darf er/sie Dich ohne Anfrage hinzufügen, selbst wenn Du die Option, als Mitschöpfer*in
+ geführt zu werden, deaktiviert hast.
+ html:
+ creation: "%{creation_link} von %{pseud_links}"
+ edit_chapter: das Kapitel bearbeiten
+ edit_series: die Serie bearbeiten
+ edit_work: das Werk bearbeiten
+ remove_chapter: Falls Du irrtümlicherweise hinzugefügt wurdest oder nicht
+ als Schöpfer*in angezeigt werden möchtest, kannst Du %{edit_chapter_link},
+ um Dich als Schöpfer*in zu entfernen.
+ remove_series: Falls Du irrtümlicherweise hinzugefügt wurdest oder nicht als
+ Schöpfer*in angezeigt werden möchtest, kannst Du %{edit_series_link}, um
+ Dich als Schöpfer*in zu entfernen.
+ remove_work: Falls Du irrtümlicherweise hinzugefügt wurdest oder nicht als
+ Schöpfer*in angezeigt werden möchtest, kannst Du %{edit_work_link}, um Dich
+ als Schöpfer*in zu entfernen.
+ intro_chapter: 'Der/Die Benutzer*in %{archivist} hat Dein Pseudonym %{pseud}
+ als Mitschöpfer*in zu folgendem Kapitel hinzugefügt:'
+ intro_series: 'Der/Die Benutzer*in %{archivist} hat Dein Pseudonym %{pseud}
+ als Mitschöpfer*in zur folgenden Serie hinzugefügt:'
+ intro_work: 'Der/Die Benutzer*in %{archivist} hat Dein Pseudonym %{pseud} als
+ Mitschöpfer*in zu folgendem Werk hinzugefügt:'
+ subject: "[%{app_name}] Archivist*innen-Benachrichtigung zur Mitschöpferschaft"
+ text:
+ creation: "%{title} (%{url}) von %{pseuds}"
+ remove_chapter: 'Falls Du irrtümlicherweise hinzugefügt wurdest oder nicht
+ als Schöpfer*in angezeigt werden möchtest, kannst Du das Kapitel bearbeiten,
+ um Dich als Schöpfer*in zu entfernen: %{url}'
+ remove_series: 'Falls Du irrtümlicherweise hinzugefügt wurdest oder nicht
+ als Schöpfer*in angezeigt werden möchtest, kannst Du die Serie bearbeiten,
+ um Dich als Schöpfer*in zu entfernen: %{url}'
+ remove_work: 'Falls Du irrtümlicherweise hinzugefügt wurdest oder nicht als
+ Schöpfer*in angezeigt werden möchtest, kannst Du das Werk bearbeiten, um
+ Dich als Schöpfer*in zu entfernen: %{url}'
+ creatorship_request:
+ html:
+ creation: "%{creation_link} von %{pseud_links}"
+ instructions: Du kannst diese Anfrage auf deiner "%{page_name}"-Seite annehmen
+ oder ablehnen.
+ page_name: Co-Creator Requests (Mitschöpfer*innen-Anfragen)
+ intro_chapter: 'dein Pseud %{pseud} wurde von %{inviting_user} eingeladen, als
+ Mitschöpfer*in des folgenden Kapitels aufgeführt zu werden:'
+ intro_series: 'dein Pseud %{pseud} wurde von %{inviting_user} eingeladen, als
+ Mitschöpfer*in der folgenden Serie aufgeführt zu werden:'
+ intro_work: 'dein Pseud %{pseud} wurde von %{inviting_user} eingeladen, als
+ Mitschöpfer*in des folgenden Werkes aufgeführt zu werden:'
+ subject: "[%{app_name}] Mitschöpfer*innen-Anfrage"
+ text:
+ creation: "%{title} (%{url}) von %{pseuds}"
+ instructions: 'Du kannst diese Anfrage auf deiner "Co-Creator Requests"- (Mitschöpfer*innen-Anfragen)
+ Seite annehmen oder ablehnen: %{url}'
+ delete_work_notification:
+ attachment: Anbei ist eine Kopie Deines Werkes, als Referenz für Dich.
+ deleted_other:
+ html: Dein Werk %{title} wurde gemäß Anfrage von %{pseud} gelöscht.
+ text: Dein Werk "%{title}" wurde gemäß Anfrage von %{pseud} gelöscht.
+ deleted_yourself:
+ html: Dein Werk %{title} wurde gemäß Deiner Anfrage gelöscht.
+ text: Dein Werk "%{title}" wurde gemäß Deiner Anfrage gelöscht.
+ questions:
+ html: Wenn Du Fragen hast, %{support}.
+ text: Falls Du Fragen hast, bitte %{support} (%{url}).
+ subject: "[%{app_name}] Dein Werk wurde gelöscht"
+ support: kontaktiere bitte den Support
+ invitation_to_claim:
+ access:
+ text: Abhängig vom ursprünglichen Archiv kann es sein, dass Deine importierten
+ Werke nur für registrierte Benutzer*innen zugänglich sind (damit man sie
+ über Google nicht finden kann). Falls dies der Fall ist, können Deine Werke
+ nur von eingeloggten Benutzer*innen geöffnet werden, außer Du entscheidest
+ Dich dafür, sie frei zugänglich zu machen. Wenn du Hilfe brauchst, Deine
+ Werke frei zugänglich zu machen, sie auszuwildern oder zu löschen, kontaktiere
+ bitte den AO3 Support.
+ claim_or_remove:
+ html: Beanspruche Deine Werke oder entferne sie hier.
+ text: 'Beanspruche Deine Werke oder entferne sie hier: %{claim_url}'
+ email_tips: Wenn Du uns kontaktierst, füge bitte E-Mail-Adressen von @transformativeworks.org
+ zur Liste sicherer Absender hinzu und schau auch in Deinem Spam-Ordner nach
+ unserer Antwort.
+ html:
+ ao3_news: AO3 Neuigkeiten
+ contact_open_doors: kontaktiere bitte Open Doors
+ contact_support: kontaktiere bitte AO3 Support
+ faq_page: Häufig gestellte Fragen (FAQ) Seite
+ tutorial_page: Anleitungsseite
+ introduction:
+ text: 'Du bekommst diese E-Mail, da kürzlich ein Archiv durch Open Doors (Offene
+ Türen: %{open_doors_link}) in das %{app_name} (%{app_short_name} - Ein Eigenes
+ Archiv: %{app_url}) importiert wurde, und wir denken, dass die folgenden
+ Fanwerke Dir gehören. Wir möchten Dir gerne die Möglichkeit geben, diese
+ Werke als Deine in Anspruch zu nehmen (oder zu löschen/auszuwildern), sofern
+ Du das möchtest. Und wenn Du noch keinen Account bei uns hast (möglicherweise
+ über eine andere E-Mail-Adresse), würden wir Dich gerne zu uns einladen!'
+ mistake:
+ text: Falls wir uns irren und diese Werke nicht von Dir sind, lösche sie bitte
+ nicht! Kontaktiere bitte Open Doors unter %{open_doors_link}, und wir kümmern
+ uns darum.
+ more_info:
+ text: Du findest Ankündigungen zu kürzlichen Archiv-Umzügen unter AO3-Neuigkeiten
+ (%{news_link}) und weitere Informationen auf Open Doors FAQ-Seite (%{open_doors_faq_link})
+ oder der Anleitungsseite (%{open_doors_tutorial_link}). Für Fragen, die
+ weder in den FAQ, den Anleitungen, noch in dieser E-Mail beantwortet werden,
+ kontaktiere den Support unter %{support_link}.
+ other_works:
+ text: Wenn Du noch weitere Werke auf dem importierten Archiv veröffentlicht
+ hast, die über eine E-Mail-Adresse laufen, auf die Du keinen Zugriff mehr
+ hast, kontaktiere bitte Open Doors mit sämtlichen Informationen, die dabei
+ helfen können, Deine Identität zu verifizieren.
+ questions:
+ text: Für alle weiteren Fragen kontaktiere bitte den AO3-Support unter %{support_link}.
+ redirects: Um Empfehlungslisten und Lesezeichen zu erhalten, kann es sein, dass
+ die Webadressen des importierten Archivs für eine begrenzte Zeit auf die importierte
+ Kopie dieser Werke umleiten (überprüfe die Ankündigung Deines Archivs, um
+ sicher zu gehen). Wenn Du diese Werke schon einmal hochgeladen hast und dabei
+ NICHT die Funktion "Import von URL" benutzt hast, gibt es nun zwei Versionen
+ des Werkes im Archiv.
+ subject: "[%{app_name}] Einladung zur Inanspruchnahme von Werken"
+ unwanted:
+ text: Wenn diese Werke Dir gehören, Du sie aber nicht behalten möchtest, kannst
+ Du sie auswildern (hierbei bleiben sie auf dem AO3 stehen, aber ohne Angabe
+ Deines Namens) oder löschen (dann werden sie komplett vom AO3 entfernt).
+ Du musst Deine Werke keinem Account hinzufügen, um sie auszuwildern oder
+ zu löschen – Du kannst beides direkt über den Inanspruchnahme-Link oben
+ erledigen. (Für Unterstützung dabei, kontaktiere bitte den Support unter
+ %{support_link}.)
+ update_redirect:
+ text: Wenn Du möchtest, dass Open Doors die Weiterleitung aktualisiert, um
+ auf Dein bereits existierendes Werk zu verweisen, lösche bitte die importierte
+ Kopie, und kontaktiere Open Doors unter %{open_doors_link} mit Deinem AO3-Accountnamen,
+ Deinem Accountnamen im importierten Archiv und dem Titel sowie der URL des
+ Fanwerks, auf das die Weiterleitung verweisen soll. (Falls Du die Weiterleitungen
+ für mehrere Werke ändern möchtest, kannst Du sie alle in einer E-Mail auflisten.)
+ uploaded_list: 'Die hochgeladenen Werke umfassen:'
+ invite_increase_notification:
+ html:
+ body:
+ one: wir wollten Dir nur Bescheid sagen, dass Du %{count} neue Einladung
+ hast, die man zur Erstellung neuer Benutzer*innenkonten im AO3 verwenden
+ kann. Du kannst Freund*innen auf %{invitation_page_link} einladen.
+ other: wir wollten Dir nur Bescheid sagen, dass Du %{count} neue Einladungen
+ hast, die man zur Erstellung neuer Benutzer*innenkonten im AO3 verwenden
+ kann. Du kannst Freund*innen auf %{invitation_page_link} einladen.
+ invitation_page_link_text: Deiner Invitations (Einladungs)-Seite
+ subject: "[%{app_name}] Neue Einladungen"
+ text:
+ body:
+ one: wir wollten Dir nur Bescheid sagen, dass Du %{count} neue Einladung
+ hast, die man zur Erstellung neuer Benutzer*innenkonten im AO3 verwenden
+ kann. Du kannst Freund*innen auf Deiner Invitations (Einladungs)-Seite
+ (%{invitation_page_url}) einladen.
+ other: wir wollten Dir nur Bescheid sagen, dass Du %{count} neue Einladungen
+ hast, die man zur Erstellung neuer Benutzer*innenkonten im AO3 verwenden
+ kann. Du kannst Freund*innen auf Deiner Invitations (Einladungs)-Seite
+ (%{invitation_page_url}) einladen.
+ invite_request_declined:
+ main:
+ one: leider können wir Deine Anfrage für eine neue Einladung zurzeit nicht
+ erfüllen.
+ other: leider können wir Deine Anfrage für %{count} neue Einladungen zurzeit
+ nicht erfüllen.
+ reason: 'Deine Anfrage war:'
+ subject: "[%{app_name}] Anfrage nach zusätzlichen Einladungscodes abgelehnt"
+ recipient_notification:
+ html:
+ collection: Ein Geschenk-Fanwerk wurde für Dich in der %{collection_link}
+ Sammlung im AO3 veröffentlicht!
+ no_collection: Ein Geschenk-Fanwerk wurde für Dich im AO3 veröffentlicht.
+ subject:
+ collection: "[%{app_name}][%{collection_title}] Ein Geschenk-Fanwerk für Dich
+ in %{collection_title}"
+ no_collection: "[%{app_name}] Ein Geschenk-Fanwerk für Dich"
+ text:
+ collection: Ein Geschenk-Fanwerk wurde für Dich in der "%{collection_title}"
+ Sammlung (%{collection_url}) im AO3 veröffentlicht!
+ signup_notification:
+ activate:
+ html: Bitte %{activate_account_link}.
+ text: 'Bitte folge diesem Link, um Dein Benutzerkonto zu aktivieren: %{activate_account_url}'
+ activate_your_account: folge diesem Link, um deinen Account zu aktivieren.
+ admin_posts: AO3-Newsletter
+ bye: Wir hoffen, dass es Dir im Archiv gefallen wird.
+ contact_support: kontaktiere bitte unser Support-Team
+ faq: Häufig gestellten Fragen (FAQ)
+ features:
+ html: Sobald Dein Benutzerkonto eingerichtet und aktiviert ist, kannst Du
+ Deine Fanwerke veröffentlichen, E-Mail-Benachrichtigungen einrichten, die
+ Dich informieren, wenn es Aktualisierungen von Deinen Lieblingsschöpfer*innen
+ oder -werken gibt, Deine Einstellungen anpassen, damit die Seite so aussieht
+ und funktioniert, wie es für Dich passt, über den Verlauf nachverfolgen,
+ welche Werke Du im Archiv angesehen hast und vieles mehr.
+ text: Sobald Dein Benutzerkonto eingerichtet und aktiviert ist, kannst Du
+ Deine Fanwerke veröffentlichen, E-Mail-Benachrichtigungen einrichten, die
+ Dich informieren, wenn es Aktualisierungen von Deinen Lieblingsschöpfer*innen
+ oder -werken gibt, Deine Einstellungen anpassen, damit die Seite so aussieht
+ und funktioniert, wie es für Dich passt, über den Verlauf nachverfolgen,
+ welche Werke Du im Archiv angesehen hast und vieles mehr.
+ information:
+ html: Es gibt eine Menge Informationen und Tipps zur Nutzung des Archivs in
+ unseren %{faq_link}. Du findest die aktuellsten Neuigkeiten zur Entwicklung
+ unserer Seite in unserem %{admin_posts_link}. Falls Du weitere Hilfe benötigen,
+ auf einen Bug stoßen oder Fragen oder Kommentare haben solltest, dann %{contact_support_link},
+ dessen Mitglieder sich immer freuen, wenn sie Dir helfen können.
+ text: 'Es gibt eine Menge Informationen und Tipps zur Nutzung des Archivs
+ in unseren Häufig gestellten Fragen (FAQ) unter %{faq_url}. Du findest die
+ aktuellsten Neuigkeiten zur Entwicklung unseren AO3-Newslettern unter %{admin_posts_url}.
+ Falls Du weitere Hilfe benötigen, auf einen Bug stoßen oder Fragen oder
+ Kommentare haben solltest, dann kontaktiere bitte unser Support-Team, dessen
+ Mitglieder sich immer freuen, wenn sie Dir helfen können: %{contact_support_url}.'
+ welcome: Willkommen im Archive of Our Own - AO3 (Ein Eigenes Archiv), %{login}!
diff --git a/config/locales/phrase-exports/el.yml b/config/locales/phrase-exports/el.yml
new file mode 100755
index 0000000..4f68c73
--- /dev/null
+++ b/config/locales/phrase-exports/el.yml
@@ -0,0 +1,663 @@
+---
+el:
+ activerecord:
+ attributes:
+ archive_warning:
+ name_with_colon:
+ one: 'Προειδοποίηση:'
+ other: 'Προειδοποιήσεις:'
+ category:
+ name_with_colon:
+ one: 'Κατηγορία:'
+ other: 'Κατηγορίες:'
+ character:
+ name_with_colon:
+ one: 'Χαρακτήρας:'
+ other: 'Χαρακτήρες:'
+ fandom:
+ name_with_colon:
+ one: 'Fandom:'
+ other: 'Fandoms:'
+ freeform:
+ name_with_colon:
+ one: 'Επιπρόσθετη Ετικέτα:'
+ other: 'Επιπρόσθετες Ετικέτες:'
+ rating:
+ name_with_colon: 'Καταλληλότητα:'
+ relationship:
+ name_with_colon:
+ one: 'Σχέση:'
+ other: 'Σχέσεις:'
+ work:
+ chapter_total_display: Κεφάλαια
+ summary: Περίληψη
+ models:
+ archive_warning:
+ one: Προειδοποίηση
+ other: Προειδοποιήσεις
+ category:
+ one: Κατηγορία
+ other: Κατηγορίες
+ chapter:
+ one: Κεφάλαιο
+ other: Κεφάλαια
+ character:
+ one: Χαρακτήρας
+ other: Χαρακτήρες
+ fandom:
+ one: Fandom
+ other: Fandoms
+ freeform:
+ one: Επιπρόσθετη Ετικέτα
+ other: Επιπρόσθετες Ετικέτες
+ rating:
+ one: Καταλληλότητα
+ other: Καταλληλότητες
+ relationship:
+ one: Σχέση
+ other: Σχέσεις
+ series:
+ one: Σειρά
+ other: Σειρές
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ one: ένας επισκέπτης
+ other: "%{count} επισκέπτες"
+ left_kudos:
+ html:
+ one: Ο/Η %{givers_list} άφησε μπράβο στο %{commentable_link}.
+ other: Οι %{givers_list} άφησαν μπράβο στο %{commentable_link}.
+ text:
+ one: Ο/Η %{givers_list} άφησε μπράβο στο %{commentable_title} (%{commentable_url}).
+ other: Οι %{givers_list} άφησαν μπράβο στο %{commentable_title} (%{commentable_url}).
+ single_guest:
+ giver: Ένας επισκέπτης
+ html: "%{giver} άφησε μπράβο στο %{commentable_link}."
+ text: Ένας επισκέπτης άφησε μπράβο στο %{commentable_title} (%{commentable_url}).
+ subject: "[%{app_name}] 'Εχεις μπράβο!"
+ mailer:
+ general:
+ closing:
+ formal: Με εκτίμηση,
+ informal: Χαιρετισμούς,
+ creation:
+ link_with_word_count: "%{creation_link} (%{word_count})"
+ title_with_chapter_number: Κεφάλαιο %{position} του έργου %{title}
+ title_with_word_count: '"%{creation_title}" (%{word_count})'
+ word_count:
+ one: "%{count} λέξη"
+ other: "%{count} λέξεις"
+ footer:
+ about:
+ html: Το ΑΟ3 είναι ένα αρχείο το οποίο διαχειρίζονται και υποστηρίζουν θαυμαστές και βασίζεται %{your_donations_link}.
+ text: 'Το AO3 είναι ένα αρχείο το οποίο διαχειρίζονται και υποστηρίζουν θαυμαστές και βασίζεται στις δωρεές σας: %{your_donations_url}.'
+ your_donations: στις δωρεές σας
+ sent_at: Στάλθηκε στις %{sent_at}.
+ why_policy_abuse:
+ contact_policy_abuse: επικοινωνήστε με τη Επιτροπή Πολιτικής & Κατάχρησης
+ html: Αν δεν καταλαβαίνετε τον λόγο που λάβατε αυτό το μήνυμα, παρακαλούμε %{contact_policy_abuse_link}.
+ text: 'Εάν δεν καταλαβαίνετε τον λόγο που λάβατε αυτό το μήνυμα, παρακαλούμε επικοινωνήστε με την Επιτροπή Πολιτικής & Κατάχρησης: %{contact_policy_abuse_url}.'
+ why_support:
+ contact_support: επικοινωνήστε με την Επιτροπή Υποστήριξης
+ html: Αν δεν καταλαβαίνετε τον λόγο που λάβατε αυτό το μήνυμα, παρακαλούμε %{contact_support_link}.
+ text: Εάν δεν καταλαβαίνετε τον λόγο που λάβατε αυτό το μήνυμα, παρακαλούμε επικοινωνήστε με την Επιτροπή Υποστήριξης %{contact_support_url}.
+ greeting:
+ formal:
+ addressed_html: Γειά, %{name},
+ unaddressed: Γειά,
+ informal:
+ addressed_html: Γεια %{name}!
+ unaddressed: Γεια!
+ introductory: Γεια σας από το Archive of Our Own – AO3 (Το Αρχείο Μας)!
+ metadata_label_indicator: ":"
+ signature:
+ abuse_team: Η ομάδα Πολιτικής & Κατάχρησης του AO3
+ app_short_name: AO3
+ open_doors: Η ομάδα των Open Doors (Ανοιχτές Πόρτες)
+ parent_org: Organization for Transformative Works – OTW (Οργανισμός Μετασχηματιστικών Έργων)
+ support: Η ομάδα Υποστήριξης του AO3
+ users:
+ mailer:
+ reset_password_instructions:
+ expiration: Αν δε χρησιμοποιήσετε αυτόν τον σύνδεσμο για να επαναφέρετε τον
+ κωδικό σας εντός μίας εβδομάδας, τότε θα λήξει και θα πρέπει να κάνετε καινούργια
+ αίτηση επαναφοράς.
+ intro: 'Kάποιος έκανε αίτηση για επαναφορά του κωδικού του λογαριασμού σας.
+ Μπορείτε να αλλάξετε τον κωδικό του λογαριασμού σας ακολουθώντας τον παρακάτω
+ σύνδεσμο και εισάγοντας τον νέο σας κωδικό:'
+ link_title: Αλλαγή του κωδικού μου.
+ subject: "[%{app_name}] Επαναφορά του κωδικού σας"
+ unrequested: Αν δεν κάνατε εσείς αυτήν την αίτηση επαναφοράς, μπορείτε να
+ αγνοήσετε αυτό το μήνυμα και ο παλιός κωδικός σας θα συνεχίσει να λειτουργεί.
+ user_mailer:
+ admin_deleted_work_notification:
+ bye: Συνημμένο είναι ένα αντίγραφο του έργου σας για αναφορά σας.
+ contact_abuse: επικοινωνήστε με την Επιτροπή Πολιτικής & Κατάχρησης
+ deleted:
+ html: Το έργο σας %{title} διαγράφηκε από το Αρχείο από έναν διαχειριστή της
+ ιστοσελίδας.
+ text: Το έργο σας "%{title}" διαγράφηκε από το AO3 από έναν διαχειριστή της
+ ιστοσελίδας.
+ html:
+ tos_violation: Αν είναι πιθανό το έργο σας να παραβίασε τους Όρους Παροχής
+ Υπηρεσιών του Αρχείου, παρακαλούμε %{contact_abuse_link}.
+ import_project:
+ html: Αν το έργο σας ήταν μέρος ενός προγράμματος εισαγωγής διαχειριζόμενο
+ από την ομάδα των Ανοιχτών Πορτών, παρακαλούμε %{opendoors_link} για περαιτέρω
+ ερωτήσεις.
+ text: Αν το έργο σας ήταν μέρος ενός προγράμματος εισαγωγής διαχειριζόμενο
+ από την ομάδα των Ανοιχτών Πορτών, παρακαλούμε επικοινωνήστε με τις Ανοιχτές
+ Πόρτες (%{opendoors_link}) για περαιτέρω ερωτήσεις.
+ opendoors: επικοινωνήστε με τις Ανοιχτές Πόρτες
+ subject: "[%{app_name}] Το έργο σας έχει διαγραφεί από έναν διαχειριστή"
+ text:
+ tos_violation: Αν είναι πιθανό το έργο σας να παραβίασε τους Όρους Παροχής
+ Υπηρεσιών του AO3, παρακαλούμε επικοινωνήστε με την Επιτροπή Πολιτικής &
+ Κατάχρησης (%{contact_abuse_url}).
+ admin_hidden_work_notification:
+ access: Όσο το έργο σας παραμένει κρυφό, θα εξακολουθείτε να έχετε πρόσβαση
+ σε αυτό μέσω του παραπάνω συνδέσμου, αλλά δε θα εμφανίζεται στη σελίδα με
+ τη λίστα των έργων σας και δε θα είναι διαθέσιμο στους άλλους χρήστες του
+ AO3.
+ check_email: Παρακαλούμε ελέγξτε την ηλεκτρονική σας αλληλογραφία (email), συμπεριλαμβανομένου
+ και του φακέλου ανεπιθύμητης αλληλογραφίας (spam), καθώς μπορεί η ομάδα Πολιτικής
+ & Κατάχρησης να έχει ήδη επικοινωνήσει μαζί σας εξηγώντας γιατί το έργο σας
+ κατέστηι κρυφό.
+ contact_abuse: επικοινωνήστε απευθείας με την Επιτροπή Πολιτικής & Κατάχρησης
+ html:
+ help: Αν δε γνωρίζετε με βεβαιότητα γιατί το έργο σας κατέστη κρυφό και δεν
+ έχετε λάβει επιπλέον μηνύματα σχετικά με αυτό το θέμα, παρακαλούμε %{contact_abuse_link}.
+ hidden: Το έργο σας με τίτλο %{title} έχει καταστεί κρυφό από την ομάδα Πολιτικής
+ & Κατάχρησης και δεν είναι πλέον δημόσια προσβάσιμο.
+ tos_violation: Αν το έργο σας κατέστη κρυφό επειδή παραβίαζε τους %{tos_link}
+ του AO3, θα πρέπει να προβείτε σε ενέργειες προκειμένου να διορθώσετε την
+ παραβίαση. Μη συμμόρφωση του έργου σας με τους Όρους Παροχής Υπηρεσιών μπορεί
+ να οδηγήσει στη διαγραφή του έργου σας από το AO3.
+ subject: "[%{app_name}] Το έργο σας έχει καταστεί κρυφό από την ομάδα Πολιτικής
+ & Κατάχρησης"
+ text:
+ help: 'Αν δε γνωρίζετε με βεβαιότητα γιατί το έργο σας κατέστη κρυφό και δεν
+ έχετε λάβει επιπλέον μηνύματα σχετικά με αυτό το θέμα, παρακαλούμε επικοινωνήστε
+ απευθείας με την Επιτροπή Πολιτικής & Κατάχρησης: %{contact_abuse_url}.'
+ hidden: Το έργο σας με τίτλο "%{title}" (%{work_url}) έχει καταστεί κρυφό
+ από την ομάδα Πολιτικής & Κατάχρησης και δεν είναι πλέον δημόσια προσβάσιμο.
+ tos_violation: Αν το έργο σας κατέστη κρυφό επειδή παραβίαζε τους Όρους Παροχής
+ Υπηρεσιών του AO3 (%{tos_url}), θα πρέπει να προβείτε σε ενέργειες προκειμένου
+ να διορθώσετε την παραβίαση. Μη συμμόρφωση του έργου σας με τους Όρους Παροχής
+ Υπηρεσιών μπορεί να οδηγήσει στη διαγραφή του έργου σας από το AO3.
+ tos: Όροι Παροχής Υπηρεσιών
+ anonymous_or_unrevealed_notification:
+ anonymous_info: Τα ανώνυμα έργα συμπεριλαμβάνονται σε λίστες ετικετών, αλλά
+ όχι στη σελίδα των έργων σας. Στο συγκεκριμένο έργο, το όνομα χρήστη σας θα
+ αντικατασταθεί με το "Anonymous" (Ανώνυμος).
+ anonymous_unrevealed_info: Οι διαχειριστές της συλλογής μπορεί αργότερα να καταστήσουν
+ το έργο σας φανερό, αλλά να το αφήσουν ανώνυμο. Τα άτομα με συνδρομές στον
+ λογαριασμό σας θα ειδοποιηθούν για αυτήν την αλλαγή. Μόλις γίνει φανερό, το
+ έργο σας θα συμπεριλαμβάνεται στις λίστες ετικετών, αλλά όχι στη σελίδα των
+ έργων σας. Στο έργο σας, το όνομα χρήστη σας θα αντικατασταθεί με το "Anonymous"
+ (Ανώνυμος).
+ changed_status:
+ anonymous:
+ html: Οι διαχειριστές της συλλογής %{collection_link} έχουν αλλάξει την
+ κατάσταση του έργου σας %{work_link} σε "ανώνυμο".
+ text: Οι διαχειριστές της συλλογής "%{collection_title}" (%{collection_url})
+ άλλαξαν την κατάσταση του έργου σας "%{work_title}" (%{work_url}) σε ανώνυμο.
+ anonymous_unrevealed:
+ html: Οι διαχειριστές της συλλογής %{collection_link} άλλαξαν την κατάσταση
+ του έργου σας %{work_link} σε "ανώνυμο και κρυμμένο".
+ text: Οι διαχειριστές της συλλογής "%{collection_title}" (%{collection_url})
+ άλλαξαν την κατάσταση του έργου σας "%{work_title}" (%{work_url}) σε ανώνυμο
+ και κρυφό.
+ unrevealed:
+ html: Οι διαχειριστές της συλλογής %{collection_link} άλλαξαν την κατάσταση
+ του έργου σας %{work_link} σε "κρυμμένο".
+ text: Οι διαχειριστές της συλλογής "%{collection_title}" (%{collection_url})
+ άλλαξαν την κατάσταση του έργου σας "%{work_title}" (%{work_url}) σε κρυφό.
+ collection_items_link_text: σελίδα Approved Collection Items (Εγκεκριμένα Αντικείμενα
+ Συλλογών)
+ do_not_want:
+ anonymous:
+ html: Αν δε θέλετε το έργο σας να είναι ανώνυμο, παρακαλούμε επισκεφθείτε
+ τη %{collection_items_link} για να το αφαιρέσετε από αυτήν τη συλλογή.
+ text: 'Αν δε θέλετε το έργο σας να είναι ανώνυμο, παρακαλούμε επισκεφθείτε
+ τη σελίδα Approved Collection Items (Εγκεκριμένα Αντικείμενα Συλλογών)
+ για να το αφαιρέσετε από αυτήν τη συλλογή: %{collection_items_url}'
+ anonymous_unrevealed:
+ html: Αν δε θέλετε το έργο σας να είναι ανώνυμο και κρυμμένο, παρακαλούμε
+ επισκεφθείτε τη %{collection_items_link} για να το αφαιρέσετε από αυτήν
+ τη συλλογή.
+ text: 'Αν δε θέλετε το έργο σας να είναι ανώνυμο και κρυμμένο, παρακαλούμε
+ επισκεφθείτε τη σελίδα Approved Collection Items (Εγκεκριμένα Αντικείμενα
+ Συλλογών) για να το αφαιρέσετε από αυτήν τη συλλογή: %{collection_items_url}'
+ unrevealed:
+ html: Αν δε θέλετε το έργο σας να είναι κρυμμένο, παρακαλούμε επισκεφθείτε
+ τη %{collection_items_link} για να το αφαιρέσετε από αυτήν τη συλλογή.
+ text: 'Αν δε θέλετε το έργο σας να είναι κρυμμένο, παρακαλούμε επισκεφθείτε
+ τη σελίδα Approved Collection Items (Εγκεκριμένα Αντικείμενα Συλλογών)
+ για να το αφαιρέσετε από αυτήν τη συλλογή: %{collection_items_url}'
+ faq_link_text: Συχνές Ερωτήσεις για τις Συλλογές
+ more_info:
+ html: Για περισσότερες πληροφορίες, επισκεφθείτε τις %{faq_link}.
+ text: 'Για περισσότερες πληροφορίες, επισκεφθείτε τις Συχνές Ερωτήσεις για
+ τις Συλλογές: %{faq_url}'
+ subject:
+ anonymous: "[%{app_name}] Το έργο σας κατέστη ανώνυμο."
+ anonymous_unrevealed: "[%{app_name}] Το έργο σας κατέστη ανώνυμο και κρυμμένο"
+ unrevealed: "[%{app_name}] Το έργο σας κατέστη κρυμμένο"
+ unrevealed_info: Τα κρυμμένα έργα δε συμπεριλαμβάνονται στις λίστες ετικετών
+ ή στη σελίδα των έργων σας. Οποιοσδήποτε ακολουθήσει έναν σύνδεσμο για το
+ έργο, θα λάβει ειδοποίηση πως είναι επί του παρόντος κρυμμένο και δεν είναι
+ δυνατή η πρόσβασή του.
+ archivist_added_to_collection_notification:
+ approved_collection_items_page: Σελίδα Approved Collection Items (Έργα Εγκεκριμένα
+ για Συλλογές)
+ archivist_notice: Καθώς οι προγραμματιστές συλλογών δρουν ως επίσημοι αρχειοφύλακες
+ του Open Doors (Ανοιχτές Πόρτες), τους επιτρέπεται να προσθέσουν το έργο σας
+ σε αυτή τη συλλογή, ακόμα κι αν έχετε τις προσκλήσεις σε συλλογές απενεργοποιημένες.
+ Οι αρχειοφύλακες θα προσθέσουν ένα έργο σε μια συλλογή μόνο [το έργο] φιλοξενούνταν
+ σε ένα εισαγόμενο αρχείο.
+ removal_instructions:
+ html: Αν θέλετε να αφαιρέσετε το έργο σας από αυτή τη συλλογή, παρακαλούμε
+ επισκεφθείτε τον σύνδεσμο %{approved_items_link}.
+ text: Αν θέλετε να αφαιρέσετε το έργο σας από αυτή τη συλλογή, παρακαλούμε
+ επισκεφθείτε τη σελίδα σας Approved Collection Items (Έργα Εγκεκριμένα για
+ Συλλογές) %{approved_items_url}
+ subject: "[%{app_name}][%{collection_title}] Ένας αρχειοφύλακας του Open Doors
+ (Ανοιχτές Πόρτες) πρόσθεσε το έργο σας σε μια συλλογή"
+ work_added:
+ html: Οι προγραμματιστές της συλλογής %{collection_link} πρόσθεσαν το έργο
+ σας %{work_link} στη συλλογή τους!
+ text: Οι προγραμματιστές της συλλογής "%{collection_title}" (%{collection_url})
+ πρόσθεσαν το έργο σας "%{work_title}" (%{work_url}) στη συλλογή τους!
+ challenge_assignment_notification:
+ any: Οτιδήποτε
+ assignment:
+ html: Σας έχει ανατεθεί το ακόλουθο αίτημα στην πρόκληση %{link} στο ΑΟ3!
+ description: 'Περιγραφή:'
+ due: 'Η ανάθεση πρέπει να υποβληθεί ως τις:'
+ html:
+ footer: Λαμβάνετε αυτό το μήνυμα γιατί δηλώσατε συμμετοχή στην πρόκληση %{title}.
+ Για περισσότερες πληροφορίες σχετικά με αυτήν την πρόκληση και τα στοιχεία
+ επικοινωνίας των διαχειριστών της, παρακαλούμε μεταβείτε στο %{footer_link}.
+ footer_link: σελίδα προφίλ της πρόκλησης
+ look_up: Μπορείτε να ανατρέξετε σε αυτήν την ανάθεση στη %{link}.
+ look_up_link: σελίδα με τις Assignments (Αναθέσεις) σας
+ optional_tags: 'Προαιρετικές Ετικέτες:'
+ prompts: 'Προτροπές:'
+ prompt_url: 'URL προτροπής:'
+ recipient: 'Παραλήπτης:'
+ recipient_missing: 'Κενό: επικοινωνήστε με έναν διαχειριστή για βοήθεια!'
+ subject: "[%{app_name}][%{collection_title}] Η Ανάθεσή σας!"
+ text:
+ assignment: Σας έχει ανατεθεί το ακόλουθο αίτημα στην πρόκληση "%{collection_title}"
+ (%{collection_url}) στο AO3!
+ footer: Λαμβάνετε αυτό το μήνυμα γιατί δηλώσατε συμμετοχή στην πρόκληση %{title}
+ (%{url}). Για περισσότερες πληροφορίες σχετικά με αυτήν την πρόκληση και
+ τα στοιχεία επικοινωνίας των διαχειριστών της, παρακαλούμε μεταβείτε στο
+ %{profile_url}.
+ look_up: Μπορείτε να βρείτε πληροφορίες για αυτήν την ανάθεση στη σελίδα των
+ Assignments (Αναθέσεών) σας στο %{link}.
+ change_email:
+ changed:
+ html: "%{login}, η ηλεκτρονική διεύθυνση (email) που είχατε συσχετίσει με
+ τον λογαριασμό σας έχει αλλάξει σε %{email}"
+ text: "%{login}, η ηλεκτρονική διεύθυνση (email) που είχατε συσχετίσει με
+ τον λογαριασμό σας έχει αλλάξει σε %{email}"
+ subject: "[%{app_name}] Αλλαγή Ηλεκτρονικής Διεύθυνσης (Email)"
+ claim_notification:
+ access:
+ contact_support: Επικοινωνία με την Επιτροπή Υποστήριξης του ΑΟ3
+ html: Ανάλογα με το αρχείο, τα έργα σας ενδέχεται να έχουν εισαχθεί ως περιορισμένα
+ μόνο σε εγγεγραμμένους χρήστες (ώστε να τα αποκρύψουν από αναζητήσεις στο
+ Google). Αν ισχύει αυτό, στα έργα θα έχουν πρόσβαση μόνο συνδεδεμένοι χρήστες
+ εκτός αν επιλέξετε να τα κάνετε ορατά σε όλους. Για βοήθεια με το ξεκλείδωμα,
+ την αποποίηση, ή τη διαγραφή των έργων σας, παρακαλούμε επικοινωνήστε με
+ το %{contact_support_link}.
+ text: Ανάλογα με το αρχείο, τα έργα σας ενδέχεται να έχουν εισαχθεί ως περιορισμένα
+ μόνο σε εγγεγραμμένους χρήστες (ώστε να τα αποκρύψουν από αναζητήσεις στο
+ Google). Αν ισχύει αυτό, στα έργα θα έχουν πρόσβαση μόνο συνδεδεμένοι χρήστες
+ εκτός αν επιλέξετε να τα κάνετε ορατά σε όλους. Για βοήθεια με το ξεκλείδωμα,
+ την αποποίηση, ή τη διαγραφή των έργων σας, παρακαλούμε επικοινωνήστε με
+ την Επιτροπή Υποστήριξης στο %{support_url}.
+ email_tips: Αν επικοινωνείτε μαζί μας, παρακαλούμε να προσθέσετε τις ηλεκτρονικές
+ διευθύνσεις από το @transformativeworks.org στη λίστα των ασφαλών επαφών σας
+ και ελέγξτε τους φακέλους ανεπιθύμητων μηνυμάτων για την απάντησή μας.
+ introduction:
+ ao3_name: Archive of Our Own – AO3 (Το Αρχείο Μας)
+ html: Λαμβάνετε αυτό το e-mail επειδή είχατε έργα σε ένα αρχείο έργων θαυμαστών
+ το οποίο εισήγαγαν οι %{open_doors_name_link} στο %{app_link}. Επειδή η
+ ηλεκτρονική αυτή διεύθυνση συνδέεται με μιά διεύθυνση καταγεγραμμένη στο
+ εισαγόμενο αρχείο, τα συσχετισμένα έργα θαυμαστών (αναγράφονται παρακάτω)
+ έχουν προστεθεί αυτόματα στον λογαριασμό σας στο AO3.
+ open_doors_name: Open Doors (Ανοιχτές Πόρτες)
+ text: 'Λαμβάνετε αυτό το e-mail επειδή είχατε έργα σε ένα αρχείο έργων θαυμαστών
+ το οποίο εισήγαγαν οι Open Doors (Ανοιχτές Πόρτες) (%{open_doors_url}) στο
+ Archive of Our Own – AO3 (Το Αρχείο Μας): %{app_url}. Επειδή η ηλεκτρονική
+ αυτή διεύθυνση συνδέεται με μιά διεύθυνση καταγεγραμμένη στο εισαγόμενο
+ αρχείο, τα συσχετισμένα έργα θαυμαστών (αναγράφονται παρακάτω) έχουν προστεθεί
+ αυτόματα στον λογαριασμό σας στο AO3.'
+ mistake:
+ contact_open_doors: Επικοινωνία με το Open Doors
+ html: Αν αυτό είναι σφάλμα και αυτά δεν είναι τα έργα σας, παρακαλούμε μην
+ τα διαγράψετε! Παρακαλούμε απλώς επικοινωνήστε με το %{contact_open_doors_link}
+ και θα το διευθετήσουμε.
+ text: Αν αυτό είναι σφάλμα και αυτά δεν είναι τα έργα σας, παρακαλούμε μην
+ τα διαγράψετε! Παρακαλούμε απλώς επικοινωνήστε με το %{open_doors_url} και
+ θα το διευθετήσουμε.
+ more_info:
+ ao3_news: Νέα του ΑΟ3
+ contact_support: Επικοινωνία με την Επιτροπή Υποστήριξης του ΑΟ3
+ faq_page: Σελίδα Συχνών Ερωτήσεων
+ html: Μπορείτε να διαβάσετε ανακοινώσεις για πρόσφατες μετακινήσεις αρχείων
+ στο %{ao3_news_link}, και να βρείτε πρόσθετες πληροφορίες στις σελίδες των
+ Ανοιχτών Πορτών %{faq_page_link} ή %{tutorial_page_link}. Για τυχόν ερωτήσεις
+ που δεν απαντήθηκαν στις Συχνές Ερωτήσεις, τους οδηγούς χρήσης, ή σε αυτό
+ το e-mail, παρακαλούμε %{contact_support_link}.
+ text: Μπορείτε να διαβάσετε ανακοινώσεις για πρόσφατες μετακινήσεις αρχείων
+ στα Νέα του ΑΟ3 (%{news_url}), και να βρείτε πρόσθετες πληροφορίες στη σελίδα
+ Συχνών Ερωτήσεων των Ανοιχτών Πορτών (%{open_doors_faq_url}) ή τη σελίδα
+ οδηγών χρήσης (%{open_doors_tutorial_url}). Για τυχόν ερωτήσεις που δεν
+ απαντήθηκαν στις Συχνές Ερωτήσεις, τους οδηγούς χρήσης, ή σε αυτό το e-mail,
+ παρακαλούμε επικοινωνήστε με την Επιτροπή Υποστήριξης στο %{support_url}.
+ tutorial_page: Σελίδα οδηγών χρήσης
+ other_works:
+ contact_open_doors: Επικοινωνία με το Open Doors
+ html: Αν είχατε άλλα έργα στο εισαγόμενο αρχείο κάτω από μία διεύθυνση ηλεκτρονικού
+ ταχυδρομείου στην οποία δεν έχετε πλέον πρόσβαση, παρακαλούμε %{contact_open_doors_link}
+ με οποιαδήποτε πληροφορία μπορεί να βοηθήσει στο να πιστοποιηθεί η ταυτότητά
+ σας.
+ text: Αν είχατε άλλα έργα στο εισαγόμενο αρχείο κάτω από μία διεύθυνση ηλεκτρονικού
+ ταχυδρομείου στην οποία δεν έχετε πλέον πρόσβαση, παρακαλούμε επικοινωνήστε
+ με τις Ανοιχτές Πόρτες με οποιαδήποτε πληροφορία μπορεί να βοηθήσει στο
+ να πιστοποιηθεί η ταυτότητά σας.
+ questions:
+ contact_support: Επικοινωνία με την Επιτροπή Υποστήριξης του ΑΟ3
+ html: Για περαιτέρω ερωτήσεις, παρακαλούμε %{contact_support_link}.
+ text: Για περαιτέρω ερωτήσεις, παρακαλούμε επικοινωνήστε με την Επιτροπή Υποστήριξης
+ του ΑΟ3 %{support_url}.
+ redirects:
+ html: Για να διατηρήσετε λίστες προτεινόμενων έργων θαυμαστών και τους σελιδοδείκτες,
+ οι ιστότοποι των εισαγόμενων αρχείων δύναται να ανακατευθύνουν στο εισαγόμενο
+ αντίγραφο για περιορισμένο χρονικό διάστημα (δείτε την ανάρτηση δημοσίευσης
+ του αρχείου σας για να είστε σίγουροι). Αν έχετε ήδη ανεβάσει ένα αντίγραφο
+ αυτών των έργων και %{negation} χρησιμοποιήσατε τη λειτουργία εισαγωγής
+ από σύνδεσμο (URL), θα υπάρχουν δύο αντίγραφα του ίδιου έργου στο AO3.
+ subject: "[%{app_name}] Μεταφόρτωση Έργου"
+ update_redirect:
+ contact_open_doors: Επικοινωνία με το Open Doors
+ html: Αν θέλετε οι Ανοιχτές Πόρτες να ενημερώσουν την ανακατεύθυνση ώστε να
+ οδηγεί στο προϋπάρχον έργο σας, παρακαλούμε να διαγράψετε το εισαγόμενο
+ αντίγραφο, %{contact_open_doors_link} με το όνομα του λογαριασμού σας στο
+ AO3, το όνομα του λογαριασμού σας στο εισαγόμενο αρχείο, και τον τίτλο και
+ σύνδεσμο (URL) του έργου θαυμαστή στο οποίο θέλετε να οδηγεί η ανακατεύθυνση.
+ (Αν έχετε πολλαπλά έργα για τα οποία θέλετε να αλλάξετε την ανακατεύθυνση,
+ μπορείτε να τα αναφέρετε όλα σε ένα email.)
+ text: Αν θέλετε οι Ανοιχτές Πόρτες να ενημερώσουν την ανακατεύθυνση ώστε να
+ οδηγεί στο προϋπάρχον έργο σας, παρακαλούμε να διαγράψετε το εισαγόμενο
+ αντίγραφο, και να επικοινωνήσετε με τις Ανοιχτές Πόρτες στο %{open_doors_url}
+ με το όνομα του λογαριασμού σας στο AO3, το όνομα του λογαριασμού σας στο
+ εισαγόμενο αρχείο, και τον τίτλο και σύνδεσμο (URL) του έργου θαυμαστή στο
+ οποίο θέλετε να οδηγεί η ανακατεύθυνση. (Αν έχετε πολλαπλά έργα για τα οποία
+ θέλετε να αλλάξετε την ανακατεύθυνση, μπορείτε να τα αναφέρετε όλα σε ένα
+ email.)
+ works_by: Τα έργα αυτά έχουν δημιουργηθεί υπό την ηλεκτρονική διεύθυνση %{email}
+ work_info:
+ html: "%{work_link} (%{fandom})"
+ text:
+ no_fandom: "- %{work_title} %{work_url}"
+ with_fandom: "- %{work_title} %{work_url} (%{fandom})"
+ collection_notification:
+ assignments_sent:
+ complete: Όλες οι αναθέσεις έχουν αποσταλεί.
+ subject: Απεσταλμένες Αναθέσεις
+ html:
+ received_message: 'Έχετε λάβει ένα μήνυμα σχετικά με τη συλλογή σας %{collection_link}:'
+ text:
+ received_message: 'Έχετε λάβει ένα μήνυμα σχετικά με τη συλλογή σας "%{collection_title}"
+ (%{collection_url}):'
+ creatorship_notification:
+ explanation: Όταν είστε συνδημιουργός σε ένα έργο, μπορείτε να προστεθείτε σε
+ νέα κεφάλαια ανεξάρτητα από τις ρυθμίσεις συνδημιουργού που έχετε θέσει. Το
+ ψευδώνυμό σας θα προστεθεί και σε όποια σειρά έχει προστεθεί το εν λόγω έργο.
+ html:
+ creation: "%{creation_link} από τους %{pseud_links}"
+ edit_chapter: επεξεργαστείτε το κεφάλαιο
+ edit_series: επεξεργαστείτε τη σειρά
+ remove_chapter: Αν έχετε προστεθεί ως συνδημιουργός κατά λάθος ή αν δε θέλετε
+ να φαίνεστε ως συνδημιουργός, μπορείτε να %{edit_chapter_link} για να αφαιρέσετε
+ το όνομά σας.
+ remove_series: Αν έχετε προστεθεί ως συνδημιουργός κατά λάθος ή αν δε θέλετε
+ να φαίνεστε ως συνδημιουργός, μπορείτε να %{edit_series_link}για να αφαιρέσετε
+ το όνομά σας.
+ intro_chapter: 'Ο χρήστης/Η χρήστρια %{adding_user} πρόσθεσε το ψευδώνυμό σας
+ %{pseud} ως συνδημιουργό στο ακόλουθο κεφάλαιο:'
+ intro_series: 'Ο χρήστης/Η χρήστρια %{adding_user} ανέφερε το ψευδώνυμό σας
+ %{pseud} ως συνδημιουργό στην ακόλουθη σειρά έργων:'
+ subject: "[%{app_name}] Ειδοποίηση Συνδημιουργού"
+ text:
+ creation: "%{title} (%{url}) από τους %{pseuds}"
+ remove_chapter: 'Αν έχετε προστεθεί ως συνδημιουργός κατά λάθος ή αν δε θέλετε
+ να φαίνεστε ως συνδημιουργός, μπορείτε να επεξεργαστείτε το κεφάλαιο για
+ να αφαιρέσετε το όνομά σας: %{url}'
+ remove_series: Αν έχετε προστεθεί ως συνδημιουργός κατά λάθος ή αν δε θέλετε
+ να φαίνεστε ως συνδημιουργός, μπορείτε να αφαιρέσετε το όνομά σας:%{url}
+ creatorship_notification_archivist:
+ explanation: Καθώς δρα με την επίσημη ιδιότητα ενός αρχειονόμου του Open Doors
+ (Ανοιχτές Πόρτες), μπορεί να σας προσθέσει χωρίς αίτημα, ακόμη και αν έχετε
+ απενεργοποιήσει την επιλογή για τη συνδημιουργία.
+ html:
+ creation: "%{creation_link} από τους %{pseud_links}"
+ edit_chapter: επεξεργαστείτε το κεφάλαιο
+ edit_series: επεξεργαστείτε τη σειρά
+ edit_work: επεξεργαστείτε το έργο
+ remove_chapter: Εάν έχετε προστεθεί κατά λάθος ή δε θέλετε να εμφανίζεστε
+ ως δημιουργός, μπορείτε να %{edit_chapter_link} και να αφαιρέσετε τον εαυτό
+ σας από δημιουργό.
+ remove_series: Εάν έχετε προστεθεί κατά λάθος ή δε θέλετε να εμφανίζεστε ως
+ δημιουργός, μπορείτε να %{edit_series_link} και να αφαιρέσετε τον εαυτό
+ σας από δημιουργό.
+ remove_work: Εάν έχετε προστεθεί κατά λάθος ή δε θέλετε να εμφανίζεστε ως
+ δημιουργός, μπορείτε να %{edit_work_link} και να αφαιρέσετε τον εαυτό σας
+ από δημιουργό.
+ intro_chapter: 'Ο/Η χρήστης %{archivist} έχει προσθέσει το ψευδώνυμό σας %{pseud}
+ ως συνδημιουργό στο παρακάτω κεφάλαιο:'
+ intro_series: 'Ο/Η χρήστης %{archivist} έχει προσθέσει το ψευδώνυμό σας %{pseud}
+ ως συνδημιουργό στην παρακάτω σειρά:'
+ intro_work: 'Ο/H χρήστης %{archivist} έχει προσθέσει το ψευδώνυμό σας %{pseud}
+ ως συνδημιουργό στο παρακάτω έργο:'
+ subject: "[%{app_name}] Ειδοποίηση αρχειονόμου για συνδημιουργό"
+ text:
+ creation: "%{title} (%{url}) από τους %{pseuds}"
+ remove_chapter: 'Εάν έχετε προστεθεί κατά λάθος ή δε θέλετε να εμφανίζεστε
+ ως δημιουργός, μπορείτε να επεξεργαστείτε το κεφάλαιο για να αφαιρέσετε
+ τον εαυτό σας από δημιουργό: %{url}'
+ remove_series: 'Εάν έχετε προστεθεί κατά λάθος ή δε θέλετε να εμφανίζεστε
+ ως δημιουργός, μπορείτε να επεξεργαστείτε τη σειρά για να αφαιρέσετε τον
+ εαυτό σας από δημιουργό: %{url}'
+ remove_work: 'Εάν έχετε προστεθεί κατά λάθος ή δε θέλετε να εμφανίζεστε ως
+ δημιουργός, μπορείτε να επεξεργαστείτε το έργο για να αφαιρέσετε τον εαυτό
+ σας από δημιουργό: %{url}'
+ creatorship_request:
+ html:
+ creation: "%{creation_link} της/ου %{pseud_links}"
+ instructions: Μπορείτε να αποδεχθείτε ή να απορρίψετε αυτό το αίτημα στη σελίδα
+ %{page_name}.
+ page_name: Co-Creator Requests (Αιτήματα Συνδημιουργού)
+ intro_chapter: 'Ο χρήστης %{inviting_user} προσκάλεσε το ψευδώνυμό σας %{pseud}
+ να συμπεριληφθεί ως συνδημιουργός στο ακόλουθο κεφάλαιο:'
+ intro_series: 'Ο χρήστης %{inviting_user} προσκάλεσε το ψευδώνυμό σας %{pseud}
+ να συμπεριληφθεί ως συνδημιουργός στην ακόλουθη σειρά:'
+ intro_work: 'Ο χρήστης %{inviting_user} προσκάλεσε το ψευδώνυμό σας %{pseud}
+ να συμπεριληφθεί ως συνδημιουργός στο ακόλουθο έργο:'
+ subject: "[%{app_name}] Αίτημα συνδημιουργού"
+ text:
+ creation: "%{title} (%{url}) της/ου %{pseuds}"
+ instructions: 'Μπορείτε να αποδεχθείτε ή να απορρίψετε αυτό το αίτημα στη
+ σελίδα Co-Creator Requests (Αιτήματα Συνδημιουργού): %{url}'
+ delete_work_notification:
+ attachment: Έχουμε επισυνάψει ένα αντίγραφο του έργου σας προς ενημέρωσή σας.
+ deleted_other:
+ html: Το έργο σας, με τίτλο %{title}, διαγράφηκε κατόπιν αιτήματος του χρήστη
+ %{pseud}.
+ text: Το έργο σας, με τίτλο "%{title}", διαγράφηκε κατόπιν αιτήματος του χρήστη
+ %{pseud}.
+ deleted_yourself:
+ html: Το έργο σας, με τίτλο %{title}, διαγράφηκε κατόπιν δικού σας αιτήματος.
+ text: Το έργο σας, με τίτλο "%{title}", διαγράφηκε κατόπιν δικού σας αιτήματος.
+ questions:
+ html: Για τυχόν απορίες, παρακαλούμε %{support}.
+ text: Για τυχόν απορίες, παρακαλούμε %{support} (%{url}).
+ subject: "[%{app_name}] Το έργο σας έχει διαγραφεί"
+ support: επικοινωνήστε με την Επιτροπή Υποστήριξης
+ invitation_to_claim:
+ access:
+ text: Ανάλογα με το αρχείο, τα έργα σας μπορεί να έχουν εισαχθεί ώστε μόνο
+ οι εγγεγραμμένοι χρήστες να έχουν πρόσβαση σε αυτά (για να μην εμφανίζονται
+ σε αναζητήσεις στο Google). Σε αυτήν την περίπτωση, μόνο οι συνδεδεμένοι
+ χρήστες θα μπορούν να αποκτήσουν πρόσβαση στα έργα, εκτός και αν επιλέξετε
+ να τα καταστήσετε πλήρως ορατά. Για βοήθεια στο ξεκλείδωμα, την αποποίηση
+ ή τη διαγραφή των έργων σας, παρακαλούμε επικοινωνήστε με την Επιτροπή Υποστήριξης
+ του AO3.
+ claim_or_remove:
+ html: Διεκδικήστε ή αφαιρέστε τα έργα σας εδώ.
+ text: 'Διεκδικήστε ή αφαιρέστε τα έργα σας εδώ: %{claim_url}'
+ email_tips: Αν βρίσκεστε σε επικοινωνία μαζί μας, παρακαλούμε προσθέστε τις
+ ηλεκτρονικές διευθύνσεις από το @transformativeworks.org στη λίστα των ασφαλών
+ επαφών και ελέγξτε τον φάκελο ανεπιθύμητης αλληλογραφίας για την απάντησή
+ μας.
+ html:
+ ao3_news: Νέα του AO3
+ contact_open_doors: επικοινωνήστε με τις Ανοιχτές Πόρτες
+ contact_support: επικοινωνήστε με την Επιτροπή Υποστήριξης του AO3
+ faq_page: σελίδα Συχνών Ερωτήσεων
+ tutorial_page: σελίδα οδηγών χρήσης
+ introduction:
+ text: Λαμβάνετε αυτό το μήνυμα γιατί ένα αρχείο εισήχθη πρόσφατα από τις Ανοιχτές
+ Πόρτες (%{open_doors_link}) στο %{app_name} (%{app_short_name} - %{app_url})
+ και πιστεύουμε πως τα ακόλουθα έργα θαυμαστών σάς ανήκουν. Θα θέλαμε να
+ σας δώσουμε την ευκαιρία να διεκδικήσετε (ή να διαγράψετε/αποποιηθείτε)
+ αυτά τα έργα εφόσον το επιθυμείτε. Και εάν δεν έχετε ήδη λογαριασμό με διαφορετική
+ ηλεκτρονική διεύθυνση (email), θα θέλαμε να σας προσκαλέσουμε να δημιουργήσετε
+ έναν!
+ mistake:
+ text: Αν πρόκειται για λάθος και αυτά δεν είναι τα έργα σας, σας παρακαλούμε
+ μην τα διαγράψετε! Παρακαλούμε απλά επικοινωνήστε με τις Ανοιχτές Πόρτες
+ (%{open_doors_link}) και θα το τακτοποιήσουμε.
+ more_info:
+ text: Μπορείτε να διαβάσετε τις ανακοινώσεις για τις πρόσφατες μεταφορές αρχείων
+ στα Νέα του AO3 (%{news_link}), και να βρείτε επιπλέον πληροφορίες στη σελίδα
+ Συχνών Ερωτήσεων των Ανοιχτών Πορτών (%{open_doors_faq_link}) ή στη σελίδα
+ των οδηγών χρήσης (%{open_doors_tutorial_link}). Για τυχόν ερωτήσεις που
+ δεν απαντήθηκαν στις Συχνές Ερωτήσεις, στους οδηγούς χρήσης ή στο παρόν
+ μήνυμα, παρακαλούμε επικοινωνήστε με την Επιτροπή Υποστήριξης στο %{support_link}.
+ other_works:
+ text: Αν είχατε και άλλα έργα στο εισαχθέν αρχείο υπό μια ηλεκτρονική διεύθυνση
+ στην οποία δεν έχετε πια πρόσβαση, παρακαλούμε επικοινωνήστε με τις Ανοιχτές
+ Πόρτες με οποιεσδήποτε πληροφορίες μπορούν να επιβεβαιώσουν την ταυτότητά.
+ questions:
+ text: Για άλλες ερωτήσεις, παρακαλούμε επικοινωνήστε με την Επιτροπή Υποστήριξης
+ του AO3 στο %{support_link}.
+ redirects: Προκειμένου να διατηρήσετε λίστες προτάσεων και σελιδοδείκτες, οι
+ ηλεκτρονικές διευθύνσεις του εισαχθέντος αρχείου μπορεί να οδηγούν στο εισαχθέν
+ αντίγραφο αυτών των έργων για λίγο καιρό (ελέγξτε την αναρτημένη ανακοίνωση
+ για το αρχείο σας για να βεβαιωθείτε). Εάν έχετε ήδη αναρτήσει ένα αντίγραφο
+ αυτών των έργων και ΔΕΝ χρησιμοποιήσατε τη λειτουργία εισαγωγής μέσω συνδέσμου
+ URL,τότε θα υπάρχουν στο αρχείο δύο αντίγραφα του ίδιου έργου.
+ subject: "[%{app_name}] Πρόσκληση για αναγνώριση έργων"
+ unwanted:
+ text: Αν αυτά τα έργα όντως ανήκουν σε εσάς, αλλά δεν τα θέλετε, μπορείτε
+ να τα αποποιηθείτε (έτσι ώστε να παραμείνουν στο AO3, αλλά χωρίς να εμφανίζεται
+ το όνομά σας) ή να τα διαγράψετε (έτσι ώστε να αφαιρεθούν πλήρως από το
+ AO3). Δεν χρειάζεται να προσθέσετε αυτά τα έργα σε κάποιον λογαριασμό προκειμένου
+ να τα αποποιηθείτε ή να τα διαγράψετε--μπορείτε να το κάνετε απευθείας από
+ τον παραπάνω σύνδεσμο. (Για βοήθεια, παρακαλούμε επικοινωνήστε με την Επιτροπή
+ Υποστήριξης στο %{support_link}.)
+ update_redirect:
+ text: Αν θέλετε οι Ανοιχτές Πόρτες να ενημερώσουν τον σύνδεσμο ανακατεύθυνσης
+ ώστε να οδηγεί στο προϋπάρχον έργο σας, παρακαλούμε διαγράψτε το εισαχθέν
+ αντίγραφο και επικοινωνήστε με τις Ανοιχτές Πόρτες στο %{open_doors_link}
+ με το όνομα του λογαριασμού σας στο AO3, το όνομα του λογαριασμού σας σε
+ ένα εισαχθέν αρχείο, καθώς και τον τίτλο και τον σύνδεσμο URL του έργου
+ λογοτεχνίας θαυμαστών στο οποίο θέλετε να οδηγεί ο σύνδεσμος ανακατεύθυνσης.
+ (Αν έχετε πολλαπλά έργα τους συνδέσμους ανακατεύθυνσης των οποίων θέλετε
+ να αλλάξετε, μπορείτε να τα απαριθμήσετε όλα σε ένα μήνυμα.)
+ uploaded_list: 'Τα αναρτημένα έργα περιλαμβάνουν τα:'
+ invite_increase_notification:
+ html:
+ body:
+ one: Θέλαμε απλά να σε ενημερώσουμε ότι έχεις μία νέα πρόσκληση που μπορεί
+ να χρησιμοποιηθεί για τη δημιουργία ενός νέου λογαριασμού στο AO3. Μπορείς
+ να προσκαλέσεις έναν φίλο σου στη %{invitation_page_link}.
+ other: Θέλαμε απλά να σε ενημερώσουμε ότι έχεις %{count} νέες προσκλήσεις
+ που μπορείς να χρησιμοποιηθούν για τη δημιουργία νέων λογαριασμών στο
+ AO3. Μπορείς να προσκαλέσεις έναν φίλο σου στη %{invitation_page_link}.
+ invitation_page_link_text: σελίδα των προσκλήσεών σου
+ subject: "[%{app_name}] Νέες Προσκλήσεις"
+ text:
+ body:
+ one: Θέλαμε απλά να σε ενημερώσουμε ότι έχεις %{count} νέες προσκλήσεις
+ που μπορούν να χρησιμοποιηθούν για τη δημιουργία ενός νέου λογαριασμού
+ στο AO3. Μπορείς να προσκαλέσεις έναν φίλο σου στη σελίδα σου Invitations
+ (Προσκλήσεις) (%{invitation_page_url})
+ other: Θέλαμε απλά να σε ενημερώσουμε ότι έχεις %{count} νέες προσκλήσεις
+ που μπορούν να χρησιμοποιηθούν για τη δημιουργία νέων λογαριασμών στο
+ AO3. Μπορείς να προσκαλέσεις έναν φίλο σου στη σελίδα σου Invitations
+ (Προσκλήσεις) %{invitation_page_url}.
+ invite_request_declined:
+ main:
+ one: Είμαστε στη δυσάρεστη θέση να σας ενημερώσουμε πως το αίτημά σας για
+ νέα πρόσκληση δεν μπορεί να ικανοποιηθεί αυτήν τη στιγμή.
+ other: Είμαστε στη δυσάρεστη θέση να σας ενημερώσουμε πως το αίτημά σας για
+ %{count} νέες προσκλήσεις δεν μπορεί να ικανοποιηθεί αυτήν τη στιγμή.
+ reason: 'Το αίτημά σας ήταν:'
+ subject: "[%{app_name}] Το αίτημα για επιπλέον πρόσκληση απορρίφθηκε"
+ recipient_notification:
+ html:
+ collection: Ένα έργο-δώρο έχει αναρτηθεί για εσάς στη συλλογή %{collection_link}
+ στο AO3!
+ no_collection: Ένα έργο-δώρο αναρτήθηκε για εσάς στο AO3!
+ subject:
+ collection: "[%{app_name}][%{collection_title}] Ένα έργο-δώρο για εσάς από
+ τη συλλογή %{collection_title}"
+ no_collection: "[%{app_name}] Ένα έργο-δώρο για εσάς"
+ text:
+ collection: Ένα έργο-δώρο έχει αναρτηθεί για εσάς στη συλλογή "%{collection_title}"
+ (%{collection_url}) στο AO3!
+ signup_notification:
+ activate:
+ html: Παρακαλούμε %{activate_account_link}.
+ text: 'Παρακαλούμε να ακολουθήσετε τον εξής σύνδεσμο για να ενεργοποιήσετε
+ τον λογαριασμό σας: %{activate_account_url}'
+ activate_your_account: ακολουθήστε αυτόν τον σύνδεσμο για να ενεργοποιήσετε
+ τον λογαριασμό σακ
+ admin_posts: Νέα του AO3
+ bye: Ελπίζουμε να απολαύσετε το Αρχείο.
+ contact_support: επικοινωνήστε με την Επιτροπή Υποστήριξης
+ faq: Συχνές Ερωτήσεις
+ features:
+ html: Μόλις ενεργοποιηθεί ο λογαριασμός σας, μπορείτε να αναρτήσετε τα έργα
+ θαυμαστή σας, να ξεκινήσετε ηλεκτρονικές συνδρομές που θα σας ειδοποιούν
+ όταν οι αγαπημένοι σας δημιουργοί ή τα αγαπημένα σας έργα έχουν ενημερωθεί,
+ να ορίσετε τις προτιμήσεις σας ώστε να προσαρμόσετε την εμφάνιση και τη
+ λειτουργία της σελίδας, να κρατάτε αρχείο των έργων που επισκεφθήκατε στο
+ Αρχείο μέσω του ιστορικού σας και πολλά άλλα.
+ text: Μόλις ενεργοποιηθεί ο λογαριασμός σας, μπορείτε να αναρτήσετε τα έργα
+ θαυμαστή σας, να ξεκινήσετε ηλεκτρονικές συνδρομές που θα σας ειδοποιούν
+ όταν οι αγαπημένοι σας δημιουργοί ή τα αγαπημένα σας έργα έχουν ενημερωθεί,
+ να ορίσετε τις προτιμήσεις ώστε να προσαρμόσετε την εμφάνιση και τη λειτουργία
+ της σελίδας για εσάς, να κρατάτε αρχείο των έργων που επισκεφθήκατε στο
+ Αρχείο μέσω του ιστορικού σας και πολλά άλλα.
+ information:
+ html: Υπάρχουν πολλές πληροφορίες και συμβουλές για το πώς να χρησιμοποιήσετε
+ το Αρχείο στις %{faq_link}. Θα βρείτε τα τελευταία νέα σχετικά με τις
+ εξελίξεις στην ιστοσελίδα στα %{admin_posts_link}. Αν χρειάζεστε επιπλέον
+ βοήθεια, συναντήσετε κάποιο σφάλμα ή έχετε απορίες ή σχόλια, παρακαλούμε
+ %{contact_support_link}, η οποία είναι πάντα πρόθυμη να βοηθήσει.
+ text: 'Υπάρχουν πολλές πληροφορίες και συμβουλές για το πώς να χρησιμοποιήσετε
+ το Αρχείο στις Συχνές Ερωτήσεις στο %{faq_url}. Θα βρείτε τα τελευταία νέα
+ σχετικά με τις εξελίξεις στην ιστοσελίδα στις αναρτήσεις των Νέων του AO3
+ στο %{admin_posts_url}. Αν χρειάζεστε επιπλέον βοήθεια, συναντήσετε κάποιο
+ σφάλμα ή έχετε απορίες ή σχόλια, παρακαλούμε επικοινωνήστε με την Επιτροπή
+ Υποστήριξης, η οποία είναι πάντα πρόθυμη να βοηθήσει: %{contact_support_url}.'
+ welcome: Καλώς ήρθατε στο AO3, %{login}!
diff --git a/config/locales/phrase-exports/es.yml b/config/locales/phrase-exports/es.yml
new file mode 100755
index 0000000..9830067
--- /dev/null
+++ b/config/locales/phrase-exports/es.yml
@@ -0,0 +1,623 @@
+---
+es:
+ activerecord:
+ attributes:
+ archive_warning:
+ name_with_colon:
+ one: 'Advertencia:'
+ other: 'Advertencias:'
+ category:
+ name_with_colon:
+ one: 'Categoría:'
+ other: 'Categorías:'
+ character:
+ name_with_colon:
+ one: 'Personaje:'
+ other: 'Personajes:'
+ fandom:
+ name_with_colon:
+ one: 'Fandom:'
+ other: 'Fandoms:'
+ freeform:
+ name_with_colon:
+ one: 'Etiqueta adicional:'
+ other: 'Etiquetas adicionales:'
+ rating:
+ name_with_colon: 'Rating:'
+ relationship:
+ name_with_colon:
+ one: 'Relación:'
+ other: 'Relaciones:'
+ work:
+ chapter_total_display: Capítulos
+ summary: Resumen
+ models:
+ archive_warning:
+ one: Advertencia
+ other: Advertencias
+ category:
+ one: Categoría
+ other: Categorías
+ chapter:
+ one: Capítulo
+ other: Capítulos
+ character:
+ one: Personaje
+ other: Personajes
+ fandom:
+ one: Fandom
+ other: Fandoms
+ freeform:
+ one: Etiqueta adicional
+ other: Etiquetas adicionales
+ rating:
+ one: Rating
+ other: Ratings
+ relationship:
+ one: Relación
+ other: Relaciones
+ series:
+ one: Serie
+ other: Series
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ one: unx visitante
+ other: "%{count} visitantes"
+ left_kudos:
+ html:
+ one: "%{givers_list} dejó kudos en %{commentable_link}."
+ other: "%{givers_list} dejaron kudos en %{commentable_link}"
+ text:
+ one: "%{givers_list} dejó kudos en %{commentable_title} (%{commentable_url})."
+ other: "%{givers_list} dejaron kudos en %{commentable_title} (%{commentable_url})."
+ single_guest:
+ giver: Unx visitante
+ html: "%{giver} dejó kudos en %{commentable_link}."
+ text: Unx visitante dejó kudos en %{commentable_title} (%{commentable_url}).
+ subject: "[%{app_name}] ¡Recibiste kudos!"
+ mailer:
+ general:
+ closing:
+ formal: Atentamente,
+ informal: Saludos,
+ creation:
+ link_with_word_count: "%{creation_link} (%{word_count})"
+ title_with_chapter_number: Capítulo %{position} de %{title}
+ title_with_word_count: '"%{creation_title}" (%{word_count})'
+ word_count:
+ one: "%{count} palabra"
+ other: "%{count} palabras"
+ footer:
+ general:
+ about:
+ html: AO3 es un archivo administrado y mantenido por fans que depende
+ de %{donate_link}.
+ text: 'AO3 es un archivo administrado y mantenido por fans que depende
+ de tus donaciones: %{donate_url}.'
+ html:
+ donate_link_text: tus donaciones
+ support_link_text: Comunícate con Soporte Técnico
+ unwanted_email:
+ html: Si recibiste este mensaje por error, por favor %{support_link}.
+ text: Si recibiste este mensaje por error, por favor, comunícate con Soporte
+ Técnico en %{support_url}.
+ sent_at: 'Enviado: %{sent_at}.'
+ greeting:
+ formal_html: Estimadx %{name},
+ informal:
+ addressed_html: "¡Hola %{name}!"
+ unaddressed: "¡Hola!"
+ introductory: Saludos de parte del Archive of Our Own – AO3 (Un Archivo Propio)
+ metadata_label_indicator: ":"
+ signature:
+ abuse_team: El equipo de Políticas y Prevención de Abuso del AO3
+ app_short_name: AO3
+ open_doors: El equipo de Open Doors (Puertas Abiertas)
+ parent_org: Organization for Transformative Works – OTW (Organización para
+ las Obras Transformativas)
+ support: El equipo de Soporte Técnico del AO3
+ users:
+ mailer:
+ reset_password_instructions:
+ expiration: Si no utilizas este enlace para cambio de contraseña en una semana,
+ expirará y deberás solicitar uno nuevo.
+ intro: 'Alguien ha solicitado restablecer la contraseña de tu cuenta. Para
+ realizar el cambio, sigue el enlace a continuación e ingresa tu nueva contraseña:'
+ link_title: Cambiar mi contraseña.
+ subject: "[%{app_name}] Cambio de contraseña"
+ unrequested: Si no solicitaste un cambio de contraseña, puedes ignorar este
+ correo electrónico y tu contraseña previa será la misma.
+ user_mailer:
+ admin_deleted_work_notification:
+ bye: Para tu referencia, se adjunta una copia de tu obra.
+ contact_abuse: Comunicate con el comité de Políticas y Prevención de Abuso
+ deleted:
+ html: Tu obra %{title} ha sido eliminada del Archivo por unx admin del sitio.
+ text: Tu obra "%{title}" ha sido eliminada por unx admin del sitio.
+ html:
+ tos_violation: Si existe la posibilidad de que tu obra haya violado los Términos
+ y Condiciones de Servicio del Archivo, por favor %{contact_abuse_link}.
+ import_project:
+ html: Si tu obra es parte de un proyecto de importación administrado por nuestro
+ equipo de Open Doors (Puertas Abiertas), por favor %{opendoors_link} si
+ tienes más preguntas.
+ text: Si tu obra es parte de un proyecto administrado por nuestro equipo de
+ Open Doors (Puertas Abiertas) y tienes más preguntas, por favor comunícate
+ con Open Doors (%{opendoors_link}).
+ opendoors: Comunícate con Open Doors (Puertas Abiertas)
+ subject: "[%{app_name}] Tu obra ha sido eliminada por unx admin"
+ text:
+ tos_violation: Si existe la posibilidad de que tu obra haya violado los Términos
+ y Condiciones de Servicio del Archivo, por favor comunícate con el comité
+ de Políticas y Prevención de Abuso (%{contact_abuse_url}).
+ admin_hidden_work_notification:
+ access: Mientras esté marcada como oculta, aún puedes acceder a tu obra mediante
+ el enlace anterior, pero no aparecerá en la lista de tu página de obras y
+ no estará disponible para otrxs usuarixs del AO3.
+ check_email: Por favor revisa tu correo, incluyendo la carpeta de spam, pues
+ es posible que el equipo de Políticas y Prevención de Abuso se haya comunicado
+ contigo para explicarte por qué tu obra fue marcada como oculta.
+ contact_abuse: ponte en contacto con Políticas y Prevención de Abuso
+ html:
+ help: Si no sabes por qué tu obra fue marcada como oculta y no recibiste ninguna
+ comunicación al respecto, por favor %{contact_abuse_link} directamente.
+ hidden: Tu obra %{title} ha sido marcada como oculta por el equipo de Políticas
+ y Prevención de Abuso y ya no está a disposición del público.
+ tos_violation: Si tu obra se marcó como oculta debido a una violación de los
+ %{tos_link} del AO3, se te solicitará que tomes las medidas pertinentes
+ para corregir dicha violación. Si no ajustas tu obra para que cumpla con
+ los Términos y Condiciones de Servicio, puede que tu obra sea eliminadadel
+ AO3.
+ subject: "[%{app_name}] Tu obra ha sido marcada como oculta por el equipo de
+ Políticas y Prevención de Abuso"
+ text:
+ help: 'Si no sabes por qué tu obra fue marcada como oculta y no recibiste
+ ninguna comunicación al respecto, por favor ponte en contacto con Políticas
+ y Prevención de Abuso directamente: %{contact_abuse_url}.'
+ hidden: Tu obra "%{title}" (%{work_url}) fue marcada como oculta por el equipo
+ de Políticas y Prevención de Abuso y ya no está a disposición del público.
+ tos_violation: Si tu obra se marcó como oculta debido a una violación de los
+ Términos y Condiciones de Servicio del AO3 (%{tos_url}), se te solicitará
+ que tomes las medidas pertinentes para corregir dicha violación. Si no ajustas
+ tu obra para que cumpla con los Términos y Condiciones de Servicio, puede
+ que tu obra sea eliminada del AO3.
+ tos: Términos y Condiciones de Servicio
+ anonymous_or_unrevealed_notification:
+ anonymous_info: Las obras anónimas están incluidas en los listados por etiquetas,
+ pero no aparecerán en tu página de obras. Tu nombre de usuarix será reemplazado
+ por "Anonymous" (Anónimo) en la obra.
+ anonymous_unrevealed_info: Lxs encargadxs de la colección pueden revelar tu
+ obra en el futuro pero dejarla anónima. No se le notificar[a a tus subscriptorxs
+ acerca de este cambio. Una vez revelada, tu obra se incluirá en los listados
+ por etiquetas, pero no aparecerá en tu página de obras. Tu nombre de usuarix
+ será reemplazado por "Anonymous" (Anónimo) en la obra.
+ changed_status:
+ anonymous:
+ html: Lxs encargadxs de la colección %{collection_link} han cambiado el
+ estado de tu obra %{work_link} a anónima.
+ text: Lxs encargadxs de la colección "%{collection_title}" (%{collection_url})
+ han cambiado el estado de tu obra "%{work_title}" (%{work_url}) a anónima.
+ anonymous_unrevealed:
+ html: Lxs encargadxs de la colección %{collection_link} han cambiado el
+ estado de tu obra %{work_link} a anónima y sin revelar.
+ text: Lxs encargadxs de la colección "%{collection_title}" (%{collection_url})
+ han cambiado el estado de tu obra "%{work_title}" (%{work_url}) a anónima
+ y no revelada
+ unrevealed:
+ html: Lxs encargadxs de la colección %{collection_link} han cambiado el
+ estado de tu obra %{work_link} a sin revelar.
+ text: Lxs encargadxs de la colección "%{collection_title}" (%{collection_url})
+ han cambiado el estado de tu obra "%{work_title}" (%{work_url}) a no revelada.
+ collection_items_link_text: página de Approved Collection Items (Obras autorizadas
+ para estar en una colección)
+ do_not_want:
+ anonymous:
+ html: Si no quieres que tu obra sea anónima, por favor visita tu %{collection_items_link}
+ para eliminarla de la colección.
+ text: 'Si no quieres que tu obra sea anónima, por favor visita tu página
+ de Approved Collection Items (Obras autorizadas para estar en una colección)
+ para eliminarla de la colección: %{collection_items_url}'
+ anonymous_unrevealed:
+ html: Si no quieres que tu obra sea anónima y esté sin revelar, por favor
+ visita tu %{collection_items_link} para eliminarla de la colección.
+ text: 'Si no quieres que tu obra sea anónima y esté sin revelar, por favor
+ visita tu página de Approved Collection Items (Obras autorizadas para
+ estar en una colección) para eliminarla de la colección: %{collection_items_url}'
+ unrevealed:
+ html: Si no quieres que tu obra esté sin revelar, por favor visita tu %{collection_items_link}
+ para eliminarla de la colección.
+ text: 'Si no quieres que tu obra esté sin revelar, por favor visita tu página
+ de Approved Collection Items (Obras autorizadas para estar en una colección)
+ para eliminarla de la colección: %{collection_items_url}'
+ faq_link_text: FAQ (preguntas frecuentes) de colecciones
+ more_info:
+ html: Para más información, visita nuestras %{faq_link}.
+ text: 'Para más información, visita nuestras FAQ (preguntas frecuentes) de
+ colecciones: %{faq_url}'
+ subject:
+ anonymous: "[%{app_name}] Tu obra ahora es anónima"
+ anonymous_unrevealed: "[%{app_name}] Tu obra ahora es anónima y está sin revelar"
+ unrevealed: "[%{app_name}] Tu obra ahora está sin revelar"
+ unrevealed_info: Las obras sin revelar no están incluidas en los listados por
+ etiquetas ni aparecen en tu página de obras. Quien abre un enlace a la obra
+ recibirá un mensaje diciendo que todavía no ha sido revelada y que no tiene
+ acceso.
+ archivist_added_to_collection_notification:
+ approved_collection_items_page: Approved Collection Items (Artículos de colección
+ aprobados)
+ archivist_notice: Dado que lxs administradorxs de la colección actúan en su
+ capacidad oficial de archivistas para Open Doors (Puertas Abiertas), están
+ autorizados para añadir tu obra a esta colección, aún si tienes deshabilitadas
+ las invitaciones a colecciones. Lxs archivistas solo pueden añadir obras a
+ colecciones en el caso de que las obras estuvieran hospedadas en un archivo
+ importado.
+ removal_instructions:
+ html: Si deseas eliminar tu obra de esta colección, revisa tu página de %{approved_items_link}.
+ text: 'Si deseas eliminar tu obra de esta colección, revisa tu página de Approved
+ Collection Items (Artículos de colección aprobados): %{approved_items_url}.'
+ subject: "[%{app_name}][%{collection_title}] Unx archivista de Open Doors (Puertas
+ Abiertas) añadió tu obra a una colección"
+ work_added:
+ html: "¡Lxs administradorxs de %{collection_link} añadieron tu obra %{work_link}
+ a su colección!"
+ text: ¡Lxs administradorxs de "%{collection_title}" (%{collection_url}) añadieron
+ tu obra "%{work_title}" (%{work_url}) a su colección!
+ challenge_assignment_notification:
+ any: Cualquiera
+ assignment:
+ html: "¡Te han asignado la siguiente solicitud en el desafío %{link} en el
+ AO3!"
+ description: 'Descripción:'
+ due: 'La fecha de entrega de esta tarea es:'
+ html:
+ footer: Has recibido este correo porque te inscribiste en el desafío %{title}.
+ Para más información sobre este desafío y sobre cómo comunicarte con quien
+ lo modera, por favor revisa %{footer_link}.
+ footer_link: página de perfil del desafío
+ look_up: Puedes buscar esta tarea en %{link}.
+ look_up_link: tu página de Assignments (Tareas)
+ optional_tags: 'Etiquetas opcionales:'
+ prompts: 'Sugerencias:'
+ prompt_url: 'URL de la sugerencia:'
+ recipient: 'Destinatarix:'
+ recipient_missing: 'No existe: contacta a quien modera para que lo solucione.'
+ subject: "[%{app_name}][%{collection_title}] ¡Tu tarea!"
+ text:
+ assignment: ¡Te han asignado la siguiente solicitud en el desafío "%{collection_title}"
+ (%{collection_url}) en el AO3 (Un Archivo Propio)!
+ footer: Has recibido este correo porque te inscribiste en el desafío %{title}
+ (%{url}). Para más información sobre este desafío y sobre cómo comunicarte
+ con quien lo modera, por favor revisa %{profile_url}.
+ look_up: Puedes buscar esta tarea en tu página de Assignments (Tareas) en
+ %{link}
+ change_email:
+ changed:
+ html: "%{login}, el correo electrónico asociado a tu cuenta se ha modificado
+ a %{email}"
+ text: "%{login}, el correo electrónico asociado a tu cuenta se ha modificado
+ a %{email}"
+ subject: "[%{app_name}] Cambio de correo electrónico"
+ claim_notification:
+ access:
+ contact_support: contacta a Soporte Técnico del AO3
+ html: Dependiendo del archivo, tus obras pudieron ser importadas para usuarixs
+ registrados solamente (para mantenerlas fuera de las búsquedas de Google).
+ Si es el caso, las obras sólo serán accesibles para usuarixs con sesión
+ iniciada, a menos que elijas ponerlas a disposición general. Para ayudarte
+ a desbloquear, renunciar o eliminar tus obras, por favor, %{contact_support_link}.
+ text: Dependiendo del archivo, tus obras pudieron ser importadas para usuarixs
+ registrados solamente (para mantenerlas fuera de las búsquedas de Google).
+ Si es el caso, las obras sólo serán accesibles para usuarixs con sesión
+ iniciada, a menos que tu elijas hacerlas disponibles para todxs. Para ayudarte
+ a desbloquear, renunciar o eliminar tus obras, por favor, contacta a Soporte
+ Técnico del AO3 en %{support_url}.
+ email_tips: Si vas a contactarnos, añade los correos relacionados a @transformativeworks.org
+ a tu lista de contactos autorizados y revisa tus carpetas de correo no deseado
+ buscando nuestras respuestas.
+ introduction:
+ ao3_name: Archive of Our Own – AO3 (Un Archivo Propio)
+ html: Recibes este correo electrónico porque tienes obras en un archivo de
+ obras fan que ha sido importado por %{open_doors_name_link} a %{app_link}.
+ Dado que este correo electrónico está vinculado a tu registro en el archivo
+ importado, las obras asociadas al mismo (listadas abajo) se han añadido
+ de manera automática a tu cuenta en el AO3.
+ open_doors_name: Open Doors (Puertas Abiertas)
+ text: 'Recibes este correo electrónico porque tienes obras en un archivo de
+ obras fan que ha sido importado por Open Doors (Puertas Abiertas) (%{open_doors_url})
+ al Archive of Our Own – AO3 (Un Archivo Propio): %{app_url}. Dado que este
+ correo electrónico está vinculado a tu registro en el archivo importado,
+ las obras asociadas al mismo (listadas abajo) se han añadido de manera automática
+ a tu cuenta en el AO3.'
+ mistake:
+ contact_open_doors: contacta a Open Doors
+ html: Si se trata de un error y no son tus obras, por favor, ¡no las elimines!
+ Solo %{contact_open_doors_link} y lo resolveremos.
+ text: Si se trata de un error y no son tus obras, por favor, ¡no las elimines!
+ Sólo contacta a Open Doors (%{open_doors_url}) y lo resolveremos.
+ more_info:
+ ao3_news: Noticias del AO3
+ contact_support: contacta a Soporte Técnico del AO3
+ faq_page: FAQ (Preguntas frecuentes)
+ html: Puedes leer los anuncios sobre mudanzas recientes de archivos en %{ao3_news_link}
+ y encontrar información adicional en las %{faq_page_link} de Open Doors
+ o %{tutorial_page_link}. Para cualquier pregunta que no se responda en las
+ FAQs, tutoriales o este correo electrónico, por favor, %{contact_support_link}.
+ text: Puedes leer los anuncios sobre mudanzas recientes de archivos en las
+ Noticias del AO3 (%{news_url}) y encontrar información adicional en las
+ FAQs de Open Doors (%{open_doors_faq_url}) o páginas de tutoriales (%{open_doors_tutorial_url}).
+ Para cualquier pregunta que no se responda en las FAQs, tutoriales o este
+ correo electrónico, por favor, contacta a Soporte Técnico en %{support_url}.
+ tutorial_page: páginas de tutoriales
+ other_works:
+ contact_open_doors: contacta a Open Doors
+ html: Si tienes otras obras en este archivo importado bajo un correo electrónico
+ al que ya no tienes acceso, por favor, %{contact_open_doors_link} con cualquier
+ información que pueda ser de ayuda para verificar tu identidad.
+ text: Si tienes otras obras en este archivo importado bajo un correo electrónico
+ al que ya no tienes acceso, por favor, contacta a Open Doors con cualquier
+ información que pueda ser de ayuda para verificar tu identidad.
+ questions:
+ contact_support: contacta a Soporte Técnico del AO3
+ html: Para más consultas, por favor, %{contact_support_link}.
+ text: Para más consultas, por favor, contacta a Soporte Técnico del AO3 en
+ %{support_url}.
+ redirects:
+ html: Para preservar listas de recomendaciones y marcadores, la dirección
+ web del archivo importado redireccionará a la copia importada de esas obras
+ por tiempo limitado (revisa la publicación del anuncio de importación de
+ tu archivo para asegurarte). Si tu ya subiste una copia de estas obras y
+ %{negation} utilizaste la herramienta de importación desde la URL, habrá
+ dos copias de la misma obra en el AO3.
+ subject: "[%{app_name}] Obras importadas"
+ update_redirect:
+ contact_open_doors: contacta a Open Doors
+ html: Si deseas que Open Doors actualice la redirección para señalar tus obras
+ preexistentes, por favor, elimina la copia importada y %{contact_open_doors_link}
+ con el nombre de tu cuenta en el AO3, el nombre de tu cuenta en el archivo
+ importado y el título y URL de la obra fan que deseas redireccionar. (Si
+ tienes múltiples obras a las que quisieras cambiarles la redirección, puedes
+ listarlas en un correo electrónico).
+ text: Si deseas que Open Doors actualice la redirección para señalar tus obras
+ preexistentes, por favor, elimina la copia importada y contacta a Open Doors
+ en %{open_doors_url} con el nombre de tu cuenta en el AO3, el nombre de
+ tu cuenta en el archivo importado y el título y URL de la obra fan que deseas
+ redireccionar. (Si tienes múltiples obras a las que quisieras cambiarles
+ la redirección, puedes listarlas en un correo electrónico).
+ works_by: 'Estas obras se escribieron bajo este correo electrónico: %{email}'
+ work_info:
+ html: "%{work_link} (%{fandom})"
+ text:
+ no_fandom: "- %{work_title} %{work_url}"
+ with_fandom: "- %{work_title} %{work_url} (%{fandom})"
+ collection_notification:
+ assignments_sent:
+ complete: Ya se han enviado todas las tareas.
+ subject: Tareas enviadas
+ html:
+ received_message: 'Recibiste un mensaje sobre tu colección %{collection_link}:'
+ text:
+ received_message: 'Recibiste un mensaje sobre tu colección "%{collection_title}"
+ (%{collection_url}):'
+ creatorship_notification:
+ explanation: Cuando eres co-creadorx de una obra, te pueden añadir a nuevos
+ capítulos sin importar tu configuración de co-creación. También se añadirá
+ tu pseudónimo a cualquier serie que incluya esta obra.
+ html:
+ creation: "%{creation_link} por %{pseud_links}"
+ edit_chapter: editar el capítulo
+ edit_series: editar la serie
+ remove_chapter: Si has sido añadidx por error o no quieres aparecer como creadorx,
+ puedes %{edit_chapter_link} para eliminarte como creadorx.
+ remove_series: Si has sido agregadx por error o no deseas aparecer como co-creadorx,
+ puedes %{edit_series_link} para eliminarte como creadorx.
+ intro_chapter: 'La cuenta de usuarix %{adding_user} ha añadido tu pseudónimo
+ %{pseud} como co-creadorx del siguiente capítulo:'
+ intro_series: 'Elx usuarix %{adding_user} ha incluido tu pseudónimo %{pseud}
+ como co-creadorx en la siguiente serie:'
+ subject: "[%{app_name}] Notificación de co-creadorx"
+ text:
+ creation: "%{title} (%{url}) por %{pseuds}"
+ remove_chapter: 'Si has sido añadidx por error o no quieres aparecer como
+ creadorx, puedes editar el capítulo para eliminarte como creadorx: %{url}'
+ remove_series: 'Si has sido agregadx por error o no deseas aparecer como co-creadorx,
+ puedes editar la serie para eliminarte como creadorx: %{url}'
+ creatorship_notification_archivist:
+ explanation: Puesto que actúan en su capacidad oficial como archivistas de Open
+ Doors (Puertas Abiertas), se les permite añadirte sin que lo solicites, incluso
+ si tienes la co-creación deshabilitada.
+ html:
+ creation: "%{creation_link} por %{pseud_links}"
+ edit_chapter: editar el capítulo
+ edit_series: editar la serie
+ edit_work: editar la obra
+ remove_chapter: Si te han añadido por error o no quieres aparecer como creadorx,
+ puedes %{edit_chapter_link}} para eliminarte como creadorx.
+ remove_series: Si te han añadido por error o no quieres aparecer como creadorx,
+ puedes %{edit_series_link} para eliminarte como creadorx.
+ remove_work: Si te han añadido por error o no quieres aparecer como creadorx,
+ puedes %{edit_work_link} para eliminarte como creadorx.
+ intro_chapter: 'Elx usuarix %{archivist} ha añadido tu pseudónimo %{pseud} como
+ co-creadorx en el siguiente capítulo:'
+ intro_series: 'Elx usuarix %{archivist} ha añadido tu pseudónimo %{pseud} como
+ co-creadorx en la siguiente serie:'
+ intro_work: 'Elx usuarix %{archivist} ha añadido tu pseudónimo %{pseud} como
+ co-creadorx en la siguiente obra:'
+ subject: "[%{app_name}] Notificación de co-creadorx por parte de archivista"
+ text:
+ creation: "%{title} (%{url}) por %{pseuds}"
+ remove_chapter: 'Si te han añadido por error o no quieres aparecer como creadorx,
+ puedes editar el capítulo para eliminarte como creadorx: %{url}'
+ remove_series: 'Si te han añadido por error o no quieres aparecer como creadorx,
+ puedes editar la serie para eliminarte como creadorx: %{url}'
+ remove_work: 'Si te han añadido por error o no quieres aparecer como creadorx,
+ puedes editar la obra para eliminarte como creadorx: %{url}'
+ creatorship_request:
+ html:
+ creation: "%{creation_link} de %{pseud_links}"
+ instructions: Puedes aceptar o rechazar la solicitud en tu página de %{page_name}.
+ page_name: Co-Creator Requests (Solicitudes de co-creadorx)
+ intro_chapter: 'La cuenta de %{inviting_user} ha invitado a tu pseudónimo %{pseud}
+ a ser listado como co-creadorx del siguiente capítulo:'
+ intro_series: 'La cuenta de %{inviting_user} ha invitado a tu pseudónimo %{pseud}
+ a ser listado como co-creadorx de la siguiente serie:'
+ intro_work: 'La cuenta de %{inviting_user} ha invitado a tu pseudónimo %{pseud}
+ a ser listado como co-creadorx de la siguiente obra:'
+ subject: "[%{app_name}] Solicitud de co-creadorx"
+ text:
+ creation: "%{title} (%{url}) de %{pseuds}"
+ instructions: 'Puedes aceptar o rechazar la solicitud en tu página de Co-Creator
+ Requests (Solicitudes de co-creadorx): %{url}'
+ delete_work_notification:
+ attachment: Hemos adjuntado una copia de la obra como referencia.
+ deleted_other:
+ html: Tu obra %{title} fue eliminada a petición de %{pseud}.
+ text: Tu obra "%{title}" fue eliminada a petición de %{pseud}.
+ deleted_yourself:
+ html: Tu obra %{title} se ha eliminado como solicitaste.
+ text: Tu obra "%{title}" fue eliminada como solicitaste.
+ questions:
+ html: Si tienes alguna pregunta, por favor, %{support}.
+ text: Si tienes alguna pregunta, por favor, %{support} (%{url}).
+ subject: "[%{app_name}] Tu obra ha sido eliminada"
+ support: comunícate con Soporte Técnico
+ invitation_to_claim:
+ access:
+ text: Dependiendo del archivo, tus obras pueden haber sido importadas de forma
+ que solo lxs usuarixs registradxs pueden verlas (para que no aparezcan en
+ búsquedas de Google). Si es así, solo lxs usuarixs que hayan iniciado sesión
+ pueden acceder a las obras, salvo que elijas hacerlas visibles para todxs.
+ Para obtener ayuda acerca de cómo hacer tus obras accesibles, renunciar
+ a ellas o borrarlas, por favor, contacta a Soporte Técnico del AO3 (Un Archivo
+ Propio).
+ claim_or_remove:
+ html: Reclama o elimina tus obras aquí.
+ text: 'Reclama o elimina tus obras aquí: %{claim_url}'
+ email_tips: Si vas a contactarnos, por favor incluye los correos electrónicos
+ de @transformativeworks.org a tu lista autorizada de contactos y revisa la
+ carpeta de spam para asegurarte de que has recibido nuestra respuesta.
+ html:
+ ao3_news: Noticias del AO3
+ contact_open_doors: contacta a Open Doors (Puertas Abiertas)
+ contact_support: contacta a Soporte Técnico del AO3 (Un Archivo Propio)
+ faq_page: página de FAQ (Preguntas frecuentes)
+ tutorial_page: página de tutorial
+ introduction:
+ text: Has recibido este correo electrónico porque recientemente Open Doors
+ (Puertas Abiertas) ha importado un archivo (%{open_doors_link}) al %{app_name}
+ (%{app_short_name} - %{app_url}) y creemos que estas obras de fan pueden
+ ser de tu autoría. Nos gustaría darte la oportunidad de reclamarlas (o borrarlas
+ o renunciar a ellas) si lo prefieres. Si todavía no tienes una cuenta con
+ otro correo electrónico, ¡nos gustaría invitarte!
+ mistake:
+ text: Si ocurrió un error y las obras no son tuyas, por favor, ¡no las borres!
+ Simplemente ponte en contacto con Puertas Abiertas (%{open_doors_link})
+ y lo solucionaremos.
+ more_info:
+ text: Puedes encontrar anuncios acerca de mudanzas recientes de archivos en
+ las Noticias de AO3 (%{news_link}) e información adicional en las FAQ (Preguntas
+ frecuentes) de Puertas Abiertas (%{open_doors_faq_link}) o en la página
+ de tutoriales (%{open_doors_tutorial_link}). Para cualquier pregunta que
+ no esté incluida en las FAQ (Preguntas frecuentes), por favor, ponte en
+ contacto con Soporte Técnico en %{support_link}.
+ other_works:
+ text: Si tienes otras obras en el archivo importado asociadas a una dirección
+ de correo electrónico a la que ya no puedes acceder, por favor ponte en
+ contacto con Puertas Abiertas con cualquier información que pueda ayudarnos
+ a verificar tu identidad.
+ questions:
+ text: Para cualquier otra consulta, por favor, ponte en contacto con Soporte
+ Técnico del AO3 en %{support_link}.
+ redirects: Para conservar las listas de recomendaciones y los marcadores, las
+ direcciones web del archivo importado pueden redirigir a las copias importadas
+ de dichas obras por un tiempo (consulta la publicación sobre el archivo en
+ cuestión para asegurarte). Si ya has subido una copia de estas obras y NO
+ has utilizado la función importar desde URL, habrá dos copias de la misma
+ obra en el archivo.
+ subject: "[%{app_name}] Invitación para reclamar obras"
+ unwanted:
+ text: Si estas obras te pertenecen, pero no las quieres, puedes renunciar
+ a ellas (de forma que permanecen en el AO3, pero sin tu nombre) o borrarlas
+ (de manera que se eliminan completamente del AO3). No necesitas añadir las
+ obras a una cuenta para renunciar a ellas o borrarlas, sino que puedes hacerlo
+ mediante el enlace para reclamar obras que aparece arriba (Si necesitas
+ ayuda, por favor, %{support_link}).
+ update_redirect:
+ text: Si quieres que Puertas Abiertas actualice la redirección para que se
+ dirija a tu obra ya publicada, por favor, borra la copia importada y %{open_doors_link}
+ con tu nombre de usuarix en AO3 y en el archivo importado y el título y
+ URL de la obra de fan a la que te gustaría que se dirigiese la redirección
+ (puedes enlistarlas en el mismo correo electrónico si tienes varias cuya
+ redirección quieras cambiar).
+ uploaded_list: 'Las obras subidas incluyen:'
+ invite_increase_notification:
+ html:
+ body:
+ one: Solo queremos informarte que tienes %{count} invitación nueva que puedes
+ utilizar para crear una cuenta nueva en el AO3. Puedes invitar a tus amigxs
+ en %{invitation_page_link}.
+ other: Solo queremos informarte que tienes %{count} invitaciones nuevas
+ que puedes utilizar para crear nuevas cuentas en el AO3. Puedes invitar
+ a tus amigxs en %{invitation_page_link}.
+ invitation_page_link_text: tu página de invitaciones
+ subject: "[%{app_name}] Nuevas Invitaciones"
+ text:
+ body:
+ one: Solo queremos informarte que tienes %{count} invitación nueva que puedes
+ utilizar para crear una cuenta nueva en el AO3. Puedes invitar a tus amigxs
+ a tu página de Invitations (Invitaciones) en %{invitation_page_url}).
+ other: Solo queremos informarte que tienes %{count} invitaciones nuevas
+ que puedes utilizar para crear nuevas cuentas en el AO3. Puedes invitar
+ a tus amigxs a tu página de Invitations (Invitaciones) en %{invitation_page_url}).
+ invite_request_declined:
+ main:
+ one: Lamentamos informarte que en este momento no podemos cumplir con tu solicitud
+ de una nueva invitación.
+ other: Lamentamos informarte que en este momento no podemos cumplir con tu
+ solicitud de %{count} nuevas invitaciones.
+ reason: 'Tu solicitud era:'
+ subject: "[%{app_name}] Solicitud de código adicional de invitación rechazada"
+ recipient_notification:
+ html:
+ collection: "¡Se ha publicado una obra como regalo para ti en la colección
+ %{collection_link} en el AO3!"
+ no_collection: "¡Se ha publicado una obra como regalo para ti en el AO3!"
+ subject:
+ collection: "[%{app_name}][%{collection_title}] Tienes una obra de regalo
+ de %{collection_title}"
+ no_collection: "[%{app_name}] Tienes una obra de regalo"
+ text:
+ collection: ¡Se ha publicado una obra como regalo para ti en la colección
+ "%{collection_title}" (%{collection_url}) en el AO3!
+ signup_notification:
+ activate:
+ html: Por favor %{activate_account_link}.
+ text: 'Por favor sigue este enlace para activar tu cuenta: %{activate_account_url}'
+ activate_your_account: Sigue este enlace para activar tu cuenta
+ admin_posts: Noticias del AO3
+ bye: Esperamos que disfrutes el AO3.
+ contact_support: contacta a Soporte Técnico
+ faq: FAQ
+ features:
+ html: Una vez que tu cuenta esté funcionando, podrás publicar obras de fans,
+ suscribirte para recibir notificaciones por email cuando tus autorxs favoritxs
+ publican o cuando se actualizan tus obras favoritas, establecer preferencias
+ para personalizar el sitio a tu gusto, estar al tanto de las obras a las
+ que has accedido en el AO3 por medio de tu historial y mucho más.
+ text: Una vez que tu cuenta esté funcionando, podrás publicar obras de fans,
+ suscribirte para recibir notificaciones por email cuando tus autorxs favoritxs
+ publican o cuando se actualizan tus obras favoritas, establecer preferencias
+ para personalizar el sitio a tu gusto, estar al tanto de las obras a las
+ que has accedido en el AO3 por medio de tu historial y mucho más.
+ information:
+ html: Hay mucha información y recomendaciones para el uso del AO3 en nuestras
+ %{faq_link}. Podrás encontrar las últimas noticias sobre los cambios al
+ sitio en %{admin_posts_link}. Si necesitas más ayuda, encuentras un error
+ o tienes preguntas o comentarios, por favor %{contact_support_link}, quienes
+ con gusto te ayudarán.
+ text: 'Hay mucha información y recomendaciones para el uso del AO3 en nuestras
+ FAQ en %{faq_url}. Podrás encontrar las últimas noticias sobre los cambios
+ al sitio en Noticias del AO3 en %{admin_posts_url}. Si necesitas más ayuda,
+ encuentras un error o tienes preguntas o comentarios, por favor contacta
+ a nuestro equipo de Soporte Técnico, quienes con gusto te ayudarán: %{contact_support_url}.'
+ welcome: "¡Te damos la bienvenida a Archive of Our Own, %{login}!"
diff --git a/config/locales/phrase-exports/fa.yml b/config/locales/phrase-exports/fa.yml
new file mode 100755
index 0000000..670038e
--- /dev/null
+++ b/config/locales/phrase-exports/fa.yml
@@ -0,0 +1,533 @@
+---
+fa:
+ activerecord:
+ attributes:
+ archive_warning:
+ name_with_colon:
+ one: 'هشدار:'
+ other: 'هشدارها:'
+ category:
+ name_with_colon:
+ one: 'دسته:'
+ other: 'دسته ها:'
+ character:
+ name_with_colon:
+ one: 'شخصیت:'
+ other: 'شخصیت ها:'
+ fandom:
+ name_with_colon:
+ one: 'فندوم:'
+ other: 'فندوم ها:'
+ freeform:
+ name_with_colon:
+ one: 'تگ اضافی:'
+ other: تگ های اضافی
+ rating:
+ name_with_colon: 'رتبه بندی:'
+ relationship:
+ name_with_colon:
+ one: 'رابطه:'
+ other: 'روابط:'
+ work:
+ summary: خلاصه
+ models:
+ archive_warning:
+ one: هشدار
+ other: هشدارها
+ category:
+ one: دسته
+ other: دسته ها
+ chapter:
+ one: فصل
+ other: فصل ها
+ character:
+ one: شخصیت
+ other: شخصیت ها
+ fandom:
+ one: فندوم
+ other: فندوم ها
+ freeform:
+ one: تگ اضافی
+ other: تگ های اضافی
+ rating:
+ one: رتبه بندی
+ other: رتبه بندی ها
+ relationship:
+ one: رابطه
+ other: روابط
+ series:
+ one: سری
+ other: سری ها
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ one: یک مهمان
+ other: "%{count} مهمان"
+ left_kudos:
+ html:
+ one: "%{givers_list} برای %{commentable_link} کودوس و قدردانی فرستاد."
+ other: "%{givers_list} برای %{commentable_link} کودوس و قدردانی فرستادند."
+ text:
+ one: "%{givers_list} برای %{commentable_title} (%{commentable_url}) کودوس
+ و قدردانی فرستاد."
+ other: "%{givers_list} برای %{commentable_title} (%{commentable_url}) کودوس
+ و قدردانی فرستادند."
+ single_guest:
+ giver: یک مهمان
+ html: "%{giver} براى %{commentable_link} كودوس و قدردانى فرستاد."
+ text: یک مهمان براى %{commentable_title} كودوس و قدردانى فرستاد (%{commentable_url}).
+ subject: "[%{app_name}] شما کودوس و قدردانی دریافت کردید!"
+ mailer:
+ general:
+ closing:
+ formal: با احترام،
+ informal: با آرزوی بهترین ها،
+ creation:
+ link_with_word_count: "(%{word_count}) %{creation_link}"
+ title_with_chapter_number: فصل %{position} از %{title}
+ title_with_word_count: (%{word_count}) "%{creation_title}"
+ word_count:
+ one: کلمه %{count}
+ other: کلمه %{count}
+ footer:
+ general:
+ about:
+ html: Archive of Our Own آرشیویست که توسط هواداران اداره و حمایت می شود
+ که به %{donate_link} تکیه می کند.
+ text: 'Archive of Our Own آرشیویست که توسط هواداران اداره و حمایت می شود
+ که به کمکهای مالی شما تکیه می کند: %{donate_url}.'
+ html:
+ donate_link_text: کمک های مالی شما
+ support_link_text: با پشتیبانی تماس بگیرید
+ unwanted_email:
+ html: اگر شما به اشتباه این پیام را دریافت کرده اید لطفا %{support_link}.
+ text: اگر شما به اشتباه این پیام را دریافت کرده اید لطفا با پشتیبانی تماس
+ بگیرید در %{support_url}.
+ sent_at: فرستاده شده در %{sent_at}.
+ greeting:
+ formal_html: "%{name} عزیز،"
+ informal:
+ addressed_html: سلام، %{name}!
+ unaddressed: سلام!
+ introductory: سلامی از Archive of Our Own – AO3 (آرشیوی از آن خودمان)!
+ metadata_label_indicator: ":"
+ signature:
+ abuse_team: تیم اختیارات AO3
+ app_short_name: AO3
+ open_doors: تیم Open Doors (درهای باز)
+ parent_org: Organization for Transformative Works – OTW (سازمانی برای آثار
+ دگرگون شونده)
+ support: تیم پشتیبانی AO3
+ users:
+ mailer:
+ reset_password_instructions:
+ expiration: اگر شما از این لینک برای ریست کردن پسوردتان تا یک هفته استفاده
+ نکنید، این لینک منقضی می شود و مجبورید دوباره درخواست یک لینک جدید را بدهید.
+ intro: 'فردی برای اکانت شما درخواست ریست کردن پسورد داده است. شما می توانید
+ پسورد اکانتتان را با دنبال کردن لینک زیر و وارد کردن پسورد جدیدتان، تغییر
+ دهید:'
+ link_title: پسوردم را تغییر دهید.
+ subject: "[%{app_name}] پسوردتان را ريست كنيد"
+ unrequested: اگر شما برای ریست کردن پسوردتان درخواست نداده اید می توانید این
+ ایمیل را نادیده بگیرید و پسورد سابقتان به کار کردن ادامه خواهد داد.
+ user_mailer:
+ admin_deleted_work_notification:
+ bye: ضمیمه شده یک کپی از اثر شما برای مراجعه شماست.
+ contact_abuse: با تیم اختیارات ما تماس بگیرید
+ deleted:
+ html: اثر شما %{title} از آرشیو AO3 توسط یک ادمین سایت حذف شده است.
+ text: اثر شما "%{title}" از آرشیو AO3 توسط یک ادمین سایت حذف شده است.
+ html:
+ tos_violation: اگر احتمال دارد که اثر شما شرایط استفاده از خدمات آرشیو را
+ زیر سوال برده باشد، لطفا %{contact_abuse_link}.
+ import_project:
+ html: اگر اثر شما بخشی از یک پروژه ی واردات که توسط تیم Open Doors (درهای
+ باز) ما مدیریت شده است بوده، لطفا در صورت داشتن سوالهای بیشتر %{opendoors_link}.
+ text: اگر اثر شما بخشی از یک پروژه ی واردات که توسط تیم درهای باز ما مدیریت
+ شده است بوده، لطفا در صورت داشتن سوالهای بیشتر با Open Doors (درهای باز)
+ تماس بگیرید (%{opendoors_link}).
+ opendoors: با درهای باز تماس بگیرید
+ subject: "[%{app_name}] اثر شما توسط یک ادمین حذف شده است"
+ text:
+ tos_violation: اگر احتمال دارد که اثر شما شرایط خدمت آرشیو را زیر سوال برده
+ باشد، لطفا با کمیته ی اختیارات ما تماس بگیرید (%{contact_abuse_url}).
+ admin_hidden_work_notification:
+ access: اگرچه اثرتان مخفی است، شما باز هم از طریق لینکی که در بالا ارائه شده
+ است قادر به دسترسی به آن هستید، اما این اثر در صفحه ی آثار شما لیست نخواهد
+ شد و در دسترس کاربران دیگر AO3 نخواهد بود.
+ check_email: لطفا ایمیلتان، شامل فولدر اسپم (هرزنامه) تان، را چک کنید، به خاطر
+ اینکه تیم اختیارات ممکن است تاکنون با شما برای توضیح اینکه چرا اثرتان مخفی
+ شده است تماس گرفته باشد.
+ contact_abuse: با کمیته ی اختیارات تماس بگیرید
+ html:
+ help: اگر از اینکه چرا اثرتان مخفی شده است مطمئن نیستید و راجع به این موضوع
+ مکاتبات بیشتری دریافت نکرده اید، لطفا مستقیما %{contact_abuse_link}.
+ hidden: اثر شما %{title} توسط تیم اختیارات مخفی شده است و دیگر به صورت عمومی
+ در دسترس نیست.
+ tos_violation: اگر اثرتان به خاطر نقض %{tos_link} AO3 مخفی شده است، شما در
+ راستای اصلاح تخلف ملزم به انجام اقدام خواهید بود. عدم تطابق اثرتان با شرایط
+ استفاده از خدمات ممکن است به حذف اثرتان از AO3 منجر شود.
+ subject: "[%{app_name}] اثر شما توسط تیم اختیارات مخفی شده است"
+ text:
+ help: 'اگر از اینکه چرا اثرتان مخفی شده است مطمئن نیستید و راجع به این موضوع
+ مکاتبات بیشتری دریافت نکرده اید، لطفا مستقیما با کمیته ی اختیارات تماس بگیرید:
+ %{contact_abuse_url}.'
+ hidden: اثر شما، "%{title}" (%{work_url}) توسط تیم اختیارات مخفی شده است و
+ دیگر به صورت عمومی در دسترس نیست.
+ tos_violation: اگر اثرتان به خاطر نقض شرایط استفاده از خدمات AO3 (%{tos_url})
+ مخفی شده است، شما در راستای اصلاح تخلف ملزم به اقدام خواهید بود. عدم تطابق
+ اثرتان با شرایط استفاده از خدمات ممکن است به حذف اثرتان از AO3 منجر شود.
+ tos: شرایط استفاده از خدمات
+ anonymous_or_unrevealed_notification:
+ anonymous_info: آثار ناشناس در لیست های تگ آورده می شوند ولی بر روی صفحه ی آثار
+ شما ظاهر نمی شوند. بر روی اثر، نام کاربری شما با "Anonymous" (ناشناس) جایگزین
+ خواهد شد.
+ anonymous_unrevealed_info: نگهدارندگان مجموعه ممکن است بعدا اثر شما را آشکار
+ کنند ولی آن را ناشناس باقی بگذارند. افرادی که مشترک شما هستند از این تغییر
+ مطلع نخواهند شد. زمانی که اثر شما آشکار شود در لیست های تگ ذکر خواهد شد ولی
+ در صفحه ی آثار شما ظاهر نخواهد شد. بر روی اثر نام کاربری شما با "Anonymous"
+ (ناشناس) جایگزین خواهد شد.
+ changed_status:
+ anonymous:
+ html: نگهدارندگان مجموعه ی %{collection_link} وضعیت اثر شما، %{work_link}،
+ را به ناشناس تغییر داده اند.
+ text: نگهداران مجموعه ی "%{collection_title}" (%{collection_url}) وضعیت
+ اثر شما "%{work_title}" (%{work_url}) را به ناشناس تغییر داده اند.
+ anonymous_unrevealed:
+ html: نگهدارندگان مجموعه ی %{collection_link} وضعیت اثر شما، %{work_link}،
+ را به ناشناس و پنهان تغییر داده اند.
+ text: نگهداران مجموعه ی "%{collection_title}" (%{collection_url}) وضعیت
+ اثر شما "%{work_title}" (%{work_url}) را به ناشناس و پنهان تغییر داده
+ اند.
+ unrevealed:
+ html: نگهدارندگان مجموعه ی %{collection_link} وضعیت اثر شما، %{work_link}،
+ را به پنهان تغییر داده اند.
+ text: نگهداران مجموعه ی "%{collection_title}" (%{collection_url}) وضعیت
+ اثر شما "%{work_title}" (%{work_url}) را به پنهان تغییر داده اند.
+ collection_items_link_text: صفحه ی Approved Collection Items (موارد مورد تایید
+ مجموعه)
+ do_not_want:
+ anonymous:
+ html: اگر نمی خواهید که اثرتان ناشناس باشد، لطفا به %{collection_items_link}
+ تان سر بزنید تا آن را از این مجموعه حذف کنید.
+ text: 'اگر نمی خواهید که اثرتان ناشناس باشد، لطفا به صفحه ی Approved Collection
+ Items (موارد مورد تایید مجموعه) تان سر بزنید تا آن را از این مجموعه حذف
+ کنید: %{collection_items_url}'
+ anonymous_unrevealed:
+ html: اگر نمی خواهید که اثرتان ناشناس و پنهان باشد، لطفا به %{collection_items_link}
+ تان سر بزنید تا آن را از این مجموعه حذف کنید.
+ text: 'اگر نمی خواهید که اثرتان ناشناس و پنهان باشد، لطفا به صفحه ی Approved
+ Collection Items (موارد مورد تایید مجموعه) تان سر بزنید تا آن را از این
+ مجموعه حذف کنید: %{collection_items_url}'
+ unrevealed:
+ html: اگر نمی خواهید که اثرتان پنهان باشد، لطفا به %{collection_items_link}
+ تان سر بزنید تا آن را از این مجموعه حذف کنید.
+ text: 'اگر نمی خواهید که اثرتان پنهان باشد، لطفا به صفحه ی Approved Collection
+ Items (موارد مورد تایید مجموعه) تان سر بزنید تا آن را از این مجموعه حذف
+ کنید: %{collection_items_url}'
+ faq_link_text: سوالات متداول مجموعه ها
+ more_info:
+ html: برای اطلاعات بیشتر، به %{faq_link}ی ما سری بزنید.
+ text: 'برای اطلاعات بیشتر به سوالات متداول مجموعه ها سری بزنید: %{faq_url}'
+ subject:
+ anonymous: "[%{app_name}] وضعیت اثر شما به ناشناس تغییر پیدا کرد"
+ anonymous_unrevealed: "[%{app_name}] وضعیت اثر شما به ناشناس و پنهان تغییر
+ پیدا کرد"
+ unrevealed: "[%{app_name}] وضعیت اثر شما به پنهان تغییر پیدا کرد"
+ unrevealed_info: آثار پنهان آثاری هستند که در لیست های تگ و صفحه ی آثار شما
+ ظاهر نمی شوند. هرکسی که لینکی را به این اثر دنبال کند یک اطلاعیه دریافت خواهد
+ کرد که این اثر در حال حاضر پنهان است و آنها نخواهند توانست که به محتوای آن
+ دست یابند.
+ challenge_assignment_notification:
+ any: هرچیزی
+ assignment:
+ html: درخواست ذیل در چالش %{link} در AO3 به شما محول شده است!
+ description: 'شرح:'
+ due: 'موعد انجام این وظیفه تا:'
+ html:
+ footer: شما این ایمیل را به دلیل ثبت نام در چالش %{title} دریافت کرده اید.
+ برای اطلاعات بیشتر در مورد این چالش و اطلاعات تماس مدیران، لطفا به %{footer_link}
+ سری بزنید.
+ footer_link: صفحه ی پروفایل چالش
+ look_up: شما می توانید این وظیفه را در %{link} جست و جو کنید.
+ look_up_link: پیج Assignments (وظایف) خود
+ optional_tags: 'تگهای اختیاری:'
+ prompts: 'درخواستها:'
+ prompt_url: 'آدرس اینترنتی درخواست:'
+ recipient: 'گیرنده:'
+ recipient_missing: 'هیچکس: برای کمک با یک مدیر تماس بگیرید!'
+ subject: "[%{app_name}][%{collection_title}] وظیفه شما!"
+ text:
+ assignment: درخواست ذیل در چالش "%{collection_title}" (%{collection_url})
+ در AO3 به شما محول شده است!
+ footer: شما این ایمیل را به دلیل ثبت نام در چالش %{title} (%{url}) دریافت
+ کرده اید. برای اطلاعات بیشتر در مورد این چالش و اطلاعات تماس مدیران لطفا
+ %{profile_url} را ببینید.
+ look_up: شما می توانید این وظیفه را در پیج Assignments (وظایف) خود در %{link}
+ جست و جو کنید.
+ change_email:
+ changed:
+ html: "%{login}، ایمیل مرتبط با اکانت شما به ایمیل %{email} تغییر یافته است."
+ text: "%{login}، ایمیل مرتبط با اکانت شما به ایمیل %{email} تغییر یافته است."
+ subject: "[%{app_name}] تغییر ایمیل"
+ collection_notification:
+ assignments_sent:
+ complete: اکنون تمامی وظایف فرستاده شده اند.
+ subject: وظایف فرستاده شده
+ html:
+ received_message: 'شما پیامی راجع به مجموعه تان %{collection_link} دریافت
+ کرده اید:'
+ text:
+ received_message: 'شما پیامی راجع به مجموعه تان "%{collection_title}" (%{collection_url})
+ دریافت کرده اید:'
+ creatorship_notification:
+ explanation: زمانی که شما تهیه کننده ی همکار یک اثر هستید، صرف نظر از تنظیمات
+ همکاریتان، می توانید به فصل های جدید اثر اضافه شوید. شما همچنین به سری هایی
+ که این اثر به آنها اضافه می شود پیوست خواهید شد.
+ html:
+ creation: "%{creation_link} توسط %{pseud_links}"
+ edit_chapter: فصل را ویرایش کنید
+ edit_series: سری را ویرایش کنید
+ remove_chapter: اگر شما به اشتباه اضافه شده اید یا نمی خواهید به عنوان تهیه
+ کننده ذکر شوید، می توانید %{edit_chapter_link} تا خودتان را به عنوان خالق
+ حذف کنید.
+ remove_series: اگر شما به اشتباه اضافه شده اید یا نمی خواهید به عنوان تهیه
+ کننده ذکر شوید،، می توانید %{edit_series_link} تا خودتان را به عنوان خالق
+ حذف کنید.
+ intro_chapter: 'کاربر %{adding_user} نام مستعار شما %{pseud} را به عنوان تهیه
+ کننده ی همکار برای فصل ذیل لیست کرده است:'
+ intro_series: 'کاربر %{adding_user} نام مستعار شما %{pseud} را به عنوان تهیه
+ کننده ی همکار برای سری ذیل لیست کرده است:'
+ subject: "[%{app_name}] اطلاعیه تهیه کننده همکار"
+ text:
+ creation: "%{title} (%{url}) توسط %{pseuds}"
+ remove_chapter: 'اگر شما به اشتباه اضافه شده اید یا نمی خواهید به عنوان تهیه
+ کننده ذکر شوید، می توانید فصل را ویرایش کنید تا خودتان را به عنوان خالق
+ حذف کنید: %{url}'
+ remove_series: 'اگر شما به اشتباه اضافه شده اید یا نمی خواهید به عنوان تهیه
+ کننده ذکر شوید، می توانید سری را ویرایش کنید تا خودتان را به عنوان خالق
+ حذف کنید: %{url}'
+ creatorship_notification_archivist:
+ explanation: به خاطر اینکه ایشان در حوزه ی رسمی خود به عنوان یک بایگان Open
+ Doors (درهای باز) فعالیت می کنند، اجازه دارند که شما را بدون هیچ درخواستی
+ اضافه کنند، حتی اگر شما تهیه کنندگی همکار خود را غیرفعال کرده باشید.
+ html:
+ creation: "%{creation_link} توسط %{pseud_links}"
+ edit_chapter: فصل را ویرایش کنید
+ edit_series: سری را ویرایش کنید
+ edit_work: اثر را ویرایش کنید
+ remove_chapter: اگر شما به اشتباه اضافه شده اید یا نمی خواهید که به عنوان
+ تهیه کننده ذکر شوید، می توانید %{edit_chapter_link} تا خودتان را به عنوان
+ خالق حذف کنید.
+ remove_series: اگر شما به اشتباه اضافه شده اید یا نمی خواهید که به عنوان تهیه
+ کننده ذکر شوید، می توانید %{edit_series_link} تا خودتان را به عنوان خالق
+ حذف کنید.
+ remove_work: اگر شما به اشتباه اضافه شده اید یا نمی خواهید که به عنوان تهیه
+ کننده ذکر شوید، می توانید %{edit_work_link} تا خودتان را به عنوان خالق حذف
+ کنید.
+ intro_chapter: 'کاربر %{archivist} نام مستعار شما %{pseud} را به عنوان تهیه
+ کننده ی همکار برای فصل ذیل اضافه کرده است:'
+ intro_series: 'کاربر %{archivist} نام مستعار شما %{pseud} را به عنوان تهیه کننده
+ ی همکار برای سری ذیل اضافه کرده است:'
+ intro_work: 'کاربر %{archivist} نام مستعار شما %{pseud} را به عنوان تهیه کننده
+ ی همکار برای اثر ذیل اضافه کرده است:'
+ subject: "[%{app_name}] اطلاعیه ی تهیه کننده ی همکار بایگان"
+ text:
+ creation: "%{title} (%{url}) توسط %{pseuds}"
+ remove_chapter: 'اگر شما به اشتباه اضافه شده اید یا نمی خواهید که به عنوان
+ تهیه کننده ذکر شوید، می توانید فصل را ویرایش کنید تا خودتان را به عنوان
+ خالق حذف کنید: %{url}'
+ remove_series: 'اگر شما به اشتباه اضافه شده اید یا نمی خواهید که به عنوان
+ تهیه کننده ذکر شوید، می توانید سری را ویرایش کنید تا خودتان را به عنوان
+ خالق حذف کنید: %{url}'
+ remove_work: 'اگر شما به اشتباه اضافه شده اید یا نمی خواهید که به عنوان تهیه
+ کننده ذکر شوید، می توانید اثر را ویرایش کنید تا خودتان را به عنوان خالق
+ حذف کنید: %{url}'
+ creatorship_request:
+ html:
+ creation: "%{creation_link} توسط %{pseud_links}"
+ instructions: شما می توانید این درخواست را در صفحه ی %{page_name} تان پذیرفته
+ یا رد کنید.
+ page_name: Co-Creator Requests (درخواست های پدیدآورنده ی همکار)
+ intro_chapter: 'کاربر %{inviting_user} نام مستعار شما، %{pseud}، را برای لیست
+ شدن به عنوان پدیدآورنده ی همکار در فصل زیر دعوت کرده است:'
+ intro_series: 'کاربر %{inviting_user} نام مستعار شما، %{pseud}، را برای لیست
+ شدن به عنوان پدیدآورنده ی همکار در سری زیر دعوت کرده است:'
+ intro_work: 'کاربر %{inviting_user} نام مستعار شما، %{pseud}، را برای لیست شدن
+ به عنوان پدیدآورنده ی همکار در اثر زیر دعوت کرده است:'
+ subject: "[%{app_name}] درخواست پدیدآورنده ی همکار"
+ text:
+ creation: "%{title} (%{url}) توسط %{pseuds}"
+ instructions: 'شما می توانید این درخواست را در صفحه ی Co-Creator Requests
+ (درخواست های پدیدآورنده ی همکار) تان پذیرفته یا رد کنید: %{url}'
+ delete_work_notification:
+ attachment: یک کپی از اثر شما برای اطلاعتان به این ایمیل ضمیمه شده است.
+ deleted_other:
+ html: اثر شما %{title} به درخواست %{pseud} حذف شد.
+ text: اثر شما "%{title}" به درخواست %{pseud} حذف شد.
+ deleted_yourself:
+ html: اثر شما %{title} به درخواست خودتان حذف شد.
+ text: اثر شما "%{title}" به درخواست خودتان حذف شد.
+ questions:
+ html: اگر سوالی دارید لطفا %{support}.
+ text: اگر سوالی دارید لطفا %{support} (%{url}).
+ subject: "[%{app_name}] اثر شما حذف شده است"
+ support: با پشتیبانی تماس بگیرید
+ invitation:
+ subject: "[%{app_name}] دعوتنامه"
+ invitation_to_claim:
+ access:
+ html: بسته به آرشیو، آثار شما ممکن است به صورت دسترسی محدود فقط برای کاربران
+ ثبت شده وارد شده باشند (تا خارج از جست و جوهای گوگل نگه داشته شوند). اگر
+ این حالت پیش آمده، آثار فقط برای کاربرانی که لاگین (وارد سایت) شده اند قابل
+ دسترسی خواهند بود، مگر اینکه شما تصمیم بگیرید که آنها را کاملا آشکار کنید.
+ برای کمک گرفتن در زمینه ی بازگشایی، بی مؤلف کردن و یا حذف آثارتان لطفا %{contact_support_link}.
+ text: بسته به آرشیو، آثار شما ممکن است به صورت دسترسی محدود فقط برای کاربران
+ ثبت شده وارد شده باشند (تا خارج از جست و جوهای گوگل نگه داشته شوند). اگر
+ این حالت پیش آمده، آثار فقط برای کاربرانی که لاگین (وارد سایت) شده اند قابل
+ دسترسی خواهد بود؛ مگر اینکه شما تصمیم بگیرید که آنها را کاملا آشکار کنید.
+ برای کمک گرفتن در زمینه ی بازگشایی، بی سرپرست کردن و یا حذف آثارتان لطفا
+ با پشتیبانی AO3 تماس بگیرید.
+ claim_or_remove:
+ html: آثارتان را در اینجا مطالبه یا حذف کنید.
+ text: 'آثارتان را در اینجا مطالبه یا حذف کنید: %{claim_url} '
+ email_tips: اگر در حال تماس گرفتن با ما هستید لطفا ایمیل آدرسهایی از طرف @transformativeworks.org
+ را در لیست سفید خود قرار دهید و فولدرهای اسپم (پوشه های هرزنامه) خود را چک
+ کنید.
+ html:
+ ao3_news: اخبار AO3
+ contact_open_doors: با درهای باز تماس بگیرید
+ contact_support: با پشتیبانی AO3 تماس بگیرید
+ faq_page: صفحه ی سوالات متداول
+ tutorial_page: صفحه ی آموزش
+ introduction:
+ html: شما به این علت این ایمیل را دریافت کرده اید چون اخیرا یک آرشیو توسط
+ %{open_doors_name_link} در %{app_link} (%{app_short_name}) وارد شده است
+ و ما فکر می کنیم که آثار ذیل متعلق به شما هستند. ما می خواهیم به شما این
+ فرصت را بدهیم که اگر تمایل دارید مالکیت این آثار را بر عهده بگیرید (یا آنها
+ را بی مؤلف کرده یا پاک کنید). و اگر هم اکنون حساب کاربری تحت ایمیل دیگری
+ ندارید ما علاقه مندیم که شما را دعوت کنیم تا به ما بپیوندید!
+ text: شما به این علت این ایمیل را دریافت کرده اید چون اخیرا یک آرشیو توسط
+ Open Doors (درهای باز) (%{open_doors_link}) در %{app_name} (%{app_short_name}
+ - %{app_url}) وارد شده است و ما فکر می کنیم که آثار ذیل متعلق به شما هستند.
+ ما می خواهیم به شما این فرصت را بدهیم که اگر تمایل دارید مالکیت این آثار
+ را بر عهده بگیرید (یا آنها را بی سرپرست کرده یا پاک کنید). و اگر هم اکنون
+ حساب کاربری تحت ایمیل دیگری ندارید ما علاقه مندیم که شما را دعوت کنیم تا
+ به ما بپیوندید!
+ mistake:
+ html: اگر اشتباهی صورت گرفته و این آثار از آن شما نیستند، لطفا آنها را پاک
+ نکنید! لطفا فقط %{contact_open_doors_link} و ما خودمان بقیه اش را حل می
+ کنیم.
+ text: اگر اشتباهی صورت گرفته و این آثار از آن شما نیستند لطفا آنها را پاک
+ نکنید! لطفا فقط با درهای باز تماس بگیرید (%{open_doors_link}) و ما خودمان
+ بقیه اش را حل می کنیم.
+ more_info:
+ html: شما می توانید اعلانیه های مربوط به انتقال آرشیوهای اخیر را در %{ao3_news_link}
+ بخوانید و اطلاعات بیشتر را در %{faq_page_link} یا %{tutorial_page_link}
+ Open Doors (درهای باز) بیابید. برای هرگونه سوالی که در سوالات متداول، آموزشها
+ یا این ایمیل به آن پاسخ داده نشده، لطفا %{contact_support_link}.
+ text: شما می توانید اعلانیه های مربوط به انتقال آرشیو اخیر را در اخبار AO3
+ (%{news_link}) بخوانید و اطلاعات بیشتر را در صفحه ی سوالات متداول (%{open_doors_faq_link})
+ یا صفحه ی آموزشهای (%{open_doors_tutorial_link}) Open Doors (درهای باز)
+ بیابید. برای هرگونه سوالی که در سوالات متداول، آموزشها یا این ایمیل به آن
+ پاسخ داده نشده لطفا با پشتیبانی در %{support_link} تماس بگیرید.
+ other_works:
+ html: اگر آثار دیگری بر روی آرشیو وارد شده داشته اید که تحت ایمیلی بوده اند
+ که دیگر به آن دسترسی ندارید، لطفا با هرگونه اطلاعاتی که به ما کمک می کند
+ تا هویت شما را شناسایی کنیم %{contact_open_doors_link}.
+ text: اگر آثار دیگری بر روی آرشیو وارد شده داشته اید که تحت ایمیلی بوده اند
+ که دیگر به آن دسترسی ندارید، لطفا با هرگونه اطلاعاتی که به ما کمک می کند
+ تا هویت شما را شناسایی کنیم با Open Doors (درهای باز) تماس بگیرید.
+ questions:
+ html: برای سوالات دیگر لطفا %{contact_support_link}.
+ text: برای سوالات دیگر لطفا با پشتیبانی AO3 در %{support_link} تماس بگیرید.
+ redirects: برای اینکه لیست پیشنهادات و بوکمارکها حفظ شوند آدرسهای وبسایت آرشیو
+ وارداتی ممکن است برای مدت محدودی به کپی وارداتی این آثار هدایت شوند (پست اعلامیه
+ را در مورد آرشیوتان بررسی کنید تا مطمئن شوید). اگر کپی ای از این آثار را آپلود
+ کرده اید ولی از ویژگی وارد کردن با استفاده از URL (آدرس اینترنتی) اینکار را
+ انجام ندادید در آن واحد دو کپی از یک اثر یکسان بر روی آرشیو خواهد بود.
+ subject: "[%{app_name}] دعوتنامه برای مطالبه ی آثار"
+ unwanted:
+ html: اگر این آثار متعلق به شما هستند ولی آنها را نمی خواهید، می توانید آنها
+ را بی مؤلف کنید (تا اینکه بر روی سایت AO3 باقی بمانند ولی بدون اسم شما)
+ یا آنها را حذف کنید (تا اینکه کاملا از روی AO3 پاک شوند). برای مؤلف کردن
+ نیازی به اضافه کردن این آثار به هیچ اکانتی یا حذف کردن آنها وجود ندارد--شما
+ می توانید این کار را مستقیما با استفاده از لینک مطالبه در بالا انجام دهید.
+ (برای کمک لطفا %{contact_support_link}.)
+ text: اگر این آثار متعلق به شما هستند ولی آنها را نمی خواهید، می توانید آنها
+ را بی سرپرست کنید (تا اینکه بر روی سایت AO3 باقی بمانند ولی بدون اسم شما)
+ یا آنها را حذف کنید (تا اینکه کاملا از روی AO3 پاک شوند). برای بی سرپرست
+ کردن نیازی به اضافه کردن این آثار به هیچ اکانتی یا حذف کردن آنها وجود ندارد--شما
+ می توانید این کار را مستقیما با استفاده از لینک مطالبه در بالا انجام دهید.
+ (برای کمک لطفا با پشتیبانی در %{support_link} تماس بگیرید.)
+ update_redirect:
+ html: اگر مایلید که Open Doors (درهای باز) آدرسی که دیگران به آن هدایت می
+ شوند را به کارهای سابق شما آپدیت کند، لطفا کپی وارداتی را حذف کرده و با
+ نام اکانت AO3 خود، نام اکانتتان بر روی آرشیو وارداتی و عنوان و آدرس URL
+ اثر هوادارانه ای که مد نظر است با %{contact_open_doors_link}. (اگر می خواهید
+ این تغییر را برای چندین اثر انجام دهید می توانید آنها را در یک ایمیل لیست
+ کنید.)
+ text: اگر مایلید که Open Doors (درهای باز) آدرسی که دیگران به آن هدایت می
+ شوند را به کارهای سابق شما آپدیت کند، لطفا کپی وارداتی را حذف کرده و با
+ نام اکانت AO3 خود، نام اکانتتان بر روی آرشیو وارداتی و عنوان و آدرس URL
+ اثر هوادارانه ای که مد نظر است با Open Doors (درهای باز) در %{open_doors_link}
+ تماس بگیرید. (اگر شما می خواهید این تغییر را برای چندین اثر انجام دهید می
+ توانید آنها را در یک ایمیل لیست کنید.)
+ uploaded_list: 'آثاری که آپلود شده اند عبارتند از:'
+ invite_increase_notification:
+ invitation_page_link_text: صفحه ی Invitations (دعوتنامه ها) شما
+ subject: "[%{app_name}] دعوتنامه های جدید"
+ invite_request_declined:
+ main:
+ one: ما ناراحتیم از اینکه به شما اطلاع می دهیم که درخواست شما برای یک دعوتنامه
+ ی جدید در حال حاضر قابل عملی شدن نیست.
+ other: ما ناراحتیم از اینکه به شما اطلاع می دهیم که درخواست شما برای %{count}
+ دعوتنامه ی جدید در حال حاضر قابل عملی شدن نیست.
+ reason: 'درخواست شما این بود که:'
+ subject: "[%{app_name}] درخواست کد دعوتنامه ی اضافی رد شد"
+ recipient_notification:
+ html:
+ collection: یک اثر هدیه برای شما در مجموعه ی %{collection_link} در AO3 پست
+ شده است!
+ no_collection: یک اثر هدیه برای شما در AO3 پست شده است!
+ subject:
+ collection: "[%{app_name}][%{collection_title}] یک اثر هدیه برای شما از %{collection_title}"
+ no_collection: "[%{app_name}] یک اثر هدیه برای شما"
+ text:
+ collection: یک اثر هدیه برای شما در مجموعه ی "%{collection_title}" (%{collection_url})
+ در AO3 پست شده است!
+ signup_notification:
+ activate:
+ html: لطفا %{activate_account_link}.
+ text: 'لطفا این لینک را برای فعالسازی اکانت خود دنبال کنید: %{activate_account_url}'
+ activate_your_account: این لینک را برای فعالسازی اکانت خود دنبال کنید
+ admin_posts: اخبار AO3
+ bye: ما امیدواریم که شما از استفاده از آرشیو لذت ببرید.
+ contact_support: با پشتیبانی تماس بگیرید
+ faq: سوالات متداول
+ features:
+ html: 'زمانی که اکانت شما فعال و درحال استفاده باشد، شما می توانید: آثار هوادارانه
+ ی تان را پست کنید، اشتراک های ایمیل ایجاد کنید تا هنگامی که خالقان یا آثار
+ مورد علاقه شما آپدیت شدند مطلع شوید، اولویت هایی را برای تغییر چگونگی ظاهر
+ و کار کردن سایت برای خودتان تعیین کنید، با استفاده از تاریخچه آثاری که به
+ آنها سر زده اید را پیگیری کنید و بسیاری موارد دیگر.'
+ text: 'زمانی که اکانت شما فعال و درحال استفاده باشد، شما می توانید: آثار هوادارانه
+ ی تان را پست کنید، اشتراک های ایمیل ایجاد کنید تا هنگامی که خالقان یا آثار
+ مورد علاقه شما آپدیت شدند مطلع شوید، اولویت هایی را برای تغییر چگونگی ظاهر
+ و کار کردن سایت برای خودتان تعیین کنید، با استفاده از تاریخچه آثاری که به
+ آنها سر زده اید را پیگیری کنید و بسیاری موارد دیگر.'
+ information:
+ html: اطلاعات و توصیه های زیادی راجع به اینکه چگونه از آرشیو استفاده کنید
+ در %{faq_link} ما وجود دارد. شما آخرین اخبار درباره ی پیشرفت های وبسایت
+ را در %{admin_posts_link} ما خواهید یافت. اگر شما به کمک بیشتر نیاز دارید،
+ به یک باگ برخورد کرده اید یا سوالات و نظراتی دارید لطفا %{contact_support_link}،
+ که همواره مشتاقند کمک کنند.
+ text: 'اطلاعات و توصیه های زیادی راجع به اینکه چگونه از آرشیو استفاده کنید
+ در سوالات متداول ما در %{faq_url} وجود دارد. شما آخرین اخبار درباره ی پیشرفت
+ های وبسایت را در اخبار AO3 در %{admin_posts_url} خواهید یافت. اگر شما به
+ کمک بیشتر نیاز دارید، به یک باگ برخورد کرده اید یا سوالات و نظراتی دارید
+ لطفا با تیم پشتیبانی ما تماس بگیرید، که همواره مشتاقند کمک کنند: %{contact_support_url}.
+
+ '
+ subject: اکانتتان را فعال کنید [%{app_name}]
+ welcome: "%{login}، به آرشیوی از آن خودمان خوش آمدید!"
diff --git a/config/locales/phrase-exports/fi.yml b/config/locales/phrase-exports/fi.yml
new file mode 100755
index 0000000..6afe9fa
--- /dev/null
+++ b/config/locales/phrase-exports/fi.yml
@@ -0,0 +1,598 @@
+---
+fi:
+ activerecord:
+ attributes:
+ archive_warning:
+ name_with_colon:
+ one: 'Varoitus:'
+ other: 'Varoitukset:'
+ category:
+ name_with_colon:
+ one: 'Kategoria:'
+ other: 'Kategoriat:'
+ character:
+ name_with_colon:
+ one: 'Hahmo:'
+ other: 'Hahmot:'
+ fandom:
+ name_with_colon:
+ one: 'Fandom:'
+ other: 'Fandomit:'
+ freeform:
+ name_with_colon:
+ one: 'Muu avainsana:'
+ other: 'Muut avainsanat:'
+ rating:
+ name_with_colon: 'Luokitus:'
+ relationship:
+ name_with_colon:
+ one: 'Suhde:'
+ other: 'Suhteet:'
+ work:
+ chapter_total_display: Lukuja
+ summary: Tiivistelmä
+ models:
+ archive_warning:
+ one: Varoitus
+ other: Varoitukset
+ category:
+ one: Kategoria
+ other: Kategoriat
+ chapter:
+ one: Luku
+ other: Lukuja
+ character:
+ one: Hahmo
+ other: Hahmot
+ fandom:
+ one: Fandom
+ other: Fandomit
+ freeform:
+ one: Muu avainsana
+ other: Muut avainsanat
+ rating:
+ one: Luokitus
+ other: Luokitukset
+ relationship:
+ one: Suhde
+ other: Suhteet
+ series:
+ one: Sarja
+ other: Sarjat
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ one: vieras
+ other: "%{count} vierasta"
+ left_kudos:
+ html:
+ one: "%{givers_list} kehui teosta %{commentable_link}."
+ other: "%{givers_list} kehuivat teosta %{commentable_link}."
+ text:
+ one: "%{givers_list} kehui teosta %{commentable_title} (%{commentable_url})."
+ other: "%{givers_list} kehuivat teosta %{commentable_title} (%{commentable_url})."
+ single_guest:
+ giver: Vieras
+ html: "%{giver} kehui teosta %{commentable_link}."
+ text: Vieras kehui teosta %{commentable_title} (%{commentable_url}).
+ subject: "[%{app_name}] Sinulle on kehuja!"
+ mailer:
+ general:
+ closing:
+ formal: Terveisin,
+ informal: Parhain terveisin,
+ creation:
+ link_with_word_count: "%{creation_link} (%{word_count})"
+ title_with_chapter_number: "%{title}-teoksen luku %{position}"
+ title_with_word_count: '"%{creation_title}" (%{word_count})'
+ word_count:
+ one: "%{count} sana"
+ other: "%{count} sanaa"
+ footer:
+ general:
+ about:
+ html: Archive of Our Own (Oma arkisto) on fanien pyörittämä ja tukema
+ arkisto, joka toimii %{donate_link} varassa.
+ text: 'Archive of Our Own (Oma arkisto) on fanien pyörittämä ja tukema
+ arkisto, joka toimii lahjoitustenne varassa: %{donate_url}.'
+ html:
+ donate_link_text: lahjoitustenne
+ support_link_text: ota yhteyttä Käyttäjätukeen
+ unwanted_email:
+ html: Jos olet saanut tämän viestin virheellisesti, ole hyvä ja %{support_link}.
+ text: Jos olet saanut tämän viestin virheellisesti, ole hyvä ja ota yhteyttä
+ Käyttäjätukeen osoitteessa %{support_url}.
+ sent_at: Lähetetty %{sent_at}.
+ greeting:
+ formal_html: Hei %{name},
+ informal:
+ addressed_html: Hei, %{name}!
+ unaddressed: Hei!
+ introductory: Tervehdys Archive of Our Own – AO3:lta (Oma arkisto)!
+ metadata_label_indicator: ":"
+ signature:
+ abuse_team: AO3 Säännöt ja väärinkäytökset -toimikunta
+ app_short_name: AO3
+ open_doors: Open Doors (Avoimet ovet) -tiimi
+ parent_org: Organization for Transformative Works – OTW (Transformatiivisten
+ teosten järjestö)
+ support: AO3 Käyttäjätukitiimi
+ users:
+ mailer:
+ reset_password_instructions:
+ expiration: Jos et käytä tätä linkkiä salasanasi vaihtamiseen viikon kuluessa,
+ se vanhenee, ja sinun on pyydettävä uusi linkki.
+ intro: 'Tilisi salasana on pyydetty asettamaan uudelleen. Voit vaihtaa salasanan
+ seuraamalla alla olevaa linkkiä ja syöttämällä uuden salasanasi:'
+ link_title: Vaihda salasanani.
+ subject: "[%{app_name}] Aseta uusi salasana"
+ unrequested: Jos et pyytänyt salasanan uudelleen asetusta, voit jättää tämän
+ viestin huomioimatta, ja vanha salasanasi pysyy voimassa.
+ user_mailer:
+ admin_deleted_work_notification:
+ bye: Tähän viestiin on liitetty tiedoksesi kopio teoksestasi.
+ contact_abuse: ota yhteyttä Säännöt ja väärinkäytökset -toimikuntaan
+ deleted:
+ html: Sivuston ylläpitäjä on poistanut teoksesi %{title} AO3:sta.
+ text: Sivuston ylläpitäjä on poistanut teoksesi "%{title}" AO3:sta.
+ html:
+ tos_violation: Jos teoksesi saattoi rikkoa AO3:n Käyttöehtoja, ole hyvä ja
+ %{contact_abuse_link}.
+ import_project:
+ html: Jos teoksesi kuului Open Doors (Avoimet ovet) -toimikunnan tuomaan projektiin,
+ ole hyvä ja %{opendoors_link}, jos sinulla on kysymyksiä.
+ text: Jos teoksesi kuului Open Doors (Avoimet ovet) -toimikunnan tuomaan projektiin,
+ ole hyvä ja ota yhteyttä Open Doors -toimikuntaan (%{opendoors_link}), jos
+ sinulla on kysymyksiä.
+ opendoors: ota yhteyttä Open Doors -toimikuntaan
+ subject: "[%{app_name}] Ylläpitäjä on poistanut teoksesi"
+ text:
+ tos_violation: Jos teoksesi saattoi rikkoa AO3:n Käyttöehtoja, ole hyvä ja
+ ota yhteyttä Säännöt ja väärinkäytökset -toimikuntaan (%{contact_abuse_url}).
+ admin_hidden_work_notification:
+ access: Teoksen ollessa piilotettuna voit tarkastella sitä yllä olevan linkin
+ kautta, mutta se ei näy teokset-sivullasi eikä se ole muiden AO3-käyttäjien
+ saatavilla.
+ check_email: Tarkistathan sähköpostisi, myös roskapostikansion, sillä Säännöt
+ ja väärinkäytökset -toimikunta on saattanut jo ottaa yhteyttä ja selittää,
+ miksi teoksesi on piilotettu.
+ contact_abuse: ota yhteyttä Säännöt ja väärinkäytökset -toimikuntaan
+ html:
+ help: Mikäli olet epävarma siitä, miksi teoksesi piilotettiin, etkä ole saanut
+ yhteydenottoa asiaan liittyen, %{contact_abuse_link}.
+ hidden: Säännöt ja väärinkäytökset -toimikunta on piilottanut teoksesi %{title},
+ eikä se ole enää julkisesti saatavilla.
+ tos_violation: Mikäli teoksesi piilotettiin siksi, että se rikkoi AO3:n %{tos_link},
+ sinun tulee toimia rikkeen korjaamiseksi. Mikäli et muuta teostasi Käyttöehtojen
+ mukaiseksi, se saatetaan poistaa AO3:sta.
+ subject: "[%{app_name}] Säännöt ja väärinkäytökset -toimikunta on piilottanut
+ teoksesi"
+ text:
+ help: 'Mikäli olet epävarma siitä, miksi teoksesi piilotettiin, etkä ole saanut
+ yhteydenottoa asiaan liittyen, ota yhteyttä Säännöt ja väärinkäytökset -toimikuntaan:
+ %{contact_abuse_url}.'
+ hidden: Säännöt ja rikkomukset -toimikunta on piilottanut teoksesi “%{title}"
+ (%{work_url}) eikä se ole enää julkisesti saatavilla.
+ tos_violation: Mikäli teoksesi piilotettiin siksi, että se rikkoi AO3:n Käyttöehtoja
+ (%{tos_url}), sinun tulee toimia rikkeen korjaamiseksi. Mikäli et muuta
+ teostasi Käyttöehtojen mukaiseksi, se saatetaan poistaa AO3:sta.
+ tos: Käyttöehtoja
+ anonymous_or_unrevealed_notification:
+ anonymous_info: Anonyymit teokset näkyvät teoslistauksissa mutta eivät omalla
+ teossivullasi. Teoksessa käyttäjänimesi tilalla lukee “Anonymous” (anonyymi).
+ anonymous_unrevealed_info: Kokoelman ylläpitäjät saattavat myöhemmin paljastaa
+ teoksesi mutta jättää sen anonyymiksi. Teoksiesi ilmoitukset tilanneet eivät
+ saa ilmoitusta tästä muutoksesta. Paljastamisen jälkeen teoksesi näkyy avainsanalistauksissa
+ mutta ei teossivullasi. Teoksessa käyttäjänimesi tilalla lukee “Anonymous”
+ (anonyymi).
+ changed_status:
+ anonymous:
+ html: Kokoelman %{collection_link} ylläpitäjät ovat muuttaneet teoksesi
+ %{work_link} tilan anonyymiksi.
+ text: Kokoelman "%{collection_title}" (%{collection_url}) ylläpitäjät ovat
+ muuttaneet teoksesi "%{work_title}" (%{work_url}) tilan anonyymiksi.
+ anonymous_unrevealed:
+ html: Kokoelman %{collection_link} ylläpitäjät ovat muuttaneet teoksesi
+ %{work_link} tilan anonyymiksi ja paljastamattomaksi.
+ text: Kokoelman "%{collection_title}" (%{collection_url}) ylläpitäjät ovat
+ muuttaneet teoksesi "%{work_title}" (%{work_url}) tilan anonyymiksi ja
+ paljastamattomaksi.
+ unrevealed:
+ html: Kokoelman %{collection_link} ylläpitäjät ovat muuttaneet teoksesi
+ %{work_link} tilan paljastattomaksi.
+ text: Kokoelman "%{collection_title}" (%{collection_url}) ylläpitäjät ovat
+ muuttaneet teoksesi "%{work_title}" (%{work_url}) tilan paljastamattomaksi.
+ collection_items_link_text: Approved Collection Items (Hyväksytyt kokoelmateokset)
+ -sivullasi
+ do_not_want:
+ anonymous:
+ html: Jos et halua teoksesi olevan anonyymi, käy %{collection_items_link}
+ poistaaksesi sen tästä kokoelmasta.
+ text: 'Jos et halua teoksesi olevan anonyymi, käy Approved Collection Items
+ (Hyväksytyt kokoelmateokset) -sivullasi poistaaksesi sen tästä kokoelmasta:
+ %{collection_items_url}'
+ anonymous_unrevealed:
+ html: Jos et halua teoksesi olevan anonyymi ja paljastamaton, käy %{collection_items_link}
+ poistaaksesi sen tästä kokoelmasta.
+ text: 'Jos et halua teoksesi olevan anonyymi ja paljastamaton, käy Approved
+ Collection Items (Hyväksytyt kokoelmateokset) -sivullasi poistaaksesi
+ sen tästä kokoelmasta: %{collection_items_url}'
+ unrevealed:
+ html: Jos et halua teoksesi olevan paljastamaton, käy %{collection_items_link}
+ poistaaksesi sen tästä kokoelmasta.
+ text: 'Jos et halua teoksesi olevan paljastamaton, käy Approved Collection
+ Items (Hyväksytyt kokoelmateokset) -sivullasi poistaaksesi sen tästä kokoelmasta:
+ %{collection_items_url}'
+ faq_link_text: Kokoelmat-UKK:sta
+ more_info:
+ html: Lisätietoa %{faq_link}.
+ text: 'Lisätietoa Kokoelmat-UKK:sta: %{faq_url}'
+ subject:
+ anonymous: "[%{app_name}] Teoksestasi on tehty anonyymi"
+ anonymous_unrevealed: "[%{app_name}] Teoksestasi tehtiin anonyymi ja paljastamaton"
+ unrevealed: "[%{app_name}] Teoksesi tehtiin paljastamattomaksi"
+ unrevealed_info: Paljastamattomat teokset eivät näy avainsanalistauksissa tai
+ teossivullasi. Kaikki, jotka pääsevät teokseen linkin kautta, saavat ilmoituksen,
+ että se on toistaiseksi paljastamaton eivätkä pääse käsiksi sen sisältöön.
+ archivist_added_to_collection_notification:
+ approved_collection_items_page: Approved Collection Items (Hyväksytyt kokoelmateokset)
+ archivist_notice: Koska arkiston ylläpitäjät toimivat virallisissa rooleissaan
+ Open Doors (Avoimet ovet) -arkistonhoitajina, heillä on lupa lisätä teoksesi
+ tähän kokoelmaan vaikka olisitkin asettanut kokoelmakutsut pois päältä. Arkistonhoitajat
+ lisäävät teoksen kokoelmaan vain jos se oli tuodussa arkistossa.
+ removal_instructions:
+ html: Jos haluat poistaa teoksesi tästä kokoelmasta, mene %{approved_items_link}
+ -sivullesi.
+ text: 'Jos haluat poistaa teoksesi tästä kokoelmasta, mene Approved Collection
+ Items (Hyväksytyt kokoelmateokset) -sivullesi: %{approved_items_url}.'
+ subject: "[%{app_name}][%{collection_title}] Open Doors (Avoimet ovet) -arkistonhoitaja
+ on lisännyt teoksesi kokoelmaan"
+ work_added:
+ html: Kokoelman %{collection_link} ylläpitäjät ovat lisänneet teoksesi %{work_link}
+ kokoelmaansa!
+ text: Kokoelman "%{collection_title}" (%{collection_url}) ylläpitäjät ovat
+ lisänneet teoksesi "%{work_title}" (%{work_url}) kokoelmaansa!
+ challenge_assignment_notification:
+ any: Mitä tahansa
+ assignment:
+ html: Sinulle on annettu toteutettavaksi pyyntö Archive of Our Ownin haasteessa
+ %{link}!
+ description: 'Kuvaus:'
+ due: 'Tehtävän on oltava valmis:'
+ html:
+ footer: Olet saanut tämän viestin koska ilmoittauduit mukaan haasteeseen %{title}.
+ Lisätietoa tästä haasteesta ja sen valvojien yhteystiedot löytyvät %{footer_link}.
+ footer_link: haasteen profiilista
+ look_up: Voit tarkastella tehtävänannon yksityiskohtia osoitteessa %{link}
+ look_up_link: Assignments (Tehtävänannot) -sivusi
+ optional_tags: 'Valinnaiset avainsanat:'
+ prompts: 'Ehdotukset:'
+ prompt_url: 'Ehdotuksen URL:'
+ recipient: 'Vastaanottaja:'
+ recipient_missing: 'Ei vastaanottajaa: ota yhteyttä valvojaan!'
+ subject: "[%{app_name}][%{collection_title}] Tehtävänantosi!"
+ text:
+ assignment: Sinulle on annettu toteutettavaksi pyyntö Archive of Our Own:n
+ haasteessa "%{collection_title}" (%{collection_url}) !
+ footer: Olet saanut tämän viestin koska ilmoittauduit mukaan haasteeseen %{title}
+ (%{url}). Lisätietoa tästä haasteesta ja sen valvojien yhteystiedot löytyvät
+ osoitteesta %{profile_url}.
+ look_up: Voit tarkastella tehtävänannon yksityiskohtia Assignments (Tehtävänannot)
+ -sivulta osoitteessa %{link}.
+ change_email:
+ changed:
+ html: "%{login}, tunnuksesi uudeksi sähköpostiosoitteeksi on asetettu %{email}"
+ text: "%{login}, tunnuksesi uudeksi sähköpostiosoitteeksi on asetettu %{email}"
+ subject: "[%{app_name}] Sähköpostiosoite muutettu"
+ claim_notification:
+ access:
+ contact_support: ota yhteyttä AO3:n Käyttäjätukeen
+ html: Arkistosta riippuen teoksesi on saatettu tuoda siten, että ne ovat näkyvillä
+ vain rekisteröityneille käyttäjille (jotta ne eivät näy Google-hauissa).
+ Jos näin on, teokset ovat saatavilla vain sisäänkirjautuneille käyttäjille,
+ jollet valitse tehdä niistä täysin näkyviä. Saadaksesi apua teostesi lukituksen
+ poistamiseen, niistä luopumiseen tai poistamiseen %{contact_support_link}
+ text: Arkistosta riippuen teoksesi on saatettu tuoda siten, että ne ovat näkyvillä
+ vain rekisteröityneille käyttäjille (jotta ne eivät näy Google-hauissa).
+ Jos näin on, teokset ovat saatavilla vain sisäänkirjautuneille käyttäjille,
+ jollet valitse tehdä niistä täysin näkyviä. Saadaksesi apua teostesi lukituksen
+ poistamiseen, niistä luopumiseen tai poistamiseen ota yhteyttä AO3:n Käyttäjätukeen
+ %{support_url}.
+ email_tips: Jos otat yhteyttä meihin, lisää @transformativeworks.org-loppuiset
+ sähköpostiosoitteet turvallisten lähettäjien listallesi ja tarkista roskapostikansiosi
+ vastauksemme varalta.
+ introduction:
+ ao3_name: Archive of Our Own – AO3:een (Oma arkisto)
+ html: Saat tämän sähköpostin koska sinulla oli teoksia faniteosarkistossa,
+ jonka %{open_doors_name_link} on tuonut %{app_link}. Koska tämä sähköpostiosoite
+ on yhdistetty tuotuun arkistoon rekisteröityyn osoitteeseen, siihen liittyvät
+ teokset (listattuna alla) on automaattisesti lisätty AO3-tilillesi.
+ open_doors_name: Open Doors (Avoimet ovet)
+ text: 'Saat tämän sähköpostin koska sinulla oli teoksia faniteosarkistossa,
+ jonka Open Doors (Avoimet ovet (%{open_doors_url}) on tuonut Archive of
+ Our Own – AO3:een (Oma arkisto): %{app_url}. Koska tämä sähköpostiosoite
+ on yhdistetty tuotuun arkistoon rekisteröityyn osoitteeseen, siihen liittyvät
+ teokset (listattuna alla) on automaattisesti lisätty AO3-tilillesi.'
+ mistake:
+ contact_open_doors: Ota yhteyttä Avoimiin oviin
+ html: Jos tämä on virhe eivätkä nämä ole teoksiasi, ethän poista niitä! %{contact_open_doors_link}
+ ja me hoidamme asian.
+ text: Jos tämä on virhe eivätkä nämä ole teoksiasi, ethän poista niitä! Ota
+ vain yhteyttä Avoimiin oviin (%{open_doors_url}) ja me hoidamme asian.
+ more_info:
+ ao3_news: AO3:n Uutisista
+ contact_support: ota yhteyttä AO3:n Käyttäjätukeen
+ faq_page: UKK-sivulta
+ html: Voit lukea ilmoituksia viimeaikaisista arkistosiirroista %{ao3_news_link}
+ ja lisätietoa saat Avoimien ovien %{faq_page_link} tai %{tutorial_page_link}.
+ Jos sinulla on kysymyksiä, joihin ei löydy vastausta UKK:sta, tutoriaaleista
+ tai tästä sähköpostista, %{contact_support_link}.
+ text: Voit lukea ilmoituksia viimeaikaisista arkistosiirroista AO3:n Uutisista
+ (%{news_url}) ja lisätietoa saat Avoimien ovien UKK-sivulta (%{open_doors_faq_url})
+ tai tutoriaalisivulta (%{open_doors_tutorial_url}). Jos sinulla on kysymyksiä,
+ joihin ei löydy vastausta UKK:sta, tutoriaaleista tai tästä sähköpostista,
+ ota yhteyttä Käyttäjätukeen %{support_url}.
+ tutorial_page: tutoriaalisivulta
+ other_works:
+ contact_open_doors: ota yhteyttä Avoimiin oviin
+ html: Jos sinulla oli muita teoksia tuodussa arkistossa sellaisen sähköpostiosoitteen
+ alla, johon sinulla ei enää ole pääsyä, %{contact_open_doors_link} ja anna
+ tietoa, joka voi auttaa identiteettisi varmistamisessa.
+ text: Jos sinulla oli muita teoksia tuodussa arkistossa sellaisen sähköpostiosoitteen
+ alla, johon sinulla ei enää ole pääsyä, ota yhteyttä Avoimiin oviin ja anna
+ tietoa, joka voi auttaa identiteettisi varmistamisessa.
+ questions:
+ contact_support: ota yhteyttä AO3:n Käyttäjätukeen
+ html: Muissa asioissa %{contact_support_link}.
+ text: 'Muissa asioissa ota yhteyttä AO3:n Käyttäjätukeen täällä: %{support_url}.'
+ redirects:
+ html: Suosituslistojen ja kirjanmerkkien säilyttämiseksi tuodun arkiston nettiosoitteet
+ saattavat uudelleenohjautua näiden teosten tuotuihin versioihin rajoitetun
+ ajan (tarkista arkistosi tuonti-ilmoitus saadaksesi varmuuden). Jos olet
+ jo julkaissut nämä teokset %{negation} käyttänyt tuo URLista -toimintoa,
+ AO3:ssa on kaksi versiota samasta teoksesta.
+ subject: "[%{app_name}] Tuotuja teoksia"
+ update_redirect:
+ contact_open_doors: ota yhteyttä Avoimiin oviin
+ html: Jos haluat Avoimien ovien päivittävän uudelleenohjauksen johtamaan jo
+ olemassa olevaan teokseesi, poista tuotu versio ja %{contact_open_doors_link}.
+ Liitä mukaan AO3-tilisi nimi, tuodun arkiston tilisi nimi sekä sen faniteoksen
+ nimi ja URL, johon haluaisit uudelleenohjauksen johtavan. (Jos sinulle on
+ useita teoksia, joihin haluaisit muuttaa uudelleenohjauksen, voit listata
+ ne kaikki samassa sähköpostissa.)
+ text: Jos haluat Avoimien ovien päivittävän uudelleenohjauksen johtamaan jo
+ olemassa olevaan teokseesi, poista tuotu versio ja ota yhteyttä Avoimiin
+ oviin %{open_doors_url}. Liitä mukaan AO3-tilisi nimi, tuodun arkiston tilisi
+ nimi sekä sen faniteoksen nimi ja URL, johon haluaisit uudelleenohjauksen
+ johtavan. (Jos sinulle on useita teoksia, joihin haluaisit muuttaa uudelleenohjauksen,
+ voit listata ne kaikki samassa sähköpostissa.)
+ works_by: Nämä teokset kirjoitettiin sähköpostin %{email} alla
+ work_info:
+ html: "%{work_link} (%{fandom})"
+ text:
+ no_fandom: "- %{work_title} %{work_url}"
+ with_fandom: "- %{work_title} %{work_url} (%{fandom})"
+ collection_notification:
+ assignments_sent:
+ complete: Kaikki tehtävänannot on nyt lähetetty.
+ subject: Tehtävänannot lähetetty
+ html:
+ received_message: 'Olet saanut kokoelmaasi %{collection_link} koskevan viestin:'
+ text:
+ received_message: 'Olet saanut kokoelmaasi "%{collection_title}" (%{collection_url})
+ koskevan viestin:'
+ creatorship_notification:
+ explanation: Teoksen kanssatekijänä sinut voidaan lisätä uusiin lukuihin riippumatta
+ tilisi kanssatekijyysasetuksista. Sinut lisätään myös kaikkiin sarjoihin,
+ joihin teos lisätään.
+ html:
+ creation: "%{creation_link}, tekijä(t) %{pseud_links}"
+ edit_chapter: muokata lukua
+ edit_series: muokata sarjaa
+ remove_chapter: Jos sinut on lisätty virheellisesti tai et halua, että sinut
+ ilmoitetaan tekijäksi, voit %{edit_chapter_link} poistaaksesi itsesi tekijöistä.
+ remove_series: Jos sinut on lisätty virheellisesti tai et halua, että sinut
+ ilmoitetaan tekijäksi, voit %{edit_series_link} poistaaksesi itsesi tekijöistä.
+ intro_chapter: 'Käyttäjä %{adding_user} on ilmoittanut nimimerkkisi %{pseud}
+ kanssatekijäksi seuraavaan lukuun:'
+ intro_series: 'Käyttäjä %{adding_user} on ilmoittanut nimimerkkisi %{pseud}
+ kanssatekijäksi seuraavaan sarjaan:'
+ subject: "[%{app_name}] Ilmoitus kanssatekijäksi lisäämisestä"
+ text:
+ creation: "%{title} (%{url}), tekijä(t) %{pseuds}"
+ remove_chapter: 'Jos sinut on lisätty virheellisesti tai et halua, että sinut
+ ilmoitetaan tekijäksi, voit muokata lukua poistaaksesi itsesi tekijöistä:
+ %{url}'
+ remove_series: 'Jos sinut on lisätty virheellisesti tai et halua, että sinut
+ ilmoitetaan tekijäksi, voit muokata sarjaa poistaaksesi itsesi tekijöistä:
+ %{url}'
+ creatorship_notification_archivist:
+ explanation: Koska hän teki näin virallisessa Open Doors (Avoimet ovet) -arkistonhoitajan
+ roolissaan, hän pystyi lisäämään sinut ilman pyyntöä, vaikka sinulla olisi
+ toiseksi tekijäksi lisääminen poissa käytöstä.
+ html:
+ creation: "%{pseud_links}: %{creation_link}"
+ edit_chapter: muokata lukua
+ edit_series: muokata sarjaa
+ edit_work: muokata teosta
+ remove_chapter: Jos sinut on lisätty virheellisesti tai et halua tulla nimetyksi
+ tekijänä, voit %{edit_chapter_link} poistaaksesi itsesi tekijöistä.
+ remove_series: Jos sinut on lisätty virheellisesti tai et halua tulla nimetyksi
+ tekijänä, voit %{edit_series_link} poistaaksesi itsesi tekijöistä.
+ remove_work: Jos haluat poistaa itsesi tekijöistä, koska sinut on lisätty
+ tekijäksi virheellisesti tai et halua olla nimettynä tekijänä, voit %{edit_work_link}
+ linkin kautta.
+ intro_chapter: 'Käyttäjä %{archivist} on lisännyt nimimerkkisi %{pseud} toiseksi
+ tekijäksi seuraavaan lukuun:'
+ intro_series: 'Käyttäjä %{archivist} on lisännyt nimimerkkisi %{pseud} toisena
+ tekijänä seuraavaan sarjaan:'
+ intro_work: 'Käyttäjä %{archivist} on lisännyt nimimerkkisi %{pseud} toiseksi
+ tekijäksi seuraavaan teokseen:'
+ subject: "[%{app_name}] Arkistonhoitajan ilmoitus toiseksi tekijäksi lisäämisestä"
+ text:
+ creation: "%{pseuds}: %{title} (%{url})"
+ remove_chapter: 'Jos sinut on lisätty tekijäksi virheellisesti tai et halua
+ tulla nimetyksi tekijänä, voit poistaa itsesi tekijänä muokkaamalla lukua:
+ %{url}'
+ remove_series: 'Jos sinut on lisätty tekijäksi virheellisesti tai et halua
+ tulla nimetyksi tekijänä, voit poistaa itsesi tekijänä muokkaamalla sarjaa:
+ %{url}'
+ remove_work: 'Jos haluat poistaa itsesi tekijöistä, koska sinut on lisätty
+ tekijäksi virheellisesti tai et halua olla nimettynä tekijänä, teos on muokattavissa
+ linkin kautta: %{url}'
+ creatorship_request:
+ html:
+ creation: "%{creation_link} tekijältä %{pseud_links}"
+ instructions: Voit hyväksyä tai hylätä kutsun %{page_name} -sivullasi.
+ page_name: Co-Creator Requests (Pyynnöt kanssatekijäksi)
+ intro_chapter: 'Käyttäjä %{inviting_user} on kutsunut nimimerkkisi %{pseud}
+ listattavaksi seuraavan luvun kanssatekijäksi:'
+ intro_series: 'Käyttäjä %{inviting_user} on kutsunut nimimerkkisi %{pseud} listattavaksi
+ seuraavan sarjan kanssatekijäksi:'
+ intro_work: 'Käyttäjä %{inviting_user} on kutsunut nimimerkkisi %{pseud} listattavaksi
+ seuraavan teoksen kanssatekijäksi:'
+ subject: "[%{app_name}] Kutsu kanssatekijäksi"
+ text:
+ creation: "%{title} (%{url}) tekijältä %{pseuds}"
+ instructions: 'Voit hyväksyä tai hylätä kutsun Co-Creator Requests (Pyynnöt
+ kanssatekijäksi) -sivullasi: %{url}'
+ delete_work_notification:
+ attachment: Kopio teoksesta on liitetty viestiin sinua varten.
+ deleted_other:
+ html: teoksesi %{title} on poistettu nimimerkin %{pseud} pyynnöstä.
+ text: teoksesi "%{title}" on poistettu nimimerkin %{pseud} pyynnöstä.
+ deleted_yourself:
+ html: teoksesi %{title} on poistettu pyynnöstäsi.
+ text: teoksesi "%{title}" on poistettu pyynnöstäsi.
+ questions:
+ html: Jos sinulla on kysyttävää, ole hyvä ja %{support}.
+ text: Jos sinulla on kysyttävää, ole hyvä ja %{support} (%{url}).
+ subject: "[%{app_name}] Teoksesi on poistettu"
+ support: ota yhteyttä Käyttäjätukeen
+ invitation_to_claim:
+ access:
+ text: Joistakin arkistoista tuodut teokset on voitu asettaa vain rekisteröityneiden
+ käyttäjien saataville (jotta ne eivät näy Google-hauissa). Siinä tapauksessa
+ vain sisäänkirjautuneet käyttäjät pääsevät niihin, ellet itse aseta niitä
+ täysin avoimiksi. Jos haluat apua teostesi avaamiseen, hylkäämiseen tai
+ poistamiseen, ole hyvä ja ota yhteyttä AO3:n Käyttäjätukeen.
+ claim_or_remove:
+ html: Ilmoita omistajuudesta tai poista teoksesi.
+ text: 'Ilmoita omistajuudesta tai poista teoksesi täällä: %{claim_url}'
+ email_tips: Jos otat meihin yhteyttä, ole hyvä ja lisää @transformativeworks.org
+ sallittujen lähettäjien listalle ja tarkista, onko vastauksemme päätynyt roskapostikansioon.
+ html:
+ ao3_news: AO3:n uutiset
+ contact_open_doors: ota yhteyttä Open Doorsiin
+ contact_support: ota yhteyttä AO3 käyttäjätukeen
+ faq_page: UKK-sivu
+ tutorial_page: ohjesivu
+ introduction:
+ text: Saat tämän sähköpostin, koska Open Doors (%{open_doors_link}) on tuonut
+ AO3:een teoksia ulkoisesta arkistosta %{app_name} (%{app_short_name} - %{app_url})
+ ja uskomme seuraavien faniteosten kuuluvan sinulle. Haluamme antaa sinulle
+ mahdollisuuden ilmoittaa, että teokset kuuluvat sinulle (tai poistaa tai
+ hylätä ne). Jos sinulla ei vielä ole tunnusta toisella sähköpostiosoitteella,
+ haluamme myös kutsua sinut mukaan!
+ mistake:
+ text: Jos olemme tehneet virheen eivätkä nämä ole sinun teoksiasi, ole hyvä
+ äläkä poista niitä! Ota yhteyttä Open Doorsiin (%{open_doors_link}), niin
+ selvitämme asian.
+ more_info:
+ text: Voit lukea viimeaikaisista arkistosiirroista AO3:n uutisista (%{news_link})
+ ja löytää enemmän tietoa Open Doorsin UKK-sivulta (%{open_doors_faq_link})
+ tai tutoriaaleista (%{open_doors_tutorial_link}). Jos sinulla on kysymyksiä,
+ joihin et löydä vastauksia UKK:sta, tutoriaaleista tai tästä sähköpostista,
+ ole hyvä ja ota yhteyttä Käyttäjätukeen (%{support_link}).
+ other_works:
+ text: Jos sinulla oli tuodussa arkistossa muita teoksia, jotka on liitetty
+ sähköpostiosoitteeseen, johon sinulla ei enää ole pääsyä, ole hyvä ja ota
+ yhteyttä Open Doorsiin mahdollisten todisteiden kanssa.
+ questions:
+ text: Jos sinulla on muuta kysyttävää, ole hyvä ja ota yhteyttä AO3:n Käyttäjätukeen
+ osoitteessa %{support_link}.
+ redirects: Siirretyn arkiston verkko-osoitteet saatetaan uudelleenohjata teosten
+ tuotuihin kopioihin rajoitetun ajan, jotta suosituslistat ja kirjanmerkit
+ toimisivat edelleen. (Tarkasta arkiston tuomisesta kertovasta uutisesta, tehdäänkö
+ näin.) Jos olet jo itse lisännyt teokset arkistoon ja ET käyttänyt tuo URL:stä
+ -toimintoa, teoksista on arkistossa kaksi kopiota.
+ subject: "[%{app_name}] Ilmoita teosten omistajuudesta"
+ unwanted:
+ text: Jos nämä teokset kuuluvat sinulle, mutta et halua niitä, voit hylätä
+ (jolloin ne jäävät AO3:een, mutta nimeäsi ei ilmoiteta niiden yhteydessä)
+ tai poistaa (jolloin ne katoavat kokonaan AO3:sta) ne. Sinun ei tarvitse
+ lisätä näitä teoksia millekään tunnukselle hylätäksesi tai poistaaksesi
+ ne–voit tehdä sen suoraan yllä olevan ilmoituslinkin kautta. (Jos haluat
+ apua, ole hyvä ja ota yhteyttä Käyttäjätukeen osoitteessa %{support_link}.)
+ update_redirect:
+ text: Jos haluat Open Doorsin päivittävän edelleenohjauksen jo lisäämääsi
+ teokseen, ole hyvä ja poista tuotu kopio ja ota yhteyttä Open Doorsiin osoitteessa
+ %{open_doors_link}. Ilmoita AO3-käyttäjätunnuksesi, tuodun arkiston käyttäjätunnuksesi
+ sekä sen faniteoksen nimi ja URL, johon haluat uudelleenohjauksen osoittavan.
+ (Jos haluat siirtää usean faniteoksen uudelleenohjaukset, voit listata ne
+ kaikki samaan sähköpostiin.)
+ uploaded_list: 'Seuraavat teokset ladattiin:'
+ invite_increase_notification:
+ html:
+ body:
+ one: Halusimme vain kertoa, että sinulla on %{count} uusi kutsu, jolla arkistoon
+ voi luoda uuden tunnuksen. Voit kutsua ystävän mukaan %{invitation_page_link}.
+ other: Halusimme vain kertoa, että sinulla on %{count} kpl uusia kutsuja,
+ joilla arkistoon voi luoda uusia tunnuksia. Voit kutsua ystäviä mukaan
+ %{invitation_page_link}.
+ invitation_page_link_text: Invitations (Kutsut) -sivullasi
+ subject: "[%{app_name}] Uusia kutsuja"
+ text:
+ body:
+ one: Halusimme vain kertoa, että sinulla on %{count} uusi kutsu, jolla arkistoon
+ voi luoda uuden tunnuksen. Voit kutsua ystävän mukaan Invitations (Kutsut)
+ -sivullasi %{invitation_page_url}.
+ other: Halusimme vain kertoa, että sinulla on %{count} kpl uusia kutsuja,
+ joilla arkistoon voi luoda uusia tunnuksia. Voit kutsua ystäviä mukaan
+ Invitations (Kutsut) -sivullasi %{invitation_page_url}.
+ invite_request_declined:
+ main:
+ one: Valitettavasti joudumme ilmoittamaan, että emme voi myöntää sinulle uutta
+ kutsua.
+ other: Valitettavasti joudumme ilmoittamaan, että emme voi myöntää sinulle
+ %{count} uutta kutsua.
+ reason: 'Pyyntösi oli:'
+ subject: "[%{app_name}] Pyyntösi ylimääräisistä kutsukoodeista on hylätty"
+ recipient_notification:
+ html:
+ collection: Sinulle lahjoitettu teos on julkaistu AO3:ssa kokoelmassa %{collection_link}!
+ no_collection: Sinulle lahjoitettu teos on julkaistu AO3:ssa!
+ subject:
+ collection: "[%{app_name}][%{collection_title}] Lahjateos sinulle kokoelmassa
+ %{collection_title}"
+ no_collection: "[%{app_name}] Sinulle on lahjoitettu teos"
+ text:
+ collection: Sinulle lahjoitettu teos on julkaistu AO3:ssa kokoelmassa "%{collection_title}",
+ jonka linkki on (%{collection_url})!
+ signup_notification:
+ activate:
+ html: Ole hyvä ja %{activate_account_link}.
+ text: 'Aktivoi tunnuksesi: %{activate_account_url}'
+ activate_your_account: käytä tätä linkkiä aktivoidaksesi käyttäjätilisi
+ admin_posts: AO3:n uutisista
+ bye: Toivomme, että nautit AO3:n käytöstä.
+ contact_support: ota yhteyttä Käyttäjätukeemme
+ faq: UKK:mme
+ features:
+ html: Kun tunnuksesi on valmis, voit julkaista faniteoksiasi, tilata suosikkitekijöidesi
+ ja -teostesi päivitykset sähköpostiisi, muuttaa sivuston ulkonäköä ja toimintoja
+ asetuksistasi, pitää kirjaa AO3:ssa avaamistasi teoksista ja paljon muuta.
+ text: Kun tunnuksesi on valmis, voit julkaista faniteoksiasi, tilata suosikkitekijöidesi
+ ja -teostesi päivitykset sähköpostiisi, muuttaa sivuston ulkonäköä ja toimintoja
+ asetuksistasi, pitää kirjaa AO3:ssa avaamistasi teoksista ja paljon muuta.
+ information:
+ html: "%{faq_link} sisältää paljon tietoa ja neuvoja AO3:n käyttöön. Uusimmat
+ tiedot sivuston muutoksista löydät %{admin_posts_link}. Jos tarvitset apua
+ tai törmäät bugiin, tai sinulla on kysymyksiä tai kommentteja, ole hyvä
+ ja %{contact_support_link}, joka auttaa aina mielellään."
+ text: UKK:mme osoitteessa %{faq_url} sisältää paljon tietoa ja neuvoja AO3:n
+ käyttöön. Uusimmat tiedot sivuston muutoksista löydät AO3:n uutisista osoitteessa
+ %{admin_posts_url}. Jos tarvitset apua tai törmäät bugiin, tai sinulla on
+ kysymyksiä tai kommentteja, ole hyvä ja ota yhteyttä Käyttäjätukeemme, joka
+ auttaa aina mielellään osoitteessa %{contact_support_url}.
+ welcome: Tervetuloa AO3:een, %{login}!
diff --git a/config/locales/phrase-exports/fil.yml b/config/locales/phrase-exports/fil.yml
new file mode 100755
index 0000000..27e9cd5
--- /dev/null
+++ b/config/locales/phrase-exports/fil.yml
@@ -0,0 +1,2 @@
+---
+fil: {}
diff --git a/config/locales/phrase-exports/fr.yml b/config/locales/phrase-exports/fr.yml
new file mode 100755
index 0000000..bb50673
--- /dev/null
+++ b/config/locales/phrase-exports/fr.yml
@@ -0,0 +1,672 @@
+---
+fr:
+ activerecord:
+ attributes:
+ archive_warning:
+ name_with_colon:
+ one: 'Avertissement :'
+ other: 'Avertissements :'
+ category:
+ name_with_colon:
+ one: 'Catégorie :'
+ other: 'Catégories :'
+ character:
+ name_with_colon:
+ one: 'Personnage :'
+ other: 'Personnages :'
+ fandom:
+ name_with_colon:
+ one: 'Fandom :'
+ other: 'Fandoms :'
+ freeform:
+ name_with_colon:
+ one: 'Tag supplémentaire :'
+ other: 'Tags supplémentaires :'
+ rating:
+ name_with_colon: 'Classification :'
+ relationship:
+ name_with_colon:
+ one: 'Relation :'
+ other: 'Relations :'
+ work:
+ chapter_total_display: Chapitres
+ summary: Résumé
+ models:
+ archive_warning:
+ one: Avertissement
+ other: Avertissements
+ category:
+ one: Catégorie
+ other: Catégories
+ chapter:
+ one: Chapitre
+ other: Chapitres
+ character:
+ one: Personnage
+ other: Personnages
+ fandom:
+ one: Fandom
+ other: Fandoms
+ freeform:
+ one: Tag supplémentaire
+ other: Tags supplémentaires
+ rating:
+ one: Classification
+ other: Classifications
+ relationship:
+ one: Relation
+ other: Relations
+ series:
+ one: Série
+ other: Séries
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ one: un/une invité-e
+ other: "%{count} invité-e-s"
+ left_kudos:
+ html:
+ one: "%{givers_list} a félicité votre œuvre %{commentable_link}."
+ other: "%{givers_list} ont félicité votre œuvre %{commentable_link}."
+ text:
+ one: "%{givers_list} a félicité votre œuvre %{commentable_title} (%{commentable_url})."
+ other: "%{givers_list} ont félicité votre œuvre %{commentable_title} (%{commentable_url})."
+ single_guest:
+ giver: Un/Une invité-e
+ html: "%{giver} a félicité votre œuvre %{commentable_link}."
+ text: Un/Une invité-e a félicité votre œuvre %{commentable_title} (%{commentable_url}).
+ subject: "[%{app_name}] Vous avez reçu des félicitations !"
+ mailer:
+ general:
+ closing:
+ formal: Bien cordialement,
+ informal: Cordialement,
+ creation:
+ link_with_word_count: "%{creation_link} (%{word_count})"
+ title_with_chapter_number: Chapitre %{position} de %{title}
+ title_with_word_count: '"%{creation_title}" (%{word_count})'
+ word_count:
+ one: "%{count} mot"
+ other: "%{count} mots"
+ footer:
+ general:
+ about:
+ html: Le site Archive of Our Own est une archive gérée et financée par
+ les fans, qui dépend de %{donate_link}.
+ text: 'Le site Archive of Our Own est une archive gérée et financée par
+ les fans, qui dépend de vos dons : %{donate_url}.'
+ html:
+ donate_link_text: vos dons
+ support_link_text: contacter le Support Technique
+ unwanted_email:
+ html: Si vous avez reçu ce message par erreur, veuillez %{support_link}.
+ text: 'Si vous avez reçu ce message par erreur, veuillez contacter le
+ Support Technique à l''adresse suivante : %{support_url}.'
+ sent_at: Envoyé le %{sent_at}.
+ greeting:
+ formal_html: Cher/Chère %{name},
+ informal:
+ addressed_html: Bonjour, %{name} !
+ unaddressed: Bonjour !
+ introductory: Bonjour de la part d'Archive of Our Own – AO3 (Notre Propre
+ Archive) !
+ metadata_label_indicator: ":"
+ signature:
+ abuse_team: L'équipe Modération d'AO3
+ app_short_name: AO3
+ open_doors: L'équipe du Projet Open Doors
+ parent_org: Organization for Transformative Works – OTW (Organisation pour
+ les Œuvres Transformatives)
+ support: L'équipe de Support Technique d'AO3
+ users:
+ mailer:
+ reset_password_instructions:
+ expiration: Ce lien est valable une semaine. S'il n'est pas utilisé dans ce
+ délai, il arrivera à expiration et vous devrez alors en demander un nouveau.
+ intro: 'Nous avons reçu une demande de réinitialisation du mot de passe de
+ votre compte. Pour le modifier, suivez le lien ci-dessous et entrez votre
+ nouveau mot de passe :'
+ link_title: Modifier mon mot de passe.
+ subject: "[%{app_name}] Réinitialisation du mot de passe"
+ unrequested: Si vous n'êtes pas à l'origine de cette demande, veuillez ignorer
+ ce message. Votre ancien mot de passe restera alors valide.
+ user_mailer:
+ admin_deleted_work_notification:
+ bye: Vous trouverez une copie de votre œuvre en pièce jointe.
+ contact_abuse: contacter notre Comité Modération
+ deleted:
+ html: Votre œuvre %{title} a été supprimée de l'Archive par un-e administrateur-trice
+ du site.
+ text: Votre œuvre "%{title}" a été supprimée de l'Archive par un-e administrateur-trice
+ du site.
+ html:
+ tos_violation: S'il est possible que votre œuvre ait enfreint les Conditions
+ d'Utilisation, veuillez %{contact_abuse_link}.
+ import_project:
+ html: Si votre œuvre faisait partie d'un projet d'importation réalisé par
+ Open Doors, veuillez %{opendoors_link} pour toute question supplémentaire.
+ text: Si votre œuvre faisait partie d'un projet d'importation réalisé par
+ Open Doors, veuillez contacter Open Doors (Portes Ouvertes) (%{opendoors_link})
+ pour toute question supplémentaire.
+ opendoors: contacter Open Doors (Portes Ouvertes)
+ subject: "[%{app_name}] Votre œuvre a été supprimée par un-e administrateur-trice"
+ text:
+ tos_violation: S'il est possible que votre œuvre ait enfreint les Conditions
+ d'Utilisation, veuillez contacter notre Comité Modération (%{contact_abuse_url}).
+ admin_hidden_work_notification:
+ access: Durant cette période, vous pourrez toujours y accéder grâce au lien
+ fourni ci-dessus, toutefois elle ne fera pas partie de la liste de vos œuvres
+ et ne sera pas accessible aux autres utilisateur-trice-s d'AO3.
+ check_email: Vérifiez vos e-mails, y compris le dossier des indésirables, car
+ le Comité Modération vous a peut-être déjà contacté-e pour vous en expliquer
+ la raison.
+ contact_abuse: contacter le Comité Modération
+ html:
+ help: Si vous ignorez pour quelle raison votre œuvre a été cachée et que vous
+ n'avez pas reçu de communication à ce sujet, merci de contacter %{contact_abuse_link}
+ directement.
+ hidden: Votre œuvre %{title} a été cachée par le Comité Modération et n'est
+ plus accessible au public.
+ tos_violation: Si votre œuvre a été cachée suite à une violation des %{tos_link}
+ d'AO3, vous devrez corriger cette violation. À défaut, vous encourez le
+ risque qu'elle soit supprimée.
+ subject: "[%{app_name}] Votre œuvre a été cachée par le Comité Modération"
+ text:
+ help: 'Si vous ignorez pour quelle raison votre œuvre a été cachée et que
+ vous n''avez pas reçu de communication à ce sujet, merci de contacter le
+ Comité Modération directement : %{contact_abuse_url}.'
+ hidden: Votre œuvre "%{title}" (%{work_url}) a été cachée par le Comité Modération
+ et n'est plus accessible au public.
+ tos_violation: Si votre œuvre a été cachée suite à une violation des Conditions
+ d'Utilisation d'AO3 (%{tos_url}), vous devez corriger cette violation. À
+ défaut, vous encourez le risque qu'elle soit supprimée.
+ tos: Conditions d'Utilisation
+ anonymous_or_unrevealed_notification:
+ anonymous_info: Les œuvres anonymes apparaissent dans les listes de tags mais
+ pas sur la page rassemblant vos œuvres. Sur l'œuvre, votre nom d'utilisateur-trice
+ sera remplacé par "Anonymous" (Anonyme).
+ anonymous_unrevealed_info: Il se peut que les personnes qui maintiennent la
+ collection révèlent votre œuvre tout en la laissant anonyme. Les personnes
+ abonnées à vos mises à jour ne recevront pas de notification lors de ce changement.
+ Une fois révélée, votre œuvre sera incluse dans les listes de tags mais pas
+ dans la page regroupant vos œuvres. Sur l'œuvre, votre nom d'utilisateur-trice
+ sera remplacé par "Anonymous" (Anonyme).
+ changed_status:
+ anonymous:
+ html: Les personnes qui maintiennent %{collection_link} ont changé le statut
+ de votre œuvre %{work_link} en anonyme.
+ text: Les personnes qui maintiennent "%{collection_title}" (%{collection_url})
+ ont changé le statut de votre œuvre "%{work_title}" (%{work_url}) en anonyme.
+ anonymous_unrevealed:
+ html: Les personnes qui maintiennent %{collection_link} ont changé le statut
+ de votre œuvre %{work_link} pour en faire une œuvre anonyme non révélée.
+ text: Les personnes qui maintiennent "%{collection_title}" (%{collection_url})
+ ont changé le statut de votre œuvre "%{work_title}" (%{work_url}) pour
+ en faire une œuvre anonyme non révélée.
+ unrevealed:
+ html: Les personnes qui maintiennent %{collection_link} ont changé le statut
+ de votre œuvre %{work_link} pour en faire une œuvre non révélée.
+ text: Les personnes qui maintiennent "%{collection_title}" (%{collection_url})
+ ont changé le statut de votre œuvre "%{work_title}" (%{work_url}) pour
+ en faire une œuvre non révélée.
+ collection_items_link_text: Approved Collection Items page (page des Objets
+ approuvés dans une Collection)
+ do_not_want:
+ anonymous:
+ html: Si vous ne souhaitez pas que votre œuvre soit anonyme, veuillez consulter
+ votre %{collection_items_link} afin de la retirer de cette collection.
+ text: 'Si vous ne souhaitez pas que votre œuvre soit anonyme, veuillez consulter
+ votre Approved Collection Items page (page des Objets approuvés dans une
+ Collection) afin de la retirer de cette collection : %{collection_items_url}'
+ anonymous_unrevealed:
+ html: Si vous ne souhaitez pas que votre œuvre soit anonyme et non révélée,
+ veuillez consulter votre %{collection_items_link} afin de la retirer de
+ cette collection.
+ text: 'Si vous ne souhaitez pas que votre œuvre soit anonyme et non révélée,
+ veuillez consulter votre Approved Collection Items page (page des Objets
+ approuvés dans une Collection) afin de la retirer de cette collection
+ : %{collection_items_url}'
+ unrevealed:
+ html: Si vous ne souhaitez pas que votre œuvre soit non révélée, veuillez
+ consulter votre %{collection_items_link} afin de la retirer de cette collection.
+ text: 'Si vous ne souhaitez pas que votre œuvre soit non révélée, veuillez
+ consulter votre Approved Collection Items page (page des Objets approuvés
+ dans une Collection) afin de la retirer de cette collection : %{collection_items_url}'
+ faq_link_text: FAQ Collections
+ more_info:
+ html: Pour plus d'information, consultez notre %{faq_link}.
+ text: 'Pour plus d''informations, consultez notre FAQ "Collections" : %{faq_url}'
+ subject:
+ anonymous: "[%{app_name}] Votre œuvre a été rendue anonyme"
+ anonymous_unrevealed: "[%{app_name}] Votre œuvre a été rendue anonyme et non
+ révélée"
+ unrevealed: "[%{app_name}] Votre œuvre a été changée en œuvre non révélée"
+ unrevealed_info: Les œuvres non révélées n'apparaissent pas dans les listes
+ de tags ou sur la page rassemblant vos œuvres. Quiconque suit un lien vers
+ l'œuvre sera informé qu'elle est actuellement non révélée et ne pourra pas
+ accéder à son contenu.
+ archivist_added_to_collection_notification:
+ approved_collection_items_page: page Approved Collection Items (page des Objets
+ approuvés dans une collection)
+ archivist_notice: Puisque les responsables de la collection agissent en leur
+ qualité officielle d'archivistes d'Open Doors (Portes Ouvertes), ils/elles
+ sont autorisé-e-s à ajouter votre œuvre à cette collection, même si vous avez
+ désactivé les invitations à des collections. Les archivistes ajouteront uniquement
+ une œuvre à une collection si elle était hébergée sur une archive importée.
+ removal_instructions:
+ html: Si vous souhaitez retirer votre œuvre de cette collection, veuillez
+ vous rendre sur votre %{approved_items_link}.
+ text: 'Si vous souhaitez retirer votre œuvre de cette collection, veuillez
+ vous rendre sur votre page Approved Collection Items (page des Objets approuvés
+ dans une collection) : %{approved_items_url}.'
+ subject: "[%{app_name}][%{collection_title}] Un/Une Archiviste d'Open Doors
+ (Portes Ouvertes) a ajouté votre œuvre à une collection"
+ work_added:
+ html: Le/La responsable de %{collection_link} a ajouté votre œuvre %{work_link}
+ à sa collection !
+ text: Les responsables "%{collection_title}" (%{collection_url}) ont ajouté
+ votre oeuvre "%{work_title}" (%{work_url}) à leur collection !
+ challenge_assignment_notification:
+ any: Non spécifié
+ assignment:
+ html: La demande suivante vous a été attribuée dans le cadre du défi %{link}
+ sur Archive of Our Own !
+ description: 'Description :'
+ due: 'Cette tâche est à terminer avant le :'
+ html:
+ footer: Vous avez reçu cet e-mail car vous vous êtes inscrit-e au défi %{title}.
+ Pour plus d'informations sur ce défi et savoir comment contacter les modérateur-trice-s,
+ veuillez consulter %{footer_link}.
+ footer_link: la page de profil du défi
+ look_up: Vous pouvez accéder à cette tâche via %{link}.
+ look_up_link: votre page Assignments (Tâches)
+ optional_tags: 'Tags optionnels :'
+ prompts: 'Prompts :'
+ prompt_url: 'URL du prompt :'
+ recipient: 'Destinataire :'
+ recipient_missing: 'Aucun-e : contactez un/une modérateur-trice pour demander
+ de l''aide !'
+ subject: "[%{app_name}][%{collection_title}] Vous Avez Reçu une Tâche !"
+ text:
+ assignment: La demande suivante vous a été attribuée dans le cadre du défi
+ "%{collection_title}" (%{collection_url}) sur Archive of Our Own !
+ footer: Vous avez reçu cet e-mail car vous vous êtes inscrit-e au défi %{title}
+ (%{url}). Pour plus d'informations sur ce défi et savoir comment contacter
+ les modérateur-trice-s, veuillez consulter %{profile_url}.
+ look_up: Vous pouvez retrouver cette tâche dans votre page Assignments (Tâches)
+ à l'adresse %{link}.
+ change_email:
+ changed:
+ html: "%{login}, l'adresse e-mail associée à votre compte a été remplacée
+ par %{email}"
+ text: "%{login}, l'adresse e-mail associée à votre compte a été remplacée
+ par %{email}"
+ subject: "[%{app_name}] Changement d'adresse e-mail"
+ claim_notification:
+ access:
+ contact_support: contacter le support technique d'AO3
+ html: Dans certains cas, il se peut que vos œuvres aient été importées de
+ manière à être visibles uniquement pour les utilisateur-trice-s connecté-e-s
+ (afin qu'elles n'apparaissent pas dans les résultats de recherche Google).
+ Si c'est le cas, cette restriction s'appliquera à vos œuvres jusqu'à ce
+ que vous décidiez de les rendre pleinement visibles. Pour savoir comment
+ déverrouiller l'accès à vos œuvres, les supprimer ou les rendre orphelines,
+ veuillez %{contact_support_link}.
+ text: Dans certains cas, il se peut que vos œuvres aient été importées de
+ manière à être visibles uniquement pour les utilisateur-trice-s connecté-e-s
+ (afin qu'elles n'apparaissent pas dans les résultats de recherche Google).
+ Si c'est le cas, cette restriction s'appliquera à vos œuvres jusqu'à ce
+ que vous décidiez de les rendre pleinement visibles. Pour savoir comment
+ déverrouiller l'accès à vos œuvres, les supprimer ou les rendre orphelines,
+ veuillez contacter le support technique d'AO3 à l'adresse %{support_url}.
+ email_tips: Si vous souhaitez nous contacter, veuillez ajouter le domaine @transformativeworks.org
+ à votre liste de contacts autorisés. Il se peut aussi que notre réponse atterrisse
+ dans vos dossiers de spam.
+ introduction:
+ ao3_name: Archive of Our Own – AO3 (Notre Propre Archive)
+ html: Vous recevez ce message car des œuvres vous appartenant publiées sur
+ une archive d'œuvres de fans ont été importées sur %{app_link} par %{open_doors_name_link}.
+ Cette adresse e-mail étant liée à celle que vous utilisez sur l'archive
+ importée, les œuvres de fans associées ont été ajoutées automatiquement
+ sur votre compte AO3.
+ open_doors_name: Open Doors (Portes Ouvertes)
+ text: 'Vous recevez ce message car des œuvres vous appartenant, publiées sur
+ une archive d''œuvres de fans, ont été importées sur Archive of Our Own
+ – AO3 (Notre Propre Archive): %{app_url} par notre équipe Open Doors (Portes
+ Ouvertes) (%{open_doors_url}) depuis une archive d''œuvres de fans. Cette
+ adresse e-mail étant liée à celle que vous utilisez sur l''archive importée,
+ les œuvres de fans associées (recensées ci-dessous) ont été ajoutées automatiquement
+ sur votre compte AO3.'
+ mistake:
+ contact_open_doors: notre équipe Open Doors
+ html: 'Si ces œuvres vous ont été attribuées par erreur, nous vous demandons
+ de ne pas les supprimer : contactez simplement %{contact_open_doors_link},
+ et nous réglerons le problème.'
+ text: 'Si ces œuvres vous ont été attribuées par erreur, nous vous demandons
+ de ne pas les supprimer : contactez simplement notre équipe Open Doors (%{open_doors_url}),
+ et nous réglerons le problème.'
+ more_info:
+ ao3_news: Actualités d'AO3
+ contact_support: contacter le support technique d'AO3
+ faq_page: FAQ
+ html: Vous trouverez les annonces relatives aux importations récentes dans
+ les %{ao3_news_link}. Pour des informations d'ordre plus général, la %{faq_page_link}
+ "Open Doors" et le %{tutorial_page_link} associé sont à votre disposition.
+ Si vous n'y trouvez pas la réponse à vos questions, veuillez %{contact_support_link}.
+ text: Vous trouverez les annonces relatives aux importations récentes dans
+ les Actualités d'AO3 (%{news_url}). Pour des informations d'ordre plus général,
+ la FAQ "Open Doors" (%{open_doors_faq_url}) et le tutoriel associé (%{open_doors_tutorial_url})
+ sont à votre disposition. Si vous n'y trouvez pas la réponse à vos questions,
+ veuillez contacter le support technique d'AO3 à l'adresse %{support_url}.
+ tutorial_page: tutoriel
+ other_works:
+ contact_open_doors: contacter l'équipe Open Doors
+ html: Si vous avez publié d'autres œuvres sur l'archive importée, mais que
+ vous n'avez plus accès à l'adresse e-mail associée, veuillez %{contact_open_doors_link}
+ en fournissant un maximum d'informations qui pourraient nous aider à vérifier
+ votre identité.
+ text: Si vous avez publié d'autres œuvres sur l'archive importée, mais que
+ vous n'avez plus accès à l'adresse e-mail associée, veuillez contacter l'équipe
+ Open Doors en fournissant un maximum d'informations qui pourraient nous
+ aider à vérifier votre identité.
+ questions:
+ contact_support: contacter le support technique d'AO3
+ html: Pour toute autre demande, merci de %{contact_support_link}.
+ text: Pour toute autre demande, merci de contacter le support technique d'AO3
+ à l'adresse %{support_url}.
+ redirects:
+ html: Pour assurer la continuité des listes de recommandation et des marque-pages,
+ il se peut que l'adresse de l'archive importée redirige les utilisateur-trice-s
+ vers la copie importée des œuvres concernées pour une durée limitée (pour
+ savoir si c'est le cas, consultez le billet annonçant l'importation de votre
+ archive). Si vous avez déjà publié une copie de ces œuvres sur AO3 %{negation}
+ passer par notre outil d'importation par URL, il existera deux copies de
+ cette œuvre sur AO3.
+ subject: "[%{app_name}] Œuvres importées"
+ update_redirect:
+ contact_open_doors: contacter l'équipe Open Doors
+ html: Si vous souhaitez qu'Open Doors mette à jour la redirection pour l'associer
+ à votre œuvre préexistante, veuillez supprimer la copie importée et %{contact_open_doors_link}
+ en précisant votre nom (ou vos noms) d'utilisateur-trice sur AO3 et sur
+ l'archive importée, ainsi que le titre et l'URL de l'œuvre vers laquelle
+ vous souhaitez programmer la redirection. (Si vous souhaitez effectuer cette
+ opération pour plusieurs œuvres, vous pouvez en dresser la liste dans un
+ même e-mail.)
+ text: Si vous souhaitez qu'Open Doors mette à jour la redirection pour l'associer
+ à votre œuvre préexistante, veuillez supprimer la copie importée et contacter
+ l'équipe Open Doors à l'adresse %{open_doors_url} en précisant votre nom
+ (ou vos noms) d'utilisateur-trice sur AO3 et sur l'archive importée, ainsi
+ que le titre et l'URL de l'œuvre vers laquelle vous souhaitez programmer
+ la redirection. (Si vous souhaitez effectuer cette opération pour plusieurs
+ œuvres, vous pouvez en dresser la liste dans un même e-mail.)
+ works_by: 'Ces œuvres ont été publiées en association avec l''adresse suivante
+ : %{email}'
+ work_info:
+ html: "%{work_link} (%{fandom})"
+ text:
+ no_fandom: "- %{work_title} %{work_url}"
+ with_fandom: "- %{work_title} %{work_url} (%{fandom})"
+ collection_notification:
+ assignments_sent:
+ complete: Toutes les tâches ont été envoyées.
+ subject: Tâches envoyées
+ html:
+ received_message: 'Vous avez reçu un message à propos de votre collection
+ %{collection_link} :'
+ text:
+ received_message: 'Vous avez reçu un message à propos de votre collection
+ "%{collection_title}" (%{collection_url}) :'
+ creatorship_notification:
+ explanation: Lorsque vous êtes co-créateur-trice d'une œuvre, votre nom peut
+ être ajouté à de nouveaux chapitres quels que soient les paramètres de la
+ co-création. Il pourra également être ajouté à toute série à laquelle l'œuvre
+ serait annexée.
+ html:
+ creation: "%{creation_link} par %{pseud_links}"
+ edit_chapter: modifier le chapitre
+ edit_series: modifier la série
+ remove_chapter: Si votre nom a été ajouté par erreur ou si vous ne souhaitez
+ pas figurer en tant que créateur-trice, vous pouvez %{edit_chapter_link}
+ et en retirer votre nom.
+ remove_series: Si votre nom a été ajouté par erreur ou si vous ne souhaitez
+ pas figurer en tant que créateur-trice, vous pouvez %{edit_series_link}
+ et en retirer votre nom.
+ intro_chapter: 'L''utilisateur-trice %{adding_user} a ajouté votre pseudo %{pseud}
+ en tant que co-créateur-trice pour le chapitre suivant :'
+ intro_series: 'L''utilisateur-trice %{adding_user} a ajouté votre pseudo %{pseud}
+ en tant que co-créateur-trice pour la série suivante :'
+ subject: "[%{app_name}] Notification de Co-création"
+ text:
+ creation: "%{title} (%{url}) par %{pseuds}"
+ remove_chapter: 'Si votre nom a été ajouté par erreur ou si vous ne souhaitez
+ pas figurer en tant que créateur-trice, vous pouvez modifier le chapitre
+ et en retirer votre nom : %{url}'
+ remove_series: 'Si votre nom a été ajouté par erreur ou si vous ne souhaitez
+ pas figurer en tant que créateur-trice, vous pouvez modifier la série et
+ en retirer votre nom : %{url}'
+ creatorship_notification_archivist:
+ explanation: L'utilisateur-trice, en sa qualité d'archiviste officiel-le d'Open
+ Doors (Portes Ouvertes), a la possibilité de vous ajouter sans vous le demander,
+ même si vous avez désactivé la co-création.
+ html:
+ creation: "%{creation_link} de %{pseud_links}"
+ edit_chapter: modifier le chapitre
+ edit_series: modifier la série
+ edit_work: modifier l'œuvre
+ remove_chapter: Si vous avez été ajouté-e par erreur ou ne souhaitez pas apparaître
+ comme créateur-trice, vous pouvez %{edit_chapter_link} pour retirer votre
+ pseudo.
+ remove_series: Si vous avez été ajouté-e par erreur ou ne souhaitez pas apparaître
+ comme créateur-trice, vous pouvez %{edit_series_link} pour retirer votre
+ pseudo.
+ remove_work: Si vous avez été ajouté-e par erreur ou ne souhaitez pas apparaître
+ comme créateur-trice, vous pouvez %{edit_work_link} pour retirer votre pseudo.
+ intro_chapter: 'L''utilisateur-trice %{archivist} a ajouté votre pseudo %{pseud}
+ en tant que co-créateur-trice du chapitre suivant :'
+ intro_series: 'L''utilisateur-trice %{archivist} a ajouté votre pseudo %{pseud}
+ en tant que co-créateur-trice de la série suivante :'
+ intro_work: 'L''utilisateur-trice %{archivist} a ajouté votre pseudo %{pseud}
+ en tant que co-créateur-trice de l''œuvre suivante :'
+ subject: "[%{app_name}] Notification d'ajout en tant que co-créateur-trice"
+ text:
+ creation: "%{title} (%{url}) de %{pseuds}"
+ remove_chapter: 'Si vous avez été ajouté-e par erreur ou ne souhaitez pas
+ apparaître comme créateur-trice, vous pouvez modifier le chapitre pour retirer
+ votre pseudo : %{url}'
+ remove_series: 'Si vous avez été ajouté-e par erreur ou ne souhaitez pas apparaître
+ comme créateur-trice, vous pouvez modifier la série pour retirer votre pseudo
+ : %{url}'
+ remove_work: 'Si vous avez été ajouté-e par erreur ou ne souhaitez pas apparaître
+ comme co-créateur-trice, vous pouvez modifier l''œuvre pour retirer votre
+ pseudo : %{url}'
+ creatorship_request:
+ html:
+ creation: "%{creation_link} par %{pseud_links}"
+ instructions: Vous pouvez accepter ou refuser cette demande sur la page %{page_name}.
+ page_name: Co-Creator Requests (Demandes de Co-création)
+ intro_chapter: 'L''utilisateur-trice %{inviting_user} a invité votre pseudo
+ %{pseud} à apparaître en co-création du chapitre suivant :'
+ intro_series: 'L''utilisateur-trice %{inviting_user} a invité votre pseudo %{pseud}
+ à apparaître en co-création de la série suivante :'
+ intro_work: 'L''utilisateur-trice %{inviting_user} a invité votre pseudo %{pseud}
+ à apparaître en co-création de l''œuvre suivante :'
+ subject: "[%{app_name}] Demande de Co-création"
+ text:
+ creation: "%{title} (%{url}) par %{pseuds}"
+ instructions: 'Vous pouvez accepter ou refuser cette invitation sur la page
+ Co-Creator Requests (Demandes de Co-création) : %{url}'
+ delete_work_notification:
+ attachment: Ci-joint une copie de votre œuvre pour référence.
+ deleted_other:
+ html: Votre œuvre %{title} a été supprimée suite à la demande de %{pseud}.
+ text: Votre œuvre "%{title}" a été supprimée suite à la demande de %{pseud}.
+ deleted_yourself:
+ html: Votre œuvre %{title} a été supprimée suite à votre demande.
+ text: Votre œuvre "%{title}" a été supprimée suite à votre demande.
+ questions:
+ html: Si vous avez des questions, veuillez %{support}.
+ text: Si vous avez des questions, veuillez %{support} (%{url}).
+ subject: "[%{app_name}] Votre œuvre a été supprimée"
+ support: contacter le Support Technique
+ invitation:
+ been_invited: Vous avez été invité-e à rejoindre notre version bêta !
+ has_invited: "%{user_name} vous a invité-e à rejoindre notre version bêta !"
+ html:
+ faq_link_text: notre FAQ
+ subject: "[%{app_name}] Invitation"
+ invitation_to_claim:
+ access:
+ text: En fonction de l’archive, il se peut que vos œuvres aient été importées
+ de façon à n’être accessibles qu’aux utilisateur-trice-s enregistré-e-s
+ (afin qu’elles n’apparaissent pas parmi les résultats de recherche Google).
+ Si c’est le cas, les œuvres seront restreintes aux utilisateur-trice-s connecté-e-s,
+ à moins que vous ne les rendiez accessibles à tous/toutes. Si vous avez
+ besoin d’aide pour cela, pour rendre orphelines ou supprimer vos œuvres,
+ veuillez contacter le Comité Support Technique d’AO3.
+ claim_or_remove:
+ html: Vous pouvez revendiquer ou supprimer vos œuvres ici.
+ text: 'Vous pouvez revendiquer ou supprimer vos œuvres ici : %{claim_url}.'
+ email_tips: Si vous nous contactez, veuillez autoriser les e-mails provenant
+ de @transformativeworks.org et vérifiez que notre réponse n’arrive pas dans
+ votre courrier indésirable.
+ html:
+ ao3_news: les actualités d’AO3
+ contact_open_doors: contacter Open Doors
+ contact_support: contacter le Comité Support Technique d’AO3
+ faq_page: FAQ
+ tutorial_page: tutoriels
+ introduction:
+ text: Vous recevez cet e-mail car une archive a récemment été importée par
+ Open Doors (%{open_doors_link}) sur %{app_name} (%{app_short_name} - %{app_url}),
+ et il nous semble que les œuvres de fans listées ci-après vous appartiennent.
+ Nous aimerions vous donner l’occasion de revendiquer ces œuvres (ou de les
+ supprimer, ou encore de les rendre orphelines, c’est-à-dire de rompre la
+ relation entre elles et vous) si vous le souhaitez. Et si vous n’avez pas
+ encore de compte AO3 sous une adresse différente, nous voudrions vous convier
+ à bord !
+ mistake:
+ text: S’il y a erreur et que ces œuvres ne vous appartiennent pas, ne les
+ supprimez pas ! Merci de simplement contacter Open Doors (%{open_doors_link})
+ et nous reprendrons la main.
+ more_info:
+ text: 'Vous pouvez consulter les dernières annonces d’importation d’archives
+ grâce aux actualités d’AO3 (%{news_link}), et trouver des informations complémentaires
+ dans la FAQ d’Open Doors (%{open_doors_faq_link}) ou dans les tutoriels
+ (%{open_doors_tutorial_link}). Si la réponse à vos questions ne se trouve
+ pas à ces endroits, ou dans cet email, veuillez contacter le Comité Support
+ Technique ici : %{support_link}.'
+ other_works:
+ text: Si certaines de vos œuvres de l’archive importée étaient associées à
+ une adresse e-mail à laquelle vous n’avez plus accès, veuillez contacter
+ Open Doors en mentionnant toute information susceptible de contribuer à
+ confirmer votre identité.
+ questions:
+ text: 'Pour toute autre demande, veuillez contacter le Comité Support Technique
+ ici : %{support_link}.'
+ redirects: Afin de préserver les listes de recommandations et les marque-pages,
+ les adresses web de l’archive importée sont susceptibles de renvoyer vers
+ la copie importée de ces œuvres pendant une durée limitée (consultez le billet
+ d’actualités concernant votre archive pour vous en assurer). Si vous avez
+ déjà mis en ligne une copie de ces œuvres et que vous n’avez PAS utilisé la
+ fonctionnalité permettant d’importer à partir d’une URL, il y aura deux copies
+ d’une même œuvre sur l’archive.
+ subject: "[%{app_name}] Invitation à revendiquer vos œuvres"
+ unwanted:
+ text: 'Si ces œuvres vous appartiennent bien mais que vous ne souhaitez pas
+ les revendiquer, vous pouvez les rendre orphelines (afin qu’elles restent
+ sur AO3 sans que votre nom y soit associé) ou les supprimer (afin qu’elles
+ disparaissent entièrement d’AO3). Il n’est pas nécessaire d’ajouter ces
+ œuvres à un compte pour les rendre orphelines ou les supprimer, vous pouvez
+ le faire directement en suivant le lien de revendication plus haut. (Si
+ vous avez besoin d’aide, veuillez contacter le Comité Support Technique
+ ici : %{support_link}.)'
+ update_redirect:
+ text: 'Si vous aimeriez qu’Open Doors mette à jour le lien de redirection
+ afin qu’il renvoie vers votre œuvre préexistante, veuillez supprimer la
+ copie importée et contacter Open Doors ici : %{open_doors_link}, en précisant
+ le nom de votre compte AO3, le nom de votre compte sur l’archive importée,
+ ainsi que le titre et l’URL de l’œuvre vers laquelle vous aimeriez que le
+ lien redirige. (Si vous souhaitez changer le lien de redirection de plusieurs
+ œuvres, vous pouvez rassembler tous les renseignements dans un seul e-mail.)'
+ uploaded_list: 'Les œuvres suivantes ont été mises en ligne :'
+ invite_increase_notification:
+ html:
+ body:
+ one: 'Nous vous envoyons ce message pour vous informer que vous possédez
+ %{count} nouvelle invitation, permettant de créer un nouveau compte sur
+ AO3. Vous pouvez aussi inviter un-e ami-e à l''adresse suivante : %{invitation_page_link}.'
+ other: 'Nous vous envoyons ce message pour vous informer que vous possédez
+ %{count} nouvelles invitations, permettant de créer de nouveaux comptes
+ sur AO3. Vous pouvez aussi inviter un-e ami-e à l''adresse suivante :
+ %{invitation_page_link}.'
+ invitation_page_link_text: votre page d'invitations
+ subject: "[%{app_name}] Nouvelles Invitations"
+ text:
+ body:
+ one: Nous vous envoyons ce message pour vous informer que vous possédez
+ %{count} nouvelle invitation, permettant de créer un nouveau compte sur
+ AO3. Vous pouvez aussi inviter un-e ami-e sur votre page d'invitations
+ (%{invitation_page_url}).
+ other: Nous vous envoyons ce message pour vous informer que vous possédez
+ %{count} nouvelles invitations, permettant de créer de nouveaux comptes
+ sur AO3. Vous pouvez aussi inviter un-e ami-e sur votre page d'invitations
+ (%{invitation_page_url}).
+ invite_request_declined:
+ main:
+ one: Nous sommes au regret de vous informer que votre demande de nouvelle
+ invitation ne peut être satisfaite pour le moment.
+ other: Nous sommes au regret de vous informer que votre demande de %{count}
+ nouvelles invitations ne peut être satisfaite pour le moment.
+ reason: 'Votre demande était la suivante :'
+ subject: "[%{app_name}] Demande de Code d'Invitation Supplémentaire Rejetée"
+ recipient_notification:
+ html:
+ collection: Une œuvre cadeau a été publiée pour vous dans la collection %{collection_link}
+ sur AO3 !
+ no_collection: Une œuvre cadeau a été publiée pour vous sur AO3 !
+ subject:
+ collection: "[%{app_name}][%{collection_title}] Une œuvre cadeau pour vous
+ dans %{collection_title}"
+ no_collection: "[%{app_name}] Une œuvre cadeau pour vous"
+ text:
+ collection: Une œuvre cadeau a été publiée pour vous dans la collection "%{collection_title}"
+ (%{collection_url}) sur AO3 !
+ signup_notification:
+ activate:
+ html: Veuillez %{activate_account_link}.
+ text: 'Veuillez suivre ce lien pour activer votre compte : %{activate_account_url}'
+ activate_your_account: suivez ce lien pour activer votre compte
+ admin_posts: Actualités d’AO3
+ bye: Nous espérons que vous vous plairez sur AO3.
+ contact_support: contacter notre Support Technique
+ faq: FAQ
+ features:
+ html: Une fois que votre compte est activé, vous pouvez notamment publier
+ vos œuvres de fans, paramétrer les abonnements par e-mail pour être averti-e
+ des mises à jour de vos créateur-trice-s et œuvres préféré-e-s, définir
+ vos préférences pour personnaliser l’apparence et l’interface du site, garder
+ une trace des œuvres que vous avez consultées sur l’Archive grâce à votre
+ historique, et bien plus encore.
+ text: Une fois que votre compte est activé, vous pouvez notamment publier
+ vos œuvres de fans, paramétrer les abonnements par e-mail pour être averti-e
+ des mises à jour de vos créateur-trice-s et œuvres préféré-e-s, définir
+ vos préférences pour personnaliser l’apparence et l’interface du site, garder
+ une trace des œuvres que vous avez consultées sur l’Archive grâce à votre
+ historique, et bien plus encore.
+ information:
+ html: Vous trouverez une multitude d’informations et de conseils sur l’utilisation
+ de l’Archive sur notre %{faq_link}. Vous pouvez trouver les dernières actualités
+ concernant le développement du site grâce aux %{admin_posts_link}. Si vous
+ avez besoin d’aide, faites face à un bug, souhaitez nous poser des questions
+ ou nous faire part de vos commentaires, veuillez %{contact_support_link},
+ qui est toujours ravi d’aider.
+ text: 'Vous trouverez une multitude d’informations et de conseils sur l’utilisation
+ d’AO3 sur notre FAQ à l’adresse suivante : %{faq_url}. Vous pouvez trouver
+ les dernières actualités publiées par les admins et concernant le développement
+ du site à l’adresse suivante : %{admin_posts_url}. Si vous avez besoin d’aide,
+ trouvez un bug, souhaitez nous poser des questions ou nous faire part de
+ vos commentaires, veuillez contacter notre équipe du Support Technique,
+ qui est toujours ravie d’aider : %{contact_support_url}.
+
+ '
+ welcome: Bienvenue sur Archive of Our Own, %{login} !
diff --git a/config/locales/phrase-exports/he.yml b/config/locales/phrase-exports/he.yml
new file mode 100755
index 0000000..7383196
--- /dev/null
+++ b/config/locales/phrase-exports/he.yml
@@ -0,0 +1,471 @@
+---
+he:
+ activerecord:
+ attributes:
+ archive_warning:
+ name_with_colon:
+ many: 'אזהרות ארכיון:'
+ one: 'אזהרת ארכיון:'
+ other: 'אזהרות ארכיון:'
+ two: 'אזהרות ארכיון:'
+ category:
+ name_with_colon:
+ many: 'קטגוריות:'
+ one: 'קטגוריה:'
+ other: 'קטגוריות:'
+ two: 'קטגוריות:'
+ character:
+ name_with_colon:
+ many: 'דמויות:'
+ one: 'דמות:'
+ other: 'דמויות:'
+ two: 'דמויות:'
+ fandom:
+ name_with_colon:
+ many: 'פאנדומים:'
+ one: 'פאנדום:'
+ other: 'פאנדומים:'
+ two: 'פאנדומים:'
+ freeform:
+ name_with_colon:
+ many: 'תגיות נוספות:'
+ one: 'תגית נוספת:'
+ other: 'תגיות נוספות:'
+ two: 'תגיות נוספות:'
+ rating:
+ name_with_colon: 'סיווג:'
+ relationship:
+ name_with_colon:
+ many: 'מערכות יחסים:'
+ one: 'מערכת יחסים:'
+ other: 'מערכות יחסים:'
+ two: 'מערכות יחסים:'
+ work:
+ chapter_total_display: פרקים
+ summary: תקציר
+ models:
+ archive_warning:
+ many: אזהרות ארכיון
+ one: אזהרת ארכיון
+ other: אזהרות ארכיון
+ two: אזהרות ארכיון
+ category:
+ many: קטגוריות
+ one: קטגוריה
+ other: קטגוריות
+ two: קטגוריות
+ chapter:
+ many: פרקים
+ one: פרק
+ other: פרקים
+ two: פרקים
+ character:
+ many: דמויות
+ one: דמות
+ other: דמויות
+ two: דמויות
+ fandom:
+ many: פאנדומים
+ one: פאנדום
+ other: פאנדומים
+ two: פאנדומים
+ freeform:
+ many: תגיות נוספות
+ one: תגית נוספת
+ other: תגיות נוספות
+ two: תגיות נוספות
+ rating:
+ many: סיווגים
+ one: סיווג
+ other: סיווגים
+ two: סיווגים
+ relationship:
+ many: מערכות יחסים
+ one: מערכת יחסים
+ other: מערכות יחסים
+ two: מערכות יחסים
+ series:
+ many: סדרות
+ one: סדרה
+ other: סדרות
+ two: סדרות
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ many: "%{count} אורחות"
+ one: אורחת
+ other: "%{count} אורחות"
+ two: "%{count} אורחות"
+ left_kudos:
+ html:
+ one: "%{givers_list} השאירה קודוס על %{commentable_link}."
+ other: "%{givers_list} השאירו קודוס על %{commentable_link}."
+ text:
+ one: "%{givers_list} השאירה קודוס על %{commentable_title} (%{commentable_url})."
+ other: "%{givers_list} השאירו קודוס על %{commentable_title} (%{commentable_url})."
+ single_guest:
+ giver: אורחת
+ html: "%{giver} השאירה קודוס על %{commentable_link}."
+ text: אורחת השאירה קודוס על %{commentable_title} (%{commentable_url}).
+ subject: "[%{app_name}] קיבלת קודוס!"
+ mailer:
+ general:
+ closing:
+ formal: בברכה,
+ informal: בברכה,
+ creation:
+ link_with_word_count: "%{creation_link} (%{word_count})"
+ title_with_chapter_number: פרק %{position} של היצירה %{title}
+ title_with_word_count: '"%{creation_title}" (%{word_count})'
+ word_count:
+ many: "%{count} מילים"
+ one: מילה אחת
+ other: "%{count} מילים"
+ two: "%{count} מילים"
+ footer:
+ general:
+ about:
+ html: AO3 הוא ארכיון המנוהל ונתמך על ידי מעריצים ומעריצות ונסמך על %{donate_link}.
+ text: 'AO3 הוא ארכיון המנוהל ונתמך על ידי מעריצים ומעריצות ונסמך על תרומות:
+ %{donate_url}.'
+ html:
+ donate_link_text: תרומות
+ support_link_text: צרו קשר עם צוות התמיכה שלנו
+ unwanted_email:
+ html: אם קיבלת הודעה זו בטעות, אנא %{support_link}.
+ text: אם קיבלת הודעה זו בטעות, אנא צרו קשר עם צוות התמיכה שלנו בכתובת
+ %{support_url}.
+ sent_at: נשלח בשעה %{sent_at}.
+ greeting:
+ formal_html: "%{name} שלום,"
+ informal:
+ addressed_html: היי, %{name}!
+ unaddressed: היי!
+ introductory: שלום מ-Archive of Our Own – AO3 (ארכיון משלנו)!
+ metadata_label_indicator: ":"
+ signature:
+ abuse_team: צוות מדיניות ושימוש לרעה של AO3
+ app_short_name: AO3
+ open_doors: צוות Open Doors (דלתות פתוחות)
+ parent_org: Organization for Transformative Works – OTW (ארגון למען יצירות
+ טרנספורמטיביות)
+ support: צוות התמיכה של AO3
+ users:
+ mailer:
+ reset_password_instructions:
+ expiration: אם לא תשתמשו בקישור לאיפוס הסיסמה בתוך שבוע, יפוג תוקף הקישור
+ ותצטרכו לשלוח בקשה חדשה לאיפוס סיסמה.
+ intro: 'קיבלנו בקשה לאיפוס סיסמה בחשבונך. באפשרותך לשנות את הסיסמה על ידי
+ כניסה לקישור הבא והזנת הסיסמה החדשה:'
+ link_title: שינוי סיסמה.
+ subject: "[%{app_name}] איפוס סיסמה"
+ unrequested: אם לא ביקשת לשנות את סיסמתך, התעלמו מדוא"ל זה והסיסמה הנוכחית
+ שלך תמשיך לשמש אותך כמקודם.
+ user_mailer:
+ admin_deleted_work_notification:
+ bye: לנוחיותך, מצורף בזאת עותק של יצירתך.
+ contact_abuse: צרו קשר עם ועדת מדיניות ושימוש לרעה
+ deleted:
+ html: יצירתך %{title} נמחקה מהארכיון על ידי אדמינית.
+ text: יצירתך "%{title}" נמחקה מהארכיון על ידי אדמינית.
+ html:
+ tos_violation: אם ייתכן שיצירתך הפרה את תנאי השימוש של הארכיון, אנא %{contact_abuse_link}.
+ import_project:
+ html: אם יצירתך הייתה שייכת למיזם שיובא לארכיון על ידי Open Doors (דלתות פתוחות),
+ ניתן %{opendoors_link}.
+ text: אם יצירתך הייתה שייכת למיזם שיובא לארכיון על ידי Open Doors (דלתות פתוחות),
+ ניתן להפנות שאלות נוספות לצוות דלתות פתוחות (%{opendoors_link}).
+ opendoors: להפנות שאלות נוספות לצוות דלתות פתוחות
+ subject: "[%{app_name}] יצירתך נמחקה על ידי אדמינית"
+ text:
+ tos_violation: אם ייתכן שיצירתך הפרה את תנאי השימוש של הארכיון, אנא צרו קשר
+ עם ועדת מדיניות ושימוש לרעה (%{contact_abuse_url}).
+ admin_hidden_work_notification:
+ access: כל עוד היצירה מוסתרת, באפשרותך לגשת אליה באמצעות הקישור שצורף לעיל,
+ אך היא לא תופיע ברשימת היצירות שלך, ומשתמשות אחרות ב-AO3 לא יוכלו למצוא אותה.
+ check_email: נבקשך לבדוק בתיבת הדוא"ל שלך, כולל בתיקיית הספאם, אם קיבלת הודעה
+ מוועדת מדיניות ושימוש לרעה. ייתכן שהצוות כבר ניסה ליצור איתך קשר ולהסביר מדוע
+ היצירה הוסתרה.
+ contact_abuse: ליצור קשר עם ועדת מדיניות ושימוש לרעה
+ html:
+ help: אם לא ברור לך מדוע היצירה הוסתרה, ולא קיבלת הודעות נוספות כלשהן בנושא,
+ תוכלו %{contact_abuse_link} באופן ישיר.
+ hidden: צוות מדיניות ושימוש לרעה הסתיר את יצירתך %{title} ואין לציבור אפשרות
+ לגשת אליה כעת.
+ tos_violation: אם היצירה הוסתרה משום שהיא מפרה את %{tos_link} של AO3, תצטרכו
+ לשנות את היצירה כך שתעמוד בתנאי השימוש. אם לא יתוקנו ההפרות, היצירה עלולה
+ להימחק מ-AO3.
+ subject: "[%{app_name}] צוות מדיניות ושימוש לרעה הסתיר את יצירתך"
+ text:
+ help: 'אם לא ברור לך מדוע היצירה הוסתרה, ולא קיבלת הודעות נוספות כלשהן בנושא,
+ תוכלו ליצור קשר עם מדיניות ושימוש לרעה באופן ישיר: %{contact_abuse_url}.'
+ hidden: צוות מדיניות ושימוש לרעה הסתיר את יצירתך "%{title}" (%{work_url})
+ ואין לציבור אפשרות לגשת אליה כעת.
+ tos_violation: אם היצירה שלך הוסתרה משום שהיא מפרה את תנאי השימוש של AO3,
+ (%{tos_url}), יהיה תצטרכו לשנות את היצירה כך שתעמוד בתנאי השימוש. אם לא
+ יתוקנו ההפרות, היצירה עלולה להימחק מ-AO3.
+ tos: תנאי השימוש
+ anonymous_or_unrevealed_notification:
+ anonymous_info: יצירות אנונימיות נכללות ברשימות התגיות, אך לא בדף היצירות שלך.
+ ביצירה עצמה, שם המשתמש/ת שלך יוחלף ב-"Anonymous" (אנונימי/ת).
+ anonymous_unrevealed_info: ייתכן שמנהלות האוסף יחשפו את היצירה שלך, אך ישאירו
+ אותה כאנונימית. משתמשות הרשומות לקבלת עדכונים מחשבונך לא יקבלו עדכון על שינוי
+ זה. ברגע שהיצירה תיחשף היא תיכלל ברשימות התגיות, אך לא בדף היצירות שלך. בדף
+ היצירה עצמה, שם המשתמש/ת שלך יוחלף ב-"Anonymous" (אנונימי/ת).
+ changed_status:
+ anonymous:
+ html: מנהלות האוסף %{collection_link} הפכו את היצירה שלך %{work_link} לאנונימית.
+ text: מנהלות האוסף "%{collection_title}" (%{collection_url}) הפכו את היצירה
+ שלך "%{work_title}" (%{work_url}) ליצירה אנונימית.
+ anonymous_unrevealed:
+ html: מנהלות האוסף %{collection_link} הפכו את היצירה שלך %{work_link} לאנונימית
+ ושטרם נחשפה.
+ text: מנהלות האוסף "%{collection_title}" (%{collection_url}) הפכו את היצירה
+ שלך "%{work_title}" (%{work_url}) ליצירה אנונימית ושטרם נחשפה.
+ unrevealed:
+ html: מנהלות האוסף %{collection_link} הפכו את היצירה שלך %{work_link} ליצירה
+ שטרם נחשפה.
+ text: מנהלות האוסף "%{collection_title}" (%{collection_url}) הפכו את היצירה
+ שלך "%{work_title}" (%{work_url}) ליצירה שטרם נחשפה.
+ collection_items_link_text: דף ה-Approved Collection Items (יצירות מאושרות באוספים)
+ do_not_want:
+ anonymous:
+ html: אם אין ברצונך שהיצירה תהייה אנונימית, יש לבקר ב %{collection_items_link}
+ שלך כדי להסיר את היצירה מאוסף זה.
+ text: 'אם אין ברצונך שהיצירה שלך תהפוך לאנונימית, יש לבקר בדף Approved Collection
+ Items (יצירות מאושרות באוספים) שלך כדי להסיר אותה מאוסף זה: %{collection_items_url}'
+ anonymous_unrevealed:
+ html: אם אין ברצונך שהיצירה שלך תהיה אנונימית ורשומה כיצירה שטרם נחשפה,
+ יש לבקר ב%{collection_items_link} שלך כדי להסיר אותה מאוסף זה.
+ text: 'אם אין ברצונך שהיצירה שלך תהיה אנונימית ורשומה כיצירה שטרם נחשפה,
+ יש לבקר בדף Approved Collection Items (יצירות מאושרות מאוספים) שלך כדי
+ להסיר אותה מאוסף זה: %{collection_items_url}'
+ unrevealed:
+ html: אם אין ברצונך שהיצירה שלך תירשם כיצירה שטרם נחשפה, יש לבקר ב%{collection_items_link}
+ שלך כדי להסיר אותה מאוסף זה.
+ text: 'אם אין ברצונך שהיצירה שלך תירשם כיצירה שטרם נחשפה, יש לבקר בדף Approved
+ Collection Items (יצירות מאושרות באוספים) שלך כדי להסיר אותה מאוסף זה:
+ %{collection_items_url}'
+ faq_link_text: שאלות נפוצות – אוספים
+ more_info:
+ html: למידע נוסף, בקרו בדף %{faq_link}.
+ text: 'למידע נוסף, אנא בקרו בדף שאלות נפוצות – אוספים: %{faq_url}'
+ subject:
+ anonymous: "[%{app_name}] היצירה שלך הפכה לאנונימית"
+ anonymous_unrevealed: "[%{app_name}] היצירה שלך שונתה לאנונימית, והמעמד שלה
+ שונה ליצירה שטרם נחשפה."
+ unrevealed: "[%{app_name}] מעמד היצירה שלך שונה ליצירה שטרם נחשפה"
+ unrevealed_info: יצירות שטרם נחשפו לא יופיעו ברשימת התגיות או בדף היצירות שלך.
+ שימוש בקישור לדף היצירה יוביל להודעה שהיצירה טרם נחשפה, ולא יהיה ניתן לגשת
+ לתוכן שלה.
+ archivist_added_to_collection_notification:
+ approved_collection_items_page: Approved Collection Items (פריטים שאושרו לאוספים)
+ archivist_notice: כיוון שמנהלות האוסף פועלות במסגרת תפקידן כארכיונאיות של Open
+ Doors (דלתות פתוחות), הן רשאיות להוסיף את היצירה שלך לאוסף זה גם אם השבתת
+ את האפשרות להזמין אותך לאוספים. ארכיונאיות מוסיפות יצירה לאוסף רק אם היא הייתה
+ חלק מארכיון מיובא.
+ removal_instructions:
+ html: אם ברצונך להסיר את יצירתך מאוסף זה, עליך להיכנס לדף %{approved_items_link}
+ שלך.
+ text: 'אם ברצונך להסיר את יצירתך מאוסף זה, עליך להיכנס לדף Approved Collection
+ Items (פריטים שאושרו לאוספים) שלך: %{approved_items_url}.'
+ subject: "[%{app_name}][%{collection_title}] ארכיונאי/ת של Open Doors (דלתות
+ פתוחות) הוסיף/ה את היצירה שלך לאוסף"
+ work_added:
+ html: מנהלות האוסף %{collection_link} הוסיפו את היצירה שלך %{work_link} לאוסף!
+ text: מנהלות האוסף "%{collection_title}" (%{collection_url}) הוסיפו את היצירה
+ שלך "%{work_title}" (%{work_url}) לאוסף!
+ challenge_assignment_notification:
+ any: הכול
+ assignment:
+ html: קיבלת את הבקשה הבאה באתגר %{link} בארכיון משלנו!
+ description: 'תיאור:'
+ due: 'תאריך ההגשה של המשימה:'
+ html:
+ footer: קיבלת דוא"ל זה כיוון שנרשמת לאתגר %{title}. מידע נוסף על אתגר זה ופרטי
+ יצירת קשר עם המנהלות שלו זמינים ב%{footer_link}.
+ footer_link: עמוד הפרופיל של האתגר
+ look_up: ניתן למצוא משימה זו ב%{link}.
+ look_up_link: עמוד ה-Assignments (משימות) שלך
+ optional_tags: 'תגיות אפשריות:'
+ prompts: 'פרומפטים:'
+ prompt_url: 'כתובת האתר (URL) של הפרומפט:'
+ recipient: 'מקבלת:'
+ recipient_missing: 'ללא: יש לפנות למנהלת האתגר לקבלת סיוע!'
+ subject: "[%{app_name}][%{collection_title}] המשימה שלך!"
+ text:
+ assignment: קיבלת את הבקשה הבאה באתגר "%{collection_title}" (%{collection_url})
+ בארכיון משלנו!
+ footer: קיבלת דוא"ל זה כיוון שנרשמת לאתגר %{title} (%{url}). מידע נוסף על
+ אתגר זה ופרטי יצירת קשר עם המנהלות שלו זמינים בכתובת %{profile_url}.
+ look_up: ניתן למצוא משימה זו בעמוד ה-Assignments (משימות) שלך בכתובת %{link}.
+ change_email:
+ changed:
+ html: '%{login}, כתובת הדוא"ל המשויכת לחשבונך שונתה ל-%{email}'
+ text: '%{login}, כתובת הדוא"ל המשויכת לחשבונך שונתה ל-%{email}'
+ subject: '[%{app_name}] כתובת הדוא"ל שלך שונתה'
+ collection_notification:
+ assignments_sent:
+ complete: כל המטלות נשלחו כעת.
+ subject: המטלות נשלחו
+ html:
+ received_message: 'קיבלת הודעה לגבי האוסף שלך %{collection_link}:'
+ text:
+ received_message: 'קיבלת הודעה לגבי האוסף שלך "%{collection_title}" (%{collection_url}):'
+ creatorship_notification:
+ explanation: כיוצר/ת שותף/ה ליצירה, ניתן להוסיפך לפרקים חדשים, ללא תלות בהגדרות
+ היוצר/ת השותף/ה שלך. כמו כן, תוסף/י לכל סדרה אליה תתווסף היצירה.
+ html:
+ creation: "%{creation_link} מאת %{pseud_links}"
+ edit_chapter: לערוך את הפרק
+ edit_series: לערוך את הסדרה
+ remove_chapter: אם הוספת בטעות, או שאינך רוצה להיות רשום/ה כיוצר/ת, באפשרותך
+ %{edit_chapter_link} על מנת להסיר את עצמך כיוצר/ת.
+ remove_series: אם הוספת בטעות, או שאינך רוצה להיות רשום/ה כיוצר/ת, באפשרותך
+ %{edit_series_link} על מנת להסיר את עצמך כיוצר/ת.
+ intro_chapter: 'המשתמש/ת %{adding_user} רשם/ה את שם העט שלך %{pseud} כיוצר/ת
+ שותף/ה לפרק הבא:'
+ intro_series: 'המשתמש/ת %{adding_user} רשם/ה את שם העט שלך %{pseud} כיוצר/ת
+ שותף/ה לסדרה הבאה:'
+ subject: "[%{app_name}] התראת יוצר/ת שותף/ה"
+ text:
+ creation: "%{title} (%{url}) מאת %{pseuds}"
+ remove_chapter: 'אם הוספת בטעות, או שאינך רוצה להיות רשום/ה כיוצר/ת, באפשרותך
+ לערוך את הפרק על מנת להסיר את עצמך כיוצר/ת: %{url}'
+ remove_series: 'אם הוספת בטעות, או שאינך רוצה להיות רשום/ה כיוצר/ת, באפשרותך
+ לערוך את הסדרה על מנת להסיר את עצמך כיוצר/ת: %{url}'
+ creatorship_notification_archivist:
+ explanation: כיוון שהארכיונאית פועלת במסגרת תפקידה במיזם Open Doors (דלתות פתוחות),
+ יש ביכולתה לשייך את שם העט שלך ללא שליחת בקשה, גם אם ביטלת את האפשרות להגדירך
+ כיוצר/ת שותף/ה.
+ html:
+ creation: "%{creation_link} מאת %{pseud_links}"
+ edit_chapter: לערוך את הפרק
+ edit_series: לערוך את הסדרה
+ edit_work: לערוך את היצירה
+ remove_chapter: אם נוספות בטעות, או אם אינך רוצה להופיע כיוצר/ת, באפשרותך
+ %{edit_chapter_link} על מנת להסיר את עצמך.
+ remove_series: אם נוספות בטעות, או אם אינך רוצה להופיע כיוצר/ת, באפשרותך %{edit_series_link}
+ על מנת להסיר את עצמך.
+ remove_work: אם נוספות בטעות, או אם אינך רוצה להופיע כיוצר/ת, באפשרותך %{edit_work_link}
+ על מנת להסיר את עצמך.
+ intro_chapter: 'המשתמשת %{archivist} הוסיפה את שם העט שלך %{pseud} בתור יוצר/ת
+ שותף/ה של הפרק הבא:'
+ intro_series: 'המשתמשת %{archivist} הוסיפה את שם העט שלך %{pseud} בתור יוצר/ת
+ שותף/ה של הסדרה הבאה:'
+ intro_work: 'המשתמשת %{archivist} הוסיפה את שם העט שלך %{pseud} בתור יוצר/ת
+ שותף/ה של היצירה הבאה:'
+ subject: "[%{app_name}] התראה על הוספתך כיוצר/ת שותף/ה על ידי ארכיונאית"
+ text:
+ creation: "%{title} (%{url}) מאת %{pseuds}"
+ remove_chapter: 'אם נוספות בטעות, או אם אינך רוצה להופיע כיוצר/ת, באפשרותך
+ לערוך את הפרק על מנת להסיר את עצמך: %{url}'
+ remove_series: 'אם נוספות בטעות, או אם אינך רוצה להופיע כיוצר/ת, באפשרותך
+ לערוך את הסדרה על מנת להסיר את עצמך: %{url}'
+ remove_work: 'אם נוספת בטעות, או אם אינך רוצה להופיע כיוצר/ת, באפשרותך לערוך
+ את היצירה על מנת להסיר את עצמך: %{url}'
+ creatorship_request:
+ html:
+ creation: "%{creation_link} מאת %{pseud_links}"
+ instructions: את ההזמנה ניתן לקבל או לדחות בדף %{page_name} שלך.
+ page_name: Co-Creator Requests (בקשות להוספת יוצר/ת שותף/ה)
+ intro_chapter: 'המשתמש/ת %{inviting_user} הזמין/ה את שם העט שלך %{pseud} להירשם
+ כיוצר/ת שותף/ה של הפרק:'
+ intro_series: 'המשתמש/ת %{inviting_user} הזמין/ה את שם העט שלך %{pseud} להירשם
+ כיוצר/ת שותף/ה של הסדרה:'
+ intro_work: 'המשתמש/ת %{inviting_user} הזמין/ה את שם העט שלך %{pseud} להירשם
+ כיוצר/ת שותף/ה של היצירה:'
+ subject: "[%{app_name}] בקשה להוספתך כיוצר/ת שותף/ה"
+ text:
+ creation: "%{title} (%{url}) מאת %{pseuds}"
+ instructions: 'את ההזמנה ניתן לקבל או לדחות בדף Co-Creator Requests (בקשות
+ להוספת יוצר/ת שותף/ה) שלך: %{url}'
+ delete_work_notification:
+ attachment: לנוחיותך, מצורף בזאת עותק של יצירתך.
+ deleted_other:
+ html: יצירתך %{title} נמחקה לבקשת %{pseud}.
+ text: יצירתך "%{title}" נמחקה לבקשת %{pseud}.
+ deleted_yourself:
+ html: יצירתך %{title} נמחקה על פי בקשתך.
+ text: יצירתך "%{title}" נמחקה על פי בקשתך.
+ questions:
+ html: אם יש לך שאלות, אנא %{support}
+ text: אם יש לך שאלות, אנא %{support} (%{url}).
+ subject: "[%{app_name}] יצירתך נמחקה"
+ support: פנו לצוות התמיכה
+ invite_increase_notification:
+ html:
+ body:
+ many: רק רצינו ליידע אותך שקיבלת %{count} הזמנות חדשות, בהן ניתן להשתמש
+ על מנת ליצור חשבון חדש בארכיון. באפשרותך להזמין חבר/ה דרך %{invitation_page_link}.
+ one: רק רצינו ליידע אותך שקיבלת הזמנה חדשה, בה ניתן להשתמש על מנת ליצור
+ חשבון חדש בארכיון. באפשרותך להזמין חבר/ה דרך %{invitation_page_link}.
+ other: רק רצינו ליידע אותך שקיבלת %{count} הזמנות חדשות, בהן ניתן להשתמש
+ על מנת ליצור חשבון חדש בארכיון. באפשרותך להזמין חבר/ה דרך %{invitation_page_link}.
+ two: רק רצינו ליידע אותך שקיבלת %{count} הזמנות חדשות, בהן ניתן להשתמש על
+ מנת ליצור חשבון חדש בארכיון. באפשרותך להזמין חבר/ה דרך %{invitation_page_link}.
+ invitation_page_link_text: דף ההזמנות שלך
+ subject: "[%{app_name}] הזמנות חדשות"
+ text:
+ body:
+ many: רק רצינו ליידע אותך שקיבלת %{count} הזמנות חדשות, בהן ניתן להשתמש
+ על מנת ליצור חשבון חדש בארכיון. באפשרותך להזמין חבר/ה דרך דף Invitations
+ (הזמנות), %{invitation_page_url}.
+ one: רק רצינו ליידע אותך שקיבלת הזמנה חדשה, בה ניתן להשתמש על מנת ליצור
+ חשבון חדש בארכיון. באפשרותך להזמין חבר/ה דרך דף Invitations (הזמנות),
+ %{invitation_page_url}.
+ other: רק רצינו ליידע אותך שקיבלת %{count} הזמנות חדשות, בהן ניתן להשתמש
+ על מנת ליצור חשבון חדש בארכיון. באפשרותך להזמין חבר/ה דרך דף Invitations
+ (הזמנות), %{invitation_page_url}.
+ two: רק רצינו ליידע אותך שקיבלת %{count} הזמנות חדשות, בהן ניתן להשתמש על
+ מנת ליצור חשבון חדש בארכיון. באפשרותך להזמין חבר/ה דרך דף Invitations
+ (הזמנות), %{invitation_page_url}.
+ invite_request_declined:
+ main:
+ many: צר לנו לבשר לך כי אין באפשרותנו להיענות לבקשתך ל-%{count} הזמנות חדשות
+ כעת.
+ one: צר לנו לבשר לך כי אין באפשרותנו להיענות לבקשתך להזמנה חדשה כעת.
+ other: צר לנו לבשר לך כי אין באפשרותנו להיענות לבקשתך ל-%{count} הזמנות חדשות
+ כעת.
+ two: צר לנו לבשר לך כי אין באפשרותנו להיענות לבקשתך ל-%{count} הזמנות חדשות
+ כעת.
+ reason: 'תוכן בקשתך:'
+ subject: "[%{app_name}] בקשתך לקוד הזמנה נוסף נדחתה"
+ recipient_notification:
+ html:
+ collection: יצירה הוקדשה לך במתנה באוסף %{collection_link} ב-AO3!
+ no_collection: יצירה הוקדשה לך במתנה ב-AO3!
+ subject:
+ collection: "[%{app_name}][%{collection_title}] קיבלת יצירה במתנה מ- %{collection_title}"
+ no_collection: "[%{app_name}] קיבלת יצירה במתנה"
+ text:
+ collection: יצירה הוקדשה לך במתנה באוסף "%{collection_title}" (%{collection_url})
+ ב-AO3!
+ signup_notification:
+ activate:
+ html: אנא %{activate_account_link}.
+ text: 'אנא לחץ/י על קישור זה כדי להפעיל את חשבונך: %{activate_account_url}'
+ activate_your_account: לחץ/י על קישור זה כדי להפעיל את חשבונך
+ admin_posts: חדשות AO3
+ bye: כולנו תקווה שתהנה/י מהשימוש באתר.
+ contact_support: צרו קשר עם התמיכה
+ faq: שאלות נפוצות
+ features:
+ html: ברגע שחשבונך יופעל, תוכל/י לפרסם יצירות, להגדיר רישומים לקבלת עדכונים
+ בדוא"ל כדי לקבל הודעה כאשר חשבונות או יצירות מועדפים מתעדכנים, להגדיר
+ העדפות כדי להתאים באופן אישי את אופן ההצגה של האתר, לעקוב אחר היצירות אליהן
+ ניגשת בעבר באמצעות ההיסטוריה, ודברים רבים נוספים.
+ text: ברגע שחשבונך יופעל, תוכל/י לפרסם יצירות, להגדיר רישומים לקבלת עדכונים
+ בדוא"ל כדי לקבל הודעה כאשר חשבונות או יצירות מועדפים מתעדכנים, להגדיר
+ העדפות כדי להתאים באופן אישי את אופן ההצגה של האתר, לעקוב אחר היצירות אליהן
+ ניגשת בעבר באמצעות ההיסטוריה, ודברים רבים נוספים.
+ information:
+ html: מידע ועצות בנוגע לשימוש בארכיון זמינים ב%{faq_link}. את החדשות האחרונות
+ אודות פיתוח האתר ניתן למצוא ב%{admin_posts_link}. אם דרושה לך עזרה נוספת,
+ אם נתקלת בבעייה באתר או אם יש לך שאלות או הערות, אנא %{contact_support_link},
+ שתמיד שמח לעזור.
+ text: 'מידע ועצות בנוגע לשימוש בארכיון זמינים בשאלות הנפוצות שלנו ב-%{faq_url}.
+ את החדשות האחרונות אודות פיתוח האתר ניתן למצוא בחדשות AO3 %{admin_posts_url}.
+ אם דרושה לך עזרה נוספת, אם נתקלת בבעייה באתר או אם יש לך שאלות או הערות,
+ אנא צור/צרי קשר עם צוות התמיכה שלנו, שתמיד שמח לעזור: %{contact_support_url}'
+ welcome: ברוכים הבאים לארכיון משלנו, %{login}!
diff --git a/config/locales/phrase-exports/hi.yml b/config/locales/phrase-exports/hi.yml
new file mode 100755
index 0000000..0e37e95
--- /dev/null
+++ b/config/locales/phrase-exports/hi.yml
@@ -0,0 +1,532 @@
+---
+hi:
+ activerecord:
+ attributes:
+ archive_warning:
+ name_with_colon:
+ one: 'चेतावनी:'
+ other: 'चेतावनियाँ:'
+ category:
+ name_with_colon:
+ one: 'वर्ग:'
+ other: 'वर्ग:'
+ character:
+ name_with_colon:
+ one: 'पात्र:'
+ other: 'पात्र:'
+ fandom:
+ name_with_colon:
+ one: 'प्रशंसक समूह:'
+ other: 'प्रशंसक समूह:'
+ freeform:
+ name_with_colon:
+ one: 'अतिरिक्त टैग:'
+ other: 'अतिरिक्त टैग्स:'
+ rating:
+ name_with_colon: 'रेटिंग:'
+ relationship:
+ name_with_colon:
+ one: 'संबंध:'
+ other: 'संबंध:'
+ work:
+ chapter_total_display: अध्याय
+ summary: सारांश
+ models:
+ archive_warning:
+ one: चेतावनी
+ other: चेतावनियाँ
+ category:
+ one: वर्ग
+ other: वर्ग
+ chapter:
+ one: अध्याय
+ other: अध्याय
+ character:
+ one: पात्र
+ other: पात्र
+ fandom:
+ one: प्रशंसक समूह
+ other: प्रशंसक समूह
+ freeform:
+ one: अतिरिक्त टैग
+ other: अतिरिक्त टैग्स
+ rating:
+ one: रेटिंग
+ other: रेटिंग्स
+ relationship:
+ one: संबंध
+ other: संबंध
+ series:
+ one: श्रृंखला
+ other: श्रृंखला
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ one: अतिथि
+ other: "%{count} अतिथिगण"
+ left_kudos:
+ html:
+ one: "%{givers_list} ने %{commentable_link} पर कुङोस दिया है।"
+ other: "%{givers_list} ने %{commentable_link} पर कुङोस दिए हैं।"
+ text:
+ one: "%{givers_list} ने %{commentable_title} (%{commentable_url}) पर कुङोस
+ दिया है।"
+ other: "%{givers_list} ने %{commentable_title} (%{commentable_url}) पर कुङोस
+ दिए हैं।"
+ single_guest:
+ giver: एक अतिथि
+ html: "%{giver} ने %{commentable_link} पर कुङोस दिया है।"
+ text: एक अतिथि ने %{commentable_title} (%{commentable_url}) पर कुङोस दिया
+ है।
+ subject: "[%{app_name}] आपको कुङोस मिला हैं!"
+ mailer:
+ general:
+ closing:
+ formal: निष्ठा से,
+ informal: उत्तम,
+ creation:
+ link_with_word_count: "%{creation_link} (%{word_count})"
+ title_with_chapter_number: अध्याय %{position} का %{title}
+ title_with_word_count: '"%{creation_title}" (%{word_count})'
+ word_count:
+ one: "%{count} शब्द"
+ other: "%{count} शब्द"
+ footer:
+ about:
+ html: AO3 एक प्रशंसकों द्वारा संचालित और फैन-समर्थित संग्रह है जो %{your_donations_link} पर निर्भर करता है।
+ text: 'AO3 एक प्रशंसकों द्वारा संचालित और फैन-समर्थित संग्रह है जो आपके दान: %{your_donations_url} पर निर्भर करता है।'
+ your_donations: आपके दान
+ sent_at: "%{sent_at} पर भेजा गया।"
+ why_policy_abuse:
+ contact_policy_abuse: नीति और दुरुपयोग से संपर्क करें।
+ html: यदि आपको नही समझ रहा है कि आपको यह ईमेल क्यों भेजा गया है, तो कृपया नीति और दुरुपयोग से संपर्क करें %{contact_policy_abuse_link}।
+ text: यदि आपको नही समझ रहा है कि आपको यह ईमेल क्यों भेजा गया है, तो कृपया नीति और दुरुपयोग से संपर्क करें %{contact_policy_abuse_url}।
+ why_support:
+ contact_support: सहायता से सम्पर्क करें
+ html: यदि आपको नही समझ रहा है कि आपको यह ईमेल क्यों भेजा गया है, तो कृपया सहायता से संपर्क करें %{contact_support_link}।
+ text: यदि आपको नही समझ रहा है कि आपको यह ईमेल क्यों भेजा गया है, तो कृपया सहायता से संपर्क करें %{contact_support_url}।
+ greeting:
+ formal:
+ addressed_html: नमस्कार %{name},
+ unaddressed: नमस्कार!
+ informal:
+ addressed_html: नमस्कार, %{name}!
+ unaddressed: नमस्कार!
+ introductory: Archive of Our Own AO3 (हमारा अपना संग्रह) – से नमस्कार!
+ metadata_label_indicator: ":"
+ signature:
+ abuse_team: AO3 नीति और दुरुपयोग समिति
+ app_short_name: AO3
+ open_doors: Open Doors (खुले दरवाज़े) समिति
+ parent_org: Organization for Transformative Works – OTW (परिवर्तनकारी कार्य हेतु संगठन)
+ support: AO3 सहायता समिति
+ users:
+ mailer:
+ reset_password_instructions:
+ expiration: अगर आप इस लिंक को एक हफ्ते में आपने पासवर्ड को बदलने के लिए इस्तेमाल
+ नहीं करेंगे, तो उसकि समय सीमा समाप्त हो जाएगी और आपको नयी लिंक मांगनी होगी।
+ intro: किसी ने आपके खाते के पासवर्ड को रिसेट करने का अनुरोध किया है। आप अपने
+ खाते के पासवर्ड को दी हुई लिंक पे जाकर, नया पासवर्ड ङालकर बदल सकते हैं।
+ link_title: मेरे पासवर्ड कि बदली।
+ subject: "[%{app_name}] पासवर्ड को रीसेट करें।"
+ unrequested: अगर आपने इस पासवर्ड रिसेट का अनुरोध नहीं किया है, आप इस ईमेल
+ को नज़रअंदाज़ कर सकते हैं और आपका पुराना पासवर्ड चलता रहेगा।
+ user_mailer:
+ admin_deleted_work_notification:
+ bye: आपके संदर्भ के लिए आपके कार्य की एक कॉपी जोड़ी गयी है।
+ contact_abuse: हमारी नीति और दुरुपयोग को संपर्क कीजिये
+ deleted:
+ html: आपके कार्य %{title} को AO3 से एक साइट प्रशासक द्वारा मिटाया गया है।
+ text: आपका कार्य "%{title}" को एक साइट प्रशासक द्वारा मिटा दिया है।
+ html:
+ tos_violation: अगर सम्भव है की आपके कार्य ने AO3 की सेवा की शर्तों का उल्लधन
+ किया है, कृपया %{contact_abuse_link}।
+ import_project:
+ html: अगर आपका कार्य किसी आयात परियोजना जिसे हमारी Open Doors (खुले दरवाज़े)
+ टीम संभाल रही है, तो कृपया %{opendoors_link} आपके प्रश्नो के लिए।
+ text: अगर आपका कार्य किसी आयात परियोजना जिसे हमारी Open Doors (खुले दरवाज़े)
+ टीम संभाल रही है, तो कृपया खुले दरवाज़े को संपर्क कीजिये (%{opendoors_link})
+ आपके प्रश्नो के लिए।
+ opendoors: खुले दरवाज़े को संपर्क कीजिये
+ subject: "[%{app_name}] आपके कार्य को एक प्रशासक द्वारा मिटा दिया गया है।"
+ text:
+ tos_violation: अगर सक्य है की आपके कार्य ने AO3 की सेवा की शर्तों का उल्लधन
+ किया है, कृपया हमारी नीति और दुरुपयोग को संपर्क कीजिये (%{contact_abuse_url})।
+ admin_hidden_work_notification:
+ access: जब तक आपका कार्य छुपा हुआ है, आप उसे ऊपर दी हुई लिंक से उपलब्ध कर सकेंगे,
+ पर वह आपके कार्य पेज पर नहीं सूचित किया जाएगा, और वह AO3 के बाकी उपयोगकर्ताओं
+ को उपलब्ध नहीं होगा।
+ check_email: कृपया आपके ईमेल को देखिये, आपके स्पैम फ़ोल्डर सहित , क्योकि शायद
+ नीति और दुरुपयोग टीम आपको पहले से संपर्क कर दिया होगा आपके कार्य को क्यों
+ छुपाया गया है वह समजाते हुए।
+ contact_abuse: नीति और दुरुपयोग को संपर्क
+ html:
+ help: अगर आप पक्का नहीं है आपके कार्य को क्यों छुपाया गया था, और आपको इस मामले
+ सम्बंधित और कोई संपर्क नहीं मिला है, कृपया सीधे %{contact_abuse_link} कीजिये।
+ hidden: आपके कार्य %{title} को नीति और दुरुपयोग टीम के द्वारा छुपाया गया है
+ और अब वह सार्वजनिक रूप से सुलभ अब नहीं है।
+ tos_violation: अगर आपके कार्य को छुपाया गया था क्योंकि वह AO3 की %{tos_link}
+ का उल्लंघन कर रहा है, तो आपको कार्रवाई करनी होगी उल्लंधन को सही करने के
+ लिए। आपके कार्य को सेवा की शर्तों के अनुपालन में लाने में असफलता आपके कार्य
+ को AO3 से हटा सकती है।
+ subject: "[%{app_name}] आपके कार्य को नीति और दुरुपयोग टीम के द्वारा छुपाया
+ गया है।"
+ text:
+ help: 'अगर आप पक्का नहीं है आपके कार्य को क्यों छुपाया गया था, और आपको इस
+ मामले सम्बंधित और कोई संपर्क नहीं मिला है, कृपया सीधे नीति और दुरुपयोग को
+ संपर्क कीजिये: %{contact_abuse_url}।'
+ hidden: आपके कार्य "%{title}" (%{work_url}) को नीति और दुरुपयोग टीम के द्वारा
+ छुपाया गया है और वह अब सार्वजनिक रूप से सुलभ नहीं है।
+ tos_violation: अगर आपके कार्य को छुपाया गया था क्योंकि वह AO3 की सेवा की शर्तें
+ (%{tos_url}) का उल्लंघन कर रहा है, तो आपको कार्रवाई करनी होगी उल्लंधन को
+ सही करने के लिए। आपके कार्य को सेवा की शर्तों के अनुपालन में लाने में असफलता
+ आपके कार्य को AO3 से हटा सकती है।
+ tos: सेवा की शर्तें
+ anonymous_or_unrevealed_notification:
+ anonymous_info: अज्ञात कार्य हमरे टैग सूचीकरण में शामिल होते है पर आप के कार्य
+ पेज पर नहीं। कार्य पर आपका उपयोगकर्ता नाम को "Anonymous" (अज्ञात) के साथ बदला
+ जाएगा।
+ anonymous_unrevealed_info: संग्रह की देखरेख करने वाले लोग आपके कार्य को शायद
+ प्रकाशित करे पर उसे अज्ञात रखे। जिन लोगों ने आपको सब्सक्राइब किया हैं उन्हें
+ इस बदलाव के बारे में सुचना नहीं मिलेगी। प्रकाशित हुए बाद आपका टैग सूचियों
+ में शामिल हो जाएगा, पर आपके कार्य पेज पर नहीं। कार्य पर आपका उपयोगकर्ता नाम
+ को "Anonymous" (अज्ञात) के साथ बदला जाएगा।
+ changed_status:
+ anonymous:
+ html: जो लोग %{collection_link} संग्रह की देखरेख करते हैं उन्होंने आपके
+ %{work_link} कार्य की अवस्था को अज्ञात स्थिति में बदल दिया है ।
+ text: जो लोग “%{collection_title}” संग्रह (%{collection_url}) की देखरेख
+ करते हैं उन्होंने आपके “%{work_title}” (%{work_url}) कार्य की अवस्था को
+ अज्ञात स्थिति में बदल दिया है ।
+ anonymous_unrevealed:
+ html: जो लोग %{collection_link} संग्रह की देखरेख करते हैं उन्होंने आपके
+ %{work_link} कार्य की अवस्था को अज्ञात और अप्रकाशित स्थिति में बदल दिया
+ है ।
+ text: जो लोग "%{collection_title}" (%{collection_url}) संग्रह की देखरेख
+ करते हैं उन्होंने आपके "%{work_title}" (%{work_url}) कार्य की अवस्था को
+ अज्ञात और अप्रकाशित स्थिति में बदल दिया है ।
+ unrevealed:
+ html: जो लोग %{collection_link} संग्रह की देखरेख करते हैं उन्होंने आपके
+ %{work_link} कार्य की अवस्था को अप्रकाशित स्थिति में बदल दिया है ।
+ text: जो लोग "%{collection_title}" (%{collection_url}) संग्रह की देखरेख
+ करते हैं उन्होंने आपके "%{work_title}" (%{work_url}) कार्य की अवस्था को
+ अप्रकाशित स्थिति में बदल दिया है ।
+ collection_items_link_text: Approved Collection Items (स्वीकृत संग्रह वस्तु)
+ पेज।
+ do_not_want:
+ anonymous:
+ html: अगर आप आपके कार्य को अज्ञात करना नहीं चाहते है, कृपया %{collection_items_link}
+ पर जाइये कार्य को संग्रह से निकालने के लिए।
+ text: अगर आप आपके कार्य को अज्ञात करना नहीं चाहते है, कृपया Approved Collection
+ Items (स्वीकृत संग्रह वस्तु) पेज पर जाइये कार्य को इस संग्रह (%{collection_items_url})
+ से निकालने के लिए।
+ anonymous_unrevealed:
+ html: अगर आप आपके कार्य को अज्ञात और अप्रकाशित करना नहीं चाहते है, कृपया
+ %{collection_items_link} पर जाइये आपके कार्य को संग्रह से निकालने के लिए।
+ text: अगर आप आपके कार्य को अज्ञात और अप्रकाशित करना नहीं चाहते है, कृपया
+ Approved Collection Items (स्वीकृत संग्रह वस्तु) पेज पर जाइये कार्य को
+ इस संग्रह (%{collection_items_url}) से निकालने के लिए।
+ unrevealed:
+ html: अगर आप आपके कार्य को अप्रकाशित करना नहीं चाहते है, कृपया %{collection_items_link}
+ पर जाइये आपके कार्य को संग्रह से निकालने के लिए।
+ text: अगर आप आपके कार्य को अप्रकाशित करना नहीं चाहते है, कृपया Approved
+ Collection Items (स्वीकृत संग्रह वस्तु) पेज पर जाइये कार्य को इस संग्रह
+ (%{collection_items_url}) से निकालने के लिए।
+ faq_link_text: संग्रह FAQ
+ more_info:
+ html: अधिक जानकारी के लिए, कृपया हमारे %{faq_link} को देखिये।
+ text: 'ज्यादा जानकारी के लिए, कृपया हमारे संग्रह FAQ: %{faq_url} को देखिये।'
+ subject:
+ anonymous: "[%{app_name}] आपके कार्य को अज्ञात किया गया हैं।"
+ anonymous_unrevealed: "[%{app_name}] आपके कार्य को अज्ञात और अप्रकाशित किया
+ गया हैं।"
+ unrevealed: "[%{app_name}] आपके कार्य को अप्रकाशित किया गया हैं।"
+ unrevealed_info: अप्रकाशित कार्य हमरे टैग सूचीकरण में या आप के कार्य पेज पर
+ शामिल नहीं होते है। जो भी आपके कार्य तक एक लिंक से आएँगे, उन्हें एक सूचना
+ मिलेगी की वह अभी अप्रकाशित है और वह उसके अंदर के विषय को अभिगम नहीं कर पाएंगे।
+ archivist_added_to_collection_notification:
+ approved_collection_items_page: Approved Collection Items (स्वीकृत संग्रह वस्तुएँ)'
+ पृष्ठ
+ archivist_notice: क्योंकि संग्रह अनुरक्षक अपने Open Doors (खुले दरवाज़े) संग्राहकों
+ के पद पर अपनी आधिकारिक क्षमता में कार्य कर रहे हैं, उन्हें आपके कार्य को इस
+ संग्रह में जोड़ने की अनुमति है, यद्यपि आपने संग्रह आमंत्रण अक्षम किए हों।
+ संग्राहक एक कार्य को केवल तब संग्रह में जोड़ेंगे यदि उसे एक आयातित संग्रह
+ पर होस्ट किया गया था।
+ removal_instructions:
+ html: अगर आप अपना कार्य इस संग्रह से हटाना चाहते हैं, कृपया अपने %{approved_items_link}
+ पर जाएँ।
+ text: 'अगर आप अपना कार्य इस संग्रह से हटाना चाहते हैं, कृपया अपने Approved
+ Collection Items (स्वीकृत संग्रह वस्तुएँ) पृष्ठ पर जाएँ: %{approved_items_url}।'
+ subject: "[%{app_name}][%{collection_title}] एक Open Doors (खुले दरवाज़े) संग्राहक
+ ने आपके कार्य को एक संग्रह में जोड़ा है।"
+ work_added:
+ html: "%{collection_link} के संग्रह अनुरक्षकों ने आपका कार्य %{work_link}
+ अपने संग्रह में जोड़ा है!"
+ text: '"%{collection_title}" (%{collection_url}) के संग्रह अनुरक्षकों ने आपका
+ कार्य "%{work_title}" (%{work_url}) अपने संग्रह में जोड़ा है!'
+ challenge_assignment_notification:
+ any: कोई भी
+ assignment:
+ html: आपको AO3 पर %{link} चुनौती में निम्नलिखित अनुरोध सौंपा गया है!
+ description: 'विवरण:'
+ due: 'यह कार्यभार देय है:'
+ html:
+ footer: आपको यह ईमेल इसलिए मिल रहा है क्योंकि आपने %{title} चुनौती के लिए
+ साइन-अप किया है। इस चुनौती के बारे में अधिक जानकारी और मंदकों की संपर्क
+ जानकारी के लिए, कृपया %{footer_link} पर जाएँ।
+ footer_link: चुनौती प्रोफ़ाइल पृष्ठ
+ look_up: आप इस कार्यभार को %{link} पर देख सकते हैं।
+ look_up_link: आपका Assignments (कार्यभार) पृष्ठ
+ optional_tags: 'वैकल्पिक टैग:'
+ prompts: 'प्रॉम्प्ट:'
+ prompt_url: 'प्रॉम्प्ट URL:'
+ recipient: 'प्राप्तकर्ता:'
+ recipient_missing: 'कोई नहीं: मदद के लिए मंदक से संपर्क करें!'
+ subject: "[%{app_name}][%{collection_title}] आपका कार्यभार!"
+ text:
+ assignment: आपको AO3 पर "%{collection_title}" (%{collection_url}) चुनौती में
+ निम्नलिखित अनुरोध सौंपा गया है!
+ footer: आपको यह ईमेल इसलिए मिल रहा है क्योंकि आपने %{title} चुनौती (%{url})
+ के लिए साइन-अप किया है। इस चुनौती के बारे में अधिक जानकारी और मंदकों के
+ संपर्क की जानकारी के लिए, कृपया %{profile_url} पर जाएँ।
+ look_up: आप इस कार्यभार को %{link} पर आपके Assignments (कार्यभार) पृष्ठ से
+ देख सकते हैं।
+ change_email:
+ changed:
+ html: "%{login}, आपके खाते से संबंधित ईमेल को %{email} से बदल दिया गया है।"
+ text: "%{login}, आपके खाते से संबंधित ईमेल को %{email} से बदल दिया गया है।"
+ subject: "[%{app_name}] ईमेल में बदलाव"
+ claim_notification:
+ access:
+ contact_support: AO3 सहायता से सम्पर्क करें
+ html: संग्रह के आधार पर, हो सकता है कि आपके कार्यों को केवल पंजीकृत उपयोगकर्ताओं
+ तक ही सीमित रखा गया हो (उन्हें Google खोजों से बाहर रखने के लिए)। यदि यह
+ मामला है, तो कार्य केवल लॉग-इन उपयोगकर्ताओं द्वारा ही पहुंच योग्य होंगे
+ जब तक कि आप उन्हें पूरी तरह से दृश्यमान बनाना नहीं चुनते। अपने कार्यों को
+ अनलॉक करने, अनाथ करने या हटाने में सहायता के लिए, कृपया %{contact_support_link}।
+ text: संग्रह के आधार पर, हो सकता है कि आपके कार्यों को केवल पंजीकृत उपयोगकर्ताओं
+ तक ही सीमित रखा गया हो (उन्हें Google खोजों से बाहर रखने के लिए)। यदि यह
+ मामला है, तो कार्य केवल लॉग-इन उपयोगकर्ताओं द्वारा ही पहुंच योग्य होंगे
+ जब तक कि आप उन्हें पूरी तरह से दृश्यमान बनाना नहीं चुनते। अपने कार्यों को
+ अनलॉक करने, अनाथ करने या हटाने में सहायता के लिए, कृपया %{support_url} पर
+ AO3 सहायता से संपर्क करें।
+ email_tips: यदि आप हमसे संपर्क कर रहे हैं, तो कृपया अपने सुरक्षित संपर्कों की
+ सूची में @transformativeworks.org से ईमेल पते जोड़ें और हमारे उत्तर के लिए
+ अपने स्पैम फ़ोल्डर्स की जांच करें।
+ introduction:
+ ao3_name: Archive of Our Own – AO3 (हमारा अपना संग्रह)
+ html: आपको यह ई-मेल इसलिए प्राप्त हो रहा है क्योंकि आपके पास एक फैनवर्क्स
+ संग्रह में काम था जिसे %{open_doors_name_link} द्वारा %{app_link} में आयात
+ किया गया है। क्योंकि यह ई-मेल पता आयातित संग्रह पर पंजीकृत पते से जुड़ा
+ हुआ है, संबंधित फैनवर्क्स (नीचे सूचीबद्ध) स्वचालित रूप से आपके AO3 खाते
+ में जोड़ दिए गए हैं।
+ open_doors_name: Open Doors (खुले दरवाज़े)
+ text: 'आपको यह ई-मेल इसलिए प्राप्त हो रहा है क्योंकि आपके पास एक फैनवर्क्स
+ संग्रह में काम था जिसे Open Doors (खुले दरवाज़े)(%{open_doors_url}) द्वारा
+ Archive of Our Own – AO3 (हमरा अपना संग्रह): %{app_url} में आयात किया गया
+ है। क्योंकि यह ई-मेल पता आयातित संग्रह पर पंजीकृत पते से जुड़ा हुआ है, संबंधित
+ फैनवर्क्स (नीचे सूचीबद्ध) स्वचालित रूप से आपके AO3 खाते में जोड़ दिए गए
+ हैं।'
+ mistake:
+ contact_open_doors: खुले दरवाज़े से सम्पर्क करें
+ html: यदि यह एक गलती है और ये आपके कार्य नहीं हैं, तो कृपया इन्हें न हटाएं!
+ कृपया केवल %{contact_open_doors_link} और हम इसे सुलझा लेंगे।
+ text: यदि यह एक गलती है और ये आपके कार्य नहीं हैं, तो कृपया इन्हें न हटाएं!
+ कृपया केवल खुले दरवाज़े से सम्पर्क करें (%{open_doors_url}) और हम इसे सुलझा
+ लेंगे।
+ more_info:
+ ao3_news: AO3 न्यूज़
+ contact_support: AO3 सहायता से सम्पर्क करें
+ faq_page: FAQ (अक्सर पूछे जाने वाले प्रश्न) पृष्ठ
+ html: आप %{ao3_news_link} पर हालिया संग्रह चालों के बारे में घोषणाएं पढ़ सकते
+ हैं, और खुले दरवाज़े के %{faq_page_link} या %{tutorial_page_link} पर अतिरिक्त
+ जानकारी पा सकते हैं। ऐसे किसी भी प्रश्न के लिए जिसका उत्तर FAQ, ट्यूटोरियल
+ या इस ई-मेल में नहीं दिया गया है, कृपया %{contact_support_link}।
+ text: आप AO3 न्यूज़ (%{news_url}) पर हालिया संग्रह चालों के बारे में घोषणाएँ
+ पढ़ सकते हैं, और खुले दरवाज़े के FAQ पेज (%{open_doors_faq_url}) या ट्यूटोरियल
+ पेज (%{open_doors_tutorial_url}) पर अतिरिक्त जानकारी पा सकते हैं। FAQ, ट्यूटोरियल
+ या इस ई-मेल में उत्तर न दिए गए किसी भी प्रश्न के लिए, कृपया %{support_url}
+ पर सहायता से संपर्क करें।
+ tutorial_page: ट्यूटोरियल पृष्ठ
+ other_works:
+ contact_open_doors: खुले दरवाज़े से सम्पर्क करें
+ html: यदि आपके पास किसी ई-मेल पते के तहत आयातित संग्रह पर अन्य कार्य हैं,
+ जिन्हें आप अब एक्सेस नहीं कर सकते हैं, तो कृपया %{contact_open_doors_link}
+ किसी भी जानकारी के साथ जो आपकी पहचान को सत्यापित करने में मदद कर सके।
+ text: यदि आपके पास किसी ई-मेल पते के तहत आयातित संग्रह पर अन्य कार्य हैं,
+ जिन्हें आप अब एक्सेस नहीं कर सकते हैं, तो कृपया किसी भी जानकारी के लिए खुले
+ दरवाज़े से संपर्क करें किसी भी जानकारी के साथ जो आपकी पहचान को सत्यापित करने
+ में मदद कर सकती है।
+ questions:
+ contact_support: AO3 सहायता से सम्पर्क करें
+ html: अन्य पूछताछ के लिए, कृपया %{contact_support_link}
+ text: अन्य पूछताछ के लिए, कृपया %{support_url} पर AO3 सहायता से संपर्क करें।
+ redirects:
+ html: रेक सूचियों और बुकमार्क को संरक्षित करने के लिए, आयातित संग्रह के वेब
+ पते सीमित समय के लिए इन कार्यों की आयातित प्रतिलिपि पर रीडायरेक्ट कर सकते
+ हैं (सुनिश्चित करने के लिए अपने संग्रह के लिए घोषणा पोस्ट की जांच करें)।
+ यदि आपने पहले ही इन कार्यों की एक प्रति अपलोड कर दी है और आपने यूआरएल से
+ आयात सुविधा का %{negation} उपयोग किया है, तो AO3 पर एक ही कार्य की दो प्रतियां
+ होंगी।
+ subject: "[%{app_name}] अपलोड किए गए कार्य"
+ update_redirect:
+ contact_open_doors: खुले दरवाज़े से सम्पर्क करें
+ html: 'यदि आप चाहते हैं कि खुले दरवाज़े आपके पहले से मौजूद कार्य को इंगित करने
+ के लिए पुन: निर्देशित को अद्यतन करे, तो कृपया आयातित प्रतिलिपि हटा दें,
+ और %{contact_open_doors_link} अपने AO3 खाते के नाम के साथ, आयातित संग्रह
+ पर अपने खाते का नाम, और उस फैनवर्क का शीर्षक और URL जिस पर आप पुन: निर्देशित
+ करना चाहेंगे। (यदि आपके पास कई कार्य हैं जिनके लिए आप पुन: निर्देश बदलना
+ चाहेंगे, तो आप उन्हें एक ईमेल में सूचीबद्ध कर सकते हैं।)'
+ text: 'यदि आप चाहते हैं कि खुले दरवाज़े आपके पहले से मौजूद काम को इंगित करने
+ के लिए पुन: निर्देशित को अद्यतन करे, तो कृपया आयातित प्रतिलिपि हटा दें,
+ और %{open_doors_url} अपने AO3 खाते के नाम के साथ, आयातित संग्रह पर अपने
+ खाते का नाम, और उस फैनवर्क का शीर्षक और URL जिस पर आप पुन: निर्देशित करना
+ चाहेंगे। (यदि आपके पास कई कार्य हैं जिनके लिए आप पुन: निर्देश बदलना चाहेंगे,
+ तो आप उन्हें एक ईमेल में सूचीबद्ध कर सकते हैं।)'
+ works_by: 'ये कार्य ई-मेल के अंतर्गत लिखी गईं हैं: %{email}'
+ work_info:
+ html: "%{work_link} (%{fandom})"
+ text:
+ no_fandom: "- %{work_title} %{work_url}"
+ with_fandom: "- %{work_title} %{work_url} (%{fandom})"
+ collection_notification:
+ assignments_sent:
+ complete: अब सभी असाइनमेंट्स भेज दिए गए हैं।
+ subject: भेजे गए असाइनमेंट्स
+ html:
+ received_message: 'आपको अपने संग्रह के बारे में एक संदेश प्राप्त हुआ है %{collection_link}:'
+ text:
+ received_message: 'आपको अपने संग्रह के बारे में एक संदेश प्राप्त हुआ है "%{collection_title}"
+ (%{collection_url}):'
+ creatorship_notification:
+ explanation: जब आप किसी काम के सह-निर्माता होते हैं, तो आपकी सह-निर्माण सेटिंग
+ पर ध्यान दिए बिना आपको नए अध्यायों में जोड़ा जा सकता है। आपको किसी भी श्रृंखला
+ में भी जोड़ा जाएगा जिसमें काम जोड़ा गया है।
+ html:
+ creation: "%{creation_link} द्वारा %{pseud_links}"
+ edit_chapter: अध्याय संपादित करें
+ edit_series: श्रृंखला संपादित करें
+ remove_chapter: अगर आपको गलती से जोड़ा गया है या आप एक निर्माता के रूप में
+ सूचीबद्ध नहीं होना चाहते हैं, तो आप खुद को निर्माता के रूप में हटाने के
+ लिए %{edit_chapter_link} कर सकते हैं।
+ remove_series: अगर आपको गलती से जोड़ा गया है या आप एक निर्माता के रूप में
+ सूचीबद्ध नहीं होना चाहते हैं, तो आप खुद को निर्माता के रूप में हटाने के
+ लिए %{edit_series_link} कर सकते हैं।
+ intro_chapter: 'उपयोगकर्ता %{adding_user} ने आपके छद्म %{pseud} को निम्नलिखित
+ अध्याय में सह-निर्माता के रूप में सूचीबद्ध किया है:'
+ intro_series: 'प्रयोक्ता %{adding_user} ने आपके उपनाम %{pseud} को निम्नलिखित
+ श्रृंखला में सह-निर्माता के रूप में सूचीबद्ध किया है:'
+ subject: "[%{app_name}] सह-निर्माता सूचना"
+ text:
+ creation: "%{title} (%{url}) %{pseuds} द्वारा"
+ remove_chapter: 'यदि आपको गलती से जोड़ा गया है या आप एक निर्माता के रूप में
+ सूचीबद्ध नहीं होना चाहते हैं, तो आप खुद को निर्माता के रूप में हटाने के
+ लिए अध्याय को संपादित कर सकते हैं: %{url}'
+ remove_series: 'यदि आपको गलती से जोड़ा गया है या आप एक निर्माता के रूप में
+ सूचीबद्ध नहीं होना चाहते हैं, तो आप खुद को निर्माता के रूप में हटाने के
+ लिए श्रृंखला को संपादित कर सकते हैं: %{url}'
+ creatorship_notification_archivist:
+ explanation: क्योंकि वे अपने Open Doors (खुले दरवाज़े) संग्राहकों के पद पर अपनी
+ आधिकारिक क्षमता में कार्य कर रहे हैं, उन्हें - बिना किसी आमंत्रण के - आपको
+ जोड़ने की अनुमति है, यद्यपि आपने सह-निर्माण अक्षम किए हों।
+ html:
+ creation: "%{pseud_links} द्वारा %{creation_link}"
+ edit_chapter: अध्याय संपादित करें
+ edit_series: शृंखला संपादित करें
+ edit_work: कार्य संपादित करें
+ remove_chapter: यदि आपको गलती से जोड़ा गया है या आप एक निर्माता के रूप में
+ सूचीबद्ध नहीं होना चाहते हैं, तो आप खुद को निर्माता के रूप से हटाने के लिए
+ %{edit_chapter_link} कर सकते हैं।
+ remove_series: यदि आपको गलती से जोड़ा गया है या आप एक निर्माता के रूप में
+ सूचीबद्ध नहीं होना चाहते हैं, तो आप खुद को निर्माता के रूप से हटाने के लिए
+ %{edit_series_link} कर सकते हैं।
+ remove_work: यदि आपको गलती से जोड़ा गया है या आप एक निर्माता के रूप में सूचीबद्ध
+ नहीं होना चाहते हैं, तो आप खुद को निर्माता के रूप में हटाने के लिए %{edit_work_link}
+ कर सकते हैं।
+ intro_chapter: 'प्रयोक्ता %{archivist} ने आपके उपनाम %{pseud} को निम्नलिखित
+ अध्याय में सह-निर्माता के रूप में जोड़ा है:'
+ intro_series: 'प्रयोक्ता %{archivist} ने आपके उपनाम %{pseud} को निम्नलिखित शृंखला
+ में सह-निर्माता के रूप में जोड़ा है:'
+ intro_work: 'प्रयोक्ता %{archivist} ने आपके उपनाम %{pseud} को निम्नलिखित कार्य
+ में सह-निर्माता के रूप में जोड़ा है:'
+ subject: "[%{app_name}] संग्राहक सह-निर्माता अधिसूचना"
+ text:
+ creation: "%{pseuds} द्वारा %{title} (%{url})"
+ remove_chapter: 'यदि आपको गलती से जोड़ा गया है या आप एक निर्माता के रूप में
+ सूचीबद्ध नहीं होना चाहते हैं, तो आप स्वयं को निर्माता के रूप से हटाने के
+ लिए अध्याय को संपादित कर सकते हैं: %{url}'
+ remove_series: 'यदि आपको गलती से जोड़ा गया है या आप एक निर्माता के रूप में
+ सूचीबद्ध नहीं होना चाहते हैं, तो आप स्वयं को निर्माता के रूप से हटाने के
+ लिए शृंखला को संपादित कर सकते हैं: %{url}'
+ remove_work: 'यदि आपको गलती से जोड़ा गया है या आप एक निर्माता के रूप में सूचीबद्ध
+ नहीं होना चाहते हैं, तो आप स्वयं को निर्माता के रूप से हटाने के लिए कार्य
+ संपादित कर सकते हैं: %{url}'
+ creatorship_request:
+ html:
+ creation: "%{creation_link} जो %{pseud_links} के द्वारा रुचित है"
+ instructions: आप इस अनुरोध को अपने %{page_name} पेज पर स्वीकार या इंकार कर
+ सकते है।
+ page_name: Co-Creator Requests (सह-निर्माता अनुरोध)
+ intro_chapter: 'उपयोगकर्ता %{inviting_user} ने आपके उपनाम %{pseud} को निम्नलिखित
+ अध्याय पर सह-निर्माता के रूप में सूचीबद्ध होने के लिए आमंत्रित किया है:'
+ intro_series: 'उपयोगकर्ता %{inviting_user} ने आपके उपनाम %{pseud} को निम्नलिखित
+ श्रृंखला पर सह-निर्माता के रूप में सूचीबद्ध होने के लिए आमंत्रित किया है:'
+ intro_work: 'उपयोगकर्ता %{inviting_user} ने आपके उपनाम %{pseud} को निम्नलिखित
+ कार्य पर सह-निर्माता के रूप में सूचीबद्ध होने के लिए आमंत्रित किया है:'
+ subject: "[%{app_name}] सह-निर्माता अनुरोध"
+ text:
+ creation: "%{title} (%{url}), %{pseuds} के द्वारा रचित"
+ instructions: 'आप इस अनुरोध को अपने Co-Creator Requests (सह-निर्माता अनुरोध)
+ पेज पर स्वीकार या इंकार कर सकते है: %{url}'
+ delete_work_notification:
+ attachment: आपके कार्य का नकल, संदर्भ हेतु जुड़ा हुआ है।
+ deleted_other:
+ html: आपका कार्य %{title}, %{pseud} के अनुरोध पर मिटा दिया गया था।
+ text: आपका कार्य "%{title}", %{pseud} के अनुरोध पर मिटा दिया गया था।
+ deleted_yourself:
+ html: आपका कार्य %{title}, आपके अनुरोध पर मिटा दिया गया था।
+ text: आपका कार्य "%{title}", आपके अनुरोध पर मिटा दिया गया था।
+ questions:
+ html: यदि आपके कोई प्रश्न हैं, तो कृपया %{support}।
+ text: यदि आपके कोई प्रश्न हैं, तो कृपया %{support} (%{url})।
+ subject: "[%{app_name}] आपका कार्य मिटा दिया गया है"
+ support: सहयोग टीम से संपर्क करें
+ invite_increase_notification:
+ html:
+ body:
+ one: हम सिर्फ आपको यह बताना चाहते हैं कि आपके पास %{count} नया आमंत्रण है,
+ जिसका उपयोग AO3 पर एक नया खाता बनाने के लिए किया जा सकता है। आप %{invitation_page_link}
+ से किसी मित्र को आमंत्रित कर सकते हैं।
+ other: हम सिर्फ आपको यह बताना चाहते हैं कि आपके पास %{count} नए आमंत्रण
+ हैं, जिनका उपयोग AO3 पर नए खाते बनाने के लिए किया जा सकता है। आप %{invitation_page_link}
+ से किसी मित्र को आमंत्रित कर सकते हैं।
+ invitation_page_link_text: आपका Invitations (आमंत्रण) पृष्ठ
+ subject: "[%{app_name}] नये आमंत्रण"
+ text:
+ body:
+ one: |-
+ हम सिर्फ आपको यह बताना चाहते हैं कि आपके पास %{count} नया आमंत्रण है, जिसका उपयोग AO3 पर एक नया खाता बनाने के लिए किया जा सकता है। आप आपने Invitations (आमंत्रण) पृष्ठ
+ (%{invitation_page_url}) से किसी मित्र को आमंत्रित कर सकते हैं।
+ other: |-
+ हम सिर्फ आपको यह बताना चाहते हैं कि आपके पास %{count} नए आमंत्रण हैं, जिनका उपयोग AO3 पर नए खाते बनाने के लिए किया जा सकता है। आप आपने Invitations (आमंत्रण) पृष्ठ
+ (%{invitation_page_url}) से किसी मित्र को आमंत्रित कर सकते हैं।
+ invite_request_declined:
+ main:
+ one: हमें आपको यह सूचित करते हुए खेद हो रहा है कि नए आमंत्रण के लिए आपका अनुरोध
+ इस समय पूरा नहीं किया जा सकता है।
+ other: हमें आपको यह सूचित करते हुए खेद हो रहा है कि %{count} नए आमंत्रणों
+ के लिए आपका अनुरोध इस समय पूरा नहीं किया जा सकता है।
+ reason: 'आपका अनुरोध था:'
+ subject: "[%{app_name}] अतिरिक्त आमंत्रण अनुरोध अस्वीकृत किया गया"
+ recipient_notification:
+ html:
+ collection: आप के लिए एक उपहार कार्य AO3 पर %{collection_link} संग्रह में
+ पोस्ट किया गया है!
+ no_collection: आप के लिए एक उपहार कार्य AO3 पर पोस्ट किया गया है!
+ subject:
+ collection: "[%{app_name}][%{collection_title}] आप के लिए एक उपहार कार्य %{collection_title}
+ से।"
+ no_collection: "[%{app_name}] आप के लिए एक उपहार कार्य"
+ text:
+ collection: आप के लिए एक उपहार कार्य AO3 पर "%{collection_title}" संग्रह (%{collection_url})
+ में पोस्ट किया गया है!
diff --git a/config/locales/phrase-exports/hr.yml b/config/locales/phrase-exports/hr.yml
new file mode 100755
index 0000000..52c46ba
--- /dev/null
+++ b/config/locales/phrase-exports/hr.yml
@@ -0,0 +1,631 @@
+---
+hr:
+ activerecord:
+ attributes:
+ archive_warning:
+ name_with_colon:
+ few: 'Upozorenja:'
+ one: 'Upozorenje:'
+ other: 'Upozorenja:'
+ category:
+ name_with_colon:
+ few: 'Kategorije:'
+ one: 'Kategorija:'
+ other: 'Kategorija:'
+ character:
+ name_with_colon:
+ few: 'Lika:'
+ one: 'Lik:'
+ other: 'Likova:'
+ fandom:
+ name_with_colon:
+ few: 'Fandoma:'
+ one: 'Fandom:'
+ other: 'Fandoma:'
+ freeform:
+ name_with_colon:
+ few: 'Dodatne oznake:'
+ one: 'Dodatna oznaka:'
+ other: 'Dodatnih oznaka:'
+ rating:
+ name_with_colon: 'Ocjena:'
+ relationship:
+ name_with_colon:
+ few: 'Veze:'
+ one: 'Veza:'
+ other: 'Veza:'
+ work:
+ chapter_total_display: Poglavlja
+ summary: Sažetak
+ models:
+ archive_warning:
+ few: Upozorenja
+ one: Upozorenje
+ other: Upozorenja
+ category:
+ few: Kategorije
+ one: Kategorija
+ other: Kategorija
+ chapter:
+ few: Poglavlja
+ one: Poglavlje
+ other: Poglavlja
+ character:
+ few: Lika
+ one: Lik
+ other: Likova
+ fandom:
+ few: Fandoma
+ one: Fandom
+ other: Fandoma
+ freeform:
+ few: Dodatne oznake
+ one: Dodatna oznaka
+ other: Dodatnih oznaka
+ rating:
+ few: Ocjene
+ one: Ocjena
+ other: Ocjena
+ relationship:
+ few: Veze
+ one: Veza
+ other: Veza
+ series:
+ few: Serijala
+ one: Serijal
+ other: Serijala
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ few: "%{count} gosta"
+ one: "%{count} gost"
+ other: "%{count} gostiju"
+ left_kudos:
+ html:
+ few: "%{givers_list} ostavili su kudos na %{commentable_link}."
+ one: "%{givers_list} ostavio/la je kudos na %{commentable_link}."
+ other: "%{givers_list} ostavilo je kudos na %{commentable_link}."
+ text:
+ few: "%{givers_list} ostavili su kudos na %{commentable_title} (%{commentable_url})."
+ one: "%{givers_list} ostavio/la je kudos na %{commentable_title} (%{commentable_url})."
+ other: "%{givers_list} ostavilo je kudos na %{commentable_title} (%{commentable_url})."
+ single_guest:
+ giver: Gost
+ html: "%{giver} ostavio/la je kudos na %{commentable_link}."
+ text: Gost je ostavio/la kudos na %{commentable_title} (%{commentable_url}).
+ subject: "[%{app_name}] Dobili ste kudos!"
+ mailer:
+ general:
+ closing:
+ formal: Srdačno,
+ informal: Lijep pozdrav,
+ creation:
+ link_with_word_count: "%{creation_link} (%{word_count})"
+ title_with_chapter_number: Poglavlje %{position} djela "%{title}"
+ title_with_word_count: '"%{creation_title}" (%{word_count})'
+ word_count:
+ few: "%{count} riječi"
+ one: "%{count} riječ"
+ other: "%{count} riječi"
+ footer:
+ general:
+ about:
+ html: AO3 je arhiv od fanova za fanove, te ovisi o %{donate_link}.
+ text: 'AO3 je arhiv od fanova za fanove, te ovisi o vašim donacijama:
+ %{donate_url}.'
+ html:
+ donate_link_text: vaših donacija
+ support_link_text: kontaktirajte korisničku podršku
+ unwanted_email:
+ html: Ako ste ovu poruku primili pogreškom, molimo vas %{support_link}.
+ text: Ako ste ovu poruku primili pogreškom, molimo vas, javite se korisničkoj
+ podršci na %{support_url}.
+ sent_at: Poslano na %{sent_at}.
+ greeting:
+ formal_html: Dragi %{name},
+ informal:
+ addressed_html: Bok, %{name}!
+ unaddressed: Bok!
+ introductory: Pozdrav od Archive of Our Own – AO3 (Našeg vlastitog arhiva)!
+ metadata_label_indicator: ":"
+ signature:
+ abuse_team: AO3 tim za pravila i zloupotrebu
+ app_short_name: AO3
+ open_doors: Tim Open Doors (Otvorenih vrata)
+ parent_org: Organization for Transformative Works – OTW (Organizacija za transformativna
+ djela)
+ support: AO3 korisnička podrška
+ users:
+ mailer:
+ reset_password_instructions:
+ expiration: Ako ne iskoristite ovu poveznicu za resetiranje lozinke unutar
+ tjedan dana, isteći će i morati ćete zatražiti novu poveznicu.
+ intro: 'Netko je zatražio resetiranje lozinke za Vaš korisnički račun. Svoju
+ lozinku možete promijeniti tako da pratite poveznicu ispod i unesete Vašu
+ novu lozinku:'
+ link_title: Promijeni moju lozinku.
+ subject: "[%{app_name}] Resetirajte svoju lozinku"
+ unrequested: Ako Vi niste zatražili resetiranje lozinke, možete ignorirati
+ ovaj email i Vaša će prijašnja lozinka nastaviti funkcionirati.
+ user_mailer:
+ admin_deleted_work_notification:
+ bye: Priložena je kopija vašeg djela za vašu referencu.
+ contact_abuse: kontaktirajte naše vijeće za Zloupotrebu
+ deleted:
+ html: Vaše djelo %{title} je izbrisano sa Arhive od strane administratora.
+ text: Vaše djelo "%{title}" je izbrisano sa Arhive od strane administratora.
+ html:
+ tos_violation: Ako je moguće da je vaše djelo prekršilo Uvjete korištenja
+ Arhive, molimo %{contact_abuse_link}.
+ import_project:
+ html: Ako je vaše djelo bilo dio uvoznog projekta kojim upravlja naš tim Otvorenih
+ vrata, molimo %{opendoors_link} za sva daljnja pitanja.
+ text: Ako je vaše djelo bilo dio uvoznog projekta kojim upravlja Open Doors
+ (Otvorena vrata), molimo kontaktirajte Otvorena vrata (%{opendoors_link})
+ za sva daljnja pitanja.
+ opendoors: kontaktirajte Otvorena vrata
+ subject: "[%{app_name}] Vaše djelo je izbrisano od strane administratora"
+ text:
+ tos_violation: Ako je moguće da je vaše djelo prekršilo Uvjete korištenja
+ Arhive, molimo kontaktirajte vijeće za Zloupotrebu (%{contact_abuse_url}).
+ admin_hidden_work_notification:
+ access: Dok je vaše djelo skriveno, još uvijek mu možete pristupiti preko gornjeg
+ linka, ali neće biti prikazano na vašoj stranici djela, i neće biti dostupno
+ drugim AO3 korisnicima.
+ check_email: Molimo provjerite vaš mail, uključujući vašu spam mapu, jer tim
+ za Pravila & zlouporabu vas je možda već kontaktirao objašnjavajući zašto
+ je vaše djelo skriveno.
+ contact_abuse: kontaktirajte Pravila & zlouporabu
+ html:
+ help: Ako niste sigurni zašto je vaše djelo skriveno, i niste dobili daljnje
+ informacije o tome, molimo %{contact_abuse_link} direktno.
+ hidden: Vaše djelo %{title} je sakrio tim za Pravila & zlouporabu i više nije
+ javno dostupno.
+ tos_violation: Ako je vaše djelo skriveno jer krši AO3 %{tos_link}, morati
+ ćete poduzeti mjere kako bi ispravili prekršaj. Neuspjeh dovođenja vašeg
+ djela u sklad sa Uvjetima korištenja može dovesti do brisanja vašeg djela
+ sa AO3.
+ subject: "[%{app_name}] Vaše djelo je sakrio tim za Pravila & zlouporabu"
+ text:
+ help: 'Ako niste sigurni zašto je vaše djelo skriveno, i niste dobili daljnje
+ informacije o tome, molimo kontaktirajte Pravila & zlouporabu direktno:
+ %{contact_abuse_url}.'
+ hidden: Vaše djelo "%{title}" (%{work_url}) je sakrio tim za Pravila & zlouporabu
+ i više nije javno dostupno.
+ tos_violation: Ako je vaše djelo skriveno zbog toga što krši AO3 Uvjete korištenja
+ (%{tos_url}), morati ćete poduzeti mjere kako bi ispravili prekršaj. Neuspjeh
+ dovođenja vašeg djela u sklad sa Uvjetima korištenja može dovesti do brisanja
+ vašeg djela sa AO3.
+ tos: Uvjete korištenja
+ anonymous_or_unrevealed_notification:
+ anonymous_info: Anonimna djela uključena su u popis oznaka, ali ne i na vašoj
+ stranici djela. Na djelu će vaše korisničko ime biti zamijenjeno s "Anonymous"
+ (anonimno).
+ anonymous_unrevealed_info: Održavači kolekcije mogu kasnije otkriti vaše djelo,
+ ali ga ostaviti anonimno. Osobe koje imaju pretplatu na vas bit će obaviještene
+ o tome. Jednom otkriveno, vaše djelo bit će uključeno na popisu oznaka, ali
+ ne na popisu djela. Na djelu,vaše će ime biti zamijenjeno s "Anonymous" (anonimno).
+ changed_status:
+ anonymous:
+ html: Održavači kolekcije %{collection_link} promijenili su status vašeg
+ djela %{work_link} u anonimno.
+ text: Održavači kolekcije "%{collection_title}" (%{collection_url}) promijenili
+ su status vašeg djela "%{work_title}" (%{work_url}) u anonimno.
+ anonymous_unrevealed:
+ html: Održavači kolekcije %{collection_link} promijenili su status Vašeg
+ djela %{work_link} u anonimno i skriveno.
+ text: Održavači kolekcije "%{collection_title}" (%{collection_url}) promijenili
+ su status vašeg djela "%{work_title}" (%{work_url}) u anonimno i skriveno.
+ unrevealed:
+ html: Održavači kolekcije %{collection_link} će promijeniti status vašeg
+ djela %{work_link} u skriveno.
+ text: Održavači kolekcije "%{collection_title}" (%{collection_url}) promijenili
+ su status vašeg djela "%{work_title}" (%{work_url}) u skriveno.
+ collection_items_link_text: Approved Collection Items (odobrene stavke kolekcije)
+ do_not_want:
+ anonymous:
+ html: Ako ne želite da vaše djelo bude anonimno, molimo posjetite %{collection_items_link}
+ kako biste ga maknuli iz kolekcije.
+ text: 'Ako ne želite da vaše djelo bude anonimno, molimo posjetite Approved
+ Collection Items (odobrene stavke kolekcije) kako biste ga maknuli iz
+ te kolekcije: %{collection_items_url}'
+ anonymous_unrevealed:
+ html: Ako ne želite da vaše djelo bude anonimno i skriveno, molimo posjetite
+ %{collection_items_link} kako biste ga maknuli iz te kolekcije.
+ text: 'Ako ne želite da vaše djelo bude anonimno i skriveno, molimo posjetite
+ Approved Collection Items (odobrene stavke kolekcije) kako biste ga maknuli
+ iz te kolekcije: %{collection_items_url}'
+ unrevealed:
+ html: Ako ne želite da vaše djelo bude skriveno, molimo posjetite %{collection_items_link}
+ kako biste ga maknuli iz te kolekcije.
+ text: 'Ako ne želite da vaše djelo bude skriveno, molimo posjetite Approved
+ Collection Items (odobrene stavke kolekcije) kako biste ga maknuli iz
+ te kolekcije: %{collection_items_url}'
+ faq_link_text: Kolekcije - najčešća pitanja i odgovori
+ more_info:
+ html: Za više informacija, posjetite %{faq_link}.
+ text: 'Za više informacija, posjetite Kolekcije - najčešća pitanja i odgovori:
+ %{faq_url}'
+ subject:
+ anonymous: "[%{app_name}] Vaše djelo je anonimno"
+ anonymous_unrevealed: "[%{app_name}] Vaše djelo je anonimno i skriveno"
+ unrevealed: "[%{app_name}] Vaše je djelo skriveno"
+ unrevealed_info: Skrivena djela nisu uključena u popise oznaka ili na vašoj
+ stranici. Tko god slijedi link na djelo, primit će poruku da je privremeno
+ nedostupno, i tom sadržaju neće biti dozvoljen pristup.
+ archivist_added_to_collection_notification:
+ approved_collection_items_page: Approved Collection Items (Stavke odobrene za
+ kolekcije)
+ archivist_notice: Zato što održavatelji kolekcije djeluju u službenom kapacitetu
+ kao Open Doors (Otvorena vrata) arhivisti, dopušteno im je dodati vaše djelo
+ u ovu kolekciju, čak i ako ste onemogućili dodavanje djela u kolekcije. Arhivisti
+ će dodati djelo u kolekciju samo ako je bilo hostano na uvezenom arhivu.
+ removal_instructions:
+ html: Ako želite ukloniti vaše djelo iz ove kolekcije, molimo posjetite vašu
+ %{approved_items_link}.
+ text: 'Ako želite ukloniti vaše djelo iz ove kolekcije, molimo posjetite stranicu
+ Approved Collection Items (Stavke odobrene za kolekcije): %{approved_items_url}.'
+ subject: "[%{app_name}][%{collection_title}] Open Doors (Otvorena vrata) arhivist
+ je dodao vaše djelo u kolekciju"
+ work_added:
+ html: Održavatelji kolekcije %{collection_link} su dodali vaše djelo %{work_link}
+ u svoju kolekciju!
+ text: Održavatelji kolekcije "%{collection_title}" (%{collection_url}) su
+ dodali vaše djelo "%{work_title}" (%{work_url}) u svoju kolekciju!
+ challenge_assignment_notification:
+ any: Bilo koji
+ assignment:
+ html: Dobili ste sljedeći zahtjev na %{link} za izazov Archive of Our Own
+ – AO3 (Našeg vlastitog ariva)!
+ description: 'Opis:'
+ due: 'Rok za ovaj zadatak je:'
+ html:
+ footer: Dobili ste ovaj email jer ste se prijavili na %{title} izazov. Za
+ više informacija o ovom izazovu i kontaktne informacije za moderatore, molimo
+ vas da posjetite %{footer_link}
+ footer_link: profilna stranica izazova
+ look_up: Možete pogledati ovaj zadatak na %{link}.
+ look_up_link: vaša Assignments (Zadaci) stranica
+ optional_tags: 'Neobavezne oznake:'
+ prompts: 'Promptovi:'
+ prompt_url: 'URL prompta:'
+ recipient: 'Primatelj:'
+ recipient_missing: 'Ne postoji: kontaktirajte moderatora za pomoć!'
+ subject: "[%{app_name}][%{collection_title}] Tvoj zadatak!"
+ text:
+ assignment: Dobili ste sljedeću molbu u "%{collection_title}" (%{collection_url})
+ izazovu kod Archive of Our Own – AO3 (Našeg vlastitog arhiva)!
+ footer: Dobili ste ovaj email jer ste se prijavili za %{title} izazov (%{url}).
+ Za više informacija o ovom izazovu i kontaktne informacije za moderatore,
+ molimo vas da posjetite %{profile_url}.
+ look_up: Možete pogledati svoje zadatke s vaše Assignments (Zadaci) stranice
+ na %{link}.
+ change_email:
+ changed:
+ html: "%{login}, e-mail povezan s vašim računom je promijenjen u %{email}"
+ text: "%{login}, e-mail povezan s vašim računom je promijenjen u %{email}"
+ subject: "[%{app_name}] Promijenjen e-mail"
+ claim_notification:
+ access:
+ contact_support: kontaktirajte AO3 podršku
+ html: Ovisno o arhivi, vaša su djela možda uvezena s ograničenjem samo na
+ registrirane korisnike (kako bi ih zadržali izvan Google pretraživanja).
+ Ako je to slučaj, djelima će moći pristupiti samo prijavljeni korisnici
+ osim ako ih ne odlučite učiniti potpuno vidljivima. Za pomoć pri otključavanju,
+ napuštanju ili brisanju vaših djela, molimo %{contact_support_link}.
+ text: Ovisno o arhivi, vaša su djela možda uvezena s ograničenjem samo na
+ registrirane korisnike (kako bi ih zadržali izvan Google pretraživanja).
+ Ako je to slučaj, djelima će moći pristupiti samo prijavljeni korisnici
+ osim ako ih ne odlučite učiniti potpuno vidljivima. Za pomoć pri otključavanju,
+ napuštanju ili brisanju vaših djela, molimo kontaktirajte podršku na %{support_url}.
+ email_tips: Ako nas kontaktirate, dodajte adrese s @transformativeworks.org
+ na svoj popis sigurnih kontakata i provjerite svoje mape neželjene pošte za
+ naš odgovor.
+ introduction:
+ ao3_name: Archive of Our Own – AO3 (Naš vlastiti arhiv)
+ html: Primili ste ovu poruku jer ste imali djela u fan arhivi koja je uvezena
+ od strane %{open_doors_name_link} u %{app_link}. Budući da je ova adresa
+ povezana s jednom već registriranom u uvezenoj arhivi, pridružena fan djela
+ (navedena u nastavku) automatski su dodana vašem AO3 računu.
+ open_doors_name: Open Doors (Otvorena vrata)
+ text: 'Primili ste ovu poruku jer ste imali djela u arhivi fan djela koja
+ je uvezena od strane Open Doors (Otvorenih vrata) (%{open_doors_url}) u
+ Archive of Our Own – AO3 (Naš vlastiti arhiv): %{app_url}. Budući da je
+ ova adresa povezana s jednom registriranom u uvezenoj arhivi, pridružena
+ fan djela (navedena u nastavku) automatski su dodana vašem AO3 računu.'
+ mistake:
+ contact_open_doors: kontaktirate Otvorena vrata
+ html: Ako je ovo greška i ovo nisu vaša djela, nemojte ih brisati! Molimo
+ vas da %{contact_open_doors_link} i mi ćemo to srediti.
+ text: Ako je ovo greška i ovo nisu vaša djela, nemojte ih brisati! Molimo
+ vas da kontaktirate Otvorena vrata (%{open_doors_url}) i mi ćemo to srediti.
+ more_info:
+ ao3_news: AO3 vijesti
+ contact_support: kontaktirate AO3 podršku
+ faq_page: Najčešća pitanja
+ html: Možete pročitati najave o nedavnim premještajima arhiva na%{ao3_news_link},
+ i pronaći dodatne informacije na Otvorenim vratima %{faq_page_link} ili
+ %{tutorial_page_link}. Za sva pitanja na koja nema odgovora u najčešćim
+ pitanjima, uputama ili ovoj poruci, molimo %{contact_support_link}.
+ text: Možete pročitati najave o nedavnim premještajima arhiva na AO3 vijestima(%{news_url})
+ i pronaći dodatne informacije na najčešćim pitanjima Otvorenih vrata (%{open_doors_faq_url})
+ ili na stranici s uputama (%{open_doors_tutorial_url}). Za sva pitanja na
+ koja nema odgovora u najčešćim pitanjima, uputama ili ovoj poruci, molimo
+ kontaktirajte podršku na %{support_url}.
+ tutorial_page: stranica s uputama
+ other_works:
+ contact_open_doors: kontaktirajte Otvorena vrata
+ html: Ako ste imali druga djela na uvezenoj arhivi pod adresom kojoj više
+ ne možete pristupiti, molimo %{contact_open_doors_link} sa svim informacijama
+ koje mogu pomoći u potvrdi vašeg identiteta.
+ text: Ako ste imali druga djela na uvezenoj arhivi pod adresom kojoj više
+ ne možete pristupiti, molimo kontaktirajte Otvorena vrata sa svim informacijama
+ koje mogu pomoći u potvrdi vašeg identiteta.
+ questions:
+ contact_support: kontaktirajte AO3 podršku
+ html: Za druge upite, molimo %{contact_support_link}.
+ text: Za druge upite, molimo kontaktirajte podršku na %{support_url}.
+ redirects:
+ html: Kako bi se sačuvali popisi fic preporuka i bookmarkovi, web-adrese uvezene
+ arhive mogu preusmjeravati na uvezenu kopiju ovih djela na ograničeno vrijeme
+ (provjerite objavu za svoju arhivu kako biste bili sigurni). Ako ste već
+ učitali kopiju ovih djela i %{negation} koristili značajku uvoza s URL-a,
+ postojat će dvije kopije istog djela na AO3-u.
+ subject: "[%{app_name}] Uvezena djela"
+ update_redirect:
+ contact_open_doors: kontaktirajte Otvorena vrata
+ html: Ako želite da Otvorena vrata ažuriraju preusmjeravanje kako bi upućivalo
+ na vaše već postojeće djelo, izbrišite uvezenu kopiju i %{contact_open_doors_link}
+ s imenom vašeg AO3 računa, nazivom vašeg računa u uvezenoj arhivi, te naslovom
+ i URL-om djela na koji želite da bude preusmjereno. (Ako imate više djela
+ za koja želite promijeniti preusmjeravanja, možete ih navesti u jednoj poruci.)
+ text: Ako želite da Otvorena vrata ažuriraju preusmjeravanje kako bi upućivalo
+ na vaše već postojeće djelo, izbrišite uvezenu kopiju i kontaktirajte Otvorena
+ vrata %{open_doors_url} s imenom vašeg AO3 računa, nazivom vašeg računa
+ u uvezenoj arhivi, te naslovom i URL-om djela na koji želite da bude preusmjereno.
+ (Ako imate više djela za koja želite promijeniti preusmjeravanja, možete
+ ih navesti u jednoj poruci.)
+ works_by: 'Djela su napisana pod ovom adresom: %{email}'
+ work_info:
+ html: "%{work_link} (%{fandom})"
+ text:
+ no_fandom: "- %{work_title} %{work_url}"
+ with_fandom: "- %{work_title} %{work_url} (%{fandom})"
+ collection_notification:
+ assignments_sent:
+ complete: Poslani su svi zadaci.
+ subject: Zadaci poslani
+ html:
+ received_message: 'Dobili ste poruku o vašoj kolekciji %{collection_link}:'
+ text:
+ received_message: 'Dobili ste poruku o vašoj kolekciji "%{collection_title}"
+ (%{collection_url}):'
+ creatorship_notification:
+ explanation: Kada ste sukreator nekog djela, možete biti dodani novim poglavljima
+ bez obzira na vaše postavke. Također ćete biti dodani serijalu kojem je dodano
+ djelo.
+ html:
+ creation: "%{creation_link} od %{pseud_links}"
+ edit_chapter: Uredite poglavlje
+ edit_series: Uredite serijal
+ remove_chapter: Ako ste pogreškom dodani ili ne želite biti navedeni kao kreator,
+ možete se %{edit_chapter_link} maknuti kao kreator.
+ remove_series: Ako ste pogreškom dodani ili ne želite biti navedeni kao kreator,
+ možete %{edit_series_link} kako biste maknuli sebe kao kreatora.
+ intro_chapter: 'Korisnik %{adding_user} je naveo vaš pseudonim %{pseud} kao
+ sukreatora na sljedećem poglavlju:'
+ intro_series: 'Korisnik %{adding_user} dodao je vaš pseudonim %{pseud} kao sukreatora
+ na sljedećem serijalu:'
+ subject: "[%{app_name}] Obavijest sukreatora"
+ text:
+ creation: "%{title} (%{url}) od %{pseuds}"
+ remove_chapter: 'Ako ste pogreškom dodani ili ne želite biti navedeni kao
+ kreator, možete urediti poglavlje kako biste maknuli sebe kao kreatora:
+ %{url}'
+ remove_series: 'Ako ste pogreškom dodani ili ne želite biti navedeni kao kreator,možete
+ urediti serijal kako biste maknuli sebe kao kreatora: %{url}'
+ creatorship_notification_archivist:
+ explanation: Budući da djeluju po službenoj dužnosti kao Open Doors arhivisti
+ (Otvorenih vrata), dozvoljeno im je dodavati Vas bez zahtjeva, čak i ako imate
+ onemogućeno sukreatorstvo.
+ html:
+ creation: "%{creation_link} od %{pseud_links}"
+ edit_chapter: uredi poglavlje
+ edit_series: uredi serijale
+ edit_work: uredi djelo
+ remove_chapter: Ako ste dodani greškom ili ne želite biti navedeni kao kreator,
+ možete se %{edit_chapter_link} ukloniti s popisa kreatora.
+ remove_series: Ako ste dodani greškom ili ne želite biti navedeni kao kreator,
+ možete se %{edit_series_link} ukloniti s popisa kreatora.
+ remove_work: Ako ste dodani greškom ili ne želite biti navedeni kao kreator,
+ možete se %{edit_work_link} ukloniti s popisa kreatora.
+ intro_chapter: 'Korisnik %{archivist} je dodao Vaš pseud %{pseud} kao sukreatora
+ sljedećih poglavlja:'
+ intro_series: 'Korisnik %{archivist} je dodao Vaš pseud %{pseud} kao sukreatora
+ sljedećih serijala:'
+ intro_work: 'Korisnik %{archivist} označio je Vaš pseudonim %{pseud} kao sukreatora
+ na sljedećem djelu:'
+ subject: "[%{app_name}] Obavijest arhivista o dodavanju sukreatora"
+ text:
+ creation: "%{title} (%{url}) od %{pseuds}"
+ remove_chapter: 'Ako ste dodani greškom ili ne želite biti navedeni kao kreator,
+ možete urediti poglavlje kako biste se uklonili s popisa kreatora: %{url}'
+ remove_series: 'Ako ste dodani greškom ili ne želite biti navedeni kao kreator,
+ možete urediti serijale kako biste se uklonili s popisa kreatora: %{url}'
+ remove_work: 'Ako ste dodani greškom ili ne želite biti navedeni kao kreator,
+ možete urediti djelo kako biste se uklonili s popisa kreatora: %{url}'
+ creatorship_request:
+ html:
+ creation: "%{creation_link} od %{pseud_links}"
+ instructions: Možete prihvatiti ili odbiti ovaj zahtjev na svojoj %{page_name}
+ stranici.
+ page_name: Co-Creator Requests (Zahtjevi sukreatora)
+ intro_chapter: 'Korisnik %{inviting_user} je pozvao Vaš pseud %{pseud} da bude
+ dodan kao sukreator na sljedećem poglavlju:'
+ intro_series: 'Korisnik %{inviting_user} je pozvao Vaš pseud %{pseud} da bude
+ dodan kao sukreator na sljedećem serijalu:'
+ intro_work: 'Korisnik %{inviting_user} je pozvao Vaš pseud %{pseud} da bude
+ dodan kao sukreator na sljedećem djelu:'
+ subject: "[%{app_name}] Zahtjev sukreatora"
+ text:
+ creation: "%{title} (%{url}) od %{pseuds}"
+ instructions: 'Možete prihvatiti ili odbiti ovaj zahtjev na svojoj Co-Creator
+ Requests (Zahtjevi sukreatora) stranici: %{url}'
+ delete_work_notification:
+ attachment: U prilogu se nalazi kopija vašeg djela za referencu.
+ deleted_other:
+ html: Vaše djelo %{title} je izbrisano na zahtjev %{pseud}.
+ text: Vaše djelo "%{title}" je izbrisano na zahtjev %{pseud}.
+ deleted_yourself:
+ html: Vaše djelo %{title} je izbrisano na vaš zahtjev.
+ text: Vaše djelo "%{title}" je izbrisano na vaš zahtjev.
+ questions:
+ html: Ako imate pitanja, molimo %{support}.
+ text: Ako imate pitanja, molimo %{support} (%{url}).
+ subject: "[%{app_name}] Vaše djelo je izbrisano"
+ support: kontaktirajte Korisničku podršku
+ invitation:
+ been_invited: Pozvani ste da se pridružite našoj otvorenoj beti!
+ has_invited: "%{user_name} Vas je pozvao da se pridružite našoj beti!"
+ html:
+ faq_link_text: naša najčešća pitanja
+ subject: "[%{app_name}] הזמנה"
+ invitation_to_claim:
+ access:
+ text: Ovisno o arhivi, moguće je da su vaša djela uvezena samo za registrirane
+ korisnike (kako se ne bi pojavili na Google pretrazi). Ako je to slučaj,
+ djela će biti dostupna samo prijavljenim korisnicima, osim ako se ne odlučite
+ da budu potpuno vidljiva. Za pomoć s otključavanjem, napuštanjem ili brisanjem
+ vaših djela, molimo kontaktirajte AO3 Podršku.
+ claim_or_remove:
+ html: Položite prava na ili uklonite svoja djela ovdje.
+ text: 'Položite prava na ili uklonite svoja djela ovdje: %{claim_url}'
+ email_tips: Ako nas kontaktirate, molimo stavite @transformativeworks.org na
+ listu prihvaćenih adresa i provjerite mapu s neželjenom poštom za naš odgovor.
+ html:
+ ao3_news: AO3 Vijesti
+ contact_open_doors: kontaktiraj Otvorena vrata
+ contact_support: kontaktiraj AO3 korisničku podršku
+ faq_page: stranica najčešćih pitanja
+ tutorial_page: uputstva
+ introduction:
+ text: Primili ste ovaj e-mail jer su Otvorena vrata (%{open_doors_link}) nedavno
+ uvezla arhiv u %{app_name} (%{app_short_name} - %{app_url}), i vjerujemo
+ da sljedeća djela pripadaju vama. Želimo vam pružiti priliku da položite
+ prava na (ili izbrišete/napustite) njih, ako želite. Ako već nemate račun
+ pod drugim e-mailom, ovim vas putem želimo pozvati da nam se pridružite!
+ mistake:
+ text: Ako je ovo greška i to nisu vaša djela, molimo vas da ih ne izbrišete!
+ Samo kontaktirajte Otvorena vrata (%{open_doors_link}) i mi ćemo se pobrinuti
+ oko toga.
+ more_info:
+ text: Nove obavijesti o nedavnim potezima arhiva možete pročitati na AO3 Vijestima
+ (%{news_link}), te pronaći dodatne informacije na najčešćim pitanjima Otvorenih
+ vrata (%{open_doors_faq_link}) ili na stranici s uputstvima (%{open_doors_tutorial_link}).
+ Za svaka pitanja koja nisu odgovorena ovdje ili u uputstvima, molimo kontaktirajte
+ Podršku na %{support_link}.
+ other_works:
+ text: Ako ste na uvezenoj arhivi imali djela pod e-mail adresom kojoj više
+ nemate pristup, molimo kontaktirajte Otvorena vrata s informacijama koje
+ mogu pomoći u potvrdi vašeg identiteta.
+ questions:
+ text: Za druga pitanja, molimo kontaktirajte AO3 Podršku na %{support_link}.
+ redirects: Kako bi sačuvali liste i bookmarkove, uvezene web adrese arhiva će
+ vas neko vrijeme možda preusmjeravati na uvezenu kopiju djela (provjerite
+ objave svoje arhive, kako biste bili sigurni). Ako ste već učitali kopiju
+ tih djela i NISTE koristili opciju URL uvoza, imat ćete dvije kopije istog
+ djela u arhivi.
+ subject: "[%{app_name}] Poziv na polaganje prava na djelo"
+ unwanted:
+ text: Ako djela pripadaju vama, al ih ne želite, možete ih napustiti (tako
+ da ostanu na AO3-u bez vašeg imena) ili izbrisati (potpuno ih ukloniti s
+ AO3-a). Ne morate ih dodati na bilo koji račun kako biste ih napustili ili
+ izbrisali--možete učiniti to direktno preko linka gore. (Za pomoć, molimo
+ kontaktirajte Podršku na %{support_link}.)
+ update_redirect:
+ text: Ako želite da Otvorena vrata preusmjeruju na vaše već postojeće djelo,
+ molimo izbrišite učitanu kopiju i kontaktirajte Otvorena vrata na %{open_doors_link}
+ s vašim AO3 korisničkim imenom, imenom računa s uvezene arhive, te naslovom
+ i URL-om fan djela na koje želite biti preusmjereni. (Ako imate više djela
+ koja želite da budu preusmjerena, možete ih sva navesti u jednom mailu.)
+ uploaded_list: 'Učitana djela sadrže:'
+ invite_increase_notification:
+ html:
+ body:
+ few: Želimo vas obavijestiti da imate %{count} nove pozivnice, koje se mogu
+ koristiti za izradu novih računa na AO3. Možete pozvati prijatelje na
+ %{invitation_page_link}.
+ one: Želimo vas obavijestiti da imate %{count} novu pozivnicu, koje se mogu
+ koristiti za izradu novih računa na AO3. Možete pozvati prijatelje na
+ %{invitation_page_link}.
+ other: Želimo vas obavijestiti da imate %{count} novih pozivnica, koje se
+ mogu koristiti za izradu novih računa na AO3. Možete pozvati prijatelje
+ na %{invitation_page_link}.
+ invitation_page_link_text: stranica Your Invitations (tvoje pozivnice)
+ subject: "[%{app_name}] Nove pozivnice"
+ text:
+ body:
+ few: Želimo vas obavijestiti da imate %{count} nove pozivnice, koje se mogu
+ koristiti za izradu novih računa na AO3. Možete pozvati prijatelje na
+ %{invitation_page_url}.
+ one: Želimo vas obavijestiti da imate %{count} novu pozivnicu, koje se mogu
+ koristiti za izradu novih računa na AO3. Možete pozvati prijatelje na
+ %{invitation_page_url}.
+ other: Želimo vas obavijestiti da imate %{count} novih pozivnica, koje se
+ mogu koristiti za izradu novih računa na AO3. Možete pozvati prijatelje
+ na %{invitation_page_url}.
+ invite_request_declined:
+ main:
+ few: Žao nam je što vas moramo obavijestiti da vaš zahtjev za %{count} nova
+ poziva trenutno ne može biti odobren.
+ one: Žao nam je što vas moramo obavijestiti da vaš zahtjev za novim pozivom
+ trenutno ne može biti odobren.
+ other: Žao nam je što vas moramo obavijestiti da vaš zahtjev za %{count} novih
+ poziva trenutno ne može biti odobren.
+ reason: 'Vaš zahtjev je bio:'
+ subject: "[%{app_name}] Zahtjev za dodatnim pozivnim kodom odbijen"
+ recipient_notification:
+ html:
+ collection: Poklon za Vas je objavljen u %{collection_link} AO3 kolekciji!
+ no_collection: Na AO3-u je objavljen poklon za Vas!
+ subject:
+ collection: "[%{app_name}][%{collection_title}] Poklon za Vas od %{collection_title}"
+ no_collection: "[%{app_name}] Poklon za Vas"
+ text:
+ collection: Poklon za Vas je objavljen u "%{collection_title}" kolekciji (%{collection_url})
+ na AO3-u!
+ signup_notification:
+ activate:
+ html: Molimo %{activate_account_link}.
+ text: 'Molimo slijedite ovu poveznicu kako bi aktivirali svoj račun: %{activate_account_url}'
+ activate_your_account: Slijedite ovu poveznicu kako bi aktivirali svoj korisnički
+ račun
+ admin_posts: AO3 News
+ bye: Nadamo se da uživate u korištenju Arhiva.
+ contact_support: kontaktirajte naš tim za Korisničku podršku
+ faq: Najčešća pitanja
+ features:
+ html: Nakon što je vaš račun otvoren i aktiviran, možete objaviti svoja fan
+ djela, napraviti preplate putem e-maila kako biste bili obaviješteni kada
+ vaši omiljeni autori učitaju novo djelo ili ažuriraju već postojeće, postavite
+ postavke da biste prilagodili način na koji stranica izgleda i radi za vas,
+ pratite djela kojima ste pristupilii na Arhivu putem svoje povijesti i još
+ mnogo toga.
+ text: Nakon što je vaš račun otvoren i aktiviran, možete objaviti svoja fan
+ djela, napraviti preplate putem e-maila kako biste bili obaviješteni kada
+ vaši omiljeni autori učitaju novo djelo ili ažuriraju već postojeće, postavite
+ postavke da biste prilagodili način na koji stranica izgleda i radi za vas,
+ pratite djela kojem ste pristupili na Arhivu putem svoje povijesti i još
+ mnogo toga.
+ information:
+ html: Postoji puno informacija i savjeta o tome kako koristiti Arhiv putem
+ stranice %{faq_link}. Naći ćete najnovije vijesti o razvoju web lokacije
+ putem stranice %{admin_posts_link}. Ako vam je potrebna dodatna pomoć, naišli
+ ste na bug ili imate pitanja ili komentare, molimo %{contact_support_link},
+ koji su uvijek spremni pomoći.
+ text: 'Postoji puno informacija i savjeta o tome kako koristiti Arhivu putem
+ stranice %{faq_url}. Naći ćete najnovije vijesti o razvoju web lokacije
+ na AO3 Vijestima na %{admin_posts_url}. Ako vam je potrebna dodatna pomoć,
+ naletili ste na bug ili imate pitanja ili komentare, molimo kontaktirajte
+ naš tim za korisničku podršku, koji su uvijek spremni pomoći: %{contact_support_url}.'
+ welcome: Dobrodošli u Naš vlastiti arhiv, %{login}!
diff --git a/config/locales/phrase-exports/hu.yml b/config/locales/phrase-exports/hu.yml
new file mode 100755
index 0000000..a77ed95
--- /dev/null
+++ b/config/locales/phrase-exports/hu.yml
@@ -0,0 +1,534 @@
+---
+hu:
+ activerecord:
+ attributes:
+ archive_warning:
+ name_with_colon:
+ one: 'Figyelmeztetés:'
+ other: 'Figyelmeztetések:'
+ category:
+ name_with_colon:
+ one: 'Kategória:'
+ other: 'Kategóriák:'
+ character:
+ name_with_colon:
+ one: 'Szereplő:'
+ other: 'Szereplők:'
+ fandom:
+ name_with_colon:
+ one: 'Fandom:'
+ other: 'Fandomok:'
+ freeform:
+ name_with_colon:
+ one: 'További kulcsszó:'
+ other: 'További kulcsszavak:'
+ rating:
+ name_with_colon: 'Besorolás:'
+ relationship:
+ name_with_colon:
+ one: 'Kapcsolat:'
+ other: 'Kapcsolatok:'
+ work:
+ chapter_total_display: Fejezetek
+ summary: Összefoglaló
+ models:
+ archive_warning:
+ one: Figyelmeztetés
+ other: Figyelmeztetések
+ category:
+ one: Kategória
+ other: Kategóriák
+ chapter:
+ one: Fejezet
+ other: Fejezetek
+ character:
+ one: Szereplő
+ other: Szereplők
+ fandom:
+ one: Fandom
+ other: Fandomok
+ freeform:
+ one: További kulcsszó
+ other: További kulcsszavak
+ rating:
+ one: Besorolás
+ other: Besorolások
+ relationship:
+ one: Kapcsolat
+ other: Kapcsolatok
+ series:
+ one: Sorozat
+ other: Sorozatok
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ one: egy vendég
+ other: "%{count} vendég"
+ left_kudos:
+ html:
+ one: "%{givers_list} kudost hagyott itt: %{commentable_link}."
+ other: "%{givers_list} kudost hagyott itt: %{commentable_link}."
+ text:
+ one: "%{givers_list} kudost hagyott itt: %{commentable_title} (%{commentable_url})."
+ other: "%{givers_list} kudost hagyott itt: %{commentable_title} (%{commentable_url})."
+ single_guest:
+ giver: Egy vendég
+ html: "%{giver} kudost hagyott itt: %{commentable_link}."
+ text: 'Egy vendég kudost hagyott itt: %{commentable_title} (%{commentable_url}).'
+ subject: "[%{app_name}] Kudost kaptál!"
+ mailer:
+ general:
+ closing:
+ formal: Üdvözlettel,
+ informal: Üdv,
+ creation:
+ link_with_word_count: "%{creation_link} (%{word_count})"
+ title_with_chapter_number: "%{title} %{position}. fejezet"
+ title_with_word_count: '"%{creation_title}" (%{word_count})'
+ word_count:
+ one: "%{count} szó"
+ other: "%{count} szó"
+ footer:
+ general:
+ about:
+ html: Az AO3 egy rajongók által működtetett és támogatott archívum, amely
+ a %{donate_link} épül.
+ text: 'Az AO3 egy rajongók által működtetett és támogatott archívum, amely
+ a Ti adományaitokból épül: %{donate_url}.'
+ html:
+ donate_link_text: Ti adományaitokból
+ support_link_text: lépj kapcsolatba a Támogatással
+ unwanted_email:
+ html: Ha tévedésből kaptad ezt az emailt, %{support_link}.
+ text: 'Ha tévedésből kaptad ezt az emailt, lépj kapcsolatba a Támogatással:
+ %{support_url}.'
+ sent_at: 'Dátum: %{sent_at}'
+ greeting:
+ formal_html: Kedves %{name},
+ informal:
+ addressed_html: Szia %{name}!
+ unaddressed: Szia!
+ introductory: Üdvözlet az Archive of Our Own – AO3-tól (A Mi Archívumunk)!
+ metadata_label_indicator: ":"
+ signature:
+ abuse_team: Az AO3 Szabályzat és Visszaélés csapata
+ app_short_name: AO3
+ open_doors: Az Open Doors (Nyitott Ajtók) csapata
+ parent_org: Organization for Transformative Works – OTW (Szervezet a Transzformatív
+ Munkákért)
+ support: Az AO3 Támogatás csapata
+ users:
+ mailer:
+ reset_password_instructions:
+ expiration: Ha egy héten belül nem használod ezt a linket a jelszavad visszaállításához,
+ a link érvényét veszti és egy újat kell majd igényelned.
+ intro: 'Valaki a fiókod jelszavának visszaállítását kérte. Az alábbi linkre
+ kattintva és az új jelszó megadásával módosíthatod a fiókod jelszavát:'
+ link_title: Jelszavam megváltoztatása.
+ subject: "[%{app_name}] Jelszó visszaállítása"
+ unrequested: Ha nem Te kérted jelszavad visszaállítását, hagyd figyelmen kívül
+ ezt az emailt, így korábbi jelszavad továbbra is működni fog.
+ user_mailer:
+ admin_deleted_work_notification:
+ bye: Csatoltuk a munkád másolatát.
+ contact_abuse: lépj kapcsolatba a Szabályzat & Visszaélés bizottsággal
+ deleted:
+ html: A munkád, %{title} törölve lett az Archívumból egy admin által.
+ text: A munkád, "%{title}" törölve lett az Archívumból egy oldal admin által.
+ html:
+ tos_violation: Ha előfordulhat, hogy a munkád megsértette az Archívum Szolgáltatási
+ Feltételeit, %{contact_abuse_link}.
+ import_project:
+ html: Ha a munkád része volt az Open Doors (Nyitott Ajtók) egyik importálási
+ projektjének, további kérdéseiddel %{opendoors_link}.
+ text: Ha a munkád része volt az Open Doors (Nyitott Ajtók) egyik importálási
+ projektjének, további kérdéseiddel lépj kapcsolatba a Nyitott Ajtók (%{opendoors_link})
+ bizottsággal.
+ opendoors: lépj kapcsolatba a Nyitott Ajtók bizottsággal
+ subject: "[%{app_name}] A munkádat törölte egy admin"
+ text:
+ tos_violation: Ha előfordulhat, hogy a munkád megsértette az Archívum Szolgáltatási
+ Feltételeit, lépj kapcsolatba a Szabályzat & Visszaélés bizottsággal (%{contact_abuse_url}).
+ admin_hidden_work_notification:
+ access: Amíg el van rejtve a munkád, továbbra is el tudod érni a fent megadott
+ linken, de nem lesz listázva a munkák oldaladon, és az AO3 többi felhasználója
+ számára sem lesz elérhető.
+ check_email: Kérjük, ellenőrizd az emailed, beleértve a spam mappádat is, mivel
+ a Szabályzat és Visszaélés csapat már lehet, hogy kapcsolatba lépett veled,
+ hogy elmagyarázza, miért rejtette el a munkádat.
+ contact_abuse: lépj közvetlen kapcsolatba a Szabályzat és Visszaélés bizottsággal
+ html:
+ help: Ha nem vagy biztos abban, miért lett elrejtve a munkád, és nem kaptál
+ további tájékoztatást ezzel kapcsolatban, kérjük, %{contact_abuse_link}.
+ hidden: A(z) %{title} munkádat elrejtette a Szabályzat és Visszaélés csapat
+ és nem érhető el nyilvánosan.
+ tos_violation: Ha a munkád azért lett elrejtve, mert megsértette az AO3-mat
+ %{tos_link}, köteles leszel intézkedni a szabálysértés megszüntetése érdekében.
+ Ha nem hozod összhangba munkád a Szolgáltatási Feltételekkel, ez a munkád
+ törléséhez vezethet.
+ subject: "[%{app_name}] A munkádat elrejtette a Szabályzat és Visszaélés csapat"
+ text:
+ help: 'Ha nem vagy biztos, miért lett elrejtve a munkád, és nem kaptál további
+ tájékoztatást ezzel kapcsolatban, kérjük, lépj közvetlen kapcsolatba a Szabályzat
+ és Visszaélés bizottsággal: %{contact_abuse_url}.'
+ hidden: A(z) "%{title}" (%{work_url}) munkádat elrejtette a Szabályzat és
+ Visszaélés csapat és nem érhető el nyilvánosan.
+ tos_violation: Ha a munkád azért lett elrejtve, mert megsértette az AO3 Szolgáltatási
+ Feltételeit (%{tos_url}), köteles leszel intézkedni a szabálysértés megszüntetése
+ érdekében. Ha nem hozod összhangba munkád a Szolgáltatási Feltételekkel,
+ ez a munkád törléséhez vezethet.
+ tos: Szolgáltatási Feltételek
+ anonymous_or_unrevealed_notification:
+ anonymous_info: A névtelen munkák rajta vannak a kulcsszó listákon, de nem jelennek
+ meg a munkáid között. A munkán a felhasználó neved “Anonymous”-ra (Névtelen)
+ lesz cserélve.
+ anonymous_unrevealed_info: A gyűjtemény karbantartói a későbbiekben nyilvánossá,
+ de névtelenné tehetik a munkád. Nem fognak értesítést kapni erről az emberek,
+ akik követnek téged. Miután újra nyilvános a munkád, rajta lesz a kulcsszó
+ listákon, de a munkáid között nem fog megjelenni. A munkán a felhasználó neved
+ “Anonymous”-ra (Névtelen) lesz cserélve.
+ changed_status:
+ anonymous:
+ html: 'A(z) %{collection_link} gyűjtemény kezelői névtelenné változtatták
+ az alábbi munkád státuszát: %{work_link}.'
+ text: A(z) "%{collection_title}" (%{collection_url}) karbantartói névtelenné
+ változtatták a munkád "%{work_title}" (%{work_url}) állapotát.
+ anonymous_unrevealed:
+ html: A(z) %{collection_link} karbantartói névtelenné és nem nyilvánossá
+ változtatták a munkád %{work_link} állapotát.
+ text: A(z) "%{collection_title}" (%{collection_url}) karbantartói névtelenné
+ és nem nyilvánossá változtatták a munkád "%{work_title}" (%{work_url})
+ állapotát.
+ unrevealed:
+ html: A(z) %{collection_link} karbantartói nem nyilvánossá változtatták
+ a munkád %{work_link} állapotát.
+ text: A(z) "%{collection_title}" (%{collection_url}) karbantartói nem nyilvánossá
+ változtatták a munkád "%{work_title}" (%{work_url}) állapotát.
+ collection_items_link_text: Approved Collection Items (Elfogadott Gyűjtemény
+ Tételek) oldal
+ do_not_want:
+ anonymous:
+ html: Ha nem szeretnéd, hogy a munkád névtelenné váljon, kérlek, látogass
+ el az %{collection_items_link} oldalra, hogy eltávolíthasd ebből a gyűjteményből.
+ text: 'Ha nem szeretnéd, hogy a munkád névtelenné váljon, kérlek, látogass
+ el az Approved Collection Items (Elfogadott Gyűjtemény Tételek) oldalra,
+ hogy eltávolíthasd ebből a gyűjteményből: %{collection_items_url}'
+ anonymous_unrevealed:
+ html: Ha szeretnéd, hogy a munkád továbbra is nyilvános legyen, és ne váljon
+ névtelenné, kérlek, látogass el az %{collection_items_link} oldalra, hogy
+ eltávolíthasd ebből a gyűjteményből.
+ text: 'Ha szeretnéd, hogy a munkád továbbra is nyilvános legyen, és ne váljon
+ névtelenné, kérlek, látogass el az Approved Collection Items (Elfogadott
+ Gyűjtemény Tételek) oldalra, hogy eltávolíthasd ebből a gyűjteményből:
+ %{collection_items_url}'
+ unrevealed:
+ html: Ha szeretnéd, hogy a munkád továbbra is nyilvános legyen, kérlek,
+ látogass el az %{collection_items_link} oldalra, hogy eltávolíthasd ebből
+ a gyűjteményből.
+ text: 'Ha szeretnéd, hogy a munkád továbbra az nyilvános legyen, kérlek,
+ látogass el az Approved Collection Items (Elfogadott Gyűjtemény Tételek)
+ oldalra, hogy eltávolíthasd ebből a gyűjteményből: %{collection_items_url}'
+ faq_link_text: Gyűjtemények GyIK
+ more_info:
+ html: További információkért látogass el a %{faq_link} oldalra.
+ text: 'További információkért látogass el a Gyűjtemények GyIK oldalára: %{faq_url}'
+ subject:
+ anonymous: "[%{app_name}] A munkádat névtelenné tettük"
+ anonymous_unrevealed: "[%{app_name}] A munkád mostantól névtelen és nem nyilvános."
+ unrevealed: "[%{app_name}] A munkád mostantól nem nyilvános."
+ unrevealed_info: A nem nyilvános munkák nem jelennek meg sem a kulcsszó listákon,
+ sem a munkáid között. Ha bárki megpróbálja elérni a linket, üzenetet fog kapni,
+ miszerint a munka jelenleg nem nyilvános, és így nem fog hozzáférni a tartalomhoz.
+ archivist_added_to_collection_notification:
+ approved_collection_items_page: Approved Collection Items (Engedélyezett Gyűjtemények)
+ oldaladat
+ archivist_notice: Mivel a gyűjtemény karbantartói hivatalos, Open Doors (Nyitott
+ Ajtók) archivista szerepükben járnak el, hozzáadhatják a munkádat ehhez a
+ gyűjteményhez akkor is, ha a gyűjteménymeghívásaid ki vannak kapcsolva. Az
+ archivisták csak akkor adnak hozzá egy munkát egy adott gyűjteményhez, ha
+ az megjelent egy importált archívumon.
+ removal_instructions:
+ html: Ha szeretnéd eltávolítani a munkádat a gyűjteményből, látogasd meg az
+ %{approved_items_link}.
+ text: 'Ha szeretnéd eltávolítani a munkádat a gyűjteményből, látogasd meg
+ az Approved Collection Items (Engedélyezett Gyűjtemények) oldaladat: %{approved_items_url}.'
+ subject: "[%{app_name}][%{collection_title}] Egy Open Doors (Nyitott Ajtók)
+ archivista hozzáadta a munkádat egy gyűjteményhez"
+ work_added:
+ html: A %{collection_link} gyűjtemény karbantartói hozzáadták „%{work_link}"
+ munkádat a gyűjteményhez!
+ text: A „%{collection_title}" (%{collection_url}) gyűjtemény karbantartói
+ hozzáadták „%{work_title}" (%{work_url}) munkádat a gyűjteményhez!
+ challenge_assignment_notification:
+ any: Bármi
+ assignment:
+ html: A következő kérést kaptad feladatként a(z) %{link} kihívásban az Archive
+ of Our Own oldalon!
+ description: 'Leírás:'
+ due: 'A feladat határideje:'
+ html:
+ footer: Azért kaptad ezt az emailt, mert feliratkoztál a(z) %{title} kihívásra.
+ További információért erről a kihívásról és a moderátorok elérhetőségéért,
+ kérjük, látogasd meg %{footer_link}.
+ footer_link: a kihívás profilját
+ look_up: Megtekintheted a feladatot %{link}.
+ look_up_link: a te Assignments (Feladatok) oldaladon.
+ optional_tags: 'Választható kulcsszavak:'
+ prompts: 'Ötletek:'
+ prompt_url: 'Ötlet URL-je:'
+ recipient: 'Ajándékozott:'
+ recipient_missing: 'Nincs: segítségért lépj kapcsolatba egy moderátorral!'
+ subject: "[%{app_name}][%{collection_title}] A feladatod!"
+ text:
+ assignment: A következő kérést kaptad feladatként a(z) "%{collection_title}"
+ kihívásban (%{collection_url}) az Archive of Our Own oldalon!
+ footer: Azért kaptad ezt az emailt, mert feliratkoztál a(z) %{title} kihívásra
+ (%{url}). További információért erről a kihívásról és a moderátorok elérhetőségéért,
+ kérjük, látogasd meg %{profile_url}.
+ look_up: 'Megtekintheted a feladatot a te Assignments (Feladatok) oldaladon:
+ %{link}.'
+ change_email:
+ changed:
+ html: "%{login}, a fiókodhoz tartozó email cím a következőre változott: %{email}"
+ text: "%{login}, a fiókodhoz tartozó email cím a következőre változott: %{email}"
+ subject: "[%{app_name}] Megváltozott email cím"
+ collection_notification:
+ assignments_sent:
+ complete: Minden feladat kiküldésre került.
+ subject: Feladatok kiküldve
+ html:
+ received_message: 'Üzeneted érkezett a(z) %{collection_link} gyűjteményed
+ kapcsán:'
+ text:
+ received_message: 'Üzeneted érkezett a(z) "%{collection_title}" (%{collection_url})
+ gyűjteményed kapcsán:'
+ creatorship_notification:
+ explanation: Ha egy mű társalkotója vagy, akkor új fejezetekhez adhatnak hozzá
+ függetlenül a társalkotói beállításaidtól. Minden olyan sorozathoz is hozzá
+ fognak adni, amelyhez a művet hozzáadják.
+ html:
+ creation: "%{pseud_links}: %{creation_link}"
+ edit_chapter: a fejezet szerkesztésével
+ edit_series: a sorozat szerkesztésével
+ remove_chapter: Ha tévedésből adtak hozzá, vagy nem akarod, hogy alkotóként
+ szerepelj, akkor %{edit_chapter_link} eltávolíthatod magad az alkotók közül.
+ remove_series: Ha tévedésből adtak hozzá, vagy nem akarod, hogy alkotóként
+ szerepelj, akkor %{edit_series_link} eltávolíthatod magad az alkotók közül.
+ intro_chapter: 'Egy %{adding_user} nevű felhasználó társalkotóként tüntette
+ fel %{pseud} pszeudódat az alábbi fejezeten:'
+ intro_series: 'Egy %{adding_user} nevű felhasználó társalkotóként tüntette fel
+ %{pseud} pszeudódat az alábbi sorozaton:'
+ subject: "[%{app_name}] Társalkotói értesítés"
+ text:
+ creation: "%{pseuds}: %{title} (%{url})"
+ remove_chapter: 'Ha tévedésből adtak hozzá, vagy nem akarod, hogy alkotóként
+ szerepelj, akkor a fejezet szerkesztésével eltávolíthatod magad az alkotók
+ közül: %{url}'
+ remove_series: 'Ha tévedésből adtak hozzá, vagy nem akarod, hogy alkotóként
+ szerepelj, akkor a sorozat szerkesztésével eltávolíthatod magad az alkotók
+ közül: %{url}'
+ creatorship_notification_archivist:
+ explanation: Mivel az Open Doors (Nyitott Ajtók) archiválójaként járnak el,
+ felkérés nélkül hozzáadhatnak téged, mint társalkotót, még ha a beállításaidban
+ ez ki is van kapcsolva.
+ html:
+ creation: "%{creation_link} - %{pseud_links}"
+ edit_chapter: a fejezet szerkesztésével
+ edit_series: a sorozat szerkesztésével
+ edit_work: a munka szerkesztésével
+ remove_chapter: Ha tévedésből lettél megjelölve vagy nem szeretnél alkotóként
+ szerepelni, %{edit_chapter_link} törölheted magad az alkotók közül.
+ remove_series: Ha tévedésből lettél megjelölve vagy nem szeretnél alkotóként
+ szerepelni, %{edit_series_link} törölheted magad az alkotók közül.
+ remove_work: Ha tévedésből lettél megjelölve vagy nem szeretnél alkotóként
+ szerepelni, %{edit_work_link} törölheted magad az alkotók közül.
+ intro_chapter: "%{archivist} felhasználó társalkotónak jelölte %{pseud} pszeudódat
+ az alábbi fejezeten:"
+ intro_series: "%{archivist} felhasználó társalkotónak jelölte %{pseud} pszeudódat
+ az alábbi sorozaton:"
+ intro_work: "%{archivist} felhasználó társalkotónak jelölte %{pseud} pszeudódat
+ az alábbi munkán:"
+ subject: "[%{app_name}] Archiváló társalkotói értesítés"
+ text:
+ creation: "%{title} (%{url}) - %{pseuds}"
+ remove_chapter: 'Ha tévedésből lettél megjelölve vagy nem szeretnél alkotóként
+ szerepelni, a fejezet szerkesztésével törölheted magad az alkotók közül:
+ %{url}'
+ remove_series: 'Ha tévedésből lettél megjelölve vagy nem szeretnél alkotóként
+ szerepelni, a sorozat szerkesztésével törölheted magad az alkotók közül:
+ %{url}'
+ remove_work: 'Ha tévedésből lettél megjelölve vagy nem szeretnél alkotóként
+ szerepelni, a munka szerkesztésével törölheted magad az alkotók közül: %{url}'
+ creatorship_request:
+ html:
+ creation: "%{pseud_links}: %{creation_link}"
+ instructions: Elfogadhatod vagy visszautasíthatod ezt a felkérést a %{page_name}
+ oldaladon.
+ page_name: Co-Creator Requests (Társalkotói felkérések)
+ intro_chapter: 'A(z) %{inviting_user} felhasználó meghívta a(z) %{pseud} pszeudódat,
+ hogy társalkotóként legyen feltüntetve a következő fejezeteken:'
+ intro_series: 'A(z) %{inviting_user} felhasználó meghívta a(z) %{pseud} pszeudódat,
+ hogy társalkotóként legyen feltüntetve a következő sorozaton:'
+ intro_work: 'A(z) %{inviting_user} felhasználó meghívta a(z) %{pseud} pszeudódat,
+ hogy társalkotóként legyen feltüntetve a következő munkán:'
+ subject: "[%{app_name}] Felkérés társalkotónak"
+ text:
+ creation: "%{pseuds}: %{title} (%{url})"
+ instructions: 'Elfogadhatod vagy visszautasíthatod ezt a felkérést a Co-Creator
+ Requests (Társalkotói felkérések) oldaladon: %{url}'
+ delete_work_notification:
+ attachment: A szóban forgó munkádat csatoltuk az e-mailhez.
+ deleted_other:
+ html: "%{pseud} kérésére töröltük %{title} című munkádat."
+ text: '%{pseud} kérésére töröltük "%{title}" című munkádat.'
+ deleted_yourself:
+ html: Kérésedre töröltük %{title} című munkádat.
+ text: Kérésedre töröltük "%{title}" című munkádat.
+ questions:
+ html: Ha kérdésed van, %{support}.
+ text: Ha kérdésed van, %{support} (%{url}).
+ subject: "[%{app_name}] Munkádat töröltük"
+ support: lépj kapcsolatba a Támogatással
+ invitation:
+ subject: "[%{app_name}] Pozivnica"
+ invitation_to_claim:
+ access:
+ text: Az archívumtól függően előfordulhat, hogy műveid csak regisztrált felhasználók
+ érhetik el (hogy ne kerüljenek a Google keresésekbe). Ebben az esetben a
+ munkákhoz csak bejelentkezett felhasználók férhetnek hozzá, hacsak nem döntesz
+ úgy, hogy teljesen láthatóvá teszed őket. Ha segítségre van szükséged munkáid
+ nyilvánossá tételében, elhagyásában vagy törlésében, lépj kapcsolatba az
+ AO3 Támogatással.
+ claim_or_remove:
+ html: Foglald le vagy töröld a munkáidat itt.
+ text: 'Foglald le vagy töröld a munkáidat itt: %{claim_url}'
+ email_tips: Ha kapcsolatba lépsz velünk, kérjük, engedélyezd a @transformativeworks.org
+ e-mail címeit, és ellenőrizd, hogy válaszunk nincs-e a spam mappában.
+ html:
+ ao3_news: AO3 Hírek
+ contact_open_doors: lépj kapcsolatba a Nyitott Ajtókkal
+ contact_support: lépj kapcsolatba az AO3 támogatással
+ faq_page: GyIK
+ tutorial_page: útmutató
+ introduction:
+ text: Azért kapod ezt az e-mailt, mert a Nyitott Ajtók (%{open_doors_link})
+ a közelmúltban importált egy archívumot az %{app_name}ra (%{app_short_name}
+ - %{app_url}), és úgy gondoljuk, hogy az alábbi rajongói munkák hozzád tartoznak.
+ Szeretnénk neked esélyt adni arra, hogy lefoglald (vagy töröld/elhagyd)
+ ezeket a munkákat, ha úgy gondolod. Illetve ha még nem rendelkezel fiókkal
+ más e-mail cím alatt, szeretnénk meghívni!
+ mistake:
+ text: Ha tévedés történt és ezek nem a Te munkáid, kérjük, ne töröld őket!
+ Csak lépj kapcsolatba a Nyitott Ajtókkal (%{open_doors_link}) és mi mindent
+ elrendezünk.
+ more_info:
+ text: 'A legfrissebb archívum költöztetésekről szóló bejelentéseket az AO3
+ Hírek (%{news_link}) oldalán olvashatod, további információkat pedig a Nyitott
+ Ajtók GyIK (%{open_doors_faq_link}) vagy útmutatók oldalán (%{open_doors_tutorial_link})
+ találhatsz. Ha olyan kérdésed van, amelyre a GYIK-ben, az útmutatókban vagy
+ ebben az e-mailben nem válaszoltunk, kérjük, lépj kapcsolatba az AO3 támogatással
+ az alábbi linken: %{support_link}.'
+ other_works:
+ text: Ha más munkáid is voltak az importált archívumon olyan e-mail cím alatt,
+ amelyhez már nem férsz hozzá, kérjük, lépj kapcsolatba a Nyitott Ajtókkal
+ bármilyen információval, amely segíthet személyazonosságod megerősítésében.
+ questions:
+ text: 'Ha más kérdésed lenne, kérjük, lépj kapcsolatba az AO3 Támogatással
+ az alábbi linken: %{support_link}.'
+ redirects: Az ajánlólisták és a könyvjelzők megőrzése érdekében az importált
+ archívum webcímei korlátozott ideig a munkák importált másolatára irányíthatnak
+ át (biztos információért ellenőrizd az archívum bejelentő posztját). Ha már
+ feltöltöttél egy példányt ezekből a munkákból és NEM használtad az URL-ből
+ történő importálás funkciót, akkor ugyanazon mű két másolata lesz az archívumon.
+ subject: "[%{app_name}] Meghívó munkák lefoglalására"
+ unwanted:
+ text: 'Ha ezek a munkák hozzád tartoznak, de nem szeretnéd már őket, lehetőséged
+ van őket elhagyni (így az AO3-on maradnak, de a neved el lesz távolítva
+ róluk), vagy törölni (így teljes mértékben eltávolítjuk őket az AO3-ról).
+ A munkák elhagyásához vagy törléséhez nem szükséges őket bármilyen fiókhoz
+ rendelni - ezt közvetlenül a fenti lefoglalási link segítségével is megteheted.
+ (További segítségért kérjük, lépj kapcsolatba a Támogatással az alábbi linken:
+ %{support_link}.)'
+ update_redirect:
+ text: Ha azt szeretnéd, hogy a Nyitott Ajtók frissítse az átirányítást, hogy
+ a már meglévő munkádra mutasson, kérjük, töröld az importált példányt, és
+ lépj kapcsolatba a Nyitott Ajtókkal (%{open_doors_link}) az AO3-fiókod nevével,
+ az importált archívumi fiókod nevével, illetve a rajongói munka címével
+ és URL-jével, amelyre az átirányítás mutasson. (Ha több munkád van, amelyeknél
+ meg szeretnéd változtatni az átirányításokat, akkor ezeket felsorolhatod
+ egy e-mailben.)
+ uploaded_list: 'A feltöltött munkák az alábbiak:'
+ invite_increase_notification:
+ html:
+ body:
+ one: 'Örömmel tudatjuk veled, hogy van %{count} új meghívód, amelyet új
+ fiók létrehozására használhatsz az AO3-on. Itt hívhatsz meg egy barátot:
+ %{invitation_page_link}.'
+ other: 'Örömmel tudatjuk veled, hogy van %{count} új meghívód, amelyeket
+ új fiókok létrehozására használhatsz az AO3-on. Itt hívhatsz meg barátokat:
+ %{invitation_page_link}.'
+ invitation_page_link_text: Invitations (Meghívók) oldalad
+ subject: "[%{app_name}] Új Meghívók"
+ text:
+ body:
+ one: Örömmel tudatjuk veled, hogy van %{count} új meghívód, amelyet új fiók
+ létrehozására használhatsz az AO3-on. Te is meghívhatod egy barátod az
+ Invitations (Meghívások) oldalon (%{invitation_page_url}).
+ other: Örömmel tudatjuk veled, hogy van %{count} új meghívód, amelyeket
+ új fiókok létrehozására használhatsz az AO3-on.Te is meghívhatod egy barátod
+ az Invitations (Meghívások) oldalon (%{invitation_page_url}).
+ invite_request_declined:
+ main:
+ one: Sajnálattal közöljük, hogy új meghívókra vonatkozó kérésed jelenleg nem
+ áll módunkban teljesíteni.
+ other: Sajnálattal közöljük, hogy %{count} új meghívóra vonatkozó kérésed
+ jelenleg nem áll módunkban teljesíteni.
+ reason: 'Kérésed a következő volt:'
+ subject: "[%{app_name}] További meghívó kódok kérése elutasítva"
+ recipient_notification:
+ html:
+ collection: Egy ajándék munkát posztoltak a számodra a(z) %{collection_link}
+ gyűjteményben az AO3-on!
+ no_collection: Egy ajándék munkát posztoltak a számodra az AO3-on!
+ subject:
+ collection: "[%{app_name}][%{collection_title}] Egy ajándék munka a számodra
+ a(z) %{collection_title} gyűjteményben"
+ no_collection: "[%{app_name}] Egy ajándék munka a számodra"
+ text:
+ collection: Egy ajándék munkát posztoltak a számodra a(z) "%{collection_title}"
+ gyűjteményben (%{collection_url}) az AO3-on!
+ signup_notification:
+ activate:
+ html: Kérjük, %{activate_account_link}.
+ text: 'Kérjük, kövesd ezt a linket a felhasználói fiókod aktiválásához: %{activate_account_url}'
+ activate_your_account: kövesd ezt a linket a felhasználói fiókod aktiválásához.
+ admin_posts: Archívum Hírek
+ bye: Reméljük, örömöd leled az Archívum használatában.
+ contact_support: lépj kapcsolatba a Támogatással
+ faq: GyIK
+ features:
+ html: Ha a felhasználói fiókod elkészült, közzé teheted rajongói munkáid,
+ beállíthatod az email feliratkozásaid, hogy értesítést kapj, amikor a kedvenc
+ alkotóid új munkát tettek közzé, vagy a kedvenc munkádat frissítették. Személyre
+ szabhatod az oldal működését és kinézetét, nyomon követheted az általad
+ az Archívumon megtekintett munkákat az előzményeidben, és még sok más funkciót
+ is elérhetsz.
+ text: Ha a felhasználói fiókod elkészült, közzé teheted rajongói munkáid,
+ beállíthatod az email feliratkozásaid, hogy értesítést kapj, amikor a kedvenc
+ alkotóid új munkát tettek közzé, vagy a kedvenc munkádat frissítették. Személyre
+ szabhatod az oldal működését és kinézetét, nyomon követheted az általad
+ az Archívumon megtekintett munkákat az előzményeidben, és még sok más funkciót
+ is elérhetsz.
+ information:
+ html: Sok információt és tanácsot találsz az Archívum használatával kapcsolatban
+ a %{faq_link} linken. Az oldal fejlesztésének legújabb híreit az %{admin_posts_link}
+ oldalon olvashatod. Ha több segítségre van szükséged, hibát fedeztél fel,
+ kérdéseid vagy megjegyzéseid vannak, %{contact_support_link}, akik mindig
+ szívesen rendelkezésedre állnak.
+ text: 'Sok információt és tanácsot találsz az Archívum használatával kapcsolatban
+ a GyIK-ben, a %{faq_url} oldalon. Az oldal fejlesztésének legújabb híreit
+ az Archívum Hírek oldalon, az %{admin_posts_url} linken olvashatod. Ha több
+ segítségre van szükséged, hibát fedeztél fel, kérdéseid vagy megjegyzéseid
+ vannak, lépj kapcsolatba a Támogatás csapattal, akik mindig szívesen rendelkezésedre
+ állnak: %{contact_support_url}.'
+ welcome: Üdvözlünk az Archive of Our Own-on (A Mi Archívumunkon), %{login}!
diff --git a/config/locales/phrase-exports/id.yml b/config/locales/phrase-exports/id.yml
new file mode 100755
index 0000000..c5a3e4b
--- /dev/null
+++ b/config/locales/phrase-exports/id.yml
@@ -0,0 +1,630 @@
+---
+id:
+ activerecord:
+ attributes:
+ archive_warning:
+ name_with_colon:
+ other: 'Peringatan:'
+ category:
+ name_with_colon:
+ other: 'Kategori:'
+ character:
+ name_with_colon:
+ other: 'Karakter:'
+ fandom:
+ name_with_colon:
+ other: 'Fandom:'
+ freeform:
+ name_with_colon:
+ other: 'Label Tambahan:'
+ rating:
+ name_with_colon: 'Rating:'
+ relationship:
+ name_with_colon:
+ other: 'Pasangan:'
+ work:
+ chapter_total_display: Bab
+ summary: Sinopsis
+ models:
+ archive_warning:
+ other: Peringatan
+ category:
+ other: Kategori
+ chapter:
+ other: Bab
+ character:
+ other: Karakter
+ fandom:
+ other: Fandom
+ freeform:
+ other: Label Tambahan
+ rating:
+ other: Rating
+ relationship:
+ other: Pasangan
+ series:
+ other: Seri
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ other: "%{count} tamu"
+ left_kudos:
+ html:
+ other: "%{givers_list} telah memberikan kudos untuk %{commentable_link}."
+ text:
+ other: "%{givers_list} telah memberikan kudos untuk %{commentable_title}
+ (%{commentable_url})."
+ single_guest:
+ giver: Seorang tamu
+ html: "%{giver} telah memberikan kudos untuk %{commentable_link}."
+ text: Seorang tamu telah memberikan kudos untuk %{commentable_title} (%{commentable_url}).
+ subject: "[%{app_name}] Anda telah menerima kudos!"
+ mailer:
+ general:
+ closing:
+ formal: Dengan hormat,
+ informal: Salam,
+ creation:
+ link_with_word_count: "%{creation_link} (%{word_count})"
+ title_with_chapter_number: Bab %{position} pada %{title}
+ title_with_word_count: '"%{creation_title}" (%{word_count})'
+ word_count:
+ other: "%{count} kata"
+ footer:
+ general:
+ about:
+ html: AO3 adalah arsip yang dikelola dan didukung oleh sesama fan dan
+ bergantung pada %{donate_link}.
+ text: 'AO3 adalah arsip yang dikelola dan didukung oleh sesama fan dan
+ bergantung pada donasi Anda: %{donate_url}.'
+ html:
+ donate_link_text: donasi Anda
+ support_link_text: hubungi Komite Bantuan
+ unwanted_email:
+ html: Jika Anda tidak seharusnya menerima pesan ini, silakan %{support_link}.
+ text: Jika Anda tidak seharusnya menerima pesan ini, silakan hubungi Komite
+ Bantuan di %{support_url}.
+ sent_at: Dikirimkan pada %{sent_at}.
+ greeting:
+ formal_html: Halo %{name},
+ informal:
+ addressed_html: Halo %{name}!
+ unaddressed: Halo!
+ introductory: Halo dari Archive of Our Own – AO3 (Arsip Milik Kita)!
+ metadata_label_indicator: ":"
+ signature:
+ abuse_team: Komite Kebijakan dan Pelanggaran AO3
+ app_short_name: AO3
+ open_doors: Tim Open Doors (Pintu Terbuka)
+ parent_org: Organization for Transformative Works – OTW (Organisasi untuk
+ Karya Transformatif)
+ support: Komite Bantuan AO3
+ users:
+ mailer:
+ reset_password_instructions:
+ expiration: Tautan ini akan kadaluarsa jika tidak digunakan untuk mengubah
+ kata sandi Anda dalam satu minggu. Setelah itu, Anda perlu meminta tautan
+ baru.
+ intro: 'Terdapat permintaan pengaturan ulang kata sandi Anda. Kata sandi akun
+ Anda dapat diubah dengan mengikuti tautan berikut dan memasukkan kata sandi
+ yang baru:'
+ link_title: Ubah kata sandi saya.
+ subject: "[%{app_name}] Atur ulang kata sandi Anda"
+ unrequested: Jika Anda tidak pernah meminta pengaturan ulang kata sandi Anda,
+ silakan abaikan surel ini. Kata sandi lama Anda akan tetap berfungsi.
+ user_mailer:
+ admin_deleted_work_notification:
+ bye: Terlampir adalah salinan karya Anda sebagai referensi.
+ contact_abuse: hubungi Komite Kebijakan dan Pelanggaran kami
+ deleted:
+ html: Karya Anda, %{title}, telah dihapus dari AO3 oleh admin situs.
+ text: Karya Anda, "%{title}", telah dihapus dari AO3 oleh admin situs.
+ html:
+ tos_violation: Jika ada kemungkinan karya Anda melanggar Ketentuan Layanan
+ AO3, silakan %{contact_abuse_link}.
+ import_project:
+ html: Jika karya Anda merupakan bagian dari proyek impor yang diatur oleh
+ Komite Open Doors (Pintu Terbuka) kami, silakan %{opendoors_link} dengan
+ pertanyaan lebih lanjut.
+ text: Jika karya Anda merupakan bagian dari proyek impor yang diatur oleh
+ Komite Open Doors (Pintu Terbuka) kami, silakan hubungi Komite Pintu Terbuka
+ (%{opendoors_link}) dengan pertanyaan lebih lanjut.
+ opendoors: hubungi Komite Pintu Terbuka
+ subject: "[%{app_name}] Karya Anda telah dihapus oleh admin"
+ text:
+ tos_violation: Jika ada kemungkinan karya Anda melanggar Ketentuan Layanan
+ AO3, silakan hubungi Komite Kebijakan dan Pelanggaran kami (%{contact_abuse_url}).
+ admin_hidden_work_notification:
+ access: Anda masih bisa mengakses karya tersebut melalui tautan di atas. Namun
+ karya tersebut tidak terdaftar dalam halaman karya Anda dan tidak tersedia
+ untuk pengguna AO3 lainnya.
+ check_email: Silakan periksa email Anda, termasuk kotak spam, karena Komite
+ Kebijakan dan Pelanggaran mungkin sudah mengirimkan alasan karya Anda disembunyikan.
+ contact_abuse: hubungi Komite Kebijakan dan Pelanggaran
+ html:
+ help: Jika Anda tidak yakin alasan karya Anda disembunyikan dan Anda belum
+ menerima komunikasi terkait hal ini, silakan %{contact_abuse_link} secara
+ langsung.
+ hidden: Karya Anda, %{title}, telah disembunyikan oleh Komite Kebijakan dan
+ Pelanggaran dan tidak dapat diakses publik lagi.
+ tos_violation: Jika karya Anda disembunyikan karena melanggar %{tos_link}
+ AO3, maka Anda harus melakukan tindakan untuk memperbaiki pelanggaran tersebut.
+ Karya Anda akan dihapus dari AO3 jika tidak mengikuti Ketentuan Layanan.
+ subject: "[%{app_name}] Karya Anda telah disembunyikan oleh Komite Kebijakan
+ dan Pelanggaran"
+ text:
+ help: 'Jika Anda tidak yakin alasan karya Anda disembunyikan dan Anda belum
+ menerima komunikasi terkait hal ini, silakan hubungi Komite Kebijakan dan
+ Pelanggaran secara langsung: %{contact_abuse_url}.'
+ hidden: Karya Anda, "%{title}" (%{work_url}), telah disembunyikan oleh Komite
+ Kebijakan dan Pelanggaran dan tidak dapat diakses publik lagi.
+ tos_violation: Jika karya Anda disembunyikan karena melanggar Ketentuan Layanan
+ AO3 (%{tos_url}), maka Anda harus melakukan tindakan untuk memperbaiki pelanggaran
+ tersebut. Karya Anda akan dihapus dari AO3 jika tidak mengikuti Ketentuan
+ Layanan.
+ tos: Ketentuan Layanan
+ anonymous_or_unrevealed_notification:
+ anonymous_info: Karya anonim tetap terlihat dalam daftar tag (label), namun
+ tidak dalam halaman karya Anda. Di karya tersebut, nama pengguna Anda akan
+ digantikan dengan "Anonymous" (Anonim).
+ anonymous_unrevealed_info: Moderator koleksi bisa saja akan membuka karya Anda
+ namun tetap membuatnya anonim. Akun yang berlangganan dengan Anda tidak akan
+ menerima notifikasi atas perubahan ini. Setelah dibuka, karya Anda akan termasuk
+ dalam daftar tag (label), tapi tidak dalam halaman karya Anda. Di karya tersebut,
+ nama pengguna Anda akan digantikan dengan "Anonymous" (Anonim).
+ changed_status:
+ anonymous:
+ html: Moderator koleksi %{collection_link} telah mengubah status karya Anda,
+ %{work_link}, menjadi anonim.
+ text: Moderator Koleksi "%{collection_title}" (%{collection_url}) telah
+ mengubah status karya anda "%{work_title}" (%{work_url}) menjadi anonim
+ anonymous_unrevealed:
+ html: Moderator koleksi %{collection_link} telah mengubah status karya Anda,
+ %{work_link}, menjadi anonim dan unrevealed (tersembunyi).
+ text: Moderator Koleksi "%{collection_title}" (%{collection_url}) telah
+ mengubah status karya anda "%{work_title}" (%{work_url}) menjadi anonim
+ dan unrevealed (tersembunyi)
+ unrevealed:
+ html: Moderator koleksi %{collection_link} telah mengubah status karya Anda,
+ %{work_link}, menjadi unrevealed (tersembunyi).
+ text: Moderator Koleksi "%{collection_title}" (%{collection_url}) telah
+ mengubah status karya anda "%{work_title}" (%{work_url}) menjadi unrevealed
+ (tersembunyi)
+ collection_items_link_text: halaman Approved Collection Items (Artikel Koleksi
+ yang Disetujui)
+ do_not_want:
+ anonymous:
+ html: Jika Anda tidak ingin karya Anda dibuat menjadi anonim, silakan kunjungi
+ %{collection_items_link} untuk menghapus karya tersebut dari koleksi ini.
+ text: 'Jika Anda tidak ingin karya Anda dibuat anonim, silakan kunjungi
+ halaman Approved Collection Items (Artikel Koleksi yang Disetujui) untuk
+ menghapus karya tersebut dari koleksi ini: %{collection_items_url}'
+ anonymous_unrevealed:
+ html: Jika Anda tidak ingin karya Anda dibuat anonim dan disembunyikan,
+ silakan kunjungi %{collection_items_link} untuk menghapus karya tersebut
+ dari koleksi ini.
+ text: 'Jika Anda tidak ingin karya Anda dibuat anonim dan disembunyikan,
+ silakan kunjungi halaman Approved Collection Items (Artikel Koleksi yang
+ Disetujui) untuk menghapus karya tersebut dari koleksi ini: %{collection_items_url}'
+ unrevealed:
+ html: Jika Anda tidak ingin karya Anda disembunyikan, silakan kunjungi %{collection_items_link}
+ untuk menghapus karya tersebut dari koleksi ini.
+ text: 'Jika Anda tidak ingin karya Anda disembunyikan, silakan kunjungi
+ halaman Approved Collection Items (Artikel Koleksi yang Disetujui) untuk
+ menghapus karya tersebut dari koleksi ini: %{collection_items_url}'
+ faq_link_text: Pertanyaan Umum tentang Koleksi
+ more_info:
+ html: Untuk informasi lebih lanjut, silakan kunjungi %{faq_link}.
+ text: 'Untuk informasi lebih lanjut, silakan kunjungi Pertanyaan Umum tentang
+ Koleksi: %{faq_url}'
+ subject:
+ anonymous: "[%{app_name}] Karya Anda telah dibuat anonim"
+ anonymous_unrevealed: "[%{app_name}] Karya Anda telah dibuat anonim dan disembunyikan"
+ unrevealed: "[%{app_name}] Karya Anda telah disembunyikan"
+ unrevealed_info: Karya tersembunyi tidak akan terlihat dalam daftar tag (label)
+ maupun halaman karya Anda. Semua orang yang membuka tautan ke karya tersebut
+ tidak akan dapat melihat kontennya dan akan menerima notifikasi bahwa karya
+ tersebut saat ini sedang disembunyikan.
+ archivist_added_to_collection_notification:
+ approved_collection_items_page: Laman Approved Collection Items (Barang Koleksi
+ yang Telah Disetujui)
+ archivist_notice: Karena pengelola koleksi bertindak sesuai dengan kewenangannya
+ sebagai admin arsip Open Doors (Pintu Terbuka), mereka diizinkan untuk menambahkan
+ karya Anda ke koleksi ini, meskipun Anda tidak mengizinkan undangan koleksi.
+ Admin arsip hanya akan menambahkan karya ke koleksi jika karya tersebut sebelumnya
+ berada di arsip yang diimpor.
+ removal_instructions:
+ html: Jika Anda ingin mengeluarkan karya Anda dari koleksi ini, mohon kunjungi
+ %{approved_items_link}.
+ text: 'Jika Anda ingin mengeluarkan karya Anda dari koleksi ini, mohon kunjungi
+ laman Approved Collection Items (Barang Koleksi yang Telah Disetujui) Anda:
+ %{approved_items_url}.'
+ subject: "[%{app_name}][%{collection_title}] Admin arsip Open Doors (Pintu Terbuka)
+ telah menambahkan karya Anda ke dalam sebuah koleksi"
+ work_added:
+ html: Pengelola koleksi dari %{collection_link} telah menambahkan karya Anda
+ %{work_link} ke dalam koleksi mereka!
+ text: Pengelola koleksi "%{collection_title}" (%{collection_url}) telah menambahkan
+ karya Anda "%{work_title}" (%{work_url}) ke koleksinya!
+ challenge_assignment_notification:
+ any: Apapun
+ assignment:
+ html: Anda ditugaskan untuk memenuhi permintaan dalam challenge (tantangan)
+ berikut %{link} di AO3!
+ description: 'Deskripsi:'
+ due: 'Tenggat waktu tugas ini adalah:'
+ html:
+ footer: Anda menerima surel ini karena Anda telah mendaftarkan diri untuk
+ mengikuti tantangan %{title}. Untuk informasi lebih lanjut terkait tantangan
+ ini atau untuk informasi kontak moderator, silakan kunjungi %{footer_link}.
+ footer_link: halaman profil tantangan
+ look_up: Anda dapat melihat tugas ini dari %{link}.
+ look_up_link: halaman Assignments (Tugas) Anda
+ optional_tags: 'Tag (Label) Opsional:'
+ prompts: 'Prompt (Ide Karya):'
+ prompt_url: 'URL Prompt (Ide Karya):'
+ recipient: 'Penerima:'
+ recipient_missing: 'Tidak tersedia: hubungi moderator untuk bantuan!'
+ subject: "[%{app_name}][%{collection_title}] Tugas Anda!"
+ text:
+ assignment: Anda telah diberikan tugas untuk memenuhi permintaan berikut dalam
+ challenge (tantangan) "%{collection_title}" (%{collection_url}) di AO3!
+ footer: Anda menerima surel ini karena Anda telah mendaftarkan diri untuk
+ mengikuti tantangan %{title} (%{url}). Untuk informasi lebih lanjut terkait
+ tantangan ini atau untuk informasi kontak moderator, silakan kunjungi %{profile_url}.
+ look_up: Anda dapat melihat tugas ini dari halaman Assignments (Tugas) di
+ %{link}.
+ change_email:
+ changed:
+ html: "%{login}, alamat surel yang diasosiasikan dengan akun Anda telah diganti
+ ke %{email}"
+ text: "%{login}, alamat surel yang diasosiasikan dengan akun Anda telah diganti
+ ke %{email}"
+ subject: "[%{app_name}] Alamat surel telah diganti"
+ claim_notification:
+ access:
+ contact_support: hubungi Komite Bantuan AO3
+ html: Menyesuaikan dengan arsipnya, karya Anda mungkin telah diimpor secara
+ terbatas hanya untuk pengguna yang terdaftar (untuk mencegah agar tidak
+ muncul di pencarian Google). Jika demikian, maka karya-karya tersebut hanya
+ dapat diakses oleh pengguna yang sudah masuk ke akun AO3, kecuali jika Anda
+ memilih untuk membuatnya terbuka untuk umum. Untuk bantuan dalam mengubah
+ pengaturan karya menjadi terbuka untuk umum, orphaning (meninggalkan karya),
+ atau menghapus karya, silakan %{contact_support_link}.
+ text: Menyesuaikan dengan arsipnya, karya Anda mungkin telah diimpor secara
+ terbatas hanya untuk pengguna yang terdaftar (untuk mencegah agar tidak
+ muncul di pencarian Google). Jika demikian, maka karya-karya tersebut hanya
+ dapat diakses oleh pengguna yang sudah masuk ke akun AO3, kecuali jika Anda
+ memilih untuk membuatnya terbuka untuk umum. Untuk bantuan dalam mengubah
+ pengaturan karya menjadi terbuka untuk umum, orphaning (meninggalkan karya),
+ atau menghapus karya, silakan hubungi Komite Bantuan AO3 di %{support_url}.
+ email_tips: Jika Anda menghubungi kami, harap tambahkan alamat surel dari @transformativeworks.org
+ ke dalam daftar pengirim aman dan periksa folder spam Anda untuk menemukan
+ balasan dari kami.
+ introduction:
+ ao3_name: Archive of Our Own - AO3 (Arsip Milik Kita)
+ html: Anda menerima surel ini karena Anda memiliki karya di arsip karya fan
+ yang telah diimpor oleh %{open_doors_name_link} ke %{app_link}. Karena alamat
+ surel ini terhubung dengan alamat surel yang terdaftar di arsip yang diimpor,
+ maka karya fan terkait (tercantum di bawah ini) telah secara otomatis ditambahkan
+ ke akun AO3 Anda.
+ open_doors_name: Open Doors (Pintu Terbuka)
+ text: 'Anda menerima surel ini karena Anda memiliki karya di arsip karya fan
+ yang telah diimpor oleh Open Doors (Pintu Terbuka) (%{open_doors_url}) ke
+ Archive of Our Own – AO3 (Arsip Milik Kita): %{app_url}. Karena alamat surel
+ ini terhubung dengan alamat surel yang terdaftar di arsip yang diimpor,
+ maka karya fan terkait (tercantum di bawah ini) telah secara otomatis ditambahkan
+ ke akun AO3 Anda.'
+ mistake:
+ contact_open_doors: hubungi Pintu Terbuka
+ html: Jika hal ini merupakan sebuah kesalahan dan karya-karya tersebut bukan
+ milik Anda, harap jangan dihapus! Harap %{contact_open_doors_link} dan kami
+ akan menyelesaikan masalah ini.
+ text: Jika ini merupakan sebuah kesalahan dan karya-karya ini bukan karya
+ Anda, harap jangan dihapus! Harap hubungi Pintu Terbuka (%{open_doors_url})
+ dan kami akan menyelesaikan masalah ini.
+ more_info:
+ ao3_news: Berita AO3
+ contact_support: hubungi Komite Bantuan AO3
+ faq_page: laman Pertanyaan Umum
+ html: Anda dapat membaca pengumuman mengenai pemindahan arsip terbaru di %{ao3_news_link},
+ dan menemukan informasi tambahan di %{faq_page_link} atau %{tutorial_page_link}
+ milik Pintu Terbuka. Untuk pertanyaan yang tidak terjawab dalam Pertanyaan
+ Umum, tutorial, atau surel ini, silakan %{contact_support_link}.
+ text: Anda dapat membaca pengumuman mengenai pemindahan arsip terbaru di Berita
+ AO3 (%{news_url}), dan menemukan informasi tambahan di halaman Pertanyaan
+ Umum (%{open_doors_faq_url}) atau halaman tutorial (%{open_doors_tutorial_url})
+ milik Pintu Terbuka. Untuk pertanyaan yang tidak terjawab dalam Pertanyaan
+ Umum, tutorial, atau surel ini, silakan hubungi Komite Bantuan AO3 di %{support_url}.
+ tutorial_page: laman tutorial
+ other_works:
+ contact_open_doors: hubungi Pintu Terbuka
+ html: Jika Anda memiliki karya-karya lain pada arsip yang diimpor yang ditulis
+ atas nama alamat surel yang tidak dapat Anda akses lagi, silakan %{contact_open_doors_link}
+ dengan informasi apa saja yang dapat membantu memverifikasi identitas Anda.
+ text: Jika Anda memiliki karya lain pada arsip yang diimpor yang ditulis atas
+ nama alamat surel yang tidak dapat Anda akses lagi, silakan hubungi Pintu
+ Terbuka dengan informasi apa saja yang dapat membantu memverifikasi identitas
+ Anda.
+ questions:
+ contact_support: hubungi Komite Bantuan AO3
+ html: Untuk pertanyaan lainnya, silakan %{contact_support_link}.
+ text: Untuk pertanyaan lainnya, silakan hubungi Komite Bantuan AO3 di %{support_url}.
+ redirects:
+ html: Untuk melestarikan daftar rekomendasi dan markah buku, alamat web dari
+ arsip yang diimpor mungkin akan dialihkan ke salinan karya yang telah diimpor
+ selama waktu tertentu (periksa artikel pengumuman arsip Anda untuk memastikannya).
+ Jika Anda telah mengunggah salinan dari karya-karya ini dan Anda %{negation}
+ menggunakan fitur impor dari URL, maka akan ada dua salinan dari karya yang
+ sama di AO3.
+ subject: "[%{app_name}] Karya diunggah"
+ update_redirect:
+ contact_open_doors: hubungi Pintu Terbuka
+ html: Jika Anda ingin Open Doors (Pintu Terbuka) untuk memperbarui pengalihan
+ tautan ke karya Anda yang sudah ada sebelumnya, hapus salinan yang telah
+ diimpor, dan %{contact_open_doors_link} dengan nama akun AO3 Anda, nama
+ akun Anda di arsip yang telah diimpor, dan judul beserta URL dari karya
+ fan yang ingin Anda jadikan sebagai tujuan dari pengalihan tautan. (Jika
+ Anda memiliki lebih dari satu karya yang ingin Anda ubah pengalihan tautannya,
+ Anda dapat mencantumkan semuanya di dalam satu surel.)
+ text: Jika Anda ingin Pintu Terbuka memperbarui pengalihan tautan untuk mengarahkan
+ ke karya Anda yang sudah ada sebelumnya, hapus salinan yang telah diimpor,
+ dan hubungi Pintu Terbuka di %{open_doors_url} dengan nama akun AO3 Anda,
+ nama akun Anda di arsip yang telah diimpor, dan judul beserta URL dari karya
+ fan yang ingin Anda jadikan sebagai tujuan dari pengalihan tautan. (Jika
+ Anda memiliki lebih dari satu karya yang ingin Anda ubah pengalihan tautannya,
+ Anda dapat mencantumkan semuanya di dalam satu surel.)
+ works_by: 'Karya-karya berikut ditulis atas nama surel: %{email}'
+ work_info:
+ html: "%{work_link} (%{fandom})"
+ text:
+ no_fandom: "- %{work_title} %{work_url}"
+ with_fandom: "- %{work_title} %{work_url} (%{fandom})"
+ collection_notification:
+ assignments_sent:
+ complete: Seluruh Assignment (tugas) telah dikirimkan.
+ subject: Assignment (tugas) telah dikirim
+ html:
+ received_message: 'Anda menerima pesan tentang collection (koleksi) %{collection_link}
+ Anda:'
+ text:
+ received_message: 'Anda menerima pesan tentang collection (koleksi) "%{collection_title}"
+ (%{collection_url}) Anda:'
+ creatorship_notification:
+ explanation: Ketika Anda menjadi seorang rekan kreator sebuah karya, Anda dapat
+ ditambahkan pada bab-bab terbaru dalam karya terlepas dari pengaturan pembuatan
+ karya rekanan sebelumnya. Anda juga akan ditambahkan ke dalam seri manapun
+ tempat karya tersebut berada.
+ html:
+ creation: "%{creation_link} oleh %{pseud_links}"
+ edit_chapter: menyunting chapter tersebut
+ edit_series: menyunting series tersebut
+ remove_chapter: Jika Anda ditambahkan akibat kesalahan atau Anda tidak ingin
+ didaftarkan sebagai kreator, Anda dapat %{edit_chapter_link} untuk menghapus
+ status Anda sebagai kreator.
+ remove_series: Jika Anda ditambahkan akibat kesalahan atau Anda tidak ingin
+ didaftarkan sebagai kreator, Anda dapat %{edit_series_link} untuk menghapus
+ status Anda sebagai kreator.
+ intro_chapter: 'Pengguna %{adding_user} telah mendaftarkan nama pengguna Anda
+ %{pseud} sebagai rekan kreator pada chapter (bab) berikut:'
+ intro_series: 'Pengguna %{adding_user} telah mendaftarkan pseud (alias) %{pseud}
+ anda sebagai rekan kreator dalam series (seri) berikut:'
+ subject: "[%{app_name}] Notifikasi Rekan kreator"
+ text:
+ creation: "%{title} (%{url}) oleh %{pseuds}"
+ remove_chapter: 'Jika Anda ditambahkan akibat kesalahan atau tidak ingin didaftarkan
+ sebagai kreator, Anda dapat menyunting chapter untuk menghapus status Anda
+ sebagai kreator: %{url}'
+ remove_series: 'Jika Anda ditambahkan akibat kesalahan atau tidak ingin didaftarkan
+ sebagai kreator, Anda dapat menyunting series tersebut untuk menghapus status
+ Anda sebagai kreator: %{url}'
+ creatorship_notification_archivist:
+ explanation: Karena berlaku dalam kapasitas resmi sebagai pengarsip Open Doors,
+ ia diizinkan untuk menunjuk Anda sebagai rekan pencipta tanpa permintaan,
+ walaupun Anda telah mematikan opsi untuk menjadi rekan pencipta.
+ html:
+ creation: "%{creation_link} oleh %{pseud_links}"
+ edit_chapter: sunting bab
+ edit_series: sunting serial
+ edit_work: sunting karya
+ remove_chapter: Jika ada kesalahan dalam penunjukan ini atau Anda tidak ingin
+ ditunjuk sebagai pencipta, Anda dapat menghapus alias Anda sebagai pencipta
+ di %{edit_chapter_link}.
+ remove_series: Jika ada kesalahan dalam penunjukan ini atau Anda tidak ingin
+ ditunjuk sebagai pencipta, Anda dapat menghapus alias Anda sebagai pencipta
+ %{edit_series_link}.
+ remove_work: Jika ada kesalahan dalam penunjukan ini atau Anda tidak ingin
+ ditunjuk sebagai pencipta, Anda dapat %{edit_work_link} untuk menghapus
+ alias Anda sebagai pencipta.
+ intro_chapter: 'Pengguna %{archivist} telah menunjuk alias %{pseud} Anda sebagai
+ rekan pencipta untuk bab berikut:'
+ intro_series: 'Pengarsip %{archivist} telah menunjuk alias %{pseud} Anda sebagai
+ rekan pencipta untuk serial berikut:'
+ intro_work: 'Pengarsip %{archivist} telah menunjuk alias %{pseud} Anda sebagai
+ rekan pencipta untuk karya berikut:'
+ subject: "[%{app_name}] Notifikasi penunjukan sebagai rekan pencipta oleh pengarsip"
+ text:
+ creation: "%{title} (%{url}) oleh %{pseuds}"
+ remove_chapter: 'Jika ada kesalahan dalam penunjukan ini atau Anda tidak ingin
+ ditunjuk sebagai pencipta, Anda dapat menyunting bab ini untuk menghapus
+ alias Anda sebagai pencipta: %{url}'
+ remove_series: 'Jika ada kesalahan dalam penunjukan ini atau Anda tidak ingin
+ ditunjuk sebagai pencipta, Anda dapat menyunting serial ini untuk menghapus
+ alias Anda sebagai pencipta: %{url}'
+ remove_work: 'Jika ada kesalahan dalam penunjukan ini atau Anda tidak ingin
+ ditunjuk sebagai pencipta, Anda dapat menyunting karya ini untuk menghapus
+ alias Anda sebagai pencipta: %{url}'
+ creatorship_request:
+ html:
+ creation: "%{creation_link} oleh %{pseud_links}"
+ instructions: Anda dapat menerima atau menolak undangan ini di halaman %{page_name}
+ Anda.
+ page_name: Co-Creator Requests (Undangan Rekan Kreator)
+ intro_chapter: 'Pengguna %{inviting_user} mengundang Pseud (alias) Anda %{pseud}
+ untuk didaftarkan sebagai rekan kreator di chapter (bab) berikut:'
+ intro_series: 'Pengguna %{inviting_user} mengundang Pseud (alias) Anda %{pseud}
+ untuk didaftarkan sebagai rekan kreator di series (seri) berikut:'
+ intro_work: 'Pengguna %{inviting_user} mengundang Pseud (alias) Anda %{pseud}
+ untuk didaftarkan sebagai rekan kreator di karya berikut:'
+ subject: "[%{app_name}] Undangan rekan kreator"
+ text:
+ creation: "%{title} (%{url}) oleh %{pseuds}"
+ instructions: 'Anda dapat menerima atau menolak undangan ini di halaman Co-Creator
+ Requests (Undangan Rekan Kreator) Anda: %{url}'
+ delete_work_notification:
+ attachment: Terlampir salinan karya Anda sebagai referensi.
+ deleted_other:
+ html: Karya Anda, %{title}, telah dihapus atas permintaan %{pseud}.
+ text: Karya Anda, "%{title}", telah dihapus atas permintaan %{pseud}.
+ deleted_yourself:
+ html: Karya Anda, %{title}, telah dihapus atas permintaan Anda.
+ text: Karya Anda, "%{title}", telah dihapus atas permintaan Anda.
+ questions:
+ html: Jika Anda memiliki pertanyaan, silakan %{support}.
+ text: Jika Anda memiliki pertanyaan, silakan %{support} (%{url}).
+ subject: "[%{app_name}] Karya Anda telah dihapus"
+ support: hubungi Komite Bantuan
+ invitation:
+ been_invited: Kamu telah diundang untuk menjadi beta kami!
+ has_invited: "%{user_name} telah mengundangmu untuk bergabung menjadi beta kami!"
+ html:
+ faq_link_text: Pertanyaan umum kami
+ subject: Undangan [%{app_name}]
+ invitation_to_claim:
+ access:
+ text: Tergantung pada arsip asalnya, karya-karya Anda mungkin saja telah diimpor
+ dan hanya dapat dilihat oleh pengguna yang terdaftar saja (untuk mencegah
+ pencarian karya tersebut melalui Google). Jika hal ini terjadi, karya-karya
+ tersebut hanya dapat diakses oleh pengguna yang telah tercatat masuk, kecuali
+ Anda memutuskan untuk membuka akses terhadap karya Anda. Untuk bantuan membuka
+ karya, meninggalkan karya, atau menghapus karya, silakan hubungi tim Dukungan
+ AO3.
+ claim_or_remove:
+ html: Klaim atau hapus karya Anda di sini.
+ text: 'Klaim atau hapus karya Anda di sini: %{claim_url}'
+ email_tips: Jika Anda ingin menghubungi kami, harap masukkan alamat-alamat surel
+ dari @transformativeworks.org ke daftar putih kontak Anda dan cek folder spam
+ Anda untuk memastikan Anda menerima balasan kami.
+ html:
+ ao3_news: Berita AO3
+ contact_open_doors: hubungi tim Open Doors
+ contact_support: hubungi tim Dukungan AO3
+ faq_page: halaman Pertanyaan Umum
+ tutorial_page: halaman tutorial
+ introduction:
+ text: Anda menerima surel ini karena baru-baru ini ada arsip yang telah diimpor
+ oleh tim Open Doors (Pintu Terbuka) (%{open_doors_link}) ke %{app_name}
+ (%{app_short_name} - %{app_url}), dan berdasarkan informasi yang kami miliki,
+ karya fan berikut ini adalah milik Anda. Kami memberikan Anda kesempatan
+ untuk mengklaim (atau menghapus/meninggalkan) karya-karya ini jika Anda
+ ingin. Jika Anda belum mempunyai akun AO3 dengan alamat surel yang berbeda,
+ kami mengundang Anda untuk bergabung dengan kami!
+ mistake:
+ text: Jika ini adalah sebuah kesalahan dan karya-karya ini bukan milik Anda,
+ mohon jangan hapus karya-karya ini! Tolong hubungi tim Open Doors (%{open_doors_link})
+ dan kami akan menanganinya.
+ more_info:
+ text: 'Anda dapat membaca pengumuman tentang kegiatan pengarsipan terbaru
+ di Berita AO3 (%{news_link}), serta mendapat informasi tambahan di halaman
+ Pertanyaan Umum tentang Open Doors (%{open_doors_faq_link}) atau di halaman
+ tutorial (%{open_doors_tutorial_link}). Untuk pertanyaan-pertanyaan yang
+ belum terjawab di halaman Pertanyaan Umum, tutorial, atau surel ini, silakan
+ hubungi Tim Dukungan AO3 di %{support_link}.
+
+ '
+ other_works:
+ text: Jika Anda memiliki karya-karya lain di arsip impor yang menggunakan
+ alamat surel yang sudah tidak dapat Anda akses, silakan hubungi tim Open
+ Doors dengan menyertakan informasi apapun yang dapat memverifikasi identitas
+ Anda.
+ questions:
+ text: Untuk pertanyaan-pertanyaan lain, silakan hubungi tim Dukungan AO3 di
+ %{support_link}.
+ redirects: Untuk mempertahankan daftar rekomendasi dan markah buku, alamat web
+ menuju arsip yang telah diimpor bisa saja telah dialihkan langsung ke salinan
+ karya yang telah diimpor ini selama jangka waktu tertentu (cek artikel pengumuman
+ arsip Anda untuk memastikannya). Jika Anda telah mengunggah salinan dari karya
+ ini dan Anda TIDAK menggunakan fitur impor dari URL, akan ada dua salinan
+ karya yang sama di AO3.
+ subject: "[%{app_name}] Undangan untuk mengklaim karya"
+ unwanted:
+ text: Jika karya-karya ini milik Anda, tapi Anda tidak menginginkannya lagi,
+ Anda dapat meninggalkan mereka (sehingga mereka dapat tetap berada di AO3,
+ tapi nama Anda akan terhapus) atau menghapus mereka (sehingga karya tersebut
+ akan dihilangkan dari AO3 seluruhnya). Untuk meninggalkan atau menghapus
+ karya, Anda tidak perlu menambahkannya ke akun manapun--Anda dapat melakukan
+ ini langsung melalui tautan klaim diatas. (Untuk bantuan, silakan hubungi
+ tim Dukungan di %{support_link}.)
+ update_redirect:
+ text: Jika Anda ingin tim Open Doors untuk memperbarui tautan pengalihan agar
+ mengarahkan ke karya yang Anda impor sendiri sebelumnya, harap hapus salinan
+ yang kami impor, lalu hubungi tim Open Doors di %{open_doors_link} menggunakan
+ nama akun AO3 Anda, nama akun Anda di arsip impor, dan judul serta URL karya
+ fan yang akan dituju oleh tautan pengalihan Anda. (Jika Anda memiliki beberapa
+ karya yang ingin Anda ganti tautan pengalihannya, Anda dapat menuliskan
+ semua tautan tersebut dalam satu surel.)
+ uploaded_list: 'Karya yang diunggah termasuk:'
+ invite_increase_notification:
+ html:
+ body:
+ other: Kami hanya ingin memberitahu bahwa Anda memiliki %{count} undangan
+ baru yang dapat digunakan untuk membuat akun baru di AO3. Anda dapat mengundang
+ teman Anda di %{invitation_page_link}.
+ invitation_page_link_text: halaman Invitations (Undangan) Anda
+ subject: "[%{app_name}] Undangan Baru"
+ text:
+ body:
+ other: Kami hanya ingin memberitahu bahwa Anda memiliki %{count} undangan
+ baru yang dapat digunakan untuk membuat akun baru di AO3. Anda dapat mengundang
+ teman Anda di halaman Invitations (Undangan) Anda (%{invitation_page_url}).
+ invite_request_declined:
+ main:
+ other: Kami mohon maaf karena permintaan Anda untuk %{count} undangan baru
+ tidak dapat kami penuhi saat ini.
+ reason: 'Permintaan Anda adalah:'
+ subject: "[%{app_name}] Permintaan kode undangan tambahan ditolak"
+ recipient_notification:
+ html:
+ collection: Sebuah karya hadiah telah diunggah ke koleksi %{collection_link}
+ di AO3 untuk Anda!
+ no_collection: Sebuah karya hadiah telah diunggah ke AO3 untuk Anda!
+ subject:
+ collection: "[%{app_name}][%{collection_title}] Karya hadiah untuk Anda dari
+ %{collection_title}"
+ no_collection: "[%{app_name}] Karya hadiah untuk Anda"
+ text:
+ collection: Sebuah karya hadiah telah diunggah ke koleksi "%{collection_title}"
+ di AO3 (%{collection_url}) untuk Anda!
+ signup_notification:
+ activate:
+ html: Mohon %{activate_account_link}.
+ text: 'Mohon ikuti tautan ini untuk mengaktifkan akun Anda: %{activate_account_url}'
+ activate_your_account: Ikuti tautan ini untuk mengaktifkan akun Anda.
+ admin_posts: Berita AO3
+ bye: Kami harap Anda menikmati menggunakan AO3.
+ contact_support: Hubungi Dukungan
+ faq: Pertanyaan yang Sering Diajukan
+ features:
+ html: Setelah akun Anda tersedia dan aktif, Anda dapat memposting karya-karya
+ penggemar, mengatur layanan surel langganan untuk memberitahu Anda saat
+ pembuat konten favorit Anda memposting karya terbaru atau karya favorit
+ Anda diperbarui, mengatur tampilan situs, melacak karya-karya yang telah
+ Anda buka melalui riwayat, dan lain-lain.
+ text: Setelah akun Anda tersedia dan aktif, Anda dapat memposting karya-karya
+ penggemar, mengatur layanan surel langganan untuk memberitahu Anda saat
+ pembuat konten favorit Anda memposting karya terbaru atau karya favorit
+ Anda diperbarui, mengatur tampilan situs, melacak karya-karya yang telah
+ Anda buka melalui riwayat, dan lain-lain.
+ information:
+ html: Terdapat banyak informasi dan saran mengenai cara penggunaan AO3 yang
+ tersedia di %{faq_link} kami. Anda dapat menemukan berita terbaru mengenai
+ perkembangan situs di %{admin_posts_link} kami. Jika Anda membutuhkan bantuan,
+ menemukan bug, atau ingin mengajukan pertanyaan atau komentar, mohon
+ %{contact_support_link}, yang dengan senang hati akan membantu Anda.
+ text: 'Terdapat banyak informasi dan saran mengenai cara penggunaan AO3 yang
+ tersedia di Pertanyaan yang Sering Diajukan kami pada %{faq_url}. Anda dapat
+ menemukan berita terbaru mengenai perkembangan situs di Berita AO3 pada
+ %{admin_posts_url}. Jika Anda membutuhkan bantuan, menemukan bug, atau ingin
+ mengajukan pertanyaan atau komentar, mohon hubungi tim Dukungan kami, yang
+ dengan senang hati akan membantu Anda: %{contact_support_url}.'
+ welcome: Selamat datang di Archive of Our Own – AO3 (Arsip Milik Kita), %{login}!
diff --git a/config/locales/phrase-exports/it.yml b/config/locales/phrase-exports/it.yml
new file mode 100755
index 0000000..0e33626
--- /dev/null
+++ b/config/locales/phrase-exports/it.yml
@@ -0,0 +1,662 @@
+---
+it:
+ activerecord:
+ attributes:
+ archive_warning:
+ name_with_colon:
+ one: 'Avvertimento:'
+ other: 'Avvertimenti:'
+ category:
+ name_with_colon:
+ one: 'Categoria:'
+ other: 'Categorie:'
+ character:
+ name_with_colon:
+ one: 'Personaggio:'
+ other: 'Personaggi:'
+ fandom:
+ name_with_colon:
+ one: 'Fandom:'
+ other: 'Fandom:'
+ freeform:
+ name_with_colon:
+ one: 'Tag Aggiuntiva:'
+ other: 'Tag Aggiuntive:'
+ rating:
+ name_with_colon: 'Rating:'
+ relationship:
+ name_with_colon:
+ one: 'Relazione:'
+ other: 'Relazioni:'
+ work:
+ chapter_total_display: Capitoli
+ summary: Riassunto
+ models:
+ archive_warning:
+ one: Avvertimento
+ other: Avvertimenti
+ category:
+ one: Categoria
+ other: Categorie
+ chapter:
+ one: Capitolo
+ other: Capitoli
+ character:
+ one: Personaggio
+ other: Personaggi
+ fandom:
+ one: Fandom
+ other: Fandom
+ freeform:
+ one: Tag Aggiuntiva
+ other: Tag Aggiuntive
+ rating:
+ one: Rating
+ other: Rating
+ relationship:
+ one: Relazione
+ other: Relazioni
+ series:
+ one: Serie
+ other: Serie
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ one: un ospite
+ other: "%{count} ospiti"
+ left_kudos:
+ html:
+ one: "%{givers_list} ha lasciato kudos per %{commentable_link}."
+ other: "%{givers_list} hanno lasciato kudos per %{commentable_link}."
+ text:
+ one: "%{givers_list} ha lasciato kudos per %{commentable_title} (%{commentable_url})."
+ other: "%{givers_list} hanno lasciato kudos per %{commentable_title} (%{commentable_url})."
+ single_guest:
+ giver: Un ospite
+ html: "%{giver} ha lasciato kudos per %{commentable_link}."
+ text: Un ospite ha lasciato kudos per %{commentable_title} (%{commentable_url}).
+ subject: "[%{app_name}] Hai dei kudos!"
+ mailer:
+ general:
+ closing:
+ formal: Un saluto,
+ informal: Un saluto,
+ creation:
+ link_with_word_count: "%{creation_link} (%{word_count})"
+ title_with_chapter_number: Capitolo %{position} di %{title}
+ title_with_word_count: '"%{creation_title}" (%{word_count})'
+ word_count:
+ one: "%{count} parola"
+ other: "%{count} parole"
+ footer:
+ general:
+ about:
+ html: AO3 è un archivio supportato e gestito dai fan che dipende dalle
+ %{donate_link}.
+ text: 'AO3 è un archivio supportato e gestito dai fan che dipende dalle
+ vostre donazioni: %{donate_url}.'
+ html:
+ donate_link_text: vostre donazioni
+ support_link_text: contatta il Supporto
+ unwanted_email:
+ html: Se hai ricevuto questo messaggio per errore, per favore %{support_link}.
+ text: Se hai ricevuto questo messaggio per errore, contatta il Supporto
+ all'indirizzo %{support_url}.
+ sent_at: Inviato %{sent_at}.
+ greeting:
+ formal_html: Ciao %{name},
+ informal:
+ addressed_html: Ciao, %{name}!
+ unaddressed: Ciao!
+ introductory: Ciao da Archive of Our Own – AO3 (Archivio Tutto Per Noi)!
+ metadata_label_indicator: ":"
+ signature:
+ abuse_team: Il team Norme & Violazioni di AO3
+ app_short_name: AO3
+ open_doors: Il team di Open Doors (Porte Aperte)
+ parent_org: Organization for Transformative Works – OTW (Organizzazione per
+ i Lavori Trasformativi)
+ support: Il team di Supporto di AO3
+ users:
+ mailer:
+ reset_password_instructions:
+ expiration: Se non utilizzi questo link per reimpostare la tua password entro
+ una settimana, il link scadrà e dovrai richiederne uno nuovo.
+ intro: 'È stato richiesta la reimpostazione della password del tuo account.
+ Puoi cambiare la tua password accedendo al seguente link e immettendo la
+ tua nuova password:'
+ link_title: Cambia la mia password.
+ subject: "[%{app_name}] Reimposta la tua password"
+ unrequested: Se non hai richiesto di reimpostare la password, puoi ignorare
+ questa email; la tua password attuale continuerà a funzionare.
+ user_mailer:
+ admin_deleted_work_notification:
+ bye: In allegato puoi trovare una copia del tuo lavoro a cui fare riferimento.
+ contact_abuse: contatta il nostro Comitato Norme & Violazioni
+ deleted:
+ html: Il tuo lavoro %{title} è stato rimosso da AO3 da un amministratore del
+ sito.
+ text: Il tuo lavoro "%{title}" è stato rimosso da AO3 da un amministratore
+ del sito.
+ html:
+ tos_violation: Se ritieni possibile che il tuo lavoro abbia violato i Termini
+ di Servizio di AO3, per favore %{contact_abuse_link}.
+ import_project:
+ html: Se il tuo lavoro faceva parte di un progetto di importazione gestito
+ dal nostro team Open Doors (Porte Aperte), per favore %{opendoors_link}
+ per ulteriori domande.
+ text: Se il tuo lavoro faceva parte di un progetto di importazione gestito
+ dal nostro team Open Doors (Porte Aperte), per favore contatta Porte Aperte
+ (%{opendoors_link}) per ulteriori domande.
+ opendoors: contatta Porte Aperte
+ subject: "[%{app_name}] Il tuo lavoro è stato rimosso da un amministratore"
+ text:
+ tos_violation: Se ritieni possibile che il tuo lavoro abbia violato i Termini
+ di Servizio di AO3, per favore contatta il nostro Comitato Norme & Violazioni
+ (%{contact_abuse_url}).
+ admin_hidden_work_notification:
+ access: Quando il tuo lavoro è nascosto, puoi comunque accedervi attraverso
+ il link fornito qui sopra, ma non viene elencato nella pagina dei tuoi lavori
+ e non risulta disponibile per gli altri utenti di AO3.
+ check_email: Ti invitiamo a controllare la tua email, compresa la cartella spam,
+ poiché il team Norme & Violazioni potrebbe averti già contattato per spiegarti
+ perché il tuo lavoro è stato nascosto.
+ contact_abuse: contatta Norme & Violazioni
+ html:
+ help: Se non hai certezza del perché il tuo lavoro sia stato nascosto e non
+ hai ricevuto ulteriori comunicazioni al riguardo, per favore %{contact_abuse_link}
+ direttamente.
+ hidden: Il tuo lavoro %{title} è stato nascosto dal team di Norme & Violazioni
+ e non è più accessibile al pubblico.
+ tos_violation: Se il tuo lavoro è stato nascosto perché viola i %{tos_link}
+ di AO3, ti verrà richiesto di intervenire per rettificare la violazione.
+ Se non provvederai ad apportare le opportune modifiche al tuo lavoro in
+ accordo con i Termini di Servizio, la conseguenza potrebbe essere la cancellazione
+ del tuo lavoro da AO3.
+ subject: "[%{app_name}] Il tuo lavoro è stato nascosto dal team di Norme & Violazioni"
+ text:
+ help: 'Se non hai certezza del perché il tuo lavoro sia stato nascosto e non
+ hai ricevuto ulteriori comunicazioni al riguardo, ti invitiamo a contattare
+ direttamente Norme & Violazioni: %{contact_abuse_url}.'
+ hidden: Il tuo lavoro "%{title}" (%{work_url}) è stato nascosto dal team di
+ Norme & Violazioni e non è più accessibile al pubblico.
+ tos_violation: Se il tuo lavoro è stato nascosto perché viola i Termini di
+ Servizio di AO3 (%{tos_url}), ti verrà richiesto di intervenire per rettificare
+ la violazione. Se non provvederai ad apportare le opportune modifiche al
+ tuo lavoro in accordo con i Termini di Servizio, la conseguenza potrebbe
+ essere la cancellazione del tuo lavoro da AO3.
+ tos: Termini di Servizio
+ anonymous_or_unrevealed_notification:
+ anonymous_info: I lavori anonimi sono inclusi negli elenchi di tag, ma non sulla
+ pagina dei tuoi lavori. Nella pagina del lavoro, il tuo nome utente sarà sostituito
+ da “Anonymous” (Anonimo).
+ anonymous_unrevealed_info: I manutentori della collezione potranno in seguito
+ rivelare il tuo lavoro ma lasciarlo anonimo. I tuoi iscritti non verranno
+ notificati di questo cambiamento. Una volta rivelato, il tuo lavoro sarà incluso
+ negli elenchi di tag, ma non sui lavori della tua pagina. Nella pagina del
+ lavoro, il tuo nome utente sarà sostituito da “Anonymous” (Anonimo).
+ changed_status:
+ anonymous:
+ html: I manutentori della collezione %{collection_link} hanno cambiato lo
+ stato del tuo lavoro %{work_link} in anonimo.
+ text: I manutentori della collezione "%{collection_title}" (%{collection_url})
+ hanno cambiato lo stato del tuo lavoro "%{work_title}" (%{work_url}) in
+ anonimo.
+ anonymous_unrevealed:
+ html: I manutentori della collezione %{collection_link} hanno cambiato lo
+ stato del tuo lavoro %{work_link} in anonimo e nascosto.
+ text: I manutentori della collezione "%{collection_title}" (%{collection_url})
+ hanno cambiato lo stato del tuo lavoro "%{work_title}" (%{work_url}) in
+ anonimo e nascosto.
+ unrevealed:
+ html: I manutentori della collezione %{collection_link} hanno cambiato lo
+ stato del tuo lavoro %{work_link} in nascosto.
+ text: I manutentori della collezione "%{collection_title}" (%{collection_url})
+ hanno cambiato lo stato del tuo lavoro "%{work_title}" (%{work_url}) in
+ nascosto.
+ collection_items_link_text: pagina “Approved Collection Items” (Elementi approvati
+ della collezione)
+ do_not_want:
+ anonymous:
+ html: Se non vuoi che il tuo lavoro resti anonimo, per favore visita la
+ tua %{collection_items_link} per rimuoverlo da questa collezione.
+ text: 'Se non vuoi che il tuo lavoro sia anonimo, per favore visita la tua
+ pagina Approved Collection Items (Elementi approvati della collezione)
+ per rimuoverlo da questa collezione: %{collection_items_url}'
+ anonymous_unrevealed:
+ html: Se non vuoi che il tuo lavoro sia anonimo e nascosto, per favore visita
+ la tua %{collection_items_link} per rimuoverlo da questa collezione.
+ text: 'Se non vuoi che il tuo lavoro sia anonimo e nascosto, per favore
+ visita la tua pagina Approved Collection Items (Elementi approvati della
+ collezione) per rimuoverlo da questa collezione: %{collection_items_url}'
+ unrevealed:
+ html: Se non vuoi che il tuo lavoro sia nascosto, per favore visita la tua
+ %{collection_items_link} per rimuoverlo da questa collezione.
+ text: 'Se non vuoi che il tuo lavoro sia nascosto, per favore visita la
+ tua pagina Approved Collection Items (Elementi approvati della collezione)
+ per rimuoverlo da questa collezione: %{collection_items_url}'
+ faq_link_text: FAQ sulle Collezioni
+ more_info:
+ html: Per maggiori informazioni visita le nostre %{faq_link}.
+ text: 'Per maggiori informazioni, visita le nostre FAQ sulle Collezioni: %{faq_url}'
+ subject:
+ anonymous: "[%{app_name}] Il tuo lavoro è stato reso anonimo"
+ anonymous_unrevealed: "[%{app_name}] Il tuo lavoro è stato reso anonimo e
+ nascosto"
+ unrevealed: "[%{app_name}] Il tuo lavoro è stato reso nascosto"
+ unrevealed_info: I lavori nascosti non sono inclusi negli elenchi di tag o sulla
+ pagina dei tuoi lavori. Chiunque raggiunga il lavoro attraverso un link riceverà
+ una notifica che il lavoro è attualmente nascosto, e non saranno in grado
+ di accedere al suo contenuto.
+ archivist_added_to_collection_notification:
+ approved_collection_items_page: pagina Approved Collection Elements (Elementi
+ Approvati della Collezione)
+ archivist_notice: Poiché i gestori della collezione agiscono in veste ufficiale
+ come archivisti di Open Doors (Porte Aperte), possono aggiungere il tuo lavoro
+ a questa collezione, anche se hai disabilitato gli inviti alle collezioni.
+ Gli archivisti aggiungono un lavoro a una collezione solo nel caso in cui
+ fosse precedentemente ospitato in un archivio che è stato importato.
+ removal_instructions:
+ html: Se desideri rimuovere il tuo lavoro da questa collezione, per favore
+ visita la tua %{approved_items_link}.
+ text: 'Se desideri rimuovere il tuo lavoro dalla collezione, per favore visita
+ la tua pagina Approved Collection Items (Elementi Approvati della Collezione):
+ %{approved_items_url}.'
+ subject: "[%{app_name}][%{collection_title}] Un archivista di Open Doors (Porte
+ Aperte) ha aggiunto il tuo lavoro a una collezione"
+ work_added:
+ html: I gestori della collezione %{collection_link} hanno aggiunto il tuo
+ lavoro %{work_link} alla loro collezione!
+ text: I gestori della collezione "%{collection_title}" (%{collection_url})
+ hanno aggiunto il tuo lavoro "%{work_title}" (%{work_url}) alla loro collezione!
+ challenge_assignment_notification:
+ any: Qualsiasi
+ assignment:
+ html: Ti è stata assegnata la seguente richiesta nella challenge %{link} su
+ AO3!
+ description: 'Descrizione:'
+ due: 'La scadenza è:'
+ html:
+ footer: Ricevi questa email perché hai effettuato l'iscrizione alla challenge
+ %{title}. Per altre informazioni su questa challenge e per visualizzare
+ i contatti dei moderatori, ti preghiamo di visitare %{footer_link}.
+ footer_link: la pagina profilo della challenge
+ look_up: Puoi vedere cosa ti è stato assegnato aprendo %{link}.
+ look_up_link: la pagina Assignments (Incarichi)
+ optional_tags: 'Tag facoltative:'
+ prompts: 'Prompt:'
+ prompt_url: 'URL del prompt:'
+ recipient: 'Destinatario:'
+ recipient_missing: 'Nessuno: contatta un moderatore per ricevere assistenza!'
+ subject: "[%{app_name}][%{collection_title}] Il tuo incarico!"
+ text:
+ assignment: Ti è stata assegnata la seguente richiesta nella challenge "%{collection_title}"
+ (%{collection_url}) su AO3!
+ footer: Ricevi questa email perché hai effettuato l'iscrizione alla challenge
+ %{title} (%{url}). Per altre informazioni sulla challenge e per visualizzare
+ i contatti dei moderatori ti preghiamo di visitare il link %{profile_url}.
+ look_up: Puoi trovare questo incarico nella tua pagina Assignments (Incarichi)
+ al link %{link}.
+ change_email:
+ changed:
+ html: "%{login}, l'indirizzo email associato al tuo account è stato modificato
+ in %{email}"
+ text: "%{login}, l'indirizzo email associato al tuo account è stato modificato
+ in %{email}"
+ subject: "[%{app_name}] Modifica indirizzo email"
+ claim_notification:
+ access:
+ contact_support: contatta il Supporto di AO3.
+ html: In base all'archivio di provenienza i tuoi lavori potrebbero essere
+ stati importati in modo tale da essere visibili solo agli utenti registrati
+ (per escluderli dalle ricerche via Google). In tal caso i tuoi lavori saranno
+ disponibili soltanto agli utenti che abbiano effettuato l'accesso, a meno
+ che tu non decida di renderli visibili a tutti. Se ti serve aiuto per sbloccare,
+ rendere orfani o eliminare i tuoi lavori, per favore %{contact_support_link}.
+ text: In base all'archivio di provenienza i tuoi lavori potrebbero essere
+ stati importati in modo tale da essere visibili solo agli utenti registrati
+ (per escluderli dalle ricerche via Google). In tal caso i tuoi lavori saranno
+ disponibili soltanto agli utenti che abbiano effettuato l'accesso, a meno
+ che tu non decida di renderli visibili a tutti. Se ti serve aiuto a sbloccare,
+ rendere orfani o eliminare i tuoi lavori, per favore contatta il supporto
+ di AO3 (%{support_url}).
+ email_tips: Nel caso tu decida di contattarci, ricordati per favore di aggiungere
+ alla tua whitelist gli indirizzi email con dominio @transformativeworks.org
+ e controlla che la nostra risposta non sia finita nella cartella di spam.
+ introduction:
+ ao3_name: Archive of Our Own – AO3 (Archivio Tutto Per Noi)
+ html: Hai ricevuto questa email perché avevi lavori presenti in un archivio
+ di fanwork che è stato importato da %{open_doors_name_link} su %{app_link}.
+ Poiché questo indirizzo email è connesso a un account sull'archivio importato,
+ i fanwork a esso associati (di cui sotto) sono stati aggiunti automaticamente
+ al tuo account su AO3.
+ open_doors_name: Open Doors (Porte Aperte)
+ text: 'Hai ricevuto questa email perché avevi lavori presenti in un archivio
+ di fanwork che è stato importato da Open Doors (Porte Aperte) (%{open_doors_url})
+ su Archive of Our Own – AO3 (Archivio Tutto Per Noi): %{app_url}. Poiché
+ questo indirizzo email è connesso a un account sull''archivio importato,
+ i fanwork a esso associati (di cui sotto) sono stati aggiunti automaticamente
+ al tuo account su AO3.'
+ mistake:
+ contact_open_doors: Contatta Porte Aperte
+ html: Se si tratta di un errore e questi non sono i tuoi lavori, per favore
+ non eliminarli! %{contact_open_doors_link} e risolveremo il problema.
+ text: Se si tratta di un errore e questi non sono i tuoi lavori, per favore
+ non eliminarli! Contatta Porte Aperte (%{open_doors_url}) e risolveremo
+ il problema.
+ more_info:
+ ao3_news: AO3 News
+ contact_support: contatta il Supporto di AO3
+ faq_page: pagina delle FAQ
+ html: Puoi leggere gli annunci riguardanti i trasferimenti di archivi più
+ recenti su %{ao3_news_link} e troverai ulteriori informazioni sulla %{faq_page_link}
+ di Porte Aperte o sulla %{tutorial_page_link}. Se non trovi risposta alla
+ tua domanda nelle FAQ, nei tutorial o in questa email, per favore %{contact_support_link}.
+ text: Puoi leggere gli annunci riguardanti i trasferimenti di archivi più
+ recenti su AO3 News (%{news_url}) e troverai ulteriori informazioni sulla
+ pagine delle FAQ di Porte Aperte (%{open_doors_faq_url}) o sulla pagina
+ dei tutorial (%{open_doors_tutorial_url}). Se non trovi risposta alla tua
+ domanda nelle FAQ, nei tutorial o in questa email, per favore contatta il
+ Supporto di AO3 (%{support_url}).
+ tutorial_page: pagina dei tutorial
+ other_works:
+ contact_open_doors: contatta Porte Aperte
+ html: Se nell'archivio importato avevi altri lavori legati a un altro indirizzo
+ email a cui non hai più accesso, per favore %{contact_open_doors_link} fornendo
+ qualsiasi informazione che permetta di verificare la tua identità come autore
+ dei lavori in oggetto.
+ text: Se nell’archivio importato avevi altri lavori legati a un altro indirizzo
+ email a cui non hai più accesso, per favore contatta Porte Aperte fornendo
+ qualsiasi informazione che permetta di verificare la tua identità come autore
+ dei lavori in oggetto.
+ questions:
+ contact_support: contatta il Supporto di AO3
+ html: Se hai ulteriori domande, per favore %{contact_support_link}.
+ text: Se hai ulteriori domande, per favore contatta il Supporto di AO3 (%{support_url}).
+ redirects:
+ html: Per conservare le liste di fanfic consigliate e i preferiti gli URL
+ dell'archivio importato potrebbero reindirizzare alla copia importata dei
+ lavori per un periodo di tempo limitato (controlla il post riguardante il
+ trasferimento del tuo archivio per averne certezza). Se hai già caricato
+ una copia di un lavoro e %{negation} hai usato la funzione "import from
+ URL" (importa da URL), su AO3 saranno presenti due copie dello stesso lavoro.
+ subject: "[%{app_name}] Lavori caricati"
+ update_redirect:
+ contact_open_doors: contatta Porte Aperte
+ html: Se desideri che Porte Aperte aggiorni il reindirizzamento perché si
+ riferisca a un tuo lavoro preesistente, per favore elimina la copia importata
+ e %{contact_open_doors_link} con il nome del tuo account di AO3, il nome
+ del tuo account sull'archivio importato, il titolo e URL del fanwork a cui
+ vorresti far riferire il reindirizzamento. (Se si tratta di più di un lavoro,
+ puoi elencarli tutti in una singola email).
+ text: Se desideri che Porte Aperte aggiorni il reindirizzamento perché si
+ riferisca a un tuo lavoro preesistente, per favore elimina la copia importata
+ e contatta Porte Aperte (%{open_doors_url}) con il nome del tuo account
+ di AO3, il nome del tuo account sull'archivio importato, il titolo e URL
+ del fanwork a cui vorresti far riferire il reindirizzamento. (Se si tratta
+ di più di un lavoro, puoi elencarli tutti in una singola email).
+ works_by: 'I seguenti lavori sono legati a questo indirizzo email: %{email}'
+ work_info:
+ html: "%{work_link} (%{fandom})"
+ text:
+ no_fandom: "- %{work_title} %{work_url}"
+ with_fandom: "- %{work_title} %{work_url} (%{fandom})"
+ collection_notification:
+ assignments_sent:
+ complete: Tutti gli incarichi sono stati inviati.
+ subject: Incarichi inviati
+ html:
+ received_message: 'Hai ricevuto un messaggio riguardo la tua collezione %{collection_link}:'
+ text:
+ received_message: 'Hai ricevuto un messaggio riguardo la tua collezione "%{collection_title}"
+ (%{collection_url}):'
+ creatorship_notification:
+ explanation: Quando sei coautore di un lavoro, puoi essere aggiunt@ a nuovi
+ capitoli indipendentemente dalle tue impostazioni riguardanti le creazioni
+ congiunte. Sarai altresì aggiunt@ a qualsiasi serie cui venga aggiunto il
+ lavoro in questione.
+ html:
+ creation: "%{creation_link} di %{pseud_links}"
+ edit_chapter: modificare il capitolo
+ edit_series: modificare la serie
+ remove_chapter: Se pensi ci sia un errore o se non desideri essere elencat@
+ come autore, puoi %{edit_chapter_link} per rimuovere il tuo pseudonimo come
+ autore.
+ remove_series: Se pensi ci sia un errore o se non desideri apparire come autore,
+ puoi %{edit_series_link} per rimuovere il tuo pseudonimo dall'elenco degli
+ autori.
+ intro_chapter: 'L’utente %{adding_user} ha indicato il tuo pseudonimo %{pseud}
+ come coautore del seguente capitolo:'
+ intro_series: 'L’utente %{adding_user} ha indicato il tuo pseudonimo %{pseud}
+ come coautore della seguente serie:'
+ subject: "[%{app_name}] Notifica di coautore"
+ text:
+ creation: "%{title} (%{url}) di %{pseuds}"
+ remove_chapter: 'Se il tuo pseudonimo è stato aggiunto per errore o se non
+ desideri apparire come autore, puoi modificare il capitolo e rimuovere il
+ tuo pseudonimo dall''elenco degli autori: %{url}'
+ remove_series: 'Se pensi ci sia un errore o se non desideri apparire come
+ autore, puoi modificare la serie per rimuovere il tuo pseudonimo dall''elenco
+ degli autori: %{url}'
+ creatorship_notification_archivist:
+ explanation: Poiché questo utente agisce nel suo ruolo ufficiale di archivista
+ di Open Doors (Porte Aperte), ha l'autorizzazione ad aggiungerti senza una
+ richiesta, anche se hai la cocreazione disabilitata.
+ html:
+ creation: "%{creation_link} di %{pseud_links}"
+ edit_chapter: modificare il capitolo
+ edit_series: modificare la serie
+ edit_work: modificare il lavoro
+ remove_chapter: Se ritieni che questa modifica sia avvenuta per errore e non
+ desideri che il tuo pseudonimo venga indicato come autore, puoi %{edit_chapter_link}
+ per rimuoverti dalla lista degli autori.
+ remove_series: Se ritieni che questa modifica sia avvenuta per errore e non
+ desideri che il tuo pseudonimo venga indicato come autore, puoi %{edit_series_link}
+ per rimuoverti dalla lista degli autori.
+ remove_work: Se ritieni che questa modifica sia avvenuta per errore e non
+ desideri che il tuo pseudonimo venga indicato come autore, puoi %{edit_work_link}
+ per rimuoverti dalla lista degli autori.
+ intro_chapter: 'L''utente %{archivist} ha aggiunto il tuo pseudonimo %{pseud}
+ come coautore del seguente capitolo:'
+ intro_series: 'L''utente %{archivist} ha aggiunto il tuo pseudonimo %{pseud}
+ come coautore della seguente serie:'
+ intro_work: 'L''utente %{archivist} ha aggiunto il tuo pseudonimo %{pseud} come
+ coautore del seguente lavoro:'
+ subject: "[%{app_name}] Notifica di coautore da parte di un archivista"
+ text:
+ creation: "%{title} (%{url}) di %{pseuds}"
+ remove_chapter: 'Se ritieni che questa modifica sia avvenuta per errore e
+ non desideri che il tuo pseudonimo venga indicato come autore, puoi modificare
+ il capitolo e rimuoverti dalla lista degli autori: %{url}'
+ remove_series: 'Se ritieni che questa modifica sia avvenuta per errore e non
+ desideri che il tuo pseudonimo venga indicato come autore, puoi modificare
+ la serie e rimuoverti dalla lista degli autori: %{url}'
+ remove_work: 'Se ritieni che questa modifica sia avvenuta per errore e non
+ desideri che il tuo pseudonimo venga indicato come autore, puoi modificare
+ il lavoro e rimuoverti dalla lista degli autori: %{url}'
+ creatorship_request:
+ html:
+ creation: "%{creation_link} di %{pseud_links}"
+ instructions: Puoi accettare o rifiutare questa richiesta sulla tua pagina
+ %{page_name}.
+ page_name: Co-Creator Requests (Richieste di coautore)
+ intro_chapter: 'L''utente %{inviting_user} ha invitato il tuo pseudonimo %{pseud}
+ ad essere elencato come coautore del seguente capitolo:'
+ intro_series: 'L''utente %{inviting_user} ha invitato il tuo pseudonimo %{pseud}
+ ad essere elencato come coautore della seguente serie:'
+ intro_work: 'L''utente %{inviting_user} ha invitato il tuo pseudonimo %{pseud}
+ ad essere elencato come coautore del seguente lavoro:'
+ subject: "[%{app_name}] Richiesta di coautore"
+ text:
+ creation: "%{title} (%{url}) di %{pseuds}"
+ instructions: 'Puoi accettare o rifiutare questa richiesta sulla tua pagina
+ Co-Creator Requests (Richieste di coautore): %{url}'
+ delete_work_notification:
+ attachment: In allegato trovi una copia del tuo lavoro da poter consultare.
+ deleted_other:
+ html: Il tuo lavoro %{title} è stato cancellato come richiesto da %{pseud}.
+ text: Il tuo lavoro "%{title}" è stato cancellato come richiesto da %{pseud}.
+ deleted_yourself:
+ html: Il tuo lavoro %{title} è stato cancellato come da tua richiesta.
+ text: Il tuo lavoro "%{title}" è stato cancellato come da tua richiesta.
+ questions:
+ html: Se dovessi avere dei dubbi, per favore %{support}.
+ text: Se dovessi avere dei dubbi, per favore %{support} (%{url}).
+ subject: "[%{app_name}] Il tuo lavoro è stato cancellato"
+ support: contatta il Supporto
+ invitation:
+ been_invited: Hai ricevuto un invito ad unirti alla nostra versione beta!
+ has_invited: "%{user_name} ti ha chiesto di unirti alla nostra versione beta!"
+ html:
+ faq_link_text: le nostre FAQ
+ subject: "[%{app_name}] Invito"
+ invitation_to_claim:
+ access:
+ html: A seconda dell’archivio, i tuoi lavori potrebbero essere stati importati
+ in modo da essere visibili solo agli utenti registrati (in modo da escluderli
+ dalle ricerche via Google). In tal caso, i lavori saranno accessibili solo
+ da utenti che abbiano effettuato l'accesso, a meno che tu non scelga di
+ renderli visibili a tutti. Se ti serve aiuto su come sbloccare, rendere
+ orfani o cancellare i tuoi lavori, per favore %{contact_support_link}.
+ text: A seconda dell’archivio, i tuoi lavori potrebbero essere stati importati
+ in modo da essere solo visibili agli utenti registrati (ed esclusi dalle
+ ricerche via Google). In tal caso, i lavori saranno accessibili solo da
+ utenti che abbiano effettuato l’accesso, a meno che tu non scelga di renderli
+ visibili a tutti. Se ti serve aiuto su come sbloccare, rendere orfani o
+ cancellare i tuoi lavori, per favore contatta il Supporto di AO3.
+ claim_or_remove:
+ html: Qui puoi rivendicare o cancellare i tuoi lavori.
+ text: 'Qui puoi rivendicare o cancellare i tuoi lavori: %{claim_url}'
+ email_tips: Nel caso tu decida di contattarci, ricordati per favore di aggiungere
+ alla tua whitelist gli indirizzi email provenienti da @transformativeworks.org
+ e controlla che la nostra risposta non sia finita nella cartella di spam.
+ html:
+ ao3_news: AO3 News
+ contact_open_doors: contatta Porte Aperte
+ contact_support: contatta il Supporto di AO3
+ faq_page: pagina delle FAQ
+ tutorial_page: guida
+ introduction:
+ text: Stai ricevendo questa email perché un archivio è stato di recente importato
+ da Open Doors (Porte Aperte) (%{open_doors_link}) in %{app_name} (%{app_short_name}
+ - %{app_url}), e crediamo che tu sia l’autore dei seguenti lavori. Vorremmo
+ darti la possibilità di rivendicarli (o cancellarli/renderli orfani). E,
+ se non hai già un account con un altro indirizzo email, ci piacerebbe invitarti!
+ mistake:
+ html: Se si tratta di un errore e questi non sono i tuoi lavori, per favore
+ non cancellarli! Semplicemente %{contact_open_doors_link} e risolveremo
+ il problema.
+ text: Se si tratta di un errore e questi non sono i tuoi lavori, per favore
+ non cancellarli! Semplicemente contatta Porte Aperte (%{open_doors_link})
+ e risolveremo il problema.
+ other_works:
+ html: Se hai altri lavori nell’archivio importato sotto un diverso indirizzo
+ email a cui non hai più accesso, %{contact_open_doors_link} con qualunque
+ informazione che possa aiutare a verificare la tua identità di autore di
+ tali lavori.
+ text: Se hai altri lavori nell’archivio importato collegati a un diverso indirizzo
+ email a cui non hai più accesso, contatta Porte Aperte con qualunque informazione
+ che possa aiutare a verificare la tua identità di autore di tali lavori.
+ questions:
+ html: Per altre domande, per favore %{contact_support_link}.
+ redirects: Per mantenere intatte le rec list e i preferiti, l’indirizzo web
+ dell’archivio importato potrebbe - per un breve periodo - reindirizzare alle
+ copie importate di questi lavori (per essere sicur@, controlla l’annuncio
+ relativo al tuo archivio). Se hai già pubblicato una copia di questi lavori
+ e NON hai usato la funzione per importare automaticamente dall’URL, ci saranno
+ due copie dello stesso lavoro nell’archivio.
+ subject: "[%{app_name}] Invito a rivendicare i tuoi lavori"
+ unwanted:
+ html: 'Se sei l’autore di questi lavori, ma non li vuoi più, li puoi rendere
+ orfani (così che rimangano su AO3 ma senza il tuo nome) o puoi cancellarli
+ (verranno in tal caso completamente rimossi da AO3). Non c’è bisogno di
+ aggiungerli a un account per renderli orfani o cancellarli: puoi farlo direttamente
+ dal link qui sopra. (Se hai bisogno di aiuto, per favore %{contact_support_link}.)'
+ update_redirect:
+ html: Se desideri che Porte Aperte aggiorni il reindirizzamento per puntare
+ al tuo lavoro pre-esistente, cancella la copia importata e %{contact_open_doors_link}
+ specificando il nome del tuo profilo su AO3, quello sull’archivio importato
+ e il titolo e URL del lavoro al quale vuoi che punti il reindirizzamento.
+ (Se vuoi cambiare il reindirizzamento di più lavori, puoi elencarli tutti
+ in una singola email.)
+ text: Se desideri che Porte Aperte aggiorni il reindirizzamento per puntare
+ al tuo lavoro pre-esistente, cancella la copia importata e contatta Porte
+ Aperte (%{open_doors_link}) specificando il nome del tuo profilo su AO3,
+ quello sull’archivio importato e titolo e URL del lavoro al quale vuoi che
+ punti il reindirizzamento. (Se vuoi cambiare il reindirizzamento di più
+ lavori, puoi elencarli tutti in una singola email.)
+ uploaded_list: 'I lavori pubblicati comprendono:'
+ invite_increase_notification:
+ html:
+ body:
+ one: Volevamo farti sapere che hai %{count} nuovo invito che può essere
+ usato per creare un nuovo account su AO3. Puoi invitare un amico recandoti
+ %{invitation_page_link}.
+ other: Volevamo farti sapere che hai %{count} nuovi inviti che possono essere
+ usati per creare nuovi account su AO3. Puoi invitare i tuoi amici recandoti
+ %{invitation_page_link}.
+ invitation_page_link_text: sulla tua pagina degli inviti
+ subject: "[%{app_name}] Nuovi inviti"
+ text:
+ body:
+ one: Volevamo farti sapere che hai %{count} nuovo invito che può essere
+ usato per creare un nuovo account su AO3. Puoi invitare un amico recandoti
+ alla tua pagina Invitations (inviti) (%{invitation_page_url}).
+ other: Volevamo farti sapere che hai %{count} nuovi inviti che possono essere
+ usati per creare nuovi account su AO3. Puoi invitare i tuoi amici recandoti
+ alla tua pagina Invitations (inviti) (%{invitation_page_url}).
+ invite_request_declined:
+ main:
+ one: Ci dispiace doverti informare che al momento la tua richiesta per un
+ ulteriore codice invito non può essere accettata.
+ other: Ci dispiace doverti informare che al momento la tua richiesta per %{count}
+ nuovi inviti non può essere accettata.
+ reason: 'La tua richiesta è stata:'
+ subject: "[%{app_name}] Richiesta per un Codice Invito aggiuntivo rifiutata"
+ recipient_notification:
+ html:
+ collection: All'interno della collezione %{collection_link} su AO3 è stato
+ pubblicato un fanwork regalo per te!
+ no_collection: Su AO3 è stato pubblicato un fanwork regalo per te!
+ subject:
+ collection: "[%{app_name}][%{collection_title}] Un fanwork regalo per te su
+ %{collection_title}"
+ no_collection: "[%{app_name}] Hai ricevuto un fanwork regalo"
+ text:
+ collection: All'interno della collezione "%{collection_title}" (%{collection_url})
+ su AO3 è stato pubblicato un fanwork regalo per te!
+ signup_notification:
+ activate:
+ html: Per favore %{activate_account_link}.
+ text: 'Per favore segui questo link per attivare il tuo account: %{activate_account_url}'
+ activate_your_account: segui questo link per attivare il tuo account
+ admin_posts: News di AO3
+ bye: Speriamo ti divertirai a usare AO3.
+ contact_support: contatta il Supporto
+ faq: FAQ
+ features:
+ html: Non appena il tuo account sarà attivo e funzionante, potrai postare
+ i tuoi fanwork, impostare le iscrizioni via email che ti permetteranno di
+ sapere quando i tuoi autori preferiti pubblicano del materiale o quando
+ i tuoi lavori preferiti vengono aggiornati, scegliere come preferisci che
+ il sito ti appaia e funzioni, tenere traccia dei fanwork che hai visualizzato
+ su AO3 attraverso la tua cronologia e molto altro ancora.
+ text: Non appena il tuo account sarà attivo e funzionante, potrai postare
+ i tuoi fanwork, impostare le iscrizioni via email che ti permetteranno di
+ sapere quando i tuoi autori preferiti pubblicano del materiale o quando
+ i tuoi lavori preferiti vengono aggiornati, scegliere come preferisci che
+ il sito ti appaia e funzioni, tenere traccia dei fanwork che hai visualizzato
+ su AO3 attraverso la tua cronologia, e molto altro ancora.
+ information:
+ html: Ci sono molte informazioni e suggerimenti su come usare AO3 nelle nostre
+ %{faq_link}. Troverai le ultime notizie riguardo agli sviluppi del sito
+ nelle nostre %{admin_posts_link}. Se hai bisogno di aiuto, incontri un bug,
+ o hai altre domande o commenti, per favore %{contact_support_link}, che
+ sarà sempre felice di aiutarti.
+ text: 'Ci sono molte informazioni e suggerimenti su come usare AO3 nelle nostre
+ FAQ su %{faq_url}. Troverai le ultime notizie riguardo agli sviluppi del
+ sito su News di AO3 su %{admin_posts_url}. Se hai bisogno di aiuto, incontri
+ un bug, o hai altre domande o commenti, per favore mettiti in contatto con
+ il team di Supporto, che sarà sempre felice di aiutarti: %{contact_support_url}.'
+ subject: "[%{app_name}] Attiva il tuo account"
+ welcome: Benvenut@ su AO3, %{login}!
diff --git a/config/locales/phrase-exports/ja.yml b/config/locales/phrase-exports/ja.yml
new file mode 100755
index 0000000..18071ae
--- /dev/null
+++ b/config/locales/phrase-exports/ja.yml
@@ -0,0 +1,404 @@
+---
+ja:
+ activerecord:
+ attributes:
+ archive_warning:
+ name_with_colon:
+ other: 警告:
+ category:
+ name_with_colon:
+ other: カテゴリー:
+ character:
+ name_with_colon:
+ other: キャラクター:
+ fandom:
+ name_with_colon:
+ other: ジャンル:
+ freeform:
+ name_with_colon:
+ other: その他のタグ:
+ rating:
+ name_with_colon: レイティング:
+ relationship:
+ name_with_colon:
+ other: カップリング:
+ work:
+ chapter_total_display: 章
+ summary: あらすじ
+ models:
+ archive_warning:
+ other: 警告
+ category:
+ other: カテゴリー
+ chapter:
+ other: 章
+ character:
+ other: キャラクター
+ fandom:
+ other: ジャンル
+ freeform:
+ other: その他のタグ
+ rating:
+ other: レイティング
+ relationship:
+ other: カップリング
+ series:
+ other: シリーズ
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ other: ゲスト%{count}名
+ left_kudos:
+ html:
+ other: "%{givers_list}があなたの作品 %{commentable_link}にいいねを送りました。"
+ text:
+ other: "%{givers_list} があなたの作品 %{commentable_title} (%{commentable_url})にいいねを送りました。"
+ single_guest:
+ giver: ゲスト1名
+ html: "%{giver}があなたの作品 %{commentable_link}にいいねを送りました。"
+ text: ゲスト1名があなたの作品 %{commentable_title} (%{commentable_url})にいいねを送りました。
+ subject: "[%{app_name}] あなたにいいねが届きました!"
+ mailer:
+ general:
+ closing:
+ formal: 敬具、
+ informal: 以上、
+ creation:
+ link_with_word_count: "%{creation_link} (%{word_count})"
+ title_with_chapter_number: "『 %{title}』の %{position} 章"
+ title_with_word_count: "『%{creation_title}』(%{word_count})"
+ word_count:
+ other: "%{count} 語"
+ footer:
+ general:
+ about:
+ html: みんなのアーカイブ(AO3)はファンによって運営され、ファンに支えられているアーカイブです。AO3は%{donate_link}に頼っています。
+ text: みんなのアーカイブ(AO3)はファンによって運営され、ファンに支えられているアーカイブです。AO3は皆様からのご寄付に頼っています:%{donate_url}.
+ html:
+ donate_link_text: 皆様からのご寄付
+ support_link_text: ユーザーサポートまでご連絡ください
+ unwanted_email:
+ html: エラーにてこのメッセージを受けた場合は%{support_link}。
+ text: 間違ってこのメッセージを受けた場合はユーザーサポート(%{support_url})までご連絡ください。
+ sent_at: "%{sent_at}に送信されました。"
+ greeting:
+ formal_html: "%{name}さん、"
+ informal:
+ addressed_html: こんにちは、%{name}さん!
+ unaddressed: こんにちは!
+ introductory: みんなのアーカイブ(AO3)から、こんにちは!
+ metadata_label_indicator: ":"
+ signature:
+ abuse_team: 迷惑・不正行為対策チーム
+ app_short_name: AO3
+ open_doors: The Open Doors (オープンドア) チーム
+ parent_org: Organization for Transformative Works ー OTW(変形的作品のためのNPO)
+ support: AO3ユーザーサポートチーム
+ users:
+ mailer:
+ reset_password_instructions:
+ expiration: 1週間以内にこのリンクからパスワード再設定をしない場合、リンクの有効期限が切れるため、新たなリンクをリクエストする必要があります。
+ intro: あなたのアカウントのパスワード再設定がリクエストされました。下記のリンクから新しいパスワードを入力することで、アカウントのパスワードを変更できます。
+ link_title: パスワードを変更する
+ subject: "[%{app_name}] パスワードの再設定"
+ unrequested: もしあなたがパスワード再設定をリクエストしなかった場合は、このメールを無視しても、元のパスワードを引き続きお使い頂けます。
+ user_mailer:
+ admin_deleted_work_notification:
+ bye: ご参考のために、あなたの作品のコピーを添付しました。
+ contact_abuse: 迷惑・不正行為対策委員会にご連絡ください
+ deleted:
+ html: あなたの作品 %{title} はサイト管理者によってAO3から削除されました。
+ text: あなたの作品「%{title}」はサイト管理者によってAO3から削除されました。
+ html:
+ tos_violation: あなたの作品がAO3のサービス利用規約を違反している可能性がある場合は、%{contact_abuse_link}。
+ import_project:
+ html: あなたの作品がOpen Doors(オープンドア)チームによって管理されているインポート企画の一部であった場合は、%{opendoors_link}。
+ text: あなたの作品がOpen Doors(オープンドア)チームによって管理されているインポート企画の一部であった場合は、オープンドアプロジェクト(%{opendoors_link})にご連絡ください。
+ opendoors: オープンドアプロジェクトにご連絡ください
+ subject: "[%{app_name}] あなたの作品は管理者によって削除されました"
+ text:
+ tos_violation: あなたの作品がAO3のサービス利用規約を違反している可能性がある場合は、迷惑・不正行為対策委員会(%{contact_abuse_url})にご連絡ください。
+ admin_hidden_work_notification:
+ access: 作品が非表示されている間も、上記のリンクから作品にアクセスできますが、あなたの投稿作品ページには掲載されず、他のAO3のユーザーが閲覧することはできません。
+ check_email: すでに迷惑・不正行為対策委員会からあなたの作品が非表示となった理由を連絡している可能性がありますので、迷惑メールフォルダも含めてメールをご確認ください。
+ contact_abuse: 迷惑・不正行為対策委員会をご連絡ください
+ html:
+ help: もし作品が非表示となった理由が確認できない、そしてこの件に関する連絡を受けていない場合は、直接に%{contact_abuse_link}。
+ hidden: あなたの作品%{title}は迷惑・不正行為対策委員会によって隠され、非表示状態になっております。
+ tos_violation: あなたの作品がAO3の%{tos_link}に違反しているために非表示となった場合、違反を修正するための措置が求められます。利用規約に従わない場合、あなたの作品はAO3から削除されることがあります。
+ subject: "[%{app_name}] あなたの作品は迷惑・不正行為対策委員会によって非表示とされました。"
+ text:
+ help: もし作品が非表示となった理由が確認できない、そしてこの件に関する連絡を受けていない場合は、直接に迷惑・不正行為対策委員会へご連絡ください:%{contact_abuse_url}。
+ hidden: あなたの作品「%{title}」(%{work_url})は迷惑・不正行為対策委員会によって隠され、非表示状態になっております。
+ tos_violation: あなたの作品がAO3の( %{tos_url})に違反しているために非表示となった場合、違反を修正するための措置が求められます。利用規約に従わない場合、あなたの作品はAO3から削除されることがあります。
+ tos: サービス利用規約
+ anonymous_or_unrevealed_notification:
+ anonymous_info: 匿名化された作品はタグリストに含まれますが、あなたの投稿作品ページには表示されません。該当作品の作者欄にはあなたのユーザー名の代わりに「Anonymous」(匿名)と表示されます。
+ anonymous_unrevealed_info: コレクション管理者は、後であなたの作品を公開するかもしれませんが、匿名化はされません。この変更は、あなたのフォロワーには通知されません。あなたの作品は公開した後にタグリストに含まれますが、あなたの投稿作品ページには表示されません。該当作品の作者欄にはあなたのユーザー名の代わりに「Anonymous」(匿名)と表示されます。
+ changed_status:
+ anonymous:
+ html: "%{collection_link}のコレクション管理者はあなたの作品「%{work_link}」を匿名化しました。"
+ text: '"%{collection_title}" (%{collection_url}) のコレクション管理者は、あなたの作品 "%{work_title}"
+ (%{work_url}) のステータスを匿名に変更しました。'
+ anonymous_unrevealed:
+ html: "%{collection_link}のコレクション管理者はあなたの作品「%{work_link}」の状況を匿名・未公開に変更しました。"
+ text: '"%{collection_title}" (%{collection_url}) のコレクション管理者は、あなたの作品 "%{work_title}"
+ (%{work_url}) のステータスを匿名・未公開に変更しました。'
+ unrevealed:
+ html: "%{collection_link}のコレクション管理者はあなたの作品「%{work_link}」の状況を未公開に変更しました。"
+ text: '"%{collection_title}" (%{collection_url}) のコレクション管理者は、あなたの作品 "%{work_title}"
+ (%{work_url}) のステータスを未公開に変更しました。'
+ collection_items_link_text: Approved Collection Items(承認済みのコレクション作品)のページ
+ do_not_want:
+ anonymous:
+ html: 作品を匿名化したくない場合は、あなたの%{collection_items_link}ページで作品をコレクションから削除してください。
+ text: 作品を匿名化したくない場合は、あなたのApproved Collection Items(承認済みのコレクション作品)ページで作品をコレクションから削除してください:%{collection_items_url}
+ anonymous_unrevealed:
+ html: 作品の状況を匿名・未公開に変更したくない場合は、あなたの%{collection_items_link}ページで作品をコレクションから削除してください。
+ text: 作品の状況を匿名・未公開に変更したくない場合は、あなたのApproved Collection Items(承認済みのコレクション作品)ページで作品をコレクションから削除してください:%{collection_items_url}
+ unrevealed:
+ html: 作品の状況を未公開に変更したくない場合は、あなたの%{collection_items_link}ページで作品をコレクションから削除してください。
+ text: 作品の状況を未公開に変更したくない場合は、あなたのApproved Collection Items(承認済みのコレクション作品)ページで作品をコレクションから削除してください:%{collection_items_url}
+ faq_link_text: コレクションFAQ
+ more_info:
+ html: 詳しくは、%{faq_link}をご覧ください。
+ text: 詳しくは、コレクションFAQをご覧ください:%{faq_url}
+ subject:
+ anonymous: "[%{app_name}] あなたの作品は匿名化されました"
+ anonymous_unrevealed: "[%{app_name}] あなたの作品は匿名・未公開化されました"
+ unrevealed: "[%{app_name}] あなたの作品は未公開化されました"
+ unrevealed_info: 未公開作品はタグリストにも、あなたの投稿作品ページにも表示されません。リンクで作品にアクセスしたいユーザーには、作品は未公開とのお知らせが表示され、作品にアクセスできません。
+ archivist_added_to_collection_notification:
+ approved_collection_items_page: Approved Collection Items (承認済みコレクションアイテム) ページ
+ archivist_notice: コレクションメンテナーはOpen Doors(オープンドアプロジェクト)の公式管理人として行動しているため、あなたがコレクションへの招待を無効にした場合でも、作品をコレクションに追加することができます。ただし管理人はインポートされたアーカイブでホストされている作品のみ、コレクションに追加します。
+ removal_instructions:
+ html: このコレクションから作品を削除したい場合は、%{approved_items_link}をご覧ください。
+ text: このコレクションから作品を削除したい場合は、Approved Collection Items(承認済みコレクションアイテム)ページをご覧ください:
+ %{approved_items_url}。
+ subject: "[%{app_name}][%{collection_title}] Open Doors(オープンドアプロジェクト)の管理人が、あなたの作品をコレクションに追加しました"
+ work_added:
+ html: "%{collection_link}のコレクションメンテナーは、%{work_link}をコレクションに追加しました!"
+ text: コレクション「"%{collection_title}」 (%{collection_url})のコレクションメンテナーは、「"%{work_title}」
+ (%{work_url})をコレクションに追加しました!
+ challenge_assignment_notification:
+ any: 何でも
+ assignment:
+ html: Archive of Our Own – AO3(みんなのアーカイブ)の %{link}チャレンジで、あなたは次のリクエストに割り当てられました。
+ description: 概要:
+ due: 課題の締め切り:
+ html:
+ footer: あなたは%{title}チャレンジに登録したため、このメールを受信しています。このチャレンジの詳細と課題管理者の連絡先情報については、%{footer_link}にてご確認ください。
+ footer_link: チャレンジのプロフィールページ
+ look_up: この課題は%{link}からご確認いただけます。
+ look_up_link: あなたのAssignments(課題)ページ
+ optional_tags: タグ(必須ではありません):
+ prompts: お題:
+ prompt_url: お題URL:
+ recipient: 受信者:
+ recipient_missing: 受信者なし:課題管理者にお問い合わせください!
+ subject: "[%{app_name}][%{collection_title}]あなたへの課題です!"
+ text:
+ assignment: Archive of Our Own – AO3(みんなのアーカイブ)の %{collection_title} チャレンジ(%{collection_url})
+ で、次のリクエストがあなたの課題として割り当てられました。
+ footer: あなたは%{title}チャレンジ(%{url})に登録したため、このメールを受信しています。このチャレンジの詳細と課題管理者の連絡先情報については、%{profile_url}にてご確認ください。
+ look_up: この課題については、Assignments(課題)のページ %{link}からご確認いただけます。
+ change_email:
+ changed:
+ html: "%{login}さん、あなたのアカウントのメールアドレスは%{email}に変更されました。"
+ text: "%{login}さん、あなたのアカウントのメールアドレスは%{email}に変更されました。"
+ subject: "[%{app_name}] メールアドレス変更のお知らせ"
+ claim_notification:
+ access:
+ contact_support: AO3サポートまでお問い合わせ
+ html: アーカイブによっては、あなたの作品がGoogle検索から除外され、AO3の登録ユーザーのみに公開されます。あなたが作品の公開範囲を変更しない限り、あなたの作品にはログインしたユーザーのみがアクセスできます。作品の公開範囲変更・所有権放棄・削除については、%{contact_support_link}ください。
+ text: アーカイブによっては、あなたの作品がGoogle検索から除外され、AO3の登録ユーザーのみに公開されます。あなたが作品の公開範囲を変更しない限り、あなたの作品にはログインしたユーザーのみがアクセスできます。作品の公開範囲変更・所有権放棄・削除については、以下のリンクでAO3サポートまでご連絡ください:%{support_url}。
+ email_tips: お問い合わせの際は、@transformativeworks.orgを安全な連絡先リストに追加し、迷惑メールフォルダをチェックして返信をお待ちください。
+ introduction:
+ ao3_name: Archive of Our Own – AO3(みんなのアーカイブ)
+ html: あなたの作品は%{open_doors_name_link}により、別のファン作品アーカイブから%{app_link}にインポートされました。このメールアドレスはインポートされたアーカイブに登録されているため、あなたのアカウントに関連する作品は自動的にあなたのAO3アカウントに追加されました。(作品について詳しくは、以下のリストを参照してください。)
+ open_doors_name: Open Doors(オープンドアプロジェクト)
+ text: あなたの作品はOpen Doors(オープンドアプロジェクト)(%{open_doors_url})により、別のファン作品アーカイブからArchive
+ of Our Own – AO3(みんなのアーカイブ)(%{app_url})にインポートされました。このメールアドレスはインポートされたアーカイブに登録されているため、あなたのアカウントに関連する作品は自動的にあなたのAO3アカウントに追加されました。(作品について詳しくは、以下のリストを参照してください。)
+ mistake:
+ contact_open_doors: Open Doorsに連絡して
+ html: もしこれが間違いであり、追加されたのがあなたの作品でない場合、どうか作品を削除しないでください! %{contact_open_doors_link}いただければ、私たちが対処いたします。
+ text: もしこれが間違いであり、追加されたのがあなたの作品でない場合、どうか作品を削除しないでください!Open Doors (%{open_doors_url})に連絡していただければ、私たちが対処いたします。
+ more_info:
+ ao3_news: AO3ニュース
+ contact_support: AO3サポートまでご連絡
+ faq_page: よくある質問ページ
+ html: アーカイブのインポートに関するニュース記事やお知らせは%{ao3_news_link}で読むことができます。Open Doors(オープンドアプロジェクト)についての情報は%{faq_page_link}や
+ %{tutorial_page_link}で掲載されています。これらをご覧いただき、まだ質問がある場合は、%{contact_support_link}ください。
+ text: アーカイブのインポートに関するニュース記事やお知らせはAO3ニュース(%{news_url})で読むことができます。Open Doors
+ (オープンドアプロジェクト) についての情報はよくある質問ページ(%{open_doors_faq_url})やチュートリアルページ (%{open_doors_tutorial_url})で掲載されています。これらをご覧いただき、まだ質問がある場合は、以下のリンクでAO3サポートまでご連絡ください:%{support_url}。
+ tutorial_page: チュートリアルページ
+ other_works:
+ contact_open_doors: Open Doorsまでご連絡
+ html: インポートされたアーカイブに、アクセスできなくなったメールアドレスで他の作品を投稿した場合は、本人確認に役立つ情報を%{contact_open_doors_link}ください。
+ text: インポートされたアーカイブに、アクセスできなくなったメールアドレスで他の作品を投稿した場合は、本人確認に役立つ情報をOpen Doorsまでご連絡ください。
+ questions:
+ contact_support: AO3サポートまでご連絡
+ html: その他のお問い合わせは、%{contact_support_link}ください。
+ text: その他のお問い合わせは、以下のリンクでAO3サポートまでご連絡ください:%{support_url}。
+ redirects:
+ html: おすすめリストやブックマークを保存するため、インポートされたアーカイブの作品ページは、短期間でAO3のコピーまでリダイレクトされる可能性があります(詳細はニュース記事をご確認ください)。もし、あなたはすでに自分の作品のコピーをURLインポート機能を%{negation}にAO3に投稿した場合、同じ作品が2つあることになります。
+ subject: "[%{app_name}] アップロードされた作品について"
+ update_redirect:
+ contact_open_doors: Open Doorsまでご連絡
+ html: リダイレクト先を既存の作品ページにしたい場合、まずはインポートされたコピーを削除し、そして%{contact_open_doors_link}ください。連絡する際に、AO3と元のアーカイブ両方のアカウント名、作品タイトルとリダイレクトしたいURLを明記してください。(複数作品のリダイレクトを変更したい場合は、1通のメールにまとめてもいいです)。
+ text: リダイレクト先を既存の作品ページにしたい場合、先ずはインポートされたコピーを削除し、そして以下のリンクでOpen Doorsまでご連絡ください:%{open_doors_url}。連絡する際に、AO3と元のアーカイブ両方のアカウント名、作品タイトルとリダイレクトしたいURLを明記してください。(複数作品のリダイレクトを変更したい場合は、1通のメールにまとめてもいいです。)
+ works_by: 下記の作品はこのメールアドレスで書かれました:%{email}
+ work_info:
+ html: "%{work_link} (%{fandom})"
+ text:
+ no_fandom: "- %{work_title} %{work_url}"
+ with_fandom: "- %{work_title} %{work_url} (%{fandom})"
+ collection_notification:
+ assignments_sent:
+ complete: すべての課題が割り当てられ、各参加者に送信されました。
+ subject: 課題が送信されました
+ html:
+ received_message: あなたのコレクション %{collection_link} に関するメッセージが届いています。
+ text:
+ received_message: あなたのコレクション「%{collection_title}」(%{collection_url})に関するメッセージが届いています。
+ creatorship_notification:
+ explanation: あなたが作品の合作者の場合は、あなたの共同制作の設定に関わらず、新しいチャプターにあなたのペンネームが合作者として追加されます。また、作品が加えられたシリーズにも追加されます。
+ html:
+ creation: "%{creation_link} 作者:%{pseud_links}"
+ edit_chapter: 章を編集して
+ edit_series: シリーズを編集して
+ remove_chapter: 間違って合作者に追加された場合や、合作者としてリストに追加されたくない場合は、%{edit_chapter_link}合作者から自分を削除できます。
+ remove_series: 間違って合作者に追加された場合や、合作者としてリストに追加されたくない場合は、%{edit_series_link}合作者として自身を削除することができます。
+ intro_chapter: "%{adding_user}さんは以下の章の合作者として、あなたのペンネーム%{pseud}を追加しました:"
+ intro_series: "%{adding_user}さんは以下のシリーズの合作者としてあなたのペンネーム%{pseud} を追加しました:"
+ subject: "[%{app_name}] 合作者のお知らせ"
+ text:
+ creation: "%{title} (%{url}) 作者:%{pseuds}"
+ remove_chapter: 間違って合作者に追加された場合や、合作者としてリストに追加されたくない場合は、章を編集して自身を合作者から削除することができます:
+ %{url}
+ remove_series: 間違って合作者に追加された場合や、合作者としてリストに追加されたくない場合は、シリーズを編集して自身を合作者から削除することができます:
+ %{url}
+ creatorship_notification_archivist:
+ explanation: Open Doors(オープンドアプロジェクト)の公式な権限を持つ管理人は、あなたが合作者機能を無効化している場合でも、リクエストを送ることなくあなたを合作者として追加することがあります。
+ html:
+ creation: "%{creation_link} 作者:%{pseud_links}"
+ edit_chapter: 章を編集して
+ edit_series: シリーズを編集して
+ edit_work: 作品を編集して
+ remove_chapter: 間違って合作者に追加された場合や、合作者としてリストに追加されたくない場合は、%{edit_chapter_link}合作者として自身を削除することができます。
+ remove_series: 間違って合作者に追加された場合や、合作者としてリストに追加されたくない場合は、%{edit_series_link}合作者として自身を削除することができます。
+ remove_work: 間違って合作者に追加された場合や、合作者としてリストに追加されたくない場合は、%{edit_work_link}合作者として自身を削除することができます。
+ intro_chapter: "%{archivist}さんはあなたのペンネーム%{pseud}を以下の章の合作者として追加しました:"
+ intro_series: "%{archivist}さんはあなたのペンネーム%{pseud}を以下のシリーズの合作者として追加しました:"
+ intro_work: "%{archivist}さんはあなたのペンネーム%{pseud}を以下の作品の合作者として追加しました:"
+ subject: "[%{app_name}]管理人による合作者追加のお知らせ"
+ text:
+ creation: "%{title} (%{url}) 作者: %{pseuds}"
+ remove_chapter: 間違って合作者に追加された場合や、合作者としてリストに追加されたくない場合は、章を編集して合作者として自身を削除することができます:%{url}
+ remove_series: 間違って合作者に追加された場合や、合作者としてリストに追加されたくない場合は、シリーズを編集して合作者として自身を削除することができます:%{url}
+ remove_work: 間違って合作者に追加された場合や、合作者としてリストに追加されたくない場合は、作品を編集して合作者として自身を削除することができます:%{url}
+ creatorship_request:
+ html:
+ creation: "%{creation_link} 作者:%{pseud_links}"
+ instructions: "%{page_name}ページでこのリクエストを承認または拒否することができます。"
+ page_name: Co-Creator Requests(合作者リクエスト)
+ intro_chapter: "%{inviting_user} さんはあなた(ペンネーム%{pseud})を以下の章の合作者として招待しました:"
+ intro_series: "%{inviting_user} さんはあなた(ペンネーム%{pseud})を以下のシリーズの合作者として招待しました:"
+ intro_work: "%{inviting_user} さんはあなた(ペンネーム%{pseud})を以下の作品の合作者として招待しました:"
+ subject: "[%{app_name}] 合作者リクエスト"
+ text:
+ creation: "%{title}(%{url}) 作者:%{pseuds}"
+ instructions: Co-Creator Requests(合作者リクエスト)ページでこのリクエストを承認または拒否することができます:%{url}
+ delete_work_notification:
+ attachment: ご参考までに、当該作品のコピーを添付にてお送りいたします。
+ deleted_other:
+ html: "%{pseud}からの要望により、あなたの作品 %{title} が削除されました。"
+ text: "%{pseud}からの要望により、あなたの作品 %{title} が削除されました。"
+ deleted_yourself:
+ html: ご要望通りに、あなたの作品 %{title} が削除されました。
+ text: ご要望通りに、あなたの作品 %{title} が削除されました。
+ questions:
+ html: ご質問がございましたら、 %{support}
+ text: ご質問がございましたら %{support} (%{url})
+ subject: "[%{app_name}] あなたの作品が削除されました"
+ support: ユーザーサポートまでお問い合わせください。
+ invitation:
+ been_invited: あなたは私たちのβ版に招待されました!
+ has_invited: "%{user_name}さんはあなたを私たちのβ版に招待しました!"
+ html:
+ faq_link_text: 私たちのFAQ
+ subject: "[%{app_name}] 招待状"
+ invitation_to_claim:
+ access:
+ text: アーカイブによっては、インポートされた作品が登録ユーザーにしか閲覧できない場合があります(作品がグーグル検索結果に表示されないようにするためです)。
+ この場合、 全体公開に設定しない限り、作品はログイン済みのユーザーにのみアクセス可能となります。作品のアンロック・匿名化・削除に関しては、 AO3サポートまでご連絡ください。
+ claim_or_remove:
+ html: こちらから作品の担当表明や削除を行えます。
+ text: 'こちらから作品の担当表明や削除を行えます: %{claim_url}'
+ email_tips: お問い合わせの際はメールアドレス@transformativeworks.orgをホワイトリストへ追加の上、こちらからの返信が迷惑メールに送信されていないかご確認ください。
+ html:
+ ao3_news: AO3ニュース
+ contact_open_doors: オープンドアまでご連絡ください
+ contact_support: AO3サポートまでご連絡ください
+ faq_page: FAQページ
+ tutorial_page: チュートリアルページ
+ introduction:
+ text: アーカイブが%{open_doors_link} より%{app_name}(%{app_short_name} - %{app_url})へインポートされました件でメールを送らせていただいています。下記には作品の一覧が載せられていますが、ご希望の場合は、作品の担当表明(または削除・匿名化)が可能です。また、アカウントをお持ちでない場合は、ぜひアカウント登録をお願いします!
+ mistake:
+ text: アーカイブ情報に間違いがある場合、また下記の作品がご自身の作品でない場合は作品を削除しないようにお願いいたします! こちらが改めて整理できますようオープンドア(%{open_doors_link})までご連絡ください。
+ more_info:
+ text: アーカイブに関する最新動向についてはAO3ニュース(%{news_link})からご覧できます。また、オープンドアの関連情報についてはFAQページ(%{open_doors_faq_link})
+ かチュートリアルページ (%{open_doors_tutorial_link})をご参照ください。 FAQやチュートリアルに記載されていないご質問またはこのメールに関するご質問がある場合は、%{support_link}でAO3サポートまでご連絡ください。
+ other_works:
+ text: アーカイブのインポート時に利用したメールアドレスにアクセスできなくなり、そのアーカイブにまだ他の作品がある場合は、ご身分を証明できる情報をオープンドアまでご連絡ください。
+ questions:
+ text: その他のご質問に関しては%{support_link}でAO3サポートまでご連絡ください。
+ redirects: オススメリスト及びブックマークを保存するために、インポートされたアーカイブ内のWebアドレスは一定期間作品のコピーまでリダイレクトされます(詳しくはご自身のアーカイブの告知ポストをご確認ください)。作品のコピーをアップロード済みであり、かつURLインポート機能をご利用でない場合、アーカイブの同じ作品のコピーが2つ作成されます。
+ subject: "[%{app_name}]作品担当表明へのご招待"
+ unwanted:
+ text: ご自身の作品ではありますが公表したくない場合は作品を匿名化(作品はAO3に保存されますが、作者名は表示されません)または削除(作品は AO3から完全削除されます)できます。上記の担当表明リンクからそのまま作品の匿名化と削除ができますので、匿名化と削除のために作品を特定のアカウント下に置く必要はありません。(協力が必要な場合は%{support_link}でAO3サポートまでご連絡ください。)
+ update_redirect:
+ text: オープンドアに既存の作品までのリダイレクトを依頼する場合は、インポートされたコピーを削除し、AO3アカウント名・インポートされたアーカイブにおけるアカウント名・リダイレクト先の作品名とURLをオープンドア%{open_doors_link}までご連絡ください(複数の作品のリダイレクト先を変更したい場合は、作品リストをメールでお知らせください)。
+ uploaded_list: 'アップロードされた作品は以下の通りです:'
+ invite_increase_notification:
+ html:
+ body:
+ other: 新しい招待状が%{count}枚あなたに届いていることをお知らせいたします。招待状はアーカイブで新しいアカウントを作成するために使えます。%{invitation_page_link}にて友達を招待することもできます。
+ invitation_page_link_text: 招待管理ページ
+ subject: "[%{app_name}] 新しい招待状"
+ text:
+ body:
+ other: 新しい招待状が%{count}枚あなたに届いていることをお知らせいたします。招待状はアーカイブで新しいアカウントを作成するために使えます。Invitations(招待状)ページ(%{invitation_page_url})で友達を招待することができます。
+ invite_request_declined:
+ main:
+ other: 申し訳ございませんが、あなたのリクエストされた%{count} 件の新しい招待コードは、今回は承認できませんでした。
+ reason: あなたのリクエストは以下の通りでした。
+ subject: "[%{app_name}]追加招待コードのリクエストが拒否されました"
+ recipient_notification:
+ html:
+ collection: あなたへのプレゼント作品が、AO3の%{collection_link} コレクションに投稿されました!
+ no_collection: あなたへのプレゼント作品がAO3に投稿されました!
+ subject:
+ collection: "[%{app_name}][%{collection_title}] %{collection_title} からあなたへのプレゼント作品が届きました"
+ no_collection: "[%{app_name}] あなたへのプレゼント作品が届きました"
+ text:
+ collection: あなたへのプレゼント作品が、AO3の 「 %{collection_title} 」コレクション(%{collection_url})に投稿されました!
+ signup_notification:
+ activate:
+ html: こちらよりどうぞ %{activate_account_link}。
+ text: 'このリンクに従いアカウントを有効化してください: %{activate_account_url}'
+ activate_your_account: このリンクに従いアアカウントを有効化してください
+ admin_posts: AO3ニュース
+ bye: AO3の利用が楽しいものとなりますよう願っております。
+ contact_support: ユーザーサポートまでご連絡ください
+ faq: FAQ
+ features:
+ html: アカウントが有効化されると、ファン作品を投稿したり、お気に入りの作者や作品が更新された時にメール通知するフォローを設定したり、サイトの見た目や機能が自分好みになるよう設定からカスタマイズしたり、AO3でアクセスした作品を確認したり、他にも沢山のことができるようになります。
+ text: 'アカウントが有効化されると、ファン作品を投稿したり、お気に入りの作者や作品が更新された時にメール通知するフォローを設定したり、サイトの見た目や機能が自分好みになるよう設定からカスタマイズしたり、AO3でアクセスした作品を確認したり、他にも沢山のことができるようになります。 '
+ information:
+ html: 私たちの %{faq_link} には、AO3の使い方に関する沢山の情報やアドバイスが掲載されています。 サイトの最新の更新状況は %{admin_posts_link}
+ にて確認できます。サポートが必要な場合、バグを見つけた場合、またはご質問やコメントがある場合は、%{contact_support_link}
+ へご連絡下さいましたら喜んでお手伝いいたします。
+ text: 私たちの %{faq_url} には、AO3の使い方に関する沢山の情報やアドバイスが掲載されています。 サイトの最新の更新状況は %{admin_posts_url}
+ にて確認できます。サポートが必要な場合、バグを見つけた場合、またはご質問やコメントがある場合は、%{contact_support_url} へご連絡下さいましたら喜んでお手伝いいたします。
+ welcome: Archive of Our Own – AO3(みんなのアーカイブ)へようこそ、 %{login} さん!
diff --git a/config/locales/phrase-exports/ko.yml b/config/locales/phrase-exports/ko.yml
new file mode 100755
index 0000000..d5d55ab
--- /dev/null
+++ b/config/locales/phrase-exports/ko.yml
@@ -0,0 +1,490 @@
+---
+ko:
+ activerecord:
+ attributes:
+ archive_warning:
+ name_with_colon:
+ other: '주의 사항:'
+ category:
+ name_with_colon:
+ other: '카테고리:'
+ character:
+ name_with_colon:
+ other: '여러 등장인물들:'
+ fandom:
+ name_with_colon:
+ other: '팬덤:'
+ freeform:
+ name_with_colon:
+ other: '기타 태그:'
+ rating:
+ name_with_colon: '등급:'
+ relationship:
+ name_with_colon:
+ other: '관계성:'
+ work:
+ chapter_total_display: 챕터
+ summary: 줄거리
+ models:
+ archive_warning:
+ other: 주의 사항
+ category:
+ other: 카테고리
+ chapter:
+ other: 챕터
+ character:
+ other: 등장인물
+ fandom:
+ other: 팬덤
+ freeform:
+ other: 기타 태그
+ rating:
+ other: 등급
+ relationship:
+ other: 관계성
+ series:
+ other: 시리즈
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ other: "%{count} 손님들"
+ left_kudos:
+ html:
+ other: "%{commentable_link}에 %{givers_list}이(가) 좋아요(kudos)를 남겼습니다."
+ text:
+ other: "%{commentable_title} (%{commentable_url})에 %{givers_list}이(가) 좋아요(kudos)를
+ 남겼습니다."
+ single_guest:
+ giver: 손님
+ html: "%{commentable_link}에 %{giver}이(가) 좋아요(kudos)를 남겼습니다."
+ text: "%{commentable_title} (%{commentable_url})에 손님이 좋아요(kudos)를 남겼습니다."
+ subject: "[%{app_name}] 회원님의 게시물에 좋아요(kudos)가 있습니다!"
+ mailer:
+ general:
+ closing:
+ formal: 드림
+ informal: 드림
+ creation:
+ link_with_word_count: "%{creation_link} (%{word_count})"
+ title_with_chapter_number: 작품 %{title}의 챕터 %{position}
+ title_with_word_count: '"%{creation_title}" (%{word_count})'
+ word_count:
+ other: "%{count} 단어"
+ footer:
+ general:
+ about:
+ html: AO3는 팬이 직접 운영 및 지원하는 아카이브로 %{donate_link}에 의존하고 있습니다.
+ text: 'AO3는 팬이 직접 운영 및 지원하는 아카이브로 여러분의 기부금에 의존하고 있습니다. 기부 링크: %{donate_url}'
+ html:
+ donate_link_text: 여러분의 기부금
+ support_link_text: 지원 위원회에 연락
+ unwanted_email:
+ html: 이 메시지를 오류로 받으신 경우, %{support_link}해 주세요.
+ text: 이 메시지를 오류로 받으신 경우, 다음 링크로 지원 위원회에 문의해 주세요. %{support_url}
+ sent_at: "%{sent_at}에 전송됨."
+ greeting:
+ formal_html: "%{name} 님,"
+ informal:
+ addressed_html: 안녕하세요, %{name} 님!
+ unaddressed: 안녕하세요!
+ introductory: Archive of Our Own – AO3 (우리만의 아카이브)에서 인사드립니다!
+ metadata_label_indicator: ":"
+ signature:
+ abuse_team: AO3 정책관리 위원회 드림
+ app_short_name: AO3
+ open_doors: The Open Doors (오픈 도어 프로젝트) 팀 드림
+ parent_org: Organization for Transformative Works – OTW (변형적 작품 단체) 드림
+ support: AO3 지원 위원회 드림
+ users:
+ mailer:
+ reset_password_instructions:
+ expiration: 일주일 안에 링크를 통해 비밀번호를 재설정하지 않을시, 현재 링크가 만료되며 새로운 링크를 요청하셔야 합니다.
+ intro: 누군가가 회원님 계정의 비밀번호 재설정을 요청하였습니다. 아래 링크에서 새로운 비밀번호를 입력하여 비밀번호를 변경하실 수
+ 있습니다.
+ link_title: 비밀번호 변경하기
+ subject: "[%{app_name}] 비밀번호 재설정"
+ unrequested: 비밀번호 재설정을 요청하지 않으셨다면 이 이메일을 무시하셔도 됩니다. 비밀번호가 변경되지 않습니다.
+ user_mailer:
+ admin_deleted_work_notification:
+ bye: 회원님을 위해 작품의 복사본이 첨부되어 있습니다.
+ contact_abuse: 정책관리 위원회에 연락하기
+ deleted:
+ html: 회원님의 %{title} 작품이 사이트 관리자에 의해 Archive of Our Own - AO3(우리만의 아카이브)에서
+ 삭제되었습니다.
+ text: 회원님의 “%{title}” 작품이 사이트 관리자에 의해 Archive of Our Own - AO3(우리만의 아카이브)에서
+ 삭제되었습니다.
+ html:
+ tos_violation: 만약 회원님의 작품이 AO3의 이용약관을 어겼을 가능성이 있다면, %{contact_abuse_link}를
+ 통해 문의해 주십시오.
+ import_project:
+ html: 만약 회원님의 작품이 Open Doors(오픈 도어 프로젝트) 팀이 관리하는 반입 프로젝트에 속해있었다면, %{opendoors_link}를
+ 통해 문의해 주십시오.
+ text: 만약 회원님의 작품이 Open Doors(오픈 도어 프로젝트) 팀이 관리하는 반입 프로젝트에 속해있었다면, (%{opendoors_link})를
+ 통해 오픈 도어 프로젝트에 문의해 주십시오.
+ opendoors: 오픈 도어 프로젝트에 연락하기
+ subject: "[%{app_name}]님의 작품이 관리자에 의해 삭제되었습니다"
+ text:
+ tos_violation: 만약 회원님의 작품이 AO3의 이용약관을 어겼을 가능성이 있다면, (%{contact_abuse_url})를
+ 통해 정책관리 위원회에 문의해 주십시오.
+ admin_hidden_work_notification:
+ access: 작품이 비공개인 동안에는 위에 제공된 링크를 통해 조회하실 수는 있으나 회원님의 작품 페이지 목록에는 포함되지 않고 AO3
+ 내 다른 사용자에게 공개되지 않습니다.
+ check_email: 정책 관리 위원회에서 회원님의 작품을 비공개한 사유를 설명하는 메일을 발송했을 수도 있으니 회원님의 메일함 및 스팸
+ 보관함을 확인해 주시기 바랍니다.
+ contact_abuse: 정책 관리 위원회에 문의
+ html:
+ help: 회원님의 작품이 비공개 전환된 사유를 알 수 없거나 해당 사항에 대한 상세 연락을 받지 못하셨다면 직접 %{contact_abuse_link}해
+ 주시기 바랍니다.
+ hidden: 정책 관리 위원회에서 회원님의 작품 %{title}을(를) 비공개로 전환하여 더 이상 공개적으로 조회할 수 없습니다.
+ tos_violation: AO3 내 %{tos_link} 규정 위반으로 작품이 비공개 되었다면 해당 위반사항을 시정하셔야 합니다.
+ 작품이 이용 약관을 준수하도록 조치를 취하지 않으신다면 작품이 AO3에서 삭제될 수도 있습니다.
+ subject: "[%{app_name}] 정책 관리 위원회에서 회원님의 작품을 비공개 처리하였습니다"
+ text:
+ help: '회원님의 작품이 비공개 전환된 사유를 알 수 없거나 해당 사항에 대한 상세 연락을 받지 못하셨다면 직접 정책 관리 위원회에
+ 문의해 주시기 바랍니다: %{contact_abuse_url}'
+ hidden: 정책 관리 위원회에서 회원님의 작품 "%{title}" (%{work_url})을(를) 비공개 전환하여 더 이상 공개적으로
+ 조회할 수 없습니다.
+ tos_violation: AO3 내 %{tos_url} 규정 위반으로 작품이 비공개 되었다면 해당 위반사항을 시정하셔야 합니다. 작품이
+ 이용 약관을 준수하도록 조치를 취하지 않으신다면 작품이 AO3에서 삭제될 수도 있습니다.
+ tos: 이용 약관
+ anonymous_or_unrevealed_notification:
+ anonymous_info: 익명 작품들은 태그 목록에 포함되지만 회원님의 작품 페이지에는 포함되지 않습니다. 작품 페이지에서 회원님의
+ 이름이 "Anonymous" (익명)으로 대체됩니다.
+ anonymous_unrevealed_info: 추후 관리자가 회원님의 작품을 익명 작품으로써 공개할 수도 있습니다. 회원님을 구독하고
+ 있는 사용자들에게는 해당 변경사항이 알려지지 않습니다. 공개 시에는 회원님의 작품이 태그 목록에 포함되나 회원님의 작품 페이지에는 나타나지
+ 않습니다. 작품상에서는 회원님의 유저 이름이 "Anonymous" (익명)로 표기됩니다.
+ changed_status:
+ anonymous:
+ html: "%{collection_link} 컬렉션 관리자가 회원님의 작품 %{work_link}을(를) 익명 작품으로 전환했습니다."
+ text: '"%{collection_title}" (%{collection_url}) 콜렉션 관리자가 회원님의 작품 "%{work_title}"
+ (%{work_url}) 를/을 익명으로 처리했습니다.'
+ anonymous_unrevealed:
+ html: "%{collection_link} 컬렉션 관리자가 회원님의 작품을 %{work_link} 을(를) 익명 및 비공개로
+ 전환했습니다."
+ text: '"%{collection_title}" (%{collection_url}) 콜렉션 관리자가 회원님의 작품 "%{work_title}"
+ (%{work_url}) 를/을 익명이자 비공개 등록으로 처리했습니다.'
+ unrevealed:
+ html: "%{collection_link} 컬렉션 관리자가 회원님의 작품 %{work_link}을(를) 비공개로 전환했습니다."
+ text: '"%{collection_title}" (%{collection_url}) 콜렉션 관리자가 회원님의 작품 "%{work_title}"
+ (%{work_url}) 를/을 비공개 등록으로 처리했습니다.'
+ collection_items_link_text: Approved Collection Items (등록된 컬렉션 작품 목록) 페이지
+ do_not_want:
+ anonymous:
+ html: 작품의 익명 처리를 원치 않으신다면 회원님의 컬렉션 목록 링크 %{collection_items_link} 에 접속하여
+ 해당 컬렉션에서 작품을 제외해 주시기 바랍니다.
+ text: '작품 익명 처리를 원치 않으신다면 회원님의 Approved Collection Items (승인된 컬렉션 목록) 페이지에
+ 접속하셔서 해당 컬렉션에서 작품을 제외해 주시기 바랍니다: %{collection_items_url}'
+ anonymous_unrevealed:
+ html: 작품 익명 및 비공개 처리를 원치 않으신다면 회원님의 컬렉션 목록 링크 %{collection_items_link} 에
+ 접속하여 해당 컬렉션에서 작품을 제외해 주시기 바랍니다.
+ text: '작품 익명 및 비공개 처리를 원치 않으신다면 회원님의 Approved Collection Items (승인된 컬렉션
+ 목록) 페이지에 접속하셔서 해당 컬렉션에서 작품을 제외해 주시기 바랍니다: %{collection_items_url}'
+ unrevealed:
+ html: 작품 비공개를 원치 않으시다면 회원님의 컬렉션 목록 링크 %{collection_items_link} 에 접속하여 해당
+ 컬렉션에서 작품을 제외해 주시기 바랍니다.
+ text: '작품 비공개 처리를 원치 않으신다면 회원님의 Approved Collection Items (승인된 컬렉션 목록) 페이지에
+ 접속하셔서 해당 컬렉션에서 작품을 제외해 주시기 바랍니다: %{collection_items_url}'
+ faq_link_text: 컬렉션 FAQ 페이지
+ more_info:
+ html: 더 자세한 사항은 %{faq_link}를 참고해 주세요.
+ text: '더 자세한 사항은 컬렉션 FAQ를 참고해 주세요: %{faq_url}'
+ subject:
+ anonymous: "[%{app_name}] 회원님의 작품이 익명 처리되었습니다."
+ anonymous_unrevealed: "[%{app_name}] 회원님의 작품이 익명 및 비공개 처리되었습니다."
+ unrevealed: "[%{app_name}] 회원님의 작품이 비공개 처리되었습니다"
+ unrevealed_info: 비공개 작품은 태그 목록 및 회원님의 작품 페이지에 나타나지 않습니다. 해당 작품 링크로 접속하는 모든 이용자에게는
+ 작품이 비공개임을 알리는 공지가 뜨며 작품 내용을 조회할 수 없습니다.
+ archivist_added_to_collection_notification:
+ approved_collection_items_page: Approved Collection Items (승인된 컬렉션 항목) 페이지
+ archivist_notice: 컬렉션 관리자는 Open Doors (오픈 도어 프로젝트) 아키비스트로서 공식 자격으로 활동하기 때문에
+ 회원님께서 컬렉션 초대를 비활성화한 경우에도 이 컬렉션에 작품을 추가할 수 있습니다. 아키비스트는 가져온 아카이브에서 호스팅된 작품인
+ 경우에만 작품을 컬렉션에 추가합니다.
+ removal_instructions:
+ html: 이 컬렉션에서 작품을 제외하려면 회원님의 %{approved_items_link}를 방문하세요.
+ text: '이 컬렉션에서 작품을 제외하려면 회원님의 Approved Collection Items (승인된 컬렉션 항목) 페이지를
+ 방문하세요. 링크: %{approved_items_url}'
+ subject: "[%{app_name}][%{collection_title}] Open Doors (오픈 도어 프로젝트) 아키비스트가
+ 회원님의 작품을 컬렉션에 추가했습니다"
+ work_added:
+ html: "%{collection_link} 컬렉션 관리자가 회원님의 %{work_link} 작품을 컬렉션에 추가했습니다!"
+ text: '"%{collection_title}" (%{collection_url}) 컬렉션 관리자가 회원님의 "%{work_title}"
+ (%{work_url}) 작품을 컬렉션에 추가했습니다!'
+ challenge_assignment_notification:
+ any: "'모든' 태그 포함"
+ assignment:
+ html: 회원님께서는 AO3 %{link} 챌린지에서 다음과 같은 리퀘를 맡게 되셨습니다!
+ description: '설명:'
+ due: '이 챌린지 과제의 마감일:'
+ html:
+ footer: "%{title} 챌린지에 등록했기 때문에 이 이메일을 받으셨습니다. 이 챌린지에 대한 자세한 내용과 진행자의 연락처는
+ %{footer_link}에서 확인할 수 있습니다."
+ footer_link: 챌린지 프로필 페이지
+ look_up: 이 챌린지 과제는 %{link}에서 확인할 수 있습니다.
+ look_up_link: 회원님의 Assignments (챌린지 과제) 페이지
+ optional_tags: '선택적 태그:'
+ prompts: '소재:'
+ prompt_url: '소재 URL:'
+ recipient: '받는 사람:'
+ recipient_missing: '없음: 관리자에게 도움을 요청하세요!'
+ subject: "[%{app_name}][%{collection_title}] 회원님의 챌린지 과제입니다!"
+ text:
+ assignment: 다음은 회원님이 맡게 되신 AO3 "%{collection_title}" 챌린지 (%{collection_url})
+ 리퀘입니다!
+ footer: "%{title} 챌린지(%{url})에 등록했기 때문에 이 이메일을 받으셨습니다. 이 챌린지에 대한 자세한 내용과 진행자의
+ 연락처는 %{profile_url}에서 확인할 수 있습니다."
+ look_up: 이 챌린지 과제는 회원님의 Assignments (챌린지 과제) 페이지 링크 %{link}에서 확인할 수 있습니다.
+ change_email:
+ changed:
+ html: "%{login}님, 회원님의 계정과 연계된 이메일 주소가 %{email}으로 변경되었습니다."
+ text: "%{login}님, 회원님의 계정과 연계된 이메일 주소가 %{email}으로 변경되었습니다."
+ subject: "[%{app_name}] 이메일 주소 변경"
+ claim_notification:
+ access:
+ contact_support: AO3 지원 위원회에 문의
+ html: 아카이브에 따라서는 통합 이전 시 회원님의 작품이 (구글 검색을 방지하기 위해) 등록된 회원에게만 보이도록 설정되는 경우가
+ 있습니다. 이때는 전체 공개로 설정을 변경하지 않으면 작품은 로그인한 회원들만 조회할 수 있습니다. 작품을 전체 공개하거나, 버리기,
+ 또는 삭제하는 데 도움이 필요하시면 %{contact_support_link}해 주시기 바랍니다.
+ text: '아카이브에 따라서는 통합 이전 시 회원님의 작품이 (구글 검색을 방지하기 위해) 등록된 회원에게만 보이도록 설정되는 경우가
+ 있습니다. 이 때는 전체 공개로 설정을 변경하지 않으면 작품은 로그인을 한 회원들만 조회할 수 있습니다. 작품을 전체 공개하거나,
+ 버리기, 또는 삭제하는 데 도움이 필요하시면 다음 링크를 통해 AO3 지원 위원해에 문의해 주시기 바랍니다: %{support_url}'
+ email_tips: 저희에게 문의를 주실 경우 @transformativeworks.org를 회원님의 안전 연락처에 추가해 주시고 저희의
+ 답변이 회원님의 스팸 폴더로 들어가 있지는 않은지 확인해 주시기 바랍니다.
+ introduction:
+ ao3_name: Archive of Our Own – AO3 (우리만의 아카이브)
+ html: 이 이메일은 %{open_doors_name_link}를 통해 %{app_link}으로 통합 이전된 팬작품 아카이브가 회원님의
+ 작품을 포함하고 있음을 알리기 위해 보내 드립니다. 본 이메일 주소가 이번에 이전된 아카이브에 등록된 계정과 연결되어 있어 관련된
+ 팬작품(아래 목록에서 확인하실 수 있습니다)은 자동으로 회원님의 AO3 계정에 추가되었습니다.
+ open_doors_name: Open Doors (오픈 도어 프로젝트)
+ text: '이 이메일은 Open Doors (오픈 도어 위원회) (%{open_doors_url})를 통해 Archive of Our
+ Own – AO3 (우리만의 아카이브): %{app_url}으로 통합 이전된 팬작품 아카이브가 회원님의 작품을 포함하고 있음을 알리기
+ 위해 보내드립니다.본 이메일 주소가 이번에 이전된 아카이브에 등록되어 있어 관련된 팬작품(아래 목록에서 확인하실 수 있습니다)은
+ 자동으로 회원님의 AO3 계정에 추가되었습니다.'
+ mistake:
+ contact_open_doors: 오픈 도어 프로젝트에 문의
+ html: 이 작품이 회원님의 작품이 아니라면 해당 작품을 삭제하지 말아 주세요! %{contact_open_doors_link}해서
+ 알려 주시면 저희가 해결해 드리겠습니다.
+ text: 만약 이 작품들이 귀하의 작품이 아니라면, 해당 작품을 삭제하지 말아주세요! 오픈 도어 위원회(%{open_doors_url})에
+ 문의해서 알려 주시면 저희가 해결해 드리겠습니다.
+ more_info:
+ ao3_news: AO3 소식
+ contact_support: AO3 지원 위원회에 문의
+ faq_page: FAQ 페이지
+ html: 최근 아카이브 이전에 대한 소식은 %{ao3_news_link}에서, 오픈 도어 프로젝트에 대한 추가 정보는 %{faq_page_link}
+ 또는 %{tutorial_page_link}에서 확인하실 수 있습니다. FAQ, 튜토리얼, 이메일 등으로 해결되지 못한 질문은 %{contact_support_link}해
+ 주시기 바랍니다.
+ text: '최근 아카이브 이전에 대한 정보는 AO3 뉴스 (%{news_url})에서, 오픈 도어 프로젝트에 대한 추가 정보는 오픈
+ 도어 FAQ 페이지 (%{open_doors_faq_url}) 또는 튜토리얼 페이지(%{open_doors_tutorial_url})에서
+ 확인하실 수 있습니다. FAQ, 튜토리얼, 이메일 등으로 해결하지 못한 질문은 다음 링크를 통해 지원 위원회에 문의해 주시기 바랍니다:
+ %{support_url}'
+ tutorial_page: 튜토리얼 페이지
+ other_works:
+ contact_open_doors: 오픈도어 프로젝트에 문의
+ html: 이전해 온 아카이브의 다른 작품들이 회원님이 현시점 사용할 수 없는 이메일 주소로 등록되어 있는 경우 무엇이든 회원님의 신원을
+ 확인할 수 있는 정보를 넣어 %{contact_open_doors_link}해 주시기 바랍니다.
+ text: 이전해온 아카이브의 다른 작품들이 회원님이 현 시점 사용할 수 없는 이메일 주소로 등록되어 있는 경우 무엇이든 회원님의 신원을
+ 확인할 수 있는 정보를 넣어 오픈 도어 위원회에 문의해 주시기 바랍니다.
+ questions:
+ contact_support: AO3 지원 위원회에 문의
+ html: 그 의 질문은 %{contact_support_link}해 주세요.
+ text: '그 의 질문은 다음 링크를 통해 AO3 지원 위원회에 문의해 주세요: %{support_url}'
+ redirects:
+ html: 추천 리스트와 책갈피를 보존하시려는 경우 이전된 아카이브의 웹주소는 일정 기간 이전해 온 해당 작품의 주소로 자동 리디렉팅이
+ 되기도 합니다(정확한 사항은 해당하는 아카이브에 관한 공지를 통해 확인해 주세요). 해당 작품의 사본을 이미 게시하고 URL을 통해
+ 불러오기 기능을 사용하지 %{negation}셨다면 AO3에는 동일한 작품이 두 개 게시물로 존재하게 됩니다.
+ subject: "[%{app_name}] 작품이 게시되었습니다"
+ update_redirect:
+ contact_open_doors: 오픈 도어 프로젝트에 문의
+ html: 리디렉팅 주소가 회원님의 기존 작품으로 이어지도록 오픈 도어 위원회에서 업데이트를 진행하기를 원하신다면 이전해 온 사본을
+ 삭제한 뒤 AO3 계정 이름, 이전한 아카이브 내 계정 이름, 리디렉팅으로 이동하게 될 팬작품의 제목과 URL을 담아 %{contact_open_doors_link}해
+ 주시기 바랍니다. (여러 작품의 라디렉팅 주소를 변경하기를 원하시면 한 통의 이메일 안에 모두 담으셔도 됩니다.)
+ text: 리디렉팅 주소가 회원님의 기존 작품으로 이어지도록 오픈 도어 위원회에서 업데이트를 진행하기를 원하신다면 이전해 온 사본을
+ 삭제한 뒤 AO3 계정 이름, 이전한 아카이브 내 계정 이름, 리디렉팅으로 이동하게 될 팬작품의 제목과 URL을 담아 %{open_doors_url}를
+ 통해 오픈 도어 위원회에 문의해 주시기 바랍니다. (여러 작품의 라디렉팅 주소를 변경하기 원하시면 한 통의 이메일 안에 모두 담으셔도
+ 됩니다.)
+ works_by: '해당 작품은 다음 이메일 주소로 작성되었습니다: %{email}'
+ work_info:
+ html: "%{work_link} (%{fandom})"
+ text:
+ no_fandom: "- %{work_title} %{work_url}"
+ with_fandom: "- %{work_title} %{work_url} (%{fandom})"
+ collection_notification:
+ assignments_sent:
+ complete: 모든 챌린지 과제가 발송 완료되었습니다.
+ subject: 챌린지 과제 발송 완료
+ html:
+ received_message: '회원님의 컬렉션 %{collection_link}에 관한 메시지가 도착했습니다:'
+ text:
+ received_message: 회원님의 컬렉션 "%{collection_title}" (%{collection_url})에 관한 메시지가
+ 도착했습니다.
+ creatorship_notification:
+ explanation: 어떤 작품의 공동 창작자가 되면 회원님의 공동 창작 설정과 관계 없이 신규 챕터에 공동 창작자로 추가됩니다. 또한
+ 해당 작품을 포함하는 모든 시리즈에도 등록됩니다.
+ html:
+ creation: "%{creation_link}, %{pseud_links} 작(作)"
+ edit_chapter: 챕터 수정하기
+ edit_series: 시리즈 수정하기
+ remove_chapter: 의도치 않게 등록 되었거나 등록을 희망하지 않으신다면 %{edit_chapter_link}를 통해 회원님을
+ 창작자에서 제외하실 수 있습니다.
+ remove_series: 의도치 않게 등록되었거나 등록을 원치 않으신다면 %{edit_series_link}를 통해 회원님을 창작자에서
+ 제외할 수 있습니다.
+ intro_chapter: "%{adding_user} 님이 회원님의 필명 %{pseud}을(를) 다음 작품 챕터의 공동 창작자로 등록했습니다:"
+ intro_series: "%{adding_user} 님이 회원님의 필명 %{pseud}을(를) 다음 시리즈의 공동 창작자로 등록했습니다:"
+ subject: "[%{app_name}] 공동 창작자 알림"
+ text:
+ creation: "%{title} (%{url}), %{pseuds} 작(作)"
+ remove_chapter: '의도치 않게 등록 되었거나 등록을 원치 않으신다면 챕터를 수정하여 회원님을 창작자에서 제외할 수 있습니다
+ : %{url}'
+ remove_series: '의도치 않게 등록 되었거나 등록을 원치 않으신다면 시리즈를 수정하여 회원님을 창작자에서 제외할 수 있습니다r:
+ %{url}'
+ creatorship_notification_archivist:
+ explanation: Open Doors (오픈 도어)의 공식적인 임무를 진행하는 아키비스트는 사용자님이 '나를 공동 창작자로 추가'
+ 설정을 비활성화 시켰더라도 요청 없이 추가할 수 있습니다.
+ html:
+ creation: "%{pseud_links}의 %{creation_link}"
+ edit_chapter: 챕터 수정하기
+ edit_series: 시리즈 수정하기
+ edit_work: 작품 수정하기
+ remove_chapter: 회원님의 공동 창작자 등록이 오류로 인한 것이라면 %{edit_chapter_link} 링크를 통해 회원님의
+ 창작자 등록을 취소하실 수 있습니다.
+ remove_series: 회원님의 공동 창작자 등록이 오류로 인한 것이라면 %{edit_series_link} 링크를 통해 창작자
+ 등록을 취소하실 수 있습니다.
+ remove_work: 회원님의 공동 창작자 등록이 오류로 인한 것이라면 %{edit_work_link}링크를 통해 창작자 등록을 취소하실
+ 수 있습니다.
+ intro_chapter: "%{archivist} 님이 회원님의 필명 %{pseud}을(를) 다음 작품 챕터의 공동 창작자로 등록했습니다:"
+ intro_series: "%{archivist} 님이 회원님의 필명 %{pseud}을(를) 다음 시리즈의 공동 창작자로 추가했습니다:"
+ intro_work: '아카이브 관리자 %{archivist} 님이 회원님의 필명 %{pseud}을(를) 다음 작품의 공동 창작자로 추가하였습니다:'
+ subject: "[%{app_name}] 아키비스트 공동 창작자 알림"
+ text:
+ creation: "%{pseuds}의 %{title} (%{url})"
+ remove_chapter: '실수로 또는 원치 않게 창작자로 추가되었을 경우 챕터 수정을 통해 회원님의 필명을 창작자 명단에서 삭제할
+ 수 있습니다: %{url}'
+ remove_series: '실수로 또는 원치 않게 창작자로 추가되었을 경우 시리즈 수정을 통해 회원님의 필명을 창작자 명단에서 삭제할
+ 수 있습니다: %{url}'
+ remove_work: '실수로 또는 원치 않게 창작자로 추가됐을 경우 작품 수정을 통해 회원님의 필명을 작품 창작자 명단에서 삭제할
+ 수 있습니다: %{url}'
+ creatorship_request:
+ html:
+ creation: "%{pseud_links}의 %{creation_link}"
+ instructions: 회원님의 %{page_name} 페이지에서 본 요청을 수락 또는 거절할 수 있습니다.
+ page_name: Co-Creator Requests (공동 창작자 요청)
+ intro_chapter: "%{inviting_user} 회원님이 회원님의 창작자명 %{pseud} 을(를) 다음 챕터의 공동 창작자로
+ 등록하기를 요청했습니다:"
+ intro_series: "%{inviting_user} 회원님이 회원님의 창작자명 %{pseud}을(를) 다음 시리즈의 공동 창작자로
+ 등록하기를 요청했습니다:"
+ intro_work: "%{inviting_user} 회원님이 회원님의 창작자명 %{pseud}을(를) 다음 작품의 공동 창작자로 등록하기를
+ 요청했습니다:"
+ subject: "[%{app_name}] 공동 창작자 등록 요청"
+ text:
+ creation: "%{pseuds}의; %{title} (%{url})"
+ instructions: '회원님의 Co-Creator Requests (공동 창작자 요청) 페이지에서 본 요청을 수락 또는 거절할
+ 수 있습니다: %{url}'
+ delete_work_notification:
+ attachment: 참고를 위해 작품의 복사본을 첨부해 드립니다.
+ deleted_other:
+ html: "%{pseud} 의 요청으로 회원님의 작품 %{title}이 삭제되었습니다."
+ text: '%{pseud}의 요청으로 회원님의 작품 "%{title}"이 삭제되었습니다.'
+ deleted_yourself:
+ html: 회원님의 요청으로 회원님의 작품 %{title}이 삭제되었습니다.
+ text: 회원님의 요청으로 회원님의 작품 "%{title}"이 삭제되었습니다.
+ questions:
+ html: 질문이 있으시다면, %{support}에 연락해 주세요.
+ text: 질문이 있으시다면, %{support}(%{url})에 연락해 주세요.
+ subject: "[%{app_name}] 회원님의 작품이 삭제되었습니다"
+ support: 지원 위원회에 연락해 주세요
+ invitation_to_claim:
+ access:
+ text: 아카이브에 따라, 작품이 (구글 검색을 피하기 위해)등록된 회원에게만 공개된 경우도 있습니다. 만약 그런 상황이라면, 작품의
+ 전면 공개를 하지 않는 이상 작품은 계속해서 로그인 된 회원에게만 공개됩니다. 작품의 전면공개, 고립하기, 삭제하기 등에 도움이
+ 필요하시다면 AO3 지원 위원회로 연락주십시오.
+ claim_or_remove:
+ html: 작품 소유권을 되찾거나 포기하는 법.
+ text: '작품 창작자로 등록하거나 삭제하는 링크: %{claim_url}'
+ email_tips: 만약 저희와 연락하고 싶으시다면, @transformativeworks.org의 이메일 주소를 스팸이 아닌 것으로
+ 분류하신 뒤 저희 답변을 스팸 폴더에서 찾아봐 주세요.
+ html:
+ ao3_news: AO3 뉴스
+ contact_open_doors: 오픈 도어 연락하기
+ contact_support: AO3 지원 연락하기
+ faq_page: 자주 묻는 질문답변
+ tutorial_page: 튜토리얼 페이지
+ introduction:
+ text: 최근 Open Doors (오픈 도어 프로젝트) (%{open_doors_link})를 통해 이전된 아카이브 %{app_name}(%{app_short_name}
+ - %{app_url})에서 다음 작품들이 회원님의 작품인 것으로 추측되어 이메일 드립니다. 원하신다면 작품의 소유권을 회복(또는
+ 삭제/고립)할 기회를 드리고 싶습니다. 그리고 만약 이미 아카이브에 다른 이메일 주소로 회원이 아니시라면, 저희 아카이브에 초대하고
+ 싶습니다!
+ mistake:
+ text: 만약 본 메일이 실수이고 작품의 창작자가 아니시라면, 부디 삭제하지 말아주십시오! 오픈 도어 프로젝트(%{open_doors_link})로
+ 연락주시면 저희가 해결하겠습니다.
+ more_info:
+ text: 최근의 아카이브 뉴스에 관해 AO3 뉴스(%{news_link})에서 읽으실 수 있으며, 오픈 도어 FAQ 페이지(%{open_doors_faq_link})
+ 나 튜토리얼 메뉴(%{open_doors_tutorial_link})를 통해 추가적인 정보를 얻으실 수 있습니다. FAQ, 튜토리얼,
+ 그리고 본 이메일에 답이 없는 질문이 있을 경우, %{support_link}로 통해서 지원 위원회로 연락 주십시오.
+ other_works:
+ text: 만약 이전된 아카이브에 접속할 수 없는 이메일 계정으로 여러 작품이 있으실 경우, 창작자 증명이 가능한 자료와 함께 오픈
+ 도어로 연락 주십시오.
+ questions:
+ text: 그 외 다른 도움이 필요하신 경우, %{support_link} 통해서 AO3 서포트로 연락 주십시오.
+ redirects: 추천 목록과 북마크를 저장하기 위해, 이전된 아카이브의 웹주소는 이전된 작품 복사본의 주소로 당분간 연결됩니다(아카이브의
+ 공지사항을 확인해주세요). 만약 이미 작품의 복사본을 등록했고 URL의 이전 기능을 사용하지 않았을 경우, 아카이브에 작품의 복사본
+ 2개가 있을 것입니다.
+ subject: "[%{app_name}] 작품 등록하기에 초대합니다"
+ unwanted:
+ text: 만약 이 작품의 창작자면서도 소유권을 포기하고 싶으실 경우, 작품을 고립(작품은 AO3에 남지만, 작가 이름이 익명으로 처리)할
+ 수 있고 또는 삭제(작품이 AO3에 남지 않고 완전히 삭제)할 수도 있습니다. 작품을 고립하거나 삭제하기 위해 계정에 등록할 필요
+ 없습니다-- 위의 링크를 통해 하실 수 있습니다. (도움이 필요하신 경우, %{support_link} 서포트로 연락 주십시오.)
+ update_redirect:
+ text: 만약 오픈 도어에서 기존 작품으로 연결되는 링크를 업데이트 하길 원하신 다면, 이전된 작품 복사본을 지우시고, %{open_doors_link}로
+ 통해서 오픈 도어에게 연락해 AO3 계정 이름, 이전된 아카이브의 계정 이름, 그리고 작품의 제목과 URL 주소를 알려주세요. (만약
+ 링크할 작품이 여러개라도, 한 이메일로 목록을 주시면 됩니다.)
+ uploaded_list: '게시한 작품은 다음을 포함합니다:'
+ invite_increase_notification:
+ html:
+ body:
+ other: 회원님께서 %{count} 개의 새 초대장을 받으셨으며 이를 사용해 AO3 아카이브에서 새로운 계정을 만들 수 있다는
+ 것을 알려드립니다. %{invitation_page_link}를 통해 친구를 초대할 수 있습니다.
+ invitation_page_link_text: Invitations (초대) 페이지
+ subject: "[%{app_name}] 새로운 초대장"
+ text:
+ body:
+ other: 회원님께서 %{count} 개의 새 초대장을 받으셨으며, 이를 통해 새로운 AO3 계정을 만들 수 있다는 것을 알려드립니다.
+ Invitations (초대) 페이지에서 친구를 초대할 수 있습니다 (%{invitation_page_url}).
+ invite_request_declined:
+ main:
+ other: 안타깝게도 회원님이 요청하신 추가 초청 코드 %{count} 개는 현재 발급해 드리기 어렵습니다.
+ reason: '회원님의 요청:'
+ subject: "[%{app_name}] 추가 초청 코드 요청이 거부되었습니다"
+ recipient_notification:
+ html:
+ collection: AO3 내 %{collection_link} 컬렉션에 회원님을 위한 선물 작품이 게시되었습니다!
+ no_collection: 회원님을 위한 선물 작품이 AO3에 게시되었습니다!
+ subject:
+ collection: "[%{app_name}][%{collection_title}] %{collection_title} 내 회원님을
+ 위한 선물 작품이 있습니다"
+ no_collection: "[%{app_name}] 회원님을 위한 선물 작품이 있습니다"
+ text:
+ collection: AO3 내 "%{collection_title}" 컬렉션(%{collection_url})에 회원님을 위한 선물
+ 작품이 게시되었습니다!
+ signup_notification:
+ activate:
+ html: "%{activate_account_link} 해주세요."
+ text: '당신의 계정을 활성화하려면 다음 링크를 눌러주세요: %{activate_account_url}'
+ activate_your_account: 계정 활성화를 위해 링크를 눌러주세요
+ admin_posts: AO3 뉴스
+ bye: 아카이브 사용하시면서 즐겁기 바랍니다.
+ contact_support: 서포트 팀과 연락하기
+ faq: FAQ
+ features:
+ html: 일단 계정이 활성화된 후, 회원님은 팬작품을 게시할 수 있고, 이메일을 통해 좋아하는 작가나 작품의 업데이트를 알림받을 수
+ 있고, 사이트 사용화면을 마음에 들게 꾸밀 수 있고, 히스토리를 통해 과거에 열람한 아카이브의 작품을 확인할 수 있고, 그 밖에도
+ 많은 것을 할 수 있습니다.
+ text: 일단 계정이 활성화된 후, 회원님은 팬작품을 게시할 수 있고, 이메일을 통해 좋아하는 작가나 작품의 업데이트를 알림받을 수
+ 있고, 사이트 사용화면을 마음에 들게 꾸밀 수 있고, 히스토리를 통해 과거에 열람한 아카이브의 작품을 확인할 수 있고, 그 밖에도
+ 많은 것을 할 수 있습니다.
+ information:
+ html: "%{faq_link}에는 아카이브를 어떻게 활용할 수 있는지 도와드리는 정보가 가득합니다. 사이트의 발달 상황에 대한 가장
+ 최신의 소식은 %{admin_posts_link}를 통해 접할 수 있습니다. 만약 더 많은 도움이 필요하신 경우 또는 질문이나 제안이
+ 있으실 경우에는 %{contact_support_link}를 이용해 주세요. 언제나 도와드릴 준비가 되어있습니다."
+ text: " %{faq_url}에는 아카이브를 어떻게 활용할 수 있는지 도와드리는 정보가 가득합니다. 사이트의 발달 상황에 대한 가장
+ 최신 소식, AO3 뉴스는 %{admin_posts_url}를 통해 접할 수 있습니다. 만약 더 많은 도움이 필요하신 경우 또는
+ 질문이나 제안이 있으실 경우에는 %{contact_support_url}를 이용해 주세요. 언제나 도와드릴 준비가 되어있습니다."
+ welcome: 우리만의 아카이브에 오신 것을 환영합니다, %{login}님!
diff --git a/config/locales/phrase-exports/lt.yml b/config/locales/phrase-exports/lt.yml
new file mode 100755
index 0000000..8e81ada
--- /dev/null
+++ b/config/locales/phrase-exports/lt.yml
@@ -0,0 +1,2 @@
+---
+lt: {}
diff --git a/config/locales/phrase-exports/lv.yml b/config/locales/phrase-exports/lv.yml
new file mode 100755
index 0000000..c16692e
--- /dev/null
+++ b/config/locales/phrase-exports/lv.yml
@@ -0,0 +1,288 @@
+---
+lv:
+ mailer:
+ general:
+ creation:
+ title_with_chapter_number: "%{title} %{position} nodaļa"
+ users:
+ mailer:
+ reset_password_instructions:
+ expiration: Ja Tu nedēļas laikā neizmantosi šo saiti, lai nomainītu paroli,
+ beigsies tās derīguma termiņš, un Tev vajadzēs to pieprasīt atkārtoti.
+ intro: 'Kāds ir pieprasījis paroles nomaiņu Tavam kontam. Tu vari nomainīt
+ sava konta paroli sekojot norādītajai saitei un ievadot savu jauno paroli:'
+ link_title: Mainīt manu paroli.
+ subject: "[%{app_name}] Atiestati savu paroli"
+ unrequested: Ja Tu nepieprasīji šo paroles nomaiņu, Tu vari ignorēt šo epastu
+ un Tava iepriekšējā parole turpinās darboties.
+ user_mailer:
+ admin_hidden_work_notification:
+ access: Kamēr Tavs darbs ir slēpts, Tu joprojām spēsi tam piekļūt, izmantojot
+ augstāk norādīto saiti, bet tas nebūs redzams Tavu darbu lapā, un tas nebūs
+ pieejams citiem AO3 lietotājiem.
+ check_email: Lūdzu, pārbaudi savu epasta adresi, ieskaitot spama sadaļu, jo
+ Pārkāpumu risināšanas komanda, iespējams, jau ir ar Tevi sazinājusies, lai
+ paskaidrotu, kāpēc Tavs darbs ir ticis paslēpts.
+ contact_abuse: sazinies ar Pārkāpumu risināšanas komandu
+ html:
+ help: Ja Tu neesi pārliecināts, kāpēc Tavs darbs ir ticis paslēpts un Tu neesi
+ saņēmis papildus informāciju, lūdzu, %{contact_abuse_link}.
+ hidden: Tavu darbu %{title} ir paslēpusi Pārkāpumu risināšanas komanda, un
+ tas vairs nav publiski pieejams.
+ tos_violation: Ja Tavs darbs tika paslēpts, jo tas pārkāpa AO3 %{tos_link},
+ Tev būs jānovērš pārkāpums. Ja tas netiks izdarīts, un darbs neatbildīs
+ Pakalpojumu sniegšanas noteikumiem, tas tiks dzēsts.
+ subject: "[%{app_name}] Tavu darbu ir paslēpusi Pārkāpumu risināšanas komanda"
+ text:
+ help: 'Ja Tu neesi pārliecināts par darba paslē''pšanas iemeslu un neesi saņēmis
+ papildus informāciju, lūdzu, sazinies ar Pārkāpumu risināšanas komandu:
+ %{contact_abuse_url}.'
+ hidden: Tavu darbu "%{title}" (%{work_url}) ir paslēpusi Pārkāpumu risināšanas
+ komanda, un tas vairs nav publiski pieejams.
+ tos_violation: Ja Tavs darbs tika paslēpts, jo tas neatbilst AO3 Pakalpojumu
+ sniegšanas noteikumiem (%{tos_url}), Tev nāksies veikt darba labojumus.
+ Ja darbu nepārveidosi, lai tas atbilstu Pakalpojumu sniegšanas noteikumiem.
+ Tavs darbs tiks dzēsts no AO3.
+ tos: Pakalpojumu sniegšanas noteikumi
+ anonymous_or_unrevealed_notification:
+ anonymous_info: Anonīmi darbi ir iekļauti birku sarakstos, bet ne Tavā publisko
+ darbu sarakstā. Darbā Tavs lietotājvārds tiks aizstāts ar "Anonīms" (Tulkošana).
+ anonymous_unrevealed_info: Kolekcijas uzturētāji var vēlāk padarīt Tavu darbu
+ par atklātu, bet atstāt to anonīmu. Cilvēki, kuri seko Tavam profilam, netiks
+ informēti par šīm izmaiņām. Kad tas tiek padarīts par atklātu, Tavs darbs
+ tiks iekļauts birku sarakstos, bet ne Tavu darbu sarakstos. Darba autora vārds
+ tiks mainīts uz ''Anonīms'' (Tulkošana).
+ changed_status:
+ anonymous:
+ html: "%{collection_link} kolekcijas uzturētāji ir padarījuši tavu darbu
+ %{work_link} anonīmu."
+ text: '"%{collection_title}" (%{collection_url}) kolekcijas uzturētāji ir
+ nomainījuši Tava darba "%{work_title}" (%{work_url}) statusu uz anonīmu.'
+ anonymous_unrevealed:
+ html: "%{collection_link} kolekcijas uzturētāji ir nomainījuši Tava darba
+ %{work_link} statusu uz anonīmu un slēptu."
+ text: '"%{collection_title}" (%{collection_url}) kolekcijas uzturētāji ir
+ nomainījuši Tava darba "%{work_title}" (%{work_url}) statusu uz anonīmu
+ un slēptu.'
+ unrevealed:
+ html: "%{collection_link} kolekcijas uzturētāji ir mainījuši Tava darba
+ %{work_link} statusu, un ir padarījuši to par slēptu."
+ text: '"%{collection_title}" (%{collection_url}) kolekcijas uzturētāji ir
+ nomainījuši Tava darba "%{work_title}" (%{work_url}) statusu uz slēptu.'
+ collection_items_link_text: Apstiprināto Kolekcijas vienumu (Tulkošana) lapa
+ do_not_want:
+ anonymous:
+ html: Ja Tu nevēlies, lai Tavs darbs ir anonīms, lūdzu apmeklē %{collection_items_link},
+ lai to noņemtu no šī saraksta.
+ text: 'Ja Tu nevēlies, lai Tavs darbs ir anonīms, apmeklē savu Apstirpināto
+ Kolekcijas Vienumu (Tulkošana) lapu, lai to noņemtu no šīs kolekcijas:
+ %{collection_items_url}'
+ anonymous_unrevealed:
+ html: Ja Tu nevēlies, lai Tavs darbs ir anonīms un slēpts, lūdzu, apmeklē
+ %{collection_items_link}, lai to noņemtu no šīs kolekcijas.
+ text: 'Ja Tu nevēlies, lai Tavs darbs ir anonīms un slēpts, apmeklē savu
+ Apstirpināto Kolekcijas Vienumu (Tulkošana) lapu, lai to noņemtu no šīs
+ kolekcijas: %{collection_items_url}'
+ unrevealed:
+ html: Ja Tu nevēlies, lai Tavs darbs ir slēpts, apmeklē savu %{collection_items_link},
+ lai to noņemtu no šīs kolekcijas.
+ text: 'Ja Tu nevēlies, lai Tavs darbs ir slēpts, apmeklē savu Apstirpināto
+ Kolekcijas Vienumu (Tulkošana) lapu, lai to noņemtu no šīs kolekcijas:
+ %{collection_items_url}'
+ faq_link_text: BUJ par kolekcijām
+ more_info:
+ html: Lai saņemtu papildus informāciju, apmeklē mūsu %{faq_link}.
+ text: 'Lai saņemtu papildus informāciju, apmeklē mūsu BUJ par kolekcijām:
+ %{faq_url}'
+ subject:
+ anonymous: "[%{app_name}] Tavs darbs tika padarīts anonīms"
+ anonymous_unrevealed: Tavs darbs [%{app_name}] tika padarīts par anonīmu un
+ slēptu.
+ unrevealed: Tavs darbs [%{app_name}] tika padarīts slēpts
+ unrevealed_info: Slēpti darbi nav iekļauti birku sarakstos un Tavu darbu sarakstā.
+ Ikviens, kurš izmanto saiti, lai apskatītu Tavu darbu, saņems paziņojumu,
+ ka tas pašlaik ir slēpts, un nespēs tam piekļūt.
+ archivist_added_to_collection_notification:
+ approved_collection_items_page: Apstiprinātās Kolekcijas (Tulkošana) lapa
+ archivist_notice: Kolekcijas uzturētāji darbojas viņu oficiālajā kapacitātē
+ kā Atvērto Durvju(Tulkošana) arhīvisti, tāpēc viņiem ir atļauts pievienot
+ Tavu darbu kolekcijai, arī tādos gadījumos, kad esi bloķējis ielūgumus uz
+ kolekcijām. Arhīvisti pievieno darbu kolekcijai tikai tad, ja ja tas tika
+ pievienots importētam arhīvam.
+ removal_instructions:
+ html: Ja Tu vēlies noņemt savu darbu no šīs kolekcijas, lūdzu, apmeklē %{approved_items_link}.
+ text: 'Ja Tu vēlies noņemt savu darbu no šīs kolekcijas, lūdzu, apmeklē savu
+ Apstiprināto kolekciju (Tulkošana) lapu: %{approved_items_url}.'
+ subject: "[%{app_name}][%{collection_title}]Open Doors(Atvērtās Durvis (Tulkošana))
+ arhīvists ir pievienojis Tavu darbu kolekcijai."
+ work_added:
+ html: "%{collection_link}Kolekcijas uzturētāji ir pievienojuši Tavu darbu
+ %{work_link} viņu kolekcijai!"
+ text: '"%{collection_title}" (%{collection_url})Kolekcijas uzturētāji ir pievienojuši
+ Tavu darbu "%{work_title}" (%{work_url}) viņu kolekcijai!'
+ challenge_assignment_notification:
+ any: Jebkurš un pēc izvēles
+ assignment:
+ html: Tev ir iedots sekojošs pieprasījums AO3 %{link} izaicinājumā.
+ description: 'Apraksts:'
+ due: 'Šī uzdevuma izpildes termiņš ir:'
+ html:
+ footer: Tu esi saņēmis šo e-pastu, jo Tu pieteicies %{title} izaicinājumam.
+ Lai uzzinātu vairāk par šo izaicinājumu un, lai saņemtu moderatoru kontaktinformāciju,
+ lūdzu, apmeklē %{footer_link}.
+ footer_link: izaicinājuma profila mājaslapu
+ look_up: Tu vari aplūkot uzdevumu Savā %{link}.
+ look_up_link: Assignments (Tulkošana) lapā
+ optional_tags: 'Tagi pēc izvēles:'
+ prompts: 'Prompti:'
+ prompt_url: 'Promptu URL:'
+ recipient: 'Saņēmējs:'
+ recipient_missing: 'Neviens: lūdz palīdzību moderatoram.'
+ subject: "[%{app_name}][%{collection_title}]Tavs uzdevums!"
+ text:
+ assignment: Tev ir piešķirts sekojošs pieprasījums "%{collection_title}" AO3
+ izaicinājumā(%{collection_url})!
+ footer: Tu esi saņēmis šo e-pastu, jo Tu pieteicies %{title} izaicinājumam
+ (%{url}). Lai uzzinātu vairāk par šo izaicinājumu un, lai saņemtu moderatoru
+ kontaktinformāciju, lūdzu, apmeklē %{profile_url}.
+ look_up: Tu vari aplūkot savu izaicinājumu Savā Assignments (Tulkošana) lapā
+ %{link}.
+ change_email:
+ changed:
+ html: "%{login}, Epasta adrese, kura ir asociēta ar Tavu profilu, ir mainīta
+ uz %{email}"
+ text: Epasta adrese %{login}, kura ir asociēta ar Tavu profilu, ir nomainīta
+ uz %{email}
+ subject: "[%{app_name}] Epasta adrese ir mainīta"
+ claim_notification:
+ access:
+ contact_support: sazinies ar AO3 Support (Atbalsts)
+ html: Atkarībā no arhīva Tavi darbi var būt importēti un tos var redzēt tikai
+ reģistrēti lietotāji (Lai paslēptu tos no Google meklētāja). Ja tas ir tā,
+ darbi būs pieejami tikai reģistrētiem lietotājiem, izņemot gadījumus, kad
+ Tu izvēlies tos padarīt redzamus citiem. Lai saņemtu palīdzību šīs darbības
+ veikšanai vai savu darbu dzēšanai, lūdzu sazinies ar %{contact_support_link}.
+ text: Atkarībā no arhīva Tavi darbi var būt importēti un tos var redzēt tikai
+ reģistrēti lietotāji (Lai paslēptu tos no Google meklētāja). Ja tas ir tā,
+ darbi būs pieejami tikai reģistrētiem lietotājiem, izņemot gadījumus, kad
+ Tu izvēlies tos padarīt redzamus citiem. Lai saņemtu palīdzību šīs darbības
+ veikšanai vai savu darbu dzēšanai, lūdzu sazinies ar user_mailer.claim_notification.access.contact_support
+ Sazinies ar AO3 Support(Atbalsts)%{support_url}.
+ email_tips: Ja Tu sazinies ar Mums, lūdzu pievieno e-pasta adreses no @transformativeworks.org
+ uz savu drošo kontaktu sarakstu un pārbaudi savu spamu, lai redzētu Mūsu atbildi.
+ introduction:
+ ao3_name: Archive Of Our Own-AO3 (Tulkošana) (Mūsu pašu arhīvs)
+ html: Tu esi saņēmis šo e-pastu, jo Tavi darbi ir iekļauti fanworks arhīvā,
+ kurš tika importēts no %{open_doors_name_link} uz %{app_link}. Šī e-pasta
+ adrese ir saistīta ar to, kas ir reģistrēta importētajā arhīvā, asociētie
+ fanu darbi (minēti lejā) automātiski tika pievienoti Tavam AO3 kontam.
+ open_doors_name: "(Atvērtās Durvis (Tulkošana)"
+ text: Tu esi saņēmis šo e-pastu, jo Tavi darbi ir iekļauti fanworks arhīvā,
+ kurš tika importēts no Atvērtajām Durvīm(%{open_doors_url}) uz AO3 arhīvu.%{app_url}.
+ . Šī e-pasta adrese ir saistīta ar to, kas ir reģistrēta importētajā arhīvā,
+ asociētie fanu darbi( minēti lejā) automātiski tika pievienoti Tavam AO3
+ kontam.
+ mistake:
+ contact_open_doors: sazinies ar Atvērtajām Durvīm (Open Doors)
+ html: Ja šī ir kļūda, un šie nav Tavi darbi, lūdzu, neizdēs tos! Lūdzu, sazinies
+ ar %{contact_open_doors_link} un Mēs to nokārtosim.
+ text: Ja šī ir kļūda, un šie nav Tavi darbi, lūdzu, neizdēs tos! Lūdzu, sazinies
+ ar (%{open_doors_url}) un Mēs to nokārtosim
+ more_info:
+ ao3_news: AO3 ziņas
+ contact_support: Sazinies ar AO3 Support (AO3 Atbalsts)
+ faq_page: BUJ lapa
+ html: Tu vari izlasīt paziņojumus par nesenām arhīva pārcelšanām %{ao3_news_link},
+ un atrast papildus informāciju Open Doors (Atvērtās Durvis) %{faq_page_link}
+ vai%{tutorial_page_link}. Ja ir kādi jautājumi, kas nav atbildēti FAQ apmācībā,
+ vai šajā e-pastā, lūdzu %{contact_support_link}.
+ text: Tu vari izlasīt paziņojumus par nesenām arhīva pārcelšanām AO3 Ziņās
+ (%{news_url}), un atrast papildus informāciju Open Doors (Atvērtās Durvis)(%{open_doors_faq_url})
+ vai apmācības lapā (%{open_doors_tutorial_url}). Ja ir kādi jautājumi, kas
+ nav atbildēti FAQ apmācība, vai šajā e-pastā, lūdzu%{support_url}.
+ tutorial_page: apmācības lapa
+ other_works:
+ contact_open_doors: sazinies ar Atvērtajām Durvīm
+ html: Ja ir bijuši citi darbi importētajā arhīvā zem e-pasta adreses, kurai
+ Tu vairs nevari piekļūt, lūdzu, sazinies %{contact_open_doors_link} ar jebkādu
+ informāciju, kas varētu palīdzēt vertificēt Tavu identitāti.
+ text: Ja ir bijuši citi darbi importētajā arhīvā zem e-pasta adreses, kurai
+ Tu vairs nevari piekļūt, lūdzu, sazinies ar jebkādu informāciju, kas varētu
+ palīdzēt vertificēt Tavu identitāti.
+ questions:
+ contact_support: sazinies ar AO3 Atbalstu
+ html: Citām nepieciešamībām, lūdzu,%{contact_support_link}.
+ text: Citām nepieciešamībām, lūdzu,%{support_url}.
+ redirects:
+ html: Lai saglabātu rec (rekomendāciju) sarakstu un grāmatzīmes, importēto
+ arhīvu mājaslapu adreses var norādit uz šo darbu importētajām kopijām uz
+ noteiktu laiku( pārbaudi sava arhīva paziņojumu, lai pārliecinātos). Ja
+ Tu jau esi lejupielādējis šo darbu kopiju un %{negation} izmantoji URL funkciju,
+ būs divas tāda paša darba kopijas AO3.
+ subject: "[%{app_name}] Darbi ir augšupielādēti"
+ update_redirect:
+ contact_open_doors: Sazinies ar Atvērtajām Durvīm
+ html: Ja Tu vēlies, lai Open Doors atjauninātu novirzījumu, lai tas norādītu
+ uz jau eksistējošu darbu, lūdzu,%{contact_open_doors_link} izdzēs importēto
+ kopiju ar savu AO3 vārdu, sava konta vārdu importētajā arhīvā un nosaukumu
+ un tā fanu darba URL, uz kuru tu gribi novirzīt. (Ja ir vairāki darbi, kurus
+ Tu vēlies novirzīt vai mainīt novirzījumu, Tu vari to pierakstīt vienā e-pastā.)
+ text: Ja Tu vēlies, lai Open Doors atjauninātu novirzījumu , lai tas norādītu
+ uz jau eksistējošu darbu un sazināties ar Open Doors %{open_doors_url} ,
+ lūdzu, izdzēs importēto kopiju ar savu AO3 vārdu, sava konta vārdu importētajā
+ arhīvā un nosaukumu un tā fanu darba URL, uz kuru tu gribi novirzīt. (Ja
+ ir vairāki darbi, kurus Tu vēlies novirzīt vai mainīt novirzījumu, Tu vari
+ to pierakstīt vienā e-pastā.)
+ works_by: 'Šie darbi tika rakstīti izmantojot šo e-pasta adresi: %{email}'
+ work_info:
+ html: "%{work_link} (%{fandom})"
+ text:
+ no_fandom: "- %{work_title} %{work_url}"
+ with_fandom: "- %{work_title} %{work_url} (%{fandom})"
+ creatorship_notification_archivist:
+ explanation: Viņi izmanto savas oficiālās iespējas kā Open Doors (Translation)
+ (Atvērtās Durvis (Tulkošana)), viņiem ir atļauts Tevi pievienot bez uzaicinājuma
+ arī tad, ja esi bloķējis ko-autora funkciju.
+ html:
+ creation: "%{creation_link} %{pseud_links}"
+ edit_chapter: Mainīt vai labot nodaļu
+ edit_series: Mainīt vai labot stāstu sērijas
+ edit_work: mainīt vai labot darbu.
+ remove_chapter: Ja Tu es pievienots klūdas dēļ vai nevēlies būt atzīmēts kā
+ autors, Tu vari noņemt sevi kā ko-autoru.%{edit_chapter_link}
+ remove_series: Ja Tu es pievienots klūdas dēļ, vai nevēlies būt atzīmēts kā
+ autors, Tu vari %{edit_series_link} noņemt sevi kā ko-autoru.
+ remove_work: Ja Tu es pievienots klūdas dēļ, vai nevēlies būt atzīmēts kā
+ autors, Tu vari %{edit_work_link} noņemt sevi kā ko-autoru.
+ intro_chapter: 'Lietotājs %{archivist} ir Tevi %{pseud} pievienojis kā ko- autoru
+ sekojošajā nodaļā:'
+ intro_series: 'Lietotājs %{archivist} ir Tevi pievienojis %{pseud} kā ko- autoru
+ sekojošajās stāstu sērijās:'
+ intro_work: 'Lietotājs %{archivist} ir pievienojis Tavu pseud %{pseud} kā ko-autoru
+ sekojošajā darbā:'
+ subject: "[%{app_name}] Arhīvista ko -autora (co-creator) ziņa"
+ text:
+ creation: "%{title} (%{url})%{pseuds}"
+ remove_chapter: Ja Tu es pievienots klūdas dēļ, vai nevēlies būt atzīmēts
+ kā autors, Tu vari labot nodaļu noņemt sevi kā ko-autoru.%{url}
+ remove_series: Ja Tu es pievienots klūdas dēļ, vai nevēlies būt atzīmēts kā
+ autors, Tu vari labot stāstu sērijas un noņemt sevi kā ko-autoru.%{url}
+ remove_work: 'Ja Tu es pievienots klūdas dēļ vai nevēlies būt atzīmēts kā
+ autors, Tu vari labot darbu un noņemt sevi kā ko-autoru: %{url}'
+ creatorship_request:
+ html:
+ creation: "%{creation_link} autors/i %{pseud_links}"
+ instructions: Tu vari apstiprināt vai noraidīt uzaicinājumu savā %{page_name}
+ lapā.
+ page_name: Līdzradītāja Uzaicinājums (Tulkošana)
+ intro_chapter: 'Lietotājs %{inviting_user} ir uzaicinājis Tavu pseidonīmu %{pseud}
+ norādīt kā līdzradītāju sekojošajai nodaļai:'
+ intro_series: 'Lietotājs %{inviting_user} ir uzaicinājis Tavu pseidonīmu %{pseud}
+ norādīt kā līdzradītāju sekojošajai sērijai:'
+ intro_work: 'Lietotājs %{inviting_user} ir uzaicinājis Tavu pseidonīmu %{pseud}
+ norādīt kā līdzradītāju sekojošajam darbam:'
+ subject: "[%{app_name}] Līdzradītāja pieprasījums"
+ text:
+ creation: Autora %{pseuds} %{title} (%{url})
+ instructions: 'Tu vari apstiprināt vai noraidīt uzaicinājumu savā Līdzradītāja
+ Uzaicinājumu (Tulkošana) lapā: %{url}'
diff --git a/config/locales/phrase-exports/mk.yml b/config/locales/phrase-exports/mk.yml
new file mode 100755
index 0000000..a674574
--- /dev/null
+++ b/config/locales/phrase-exports/mk.yml
@@ -0,0 +1,2 @@
+---
+mk: {}
diff --git a/config/locales/phrase-exports/mr.yml b/config/locales/phrase-exports/mr.yml
new file mode 100755
index 0000000..4bbfc71
--- /dev/null
+++ b/config/locales/phrase-exports/mr.yml
@@ -0,0 +1,533 @@
+---
+mr:
+ activerecord:
+ attributes:
+ archive_warning:
+ name_with_colon:
+ one: 'इशारा:'
+ other: 'इशारे:'
+ category:
+ name_with_colon:
+ one: 'प्रकार:'
+ other: 'प्रकार:'
+ character:
+ name_with_colon:
+ one: 'व्यक्तिरेखा:'
+ other: 'व्यक्तिरेखा:'
+ fandom:
+ name_with_colon:
+ one: 'रसिकगट:'
+ other: 'रसिकगट:'
+ freeform:
+ name_with_colon:
+ one: 'अतिरिक्त टाचणखूण:'
+ other: 'अतिरिक्त टाचणखूणा:'
+ rating:
+ name_with_colon: 'गुणांकन:'
+ relationship:
+ name_with_colon:
+ one: 'नाते:'
+ other: 'नाती:'
+ work:
+ chapter_total_display: प्रकरण
+ summary: सारांश
+ models:
+ archive_warning:
+ one: इशारा
+ other: इशारे
+ category:
+ one: प्रकार
+ other: प्रकार
+ chapter:
+ one: प्रकरण
+ other: प्रकरण
+ character:
+ one: व्यक्तिरेखा
+ other: व्यक्तिरेखा
+ fandom:
+ one: रसिकगट
+ other: रसिकगट
+ freeform:
+ one: अतिरिक्त टाचणखूण
+ other: अतिरिक्त टाचणखूणा
+ rating:
+ one: गुणांकन
+ other: गुणांकन
+ relationship:
+ one: नाते
+ other: नाती
+ series:
+ one: मालिका
+ other: मालिका
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ one: एक पाहुणा
+ other: "%{count} पाहुणे"
+ left_kudos:
+ html:
+ one: "%{givers_list} यांनी पुढील कार्यावर टाळ्या दिल्या आहेत %{commentable_link}."
+ other: "%{givers_list} यांनी पुढील कार्यावर टाळ्या दिल्या आहेत %{commentable_link}."
+ text:
+ one: "%{givers_list} यांनी पुढील कार्यावर टाळ्या दिल्या आहेत %{commentable_title}
+ (%{commentable_url})."
+ other: "%{givers_list} यांनी पुढील कार्यावर टाळ्या दिल्या आहेत %{commentable_title}
+ (%{commentable_url})."
+ single_guest:
+ giver: एक पाहुणा
+ html: "%{commentable_link} वर %{giver} या पाहुण्याने टाळ्या दिल्या आहेत."
+ text: "%{commentable_title} (%{commentable_url}) वर पाहुण्यांनी टाळ्या दिल्या
+ आहेत"
+ subject: "[%{app_name}] आपल्याला टाळ्या प्राप्त झाल्या आहेत!"
+ mailer:
+ general:
+ closing:
+ formal: मनःपूर्वक,
+ informal: शुभेच्छा,
+ creation:
+ link_with_word_count: "%{creation_link} (%{word_count})"
+ title_with_chapter_number: "%{title} चं %{position} प्रकरण"
+ title_with_word_count: '"%{creation_title}" (%{word_count})'
+ word_count:
+ one: "%{count} शब्द"
+ other: "%{count} शब्द"
+ footer:
+ general:
+ about:
+ html: AO3 हे एक रसिक-चलित आणि रसिक-समर्थित संग्रह आहे जो %{donate_link}वर
+ अवलंबून आहे.
+ text: AO3 हे रसिक-चलित आणि रसिक-समर्थित संग्रह आहे जो आपल्या देणग्याः
+ %{donate_url} यांवर अवलंबून आहे.
+ html:
+ donate_link_text: आपल्या देणग्यां
+ support_link_text: समिती-संवाद समितीस संपर्क साधा
+ unwanted_email:
+ html: जर आपल्याला हा संदेश चुकुन पाठवला गेला असेल, तर कृपया %{support_link}.
+ text: जर आपल्याला हा संदेश चुकुन प्राप्त झाला असेल, तर कृपया समिती-संवाद
+ समितीस संपर्क साधा %{support_url}.
+ sent_at: "%{sent_at} ला पाठवलं गेलं."
+ greeting:
+ formal_html: प्रिय %{name},
+ informal:
+ addressed_html: नमस्कार, %{name}!
+ unaddressed: नमस्कार!
+ introductory: Archive of Our Own – AO3 (आमचा स्वतःचा संग्रह) तर्फे नमस्कार!
+ metadata_label_indicator: ":"
+ signature:
+ abuse_team: AO3 नियम आणि तक्रारनिवारण समिती
+ app_short_name: AO3
+ open_doors: The Open Doors (रसिक मुक्तद्वार समिती)
+ parent_org: Organization for Transformative Works – OTW (परिवर्तनात्मक रसिक-कला
+ मंडळी)
+ support: AO3 समिती-संवाद समिती
+ users:
+ mailer:
+ reset_password_instructions:
+ expiration: जर आपण ही दूवा वापरुन पुढच्या एका आठवड्यात आपले संकेतशब्द बदलले
+ नाही, तर विनंती कालबाह्य होईल, आणि आपल्याला एका नव्या दूव्याची विनंती करावी
+ लागेल.
+ intro: 'कोणीतरी आपल्या खात्याचा संकेतशब्द पुनःप्रस्थापित करण्याची विनंती केली
+ आहे. आपण आपल्या खात्याचा संकेतशब्द खाली दिलेल्या दूव्याचे अनुसरण करून मग
+ आपला नवा संकेतशब्द प्रविष्ट करून बदलू शकता:'
+ link_title: माझा संकेतशब्द बदला
+ subject: "[%{app_name}] आपला संकेतशब्द पुनःप्रस्थापित करा"
+ unrequested: जर आपण संकेतशब्द बदलण्याची विनंती नसेल केली, तर आपण ह्या ई-मेल
+ कडे दुर्लक्ष करू शकता आणि तुमचा पूर्वीचा संकेतशब्द काम करत राहील.
+ user_mailer:
+ admin_deleted_work_notification:
+ bye: आपल्या संदर्भासाठी आपल्या कार्याची कॉपी इथे संलग्न केलेली आहे.
+ contact_abuse: नियम आणि तक्रारनिवारण समिती ला संपर्क करा.
+ deleted:
+ html: आपले कार्य %{title} AO3 वरून एका साईट व्यवस्थापकाने काढून टाकले आहे.
+ text: आपले कार्य "%{title}" AO3 वरून एका साईट व्यवस्थापकाने काढून टाकले आहे.
+ html:
+ tos_violation: जर शक्यता असेल की आपल्या कार्याने AO3 च्या नियम आणि ध्येयधोरणेचे
+ उल्लंघन केले असेल तर कृपया %{contact_abuse_link} ला भेट द्या.
+ import_project:
+ html: जर आपले कार्य आयात केलेल्या प्रकल्पाचा भाग असेल जे Open Doors (रसिकमुक्तद्वार
+ प्रकल्प) सांभाळतात, तर कृपया इतर प्रश्नांच्या समाधानाकरीता %{opendoors_link}
+ ला भेट द्या.
+ text: जर आपले कार्य आयात केलेल्या प्रकल्पाचा भाग असेल जे Open Doors (रसिकमुक्तद्वार
+ प्रकल्प) सांभाळतात, तर कृपया इतर प्रश्नांच्या समाधानाकरीता Open Doors (%{opendoors_link})
+ शी संपर्क करा.
+ opendoors: रसिकमुक्तद्वार प्रकल्पाला संपर्क करा.
+ subject: "[%{app_name}] आपले कार्य एका व्यवस्थापकाने काढून टाकले आहे."
+ text:
+ tos_violation: जर शक्यता असेल की आपल्या कार्याने AO3 च्या नियम आणि ध्येयधोरणेचे
+ उल्लंघन केले असेल तर कृपया आमच्या नियम आणि तक्रारनिवारण समिती (%{contact_abuse_url})
+ ला संपर्क करा.
+ admin_hidden_work_notification:
+ access: तुमचे कार्य लपलेले असून, तुम्ही वर दिलेल्या दूवा द्वारे त्यात प्रवेश
+ करण्यात सक्षम असाल, परंतु ते तुमच्या कार्य पृष्ठावर सूचीबद्ध केले जाणार नाही
+ आणि ते AO3 च्या इतर वापरकर्त्यांसाठी उपलब्ध होणार नाही.
+ check_email: कृपया तुमचे ईमेल तपासा, तुमच्या स्पॅम फोल्डरसह, कारण तुमचे काम
+ का लपवले गेले हे स्पष्ट करण्यासाठी नियम आणि तक्रारनिवारण समिती ने तुमच्याशी
+ आधीच संपर्क साधला असेल.
+ contact_abuse: नियम आणि तक्रारनिवारण समिती ला संपर्क करा
+ html:
+ help: तुमचे काम का लपवले गेले आहे ह्याच्या बद्दल तुम्ही अनिश्चित असल्यास,
+ आणि तुम्हाला या प्रकरणाशी संबंधित अधिक संप्रेषण प्राप्त झाले नसेल, तर कृपया
+ थेट %{contact_abuse_link}.
+ hidden: तुमचे कार्य %{title} नियम आणि तक्रारनिवारण समिती ने लपवले आहे आणि
+ ते यापुढे सार्वजनिकपणे उपलब्ध असणार नाही.
+ tos_violation: AO3 च्या %{tos_link} चे उल्लंघन केल्यामुळे तुमचे कार्य लपवले
+ गेले असल्यास, तुम्हाला उल्लंघन दुरुस्त करण्यासाठी कारवाई करणे आवश्यक असेल.
+ तुम्ही तुमचे काम नियम आणि ध्येयधोरणे चे पालन करण्यात अयशस्वी झाल्यास तुमचे
+ काम AO3 मधून हटवले जाऊ शकते.
+ subject: "[%{app_name}] तुमचे कार्य नियम आणि तक्रारनिवारण समिती ने लपवले आहे"
+ text:
+ help: 'तुमचे काम का लपवले गेले आहे ह्याच्या बद्दल तुम्ही अनिश्चित असल्यास,
+ आणि तुम्हाला या प्रकरणाशी संबंधित अधिक संप्रेषण प्राप्त झाले नसेल, तर कृपया
+ थेट नियम आणि तक्रारनिवारण समिती ला संपर्क करा: %{contact_abuse_url}.'
+ hidden: तुमचे कार्य "%{title}" (%{work_url}) नियम आणि तक्रारनिवारण समिती ने
+ लपवले आहे आणि ते यापुढे सार्वजनिकपणे उपलब्ध असणार नाही.
+ tos_violation: AO3 च्या नियम आणि ध्येयधोरणे (%{tos_url}) चे उल्लंघन केल्यामुळे
+ तुमचे काम लपवले गेले असल्यास, तुम्हाला उल्लंघन दुरुस्त करण्यासाठी कारवाई
+ करणे आवश्यक असेल. तुम्ही तुमचे काम नियम आणि ध्येयधोरणे चे पालन करण्यात अयशस्वी
+ झाल्यास तुमचे काम AO3 मधून हटवले जाऊ शकते.
+ tos: नियम आणि ध्येयधोरणे
+ anonymous_or_unrevealed_notification:
+ anonymous_info: निनावी कार्ये टाचणखुणांच्या यादीत सामील केली जातात पण आपल्या
+ कार्यांच्या पृष्ठावर दिसत नाहीत. त्या कार्यावर आपले वापरकर्तानाम “Anonymous”
+ (निनावी) म्हणून पुनःस्थापित केले जाईल.
+ anonymous_unrevealed_info: संकलनाचे व्यवस्थापक काही काळा-नंतर आपले कार्य उघड
+ करूनही निनावी ठेऊ शकतात. आपल्या वर्गणीदारांना ह्या परिवर्तनाबद्दल कळवले जाणार
+ नाही. एकदा आपले कार्य उघड केले गेले कि ते टाचणखुणांच्या यादीत सामील केले जाईल
+ पण आपल्या कार्यांच्या पृष्ठावर दिसणार नाही. त्या कार्यावर आपले वापरकर्तानाम
+ “Anonymous” (निनावी) म्हणून पुनःस्थापित केले जाईल.
+ changed_status:
+ anonymous:
+ html: "%{collection_link} संकलनाच्या व्यवस्थापकांनी आपल्या कार्याची %{work_link}
+ स्थिती निनावी केली आहे."
+ text: '"%{collection_title}" (%{collection_url}) संकलनाच्या व्यवस्थापकांनी
+ आपल्या कार्याची "%{work_title}" (%{work_url}) स्थिती निनावी केली आहे.'
+ anonymous_unrevealed:
+ html: "%{collection_link} संकलनाच्या व्यवस्थापकांनी आपल्या कार्याची %{work_link}
+ स्थिती निनावी आणि अदृश्य केली आहे."
+ text: '"%{collection_title}" (%{collection_url}) संकलनाच्या व्यवस्थापकांनी
+ आपल्या कार्याची "%{work_title}" (%{work_url}) स्थिती निनावी आणि अदृश्य
+ केली आहे.'
+ unrevealed:
+ html: "%{collection_link} संकलनाच्या व्यवस्थापकांनी आपल्या कार्याची %{work_link}
+ स्थिती अदृश्य केली आहे."
+ text: '"%{collection_title}" (%{collection_url}) संकलनाच्या व्यवस्थापकांनी
+ आपल्या कार्याची "%{work_title}" (%{work_url}) स्थिती अदृश्य केली आहे.'
+ collection_items_link_text: Approved Collection Items (स्वीकृत संकलन बाब) पृष्ठ
+ do_not_want:
+ anonymous:
+ html: जर आपल्याला आपले कार्य निनावी करायचे नसेल तर कृपया %{collection_items_link}
+ ला भेट द्या आणि तुमचे कार्य ह्या संकलनातून काढा.
+ text: 'जर आपल्याला आपले कार्य निनावी करायचे नसेल तर कृपया Approved Collection
+ Items (स्वीकृत संकलन बाब) पृष्ठाला भेट द्या आणि आपले कार्य ह्या संकलनातून
+ काढा: %{collection_items_url}'
+ anonymous_unrevealed:
+ html: जर आपल्याला आपले कार्य निनावी आणि अदृश्य करायचे नसेल तर कृपया %{collection_items_link}
+ ला भेट द्या आणि आपले कार्य ह्या संकलनातून काढा.
+ text: 'जर आपल्याला आपले कार्य निनावी आणि अदृश्य करायचे नसेल तर कृपया Approved
+ Collection Items (स्वीकृत संकलन बाब) पृष्ठाला भेट द्या आणि आपले कार्य
+ ह्या संकलनातून काढा: %{collection_items_url}'
+ unrevealed:
+ html: जर आपल्याला आपले कार्य अदृश्य करायचे नसेल तर कृपया %{collection_items_link}
+ ला भेट द्या आणि आपले कार्य ह्या संकलनातून काढा.
+ text: 'जर आपल्याला आपले कार्य अदृश्य करायचे नसेल तर कृपया Approved Collection
+ Items (स्वीकृत संकलन बाब) पृष्ठाला भेट द्या आणि आपले कार्य ह्या संकलनातून
+ काढा: %{collection_items_url}'
+ faq_link_text: संकलन वाविप्र
+ more_info:
+ html: अधिक माहिती साठी आमच्या %{faq_link} ला भेट द्या.
+ text: 'अधिक माहिती साठी आमच्या संकलन वाविप्रला भेट द्या: %{faq_url}'
+ subject:
+ anonymous: "[%{app_name}] आपले कार्य निनावी केले गेले आहे."
+ anonymous_unrevealed: "[%{app_name}] आपले कार्य निनावी आणि अदृश्य केले गेले
+ आहे"
+ unrevealed: "[%{app_name}] आपले कार्य अदृश्य केले गेले आहे"
+ unrevealed_info: अदृश्य कार्य टाचणखुणांच्या यादीत किंवा आपल्या कार्यांच्या पृष्ठावर
+ दिसत नाहीत. जर कोणी त्या कार्याची लिंक वापरली तर त्यांना सूचित केले जाईल कि
+ ते कार्य अदृश्य आहे, आणि त्यांना त्याचा मजकूर दिसणार नाही.
+ archivist_added_to_collection_notification:
+ approved_collection_items_page: Approved Collection Items (मान्य केलेल्या संग्रह
+ कृत्या) पृष्ठ
+ archivist_notice: जरी आपण संकलन आमंत्रणे अमान्य केले असतील तरीही संकलन व्यवस्थापक
+ त्यांच्या Open Doors रसिक-मुक्तद्वार प्रकल्प) संग्रहकाच्या अधिकृत भूमिकेत
+ काम करत असल्यामुळे, ते आपली कृती या संकलनेत सामील करू शकतात. संग्राहक एक कृती
+ संकलनेत फक्त तेव्हा सामील करतात जेव्हा ती कृती एका बाहेरून आणलेल्या संग्रहात
+ सादर केली गेली असेल.
+ removal_instructions:
+ html: जर आपण आपली कृती या संकलनेतून काढू इच्छिता, तर %{approved_items_link}
+ वर जा.
+ text: जर आपण आपली कृती या संकलनेतून काढू इच्छिता, तर Approved Collection Items
+ (मान्य केलेल्या संग्रह कृत्या) %{approved_items_url} पृष्ठावर जा.
+ subject: "[%{app_name}][%{collection_title}] Open Doors (रसिक-मुक्तद्वार प्रकल्प)
+ च्या एका संग्रहकाने आपली कृती एका संकलनात सामील केली आहे"
+ work_added:
+ html: "%{collection_link} च्या व्यवस्थापकांनी आपली कृती %{work_link} त्यांच्या
+ संकलनेत सामील केली आहे!"
+ text: '"%{collection_title}" %{collection_url} च्या व्यवस्थापकांनी आपली कृती
+ "%{work_title}" %{work_url} त्यांच्या संकलनेत सामील केली आहे!'
+ challenge_assignment_notification:
+ any: कोणतेही
+ assignment:
+ html: AO3 मधल्या %{link} आव्हाना मधून तुम्हाला ही विनंती सोपवली गेली आहे!
+ description: 'वर्णन:'
+ due: 'हे आव्हानासाठी लेखन द्यायची अपेक्षित वेळ:'
+ html:
+ footer: आपल्याला हा ई-मेल प्राप्त होत आहे कारण आपण %{title} आव्हानासाठी नोंद
+ केली आहे. ह्या आव्हानाबद्दल आणखी माहितीसाठी आणि संपादकांच्या संपर्क माहितीसाठी,
+ कृपया %{footer_link} ला भेट द्या.
+ footer_link: आव्हान खाते-रेखाचित्र पृष्ठ
+ look_up: आपण हे आव्हानासाठी लेखन %{link} इकडे बघू शकता.
+ look_up_link: आपले Assignments (आव्हानासाठी लेखन) पृष्ठ
+ optional_tags: 'ऐच्छिक टाचणखूणा:'
+ prompts: 'लेखनबीज:'
+ prompt_url: 'लेखन बीज URL:'
+ recipient: 'प्राप्तकरता:'
+ recipient_missing: 'कोणी नाही: मदतीसाठी एका संपादकाला संपर्क करा!'
+ subject: "[%{app_name}][%{collection_title}] आपले आव्हानासाठी लेखन!"
+ text:
+ assignment: AO3 मधल्या "%{collection_title}" (%{collection_url}) आव्हानामधून
+ आपल्याला ही विनंती सोपवली गेली आहे!
+ footer: आपल्याला हा ई-मेल प्राप्त होत आहे कारण आपण %{title} आव्हाना (%{url})
+ साठी नोंद केली आहे. ह्या आव्हानाबद्दल आणखी माहितीसाठी आणि संपादकांच्या संपर्क
+ माहितीसाठी, कृपया %{profile_url} ला भेट द्या.
+ look_up: आपण हे आव्हानासाठी लेखन आपल्या Assignments (आव्हानासाठी लेखन) पृष्ठावर
+ %{link} बघू शकता.
+ change_email:
+ changed:
+ html: "%{login}, आपल्या खात्याशी संबंधित ईमेल %{email} मध्ये बदलला गेला आहे"
+ text: "%{login}, आपल्या खात्याशी संबंधित ईमेल %{email} मध्ये बदलला गेला आहे"
+ subject: "[%{app_name}] ईमेल बदलले गेले"
+ claim_notification:
+ access:
+ contact_support: AO3 समिती-संवाद समितीशी संपर्क साधा
+ html: संग्रहावर अवलंबित, आपल्या कृती फक्त नोंदवलेल्या वापरकर्त्यांना उपलब्ध
+ केले असू शकतात (ज्यानेकरून ते गूगल मध्ये शोधात येणार नाहीत). असे जर असेल,
+ तर आपण जोपर्यंत सगळ्यांसाठी हे कृती उपलब्ध करायचा निर्णय घेत नाही, तोपर्यंत
+ ते फक्त लॉग ईन केलेल्या वापरकर्त्यांना वाचता येतील. आपल्या कृती अनिर्बंधीत
+ किंवा मुक्त करण्यात किंवा हटवण्यात मदत हवी असल्यास, कृपया %{contact_support_link}.
+ text: संग्रहावर अवलंबित, आपल्या कृती फक्त नोंदवलेल्या वापरकर्त्यांना उपलब्ध
+ केले असू शकतात (ज्यानेकरून ते गूगल मध्ये शोधात येणार नाहीत). असे जर असेल,
+ तर आपण जोपर्यंत सगळ्यांसाठी हे कृती उपलब्ध करायचा निर्णय घेत नाही, तोपर्यंत
+ ते फक्त लॉग ईन केलेल्या वापरकर्त्यांना वाचता येतील. आपल्या कृती अनिर्बंधीत
+ किंवा मुक्त करण्यात किंवा हटवण्यात मदत हवी असल्यास, कृपया समिती-संवाद समितीशी
+ इथे %{support_url} संपर्क साधा.
+ email_tips: आपण आमच्या संपर्कात असाल तर कृपया @transformativeworks.orgचे ईमेल
+ आयडी आपल्या safe contact (सुरक्षित संपर्का)च्या यादीत घाला आणि आपले स्पॅम
+ फोल्डर बघत ऱ्हावा, आमचे उत्तर तिथे आले असू शकते.
+ introduction:
+ ao3_name: Archive of Our Own – AO3 (आमचा स्वतःचा संग्रह)
+ html: हा ईमेल आपल्याला पाठवण्यात आला आहे कारण आपल्या कृती एका रसिककृती संग्रहात
+ सामील होत्या जो %{open_doors_name_link} तर्फे %{app_link} मध्ये आयात केले
+ गेले आहेत. ही ईमेल आयडी त्या संग्रहावर नोंदवलेल्या आयडीशी जोडलेली असल्यामुळे,
+ सर्व संबंधित (खाली दिलेल्या) रसिककृती आपोआप आपल्या AO3 खात्याला जमा झाले
+ आहेत.
+ open_doors_name: Open Door (रसिकमुक्तद्वार प्रकल्प)
+ text: 'हा ईमेल आपल्याला पाठवण्यात आला आहे कारण आपल्या कृती एका रसिककृती संग्रहात
+ सामील होत्या जो (%{open_doors_url}) तर्फे Archive of Our Own – AO3 (आमचा
+ स्वतःचा संग्रह): %{app_url} मध्ये आयात केला गेला आहे. ही ईमेल आयडी त्या
+ संग्रहावर नोंदवलेल्या आयडीशी जोडलीला असल्यामुळे, सर्व संबंधित (खाली दिलेल्या)
+ रसिककृती आपोआप आपल्या AO3 खात्याला जमा झाले आहेत.'
+ mistake:
+ contact_open_doors: रसिकमुक्तद्वार प्रकल्पाशी संपर्क साधा
+ html: जर इथे चूक झाली असेल आणि ह्या कृती आपल्या नसतील तर कृपया त्यांना हटवू
+ नका! फक्त %{contact_open_doors_link} आणि आम्ही त्याची काळजी घेऊ.
+ text: जर इथे चूक झाली असेल आणि ह्या आपल्या कृती नसतील तर कृपया त्या हटवू नका!
+ फक्त रसिकमुक्तद्वार प्रकल्पाशी संपर्क साधा (%{open_doors_url}) आणि आम्ही
+ त्याची काळजी घेऊ.
+ more_info:
+ ao3_news: AO3 बातम्या
+ contact_support: AO3 समिती-संवाद समितीशी संपर्क साधा
+ faq_page: वाविप्र पृष्ठ
+ html: अलीकडे झालेल्या संग्रह स्थलांतरांबद्दलच्या घोषणा आपण %{ao3_news_link}
+ खाली वाचू शकता, आणि अधिक माहिती रसिकमुक्तद्वार प्रकल्पच्या %{faq_page_link}
+ किंवा %{tutorial_page_link} वर सापडेल. वाविप्र, शिकवणी, किंवा या ईमेल मध्ये
+ आपल्या कुठल्यापण प्रश्नाचे उत्तर नाही मिळाले तर कृपया %{contact_support_link}.
+ text: अलीकडे झालेल्या संग्रह स्थलांतरांबद्दलच्या घोषणा आपण AO3 बातमी (%{news_url})
+ खाली वाचू शकता, आणि अधिक माहिती रसिकमुक्तद्वार प्रकल्पच्या वाविप्र पृष्ठ
+ (%{open_doors_faq_url}) किंवा शिकवणी पृष्ठा(%{open_doors_tutorial_url})
+ वर सापडेल. वाविप्र, शिकवणी किंवा, ह्या ईमेल मध्ये आपल्या प्रश्नाचे उत्तर
+ नाही मिळाले तर कृपया समिती-संवाद समितीशी इथे %{support_url} संपर्क साधा.
+ tutorial_page: शिकवणी पृष्ठ
+ other_works:
+ contact_open_doors: रसिकमुक्तद्वार प्रकल्पाशी संपर्क साधा
+ html: जर आयात केलेल्या संग्रहावर आपल्या कुठल्यातर दुसऱ्या ईमेल आयडीखाली इतर
+ कृती होत्या पण आपण त्या ईमेल आयडी मध्ये आता प्रवेश नाही घेऊ शकत, तर आपल्या
+ ओळखीची खात्री करता येईल अशी कोणतीही माहिती घेऊन %{contact_open_doors_link}.
+ text: जर आयात केलेल्या संग्रहावर आपल्या कुठल्यातर दुसऱ्या ईमेल आयडीखाली इतर
+ कृती होत्या पण आपण त्या ईमेल आयडी मध्ये आता प्रवेश नाही घेऊ शकत, तर आपल्या
+ ओळखीची खात्री करता येईल अशी कोणतीही माहिती घेऊन कृपया रसिकमुक्तद्वार प्रकल्पाशी
+ संपर्क साधा.
+ questions:
+ contact_support: AO3 समिती-संवाद समितीशी संपर्क साधा
+ html: इतर प्रश्नांसाठी कृपया %{contact_support_link}.
+ text: इतर प्रश्नांसाठी कृपया %{support_url} खाली AO3 समिती-संवाद समितीशी संपर्क
+ साधा.
+ redirects:
+ html: सूचक यादी व वाचनखुणा जपवण्यासाठी आयात केलेल्या संग्रहाच्या दुवा कदाचित
+ थोड्या वेळासाठी आयात केलेल्या प्रतिकडे नेहतील (याची खात्री करायला आपल्या
+ संग्रहाची घोषणा बघा). जर आपण हे कृती आधीच अपलोड केल्या असतील आणि import
+ from URL (दुवेवरून आयात करा) हे साधन वापरले असेल %{negation}, तर AO3वरती
+ एकाच कृतीच्या दोन प्रती असतील.
+ subject: "[%{app_name}] कृती अपलोड केल्या आहेत"
+ update_redirect:
+ contact_open_doors: रसिकमुक्तद्वार प्रकल्पाशी संपर्क साधा
+ html: जर Open Doors मार्गदर्शनाचे अद्यतन आपल्या आधीच्या कृतीला न्हेण्यासाठी
+ करावे हे आपल्याला हवे असेल तर कृपया आयात केलेली कृती हटवा, आणि आपल्या AO3
+ खात्याचे नाव, आयात केलेल्या संग्रहावरच्या आपल्या खात्याचे नाव, ज्या कृतीकडे
+ मार्गदर्शन जावे त्याचे नाव व त्याची दुवा, ह्या सर्व माहितीसकट %{contact_open_doors_link}.
+ (अनेक कृतींसाठी नवीन मार्गदर्शन हवे असल्यास आपण एका ईमेल मधे ती माहिती देऊ
+ शकता.)
+ text: जर Open Doors मार्गदर्शनाचे अद्यतन आपल्या आधीच्या कृतीला न्हेण्यासाठी
+ करावे हे आपल्याला हवे असेल तर कृपया आयात केलेली कृती हटवा, आणि आपल्या AO3
+ खात्याचे नाव, आयात केलेल्या संग्रहावरच्या आपल्या खात्याचे नाव, ज्या कृतीकडे
+ मार्गदर्शन जावे त्याचे नाव व त्याची दुवा, ह्या सर्व माहितीसकट रसिकमुक्तद्वार
+ प्रकल्पाशी इथे %{open_doors_url} संपर्क साधा. (अनेक कृतींसाठी नवीन मार्गदर्शन
+ हवे असल्यास आपण एका ईमेल मधे ती माहिती देऊ शकता.)
+ works_by: 'हे सर्व कृती ह्या ईमेल खाली लिहिल्या गेल्या होत्या: %{email}'
+ work_info:
+ html: "%{work_link} (%{fandom})"
+ text:
+ no_fandom: "- %{work_title} %{work_url}"
+ with_fandom: "- %{work_title} %{work_url} (%{fandom})"
+ collection_notification:
+ assignments_sent:
+ complete: सर्व आव्हानांसाठी लेखन आता पाठवले गेले आहेत.
+ subject: आव्हानासाठी लेखन पाठवले गेले आहे
+ html:
+ received_message: 'आपल्याला आपल्या संकलनाबद्दल एक निरोप मिळाला आहे %{collection_link}:'
+ text:
+ received_message: 'आपल्याला आपल्या संकलनाबद्दल एक निरोप मिळाला आहे "%{collection_title}"
+ (%{collection_url}):'
+ creatorship_notification:
+ explanation: आपण जेव्हा एखाद्या कार्याचे सह-निर्माते असता तेव्हा नवीन अध्यायासाठी
+ आपण आपोआपच सामिल केले जाल, आपले सह-निर्माता सेटिंग्ज काहीही असोत. ते कार्य
+ ज्या मालिकेचा भाग होईल तिथेही आपल्याला सामिल केले जाईल.
+ html:
+ creation: "%{pseud_links} च्या तर्फे %{creation_link}"
+ edit_chapter: अध्यायामध्ये बदल करा
+ edit_series: मालिकेमध्ये बदल करा
+ remove_chapter: जर आपले नाव चुकुन सामिल झाले असेल किंवा आपल्याला सह-निर्मात्या
+ म्हणून नमुद व्हायचे नसेल तर आपण %{edit_chapter_link} हे करून स्वतःला हटवू
+ शकता.
+ remove_series: जर आपले नाव चुकुन सामिल झाले असेल किंवा आपल्याला सह-निर्मात्या
+ म्हणून नमुद व्हावयाचे नसेल तर आपण %{edit_series_link} हे करून स्वतःला हटवू
+ शकता.
+ intro_chapter: 'ह्या वापरकर्त्याने %{adding_user} आपला स्युडोआयडी %{pseud} खालील
+ अध्यायासाठी सह-निर्माता म्हणून नोंदविला आहे:'
+ intro_series: 'ह्या वापरकर्त्याने %{adding_user} आपला स्युडोआयडी %{pseud} खालील
+ मालिकेसाठी सह-निर्माता म्हणून नोंदविला आहे:'
+ subject: "[%{app_name}] सह-निर्माता सूचना"
+ text:
+ creation: "%{pseuds} च्या तर्फे %{title} (%{url})"
+ remove_chapter: जर आपले नाव चुकुन सामिल झाले असेल किंवा आपल्याला सह-निर्मात्या
+ म्हणून नमुद व्हायचे नसेल तर आपण अध्यायामध्ये बदल करून स्वतःला हटवू शकताः
+ %{url}
+ remove_series: 'जर आपले नाव चुकुन सामिल झाले असेल किंवा आपल्याला सह-निर्मात्या
+ म्हणून नमुद व्हायचे नसेल तर आपण मालिकेमध्ये बदल करून स्वतःला हटवू शकता:
+ %{url}'
+ creatorship_notification_archivist:
+ explanation: ते Open Doors (रसिकमुक्तद्वार संकल्पाचे) संग्राहक म्हणून त्यांच्या
+ अधिकृत क्षमतेअंतर्गत काम करत असल्यामुळे, ते आपल्याला निमंत्रणाशिवाय सामील
+ करू शकतात, जरी तुम्ही सहनिर्मिती अक्षम केली असेल तरी.
+ html:
+ creation: "%{pseud_links} द्वारे %{creation_link}"
+ edit_chapter: अध्यायाचे संपादन करा
+ edit_series: लेखमाला संपादित करा
+ edit_work: कार्याचे संपादन करा
+ remove_chapter: जर आपल्याला चुकून सामील केले गेले असेल किंवा आपल्याला निर्माते
+ म्हणून सामील व्हायचे नसेल, तर आपण निर्माते म्हणून स्वतःला काढायला %{edit_chapter_link}
+ वर जाऊ शकता.
+ remove_series: जर आपल्याला चुकून सामील केले गेले असेल किंवा आपल्याला निर्माते
+ म्हणून सामील व्हायचे नसेल, तर आपण निर्माते म्हणून स्वतःला काढायला %{edit_series_link}
+ वर जाऊ शकता.
+ remove_work: जर आपल्याला चुकून सामील केले गेले असेल किंवा आपल्याला निर्माते
+ म्हणून सामील व्हायचे नसेल, तर आपण निर्माते म्हणून स्वतःला काढायला %{edit_work_link}
+ वर जाऊ शकता.
+ intro_chapter: "%{archivist} वापरकर्त्याने आपला स्यूडोआयडी %{pseud} पुढील प्रकरणात
+ सह-निर्माते म्हणून सामील केला आहे:"
+ intro_series: "%{archivist} वापरकर्त्याने आपला स्यूडोआयडी %{pseud} पुढील लेखमालेत
+ सह-निर्माते म्हणून सामील केले आहे:"
+ intro_work: 'वापरकर्ता %{archivist} यांनी आपला स्यूडोआयडी %{pseud} पुढील कार्यात
+ सह-निर्माते म्हणून सामील केला आहे:'
+ subject: "[%{app_name}] संग्राहक सह-निर्माती अधिसूचना"
+ text:
+ creation: "%{pseuds} द्वारे %{title} (%{url})"
+ remove_chapter: 'जर आपल्याला चुकून सामील केले गेले असेल किंवा आपल्याला निर्माते
+ म्हणून सामील व्हायचे नसेल, तर आपण निर्माते म्हणून स्वतःला काढायला प्रकरण
+ संपादित करू शकता: %{url}'
+ remove_series: 'जर आपल्याला चुकून सामील केले गेले असेल किंवा आपल्याला निर्माते
+ म्हणून सामील व्हायचे नसेल, तर आपण निर्माते म्हणून स्वतःला काढायला लेखमाला
+ संपादित करू शकता: %{url}'
+ remove_work: 'जर आपल्याला चुकून सामील केले गेले असेल किंवा आपल्याला निर्माते
+ म्हणून सामील व्हायचे नसेल, तर आपण निर्माते म्हणून स्वतःला काढायला कार्य
+ संपादित करू शकता: %{url}.'
+ creatorship_request:
+ html:
+ creation: "%{creation_link} द्वारे %{pseud_links}"
+ instructions: तुम्ही %{page_name} पृष्ठावर जाऊन ही विनंती स्वीकारू किंवा नाकारू
+ शकता.
+ page_name: Co-Creator Requests (सहनिर्माती विनंत्या)
+ intro_chapter: "%{inviting_user} वापरकर्त्याने पुढील प्रकरणावर आपल्या स्यूडोआयडी
+ %{pseud} ला सहनिर्माते नेमण्यासाठी आमंत्रण दिले आहे:"
+ intro_series: "%{inviting_user} वापरकर्त्याने पुढील लेखमालेवर आपल्या स्यूडोआयडी
+ %{pseud} ला सहनिर्माते नेमण्यासाठी आमंत्रण दिले आहे:"
+ intro_work: "%{inviting_user} वापरकर्त्याने पुढील कार्यावर आपल्या स्यूडोआयडी
+ %{pseud} ला सहनिर्माते नेमण्यासाठी आमंत्रण दिले आहे:"
+ subject: "[%{app_name}] सहनिर्माती विनंती"
+ text:
+ creation: "%{title} (%{url}) द्वारे %{pseuds}"
+ instructions: 'तुम्ही Co-Creator Requests (सहनिर्माती विनंत्या) पृष्ठावर जाऊन
+ ही विनंती स्वीकारू किंवा नाकारू शकता: %{url}'
+ delete_work_notification:
+ attachment: आपल्या संदर्भाकरिता आपल्या कार्याची प्रत सोबत जोडण्यात आली आहे.
+ deleted_other:
+ html: आपले कार्य %{title}, %{pseud} यांच्या विनंतीमुळे हटविण्यात आले आहे.
+ text: आपले कार्य “%{title}”, %{pseud} यांच्या विनंतीमुळे हटविण्यात आले आहे.
+ deleted_yourself:
+ html: आपल्या विनंतीनुसार, आपले कार्य %{title} हटविण्यात आले आहे.
+ text: आपल्या विनंतीनुसार, आपले कार्य “%{title}” हटविण्यात आले आहे.
+ questions:
+ html: जर आपल्याला काही शंका असतील, तर कृपया %{support}.
+ text: जर आपल्याला काही शंका असतील, तर कृपया %{support} (%{url}).
+ subject: "[%{app_name}] आपले कार्य हटविण्यात आले आहे"
+ support: समिती-संवाद समितीस संपर्क साधा
+ invite_increase_notification:
+ html:
+ body:
+ one: आम्हाला आपल्याला कळवायचे आहे की आपल्याला %{count} नवीन आमंत्रण प्राप्त
+ झाले आहे, जे AO3 वर नवीन खाते तयार करण्यासाठी वापरले जाऊ शकते. आपण %{invitation_page_link}
+ वर एका मित्राला आमंत्रित करू शकता.
+ other: आम्हाला आपल्याला कळवायचे आहे की आपल्याला %{count} नवीन आमंत्रणे
+ प्राप्त झाली आहेत, जी AO3 वर नवीन खाते तयार करण्यासाठी वापरली जाऊ शकतात.
+ आपण %{invitation_page_link} वर एका मित्राला आमंत्रित करू शकता.
+ invitation_page_link_text: आपली Invitations (आमंत्रणे) पृष्ठ
+ subject: "[%{app_name}] नवीन आमंत्रणे"
+ text:
+ body:
+ one: आम्हाला आपल्याला कळवायचे आहे की आपल्याला %{count} नवीन आमंत्रण प्राप्त
+ झाले आहे, जे AO3 वर नवीन खाते तयार करण्यासाठी वापरले जाऊ शकते. आपण आपल्या
+ Invitations (आमंत्रणे) पृष्ठावर (%{invitation_page_url}) एका मित्राला
+ आमंत्रित करू शकता.
+ other: आम्हाला आपल्याला कळवायचे आहे की आपल्याला %{count} नवीन आमंत्रणे
+ प्राप्त झाली आहेत, जी AO3 वर नवीन खाते तयार करण्यासाठी वापरली जाऊ शकतात.आपण
+ आपल्या Invitations (आमंत्रणे) पृष्ठावर (%{invitation_page_url}) एका मित्राला
+ आमंत्रित करू शकता.
+ invite_request_declined:
+ main:
+ one: आम्हाला कळवण्यास खेद होत आहे की आपली नवीन आमंत्रणाची विनंती यावेळी
+ पूर्ण होऊ शकत नाही.
+ other: आम्हाला कळवण्यास खेद होत आहे की आपली %{count} नवीन आमंत्रणांची विनंती
+ यावेळी पूर्ण होऊ शकत नाही.
+ reason: 'आपली विनंती होती:'
+ subject: "[%{app_name}] अतिरिक्त आमंत्रण विनंतीला नकार"
+ recipient_notification:
+ html:
+ collection: "%{collection_link} हे AO3 वरती असलेल्या संकलनामध्ये आपल्यासाठी
+ एक भेट रसिककृती प्रकाशित केली गेली!"
+ no_collection: आपल्यासाठी एक भेट रसिककृती AO3 वर प्रकाशित केली गेली!
+ subject:
+ collection: "[%{app_name}][%{collection_title}] %{collection_title} कडून आपल्यासाठी
+ एक भेट रसिककृती"
+ no_collection: "[%{app_name}] आपल्यासाठी एक भेट रसिककृती"
+ text:
+ collection: '"%{collection_title}" हे AO3 वरती असलेल्या (%{collection_url})
+ संकलनामध्ये तुमच्यासाठी एक भेट रसिककृती प्रकाशित केली गेली!'
diff --git a/config/locales/phrase-exports/ms.yml b/config/locales/phrase-exports/ms.yml
new file mode 100755
index 0000000..5af7ec8
--- /dev/null
+++ b/config/locales/phrase-exports/ms.yml
@@ -0,0 +1,615 @@
+---
+ms:
+ activerecord:
+ attributes:
+ archive_warning:
+ name_with_colon:
+ other: 'Amaran:'
+ category:
+ name_with_colon:
+ other: 'Kategori:'
+ character:
+ name_with_colon:
+ other: 'Watak:'
+ fandom:
+ name_with_colon:
+ other: 'Fandom:'
+ freeform:
+ name_with_colon:
+ other: 'Tag Tambahan:'
+ rating:
+ name_with_colon: 'Taraf:'
+ relationship:
+ name_with_colon:
+ other: 'Hubungan:'
+ work:
+ chapter_total_display: Bab
+ summary: Ringkasan
+ models:
+ archive_warning:
+ other: Amaran
+ category:
+ other: Kategori
+ chapter:
+ other: Bab
+ character:
+ other: Watak
+ fandom:
+ other: Fandom
+ freeform:
+ other: Tag Tambahan
+ rating:
+ other: Taraf
+ relationship:
+ other: Hubungan
+ series:
+ other: Siri
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ other: "%{count} tetamu"
+ left_kudos:
+ html:
+ other: "%{givers_list} telah meninggalkan kudos untuk %{commentable_link}."
+ text:
+ other: "%{givers_list} telah meninggalkan kudos untuk %{commentable_title}
+ (%{commentable_url})."
+ single_guest:
+ giver: Seorang tetamu
+ html: "%{giver} telah meninggalkan kudos untuk %{commentable_link}."
+ text: Seorang tetamu telah meninggalkan kudos untuk %{commentable_title} (%{commentable_url}).
+ subject: "[%{app_name}] Anda telah menerima kudos!"
+ mailer:
+ general:
+ closing:
+ formal: Yang benar,
+ informal: Yang benar,
+ creation:
+ link_with_word_count: "%{creation_link} (%{word_count})"
+ title_with_chapter_number: Bab %{position} daripada %{title}
+ title_with_word_count: '"%{creation_title}" (%{word_count})'
+ word_count:
+ other: "%{count} perkataan"
+ footer:
+ general:
+ about:
+ html: AO3 adalah arkib yang diurus dan disokong oleh peminat yang bergantung
+ pada %{donate_link}.
+ text: 'AO3 adalah arkib yang diurus dan disokong oleh peminat yang bergantung
+ pada sumbangan anda: %{donate_url}.'
+ html:
+ donate_link_text: sumbangan anda
+ support_link_text: hubungi Bantuan
+ unwanted_email:
+ html: Jika anda tidak sepatutnya menerima mesej ini, sila %{support_link}.
+ text: Jika anda tidak sepatutnya menerima mesej ini, sila hubungi Bantuan
+ di %{support_url}.
+ sent_at: Dihantar pada %{sent_at}.
+ greeting:
+ formal_html: "%{name},"
+ informal:
+ addressed_html: Hai, %{name}!
+ unaddressed: Hai!
+ introductory: Helo daripada Archive of Our Own – AO3 (Arkib Milik Kita)!
+ metadata_label_indicator: ":"
+ signature:
+ abuse_team: Pasukan Polisi & Aduan AO3
+ app_short_name: AO3
+ open_doors: Pasukan The Open Doors (Projek Pemeliharaan Bahan-Bahan Digital
+ dan Bukan Digital)
+ parent_org: Organization for Transformative Works – OTW (Organisasi Karya-Karya
+ Transformatif)
+ support: Pasukan Bantuan AO3
+ users:
+ mailer:
+ reset_password_instructions:
+ expiration: Pautan ini akan dinyahaktifkan dalam masa seminggu sekiranya ia
+ tidak digunakan. Anda perlu meminta pautan yang baharu setelah tempoh tersebut
+ tamat.
+ intro: 'Sebuah permintaan untuk menetapkan semula kata laluan untuk akaun
+ anda telah dibuat. Anda boleh mengikuti pautan yang berikut untuk menetapkan
+ semula kata laluan anda:'
+ link_title: Tetapkan semula kata laluan saya.
+ subject: "[%{app_name}] Tetapkan semula kata laluan anda"
+ unrequested: Jika anda tidak memohon penetapan semula kata laluan, sila abaikan
+ e-mel ini. Kata laluan anda yang asal masih akan dapat digunakan.
+ user_mailer:
+ admin_deleted_work_notification:
+ bye: Salinan karya anda telah dilampirkan untuk rujukan anda.
+ contact_abuse: hubungi Jawatankuasa Polisi & Aduan kami
+ deleted:
+ html: Karya anda %{title} telah dipadam daripada AO3 oleh seorang admin laman
+ web berkenaan.
+ text: Karya anda "%{title}" telah dipadam daripada AO3 oleh seorang admin
+ laman web berkenaan.
+ html:
+ tos_violation: Jika terdapat kemungkinan karya anda telah melanggar Syarat-Syarat
+ Perkhidmatan AO3, sila %{contact_abuse_link}.
+ import_project:
+ html: Jika karya anda merupakan sebahagian daripada projek import yang diuruskan
+ oleh pasukan Open Doors (Projek Pemeliharaan Bahan-Bahan Digital dan Bukan
+ Digital) kami, sila %{opendoors_link} untuk sebarang persoalan.
+ text: Jika karya anda merupakan sebahagian daripada projek import yang diuruskan
+ oleh pasukan Open Doors (Projek Pemeliharaan Bahan-Bahan Digital dan Bukan
+ Digital) kami, sila hubungi Open Doors (%{opendoors_link}) untuk sebarang
+ persoalan.
+ opendoors: hubungi Open Doors
+ subject: "[%{app_name}] Karya anda telah dipadam oleh seorang admin"
+ text:
+ tos_violation: Jika terdapat kemungkinan karya anda telah melanggar Syarat-Syarat
+ Perkhidmatan AO3, sila hubungi Jawatankuasa Polisi & Aduan kami (%{contact_abuse_url}).
+ admin_hidden_work_notification:
+ access: Ketika karya anda disembunyikan, anda masih boleh mengakses menerusi
+ capaian yang diberikan di atas, tetapi ia tidak akan disenaraikan di halaman
+ karya anda, dan tidak akan dipaparkan kepada pengguna AO3 yang lain.
+ check_email: Sila semak e-mel anda, termasuk folder spam, kerana pasukan Polisi
+ & Aduan mungkin telah menghubungi anda berkenaan mengapa karya anda disembunyikan.
+ contact_abuse: hubungi Polisi & Aduan
+ html:
+ help: Sekiranya anda tidak pasti mengapa karya anda disembunyikan dan anda
+ masih belum menerima hubungan lanjut tentang hal ini, sila hubungi %{contact_abuse_link}
+ secara langsung.
+ hidden: Karya anda %{title} telah disembunyikan oleh pasukan Polisi & Aduan
+ dan tidak dapat diakses oleh pihak umum lagi.
+ tos_violation: Sekiranya karya anda disembunyikan kerana melanggar %{tos_link}
+ AO3, anda akan perlu mengambil tindakan untuk membetulkan pelanggaran tersebut.
+ Kegagalan karya anda mematuhi Syarat-syarat Perkhidmatan mungkin akan menyebabkan
+ karya anda dipadam daripada AO3.
+ subject: "[%{app_name}] Karya anda telah disembunyikan oleh pasukan Polisi &
+ Aduan"
+ text:
+ help: 'Sekiranya anda tidak pasti mengapa karya anda disembunyikan dan anda
+ masih belum menerima hubungan lanjut tentang hal ini, sila hubungi Polisi
+ & Aduan secara langsung: %{contact_abuse_url}.'
+ hidden: Karya anda "%{title}" (%{work_url}) telah disembunyikan oleh pasukan
+ Polisi & Aduan dan tidak dapat diakses oleh umum lagi.
+ tos_violation: Sekiranya karya anda disembunyikan kerana melanggar Syarat-syarat
+ Perkhidmatan AO3 (%{tos_url}), anda akan perlu mengambil tindakan untuk
+ membetulkan pelanggaran tersebut. Kegagalan karya anda mematuhi Syarat-syarat
+ Perkhidmatan mungkin akan menyebabkan karya anda dipadam daripada AO3.
+ tos: Syarat-syarat Perkhidmatan
+ anonymous_or_unrevealed_notification:
+ anonymous_info: Karya tidak bernama termasuk dalam senarai tag, tetapi tidak
+ pada halaman karya anda. Pada karya tersebut, nama pengguna anda akan digantikan
+ dengan “Anonymous” (Tidak Bernama).
+ anonymous_unrevealed_info: Pengendali koleksi kemudiannya boleh mempamerkan
+ karya anda tetapi membiarkan ia tidak bernama. Sesiapa yang melanggan anda
+ tidak akan diberitahu mengenai perubahan ini. Setelah dipamerkan, karya anda
+ akan termasuk dalam senarai tag, tetapi bukan pada laman karya anda. Pada
+ karya tersebut, nama pengguna anda akan digantikan dengan “Anonymous” (Tidak
+ Bernama)
+ changed_status:
+ anonymous:
+ html: Pengendali koleksi %{collection_link} telah mengubah status karya
+ anda %{work_link} kepada tidak bernama.
+ text: Penyenggara koleksi "%{collection_title}" (%{collection_url}) telah
+ mengubah status karya anda "%{work_title}" (%{work_url}) kepada tanpa
+ nama.
+ anonymous_unrevealed:
+ html: Pengendali koleksi %{collection_link} telah mengubah status karya
+ anda %{work_link} kepada tidak bernama dan tidak dipamerkan.
+ text: Penyenggara koleksi "%{collection_title}" (%{collection_url}) telah
+ mengubah status karya anda "%{work_title}" (%{work_url}) kepada tanpa
+ nama dan belum dipamerkan.
+ unrevealed:
+ html: Pengendali koleksi %{collection_link} telah mengubah status karya
+ anda %{work_link} kepada tidak dipamerkan.
+ text: Penyenggara koleksi "%{collection_title}" (%{collection_url}) telah
+ mengubah status karya anda "%{work_title}" (%{work_url}) kepada belum
+ dipamerkan.
+ collection_items_link_text: Laman Approved Collection Items (Butiran Koleksi
+ Diluluskan)
+ do_not_want:
+ anonymous:
+ html: Jika anda tidak mahu karya anda menjad tidak bernama, sila lawati
+ %{collection_items_link} anda untuk mengeluarkannya daripada koleksi ini.
+ text: 'Jika anda tidak mahu karya anda sebagai tidak bernama, sila lawati
+ laman Approved Collection Items (Butiran Koleksi Diluluskan) anda untuk
+ mengeluarkannya daripada koleksi ini: %{collection_items_url}'
+ anonymous_unrevealed:
+ html: Jika anda tidak mahu karya anda sebagai tidak bernama dan tidak dipamerkan,
+ sila lawat %{collection_items_link} anda untuk mengeluarkannya daripada
+ koleksi ini.
+ text: 'Jika anda tidak mahu karya anda sebagai tidak bernama dan tidak dipamerkan,
+ sila lawat laman Approved Collection Items (Butiran Koleksi Diluluskan)
+ anda untuk mengeluarkannya daripada koleksi ini: %{collection_items_url}'
+ unrevealed:
+ html: Jika anda tidak mahu karya anda sebagai tidak dipamerkan, sila lawati
+ %{collection_items_link} anda untuk mengeluarkannya daripada koleksi ini.
+ text: 'Jika anda tidak mahu karya anda sebagai tidak dipamerkan, sila lawat
+ laman Approved Collection Items (Butiran Koleksi Diluluskan) anda untuk
+ mengeluarkannya daripada koleksi ini: %{collection_items_url}'
+ faq_link_text: FAQ Koleksi
+ more_info:
+ html: Untuk maklumat lebih anjut, sila lawat %{faq_link} kami.
+ text: 'Untuk maklumat lebih lanjut, sila lawati FAQ Koleksi kami: %{faq_url}'
+ subject:
+ anonymous: "[%{app_name}] Karya anda telah menjadi tidak bernama"
+ anonymous_unrevealed: "[%{app_name}] Karya anda telah dibuat tidak bernama
+ dan tidak dipamerkan"
+ unrevealed: "[%{app_name}] Karya anda tidak dipamerkan"
+ unrevealed_info: Karya yang tidak dipamerkan tidak termasuk dalam senarai tag
+ atau pada laman karya anda. Sesiapa yang mengikuti pautan ke karya tersebut
+ akan menerima notis bahawa karya tersebut tidak dipamerkan, dan mereka tidak
+ akan dapat mengakses kandungan tersebut.
+ archivist_added_to_collection_notification:
+ approved_collection_items_page: Halaman Approved Collection Items (Item Koleksi
+ yang Diluluskan)
+ archivist_notice: Oleh kerana penyelenggara koleksi bertindak dalam kapasiti
+ rasmi mereka sebagai ahli arkib Open Doors (Projek Pemeliharaan Bahan-Bahan
+ Digital dan Bukan Digital), mereka dibenarkan untuk menambah karya anda ke
+ koleksi ini, walaupun anda telah menutup jemputan koleksi. Ahli arkib hanya
+ akan menambah sesebuah karya ke koleksi jika ia dihoskan di arkib yang diimport.
+ removal_instructions:
+ html: Jika anda ingin mengalih keluar karya anda daripada koleksi ini, sila
+ lawati %{approved_items_link} anda.
+ text: 'Jika anda ingin mengalih keluar karya anda daripada koleksi ini, sila
+ lawati halaman Approved Collection Items (Item Koleksi yang Diluluskan)
+ anda: %{approved_items_url}.'
+ subject: "[%{app_name}][%{collection_title}] Ahli arkib Open Doors (Projek Pemeliharaan
+ Bahan-Bahan Digital dan Bukan Digital) telah menambah karya anda ke koleksi"
+ work_added:
+ html: Penyelenggara koleksi %{collection_link} telah menambah karya anda %{work_link}
+ ke koleksi mereka!
+ text: Penyelenggara koleksi bagi "%{collection_title}" (%{collection_url})
+ telah menambah karya anda "%{work_title}" (%{work_url}) ke koleksi mereka!
+ challenge_assignment_notification:
+ any: Mana-mana
+ assignment:
+ html: Anda telah ditugaskan dengan permintaan yang dinyatakan pada cabaran
+ %{link} di Arkib Milik Kita!
+ description: 'Penerangan:'
+ due: 'Tugasan ini akan berakhir pada:'
+ html:
+ footer: Anda menerima emel ini kerana anda telah mendaftar untuk cabaran %{title}.
+ Untuk maklumat lebih lanjut tentang cabaran ini dan maklumat untuk dihubungi
+ bagi moderator, sila lihat %{footer_link}.
+ footer_link: laman profil cabaran
+ look_up: Anda boleh melihat tugasan ini di %{link}.
+ look_up_link: halaman Assignments (Tugasan) anda
+ optional_tags: 'Tag Pilihan:'
+ prompts: 'Prompt:'
+ prompt_url: 'URL Prompt:'
+ recipient: 'Penerima:'
+ recipient_missing: 'Tiada: hubungi moderator untuk bantuan!'
+ subject: "[%{app_name}][%{collection_title}] Tugasan Anda!"
+ text:
+ assignment: Anda telah ditugaskan dengan permintaan yang dinyatakan pada cabaran
+ "%{collection_title}" (%{collection_url}) di Arkib Milik Kita!
+ footer: Anda menerima emel ini kerana anda telah mendaftar untuk cabaran %{title}
+ (%{url}). Untuk maklumat lebih lanjut mengenai cabaran ini maklumat untuk
+ dihubungi bagi moderator, sila lihat %{profile_url}.
+ look_up: Anda boleh mencari tugasan ini dari halaman Assignments (Tugasan)
+ anda di %{link}.
+ change_email:
+ changed:
+ html: "%{login}, emel yang dikaitkan dengan akaun anda kini telah diubah kepada
+ %{email}"
+ text: "%{login}, emel yang dikaitkan dengan akaun anda kini telah diubah kepada
+ %{email}"
+ subject: "[%{app_name}] Emel diubah"
+ claim_notification:
+ access:
+ contact_support: hubungi Bantuan AO3
+ html: Bergantung kepada arkib, karya anda mungkin telah diimport dihadkan
+ kepada pengguna berdaftar sahaja (untuk tidak dijumpai dalam pencarian Google).
+ Jika ini berlaku, karya-karya tersebut akan hanya diakses oleh pengguna
+ yang telah log masuk melainkan anda memilih untuk membuat karya tersebut
+ dilihat sepenuhnya. Untuk bantuan membuka, meyatim, atau memadam karya-karya
+ anda, sila %{contact_support_link}.
+ text: Bergantung kepada arkib, karya anda mungkin telah diimport dihadkan
+ kepada pengguna berdaftar sahaja (untuk tidak dijumpai dalam pencarian Google).
+ Jika ini berlaku, karya-karya tersebut akan hanya diakses oleh pengguna
+ yang telah log masuk melainkan anda memilih untuk membuat karya tersebut
+ jelas sepenuhnya. Untuk bantuan membuka, meyatim, atau memadam karya-karya
+ anda, sila hubungi Bantuan AO3 di %{support_url}.
+ email_tips: Jika anda menghubungi kami, sila masukkan alamat e-mel dari @transformativeworks.org
+ ke dalam senarai hubungan selamat anda dan periksa folder spam untuk balasan
+ kami.
+ introduction:
+ ao3_name: Archive of Our Own - AO3 (Arkib Milik Kita)
+ html: Anda menerima e-mel ini kerana anda mempunyai karya-karya di dalam arkib
+ karya peminat yang telah diimport oleh %{open_doors_name_link} ke dalam
+ %{app_link}. Disebabkan alamat e-mel ini dihubungkan dengan e-mel berdaftar
+ pada arkib tersebut, mana-mana karya yang berkaitan (disenaraikan di bawah)
+ telah dimasukkan secara automatik ke akaun AO3 anda.
+ open_doors_name: Open Doors (Projek Pemeliharaan Bahan-Bahan Digital dan Bukan
+ Digital)
+ text: 'Anda menerima e-mel ini kerana anda mempunyai karya-karya di dalam
+ arkib karya peminat yang telah diimport oleh Open Doors (Projek Pemeliharaan
+ Bahan-Bahan Digital dan Bukan Digital) (%{open_doors_url}) ke dalam Archive
+ of Our Own – AO3 (Arkib Milik Kita): %{app_url}. Disebabkan alamat e-mel
+ ini dihubungkan dengan e-mel berdaftar pada arkib tersebut, mana-mana karya
+ yang berkaitan (disenaraikan di bawah) telah dimasukkan secara automatik
+ ke akaun AO3 anda.'
+ mistake:
+ contact_open_doors: hubungi Open Doors
+ html: Jika ini merupakan kesilapan dan karya-karya ini bukan milik anda, jangan
+ padam! Sila %{contact_open_doors_link} dan kami akan selesaikannya.
+ text: Jika ini merupakan kesilapan dan karya-karya ini bukan milik anda, jangan
+ padam! Sila hubungi Open Doors (%{open_doors_url}) dan kami akan selesaikannya.
+ more_info:
+ ao3_news: Berita AO3
+ contact_support: hubungi Bantuan AO3
+ faq_page: laman FAQ
+ html: Anda boleh membaca pengumuman mengenai pemindahan arkib terkini di %{ao3_news_link},
+ serta mencari informasi tambahan di %{faq_page_link} atau %{tutorial_page_link}
+ Open Doors. Bagi sebarang pertanyaan yang tidak dijawab dalam FAQ, tutorial
+ atau e-mel ini, sila %{contact_support_link}.
+ text: Anda boleh membaca pengumuman mengenai pemindahan arkib terkini di Berita
+ AO3 (%{news_url}), serta mencari informasi tambahan di laman FAQ Open Doors
+ (%{open_doors_faq_url}) atau laman tutorial (%{open_doors_tutorial_url}).
+ Bagi sebarang pertanyaan yang tidak terjawab dalam FAQ, tutorial atau e-mel
+ ini, sila hubungi Bantuan di %{support_url}.
+ tutorial_page: laman tutorial
+ other_works:
+ contact_open_doors: hubungi Open Doors
+ html: Jika anda mempunyai karya-karya pada arkib import di bawah alamat e-mel
+ yang anda tidak lagi boleh akses, sila %{contact_open_doors_link} dengan
+ sebarang maklumat yang boleh membantu mengesahkan identiti anda.
+ text: Jika anda mempunyai karya-karya pada arkib import di bawah alamat e-mel
+ yang anda tidak lagi mampu akses, sila hubungi Open Doors dengan apa-apa
+ maklumat yang boleh membantu mengesahkan identiti anda.
+ questions:
+ contact_support: hubungi Bantuan AO3
+ html: Untuk pertanyaan lanjut, sila %{contact_support_link}.
+ text: Untuk pertanyaan lanjut, sila hubungi Bantuan AO3 di %{support_url}.
+ redirects:
+ html: Untuk memelihara senarai cadangan dan menanda, alamat laman dari arkib
+ diimport mungkin mengubah hala ke salinan import karya-karya ini untuk masa
+ terhad (lihat pengumuman bagi arkib kita untuk kepastian). Jika anda sudah
+ memuat naik salinan karya tersebut dan anda %{negation} mengguna ciri import
+ dari URL, dua salinan karya yang sama akan terpapar pada AO3.
+ subject: "[%{app_name}] Karya yang dimuat naik"
+ update_redirect:
+ contact_open_doors: hubungi Open Doors
+ html: Jika anda ingin Open Doors untuk mengemas kini ubah hala ke karya anda
+ yang sedia ada, sila padamkan salinan import, dan %{contact_open_doors_link}
+ dengan nama akaun AO3 anda, nama akaun anda pada arkib import, dan tajuk
+ dan URL karya anda yang ingin dihalakan. (Jika anda mempunyai beberapa karya
+ yang anda ingin menukar ubah hala, anda boleh senaraikan dalam satu e-mel.)
+ text: Jika anda ingin Open Doors untuk mengemas kini ubah hala ke karya anda
+ yang sedia ada, sila padamkan salinan import, dan hubungi Open Doors di
+ %{open_doors_url} dengan nama akaun AO3 anda, nama akaun anda pada arkib
+ import, dan tajuk dan URL karya anda yang ingin dihalakan. (Jika anda mempunyai
+ beberapa karya yang anda ingin menukar ubah hala, anda boleh menyenaraikan
+ dalam satu e-mel.)
+ works_by: 'Karya-karya ini ditulis di bawah e-mel: %{email}'
+ work_info:
+ html: "%{work_link} (%{fandom})"
+ text:
+ no_fandom: "- %{work_title} %{work_url}"
+ with_fandom: "- %{work_title} %{work_url} (%{fandom})"
+ collection_notification:
+ assignments_sent:
+ complete: Semua tugasan telah dihantar
+ subject: Tugasan dihantar
+ html:
+ received_message: 'Anda telah menerima pesanan tentang koleksi anda %{collection_link}:'
+ text:
+ received_message: 'Anda telah menerima pesanan tentang koleksi anda "%{collection_title}"
+ (%{collection_url}):'
+ creatorship_notification:
+ explanation: Apabila anda menjadi seorang pereka bersama untuk sesebuah karya,
+ anda boleh dimasukkan pada bab-bab baru, tanpa mengira tetapan pereka bersama
+ anda. Anda juga akan ditambah ke mana-mana siri di mana karya tersebut akan
+ disertakan.
+ html:
+ creation: "%{creation_link} oleh %{pseud_links}"
+ edit_chapter: Edit bab
+ edit_series: olah siri
+ remove_chapter: Jika anda telah silap dimasukkan atau tidak mahu disenarai
+ sebagai pereka, anda boleh %{edit_chapter_link} untuk mengeluarkan diri
+ anda sebagai pereka.
+ remove_series: Jika anda telah silap dimasukkan atau tidak mahu disenarai
+ sebagai pereka, anda boleh %{edit_series_link} untuk mengeluarkan diri sebagai
+ pereka.
+ intro_chapter: 'Pengguna %{adding_user} telah menyenaraikan nama samaran anda
+ %{pseud} sebagai pereka bersama untuk bab yang berikut:'
+ intro_series: 'Pengguna %{adding_user} telah menyenaraikan nama samaran anda
+ %{pseud} sebagai pereka bersama bagi siri berikut:'
+ subject: "[%{app_name}] Pemberitahuan Pereka Bersama"
+ text:
+ creation: "%{title} (%{url}) oleh %{pseuds}"
+ remove_chapter: 'Jika anda telah silap dimasukkan atau tidak mahu disenarai
+ sebagai pereka, anda boleh mengedit bab ini untuk mengeluarkan diri sebagai
+ pereka: %{url}'
+ remove_series: 'Jika anda telah silap dimasukkan atau tidak mahu disenarai
+ sebagai pereka, anda boleh untuk mengeluarkan diri sebagai pereka: %{url}'
+ creatorship_notification_archivist:
+ explanation: Oleh sebab mereka bertindak melalui peranan rasmi mereka sebagai
+ ahli arkib Open Doors (Projek Pemeliharaan Bahan-Bahan Digital dan Bukan Digital),
+ mereka boleh menyertakan anda tanpa permintaan, walaupun anda memilih untuk
+ menolak perekaan bersama.
+ html:
+ creation: "%{creation_link} oleh %{pseud_links}"
+ edit_chapter: mengedit bab ini
+ edit_series: mengedit siri ini
+ edit_work: mengedit karya ini
+ remove_chapter: Jika anda telah disertakan secara tidak sengaja ataupun anda
+ tidak ingin disenaraikan sebagai pereka, anda boleh %{edit_chapter_link}
+ untuk membuang nama samaran anda sebagai pereka.
+ remove_series: Jika anda telah disertakan secara tidak sengaja ataupun anda
+ tidak ingin disenaraikan sebagai pereka, anda boleh %{edit_series_link}
+ untuk membuang nama samaran anda sebagai pereka.
+ remove_work: Jika anda telah disertakan secara tidak sengaja ataupun anda
+ tidak ingin disenaraikan sebagai pereka, anda boleh %{edit_work_link} untuk
+ membuang nama samaran anda sebagai pereka.
+ intro_chapter: 'Pengguna %{archivist} telah menyertakan nama samaran anda %{pseud}
+ sebagai pereka bersama untuk bab berikut:'
+ intro_series: 'Pengguna %{archivist} telah menyertakan nama samaran anda %{pseud}
+ sebagai pereka bersama berikut untuk siri berikut:'
+ intro_work: 'Pengguna %{archivist} telah menyertakan nama samaran anda %{pseud}
+ sebagai pereka bersama untuk karya berikut:'
+ subject: "[%{app_name}] Pemberitahuan pereka bersama ahli arkib"
+ text:
+ creation: "%{title} (%{url}) oleh %{pseuds}"
+ remove_chapter: 'Jika anda telah disertakan secara tidak sengaja ataupun anda
+ tidak ingin disenaraikan sebagai pereka, anda boleh mengedit bab ini untuk
+ membuang nama samaran anda sebagai pereka: %{url}'
+ remove_series: 'Jika anda telah disertakan secara tidak sengaja ataupun anda
+ tidak ingin disenaraikan sebagai pereka, anda boleh mengedit siri ini untuk
+ membuang nama samaran anda sebagai pereka: %{url}'
+ remove_work: 'Jika anda telah disertakan secara tidak sengaja ataupun anda
+ tidak ingin disenaraikan sebagai pereka, anda boleh mengedit karya ini untuk
+ membuang nama samaran anda sebagai pereka: %{url}'
+ creatorship_request:
+ html:
+ creation: "%{creation_link} oleh %{pseud_links}"
+ instructions: Anda boleh menerima atau menolak permintaan ini di halaman %{page_name}
+ anda.
+ page_name: Co-Creator Requests (Permintaan-Permintaan Pereka Bersama)
+ intro_chapter: 'Pengguna %{inviting_user} telah menjemput nama samaran anda
+ %{pseud} untuk disenaraikan sebagai pereka bersama bab di bawah:'
+ intro_series: 'Pengguna %{inviting_user} telah menjemput nama samaran anda %{pseud}
+ untuk disenaraikan sebagai pereka bersama di siri tersebut:'
+ intro_work: 'Pengguna %{inviting_user} telah menjemput nama samaran anda %{pseud}
+ untuk disenaraikan sebagai pereka bersama di karya tersebut:'
+ subject: "[%{app_name}] Permintaan pereka bersama"
+ text:
+ creation: "%{title} (%{url}) oleh %{pseuds}"
+ instructions: 'Anda boleh menerima atau menolak permintaan ini di halaman
+ Co-Creator Requests (Permintaan-Permintaan Pereka Bersama) anda: %{url}'
+ delete_work_notification:
+ attachment: Dilampirkan adalah sebuah salinan karya anda untuk rujukan.
+ deleted_other:
+ html: Karya anda %{title} telah dipadamkan atas permintaan %{pseud}.
+ text: Karya anda "%{title}" telah dipadamkan atas permintaan %{pseud}.
+ deleted_yourself:
+ html: Karya anda %{title} telah dipadamkan atas permintaan anda.
+ text: Karya anda "%{title}" telah dipadamkan atas permintaan anda.
+ questions:
+ html: Sekiranya anda mempunyai sebarang pertanyaan, sila %{support}.
+ text: Sekiranya anda mempunyai sebarang pertanyaan, sila %{support} (%{url}).
+ subject: "[%{app_name}] Karya anda telah dipadamkan"
+ support: hubungi Bantuan
+ invitation_to_claim:
+ access:
+ text: Bergantung kepada arkib, karya-karya anda yang diimport mungkin dihadkan
+ kepada pengguna berdaftar sahaja (supaya tidak termasuk ke dalam carian
+ Google). Jika berlaku sedemikian, karya-karya tersebut hanya boleh diakses
+ oleh pengguna yang log masuk, melainkan anda memilih untuk membuatkan karya-karya
+ boleh diakses oleh orang ramai. Untuk bantuan membebaskan, meyatimkan, atau
+ memadam karya-karya anda, sila hubungi Bantuan AO3.
+ claim_or_remove:
+ html: Tuntut atau padamkan karya-karya anda di sini.
+ text: 'Tuntut atau padam karya-karya anda di sini: %{claim_url}'
+ email_tips: Jika anda menghubungi kami, sila whitelist alamat-alamat e-mel daripada
+ @transformativeworks.org dan semak folder spam anda untuk balasan kami.
+ html:
+ ao3_news: Berita AO3
+ contact_open_doors: hubungi Open Doors
+ contact_support: hubungi bantuan AO3
+ faq_page: laman FAQ
+ tutorial_page: laman tutorial
+ introduction:
+ text: Anda menerima e-mel ini kerana sebuah arkib baru sahaja diimport oleh
+ Open Doors (%{open_doors_link}) ke dalam %{app_name} (%{app_short_name}
+ - %{app_url}), dan kami percaya bahawa hasil kerja peminat berikut adalah
+ milik anda. Kami ingin memberikan peluang kepada anda untuk menuntut (atau
+ memadam/menyatim) karya-karya tersebut jika anda mahu. Dan jika anda belum
+ lagi mempunyai akaun dengan e-mel berbeza, kami ingin menjemput anda untuk
+ menyertai kami!
+ mistake:
+ text: Jika ini merupakan suatu kesilapan dan karya-karya ini bukan milik anda,
+ sila jangan padamkannya! Sila hubungi sahaja Open Doors (%{open_doors_link})
+ dan kami akan selesaikannya.
+ more_info:
+ text: Anda boleh membaca pengumuman mengenai pergerakan arkib terbaru di Berita
+ AO3 (%{news_link}), dan mencari maklumat tambahan tentang laman FAQ Open
+ Doors (%{open_doors_faq_link}) atau laman tutorial (%{open_doors_tutorial_link}).
+ Untuk sebarang soalan yang tidak dijawab di dalam FAQ, tutorial, atau e-mel
+ ini, sila hubungi Bantuan di %{support_link}.
+ other_works:
+ text: Jika anda mempunyai karya-karya lain pada arkib yang diimport dengan
+ alamat e-mel lain yang anda tidak dapat akses, sila hubungi Open Doors dengan
+ apa-apa maklumat yang boleh membantu mengesahkan identiti anda.
+ questions:
+ text: Untuk pertanyaan lanjut, sila hubungi Bantuan AO3 di %{support_link}.
+ redirects: Untuk memelihara senarai cadangan dan penanda laman, alamat-alamat
+ laman web dari arkib yang diimport mungkin akan berubah hala ke salinan untuk
+ karya-karya tersebut yang diimport untuk jangka masa yang terhad (semak artikel
+ pengumuman untuk arkib anda untuk kepastian). Jika anda telah memuat naik
+ salinan untuk karya-karya tersebut dan anda TIDAK menggunakan ciri import
+ daripada URL, akan terdapat dua salinan untuk karya yang sama di dalam arkib.
+ subject: "[%{app_name}] Jemputan untuk menuntut karya"
+ unwanted:
+ text: Jika karya-karya ini milik anda, tetapi anda tidak menyimpannya lagi,
+ anda boleh meyatimkan (supaya mereka kekal di dalam AO3, tetapi dengan nama
+ anda dibuang) atau memadamkan mereka (supaya mereka dibuang sepenuhnya dari
+ AO3). Anda tidak perlu menambahkan karya-karya tersebut ke mana-mana akaun
+ untuk meyatimkan atau memadamkan mereka--anda boleh lakukan terus daripada
+ pautan tuntut di atas. (Untuk bantuan, sila hubungi Bantuan di %{support_link}.)
+ update_redirect:
+ text: Jika anda ingin Open Doors untuk mengemas kini pengalihan untuk menuju
+ ke karya anda yang sedia ada, sila padamkan salinan yang telah dimuat naik,
+ dan hubungi Open Doors di %{open_doors_link} dengan akaun AO3 anda, nama
+ akaun anda pada arkib yang diimport, dan tajuk dan URL hasil kerja peminat
+ anda yang anda ingin tujukan pengalihan tersebut. (Jika anda mempunyai pelbagai
+ karya yang anda ingin ubah pengalihan, anda boleh senaraikannya dalam satu
+ e-mel.)
+ uploaded_list: 'Karya-karya yang dimuat naik termasuk:'
+ invite_increase_notification:
+ html:
+ body:
+ other: Kami ingin memaklumkan bahawa anda mempunyai %{count} jemputan baharu
+ yang boleh digunakan untuk membuat akaun baharu di AO3. Anda boleh menjemput
+ rakan anda di %{invitation_page_link}.
+ invitation_page_link_text: laman jemputan anda
+ subject: "[%{app_name}] Jemputan Baharu"
+ text:
+ body:
+ other: Kami ingin memaklumkan bahawa anda mempunyai %{count} jemputan baharu
+ yang boleh digunakan untuk membuat akaun baharu di AO3. Anda boleh menjemput
+ rakan anda di laman Invitations (Jemputan) anda (%{invitation_page_url}).
+ invite_request_declined:
+ main:
+ other: Dengan berat hati ingin kami maklumkan kepada anda bahawa permintaan
+ anda untuk %{count} jemputan baharu tidak dapat dipenuhi ketika ini.
+ reason: 'Berikut merupakan permintaan anda:'
+ subject: "[%{app_name}] Permintaan Kod Jemputan Tambahan Ditolak"
+ recipient_notification:
+ html:
+ collection: Karya hadiah telah disiarkan untuk anda dalam koleksi %{collection_link}
+ di AO3!
+ no_collection: Karya hadiah telah disiarkan untuk anda di AO3!
+ subject:
+ collection: "[%{app_name}][%{collection_title}] Karya hadiah untuk anda dari
+ %{collection_title}"
+ no_collection: "[%{app_name}] Hadiah karya untuk anda"
+ text:
+ collection: Karya hadiah telah disiarkan untuk anda dalam koleksi "%{collection_title}"
+ (%{collection_url}) di AO3!
+ signup_notification:
+ activate:
+ html: Sila %{activate_account_link}.
+ text: 'Sila ikuti pautan ini untuk aktifkan akaun anda: %{activate_account_url}'
+ activate_your_account: Ikuti pautan ini untuk aktifkan akaun anda
+ admin_posts: Berita AO3
+ bye: Kami harap anda memperolehi pengalaman baik menggunakan arkib ini.
+ contact_support: Hubungi Jawatankuasa Sokongan
+ faq: FAQ
+ features:
+ html: Setelah akaun anda diaktifkan, anda boleh menerbitkan hasil kerja peminat,
+ menetapkan langganan e-mel untuk memaklumkan anda jika pereka atau karya
+ kegemaran anda telah dikemaskini, menetapkan pilihan anda untuk mengubah
+ suai rupa atau bagaimana laman ini berfungsi kepada anda, mengikuti hasil
+ karya yang anda telah akses di arkib melalui sejarah anda dan banyak lagi.
+ text: Setelah akaun anda diaktifkan, anda boleh menerbitkan hasil kerja peminat,
+ menetapkan langganan e-mel untuk memaklumkan anda jika pereka atau karya
+ kegemaran anda telah dikemaskini, menetapkan pilihan anda untuk mengubah
+ suai rupa atau bagaimana laman ini berfungsi kepada anda, mengikuti hasil
+ karya yang anda telah akses di arkib melalui sejarah anda dan banyak lagi.
+ information:
+ html: Terdapat banyak maklumat dan nasihat tentang cara-cara menggunakan Arkib
+ di %{faq_link} kami. Anda akan menemui berita terkini tentang perkembangan
+ laman di %{admin_posts_link} kami. Jika anda memerlukan lebih pertolongan,
+ menghadapi ralat perisian dalaman atau mempunyai soalan atau komen, sila
+ %{contact_support_link}, yang sentiasa bersedia untuk menolong.
+ text: 'Terdapat banyak maklumat dan nasihat tentang cara-cara menggunakan
+ Arkib di FAQ kami di %{faq_url}. Anda akan menemui berita terkini tentang
+ perkembangan laman dalam Berita AO3 di %{admin_posts_url}. Jika anda memerlukan
+ lebih pertolongan, menghadapi ralat perisian dalaman atau mempunyai soalan
+ atau komen, sila berhubung dengan bahagian Bantuan kami, yang sentiasa
+ bersedia untuk menolong: %{contact_support_url}'
+ welcome: Selamat datang ke Arkib Milik Kita, %{login}!
diff --git a/config/locales/phrase-exports/nb.yml b/config/locales/phrase-exports/nb.yml
new file mode 100755
index 0000000..eb15a48
--- /dev/null
+++ b/config/locales/phrase-exports/nb.yml
@@ -0,0 +1,620 @@
+---
+nb:
+ activerecord:
+ attributes:
+ archive_warning:
+ name_with_colon:
+ one: 'Advarsel:'
+ other: 'Advarsler:'
+ category:
+ name_with_colon:
+ one: 'Kategori:'
+ other: 'Kategorier:'
+ character:
+ name_with_colon:
+ one: 'Karakter:'
+ other: 'Karakterer:'
+ fandom:
+ name_with_colon:
+ one: 'Fandom:'
+ other: 'Fandommer:'
+ freeform:
+ name_with_colon:
+ one: 'Ytterligere tagg:'
+ other: 'Ytterligere tagger:'
+ rating:
+ name_with_colon: 'Aldersgrense:'
+ relationship:
+ name_with_colon:
+ one: 'Forhold:'
+ other: 'Forhold:'
+ work:
+ chapter_total_display: Kapitler
+ summary: Sammendrag
+ models:
+ archive_warning:
+ one: Advarsel
+ other: Advarsler
+ category:
+ one: Kategori
+ other: Kategorier
+ chapter:
+ one: Kapittel
+ other: Kapitler
+ character:
+ one: Karakter
+ other: Karakterer
+ fandom:
+ one: Fandom
+ other: Fandommer
+ freeform:
+ one: Ytterligere tagg
+ other: Ytterligere tagger
+ rating:
+ one: Aldersgrense
+ other: Aldersgrenser
+ relationship:
+ one: Forhold
+ other: Forhold
+ series:
+ one: Serie
+ other: Serier
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ one: en gjest
+ other: "%{count} gjester"
+ left_kudos:
+ html:
+ one: "%{givers_list} la igjen kudos på %{commentable_link}."
+ other: "%{givers_list} la igjen kudos på %{commentable_link}."
+ text:
+ one: "%{givers_list} la igjen kudos på %{commentable_title} (%{commentable_url})."
+ other: "%{givers_list} la igjen kudos på %{commentable_title} (%{commentable_url})."
+ single_guest:
+ giver: En gjest
+ html: "%{giver} la igjen kudos på %{commentable_link}."
+ text: En gjest la igjen kudos på %{commentable_title} (%{commentable_url}).
+ subject: "[%{app_name}] Du har fått kudos!"
+ mailer:
+ general:
+ closing:
+ formal: Med vennlig hilsen
+ informal: Med vennlig hilsen
+ creation:
+ link_with_word_count: "%{creation_link} (%{word_count})"
+ title_with_chapter_number: Kapittel %{position} av %{title}
+ title_with_word_count: '"%{creation_title}" (%{word_count})'
+ word_count:
+ one: "%{count} ord"
+ other: "%{count} ord"
+ footer:
+ general:
+ about:
+ html: Vårt eget arkiv er et fan-drevet og fan-støttet arkiv som avhenger
+ av %{donate_link}.
+ text: 'AO3 er et fan-drevet og fan-støttet arkiv som avhenger av dine
+ donasjoner: %{donate_url}.'
+ html:
+ donate_link_text: dine donasjoner
+ support_link_text: kontakte support
+ unwanted_email:
+ html: Dersom du feilaktig har mottatt denne beskjeden kan du %{support_link}.
+ text: 'Dersom du feilaktig har mottatt denne beskjeden, vennligst kontakt
+ support: %{support_url}.'
+ sent_at: Sendt %{sent_at}.
+ greeting:
+ formal_html: Kjære %{name}
+ informal:
+ addressed_html: Hei, %{name}!
+ unaddressed: Hei!
+ introductory: En hilsen fra Archive of Our Own – AO3 (Vårt eget arkiv)!
+ metadata_label_indicator: ":"
+ signature:
+ abuse_team: Teamet for retningslinjer og misbruk
+ app_short_name: AO3
+ open_doors: Teamet for Open Doors (Åpne Dører)
+ parent_org: Organization for Transformative Works – OTW (Organisasjonen for
+ transformative verk)
+ support: Teamet for AO3 support
+ users:
+ mailer:
+ reset_password_instructions:
+ expiration: Hvis du ikke bruker denne lenken til å tilbakestille passordet
+ ditt innen en uke, vil lenken utløpe, og du må be om en ny en.
+ intro: 'Noen har bedt om å tilbakestille passordet til kontoen din. Du kan
+ endre passordet til kontoen din ved å følge lenken under og fylle inn det
+ nye passordet ditt:'
+ link_title: Endre passordet mitt.
+ subject: "[%{app_name}] Tilbakestill passordet ditt"
+ unrequested: Hvis du ikke ba om å tilbakestille passordet ditt, kan du ignorere
+ denne e-posten og det tidligere passordet ditt vil fortsette å fungere.
+ user_mailer:
+ admin_deleted_work_notification:
+ bye: Vedlagt ligger en kopi av verket for din referanse.
+ contact_abuse: kontakt vår komité for retningslinjer og misbruk
+ deleted:
+ html: Ditt verk %{title} ble slettet fra Arkivet av en sideadministrator.
+ text: Ditt verk "%{title}" ble slettet fra Arkivet av en sideadministrator.
+ html:
+ tos_violation: Hvis det er mulig at verket ditt brøt med Arkivets brukervilkår,
+ vennligst %{contact_abuse_link}.
+ import_project:
+ html: Hvis verket var en del av et importprosjekt administrert av vårt Åpne
+ Dører-team, vennligst %{opendoors_link} for eventuelle spørsmål.
+ text: Hvis verket var en del av et importprosjekt administrert av vårt Åpne
+ Dører-team, vennligst kontakt Åpne Dører (%{opendoors_link}) for eventuelle
+ spørsmål.
+ opendoors: kontakt Åpne Dører
+ subject: "[%{app_name}] Ditt verk har blitt slettet av en administrator"
+ text:
+ tos_violation: Hvis det er mulig at verket ditt brøt med Arkivets brukervilkår,
+ vennligst kontakt vår komité for retningslinjer og misbruk %{contact_abuse_url}.
+ admin_hidden_work_notification:
+ access: Mens verket ditt er skjult kan du fortsatt få tilgang til det gjennom
+ lenken ovenfor, men det vil ikke være synlig på siden med oversikt over dine
+ verk, og det vil heller ikke være tilgjengelig for andre brukere av AO3.
+ check_email: Vennligst sjekk e-posten din, inkludert spam innboksen ettersom
+ teamet for retningslinjer og misbruk allerede kan ha kontaktet deg for å forklare
+ hvorfor verket ditt har blitt skjult.
+ contact_abuse: kontakt retningslinjer og misbruk
+ html:
+ help: Hvis du er usikker på hvorfor verket ditt ble skjult og du ikke har
+ mottatt videre kommunikasjon angående dette, vennligst %{contact_abuse_link}
+ direkte.
+ hidden: Verket ditt %{title} har blitt skjult av teamet for retningslinjer
+ og misbruk og er ikke lenger offentlig tilgjengelig.
+ tos_violation: Hvis verket ditt ble skjult fordi det er i strid med AO3s %{tos_link}
+ blir du nødt til å gjøre endringer for å rette opp dette. Dersom du ikke
+ endrer verket ditt slik at det samsvarer med brukervilkårene, kan det føre
+ til at verket ditt blir slettet fra AO3.
+ subject: "[%{app_name}] Verket ditt har blitt skjult av teamet for retningslinjer
+ og misbruk"
+ text:
+ help: 'Hvis du er usikker på hvorfor verket ditt ble skjult og du ikke har
+ mottatt videre kommunikasjon angående dette, vennligst kontakt retningslinjer
+ og misbruk direkte: %{contact_abuse_url}.'
+ hidden: Verket ditt "%{title}" (%{work_url}) har blitt skjult av teamet for
+ retningslinjer og misbruk og er ikke lenger offentlig tilgjengelig.
+ tos_violation: Hvis verket ditt ble skjult fordi det er i strid med AO3s brukervilkår
+ (%{tos_url}) blir du nødt til å gjøre endringer for å rette opp dette. Dersom
+ du ikke endrer verket ditt slik at det samsvarer med brukervilkårene, kan
+ det føre til at verket ditt blir slettet fra AO3.
+ tos: Brukervilkår
+ anonymous_or_unrevealed_notification:
+ anonymous_info: Anonyme verk inkluderes i tagg-lister, men ikke på oversikten
+ over dine verk. På selve verket vil brukernavnet ditt erstattes med “Anonymous”
+ (Anonym).
+ anonymous_unrevealed_info: Samlingens administratorer kan ved en senere anledning
+ velge å offentliggjøre verket men holde det anonymt. Brukere som abonnerer
+ på dine verk vil ikke motta noen notifikasjon når dette skjer. Når verket
+ er offentliggjort vil det inkluderes i tagg-lister, men ikke på oversikten
+ over dine verk. På selve verket vil brukernavnet ditt erstattes med “Anonymous”
+ (Anonym).
+ changed_status:
+ anonymous:
+ html: Administratorene for %{collection_link} har gjort verket ditt %{work_link}
+ anonymt.
+ text: Administratorene for samlingen "%{collection_title}" (%{collection_url})
+ har gjort verket ditt "%{work_title}" (%{work_url}) anonymt.
+ anonymous_unrevealed:
+ html: Administratorene for samlingen %{collection_link} har endret statusen
+ til verket ditt %{work_link} til skjult og anonymisert.
+ text: Administratorene for samlingen "%{collection_title}" (%{collection_url})
+ har endret statusen til verket ditt "%{work_title}" (%{work_url}) til
+ anonymisert og skjult.
+ unrevealed:
+ html: Administratorene for samlingen %{collection_link} har endret statusen
+ til verket ditt %{work_link} til skjult.
+ text: Administratorene for samlingen "%{collection_title}" (%{collection_url})
+ har endret statusen til verket ditt "%{work_title}" (%{work_url}) til
+ skjult.
+ collection_items_link_text: siden for Approved Collection Items (Godkjente samlingsverk)
+ do_not_want:
+ anonymous:
+ html: Dersom du ikke ønsker at verket ditt skal være anonymisert, vennligst
+ gå til %{collection_items_link} for å fjerne det fra det samlingen.
+ text: 'Dersom du ikke ønsker at verket ditt skal være anonymisert, vennligst
+ besøk din side for Approved Collection Items (Godkjente samlingsverk)
+ for å fjerne det fra denne samlingen: %{collection_items_url}'
+ anonymous_unrevealed:
+ html: Dersom du ikke ønsker at verket ditt skal være hverken skjult eller
+ anonymisert, vennligst besøk %{collection_items_link} for å fjerne det
+ fra denne samlingen.
+ text: 'Dersom du ikke ønsker at verket ditt skal være hverken skjult eller
+ anonymisert, vennligst besøk din side for Approved Collection Items (Godkjente
+ samlingsverk) for å fjerne det fra denne samlingen: %{collection_items_url}'
+ unrevealed:
+ html: Dersom du ikke ønsker at verket ditt skal være skjult, vennligst besøk
+ %{collection_items_link} for å fjerne det fra denne samlingen.
+ text: 'Dersom du ikke ønsker at verket ditt skal være skjult, vennligst
+ besøk din side for Approved Collection Items (Godkjente samlingsverk)
+ for å fjerne det fra denne samlingen: %{collection_items_url}'
+ faq_link_text: ofte stilte spørsmål om samlinger.
+ more_info:
+ html: For mer informasjon kan du besøke våre %{faq_link}.
+ text: 'For mer informasjon kan du besøke våre ofte stilte spørsmål om samlinger:
+ %{faq_url}'
+ subject:
+ anonymous: "[%{app_name}] Verket ditt har blitt anonymisert"
+ anonymous_unrevealed: "[%{app_name}] Verket ditt har blitt anonymisert og
+ skjult."
+ unrevealed: "[%{app_name}] Verket ditt er skjult"
+ unrevealed_info: Skjulte verk inkluderes ikke i tagg-lister, ei heller i oversikten
+ over dine verk. Alle som følger en link til verket ditt vil få en notifikasjon
+ om at verket for øyeblikket er skjult, og at de derfor ikke har tilgang.
+ archivist_added_to_collection_notification:
+ approved_collection_items_page: siden din for Approved Collection Items (godkjente
+ samlingselementer)
+ archivist_notice: Fordi samlingsadministratorene handler i kraft av å være arkivarer
+ for Open Doors (Åpne Dører), har de lov til å legge ditt verk inn i denne
+ samlingen, selv om du har samlingsinvitasjoner deaktivert. Arkivarer vil kun
+ legge til et verk i en samling dersom det var en del av et importert arkiv.
+ removal_instructions:
+ html: Om du ønsker å fjerne verket ditt fra denne samlingen, besøk %{approved_items_link}.
+ text: 'Om du ønsker å fjerne verket ditt fra denne samlingen, besøk siden
+ din for Approved Collection Items (godkjente samlingselementer): %{approved_items_url}.'
+ subject: "[%{app_name}][%{collection_title}] En arkivar fra Open Doors (Åpne
+ Dører) har lagt til verket ditt i en samling."
+ work_added:
+ html: Samlingsadministratorene til %{collection_link} har lagt ditt verk %{work_link}
+ inn i samlingen sin!
+ text: Samlingsadministratorene til "%{collection_title}" (%{collection_url})
+ har lagt ditt verk "%{work_title}" (%{work_url}) inn i samlingen sin!
+ challenge_assignment_notification:
+ any: Uansett
+ assignment:
+ html: Du har fått følgende i oppdrag i %{link}-utfordringen på Archive of
+ Our Own (Vårt eget arkiv)!
+ description: 'Beskrivelse:'
+ due: 'Dette oppdraget har frist:'
+ html:
+ footer: Du mottar denne e-posten fordi du har svart på %{title}-utfordringen.
+ For mer informasjon om denne utfordringen og kontaktinformasjonen til moderatorene,
+ gå til %{footer_link}.
+ footer_link: utfordringens profilside
+ look_up: Du kan finne dette oppdraget på %{link}.
+ look_up_link: Assignments (oppdrag)-siden din
+ optional_tags: 'Valgfrie tagger:'
+ prompts: 'Inspirasjon:'
+ prompt_url: 'URL til inspirasjonen:'
+ recipient: 'Mottaker:'
+ recipient_missing: 'Ingen: kontakt en moderator for hjelp!'
+ subject: "[%{app_name}][%{collection_title}] Ditt oppdrag!"
+ text:
+ assignment: Du har fått følgende i oppdrag i "%{collection_title}"-utfordringen
+ (%{collection_url}) på Archive of Our Own (Vårt eget arkiv)!
+ footer: Du mottar denne e-posten fordi du har meldt deg på %{title}-utfordringen
+ (%{url}). For mer informasjon om denne utfordringen og kontaktinformasjonen
+ til moderatorene, gå til %{profile_url}.
+ look_up: Du kan finne dette oppdraget på Assignments (oppdrag)-siden din på
+ %{link}.
+ change_email:
+ changed:
+ html: "%{login}, e-posten knyttet til din konto har blitt endret til %{email}"
+ text: "%{login}, e-posten knyttet til din konto har blitt endret til %{email}"
+ subject: "[%{app_name}] E-post endret"
+ claim_notification:
+ access:
+ contact_support: kontakt AO3 support
+ html: Avhengig av arkivet, kan det hende verkene dine har blitt importert
+ som begrenset til kun registrerte brukere (for å unngå at de dukker opp
+ i Google-søk). Hvis dette er tilfellet, vil verkene kun være tilgjengelige
+ for brukere som er logget inn, med mindre du velger å gjøre dem helt synlige.
+ For hjelp med å åpne, si fra deg eller slette verkene dine, vennligst %{contact_support_link}.
+ text: 'Avhengig av arkivet, kan det hende verkene dine har blitt importert
+ som begrenset til kun registrerte brukere (for å unngå at de dukker opp
+ i Google-søk). Hvis dette er tilfellet, vil verkene kun være tilgjengelige
+ for brukere som er logget inn, med mindre du velger å gjøre dem helt synlige.
+ For hjelp med å åpne, si fra deg eller slette verkene dine, vennligst kontakt
+ AO3 support her: %{support_url}.'
+ email_tips: Hvis du kontakter oss, vennligst legg til e-postadresser fra @transformativeworks.org
+ til listen din over sikre kontakter og sjekk søppelpostmappene dine etter
+ svar fra oss.
+ introduction:
+ ao3_name: Archive of Our Own – AO3 (Vårt eget arkiv)
+ html: Du mottar denne e-posten fordi du hadde verk i et fanverkarkiv som har
+ blitt importert av %{open_doors_name_link} til %{app_link}. Fordi denne
+ e-postadressen er knyttet til en som er registrert hos det importerte arkivet,
+ har de tilhørende fanverkene (listet opp nedenfor) automatisk blitt lagt
+ til AO3-kontoen din.
+ open_doors_name: Open Doors (Åpne Dører)
+ text: 'Du mottar denne e-posten fordi du hadde verk i et fanverkarkiv som
+ har blitt importert av Open Doors (Åpne Dører) (%{open_doors_url}) til Archive
+ of Our Own – AO3 (Vårt eget arkiv): %{app_url}. Fordi denne e-postadressen
+ er knyttet til en som er registrert hos det importerte arkivet, har de tilhørende
+ fanverkene (listet opp nedenfor) automatisk blitt lagt til AO3-kontoen din.'
+ mistake:
+ contact_open_doors: kontakt Åpne Dører
+ html: Hvis dette er feil og disse verkene ikke tilhører deg, ikke slett dem!
+ Vennligst %{contact_open_doors_link}, så ordner vi det.
+ text: Hvis dette er feil og disse verkene ikke tilhører deg, ikke slett dem!
+ Vennligst kontakt Åpne Dører (%{open_doors_url}), så ordner vi det.
+ more_info:
+ ao3_news: AO3 Nyheter
+ contact_support: kontakt AO3 support
+ faq_page: ofte stilte spørsmål-side
+ html: Du kan lese kunngjøringer om nylige arkivforflyttninger på %{ao3_news_link},
+ og finne ytterligere informasjon på Åpne Dører sin %{faq_page_link} eller
+ %{tutorial_page_link}. For spørsmål som ikke er besvart i ofte stilte spørsmål,
+ tutorialer, eller i denne e-posten, vennligst %{contact_support_link}.
+ text: 'Du kan lese kunngjøringer om nylige arkivforflyttninger på AO3 Nyheter
+ (%{news_url}) og finne ytterligere informasjon på Åpne Dører sin ofte stilte
+ spørsmål-side (%{open_doors_faq_url}) eller tutorial-side (%{open_doors_tutorial_url}).
+ For spørsmål som ikke er besvart i ofte stilte spørsmål, tutorialer, eller
+ i denne e-posten, vennligst kontakt support her: %{support_url}.'
+ tutorial_page: tutorial-side
+ other_works:
+ contact_open_doors: kontakt Åpne Dører
+ html: Hvis du hadde andre verk på det importerte arkivet under en e-postadresse
+ du ikke lenger har tilgang til, vennligst %{contact_open_doors_link} med
+ all informasjon som kan hjelpe med å verifisere identiteten din.
+ text: Hvis du hadde andre verk på det importerte arkivet under en e-postadresse
+ du ikke lenger har tilgang til, vennligst kontakt Åpne Dører med all informasjon
+ som kan hjelpe med å verifisere identiteten din.
+ questions:
+ contact_support: kontakt AO3 support
+ html: For andre henvendelser, vennligst %{contact_support_link}.
+ text: 'For andre henvendelser, vennligst kontakt AO3 support her: %{support_url}.'
+ redirects:
+ html: For å bevare anbefalingslister og bokmerker, kan nettadressen til det
+ importerte arkivet omdirigere til den importerte kopien av disse verkene
+ i en begrenset periode (sjekk kunngjøringsinnlegget til arkivet ditt for
+ å være sikker). Hvis du allerede har lastet opp en kopi av disse verkene
+ og du %{negation} brukte funksjonen hvor verket importeres fra URL-en, kommer
+ det til å være to kopier av samme verk på AO3.
+ subject: "[%{app_name}] Verk lastet opp"
+ update_redirect:
+ contact_open_doors: kontakt Åpne Dører
+ html: Hvis du ønsker at Åpne Dører skal oppdatere omdirigeringspunktet til
+ verket ditt som allerede eksisterer på AO3, vennligst slett den importerte
+ kopien og %{contact_open_doors_link} med navnet på AO3-kontoen din, navnet
+ på kontoen din fra det importerte arkivet, samt tittelen og URL-en til fanverket
+ du ønsker at omdirigeringen skal gå til. (Hvis du har flere verk du ønsker
+ å endre omdirigeringen til, kan du oppgi alle sammen i én e-post.)
+ text: 'Hvis du ønsker at Åpne Dører skal oppdatere omdirigeringspunktet til
+ verket ditt som allerede eksisterer på AO3, vennligst slett den importerte
+ kopien og kontakt Åpne Dører her: %{open_doors_url} med navnet på AO3-kontoen
+ din, navnet på kontoen din fra det importerte arkivet, samt tittelen og
+ URL-en til fanverket du ønsker at omdirigeringen skal gå til. (Hvis du har
+ flere verk du ønsker å endre omdirigeringen til, kan du oppgi alle sammen
+ i én e-post.)'
+ works_by: 'Disse verkene ble skrevet under e-posten: %{email}'
+ work_info:
+ html: "%{work_link} (%{fandom})"
+ text:
+ no_fandom: "- %{work_title} %{work_url}"
+ with_fandom: "- %{work_title} %{work_url} (%{fandom})"
+ collection_notification:
+ assignments_sent:
+ complete: Alle oppdrag er nå sendt ut.
+ subject: Oppdrag utsendt
+ html:
+ received_message: 'Du har fått en melding om samlingen din %{collection_link}:'
+ text:
+ received_message: 'Du har fått en melding om samlingen din "%{collection_title}"
+ (%{collection_url}):'
+ creatorship_notification:
+ explanation: Når du er medskaper på et verk kan du bli lagt til på nye kapitler
+ uavhengig av dine medskaper-innstillinger. Du blir også lagt til på alle serier
+ som verket tilhører.
+ html:
+ creation: "%{creation_link} av %{pseud_links}"
+ edit_chapter: redigere kapittelet
+ edit_series: redigere serien
+ remove_chapter: Dersom du har blitt lagt til ved en feiltakelse eller du ikke
+ ønsker å stå oppført som skaper, kan du %{edit_chapter_link} for å fjerne
+ deg selv som skaper.
+ remove_series: Dersom du har blitt lagt til ved en feiltakelse eller du ikke
+ ønsker å stå oppført som skaper, kan du %{edit_series_link} for å fjerne
+ deg selv som skaper.
+ intro_chapter: 'Brukeren %{adding_user} har lagt til pseudonymet ditt %{pseud}
+ som medskaper på følgende kapittel:'
+ intro_series: 'Brukeren %{adding_user} har lagt til pseudonymet ditt %{pseud}
+ som medskaper på følgende serie:'
+ subject: "[%{app_name}] Varsel om at du har blitt lagt til som medskaper"
+ text:
+ creation: "%{title} (%{url}) av %{pseuds}"
+ remove_chapter: 'Dersom du har blitt lagt til ved en feiltakelse eller ikke
+ ønsker å stå oppført som skaper, kan du redigere kapittelet for å fjerne
+ deg selv som skaper: %{url}'
+ remove_series: 'Dersom du har blitt lagt til ved en feiltakelse eller du ikke
+ ønsker å stå oppført som skaper, kan du redigere serien for å fjerne deg
+ selv som skaper: %{url}'
+ creatorship_notification_archivist:
+ explanation: Fordi hen er offisielt utstedt arkivar for Åpne Dører, har hen
+ lov til å legge deg til uten forespørsel, selv om du har slått av innstillingen
+ for å kunne legges til som medskaper.
+ html:
+ creation: "%{creation_link} av %{pseud_links}"
+ edit_chapter: redigere kapittelet
+ edit_series: redigere serien
+ edit_work: redigere arbeidet
+ remove_chapter: Dersom du har blitt lagt til ved en feil, eller ikke ønsker
+ å listes som skaper, så kan du %{edit_chapter_link} for å fjerne deg selv
+ som skaper.
+ remove_series: Dersom du har blitt lagt til ved en feil, eller ikke ønsker
+ å listes som skaper, så kan du %{edit_series_link} for å fjerne deg selv
+ som skaper.
+ remove_work: Dersom du har blitt lagt til ved en feiltakelse eller ikke ønsker
+ å stå oppført som skaper, kan du %{edit_work_link} for å fjerne deg selv
+ som skaper.
+ intro_chapter: 'Brukeren %{archivist} har lagt til pseudonymet ditt %{pseud}
+ som medskaper på følgende kapittel:'
+ intro_series: 'Brukeren %{archivist} har lagt til pseudonymet ditt %{pseud}
+ som medskaper på følgende serie:'
+ intro_work: 'Brukeren %{archivist} har lagt til pseudonymet ditt %{pseud} som
+ medskaper på følgende verk:'
+ subject: "[%{app_name}] En arkivar har lagt deg til som medskaper"
+ text:
+ creation: "%{title} (%{url}) av %{pseuds}"
+ remove_chapter: 'Dersom du har blitt lagt til ved en feiltakelse eller ikke
+ ønsker å stå oppført som skaper, kan du redigere kapittelet for å fjerne
+ deg selv som skaper: %{url}'
+ remove_series: 'Dersom du har blitt lagt til ved en feiltakelse eller ikke
+ ønsker å stå oppført som skaper, kan du redigere serien for å fjerne deg
+ selv som skaper: %{url}'
+ remove_work: 'Dersom du har blitt lagt til ved en feiltakelse eller ikke ønsker
+ å stå oppført som skaper, kan du redigere verket for å fjerne deg selv som
+ skaper: %{url}'
+ creatorship_request:
+ html:
+ creation: "%{creation_link} av %{pseud_links}"
+ instructions: Du kan godkjenne eller avvise denne forespørselen på siden din
+ for %{page_name}.
+ page_name: Co-Creator Requests (medskaperforespørsler)
+ intro_chapter: 'Brukeren %{inviting_user} har invitert pseudonymet ditt %{pseud}
+ til å bli lagt til som medskaper på følgende kapittel:'
+ intro_series: 'Brukeren %{inviting_user} har invitert pseudonymet ditt %{pseud}
+ til å bli lagt til som medskaper på følgende serie:'
+ intro_work: 'Brukeren %{inviting_user} har invitert pseudonymet ditt %{pseud}
+ til å bli lagt til som medskaper på følgende verk:'
+ subject: "[%{app_name}] Medskaperforespørsel"
+ text:
+ creation: "%{title} (%{url}) av %{pseuds}"
+ instructions: 'Du kan godkjenne eller avvise denne forespørselen på siden
+ din for Co-Creator Requests (medskaperforespørsler): %{url}'
+ delete_work_notification:
+ attachment: For videre referanse finner du en kopi at verket ditt vedlagt.
+ deleted_other:
+ html: Ditt verk %{title} ble slettet etter forespørsel fra %{pseud}.
+ text: Ditt verk “%{title}" ble slettet etter forespørsel fra %{pseud}.
+ deleted_yourself:
+ html: Ditt verk %{title} ble slettet per din forespørsel.
+ text: Ditt verk "%{title}" ble slettet per din forespørsel.
+ questions:
+ html: Dersom du har noen spørsmål, vennligst %{support}.
+ text: Dersom du har noen spørsmål, vennligst %{support} (%{url}).
+ subject: "[%{app_name}] Ditt verk har blitt slettet"
+ support: kontakt support
+ invitation_to_claim:
+ access:
+ text: Avhengig av arkivet, kan verkene dine ha blitt importert begrenset til
+ registrerte brukere (slik at de ikke vises i Google-søk). Om det er slik,
+ vil verkene kun være synlige for innloggede brukere med mindre du velger
+ å gjøre dem synlige. For hjelp med å låse opp, si i fra deg eller slette
+ verk, vennligst kontakt AO3 support.
+ claim_or_remove:
+ html: Gjør krav på eller fjern verk her.
+ text: 'Gjør krav på eller fjern verk her: %{claim_url}'
+ email_tips: 'Dersom du tar kontakt med oss, vennligst merk e-postadresser fra
+ @transformativeworks.org som trygge og sjekk spamfilteret. '
+ html:
+ ao3_news: AO3 nyheter
+ contact_open_doors: kontakt Åpne Dører
+ contact_support: kontakt AO3 support
+ faq_page: FAQ-side
+ tutorial_page: tutorial-side
+ introduction:
+ text: Du mottar denne e-posten fordi et arkiv nylig har blitt importert av
+ Åpne Dører (%{open_doors_link}) inn på %{app_name} (%{app_short_name} -
+ %{app_url}), og vi tror at de følgende verkene tilhører deg. Vi vil gi deg
+ sjansen til å gjøre krav på (eller slette/si i fra deg) disse verkene om
+ du vil. Og om du ikke allerede har en konto under en annen e-postadresse,
+ vil vi gjerne invitere deg med!
+ mistake:
+ text: Om dette er en feil og disse verkene ikke tilhører deg, vær snill og
+ ikke slett dem! Vennligst kontakt Åpne Dører (%{open_doors_link}) og vi
+ vil ordne opp i det.
+ more_info:
+ text: 'Du kan lese kunngjøringene om nylige arkiv-flyttinger på AO3 nyheter
+ (%{news_link}), og du finner tilleggsinformasjon på Åpne Dørers FAQ-side
+ (%{open_doors_faq_link}) eller tutorial-siden (%{open_doors_tutorial_link}).
+ For spørsmål som ikke besvares i FAQ-en, tutorialen, eller denne e-posten,
+ vennligst kontakt support her: %{support_link}.'
+ other_works:
+ text: Om du hadde andre verk på det importerte arkivet under en e-postadresse
+ du ikke lenger har tilgang til, vennligst kontakt Åpne Dører med informasjon
+ som kan bekrefte din identitet.
+ questions:
+ text: 'For andre spørsmål, vennligst kontakt AO3 support her: %{support_link}.'
+ redirects: For å bevare anbefalingslister og bokmerker, vil det importerte arkivets
+ URL muligens viderekoble til den importerte versjonen av disse verkene i en
+ begrenset periode (sjekk innlegget med kunngjøringen for arkivet ditt for
+ å være sikker). Om du allerede har publisert noen av disse verkene og du IKKE
+ importerer fra URL, vil det eksistere to kopier av samme verk på arkivet.
+ subject: "[%{app_name}] Invitasjon til å gjøre krav på verk"
+ unwanted:
+ text: Om disse verkene tilhører deg men du ikke vil ha dem, kan du si dem
+ i fra deg (slik at de blir på AO3, men navnet ditt blir fjernet) eller slette
+ dem (slik at de blir fjernet helt fra AO3). Du behøver ikke å legge disse
+ verkene til en konto for å slette dem eller si dem i fra deg – du kan gjøre
+ det direkte fra krav-linken over. (Om du trenger hjelp med dette, vennligst
+ kontakt support på %{support_link}.)
+ update_redirect:
+ text: Om du vil at Åpne Dører skal oppdatere viderekoblingen til å vise til
+ et allerede eksisterende verk, vennligst slett den importerte versjonen,
+ og kontakt Åpne Dører på %{open_doors_link} med ditt AO3-brukernavn, brukernavnet
+ ditt på det importerte arkivet samt tittelen og URLen til det fanverket
+ du vil viderekoble til. (Om du har flere verk du vil endre viderekoblingen
+ til, kan du liste alle i samme e-post).
+ uploaded_list: 'Verkene det gjelder er:'
+ invite_increase_notification:
+ html:
+ body:
+ one: Vi ville bare informere deg om at du har %{count} ny invitasjon som
+ kan brukes til å lage en ny bruker på arkivet. Du kan invitere en venn
+ på %{invitation_page_link}.
+ other: Vi ville bare informere deg om at du har %{count} nye invitasjoner
+ som kan brukes til å lage nye brukere på arkivet. Du kan invitere en venn
+ på %{invitation_page_link}.
+ invitation_page_link_text: din Invitations (invitasjoner)-side
+ subject: "[%{app_name}] Nye invitasjoner"
+ text:
+ body:
+ one: Vi ville bare informere deg om at du har %{count} ny invitasjon som
+ kan brukes til å lage en ny bruker på arkivet. Du kan invitere en venn
+ på din Invitations (invitasjoner)-side (%{invitation_page_url}).
+ other: Vi ville bare informere deg om at du har %{count} nye invitasjoner
+ som kan brukes til å lage nye brukere på arkivet. Du kan invitere en venn
+ på din Invitations (invitasjoner)-side (%{invitation_page_url}).
+ invite_request_declined:
+ main:
+ one: Vi beklager å måtte informere deg om at din forespørsel om å få tilsendt
+ en ny invitasjon ikke kan innfris på dette tidspunktet.
+ other: Vi beklager å måtte informere deg om at din forespørsel om å få tilsendt
+ %{count} nye invitasjoner ikke kan innfris på dette tidspunktet.
+ reason: 'Din forespørsel var:'
+ subject: "[%{app_name}] Forespørsel om ekstra invitasjonskode avvist"
+ recipient_notification:
+ html:
+ collection: Et verk er publisert som en gave til deg i samlingen %{collection_link}
+ på AO3!
+ no_collection: Et verk er publisert på AO3 som en gave til deg!
+ subject:
+ collection: "[%{app_name}][%{collection_title}] Du har fått et verk fra %{collection_title}"
+ no_collection: "[%{app_name}] Du har fått et verk"
+ text:
+ collection: Et verk er publisert som en gave til deg i samlingen "%{collection_title}"
+ (%{collection_url}) på AO3!
+ signup_notification:
+ activate:
+ html: Vennligst %{activate_account_link}.
+ text: 'Vennligst følg denne lenken for å aktivere kontoen din: %{activate_account_url}'
+ activate_your_account: følg denne lenken for å aktivere kontoen din
+ admin_posts: AO3 nyheter
+ bye: Vi håper du vil trives med å bruke arkivet.
+ contact_support: kontakt vårt support team
+ faq: Ofte stilte spørsmål
+ features:
+ html: Så snart kontoen din er aktivert kan du publisere dine verk, lage e-post-abonnementer
+ slik at du får beskjed når dine favoritter har oppdatert, tilpasse dine
+ preferanser for hvordan siden ser ut og fungerer for deg, holde orden på
+ hvilke verk du har klikket på via loggen, og mye mer.
+ text: Så snart kontoen din er aktivert kan du publisere dine verk, lage e-post-abonnementer
+ slik at du får beskjed når dine favoritter har oppdatert, tilpasse dine
+ preferanser for hvordan siden ser ut og fungerer for deg, holde orden på
+ hvilke verk du har klikket på via loggen, og mye mer.
+ information:
+ html: Du kan finne mye informasjon og råd om hvordan du bruker arkivet under
+ %{faq_link}. Nyheter om nettstedets utvikling finner du her i %{admin_posts_link}.
+ Om du har behov for mer hjelp, støter på en feil eller har andre spørsmål
+ eller tilbakemeldinger, vennligst %{contact_support_link}, som alltid står
+ klare til å hjelpe.
+ text: 'Du kan finne mye informasjon og råd for hvordan du bruker arkivet i
+ vår FAQ (ofte stilte spørsmål) under %{faq_url}. Oppdateringer om nettstedets
+ utvikling finner du hos AO3 nyheter: %{admin_posts_url}. Om du har behov
+ for mer hjelp, støter på en feil eller har andre spørsmål eller tilbakemeldinger,
+ vennligst kontakt vårt support team, som alltid står klare til å hjelpe:
+ %{contact_support_url}.'
+ welcome: Velkommen til Archive of Our Own, %{login}!
diff --git a/config/locales/phrase-exports/nl.yml b/config/locales/phrase-exports/nl.yml
new file mode 100755
index 0000000..3aef3de
--- /dev/null
+++ b/config/locales/phrase-exports/nl.yml
@@ -0,0 +1,563 @@
+---
+nl:
+ activerecord:
+ attributes:
+ archive_warning:
+ name_with_colon:
+ one: 'Waarschuwing:'
+ other: 'Waarschuwingen:'
+ category:
+ name_with_colon:
+ one: 'Categorie:'
+ other: 'Categorieën:'
+ character:
+ name_with_colon:
+ one: 'Personage:'
+ other: 'Personages:'
+ fandom:
+ name_with_colon:
+ one: 'Fandom:'
+ other: 'Fandoms:'
+ freeform:
+ name_with_colon:
+ one: 'Extra Tag:'
+ other: 'Extra Tags:'
+ rating:
+ name_with_colon: 'Classificatie:'
+ relationship:
+ name_with_colon:
+ one: 'Relatie:'
+ other: 'Relaties:'
+ work:
+ chapter_total_display: Hoofdstukken
+ summary: Samenvatting
+ models:
+ archive_warning:
+ one: Waarschuwing
+ other: Waarschuwingen
+ category:
+ one: Categorie
+ other: Categorieën
+ chapter:
+ one: Hoofdstuk
+ other: Hoofdstukken
+ character:
+ one: Personage
+ other: Personages
+ fandom:
+ one: Fandom
+ other: Fandoms
+ freeform:
+ one: Extra Tag
+ other: Extra Tags
+ rating:
+ one: Classificatie
+ other: Classificaties
+ relationship:
+ one: Relatie
+ other: Relaties
+ series:
+ one: Serie
+ other: Series
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ one: een bezoeker
+ other: "%{count} bezoekers"
+ left_kudos:
+ html:
+ one: Je hebt kudos gekregen van %{givers_list} voor %{commentable_link}.
+ other: Je hebt kudos gekregen van %{givers_list} voor %{commentable_link}.
+ text:
+ one: Je hebt kudos gekregen van %{givers_list} voor %{commentable_title}
+ (%{commentable_url}).
+ other: Je hebt kudos gekregen van %{givers_list} voor %{commentable_title}
+ (%{commentable_url}).
+ single_guest:
+ giver: een bezoeker
+ html: Je hebt kudos gekregen van %{giver} voor %{commentable_link}.
+ text: Je hebt kudos gekregen van een bezoeker voor %{commentable_title} (%{commentable_url}).
+ subject: "[%{app_name}] Kudos!"
+ mailer:
+ general:
+ closing:
+ formal: Met vriendelijke groeten,
+ informal: Groeten,
+ creation:
+ link_with_word_count: "%{creation_link} (%{word_count})"
+ title_with_chapter_number: Hoofdstuk %{position} van %{title}
+ title_with_word_count: '"%{creation_title}" (%{word_count})'
+ word_count:
+ one: "%{count} woord"
+ other: "%{count} woorden"
+ footer:
+ general:
+ about:
+ html: AO3 is een door fans beheerd en ondersteund archief dat afhankelijk
+ is van %{donate_link}
+ text: 'AO3 is een door fans beheerd en ondersteund archief dat afhankelijk
+ is van jouw donaties: %{donate_url}.'
+ html:
+ donate_link_text: jouw donaties
+ support_link_text: Neem contact op met Support
+ unwanted_email:
+ html: Is dit bericht niet voor jou bedoeld? %{support_link}.
+ text: Is dit bericht niet voor jou bedoeld? Neem dan contact op met Support
+ via %{support_url}.
+ sent_at: Verstuurd om %{sent_at}.
+ greeting:
+ formal_html: Beste %{name},
+ informal:
+ addressed_html: Hallo, %{name}!
+ unaddressed: Hallo!
+ introductory: Hallo van Archive of Our Own – AO3 (Ons Eigen Archief)!
+ metadata_label_indicator: ":"
+ signature:
+ abuse_team: AO3 Beleids- en Misbruikcommissie
+ app_short_name: AO3
+ open_doors: Open Doors (Open Deuren)
+ parent_org: Organization for Transformative Works – OTW (Organisatie voor
+ Transformatieve Werken)
+ support: AO3 Support
+ users:
+ mailer:
+ reset_password_instructions:
+ expiration: Als je deze link niet binnen een week gebruikt om je wachtwoord
+ opnieuw in te stellen, zal de link vervallen en moet je een nieuwe aanvragen.
+ intro: 'Er is een wachtwoordherstel voor je account aangevraagd. Je kan je
+ wachtwoord veranderen door de onderstaande link te volgen en een nieuw wachtwoord
+ te kiezen:'
+ link_title: Verander mijn wachtwoord.
+ subject: "[%{app_name}] Wijzig je wachtwoord"
+ unrequested: Als je geen wachtwoordherstel hebt aangevraagd, kan je deze e-mail
+ negeren en zal je vorige wachtwoord blijven werken.
+ user_mailer:
+ admin_deleted_work_notification:
+ bye: Een kopie van je werk is ter referentie bijgevoegd.
+ contact_abuse: neem dan contact op met Beleid & Misbruik
+ deleted:
+ html: Jouw werk %{title} is verwijderd uit AO3 door een site-admin.
+ text: Jouw werk "%{title}" is verwijderd uit AO3 door een site-admin.
+ html:
+ tos_violation: Als het mogelijk is dat jouw werk de servicevoorwaarden van
+ AO3 schond, %{contact_abuse_link}.
+ import_project:
+ html: Als jouw werk onderdeel was van een importproject beheerd door Open
+ Doors (Open Deuren) en je nog verdere vragen hebt, %{opendoors_link}.
+ text: Als jouw werk onderdeel was van een importproject beheerd door Open
+ Doors (Open Deuren) en je nog verdere vragen hebt, neem dan contact op met
+ Open Deuren (%{opendoors_link}).
+ opendoors: neem dan contact op met Open Deuren
+ subject: "[%{app_name}] Jouw werk is verwijderd door een admin"
+ text:
+ tos_violation: Als het mogelijk is dat jouw werk de servicevoorwaarden van
+ AO3 schond, neem dan contact op met Beleid & Misbruik (%{contact_abuse_url}).
+ admin_hidden_work_notification:
+ access: Terwijl je werk verborgen is, is het voor jou toegankelijk via de bovenstaande
+ link maar het zal niet zichtbaar zijn op de pagina met jouw werken en niet
+ beschikbaar zijn voor andere gebruikers van AO3.
+ check_email: Controleer je e-mail, inclusief je spamfolder, want het Beleids-
+ en Misbruikteam kan al geprobeerd hebben om contact met je op te nemen om
+ uit te leggen waarom je werk is verborgen.
+ contact_abuse: neem dan contact op met het Beleids- en Misbruikteam
+ html:
+ help: Als je niet weet waarom je werk verborgen is en je hebt geen verdere
+ communicatie hierover ontvangen, %{contact_abuse_link}.
+ hidden: Jouw werk %{title} is verborgen door het Beleids- en Misbruikteam
+ en is niet meer publiek toegankelijk.
+ tos_violation: Als je werk verborgen is omdat het AO3's %{tos_link} overtreedt,
+ zal van jou verwacht worden dat je actie onderneemt om de overtreding te
+ corrigeren. Als je er niet in slaagt je werk inschikkelijk te maken met
+ de Servicevoorwaarden, kan dat ertoe leiden dat je werk verwijderd wordt
+ van AO3.
+ subject: "[%{app_name}] Jouw werk is verborgen door het Beleids- en Misbruikteam"
+ text:
+ help: 'Als je niet weet waarom je werk verborgen is en je hebt geen verdere
+ communicatie hierover ontvangen, neem dan rechtstreeks contact op met het
+ Beleids- en Misbruikteam: %{contact_abuse_url}.'
+ hidden: Jouw werk "%{title}" (%{work_url}) is verborgen door het Beleids-
+ en Misbruikteam en is niet meer publiek toegankelijk.
+ tos_violation: Als je werk verborgen is omdat het AO3's Servicevoorwaarden
+ (%{tos_url}) overtreedt, zal van jou verwacht worden dat je actie onderneemt
+ om de overtreding te corrigeren. Als je er niet in slaagt je werk inschikkelijk
+ te maken met de Servicevoorwaarden, kan dat ertoe leiden dat je werk verwijderd
+ wordt van AO3.
+ tos: Servicevoorwaarden
+ anonymous_or_unrevealed_notification:
+ anonymous_info: Anonieme werken worden wel weergegeven in taglijsten, maar niet
+ op de pagina met jouw werken. Op het werk wordt je gebruikersnaam vervangen
+ door “Anonymous” (Anoniem).
+ anonymous_unrevealed_info: De beheerders van de verzameling kunnen je werk later
+ onthullen maar het anoniem laten. Mensen die op jou geabonneerd zijn, zullen
+ niet van deze verandering op de hoogte gebracht worden. Als je werk onthuld
+ is, zal het wel in taglijsten weergegeven worden, maar niet op de pagina met
+ jouw werken. Op het werk zal je gebruikersnaam vervangen worden door “Anonymous”
+ (Anoniem).
+ changed_status:
+ anonymous:
+ html: De beheerders van de verzameling %{collection_link} hebben de status
+ van je werk %{work_link} veranderd naar anoniem.
+ text: De beheerders van de verzameling "%{collection_title}" (%{collection_url})
+ hebben de status van je werk "%{work_title}" (%{work_url}) veranderd naar
+ anoniem.
+ anonymous_unrevealed:
+ html: De beheerders van de verzameling %{collection_link} hebben de status
+ van je werk %{work_link} veranderd naar anoniem en nog niet onthuld.
+ text: De beheerders van de verzameling "%{collection_title}" (%{collection_url})
+ hebben de status van je werk "%{work_title}" (%{work_url}) veranderd naar
+ anoniem en nog niet onthuld.
+ unrevealed:
+ html: De beheerders van de verzameling %{collection_link} hebben de status
+ van je werk %{work_link} veranderd naar nog niet onthuld.
+ text: De beheerders van de verzameling "%{collection_title}" (%{collection_url})
+ hebben de status van je werk "%{work_title}" (%{work_url}) veranderd naar
+ nog niet onthuld.
+ collection_items_link_text: Approved Collection Items (Goedgekeurde Verzamelde
+ Werken) pagina
+ do_not_want:
+ anonymous:
+ html: Als je niet wilt dat je werk anoniem wordt, ga dan naar je %{collection_items_link}
+ om het uit deze verzameling te verwijderen.
+ text: 'Als je niet wilt dat je werk anoniem gemaakt wordt, ga dan naar je
+ Approved Collection Items (Goedgekeurde Verzamelde Werken) pagina om het
+ uit deze verzameling te verwijderen: %{collection_items_url}'
+ anonymous_unrevealed:
+ html: Als je niet wilt dat je werk anoniem en niet onthuld blijft, ga dan
+ naar je %{collection_items_link} om het uit deze verzameling te verwijderen.
+ text: 'Als je niet wilt dat je werk anoniem gemaakt en niet onthuld blijft,
+ ga dan naar je Approved Collection Items (Goedgekeurde Verzamelde Werken)
+ pagina om het uit deze verzameling te verwijderen: %{collection_items_url}'
+ unrevealed:
+ html: Als je niet wilt dat je werk niet onthuld blijft, ga dan naar je %{collection_items_link}
+ om het uit deze verzameling te verwijderen.
+ text: 'Als je niet wilt dat je werk niet onthuld blijft, ga dan naar je
+ Approved Collection Items (Goedgekeurde Verzamelde Werken) pagina om het
+ uit deze verzameling te verwijderen: %{collection_items_url}'
+ faq_link_text: Verzamelingen FAQ
+ more_info:
+ html: Voor meer informatie, ga naar onze %{faq_link}.
+ text: 'Voor meer informatie, ga naar onze Verzamelingen FAQ: %{faq_url}'
+ subject:
+ anonymous: "[%{app_name}] Jouw werk werd anoniem gemaakt"
+ anonymous_unrevealed: "[%{app_name}] Je werk werd anoniem gemaakt en nog niet
+ onthuld"
+ unrevealed: "[%{app_name}] Jouw werk werd gemarkeerd als nog niet onthuld"
+ unrevealed_info: Nog niet onthulde werken worden niet weergegeven in taglijsten
+ of op de pagina met jouw werken. Iedereen die een link naar het werk volgt,
+ krijgt een waarschuwing dat het momenteel nog niet onthuld is en dat het niet
+ mogelijk is om de inhoud te bereiken.
+ archivist_added_to_collection_notification:
+ approved_collection_items_page: Approved Collection Items (Toegestane collectie-items)
+ pagina
+ archivist_notice: Omdat de beheerders van de collectie in hun officiële capaciteit
+ als Open Doors (Open Deuren) archiefbeheerders handelen, is het hen toegestaan
+ jouw werk aan deze collectie toe te voegen, ook al heb je collectie uitnodigingen
+ uitstaan. Archiefbeheerders zullen enkel werken aan een collectie toevoegen
+ als het op een geïmporteerd archief gehost was.
+ removal_instructions:
+ html: Als je jouw werk uit deze collectie wil verwijderen, bezoek dan jouw
+ %{approved_items_link}.
+ text: 'Als je jouw werk uit deze collectie wil verwijderen, bezoek dan jouw
+ Approved Collection Items (Toegestane collectie-items) pagina: %{approved_items_url}.'
+ subject: "[%{app_name}][%{collection_title}] Een Open Doors (Open Deuren) archiefbeheerder
+ heeft je werk toegevoegd aan een collectie"
+ work_added:
+ html: De beheerders van de collectie %{collection_link} hebben jouw werk %{work_link}
+ toegevoegd aan hun collectie!
+ text: De beheerders van de collectie "%{collection_title}" (%{collection_url})
+ hebben jouw werk "%{work_title}" (%{work_url}) toegevoegd aan hun collectie!
+ challenge_assignment_notification:
+ any: Vrije keuze
+ assignment:
+ html: Het volgende verzoek wordt aan jou toegewezen in de %{link} uitdaging
+ op AO3!
+ description: 'Beschrijving:'
+ due: 'Deze taak moet klaar zijn op:'
+ html:
+ footer: Je krijgt deze e-mail omdat je deelneemt aan de %{title} uitdaging.
+ Voor meer informatie over deze uitdaging en de contactinfo van de moderators,
+ ga naar %{footer_link}.
+ footer_link: de profielpagina van de uitdaging
+ look_up: Je kan deze taak opzoeken vanaf %{link}.
+ look_up_link: jouw Assignments (Taken) pagina
+ optional_tags: 'Optionele tags:'
+ prompts: 'Prompts:'
+ prompt_url: 'Prompt URL:'
+ recipient: 'Ontvanger:'
+ recipient_missing: 'Geen: vraag hulp aan een moderator!'
+ subject: "[%{app_name}][%{collection_title}] Jouw taak!"
+ text:
+ assignment: Het volgende verzoek wordt aan jou toegewezen in de "%{collection_title}"
+ uitdaging (%{collection_url}) op AO3!
+ footer: Je krijgt deze e-mail omdat je deelneemt aan de %{title} uitdaging
+ (%{url}). Voor meer informatie over deze uitdaging en de contactinfo van
+ de moderators, ga naar %{profile_url}.
+ look_up: Je kan deze taak opzoeken vanaf je Assignments (Taken) pagina via
+ %{link}.
+ change_email:
+ changed:
+ html: "%{login}, het e-mailadres van je account werd gewijzigd naar %{email}"
+ text: "%{login}, het e-mailadres van je account werd gewijzigd naar %{email}"
+ subject: "[%{app_name}] E-mailadres gewijzigd"
+ claim_notification:
+ access:
+ contact_support: neem contact op met AO3 Support
+ html: Afhankelijk van het archief werden je werken misschien geïmporteerd
+ met de toegang beperkt tot gebruikers met een account (zodat ze niet op
+ Google verschijnen). Als dit het geval is, hebben alleen ingelogde gebruikers
+ toegang tot je werken, tenzij je ervoor kiest ze volledig zichtbaar te maken.
+ Voor hulp bij het vrijgeven, verlaten of verwijderen van je werken, %{contact_support_link}.
+ text: Afhankelijk van het archief werden je werken misschien geïmporteerd
+ met de toegang beperkt tot gebruikers met een account (zodat ze niet op
+ Google verschijnen). Als dit het geval is, hebben alleen ingelogde gebruikers
+ toegang tot je werken, tenzij je ervoor kiest ze volledig zichtbaar te maken.
+ Voor hulp bij het vrijgeven, verlaten of verwijderen van je werken, neem
+ contact op met Support via %{support_url}.
+ email_tips: Wanneer je contact met ons opneemt, voeg dan e-mailadressen van
+ @transformativeworks.org toe aan je lijst met veilige afzenders en controleer
+ of ons antwoord in je ongewenste e-mail zit.
+ introduction:
+ ao3_name: Archive of Our Own – AO3 (Ons Eigen Archief)
+ html: Je ontvangt deze e-mail omdat je werken had in een fanwerkarchief dat
+ nu geïmporteerd is door %{open_doors_name_link} naar %{app_link}. Omdat
+ dit e-mailadres bij een account hoort dat geregistreerd stond bij het geïmporteerde
+ archief, zijn de bijbehorende fanwerken (zie hieronder) automatisch toegevoegd
+ aan je AO3-account.
+ open_doors_name: Open Doors (Open Deuren)
+ text: 'Je ontvangt deze e-mail omdat je werken had in een archief dat geïmporteerd
+ is door Open Doors (Open Deuren) (%{open_doors_url}) naar Archive of Our
+ Own – AO3 (Ons Eigen Archief): %{app_url}. Omdat dit e-mailadres bij een
+ account hoort dat geregistreerd stond bij het geïmporteerde archief, zijn
+ de bijbehorende fanwerken (zie hieronder) automatisch toegevoegd aan je
+ AO3-account.'
+ mistake:
+ contact_open_doors: Neem contact op met Open Deuren
+ html: Als dit een fout is en deze werken niet van jou zijn, verwijder ze dan
+ niet! %{contact_open_doors_link} en we lossen het op.
+ text: Als dit een fout is en deze werken niet van jou zijn, verwijder ze dan
+ niet! Neem contact op met Open Deuren (%{open_doors_url}) en we lossen het
+ op.
+ more_info:
+ ao3_news: AO3 Nieuws
+ contact_support: neem dan contact op met AO3 Support
+ faq_page: veelgestelde vragen
+ html: Je kan aankondigen over recent verhuisde archieven lezen op %{ao3_news_link}
+ en extra informatie is te vinden bij de %{faq_page_link} en %{tutorial_page_link}
+ van Open Deuren. Als je vragen hebt die niet in de veelgestelde vragen,
+ tutorials of deze e-mail beantwoord worden, %{contact_support_link}.
+ text: Je kan aankondigen over recent verhuisde archieven lezen op AO3 Nieuws
+ (%{news_url}) en extra informatie is te vinden bij de veelgestelde vragen
+ (%{open_doors_faq_url}) en tutorials (%{open_doors_tutorial_url}) van Open
+ Deuren. Als je vragen hebt die niet in de veelgestelde vragen, tutorials
+ of deze e-mail beantwoord worden, neem dan contact op met Support via %{support_url}.
+ tutorial_page: tutorials
+ other_works:
+ contact_open_doors: neem dan contact op met Open Deuren
+ html: Als je andere werken op het geïmporteerde archief had onder een e-mailadres
+ waar je niet meer bij kan, %{contact_open_doors_link} met details die jouw
+ identiteit kunnen bewijzen.
+ text: Als je andere werken op het geïmporteerde archief had onder een e-mailadres
+ waar je niet meer bij kan, neem dan contact op met Open Deuren met details
+ die jouw identiteit kunnen bewijzen.
+ questions:
+ contact_support: neem contact op met AO3 Support
+ html: Voor andere vragen, %{contact_support_link}.
+ text: Voor andere vragen, neem contact op met AO3 Support via %{support_url}.
+ redirects:
+ html: Om lijsten met aanbevelingen en bladwijzers te bewaren kunnen de webadressen
+ van het geïmporteerde archief tijdelijk omgeleid worden naar de geïmporteerde
+ kopie van deze werken (zie de aankondiging voor jouw archief om dit te checken).
+ Als je zelf al een kopie van deze werken hebt geüpload en je hebt de functie
+ om te importeren vanaf een URL %{negation} gebruikt, zullen er twee kopieën
+ van hetzelfde werk op AO3 staan.
+ subject: "[%{app_name}] Werken geüpload"
+ update_redirect:
+ contact_open_doors: neem contact op met Open Deuren
+ html: Als je wil dat Open Deuren de omleiding updatet zodat hij naar jouw
+ bestaande werk leidt, verwijder dan de geïmporteerde kopie en %{contact_open_doors_link}
+ met je AO3-accountnaam, je accountnaam op het geïmporteerde archief en de
+ titel en URL van het fanwerk waar je naar wil laten omleiden. (Als je meerdere
+ werken hebt waarvan je de omleiding wil laten wijzigen, kan je deze allemaal
+ in dezelfde e-mail noemen.)
+ text: Als je wil dat Open Deuren de omleiding updatet zodat hij naar jouw
+ bestaande werk leidt, verwijder dan de geïmporteerde kopie en neem contact
+ op met Open Deuren via %{open_doors_url} met je AO3-accountnaam, je accountnaam
+ op het geïmporteerde archief en de titel en URL van het fanwerk waar je
+ naar wil laten omleiden. (Als je meerdere werken hebt waarvan je de omleiding
+ wil laten wijzigen, kan je deze allemaal in dezelfde e-mail noemen.)
+ works_by: 'Deze werken werden geschreven door de persoon met het e-mailadres:
+ %{email}'
+ work_info:
+ html: "%{work_link} (%{fandom})"
+ text:
+ no_fandom: "- %{work_title} %{work_url}"
+ with_fandom: "- %{work_title} %{work_url} (%{fandom})"
+ collection_notification:
+ assignments_sent:
+ complete: Alle taken zijn verstuurd.
+ subject: Taken verstuurd
+ html:
+ received_message: 'Je hebt een bericht ontvangen over je collectie %{collection_link}:'
+ text:
+ received_message: 'Je hebt een bericht ontvangen over je collectie "%{collection_title}"
+ (%{collection_url}):'
+ creatorship_notification:
+ explanation: Als medeproducent van een werk, kan je worden toegevoegd aan nieuwe
+ hoofdstukken ongeacht je instellingen. Je zal ook worden toegevoegd aan series
+ waaraan het werk wordt toegevoegd.
+ html:
+ creation: "%{creation_link} door %{pseud_links}"
+ edit_chapter: het hoofdstuk bewerken
+ edit_series: de serie bewerken
+ remove_chapter: Als je per ongeluk werd toegevoegd of niet als producent wil
+ bekend staan, dan kan je %{edit_chapter_link} om jezelf te verwijderen als
+ producent.
+ remove_series: Als je per ongeluk werd toegevoegd of niet als producent wil
+ bekend staan, dan kan je %{edit_series_link} om jezelf te verwijderen als
+ producent.
+ intro_chapter: 'De gebruiker %{adding_user} heeft je pseudoniem %{pseud} vastgelegd
+ als een medeproducent van het volgende hoofdstuk:'
+ intro_series: 'De gebruiker %{adding_user} heeft je pseudoniem %{pseud} vastgelegd
+ als een medeproducent van de volgende serie:'
+ subject: "[%{app_name}] Medeproducent Notificatie"
+ text:
+ creation: "%{title} (%{url}) door %{pseuds}"
+ remove_chapter: 'Als je per ongeluk werd toegevoegd of niet als producent
+ bekend wil staan, dan kan je het hoofdstuk aanpassen om jezelf te verwijderen
+ als producent: %{url}'
+ remove_series: 'Als je per ongeluk werd toegevoegd of niet als producent wil
+ bekend staan, dan kan je de serie bewerken om jezelf te verwijderen als
+ producent: %{url}'
+ creatorship_notification_archivist:
+ explanation: Omdat ze handelen in hun officiële capaciteit als Open Deuren archiefbeheerder,
+ zijn ze gemachtigd om je zonder verzoek toe te voegen, zelfs als je medeproductie
+ hebt uitgeschakeld.
+ html:
+ creation: "%{creation_link} door %{pseud_links}"
+ edit_chapter: het hoofdstuk bewerken
+ edit_series: de serie bewerken
+ edit_work: het werk bewerken
+ remove_chapter: Als je per ongeluk werd toegevoegd of als je niet als producent
+ bekend wilt staan, dan kan je %{edit_chapter_link} om jezelf te verwijderen
+ als producent.
+ remove_series: Als je per ongeluk werd toegevoegd of als je niet als producent
+ bekend wilt staan, dan kan je %{edit_series_link} om jezelf te verwijderen
+ als producent.
+ remove_work: Als je per ongeluk werd toegevoegd of als je niet als producent
+ bekend wilt staan, dan kan je %{edit_work_link} om jezelf te verwijderen
+ als producent.
+ intro_chapter: 'De gebruiker %{archivist} heeft jouw pseudoniem %{pseud} als
+ medeproducent toegevoegd aan het volgende hoofdstuk:'
+ intro_series: 'De gebruiker %{archivist} heeft jouw pseudoniem %{pseud} als
+ medeproducent toegevoegd aan de volgende serie:'
+ intro_work: 'De gebruiker %{archivist} heeft jouw pseudoniem %{pseud} als medeproducent
+ toegevoegd aan het volgende werk:'
+ subject: "[%{app_name}] Archiefbeheerder medeproducent notificatie"
+ text:
+ creation: "%{title} (%{url}) door %{pseuds}"
+ remove_chapter: 'Als je per ongeluk werd toegevoegd of als je niet als producent
+ bekend wilt staan, dan kan je het hoofdstuk bewerken om jezelf te verwijderen
+ als producent: %{url}'
+ remove_series: 'Als je per ongeluk werd toegevoegd of als je niet als producent
+ bekend wilt staan, dan kan je de serie bewerken om jezelf te verwijderen
+ als producent: %{url}'
+ remove_work: 'Als je per ongeluk werd toegevoegd of als je niet als producent
+ bekend wilt staan, dan kan je het werk bewerken om jezelf te verwijderen
+ als producent: %{url}'
+ creatorship_request:
+ html:
+ creation: "%{creation_link} door %{pseud_links}"
+ instructions: Je kan dit verzoek aanvaarden of weigeren op je %{page_name}
+ pagina.
+ page_name: Co-Creator Requests (Medeproducent aanvragen)
+ intro_chapter: 'De gebruiker %{inviting_user} heeft jouw pseudoniem %{pseud}
+ uitgenodigd om als medeproducent vermeld te worden op het volgende hoofdstuk:'
+ intro_series: 'De gebruiker %{inviting_user} heeft jouw pseudoniem %{pseud}
+ uitgenodigd om als medeproducent vermeld te worden op de volgende serie:'
+ intro_work: 'De gebruiker %{inviting_user} heeft jouw pseudoniem %{pseud} uitgenodigd
+ om als medeproducent vermeld te worden op het volgende werk:'
+ subject: "[%{app_name}] Medeproducent aanvraag"
+ text:
+ creation: "%{title} (%{url}) door %{pseuds}"
+ instructions: 'Je kan dit verzoek aanvaarden of weigeren op je Co-Creator
+ Requests (Medeproducent aanvragen) pagina: %{url}'
+ delete_work_notification:
+ attachment: Een kopie van je werk is voor jou bijgevoegd.
+ deleted_other:
+ html: Je werk %{title} is op verzoek van %{pseud} verwijderd.
+ text: Je werk "%{title}" is op verzoek van %{pseud} verwijderd.
+ deleted_yourself:
+ html: Je werk %{title} is op jouw verzoek verwijderd.
+ text: Je werk "%{title}" is op jouw verzoek verwijderd.
+ questions:
+ html: Als je vragen hebt, %{support}.
+ text: Als je vragen hebt, %{support} (%{url}).
+ subject: "[%{app_name}] Je werk is verwijderd"
+ support: neem dan contact op met Support
+ invite_increase_notification:
+ html:
+ body:
+ one: We willen je laten weten dat je een nieuwe uitnodiging hebt, waarmee
+ een AO3-account kan aangemaakt worden. Je kan een vriend uitnodigen via
+ %{invitation_page_link}.
+ other: We willen je laten weten dat je %{count} nieuwe uitnodigingen hebt,
+ waarmee AO3-accounts kunnen aangemaakt worden. Je kan een vriend uitnodigen
+ via %{invitation_page_link}.
+ invitation_page_link_text: je Invitations (Uitnodigingen) pagina
+ subject: "[%{app_name}] Nieuwe Uitnodigingen"
+ text:
+ body:
+ one: We willen je laten weten dat je een nieuwe uitnodiging hebt, waarmee
+ een AO3-account kan aangemaakt worden. Je kan een vriend uitnodigen via
+ %{invitation_page_url}.
+ other: We willen je laten weten dat je %{count} nieuwe uitnodigingen hebt,
+ waarmee AO3-accounts kunnen aangemaakt worden. Je kan een vriend uitnodigen
+ via %{invitation_page_url}.
+ invite_request_declined:
+ main:
+ one: Jammer genoeg kunnen we je verzoek voor een nieuwe uitnodiging op dit
+ moment niet goedkeuren.
+ other: Jammer genoeg kunnen we je verzoek voor %{count} nieuwe uitnodigingen
+ op dit moment niet goedkeuren.
+ reason: 'Je verzoek was:'
+ subject: "[%{app_name}] Verzoek uitnodigingscode geweigerd"
+ recipient_notification:
+ html:
+ collection: Iemand heeft je een werk cadeau gedaan in de %{collection_link}
+ collectie op AO3!
+ no_collection: Iemand heeft je een werk cadeau gedaan op AO3!
+ subject:
+ collection: "[%{app_name}][%{collection_title}] Een werk voor jou in %{collection_title}"
+ no_collection: "[%{app_name}] Een werk voor jou"
+ text:
+ collection: Iemand heeft je een werk cadeau gedaan in de "%{collection_title}"
+ collectie (%{collection_url}) op AO3!
+ signup_notification:
+ activate:
+ html: "%{activate_account_link}."
+ text: 'Volg deze link om je account te activeren: %{activate_account_url}'
+ activate_your_account: volg deze link om je account te activeren
+ admin_posts: AO3 Nieuws
+ bye: We hopen dat je veel plezier gaat beleven aan AO3.
+ contact_support: contacteer het Support team
+ faq: FAQ
+ features:
+ html: Als je account is geactiveerd, kun je je eigen werken plaatsen, je via
+ e-mail abonneren op je favoriete producenten en werken zodat je weet wanneer
+ deze updaten, aanpassen hoe de website eruit ziet en functioneert, bijhouden
+ welke werken je hebt bezocht op AO3 via je geschiedenis, en nog veel meer.
+ text: Als je account is geactiveerd, kun je je eigen werken plaatsen, je via
+ e-mail abonneren op je favoriete producenten en werken zodat je weet wanneer
+ deze updaten, aanpassen hoe de website eruit ziet en functioneert, bijhouden
+ welke werken je hebt bezocht op AO3 via je geschiedenis, en nog veel meer.
+ information:
+ html: Er is veel informatie en advies over hoe je AO3 kunt gebruiken in onze
+ %{faq_link}. Het laatste nieuws over site-ontwikkeling vind je in ons %{admin_posts_link}.
+ Als je meer hulp nodig hebt, een probleem tegenkomt of vragen of opmerkingen
+ hebt, %{contact_support_link} en we helpen je graag verder.
+ text: 'Er is veel informatie en advies over hoe je AO3 kunt gebruiken in onze
+ FAQ via %{faq_url}. Het laatste nieuws over site-ontwikkeling vind je in
+ ons AO3 Nieuws via %{admin_posts_url}. Als je meer hulp nodig hebt, een
+ probleem tegenkomt of vragen of opmerkingen hebt, contacteer dan ons Support
+ team dat je altijd graag verder helpt: %{contact_support_url}.'
+ welcome: Welkom bij AO3, %{login}!
diff --git a/config/locales/phrase-exports/pl.yml b/config/locales/phrase-exports/pl.yml
new file mode 100755
index 0000000..8f893ba
--- /dev/null
+++ b/config/locales/phrase-exports/pl.yml
@@ -0,0 +1,554 @@
+---
+pl:
+ activerecord:
+ attributes:
+ archive_warning:
+ name_with_colon:
+ few: 'Ostrzeżenia:'
+ many: 'Ostrzeżenia:'
+ one: 'Ostrzeżenie:'
+ category:
+ name_with_colon:
+ few: 'Kategorie:'
+ many: 'Kategorie:'
+ one: 'Kategoria:'
+ character:
+ name_with_colon:
+ few: 'Postacie:'
+ many: 'Postacie:'
+ one: 'Postać:'
+ fandom:
+ name_with_colon:
+ few: 'Fandomy:'
+ many: 'Fandomy:'
+ one: 'Fandom:'
+ freeform:
+ name_with_colon:
+ few: 'Tagi dodatkowe:'
+ many: 'Tagi dodatkowe:'
+ one: 'Tag dodatkowy:'
+ rating:
+ name_with_colon: 'Kategoria wiekowa:'
+ relationship:
+ name_with_colon:
+ few: 'Związki:'
+ many: 'Związki:'
+ one: 'Związek:'
+ work:
+ chapter_total_display: Rozdziały
+ summary: Streszczenie
+ models:
+ archive_warning:
+ few: Ostrzeżenia
+ many: Ostrzeżenia
+ one: Ostrzeżenie
+ category:
+ few: Kategorie
+ many: Kategorie
+ one: Kategoria
+ chapter:
+ few: Rozdziały
+ many: Rozdziały
+ one: Rozdział
+ character:
+ few: Postacie
+ many: Postacie
+ one: Postać
+ fandom:
+ few: Fandomy
+ many: Fandomy
+ one: Fandom
+ freeform:
+ few: Tagi dodatkowe
+ many: Tagi dodatkowe
+ one: Tag dodatkowy
+ rating:
+ few: Kategorie wiekowe
+ many: Kategorie wiekowe
+ one: Kategoria wiekowa
+ relationship:
+ few: Związki
+ many: Związki
+ one: Związek
+ series:
+ few: Serie
+ many: Serie
+ one: Seria
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ few: "%{count} gości"
+ many: "%{count} gości"
+ one: 1 gościa
+ other: "%{count} gości"
+ left_kudos:
+ html:
+ few: Kudos dla %{commentable_link} od %{givers_list}.
+ many: Kudos dla %{commentable_link} od %{givers_list}.
+ one: Kudos dla %{commentable_link} od %{givers_list}.
+ text:
+ few: Kudos dla %{commentable_title} (%{commentable_url}) od %{givers_list}.
+ many: Kudos dla %{commentable_title} (%{commentable_url}) od %{givers_list}.
+ one: Kudos dla %{commentable_title} (%{commentable_url}) od %{givers_list}.
+ single_guest:
+ giver: 1 gościa
+ html: Kudos dla %{commentable_link} od %{giver}.
+ text: Kudos dla %{commentable_title} (%{commentable_url}) od 1 gościa.
+ subject: "[%{app_name}] Masz kudos!"
+ mailer:
+ general:
+ closing:
+ formal: Z wyrazami szacunku,
+ informal: Pozdrawiamy,
+ creation:
+ link_with_word_count: "%{creation_link} (%{word_count})"
+ title_with_chapter_number: Rozdział %{position} pracy %{title}
+ title_with_word_count: '"%{creation_title}" (%{word_count})'
+ word_count:
+ few: "%{count} słowa"
+ many: "%{count} słów"
+ one: "%{count} słowo"
+ footer:
+ general:
+ about:
+ html: AO3 jest prowadzonym i wspieranym przez fanów archiwum, które działa
+ dzięki %{donate_link}.
+ text: 'AO3 jest prowadzonym i wspieranym przez fanów archiwum, które działa
+ dzięki Waszym darowiznom: %{donate_url}.'
+ html:
+ donate_link_text: Waszym darowiznom
+ support_link_text: kontakt ze Wsparciem
+ unwanted_email:
+ html: Jeśli ta wiadomość została wysłana do Ciebie przez pomyłkę, prosimy
+ o %{support_link}.
+ text: Jeśli ta wiadomość została wysłana do Ciebie przez pomyłkę, prosimy
+ o kontakt ze Wsparciem poprzez %{support_url}.
+ sent_at: Wysłano w %{sent_at}.
+ greeting:
+ formal_html: Szanowna/y %{name},
+ informal:
+ addressed_html: Cześć, %{name}!
+ unaddressed: Cześć!
+ introductory: Pozdrowienia z Archive of Our Own – AO3 (Naszego Własnego Archiwum)!
+ metadata_label_indicator: ":"
+ signature:
+ abuse_team: Zespół Zasad i Nadużyć AO3
+ app_short_name: AO3
+ open_doors: Zespół Open Doors (Drzwi Otwartych)
+ parent_org: Organization for Transformative Works – OTW (Organizacja na rzecz
+ Twórczości Przeobrażonej)
+ support: Zespół Wsparcia AO3
+ users:
+ mailer:
+ reset_password_instructions:
+ expiration: Jeżeli w ciągu tygodnia nie wykorzystasz tego linku do zresetowania
+ hasła, straci on swoją ważność i konieczne będzie wygenerowanie nowego.
+ intro: 'Ktoś zażądał zmiany hasła do Twojego konta. Możesz ustawić nowe zabezpieczenie
+ dla swojego konta, przechodząc w poniższe łącze i wprowadzając nowe hasło:'
+ link_title: Zmień moje hasło.
+ subject: "[%{app_name}] Zmień swoje hasło"
+ unrequested: Jeśli nie prosiłaś/eś o zmianę hasła, możesz zignorować tę wiadomość,
+ a Twoje dotychczasowe hasło nadal będzie funkcjonować.
+ user_mailer:
+ admin_deleted_work_notification:
+ bye: Dołączamy kopię Twojej pracy, abyś mogła/mógł się do niej odnieść.
+ contact_abuse: skontaktuj się z naszą Komisją ds. Zasad i Nadużyć
+ deleted:
+ html: Twoja praca %{title} została usunięta z Archiwum przez administratora
+ strony.
+ text: Twoja praca "%{title}" została usunięta z Archiwum przez administratora
+ strony.
+ html:
+ tos_violation: Jeśli podejrzewasz, że Twoja twórczość mogła złamać Regulamin
+ Serwisu AO3, prosimy, %{contact_abuse_link}.
+ import_project:
+ html: Jeśli Twoja praca była częścią projektu importu zarządzanego przez naszą
+ grupę Open Doors (Drzwi Otwartych), prosimy o %{opendoors_link} w razie
+ dalszych pytań.
+ text: Jeśli Twoja praca była częścią projektu importu zarządzanego przez naszą
+ grupę Drzwi Otwartych, prosimy o skontaktowanie się z Drzwiami Otwartymi
+ (%{opendoors_link}) w razie dalszych pytań.
+ opendoors: skontaktowanie się z Drzwiami Otwartymi
+ subject: "[%{app_name}] Twoja praca została usunięta przez administratora"
+ text:
+ tos_violation: Jeśli podejrzewasz, że Twoja twórczość mogła złamać Regulamin
+ Serwisu AO3, prosimy, skontaktuj się z naszą Komisją ds. Zasad i Nadużyć
+ (%{contact_abuse_url}).
+ admin_hidden_work_notification:
+ access: Gdy twoja praca jest ukryta, nadal masz do niej dostęp poprzez link
+ podany wyżej, ale nie jest ona wymieniona na liście Twoich prac, ani nie jest
+ dostępna dla innych użytkowników AO3.
+ check_email: Prosimy, sprawdź swojego maila, w tym folder spam, gdyż zespół
+ Zasad i Nadużyć mógł już się z Tobą skontaktować, aby wyjaśnić, dlaczego Twoja
+ praca została ukryta.
+ contact_abuse: kontakt z Zasadami i Nadużyciami
+ html:
+ help: Jeśli nie masz pewności, dlaczego Twoja praca została ukryta i nie dotarła
+ do Ciebie dalsza korespondencja w tej sprawie, prosimy o bezpośredni %{contact_abuse_link}.
+ hidden: Twoja praca %{title} została ukryta przez zespół Zasad i Nadużyć i
+ nie jest już publicznie dostępna.
+ tos_violation: Jeśli Twoja praca została ukryta z powodu naruszenia %{tos_link}
+ AO3, musisz podjąć kroki, aby skorygować naruszenie. Nieskorygowana niezgodność
+ z Regulaminem Serwisu może doprowadzić do usunięcia Twojej pracy z AO3.
+ subject: "[%{app_name}] Twoja praca została ukryta przez zespół Zasad i Nadużyć."
+ text:
+ help: 'Jeśli nie masz pewności, dlaczego Twoja praca została ukryta i nie
+ dotarła do Ciebie dalsza korespondencja w tej sprawie, prosimy o bezpośredni
+ kontakt z Zasadami i Nadużyciami: %{contact_abuse_url}.'
+ hidden: Twoja praca "%{title}" (%{work_url}) została ukryta przez zespół Zasad
+ i Nadużyć i nie jest już publicznie dostępna.
+ tos_violation: Jeśli Twoja praca została ukryta z powodu naruszenia Regulaminu
+ Serwisu AO3 (%{tos_url}), musisz podjąć kroki, aby skorygować naruszenie.
+ Nieskorygowana niezgodność z Regulaminem Serwisu może doprowadzić do usunięcia
+ Twojej pracy z AO3.
+ tos: Regulaminu Serwisu
+ anonymous_or_unrevealed_notification:
+ anonymous_info: Prace anonimowe pojawiają się w wynikach wyszukiwania tagów,
+ ale nie na stronie z Twoimi pracami. Twoja nazwa użytkownika przy pracy zostanie
+ zastąpiona przez "Anonymous" (Anonimowe).
+ anonymous_unrevealed_info: Opiekunowie kolekcji mogą później ujawnić Twoją pracę,
+ nadal pozostawiając ją anonimową. Twoi subskrybenci nie zostaną powiadomieni
+ o tej zmianie. Po ujawnieniu, Twoja praca pojawi się w wynikach wyszukiwania
+ tagów, ale nie na stronie Twoich prac. Na pracy, Twoja nazwa użytkownika zostanie
+ zamieniona na "Anonymous" (Anonimowe).
+ changed_status:
+ anonymous:
+ html: Opiekunowie kolekcji %{collection_link} zmienili status Twojej pracy
+ %{work_link} na pracę anonimową.
+ text: Opiekunowie kolekcji "%{collection_title}" (%{collection_url}) zmienili
+ status Twojej pracy "%{work_title}" (%{work_url}) na anonimową.
+ anonymous_unrevealed:
+ html: Opiekunowie kolekcji %{collection_link} zmienili status Twojej pracy
+ %{work_link} na anonimową i nieujawnioną.
+ text: Opiekunowie kolekcji "%{collection_title}" (%{collection_url}) zmienili
+ status Twojej pracy "%{work_title}" (%{work_url}) na anonimową i nieujawnioną.
+ unrevealed:
+ html: Opiekunowie kolekcji %{collection_link} zmienili status Twojej pracy
+ %{work_link} na pracę nieujawnioną.
+ text: Opiekunowie kolekcji "%{collection_title}" (%{collection_url}) zmienili
+ status Twojej pracy "%{work_title}" (%{work_url}) na nieujawnioną.
+ collection_items_link_text: strony Approved Collection Items (Zatwierdzonych
+ Elementów Kolekcji)
+ do_not_want:
+ anonymous:
+ html: Jeżeli nie chcesz, aby Twoja praca była anonimowa, prosimy o odwiedzenie
+ Twojej %{collection_items_link} w celu usunięcia pracy z kolekcji.
+ text: 'Jeżeli nie chcesz, żeby Twoja praca była anonimowa, prosimy przejdź
+ do Twojej strony Approved Collection Items (Zatwierdzonych Elementów Kolekcji),
+ by usunąć ją z tej kolekcji: %{collection_items_url}'
+ anonymous_unrevealed:
+ html: Jeżeli nie chcesz, żeby Twoja praca była anonimowa i nieujawniona,
+ prosimy przejdź do Twojej %{collection_items_link}, by usunąć ją z tej
+ kolekcji.
+ text: 'Jeżeli nie chcesz, żeby Twoja praca była anonimowa i nieujawniona,
+ prosimy przejdź do Twojej strony Approved Collection Items Zatwierdzonych
+ Elementów Kolekcji), by usunąć ją z tej kolekcji: %{collection_items_url}'
+ unrevealed:
+ html: Jeżeli nie chcesz, aby Twoja praca była nieujawniona, prosimy o odwiedzenie
+ Twojej %{collection_items_link} w celu usunięcia pracy z kolekcji.
+ text: 'Jeżeli nie chcesz, żeby Twoja praca była nieujawniona, prosimy przejdź
+ do Twojej strony Approved Collection Items (Zatwierdzonych Elementów Kolekcji),
+ by usunąć ją z tej kolekcji: %{collection_items_url}'
+ faq_link_text: FAQ Kolekcji
+ more_info:
+ html: Aby uzyskać więcej informacji, odwiedź nasze %{faq_link}.
+ text: 'Aby uzyskać więcej informacji, odwiedź nasze FAQ Kolekcji: %{faq_url}'
+ subject:
+ anonymous: "[%{app_name}] Twoja praca stała się anonimową"
+ anonymous_unrevealed: "[%{app_name}] Twoja praca stała się anonimową i nieujawnioną"
+ unrevealed: "[%{app_name}] Twoja praca stała się nieujawnioną"
+ unrevealed_info: Nieujawnione prace nie pojawiają się w wynikach wyszukiwania
+ tagów ani na stronie Twoich prac. Każdy, kto wejdzie w link do pracy, otrzyma
+ wiadomość, że praca jest aktualnie nieujawniona, i nie będzie w stanie uzyskać
+ dostępu do jej treści.
+ archivist_added_to_collection_notification:
+ approved_collection_items_page: stronę Approved Collection Items (Zaakceptowanych
+ Elementów Kolekcji)
+ archivist_notice: Ponieważ zarządzający kolekcją działają w zakresie swoich
+ oficjalnych uprawnień jako archiwiści Open Doors (Drzwi Otwartych), mogą oni
+ dodać Twoją pracę do swojej kolekcji, nawet jeśli Twoje ustawienia nie zezwalają
+ na zaproszenia do kolekcji. Archiwiści dodadzą Twoją pracę do kolekcji wyłącznie
+ jeśli była ona umieszczona na importowanym archiwum.
+ removal_instructions:
+ html: Jeśli chcesz usunąć swoją pracę z tej kolekcji, prosimy odwiedź swoją
+ %{approved_items_link}.
+ text: 'Jeśli chcesz usunąć swoją pracę z tej kolekcji, prosimy odwiedź swoją
+ stronę Approved Collection Items (Zaakceptowanych Elementów Kolekcji): %{approved_items_url}.'
+ subject: "[%{app_name}][%{collection_title}] Archiwista Open Doors (Drzwi Otwartych)
+ dołączył Twoją pracę do kolekcji"
+ work_added:
+ html: Osoby zarządzające kolekcją %{collection_link} dołączyły Twoją pracę
+ %{work_link} do swojej kolekcji!
+ text: Osoby zarządzające kolekcją "%{collection_title}" (%{collection_url})
+ dołączyły Twoją pracę "%{work_title}" (%{work_url}) do swojej kolekcji!
+ challenge_assignment_notification:
+ any: Pełna dowolność
+ assignment:
+ html: Przypisane zostało Ci następujące zadanie w wyzwaniu %{link} na Naszym
+ Własnym Archiwum!
+ description: 'Opis:'
+ due: 'To zadanie należy wykonać do:'
+ html:
+ footer: Otrzymałaś/eś ten e-mail ponieważ zapisałaś/eś się do wyzwania %{title}.
+ Aby dowiedzieć się więcej na temat tego wyzwania i by móc skontaktować się
+ z moderatorami, proszę przejdź do %{footer_link}.
+ footer_link: strony profilowej wyzwania
+ look_up: Możesz sprawdzić swoje zadanie na %{link}.
+ look_up_link: Twojej stronie Assignments (Zadań)
+ optional_tags: 'Opcjonalne Tagi:'
+ prompts: 'Prompty:'
+ prompt_url: 'URL promptu:'
+ recipient: 'Odbiorca:'
+ recipient_missing: 'Brak: skontaktuj się z moderatorem aby uzyskać pomoc!'
+ subject: "[%{app_name}][%{collection_title}] Twoje Zadanie!"
+ text:
+ assignment: Przypisane zostało Ci następujące zadanie w "%{collection_title}"
+ wyzwaniu (%{collection_url}) na Naszym Własnym Archiwum!
+ footer: Otrzymałaś/eś ten e-mail ponieważ zapisałaś/eś się do wyzwania %{title}
+ (%{url}). Aby dowiedzieć się więcej na temat tego wyzwania i by móc skontaktować
+ się z moderatorami, proszę przejdź do %{profile_url}.
+ look_up: Możesz sprawdzić to zadanie na Twojej stronie Assignments (Zadań)
+ na %{link}.
+ change_email:
+ changed:
+ html: "%{login}, adres email powiązany z Twoim kontem został zmieniony na
+ %{email}"
+ text: "%{login}, adres email powiązany z Twoim kontem został zmieniony na
+ %{email}"
+ subject: "[%{app_name}] Email został zmieniony"
+ collection_notification:
+ assignments_sent:
+ complete: Wszystkie zadania zostały wysłane.
+ subject: Wysłane zadania
+ html:
+ received_message: 'Masz nową wiadomość dotyczącą Twojej kolekcji %{collection_link}:'
+ text:
+ received_message: 'Masz nową wiadomość dotyczącą Twojej kolekcji "%{collection_title}"
+ (%{collection_url}):'
+ creatorship_notification:
+ explanation: Gdy jesteś współtwórcą pracy, możesz zostać dodana/y do nowych
+ rozdziałów bez względu na Twoje ustawienia współtworzenia. Będziesz również
+ dodana/y do wszelkich serii, do których należy praca.
+ html:
+ creation: "%{creation_link} autorstwa %{pseud_links}"
+ edit_chapter: edytować rozdział
+ edit_series: edytować serię
+ remove_chapter: Jeśli dodano cię przez pomyłkę lub nie chcesz widnieć jako
+ współtwórca, możesz %{edit_chapter_link}, by usunąć siebie jako twórcę.
+ remove_series: Jeśli dodano cię przez pomyłkę lub nie chcesz widnieć jako
+ współtwórca, możesz %{edit_series_link}, by usunąć siebie jako twórcę.
+ intro_chapter: 'Użytkownik %{adding_user} dodał Twój pseudonim %{pseud} jako
+ współtwórcę następującego rozdziału:'
+ intro_series: 'Użytkownik %{adding_user} dodał Twój pseudonim %{pseud} jako
+ współtwórcę następującej serii:'
+ subject: "[%{app_name}] Powiadomienie o Współautorstwie"
+ text:
+ creation: "%{title} (%{url}) autorstwa %{pseuds}"
+ remove_chapter: 'Jeśli dodano Cię przez pomyłkę lub nie chcesz widnieć jako
+ współtwórca, możesz edytować rozdział, by usunąć siebie jako twórcę: %{url}'
+ remove_series: 'Jeśli dodano Cię przez pomyłkę lub nie chcesz widnieć jako
+ współtwórca, możesz edytować serię, by usunąć siebie jako twórcę: %{url}'
+ creatorship_notification_archivist:
+ explanation: Ponieważ działa w zakresie swoich oficjalnych uprawnień archiwisty
+ Open Doors (Drzwi Otwartych), może dodać Cię bez wysłania do Ciebie prośby
+ o zgodę na to, nawet jeśli masz wyłączoną opcję współtworzenia.
+ html:
+ creation: "%{creation_link} autorstwa %{pseud_links}"
+ edit_chapter: edytować rozdział
+ edit_series: edytować serię
+ edit_work: edytować pracę
+ remove_chapter: Jeśli dodano Cię przez przypadek lub nie chcesz by wymieniano
+ Cię jako twórcę, możesz %{edit_chapter_link} aby usunąć siebie jako twórcę.
+ remove_series: Jeśli dodano Cię przez przypadek lub nie chcesz by wymieniano
+ Cię jako twórcę, możesz %{edit_series_link} aby usunąć siebie jako twórcę.
+ remove_work: Jeśli dodano Cię przez przypadek lub nie chcesz by wymieniano
+ Cię jako twórcę, możesz %{edit_work_link} aby usunąć siebie jako twórcę.
+ intro_chapter: 'Użytkownik %{archivist} dodał/a twój pseudonim %{pseud} jako
+ współtwórcę następującego rozdziału:'
+ intro_series: 'Użytkownik %{archivist} dodał Twój pseudonim %{pseud} jako współtwórcę
+ następującej serii:'
+ intro_work: 'Użytkownik %{archivist} dodał Twój pseudonim %{pseud} jako współtwórcę
+ poniższej pracy:'
+ subject: "[%{app_name}] Powiadomienie od archiwisty o współtworzeniu"
+ text:
+ creation: "%{title} (%{url}) autorstwa %{pseuds}"
+ remove_chapter: 'Jeśli dodano Cię przez przypadek lub nie chcesz by wymieniano
+ Cię jako twórcę, możesz edytować ten rozdział, aby usunąć siebie jako twórcę:
+ %{url}'
+ remove_series: 'Jeśli dodano Cię przez przypadek lub nie chcesz by wymieniano
+ Cię jako twórcę, możesz edytować tę serię, aby usunąć siebie jako twórcę:
+ %{url}'
+ remove_work: 'Jeśli dodano Cię przez przypadek lub nie chcesz by wymieniano
+ Cię jako twórcę, możesz edytować tę pracę, aby usunąć siebie jako twórcę:
+ %{url}'
+ creatorship_request:
+ html:
+ creation: "%{creation_link} autorstwa %{pseud_links}"
+ instructions: Możesz zaakceptować lub odrzucić tę propozycję na Twojej stronie
+ %{page_name}.
+ page_name: Co-Creator Requests (Propozycji Współtworzenia)
+ intro_chapter: 'Użytkownik %{inviting_user} zaprasza Twój pseudonim %{pseud}
+ do dołączenia do listy współtwórców poniższego rozdziału:'
+ intro_series: 'Użytkownik %{inviting_user} zaprasza Twój pseudonim %{pseud}
+ do dołączenia do listy współtwórców poniższej serii:'
+ intro_work: 'Użytkownik %{inviting_user} zaprasza Twój pseudonim %{pseud} do
+ dołączenia do listy współtwórców poniższej pracy:'
+ subject: "[%{app_name}] Propozycja współtworzenia"
+ text:
+ creation: "%{title} (%{url}) autorstwa %{pseuds}"
+ instructions: 'Możesz zaakceptować lub odrzucić tę propozycję na Twojej stronie
+ Co-Creator Requests (Propozycji Współtworzenia): %{url}'
+ delete_work_notification:
+ attachment: Dołączamy do wglądu kopię Twojej pracy.
+ deleted_other:
+ html: Wasza wspólna praca %{title} została usunięta na wniosek %{pseud}.
+ text: Wasza wspólna praca "%{title}" została usunięta na wniosek %{pseud}.
+ deleted_yourself:
+ html: Twoja praca %{title} została usunięta na Twój wniosek.
+ text: Twoja praca "%{title}" została usunięta na Twój wniosek.
+ questions:
+ html: Jeśli masz jakiekolwiek pytania, prosimy %{support}.
+ text: Jeśli masz jakiekolwiek pytania, prosimy %{support} (%{url}).
+ subject: "[%{app_name}] Twoja praca została usunięta"
+ support: skontaktuj się ze Wsparciem
+ invitation_to_claim:
+ access:
+ text: W zależności od archiwum, Twoje prace mogły zostać zaimportowane z ograniczeniem
+ dostępności wyłącznie do zarejestrowanych użytkowników (aby utrzymać je
+ poza zasięgiem wyszukiwania w Google). W takim wypadku prace będą osiągalne
+ tylko dla zalogowanych użytkowników, o ile nie zdecydujesz się uczynić je
+ ogólnodostępnymi. Aby uzyskać pomoc w odblokowaniu, osieroceniu bądź usunięciu
+ Twoich prac, prosimy skontaktuj się ze Wsparciem AO3.
+ claim_or_remove:
+ html: Tutaj przejmij lub usuń swoje prace.
+ text: 'Tutaj przejmij lub usuń swoje prace: %{claim_url}'
+ email_tips: W razie kontaktowania się z nami, prosimy o dodanie adresów mailowych
+ zawierających @transformativeworks.org do listy zaufanych nadawców oraz o
+ sprawdzenie, czy nasza odpowiedź nie znajduje się w folderze spam.
+ html:
+ ao3_news: Aktualnościach AO3
+ contact_open_doors: skontaktowanie się z Drzwiami Otwartymi
+ contact_support: skontaktuj się ze Wsparciem AO3
+ faq_page: stronie FAQ
+ tutorial_page: stronie samouczka
+ introduction:
+ text: Otrzymałaś/eś tego e-maila z ponieważ Drzwi Otwarte (%{open_doors_link})
+ niedawno dokonały importu archiwum do %{app_name} (%{app_short_name} -
+ %{app_url}), i wierzymy, że poniższe prace należą do Ciebie. Chcielibyśmy
+ dać Ci szansę przejęcia (lub usunięcia/osierocenia) tych prac, jeśli sobie
+ tego zażyczysz. A jeśli nie posiadasz jeszcze konta pod innym adresem e-mail,
+ chcielibyśmy zaprosić Cię na pokład!
+ mistake:
+ text: Jeśli nastąpiła pomyłka i poniższe prace nie należą do Ciebie, nie usuwaj
+ ich! Prosimy o skontaktowanie się z Drzwiami Otwartymi (%{open_doors_link}),
+ a my wszystko uporządkujemy.
+ more_info:
+ text: Ogłoszenia o ostatnich działaniach możesz przeczytać w Aktualnościach
+ AO3 (%{news_link}), a więcej informacji znajdziesz na stronie FAQ Drzwi
+ Otwartych (%{open_doors_faq_link}) oraz stronach samouczka (%{open_doors_tutorial_link}).
+ W przypadku pytań, na które nie znalazłaś/eś odpowiedzi w FAQ, samouczku
+ ani tym e-mailu, prosimy skontaktuj się ze Wsparciem poprzez stronę %{support_link}.
+ other_works:
+ text: Jeśli na zaimportowanym archiwum posiadasz inne prace, przypisane do
+ adresu e-mail, do którego nie masz już dostępu, prosimy o skontaktowanie
+ się z Drzwiami Otwartymi i podanie jakichkolwiek informacji, mogących pomóc
+ w potwierdzeniu Twoich roszczeń.
+ questions:
+ text: W razie dalszych wątpliwości, prosimy skontaktuj się ze Wsparciem AO3
+ przez stronę %{support_link}.
+ redirects: Aby zachować listy z rekomendacjami oraz zakładki, adres internetowy
+ zaimportowanego archiwum może przez jakiś czas jeszcze przekierowywać do zaimportowanej
+ kopii (dla pewności, sprawdź post z ogłoszeniem dotyczącym Twojego archiwum).
+ Jeśli już zamieściłaś/eś kopie tych prac i NIE użyłaś/eś funkcji importowania
+ z URL, na archiwum znajdą się dwie kopie tej samej pracy.
+ subject: "[%{app_name}] Zaproszenie do przejęcia prac"
+ unwanted:
+ text: Jeśli powyższe prace należą do Ciebie, ale nie chcesz ich przejąć, możesz
+ je osierocić (wówczas pozostaną one dostępne na AO3, lecz Twój pseudonim
+ zostania z nich usunięty) lub skasować (by zostały całkowicie usunięte z
+ AO3). Aby usunąć lub osierocić prace nie musisz dodawać ich do żadnego konta
+ - możesz zrobić to bezpośrednio z powyższego linku do przejęcia. (Żeby uzyskać
+ wsparcie, prosimy skontaktuj się ze Wsparciem poprzez stronę %{support_link}.)
+ update_redirect:
+ text: Jeśli wolisz, żeby Drzwi Otwarte zaktualizowały link przekierowujący
+ tak, aby prowadził do już istniejącej pracy, prosimy o usunięcie zaimportowanej
+ kopii i skontaktowanie się z Drzwiami Otwartymi przez stronę %{open_doors_link},
+ podając nazwę swojego konta AO3, nazwę konta na importowanym archiwum, oraz
+ tytuł i URL pracy, do której ma prowadzić link. (Jeśli dotyczy to kilku
+ prac, możesz wyliczyć je wszystkie w jednej wiadomości).
+ uploaded_list: 'W zamieszczonych pracach znalazły się:'
+ invite_increase_notification:
+ html:
+ body:
+ few: Masz %{count} nowe zaproszenia, które mogą być użyte do stworzenia
+ nowych kont na AO3. Możesz zaprosić kogoś przez %{invitation_page_link}.
+ many: Masz %{count} nowych zaproszeń, które mogą być użyte do stworzenia
+ nowych kont na AO3. Możesz zaprosić kogoś przez %{invitation_page_link}.
+ one: Masz %{count} nowe zaproszenie, które może być użyte do stworzenia
+ nowego konta na AO3. Możesz zaprosić kogoś przez %{invitation_page_link}.
+ invitation_page_link_text: swoją stronę Invitations (Zaproszenia)
+ subject: "[%{app_name}] Nowe zaproszenia"
+ text:
+ body:
+ few: Masz %{count} nowe zaproszenia, które mogą być użyte do stworzenia
+ nowych kont na AO3. Możesz zaprosić kogoś przez swoją stronę Invitations
+ (Zaproszenia) (%{invitation_page_url}).
+ many: Masz %{count} nowych zaproszeń, które mogą być użyte do stworzenia
+ nowych kont na AO3. Możesz zaprosić kogoś przez swoją stronę Invitations
+ (Zaproszenia) (%{invitation_page_url}).
+ one: Masz %{count} nowe zaproszenie, które może być użyte do stworzenia
+ nowego konta na AO3. Możesz zaprosić kogoś przez swoją stronę Invitations
+ (Zaproszenia) (%{invitation_page_url}) .
+ invite_request_declined:
+ main:
+ few: Z przykrością informujemy, że Twoja prośba o %{count} nowe zaproszenia
+ w chwili obecnej nie może zostać zrealizowana.
+ many: Z przykrością informujemy, że Twoja prośba o %{count} nowych zaproszeń
+ w chwili obecnej nie może zostać zrealizowana.
+ one: Z przykrością informujemy, że Twoja prośba o nowe zaproszenie w chwili
+ obecnej nie może zostać zrealizowana.
+ reason: 'Treść Twojej prośby:'
+ subject: "[%{app_name}] Prośba o dodatkowe zaproszenia została odrzucona"
+ recipient_notification:
+ html:
+ collection: W kolekcji %{collection_link} na AO3 opublikowano pracę, będącą
+ prezentem dla Ciebie!
+ no_collection: Na AO3 opublikowano pracę, będącą prezentem dla Ciebie!
+ subject:
+ collection: "[%{app_name}][%{collection_title}] Obdarowano Cię pracą w %{collection_title}"
+ no_collection: "[%{app_name}] Obdarowano Cię pracą"
+ text:
+ collection: W kolekcji "%{collection_title}" (%{collection_url}) na AO3 opublikowano
+ pracę, będącą prezentem dla Ciebie!
+ signup_notification:
+ activate:
+ html: Prosimy, %{activate_account_link}.
+ text: 'Prosimy o aktywację konta: %{activate_account_url}'
+ activate_your_account: aktywuj swoje konto
+ admin_posts: archiwalnych postach adminów
+ bye: Mamy nadzieję, że jesteś zadowolona/y z Archiwum.
+ contact_support: skontaktuj się z naszym zespołem Wsparcia
+ faq: FAQ
+ features:
+ html: Kiedy twoje konto będzie już działać, możesz publikować swoje prace,
+ ustawić subskrypcje, by dostawać powiadomienia mailowe, kiedy twój/a ulubiony
+ autor/ka coś opublikuje lub ulubione prace zostaną zaktualizowane, ustawić
+ preferencje, by spersonalizować wygląd strony i to jak działa ona dla ciebie,
+ śledzić prace czytane w Archiwum za pomocą historii oraz wiele innych.
+ text: Kiedy twoje konto będzie już działać, możesz publikować swoje prace,
+ ustawić subskrybcje, by dostać powiadomienie mailowe, kiedy twój/a ulubiony
+ autor/ka coś opublikuje lub ulubione prace zostaną zaktualizowane, ustawić
+ preferencje, by spersonalizować wygląd strony i to jak działa ona dla ciebie,
+ śledzić prace czytane w Archiwum za pomocą historii oraz wiele innych.
+ information:
+ html: Możesz znaleźć wiele informacji i rad na temat używania Archiwum w naszym
+ %{faq_link}. Najnowsze wiadomości o rozwoju strony znajdziesz w naszych
+ %{admin_posts_link}. Jeśli potrzebujesz pomocy, natkniesz się na bugi lub
+ masz inne pytania lub komentarze, prosimy %{contact_support_link}, który
+ jest zawsze chętny do pomocy.
+ text: Możesz znaleźć wiele informacji i rad na temat używania Archiwum w naszym
+ %{faq_url}. Najnowsze wiadomości o rozwoju strony znajdziesz w naszych %{admin_posts_url}.
+ Jeśli potrzebujesz pomocy, natkniesz się na bugi lub masz inne pytania lub
+ komentarze, prosimy %{contact_support_url}, którzy są zawsze chętni do
+ pomocy.
+ welcome: Witaj w Naszym Własnym Archiwum, %{login}!
diff --git a/config/locales/phrase-exports/pt-BR.yml b/config/locales/phrase-exports/pt-BR.yml
new file mode 100755
index 0000000..1fc1612
--- /dev/null
+++ b/config/locales/phrase-exports/pt-BR.yml
@@ -0,0 +1,1197 @@
+---
+pt-BR:
+ a11y:
+ navigation: Navegação
+ abuse_reports:
+ new:
+ form:
+ comment:
+ error: Por favor descreva o problema.
+ include: inclua todos os links pertinentes, bem como qualquer outra informação
+ que julgue relevante, em sua mensagem
+ label: Seu comentário (campo obrigatório)
+ tos: Termos de Serviço
+ email:
+ description: Não podemos agir com base em denúncias que não sejam associadas
+ a um endereço de e-mail válido.
+ label: Seu endereço de e-mail (campo obrigatório)
+ landmark:
+ send: Enviar à equipe de Diretrizes e Abuso do AO3
+ language:
+ label: Selecione o idioma (campo obrigatório)
+ legend:
+ abuse: Link e descrição da denúncia
+ link:
+ description: Se você chegou a essa página através do link "Report Abuse"
+ (Denunciar abuso) no fim da página, esse campo terá sido preenchido automaticamente.
+ error: Por favor especifique o link da página a que sua denúncia se refere.
+ label: Link da página que você está denunciando (campo obrigatório)
+ name:
+ label: Seu nome (campo opcional)
+ submit:
+ active: Enviar
+ summary:
+ error: Por favor digite um breve resumo da sua mensagem
+ label: Breve resumo da violação dos Termos de Serviço (campo obrigatório)
+ heading:
+ page_title: Denunciar Abuso
+ include:
+ ao3: Termos de Serviço do AO3
+ intro: 'O que incluir na descrição:'
+ languages: 'Podemos responder a denúncias nas seguintes línguas: %{list_html}.'
+ sources: links ou screenshots de quaisquer fontes relevantes
+ username: o nome da conta no AO3 da pessoa que você está denunciando
+ purview:
+ support: entre em contato com a equipe de Suporte
+ tos: Termos de Serviço
+ reportable:
+ hack: caso você ache que sua conta foi hackeada ou que alguém está tentando
+ hackear sua conta, ou
+ intro: 'Por favor utilize esse formulário para enviar uma denúncia somente
+ se você não tenha nos enviado essa mesma denúncia nos últimos 60 dias. Você
+ pode nos contactar aqui sobre os seguintes assuntos:'
+ activerecord:
+ attributes:
+ admin/role:
+ board: Conselho de Administração
+ communications: Comunicação
+ docs: Documentação para o AO3
+ open_doors: Portas Abertas
+ policy_and_abuse: Diretrizes e Abuso
+ superadmin: Superadmin
+ support: Suporte
+ tag_wrangling: Organização de Tags
+ translation: Tradução
+ archive_warning:
+ name_with_colon:
+ one: 'Advertência:'
+ other: 'Advertências:'
+ category:
+ name_with_colon:
+ one: 'Categoria:'
+ other: 'Categorias:'
+ chapters/creatorships:
+ base: 'Nome de artista inválido:'
+ pseud_id: Pseudônimo
+ character:
+ name_with_colon:
+ one: 'Personagem:'
+ other: 'Personagens:'
+ creatorships:
+ base: 'Nome de artista inválido:'
+ pseud_id: Pseudônimo
+ external_work:
+ author: Artista
+ user_defined_tags_count: Tags de fandom, relacionamento e personagem
+ fandom:
+ name_with_colon:
+ one: 'Fandom:'
+ other: 'Fandoms:'
+ freeform:
+ name_with_colon:
+ one: 'Tag adicional:'
+ other: 'Tags adicionais:'
+ meta_tagging:
+ meta_tag: Metatag
+ meta_tag_id: Metatag
+ sub_tag: Subtag
+ sub_tag_id: Subtag
+ rating:
+ name_with_colon: 'Classificação:'
+ relationship:
+ name_with_colon:
+ one: 'Relacionamento:'
+ other: 'Relacionamentos:'
+ series/creatorships:
+ base: 'Nome de artista inválido:'
+ pseud_id: Pseudônimo
+ work:
+ chapter_total_display: Capítulos
+ summary: Sumário
+ work/chapters:
+ base: 'Capítulo inválido:'
+ content: Conteúdo
+ work/creatorships:
+ pseud_id: Pseudônimo
+ errors:
+ models:
+ block:
+ attributes:
+ blocked:
+ official: Desculpe, você não pode bloquear uma conta oficial do site.
+ blocked_id:
+ taken: Você já bloqueou essa conta.
+ comment:
+ attributes:
+ user:
+ format: "%{message}"
+ creatorship:
+ attributes:
+ pseud_id:
+ taken: já aparece na obra como artista.
+ kudo:
+ attributes:
+ commentable:
+ author_on_own_work: Não é possível deixar kudos na sua própria obra.
+ blank: Em que obra você gostaria de deixar kudos?
+ user_is_banned: Não é possível deixar kudos de uma conta banida do AO3.
+ user_is_suspended: Não é possível deixar kudos enquanto sua conta está
+ suspensa.
+ commentable_type:
+ inclusion: Onde você queria deixar kudos?
+ format: "%{message}"
+ taken: Você já deixou kudos nessa obra :)
+ mute:
+ attributes:
+ muted:
+ blank: Desculpe, não foi possível encontrar uma conta com esse nome.
+ format: "%{message}"
+ related_work:
+ attributes:
+ parent:
+ blank: A obra que você escolheu como inspiração não existe. Por favor
+ verifique o link.
+ not_work: Você só pode inserir o link para uma obra já existente no
+ campo Inspiração.
+ protected: Não é possível usar o recurso Obras relacionadas para citar
+ as obras de uma conta protegida como %{login}.
+ format: "%{message}"
+ user:
+ attributes:
+ password_confirmation:
+ confirmation: não é igual à nova senha
+ models:
+ archive_warning:
+ one: Advertência
+ other: Advertências
+ category:
+ one: Categoria
+ other: Categorias
+ chapter:
+ one: Capítulo
+ other: Capítulos
+ character:
+ one: Personagem
+ other: Personagens
+ comment: Comentar
+ fandom:
+ one: Fandom
+ other: Fandoms
+ freeform:
+ one: Tag adicional
+ other: Tags adicionais
+ pseud: Pseudônimo
+ rating:
+ one: Classificação
+ other: Classificação
+ relationship:
+ one: Relacionamento
+ other: Relacionamentos
+ series:
+ one: Série
+ other: Séries
+ tag:
+ one: Tag
+ other: Tags
+ admin:
+ admin_options:
+ delete:
+ bookmark: Apagar favorito
+ confirmation: Tem certeza que quer apagar isso?
+ external_work: Apagar obra externa
+ series: Apagar série
+ work: Apagar obra
+ edit:
+ external_work: Editar obra externa
+ edit_tags: Editar tags e idioma
+ hide:
+ bookmark: Ocultar favorito
+ external_work: Ocultar obra externa
+ series: Ocultar série
+ work: Ocultar obra
+ landmark: Ações de admin
+ not_spam: Desmarcar como spam
+ spam: Marcar como spam
+ unhide:
+ bookmark: Tornar favorito visível
+ external_work: Tornar visível obra externa
+ series: Tornar a série visível
+ work: Tornar a obra visível
+ blacklist:
+ emails_found:
+ one: um e-mail localizado
+ other: "%{count} e-mails localizados"
+ mailer:
+ reset_password_instructions:
+ link_title_html: Trocar minha senha.
+ passwords:
+ edit:
+ label:
+ confirmation: Confirmar nova senha
+ landmark:
+ submit: Enviar
+ page_heading: Definir minha senha de admin
+ submit: Definir senha de admin
+ new:
+ page_heading: Esqueceu sua senha de admin?
+ reset_login_html: Nome de admin
+ submit: Redefinir senha de admin
+ sessions:
+ new:
+ label:
+ login: Nome de admin
+ password: Senha de admin
+ landmark:
+ reset: Redefinir senha
+ page_heading: Logar como admin
+ reset_link: Esqueceu senha de admin?
+ submit: Logar como admin
+ settings:
+ index:
+ fields:
+ account_creation_enabled: Criação de conta habilitada.
+ downloads_enabled: Permitir downloads
+ enable_test_caching: Habilitar caching (configuração experimental)
+ hide_spam: Ocultar obras com spam automaticamente
+ invite_from_queue_frequency: Com que frequência (em número de dias) devemos
+ enviar convites para gente na fila de espera
+ invite_from_queue_number: Número de pessoas na fila que receberão convites
+ imediatamente
+ request_invite_enabled: Usuáries podem pedir convites
+ suspend_filter_counts: Desabilitar (em parte) o rastreamento de filtros
+ devido a um alto volume de postagens
+ tag_wrangling_off: Desligar organização de tags para todo mundo que não
+ é admin
+ heading: Configurações do AO3
+ legend:
+ account_and_invitations: Contas e convites
+ actions: Ações
+ disable_support_form: Desabilitar formulário de Suporte
+ queue_status: "%{number} receberão convites no dia %{date}."
+ update: Atualizar
+ update:
+ success: As configurações do AO3 foram atualizadas com sucesso.
+ admins:
+ index:
+ confidentiality_reminder: Você efetuou login com uma conta admin. Isso quer
+ dizer que você provavelmente poderá acessar informações pessoais ou confidenciais
+ (como endereços de e-mail, nomes de conta, endereços de IP, nomes de quem
+ postou obras anônimas, etc.). Por favor não use essa informação fora de suas
+ responsabilidades na OTW. Se você tiver alguma pergunta sobre o que pode ou
+ não fazer com informações que você encontrar aqui, entre em contato com presidentes
+ do seu comitê.
+ page_title: Oi, %{login}!
+ roles:
+ heading: 'Suas permissões como admin:'
+ none: No momento você não tem nenhuma permissão de admin associada à sua conta.
+ admin_posts:
+ admin_post_form:
+ comment_permissions:
+ label: Quem pode comentar nesse post de admin
+ language:
+ label: Escolha um idioma
+ tags:
+ label: Tags
+ translated_post:
+ label: Tradução do post
+ blocked:
+ unblock: Desbloquear
+ users:
+ confirm_block:
+ sure_html: Tem certeza que você quer %{block}%{username}?
+ create:
+ blocked: Você bloqueou a conta %{name}.
+ index:
+ blocked_users: Contas bloqueadas
+ none: Você não bloqueou nenhuma conta.
+ collection_items:
+ collection_item_form:
+ add: Adicionar
+ add_bookmark_header: Adicionar favorito a coleções
+ invite: Convidar
+ index:
+ navigation:
+ unreviewed_by_user: Aguardando aprovação
+ comments:
+ check_blocked:
+ reply: Desculpe, essa pessoa bloqueou sua conta.
+ commentable:
+ blocked: Desculpe, não é possível deixar comentários, pois sua conta foi bloqueada
+ por alguém na lista de artistas que criaram essa obra.
+ permissions:
+ admin_post:
+ alt_action: Você pode, no entanto, %{support_link} se tiver comentários
+ ou perguntas para a nossa equipe.
+ disable_anon: Desculpe, esse post de notícias não permite que pessoas sem
+ conta registrada no AO3 deixem comentários.
+ support_link: entre em contato com a equipe de Suporte
+ options:
+ disable_all: Não é permitido que ninguém comente
+ disable_anon: Somente contas registradas podem deixar comentários
+ enable_all: Contas registradas e visitantes podem comentar
+ work:
+ alt_action: Mas você ainda pode deixar kudos!
+ disable_all: Desculpe, essa obra não permite comentários.
+ disable_anon: Desculpe, essa obra não permite que pessoas sem conta no AO3
+ deixem comentários.
+ hidden: Desculpe, você não pode adicionar ou editar comentários numa obra
+ oculta.
+ unrevealed: Desculpe, você não pode adicionar ou editar comentários numa
+ obra não revelada.
+ freeze:
+ permission_denied: Desculpe, sua conta não tem as permissões necessárias para
+ congelar esses comentários.
+ devise:
+ confirmations:
+ confirmed: Seu endereço de e-mail foi confirmado com sucesso.
+ send_instructions: Você receberá em alguns minutos um e-mail com instruções
+ para confirmar seu e-mail.
+ send_paranoid_instructions: Se seu endereço de e-mail existe no nosso banco
+ de dados, você receberá em alguns minutos um e-mail com instruções para confirmar
+ seu e-mail.
+ failure:
+ admin:
+ already_authenticated: Você já efetuou login.
+ inactive: Sua conta ainda não foi ativada.
+ locked: Sua conta está bloqueada.
+ not_found_in_database: A senha ou nome de conta admin que você digitou não
+ está de acordo com nosso banco de dados.
+ timeout: Sua sessão expirou. Por favor efetue login novamente para prosseguir.
+ unauthenticated: Você precisa efetuar login ou criar uma conta para poder
+ prosseguir.
+ unconfirmed: Você tem que confirmar seu endereço de e-mail antes de prosseguir.
+ user:
+ already_authenticated: Você já efetou login.
+ inactive: Você tem que ativar sua conta antes de efetuar login. Por favor
+ cheque sua caixa de entrada ou entre em contato com a equipe de Suporte.
+ last_attempt: Você tem mais uma tentativa disponível antes que sua conta seja
+ bloqueada.
+ locked: Sua conta foi bloqueada por 5 minutes devido a um excesso de tentativas
+ de login mal-sucedidas.
+ not_found_in_database: A senha ou a conta que você digitou não estão de acordo
+ com nosso banco de dados. Por favor tente novamente ou redefina
+ sua senha. Se você não consegue efetuar login mesmo assim, visite Problemas ao logar? para maiores informações.
+ timeout: Sua sessão expirou. Por favor efetue login novamente para continuar.
+ unauthenticated: Você precisa efetuar login ou criar uma conta para prosseguir.
+ mailer:
+ password_change:
+ subject: Senha alterada
+ reset_password_instructions:
+ subject: "[%{app_name}] Link para redefinir senha criado"
+ omniauth_callbacks:
+ failure: 'Não foi possível autenticar você de %{kind} por causa da seguinte
+ razão: "%{reason}".'
+ success: Acesso da conta %{kind} autenticado com sucesso.
+ passwords:
+ updated: Sua senha foi alterada com sucesso. Login efetuado.
+ updated_not_active: Sua senha foi alterada com sucesso.
+ registrations:
+ destroyed: Adeus! Sua conta foi cancelada com sucesso. Esperamos lhe ver novamente
+ em breve.
+ signed_up: A equipe AO3 lhe deseja as boas vindas! Você se registrou com sucesso.
+ signed_up_but_inactive: Você criou sua conta com sucesso. No então, não foi
+ possível efetuar login, pois você ainda não completou a ativação da sua conta
+ por e-mail.
+ updated: Sua conta foi atualizada com sucesso.
+ sessions:
+ already_signed_out: Logout efetuado com sucesso.
+ signed_in: Login efetuado com sucesso.
+ signed_out: Logout efetuado com sucesso.
+ unlocks:
+ send_instructions: Você receberá um e-mail com instruções para desbloquear sua
+ conta em alguns minutos.
+ unlocked: Sua conta foi desbloqueada com sucesso. Por favor efetue login para
+ continuar.
+ errors:
+ messages:
+ confirmation_period_expired: tem que ser confirmada durante %{period}, por favor
+ envie sua solicitação novamente.
+ not_found: não encontrado
+ not_locked: não estava bloqueado
+ not_saved:
+ one: Um erro impediu que esse %{resource} fosse salvo.
+ other: "%{count} erros impediram que esse %{resource} fosse salvo."
+ feedbacks:
+ new:
+ abuse:
+ reports: No caso de denúncias de violações de nossos Termos de Serviço como
+ assédio, spam ou plágio, ou se você acha que sua conta pode ter sido hackeada,
+ por favor, %{contact_link}. Não podemos agir em resposta a esse tipo de
+ denúncia ou dar mais informações sobre denúncias enviadas à equipe de Diretrizes
+ e Abuso.
+ do_not_spam_html: "Respondemos todas as solicitações que recebemos,
+ mas somos uma pequena equipe voluntária. Por isso, pedimos que você
+ não nos envie mais de uma mensagem sobre o mesmo problema ou encoraje outras
+ pessoas a fazê-lo, a menos que tenham novas informações para compartilhar
+ conosco."
+ form:
+ comment:
+ description: Por favor escreva sua mensagem com o máximo de detalhes possível,
+ incluindo mensagens de erro e/ou links
+ error: Por favor, digite seu comentário.
+ label: Sua pergunta ou problema (campo obrigatório)
+ email:
+ label: Seu e-mail (campo obrigatório)
+ ip: Nosso filtro de spam coleta endereços de IP, mas nós nunca os vemos.
+ language:
+ label: Selecione o idioma (campo obrigatório)
+ legend:
+ contact_info: Informações de contato
+ send: Envie seu feedback
+ name:
+ label: Seu nome (campo opcional)
+ submit:
+ active: Enviar
+ disabled: Por favor, aguarde.
+ summary:
+ error: Por favor insira um breve resumo da sua mensagem
+ label: Breve resumo da sua pergunta ou problema (campo obrigatório)
+ heading:
+ instructions: Por favor utilize esse formulário para enviar perguntas sobre
+ como utilizar o AO3, bem como para relatar problemas técnicos.
+ landmark:
+ reference: Links para referência
+ page_title: Suporte e Feedback
+ languages_html: "Podemos responder a solicitações de Suporte nos seguintes
+ idiomas: %{list}. Por favor leve em consideração que respostas em línguas
+ além do inglês podem demorar mais."
+ navigation:
+ faqs: FAQ e tutoriais
+ known_issues: Problemas conhecidos
+ release_notes: Notas de versão
+ reportable:
+ account_creation: Dificuldades com a configuração da sua conta
+ bugs: Bugs, erros ou problemas inesperados na utilização do site.
+ intro: 'Você pode entrar em contato com a equipe de Suporte sobre vários assuntos,
+ incluindo:'
+ lost_access: Senha ou e-mail perdidos, impossibilitando o acesso à sua conta
+ new_features: 'Pedidos para desenvolvimento futuro de recursos '
+ orphaned_works: Perguntas sobre obras orfanadas
+ policy_questions: Perguntas gerais sobre políticas do AO3
+ site_questions: Perguntas sobre como usar o site
+ tag_changes: Pedidos para canonizar ou modificar tags
+ work_problems: Obras marcadas com a língua errada ou obras duplicadas
+ status:
+ twitter: "@AO3_Status"
+ home:
+ donate:
+ page_title: Doe ou faça parte da nossa equipe voluntária
+ kudos:
+ create:
+ success: Obrigade por deixar kudos!
+ guest_header:
+ one: "%{count} visitante também deixou kudos"
+ other: "%{count} visitantes também deixaram kudos"
+ user_links:
+ more_link:
+ one: mais %{count} usuárie
+ other: mais %{count} usuáries
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ one: 1 visitante
+ other: "%{count} visitantes"
+ left_kudos:
+ html:
+ one: "%{givers_list} deixou kudos em %{commentable_link}."
+ other: "%{givers_list} deixaram kudos em %{commentable_link}."
+ text:
+ one: "%{givers_list} deixou kudos em %{commentable_title} (%{commentable_url})."
+ other: "%{givers_list} deixaram kudos em %{commentable_title} (%{commentable_url})."
+ single_guest:
+ giver: 1 visitante
+ html: "%{giver} deixou kudos em %{commentable_link}."
+ text: 1 visitante deixou kudos em %{commentable_title} (%{commentable_url}).
+ subject: "[%{app_name}] Kudos pra você!"
+ layouts:
+ proxy_notice:
+ button: Desabilitar aviso
+ faux_heading: 'Aviso importante:'
+ point1: Você está utilizando um site de proxy que não faz parte do Archive of
+ Our Own – AO3 (Nosso Próprio Arquivo).
+ point2: Quem criou esse site de proxy pode ver toda a sua atividade no site,
+ incluindo seu endereço de IP. Se você efetuar login na sua conta do AO3 através
+ desse site, ele poderá ver sua senha.
+ mailer:
+ general:
+ closing:
+ formal: Atenciosamente,
+ informal: Atenciosamente,
+ creation:
+ link_with_word_count: "%{creation_link} (%{word_count})"
+ title_with_chapter_number: Capítulo %{position} de %{title}
+ title_with_word_count: '"%{creation_title}" (%{word_count})'
+ word_count:
+ one: "%{count} palavra"
+ other: "%{count} palavras"
+ footer:
+ general:
+ about:
+ html: O AO3 é um arquivo que depende do trabalho voluntário e das %{donate_link}.
+ text: 'O AO3 é um arquivo que depende do trabalho voluntário e das doações
+ de fãs: %{donate_url}.'
+ html:
+ donate_link_text: doações de fãs
+ support_link_text: entre em contato com a nossa equipe de Suporte
+ unwanted_email:
+ html: Se você recebeu essa mensagem por engano, por favor %{support_link}.
+ text: 'Se você recebeu essa mensagem por engano, por favor entre em contato
+ com a nossa equipe de Suporte: %{support_url}.'
+ sent_at: Enviado %{sent_at}.
+ greeting:
+ formal_html: Olá, %{name},
+ informal:
+ addressed_html: Oi, %{name}!
+ unaddressed: Oi!
+ introductory: Olá! Aqui é o Archive of Our Own – AO3 (Um Arquivo Todo Nosso).
+ metadata_label_indicator: ":"
+ signature:
+ abuse_team: Equipe de Diretrizes e Abuso do AO3
+ app_short_name: AO3
+ open_doors: Equipe do Open Doors (Portas Abertas)
+ parent_org: Organization for Transformative Works – OTW (Organização para
+ Obras Transformativas)
+ support: Equipe de Suporte do AO3
+ muted:
+ users:
+ confirm_mute:
+ blocked_users_link_text: sua página de Contas Bloqueadas
+ cancel: Cancelar
+ restore_site_skin_faq_link_text: Instruções para voltar para a skin padrão
+ do site
+ will:
+ seeing_content: ocultar completamente obras, séries, favoritos e comentários
+ sempre que você estiver visitando o AO3; não haverá espaço em branco ou
+ indicação alguma de que algo foi removido dali
+ will_not:
+ hide_content_for_others: ocultar obras, séries, favoritos e comentários
+ de outras pessoas
+ prevent_emails: impedir você de receber e-mails com notificações de novos
+ comentários, capítulos ou obras dessa pessoa
+ confirm_unmute:
+ resume:
+ see_content: ver suas obras, séries, favoritos e comentários no site
+ index:
+ blocked_users_link_text: sua página de Contas Bloqueadas
+ restore_site_skin_faq_link_text: instruções para usar novamente a skin padrão
+ do site
+ pseuds:
+ delete_preview:
+ heading: Apagar pseudônimo
+ skins:
+ confirm_delete:
+ confirm_html: Tem certeza que quer apagar a skin "%{skin_title}"?
+ tags:
+ index:
+ about:
+ popular_in_collection: Aqui estão algumas das tags mais populares utilizadas
+ nessa coleção.
+ troubleshooting:
+ show:
+ fix_associations:
+ title: Consertar associações da tag
+ fix_counts:
+ title: Consertar contagem de uma tag
+ fix_meta_tags:
+ title: Consertar metatags
+ page_description:
+ tag: Se essa tag está se comportando de forma indesejada, tente uma das opções
+ a seguir para consertá-la.
+ work: Se essa obra está se comportando de forma indesejada, tente uma das
+ opções a seguir para consertá-la.
+ page_title:
+ tag: Investigar problema com tag
+ work: Investigar problema com obra
+ reindex_tag:
+ description: Reindexa essa tag e todas as obras, os favoritos, as séries,
+ os pseudônimos e as obras externas associados a ela. Use essa opção se nenhuma
+ das outras funcionou. Essa opção não conserta problemas com o preenchimento
+ automático.
+ title: Reindexar tag
+ reindex_work:
+ description: Reindexar obra. Utilize essa opção se a obra não está aparecendo
+ nas listagens de busca ou se está aparecendo em listagens onde não deveria
+ constar (por exemplo, se alguém orfanou uma obra e ainda é possível achá-la
+ ao se buscar pelo nome antigo; ou se a obra não segue o esperado quando
+ se filtram crossovers, completa/incompleta, contagem de palavras etc.).
+ title: Reindexar obra
+ update_tag_filters:
+ title: Atualizar filtros de tags
+ update_work_filters:
+ title: Atualizar filtros de uma obra
+ users:
+ change_username:
+ last_renamed: 'A última vez que você trocou o nome associado à sua conta foi
+ na seguinte data e hora: %{renamed_at}.'
+ delete_preview:
+ cancel: Cancelar
+ confirm: Tem certeza? Não é possível desfazer essa ação.
+ co_creations:
+ legend: Obras que você criou com outras pessoas
+ orphan_info: Você não pode deletar essas obras, pois são compartilhadas com
+ coartistas. Pode, no entanto, %{orphan_link} as obras ou remover completamente
+ seu nome como coartista.
+ summary: 'Você é coartista em %{work_count} obra(s) com as seguintes pessoas:
+ %{co_creators}.'
+ heading: O que você quer fazer com suas obras?
+ options:
+ delete: Apagar completamente
+ keep_pseud: Deixar meu pseudônimo na obra, mas associá-la à orphan_account
+ (conta órfã)
+ orphan_pseud: Mudar meu pseudônimo para "orphan" e associar à orphan_account
+ (conta órfã)
+ remove: Retirar completamente meu nome como coartista
+ orphan: orfanar
+ orphaning: orfanar
+ sole_creations:
+ collections_summary: 'Você tem %{collection_count} coleção (ou coleções) associadas
+ aos seguintes pseudônimos: %{pseuds}.'
+ legend: Obras e coleções que você postou somente sob seu próprio nome
+ options_info: Você pode apagá-las, mas por favor considere a opção de %{orphaning_link}
+ ao invés disso!
+ works_summary: 'Você tem %{work_count} obra(s) com os pseudônimos a seguir:
+ %{pseuds}.'
+ submit: Salvar
+ mailer:
+ reset_password_instructions:
+ expiration: Se você não utilizar esse link para redefinir sua senha dentro
+ de uma semana, ele irá expirar e você terá que solicitar um novo link.
+ intro: Alguém solicitou a redefinição da senha da sua conta. Para fazer a
+ alteração, acesse o link a seguir e insira uma nova senha.
+ link_title: Mudar minha senha.
+ subject: "[%{app_name}] Mudança de senha"
+ unrequested: Caso você não tenha solicitado a redefinição da sua senha, ignore
+ esse e-mail. Sua senha atual irá continuar funcionando.
+ sessions:
+ new:
+ login:
+ request_invite: Pedir um convite para entrar
+ show:
+ login_banner:
+ dismiss: Desabilitar banner de forma permanente
+ hide: Desabilitar banner de ajuda para quem efetua login pela primeira vez
+ user_mailer:
+ admin_deleted_work_notification:
+ bye: Uma cópia da obra removida encontra-se em anexo.
+ contact_abuse: entre em contato com a nossa equipe de Diretrizes e Abuso
+ deleted:
+ html: Sua obra %{title} foi removida do AO3 por um membro da equipe de administração
+ do site.
+ text: Sua obra "%{title}" foi apagada do AO3 por um membro da equipe de administração
+ do site.
+ html:
+ tos_violation: Caso seja possível que sua obra tenha violado os Termos de
+ Serviço do AO3, por favor %{contact_abuse_link}.
+ import_project:
+ html: Se sua obra era parte de um projeto de importação ligado ao Open Doors
+ (Portas Abertas), por favor %{opendoors_link} com qualquer dúvida.
+ text: Se sua obra era parte de um projeto de importação ligado ao Open Doors
+ (Portas Abertas), por favor entre em contato com a equipe do Portas Abertas
+ (%{opendoors_link}) com qualquer dúvida.
+ opendoors: entre em contato com a equipe do Portas Abertas
+ subject: "[%{app_name}] Sua obra foi removida pela administração do AO3"
+ text:
+ tos_violation: Caso seja possível que sua obra tenha violado os Termos de
+ Serviço do AO3, por favor entre em contato com a nossa equipe de Diretrizes
+ e Abuso (%{contact_abuse_url}).
+ admin_hidden_work_notification:
+ access: Enquanto sua obra estiver oculta, você poderá acessá-la através do link
+ fornecido acima, mas ela não aparecerá na sua página de obras e não estará
+ disponível para quem visitar o AO3.
+ check_email: Por favor, cheque seu e-mail, inclusive sua pasta de spam, pois
+ a equipe de Diretrizes e Abuso já pode ter entrado em contato para explicar
+ a razão dessa medida.
+ contact_abuse: entre em contato com a equipe de Diretrizes e Abuso
+ html:
+ help: Se não tiver certeza do motivo dessa decisão e não tiver recebido outros
+ comunicados em relação a este assunto, por favor %{contact_abuse_link} diretamente.
+ hidden: A equipe de Diretrizes e Abuso ocultou sua obra %{title} e ela não
+ está mais publicamente acessível.
+ tos_violation: Caso isso tenha ocorrido por sua obra violar os %{tos_link}
+ do AO3, é necessário que você corrija a infração. A não adequação da sua
+ obra aos Termos de Serviço pode fazer com que ela seja removida do AO3.
+ subject: "[%{app_name}] A equipe de Diretrizes e Abuso ocultou sua obra"
+ text:
+ help: 'Se não tiver certeza do motivo dessa decisão e não tiver recebido outros
+ comunicados em relação a este assunto, por favor entre em contato diretamente
+ com a equipe de Diretrizes e Abuso: %{contact_abuse_url}.'
+ hidden: A equipe de Diretrizes e Abuso ocultou sua obra "%{title}" (%{work_url})
+ e ela não está mais publicamente acessível.
+ tos_violation: Caso isso tenha ocorrido por sua obra violar os Termos de Serviço
+ (%{tos_url}) do AO3, é necessário que você corrija a infração. A não adequação
+ da sua obra aos Termos de Serviço pode fazer com que ela seja removida do
+ AO3.
+ tos: Termos de Serviço
+ anonymous_or_unrevealed_notification:
+ anonymous_info: Obras anônimas aparecem em todo o site, exceto na sua página
+ pessoal. A obra será atribuída a "Anonymous".
+ anonymous_unrevealed_info: A administração da coleção poderá, posteriormente,
+ revelar a sua obra, mas mantê-la anônima. Nesse caso, pessoas que recebem
+ atualizações sobre suas obras não serão notificadas. Uma vez revelada, sua
+ obra aparecerá em todo o site, exceto na sua página pessoal. Enquanto a obra
+ permanecer anônima, ela será atribuída a "Anonymous".
+ changed_status:
+ anonymous:
+ html: A equipe de administração da coleção %{collection_link} mudou o status
+ da sua obra %{work_link} para anônima.
+ text: A equipe de administração da coleção "%{collection_title}" (%{collection_url})
+ mudou o status da sua obra "%{work_title}" (%{work_url}) para anônima.
+ anonymous_unrevealed:
+ html: A administração da coleção %{collection_link} mudou o status da sua
+ obra %{work_link} para anônima e não revelada.
+ text: A equipe de administração da coleção "%{collection_title}" (%{collection_url})
+ mudou o status da sua obra "%{work_title}" (%{work_url}) para anônima
+ e não revelada.
+ unrevealed:
+ html: A equipe de administração da coleção %{collection_link} mudou o status
+ da sua obra %{work_link} para não revelada.
+ text: A equipe de administração da coleção "%{collection_title}" (%{collection_url})
+ mudou o status da sua obra "%{work_title}" (%{work_url}) para não revelada.
+ collection_items_link_text: página de Approved Collection Items (Itens aprovados
+ para coleção)
+ do_not_want:
+ anonymous:
+ html: Se você não deseja que sua obra continue anônima, por favor acesse
+ sua %{collection_items_link} para removê-la dessa coleção.
+ text: 'Se você não deseja que sua obra continue anônima, por favor acesse
+ sua página de Approved Collection Items (Itens aprovados para coleção)
+ para removê-la dessa coleção: %{collection_items_url}'
+ anonymous_unrevealed:
+ html: Se você não deseja que a sua obra continue anônima e não revelada,
+ por favor acesse sua %{collection_items_link} para removê-la dessa coleção.
+ text: 'Se você não deseja que a sua obra continue anônima e não revelada,
+ por favor acesse sua página de Approved Collection Items (Itens aprovados
+ para coleção) para removê-la dessa coleção: %{collection_items_url}'
+ unrevealed:
+ html: Se você não deseja que a sua obra continue com o status de não revelada,
+ por favor acesse sua %{collection_items_link} para removê-la dessa coleção.
+ text: 'Se você não deseja que a sua obra continue com o status de não revelada,
+ por favor acesse sua página de Approved Collection Items (Itens aprovados
+ para coleção) para removê-la dessa coleção: %{collection_items_url}'
+ faq_link_text: seção de FAQ sobre Coleções
+ more_info:
+ html: Para mais informações, acesse a nossa %{faq_link}.
+ text: 'Para mais informações, acesse a nossa seção de FAQ sobre Coleções:
+ %{faq_url}'
+ subject:
+ anonymous: "[%{app_name}] Sua obra foi marcada como anônima"
+ anonymous_unrevealed: "[%{app_name}] Sua obra foi marcada como anônima e não
+ revelada"
+ unrevealed: "[%{app_name}] Sua obra foi marcada como não revelada"
+ unrevealed_info: Obras não reveladas não aparecem no site em geral ou na sua
+ página pessoal. Qualquer pessoa que acessar um link para essa obra receberá
+ um aviso dizendo que ela se encontra não revelada no momento e que não é possível
+ acessar seu conteúdo.
+ archivist_added_to_collection_notification:
+ approved_collection_items_page: página Approved Collection Items (Itens Aprovados
+ em Coleções)
+ archivist_notice: Como quem mantém a coleção atua em sua capacidade oficial
+ como arquivista do Open Doors (Portas Abertas), essas pessoas têm permissão
+ para adicionar sua obra a essa coleção, mesmo que você tenha desativado os
+ convites para coleção. Arquivistas só adicionarão uma obra a uma coleção se
+ ela tiver sido hospedada em um arquivo importado.
+ removal_instructions:
+ html: Caso queira remover sua obra dessa coleção, por favor, visite sua %{approved_items_link}.
+ text: 'Caso queira remover sua obra dessa coleção, por favor, visite sua página
+ de Approved Collection Items (Itens Aprovados em Coleções): %{approved_items_url}.'
+ subject: "[%{app_name}][%{collection_title}] Sua obra foi incluída em uma coleção
+ do Open Doors (Portas Abertas)."
+ work_added:
+ html: A curadoria da coleção %{collection_link} incluiu sua obra %{work_link}
+ nessa coleção!
+ text: A curadoria da coleção "%{collection_title}" (%{collection_url}) incluiu
+ sua obra "%{work_title}" (%{work_url}) nessa coleção!
+ challenge_assignment_notification:
+ any: Quaisquer
+ assignment:
+ html: Você recebeu a seguinte tarefa no desafio %{link} no AO3!
+ description: 'Descrição:'
+ due: 'Prazo:'
+ html:
+ footer: Você recebeu esse email porque se inscreveu no desafio %{title}. Para
+ mais informações sobre ele e sobre como entrar em contato com a moderação,
+ acesse %{footer_link}.
+ footer_link: a página do desafio
+ look_up: Acesse mais detalhes sobre essa tarefa na %{link}.
+ look_up_link: sua página de Assignments (Tarefas)
+ optional_tags: 'Tags opcionais:'
+ prompts: 'Ideias:'
+ prompt_url: 'URL da ideia:'
+ recipient: 'Para:'
+ recipient_missing: 'Ninguém: entre em contato com a moderação do desafio para
+ receber ajuda!'
+ subject: "[%{app_name}][%{collection_title}] Sua tarefa!"
+ text:
+ assignment: Você recebeu a seguinte tarefa no desafio "%{collection_title}"
+ (%{collection_url}) no AO3!
+ footer: Você recebeu esse email porque se inscreveu no desafio %{title} (%{url}).
+ Para mais informações sobre ele e sobre como entrar em contato com a moderação,
+ acesse %{profile_url}.
+ look_up: 'Acesse mais detalhes sobre essa tarefa na sua página de Assignments
+ (Tarefas): %{link}.'
+ change_email:
+ changed:
+ html: "%{login}, o e-mail associado à sua conta foi alterado para %{email}."
+ text: "%{login}, o e-mail associado à sua conta foi alterado para %{email}."
+ subject: "[%{app_name}] Mudança de endereço de e-mail efetuada"
+ claim_notification:
+ access:
+ contact_support: entre em contato com o Suporte do AO3
+ html: Dependendo do arquivo, talvez as suas obras tenham sido importadas com
+ acesso apenas para pessoas com contas registradas (para mantê-las fora das
+ pesquisas do Google). Se for o caso, essas obras serão acessíveis apenas
+ a quem fez login, ao menos que você decida torná-las visíveis a visitantes.
+ Se precisar de ajuda para desbloquear, orfanar, ou deletar suas obras, por
+ favor, %{contact_support_link}.
+ text: Dependendo do arquivo, talvez as suas obras tenham sido importadas com
+ acesso apenas para pessoas com contas registradas (para mantê-las fora das
+ pesquisas do Google). Se for o caso, essas obras serão acessíveis apenas
+ a quem fez login, ao menos que você decida torná-las visíveis a visitantes.
+ Se precisar de ajuda para desbloquear, orfanar, ou deletar suas obras, por
+ favor, entre em contato com o Suporte do AO3 no link %{support_url}.
+ email_tips: Se você entrar em contato conosco, por favor, adicione os endereços
+ de e-mail de @transformativeworks.org à sua lista de contatos e verifique
+ as suas pastas de spam para encontrar nossas respostas.
+ introduction:
+ ao3_name: Archive of Our Own – AO3 (Um Arquivo Todo Nosso)
+ html: Você está recebendo este e-mail porque tinha obras em um arquivo de
+ obras de fãs que foi importado pelo %{open_doors_name_link} ao %{app_link}.
+ Como este endereço de e-mail está conectado a um registro do arquivo importado,
+ as obras de fãs associadas (listadas abaixo) foram adicionadas à sua conta
+ do AO3.
+ open_doors_name: Open Doors (Portas Abertas)
+ text: 'Você está recebendo este e-mail porque tinha obras em um arquivo de
+ obras de fãs que foi importado pelo Open Doors (Portas Abertas) (%{open_doors_url})
+ ao %Archive of Our Own – AO3 (Um Arquivo Todo Nosso): %{app_url}. Como este
+ endereço de e-mail está conectado a um registro do arquivo importado, as
+ obras de fãs associadas (listadas abaixo) foram adicionadas à sua conta
+ do AO3.'
+ mistake:
+ contact_open_doors: entre em contato com o Open Doors
+ html: Se isto é um erro e estas obras não são suas, por favor, não as delete!
+ Simplesmente %{contact_open_doors_link} e nós resolveremos.
+ text: Se isto é um erro e estas obras não são suas, por favor, não as delete!
+ Simplesmente entre em contato com o Open Doors (%{open_doors_url}) e nós
+ resolveremos.
+ more_info:
+ ao3_news: Notícias do AO3
+ contact_support: entre em contato com o Suporte do AO3
+ faq_page: página de FAQ
+ html: Encontre anúncios sobre arquivos recentemente movidos nas %{ao3_news_link},
+ e informações adicionais na %{faq_page_link} ou no %{tutorial_page_link}
+ do Open Doors. Em caso de dúvidas não respondidas nas páginas de FAQ, tutoriais,
+ ou neste e-mail, por favor, %{contact_support_link}.
+ text: Encontre anúncios sobre arquivos recentemente movidos nas Notícias do
+ AO3 (%{news_url}), e informações adicionais na página de FAQ (%{open_doors_faq_url})
+ ou no tutorial (%{open_doors_tutorial_url}) do Open Doors. Em caso de dúvidas
+ não respondidas nas páginas de FAQ, tutoriais, ou neste e-mail, por favor,
+ entre em contato com o Suporte no link %{support_url}.
+ tutorial_page: tutorial
+ other_works:
+ contact_open_doors: entre em contato com o Open Doors
+ html: Se você tiver outras obras no arquivo importado sob um endereço de e-mail
+ diferente ao qual não possui mais acesso, por favor, %{contact_open_doors_link}
+ com quaisquer informações que possam ajudar na verificação da sua identidade.
+ text: Se você tiver outras obras no arquivo importado sob um endereço de e-mail
+ diferente ao qual não possui mais acesso, por favor, entre em contato com
+ o Open Doors com quaisquer informações que possam ajudar na verificação
+ da sua identidade.
+ questions:
+ contact_support: entre em contato com o Suporte do AO3
+ html: Em caso de outras dúvidas, por favor, %{contact_support_link}.
+ text: Em caso de outras dúvidas, por favor, entre em contato com o Suporte
+ do AO3 no link %{support_url}.
+ redirects:
+ html: Para preservar listas de recomendações e favoritos, os endereços dos
+ arquivos importados podem redirecionar à cópia importada dessas obras por
+ um tempo limitado (verifique o anúncio de importação do seu arquivo para
+ ter certeza). Se você já publicou uma cópia dessas obras e você %{negation}
+ utilizou a função Importação por URL, haverá duas cópias da mesma obra no
+ AO3.
+ subject: "[%{app_name}] Upload de obras"
+ update_redirect:
+ contact_open_doors: entre em contato com o Open Doors
+ html: Se você gostaria que o Open Doors atualizasse o redirecionamento para
+ a sua obra pré-existente, por favor, delete a cópia importada e %{contact_open_doors_link}
+ com o nome da sua conta no AO3, o nome da sua conta no arquivo importado,
+ o título e a URL da obra de fã que você gostaria que fosse redirecionada.
+ (Se você tiver múltiplas obras das quais gostaria de mudar o redirecionamento,
+ pode listá-las em um único e-mail.)
+ text: Se você gostaria que o Open Doors atualizasse o redirecionamento para
+ a sua obra pré-existente, por favor, delete a cópia importada e entre em
+ contato com o Open Doors no link %{open_doors_url} com o nome da sua conta
+ no AO3, o nome da sua conta no arquivo importado, o título e a URL da obra
+ de fã que você gostaria que fosse redirecionada. (Se você tiver múltiplas
+ obras das quais gostaria de mudar o redirecionamento, pode listá-las em
+ um único e-mail.)
+ works_by: 'Estas obras foram escritas sob o e-mail: %{email}'
+ work_info:
+ html: "%{work_link} (%{fandom})"
+ text:
+ no_fandom: "- %{work_title} %{work_url}"
+ with_fandom: "- %{work_title} %{work_url} (%{fandom})"
+ collection_notification:
+ assignments_sent:
+ complete: Todas as tarefas foram enviadas.
+ subject: Tarefas enviadas
+ html:
+ received_message: 'Você recebeu uma mensagem sobre sua coleção %{collection_link}:'
+ text:
+ received_message: 'Você recebeu uma mensagem sobre sua coleção "%{collection_title}"
+ (%{collection_url}):'
+ creatorship_notification:
+ explanation: Agora que o seu pseudônimo foi adicionado à obra, ele também poderá
+ aparecer em novos capítulos dessa mesma obra, independentemente das suas preferências
+ no site. Seu pseudônimo também aparecerá em qualquer série à qual a obra for
+ adicionada.
+ html:
+ creation: "%{creation_link} por %{pseud_links}"
+ edit_chapter: editar o capítulo
+ edit_series: editar a série
+ remove_chapter: Caso isso tenha ocorrido por engano ou você não queira que
+ seu pseudônimo apareça nessa obra como coartista, é só %{edit_chapter_link}
+ para remover seu nome.
+ remove_series: Caso isso tenha ocorrido por engano ou você não queira que
+ seu pseudônimo apareça nessa série como coartista, é só %{edit_series_link}
+ para remover seu nome.
+ intro_chapter: 'Seu pseudônimo %{pseud} foi adicionado por %{adding_user} como
+ coartista do seguinte capítulo:'
+ intro_series: 'Seu pseudônimo %{pseud} foi adicionado por %{adding_user} como
+ coartista da seguinte série:'
+ subject: "[%{app_name}] Sua conta foi adicionada como coartista"
+ text:
+ creation: "%{title} (%{url}) por %{pseuds}"
+ remove_chapter: 'Caso isso tenha ocorrido por engano ou você não queira que
+ seu pseudônimo apareça nessa obra como coartista, é só editar o capítulo
+ para remover seu nome: %{url}'
+ remove_series: 'Caso isso tenha ocorrido por engano ou você não queira que
+ seu pseudônimo apareça nessa série como coartista, é só editar a série para
+ remover seu nome: %{url}'
+ creatorship_notification_archivist:
+ explanation: Essa conta arquivista, por ser participante oficial do projeto
+ Open Doors (Portas Abertas), pode adicionar seu nome a uma obra sem solicitação
+ prévia, mesmo que você não tenha habilitado cocriação na sua conta.
+ html:
+ creation: "%{creation_link} por %{pseud_links}"
+ edit_chapter: edite o capítulo
+ edit_series: edite a série
+ edit_work: edite a obra
+ remove_chapter: Caso isso tenha ocorrido por engano ou você não queira que
+ seu pseudônimo apareça nessa obra como coartista, %{edit_chapter_link} para
+ remover seu nome.
+ remove_series: Caso isso tenha ocorrido por engano ou você não queira que
+ seu pseudônimo apareça nessa obra como coartista, %{edit_series_link} para
+ remover seu nome.
+ remove_work: Caso isso tenha ocorrido por engano ou você não queira que seu
+ pseudônimo apareça nessa obra como coartista, %{edit_work_link} para remover
+ seu nome.
+ intro_chapter: 'Seu pseudônimo %{pseud} foi adicionado como coartista do capítulo
+ abaixo por %{archivist}:'
+ intro_series: 'Seu pseudônimo %{pseud} foi adicionado como coartista da série
+ abaixo por %{archivist}:'
+ intro_work: 'Seu pseudônimo %{pseud} foi adicionado como coartista da obra abaixo
+ por %{archivist}:'
+ subject: "[%{app_name}] Uma conta arquivista adicionou você como coartista"
+ text:
+ creation: "%{title} (%{url}) por %{pseuds}"
+ remove_chapter: 'Caso isso tenha ocorrido por engano ou você não queira que
+ seu pseudônimo apareça nessa obra como coartista, você pode editar o capítulo
+ para remover seu nome: %{url}'
+ remove_series: 'Caso isso tenha ocorrido por engano ou você não queira que
+ seu pseudônimo apareça nessa obra como coartista, você pode editar a série
+ para remover seu nome: %{url}'
+ remove_work: 'Caso isso tenha ocorrido por engano ou você não queira que seu
+ pseudônimo apareça nessa obra como coartista, edite a obra para remover
+ seu nome: %{url}'
+ creatorship_request:
+ html:
+ creation: "%{creation_link} por %{pseud_links}"
+ instructions: Para aceitar ou recusar esse convite, acesse sua página de %{page_name}.
+ page_name: Co-Creator Requests (Convites para ser coartista)
+ intro_chapter: 'A conta %{inviting_user} convidou seu pseudônimo %{pseud} para
+ aparecer como coartista do seguinte capítulo:'
+ intro_series: 'A conta %{inviting_user} convidou seu pseudônimo %{pseud} para
+ aparecer como coartista da seguinte série:'
+ intro_work: 'A conta %{inviting_user} convidou seu pseudônimo %{pseud} para
+ aparecer como coartista da seguinte obra:'
+ subject: "[%{app_name}] Você recebeu um convite para ser coartista"
+ text:
+ creation: "%{title} (%{url}) por %{pseuds}"
+ instructions: 'Para aceitar ou recusar esse convite, acesse sua página de
+ Co-Creator Requests (Convites para ser coartista): %{url}'
+ delete_work_notification:
+ attachment: Uma cópia da obra removida encontra-se em anexo.
+ deleted_other:
+ html: Sua obra %{title} foi removida do AO3, conforme solicitado por %{pseud}.
+ text: Sua obra %{title} foi removida do AO3, conforme solicitado por %{pseud}.
+ deleted_yourself:
+ html: Conforme sua solicitação, a obra %{title} foi removida do AO3.
+ text: Conforme sua solicitação, a obra %{title} foi removida do AO3.
+ questions:
+ html: Caso tenha alguma dúvida, por favor %{support}.
+ text: Caso tenha alguma dúvida, por favor %{support} (%{url}).
+ subject: "[%{app_name}] Sua obra foi removida"
+ support: entre em contato com a nossa equipe de Suporte
+ invitation:
+ been_invited: Você recebeu um convite para se juntar ao nosso site beta!
+ has_invited: "%{user_name} convidou você para participar do nosso site beta!"
+ html:
+ faq_link_text: nossa página de FAQ
+ subject: "[%{app_name}] Convite"
+ invitation_to_claim:
+ access:
+ text: 'Dependendo do arquivo, suas obras podem ter sido importadas e disponibilizadas
+ apenas para pessoas registradas (para manter as obras fora de pesquisas
+ do Google). Se for esse o caso, as obras só serão acessíveis a quem tiver
+ feito login no arquivo, a não ser que você opte por torná-las totalmente
+ visíveis. Se precisar de ajuda para desbloquear, orfanar ou apagar suas
+ obras, por favor entre em contato com o Suporte do AO3. '
+ claim_or_remove:
+ html: Reivindique ou apague suas obras aqui.
+ text: 'Reivindique ou apague suas obras aqui: %{claim_url}'
+ email_tips: Se você está entrando em contato conosco, por favor, adicione à
+ sua lista de contatos os e-mails do domínio @transformativeworks.org e verifique
+ sua pasta de spam para checar se recebeu uma resposta.
+ html:
+ ao3_news: Notícias do AO3
+ contact_open_doors: entre em contato com o Portas Abertas
+ contact_support: entre em contato com a equipe de Suporte do AO3
+ faq_page: página de FAQ
+ tutorial_page: página de tutorial
+ introduction:
+ text: Você está recebendo este e-mail, pois um arquivo foi recentemente importado
+ pelo Open Doors (Portas Abertas) (%{open_doors_link}) para %{app_name} (%{app_short_name}
+ - %{app_url}) e acreditamos que as obras de fãs a seguir pertencem a você.
+ Queremos lhe dar a chance de reivindicar (ou apagar/orfanar) essas obras
+ se quiser. Caso ainda não tenha uma conta sob outro endereço de e-mail,
+ gostaríamos de lhe convidar para participar!
+ mistake:
+ text: Se esse for um erro e essas obras não forem suas, por favor não as apague!
+ Por favor apenas entre em contato com o Portas Abertas %{open_doors_link}
+ e vamos corrigir a situação.
+ more_info:
+ html: Para anúncios sobre novas mudanças de arquivo, consulte nossa página
+ de %{ao3_news_link}. Para encontrar mais informações, visite também a %{faq_page_link}
+ e a %{tutorial_page_link} do Portas Abertas. Caso tenha alguma pergunta
+ que não foi respondida nos links ou nesse e-mail, por favor %{contact_support_link}.
+ text: Para anúncios sobre novas mudanças de arquivo, consulte nossa página
+ de Notícias do AO3 (%{news_link}). Para encontrar mais informações, visite
+ também a página de FAQ (%{open_doors_faq_link}) e de tutoriais (%{open_doors_tutorial_link})
+ do Portas Abertas. Caso tenha alguma pergunta que não foi respondida nos
+ links ou nesse e-mail, por favor entre em contato com o Suporte em %{support_link}.
+ other_works:
+ text: Se você tinha outras obras no arquivo importado sob uma conta de e-mail
+ que não consegue mais acessar, por favor entre em contato com o Portas Abertas
+ com qualquer informação que possa ajudar a confirmar sua identidade.
+ questions:
+ html: Para outras questões, por favor %{contact_support_link}.
+ text: Para outras questões, por favor entre em contato com o Suporte do AO3
+ em %{support_link}.
+ redirects: Para preservar as listas de recomendações e favoritos, os links da
+ página da web do arquivo importado podem redirecionar para uma cópia das obras
+ importadas por um tempo limitado (acesse o post de anúncio do seu arquivo
+ para ter certeza). Se você já publicou uma cópia dessas obras e NÃO usou a
+ ferramenta de importação por URL, então duas cópias da mesma obra serão armazenadas
+ no arquivo.
+ subject: "[%{app_name}] Convite para a reivindicação de obras"
+ unwanted:
+ html: Se essas obras lhe pertencem, mas você não as deseja, você pode as orfanar
+ (para que permaneçam no AO3 com seu nome removido) ou apagar (para que sejam
+ inteiramente removidas do AO3). Você não precisa adicionar essas obras a
+ nenhuma conta para orfanar ou apagar — a ação pode ser feita diretamente
+ através do link de reivindicação acima. (Para receber ajuda, por favor %{contact_support_link}.)
+ text: |-
+ Se essas obras lhe pertencem, mas você não as deseja, você pode as orfanar (para que permaneçam no AO3 com seu nome removido) ou apagar (para que sejam inteiramente removidas do AO3). Você não precisa adicionar essas obras a nenhuma conta para orfanar ou apagar — a ação pode ser feita diretamente através do link de reivindicação acima.
+ (Se precisar de ajuda, por favor entre em contato com o Suporte em %{support_link}.)
+ update_redirect:
+ text: Se você deseja que o Portas Abertas atualize o link de redirecionamento
+ para sua obra já publicada no Arquivo, por favor apague a versão importada
+ e entre em contato com o Portas Abertas em %{open_doors_link} com o nome
+ que usa na sua conta do AO3, o nome que usava no arquivo importado, o título
+ e a URL da obra de fã que você deseja usar como link de redirecionamento.
+ (Se você tiver múltiplas obras com as quais deseja realizar esse procedimento,
+ todas podem ser mandadas em um único e-mail.)
+ uploaded_list: 'Foi realizado o upload das seguintes obras:'
+ invite_increase_notification:
+ html:
+ body:
+ one: Só pra avisar que você tem um novo convite, que pode ser usado para
+ criar uma nova conta no AO3. Para convidar alguém, visite a %{invitation_page_link}.
+ other: Só pra avisar que você tem %{count} novos convites, que podem ser
+ usados para criar novas contas no AO3. Para convidar alguém, visite a
+ %{invitation_page_link}.
+ invitation_page_link_text: sua página de Invitations (Convites)
+ subject: "[%{app_name}] Novos convites"
+ text:
+ body:
+ one: Só pra avisar que você tem um novo convite, que pode ser usado para
+ criar uma nova conta no AO3. Para convidar alguém, visite a sua página
+ de Invitations (Convites) (%{invitation_page_url}).
+ other: Só pra avisar que você tem %{count} novos convites, que podem ser
+ usados para criar novas contas no AO3. Para convidar alguém, visite a
+ sua página de Invitations (Convites) (%{invitation_page_url}).
+ invite_request_declined:
+ main:
+ one: Lamentamos informar que a sua solicitação de um novo convite não pode
+ ser atendida no momento.
+ other: Lamentamos informar que a sua solicitação de %{count} novos convites
+ não pode ser atendida no momento.
+ reason: 'A solicitação que você nos enviou foi:'
+ subject: "[%{app_name}] Solicitação de convite adicional negada"
+ recipient_notification:
+ html:
+ collection: Uma obra de presente foi publicada para você na coleção %{collection_link}
+ no AO3!
+ no_collection: Uma obra de presente foi publicada para você no AO3!
+ subject:
+ collection: "[%{app_name}][%{collection_title}] Uma obra de presente para
+ você na coleção %{collection_title}"
+ no_collection: "[%{app_name}] Uma obra de presente para você"
+ text:
+ collection: Uma obra de presente foi publicada para você na coleção "%{collection_title}"
+ (%{collection_url}) no AO3!
+ signup_notification:
+ activate:
+ html: Por favor %{activate_account_link}.
+ text: 'Por favor siga este link para ativar a sua conta: %{activate_account_url}'
+ activate_your_account: siga este link para ativar sua conta
+ admin_posts: Notícias do AO3
+ bye: Esperamos que você curta usar o AO3.
+ contact_support: entre em contato com a nossa equipe de Suporte
+ faq: FAQ
+ features:
+ html: Depois que sua conta for ativada, você poderá publicar suas obras de
+ fã, fazer assinaturas para receber notificações por e-mail quando houver
+ atualizações das obras e artistas que mais gosta, configurar suas preferências
+ para definir a aparência e funcionamento do site para você, saber quais
+ obras você acessou no AO3 através do seu histórico e muito mais.
+ text: Depois que sua conta for ativada, você poderá publicar suas obras, fazer
+ assinaturas para receber notificações por e-mail quando houver atualizações
+ das obras e artistas que mais gosta, configurar suas preferências para definir
+ a aparência e funcionamento do site para você, saber quais obras você acessou
+ no AO3 através do seu histórico e muito mais.
+ information:
+ html: Existem muitas informações e dicas sobre como usar o AO3 na nossa %{faq_link}.
+ Você encontrará as mais recentes notícias a respeito do desenvolvimento
+ do site em %{admin_posts_link}. Se você precisar de mais ajuda, encontrar
+ algum bug ou tiver dúvidas ou sugestões, por favor %{contact_support_link},
+ que está sempre à sua disposição.
+ text: 'Há muitas informações e dicas sobre como usar o AO3 na nossa FAQ em
+ %{faq_url}. Você encontrará as mais recentes notícias a respeito do desenvolvimento
+ do site em Notícias do AO3 em %{admin_posts_url}. Se você precisar de mais
+ ajuda, encontrar algum bug ou tiver dúvidas ou sugestões, por favor, entre
+ em contato com a nossa equipe de Suporte, que está sempre à sua disposição:
+ %{contact_support_url}'
+ subject: "[%{app_name}] Ative sua conta"
+ welcome: Boas vindas ao Archive of Our Own (AO3), %{login}!
+ validators:
+ email:
+ blacklist: foi bloqueado a pedido da administração. Isso que dizer que esse
+ endereço não pode ser utilizado em comentários de visitantes. Por favor cheque
+ o endereço de e-mail para se certificar de que ele é de fato seu e entre em
+ contato com a equipe de Suporte do AO3 se tiver qualquer pergunta.
+ format:
+ allow_blank: deveria ter o formato de um endereço de e-mail. Por favor utilize
+ um endereço diferente ou deixe em branco.
+ no_blank: deveria ter o formato de um endereço de e-mail.
diff --git a/config/locales/phrase-exports/pt-PT.yml b/config/locales/phrase-exports/pt-PT.yml
new file mode 100755
index 0000000..297597a
--- /dev/null
+++ b/config/locales/phrase-exports/pt-PT.yml
@@ -0,0 +1,539 @@
+---
+pt-PT:
+ activerecord:
+ attributes:
+ archive_warning:
+ name_with_colon:
+ one: 'Aviso:'
+ other: 'Avisos:'
+ category:
+ name_with_colon:
+ one: 'Categoria:'
+ other: 'Categorias:'
+ character:
+ name_with_colon:
+ one: 'Personagem:'
+ other: 'Personagens:'
+ fandom:
+ name_with_colon:
+ one: 'Fandom:'
+ other: 'Fandoms:'
+ freeform:
+ name_with_colon:
+ one: 'Tag Adicional:'
+ other: 'Tags Adicionais:'
+ rating:
+ name_with_colon: 'Classificação:'
+ relationship:
+ name_with_colon:
+ one: 'Relação:'
+ other: 'Relações:'
+ work:
+ chapter_total_display: Capítulos
+ summary: Sumário
+ models:
+ archive_warning:
+ one: Aviso
+ other: Avisos
+ category:
+ one: Categoria
+ other: Categorias
+ chapter:
+ one: Capítulo
+ other: Capítulos
+ character:
+ one: Personagem
+ other: Personagens
+ fandom:
+ one: Fandom
+ other: Fandoms
+ freeform:
+ one: Tag Adicional
+ other: Tags Adicionais
+ rating:
+ one: Classificação
+ other: Classificações
+ relationship:
+ one: Relação
+ other: Relações
+ series:
+ one: Série
+ other: Séries
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ one: uma pessoa convidada
+ other: "%{count} pessoas convidadas"
+ left_kudos:
+ html:
+ one: "%{givers_list} deixou kudos em %{commentable_link}."
+ other: "%{givers_list} deixaram kudos em %{commentable_link}."
+ text:
+ one: "%{givers_list} deixou kudos em %{commentable_title} (%{commentable_url})."
+ other: "%{givers_list} deixaram kudos em %{commentable_title} (%{commentable_url})."
+ single_guest:
+ giver: Uma pessoa convidada
+ html: "%{giver} deixou kudos em %{commentable_link}."
+ text: Uma pessoa convidada deixou kudos em %{commentable_title} (%{commentable_url}).
+ subject: "[%{app_name}] Recebeste kudos!"
+ mailer:
+ general:
+ closing:
+ formal: Atenciosamente,
+ informal: Os melhores cumprimentos,
+ creation:
+ link_with_word_count: "%{creation_link} (%{word_count})"
+ title_with_chapter_number: Capítulo %{position} de %{title}
+ title_with_word_count: '"%{creation_title}" (%{word_count})'
+ word_count:
+ one: "%{count} palavra"
+ other: "%{count} palavras"
+ footer:
+ general:
+ about:
+ html: O AO3 é um arquivo gerido e suportado por fãs que depende das %{donate_link}.
+ text: 'O AO3 é um arquivo gerido e suportado por fãs que depende das tuas
+ doações: %{donate_url}.'
+ html:
+ donate_link_text: tuas doações
+ support_link_text: contacta a equipa de Suporte
+ unwanted_email:
+ html: Se recebeste esta mensagem por engano, por favor %{support_link}.
+ text: Se recebeste esta mensagem por engano, por favor contacta a equipa
+ de Suporte em %{support_url}.
+ sent_at: Enviada %{sent_at}.
+ greeting:
+ formal_html: Olá %{name},
+ informal:
+ addressed_html: Olá, %{name}!
+ unaddressed: Olá!
+ introductory: Olá do Archive of Our Own – AO3 (O Nosso Próprio Arquivo)!
+ metadata_label_indicator: ":"
+ signature:
+ abuse_team: A equipa de Políticas e Abuso do AO3
+ app_short_name: AO3
+ open_doors: A equipa do Open Doors (Portas Abertas)
+ parent_org: Organization for Transformative Works – OTW (Organização para
+ Obras Transformativas)
+ support: A equipa de Suporte do AO3
+ users:
+ mailer:
+ reset_password_instructions:
+ expiration: Se não utilizares este link para repor a tua palavra-passe dentro
+ de uma semana, o link expirará e terás de pedir um novo.
+ intro: 'Alguém pediu uma reposição da palavra-passe da tua conta. Podes alterar
+ a tua palavra-passe seguindo o link abaixo e inserindo a tua nova palavra-passe:'
+ link_title: Mudar a minha palavra-passe.
+ subject: "[%{app_name}] Repõe a tua palavra-passe"
+ unrequested: Se não pediste uma reposição de palavra-passe, podes ignorar
+ este email e a tua antiga palavra-passe continuará a funcionar.
+ user_mailer:
+ admin_deleted_work_notification:
+ bye: Foi anexada uma cópia da obra para tua referência.
+ contact_abuse: contacta o nosso Comité de Políticas e Abuso
+ deleted:
+ html: A tua obra %{title} foi eliminada do Arquivo por uma pessoa administradora
+ do site.
+ text: A tua obra "%{title}" foi eliminada do Arquivo por uma pessoa administradora
+ do site.
+ html:
+ tos_violation: Se é possível que a tua obra tenha violado os Termos de Serviço
+ do Arquivo, por favor %{contact_abuse_link}.
+ import_project:
+ html: Se a tua obra fazia parte de um projeto importado gerido pela nossa
+ equipa do Open Doors (Portas Abertas), por favor %{opendoors_link} com as
+ tuas questões.
+ text: Se a tua obra fazia parte de um projeto importado gerido pela nossa
+ equipa do Open Doors (Portas Abertas), por favor contacta o Portas Abertas
+ (%{opendoors_link}) com quaisquer questões que tenhas.
+ opendoors: contacta o Portas Abertas
+ subject: "[%{app_name}] A tua obra foi eliminada por uma pessoa administradora"
+ text:
+ tos_violation: Se é possível que a tua obra tenha violado os Termos de Serviço
+ do Arquivo, por favor contacta o nosso Comité de Políticas e Abuso (%{contact_abuse_url}).
+ admin_hidden_work_notification:
+ access: Ainda poderás aceder à obra oculta através do link indicado acima, mas
+ esta não aparecerá na tua página de obras e não estará disponível para outras
+ pessoas utilizadoras do AO3.
+ check_email: Por favor verifica o teu email, incluindo a tua pasta de spam,
+ pois a equipa de Políticas e Abuso pode já te ter contactado para explicar
+ porque é que a tua obra foi ocultada.
+ contact_abuse: contacta a equipa de Políticas e Abuso
+ html:
+ help: Se não sabes porque é que a tua obra foi ocultada e não recebeste mais
+ nenhuma comunicação sobre o assunto, por favor %{contact_abuse_link} diretamente.
+ hidden: A tua obra %{title} foi ocultada pela equipa de Políticas e Abuso
+ e já não está acessível publicamente.
+ tos_violation: Se a tua obra foi ocultada por infringir os %{tos_link} do
+ AO3, será necessário que faças algo para corrigir a infração. Caso não tomes
+ as medidas necessárias para a tua obra estar em conformidade com os Termos
+ de Serviço, a tua obra poderá ser eliminada do AO3.
+ subject: "[%{app_name}] A tua obra foi ocultada pela equipa de Políticas e Abuso"
+ text:
+ help: 'Se não sabes porque é que a tua obra foi ocultada e não recebeste mais
+ nenhuma comunicação sobre o assunto, por favor contacta a equipa de abuso
+ diretamente: %{contact_abuse_url}.'
+ hidden: A tua obra "%{title}" (%{work_url}) foi ocultada pela equipa de Políticas
+ e Abuso e já não está acessível publicamente.
+ tos_violation: Se a tua obra foi ocultada por infringir os Termos de Serviço
+ (%{tos_url}) do AO3, será necessário que faças algo para corrigir a infração.
+ Caso não tomes as medidas necessárias para a tua obra estar em conformidade
+ com os Termos de Serviço, a tua obra poderá ser eliminada do AO3.
+ tos: Termos de Serviço
+ anonymous_or_unrevealed_notification:
+ anonymous_info: Obras anónimas são incluídas em listas de tags, mas não na tua
+ página de obras. Na obra, o teu nome de utilizador será substituído por “Anonymous”
+ (Anónimo).
+ anonymous_unrevealed_info: As pessoas responsáveis pela coleção podem revelar
+ a tua obra mais tarde mas mantê-la anónima. As pessoas que te subscrevem não
+ serão notificadas sobre esta alteração. Uma vez revelada, a tua obra será
+ incluída nas listas de tags, mas não na tua página de obras. Na obra, o teu
+ nome de utilizador será substituído por “Anonymous” (Anónimo).
+ changed_status:
+ anonymous:
+ html: As pessoas responsáveis por manter a coleção %{collection_link} alteraram
+ o estado da tua obra %{work_link} para “anónima”.
+ text: As pessoas responsáveis por manter a coleção "%{collection_title}"
+ (%{collection_url}) alteraram o estado da tua obra "%{work_title}" (%{work_url})
+ para “anónima”.
+ anonymous_unrevealed:
+ html: As pessoas responsáveis por manter a coleção %{collection_link} alteraram
+ o estado da tua obra %{work_link} para “anónima” e “não revelada”.
+ text: As pessoas responsáveis por manter a coleção "%{collection_title}"
+ (%{collection_url}) alteraram o estado da tua obra "%{work_title}" (%{work_url})
+ para “anónima” e “não revelada”.
+ unrevealed:
+ html: As pessoas responsáveis por manter a coleção %{collection_link} alteraram
+ o estado da tua obra %{work_link} para “não revelada”.
+ text: As pessoas responsáveis por manter a coleção "%{collection_title}"
+ (%{collection_url}) alteraram o estado da tua obra "%{work_title}" (%{work_url})
+ para “não revelada”.
+ collection_items_link_text: página de Approved Collection Items (Itens de Coleção
+ Aprovados)
+ do_not_want:
+ anonymous:
+ html: Se não queres que a tua obra seja anónima, por favor visita a tua
+ %{collection_items_link} para a remover desta coleção.
+ text: 'Se não queres que a tua obra seja anónima, por favor visita a tua
+ página de Approved Collection Items (Itens de Coleção Aprovados) para
+ a remover desta coleção: %{collection_items_url}'
+ anonymous_unrevealed:
+ html: Se não queres que a tua obra seja anónima e fique inacessível até
+ ser revelada, por favor visita a tua %{collection_items_link} para a remover
+ desta coleção.
+ text: 'Se não queres que a tua obra seja anónima e fique inacessível até
+ ser revelada, por favor visita a tua página de Approved Collection Items
+ (Itens de Coleção Aprovados) para a remover desta coleção: %{collection_items_url}'
+ unrevealed:
+ html: Se não queres que a tua obra fique inacessível até ser revelada, por
+ favor visita a tua %{collection_items_link} para a remover desta coleção.
+ text: 'Se não queres que a tua obra fique inacessível até ser revelada,
+ por favor visita a tua página de Approved Collection Items (Itens de Coleção
+ Aprovados) para a remover desta coleção: %{collection_items_url}'
+ faq_link_text: FAQs sobre Coleções
+ more_info:
+ html: Para mais informações, visita as nossas %{faq_link}.
+ text: 'Para mais informações, visita as nossas FAQs sobre Coleções: %{faq_url}.'
+ subject:
+ anonymous: "[%{app_name}] A tua obra foi tornada anónima"
+ anonymous_unrevealed: "[%{app_name}] A tua obra foi tornada anónima e não
+ revelada"
+ unrevealed: "[%{app_name}] A tua obra foi tornada numa obra não revelada"
+ unrevealed_info: Obras não reveladas não são incluídas nas listas de tags ou
+ na tua página de obras. Qualquer pessoa que siga uma ligação para a obra vai
+ receber um aviso a indicar que, atualmente, não está revelada e que não poderá
+ aceder ao seu conteúdo.
+ archivist_added_to_collection_notification:
+ approved_collection_items_page: página Approved Collection Items (Itens de Coleção
+ Aprovados)
+ archivist_notice: Como as pessoas responsáveis pela coleção estão a agir na
+ sua função oficial como arquivista do Open Doors (Portas Abertas), podem adicionar
+ a tua obra a esta coleção, mesmo que tenhas desativado a opção de convites
+ para coleções. As pessoas arquivistas só irão adicionar uma obra a uma coleção
+ se a obra estava alojada num arquivo importado.
+ removal_instructions:
+ html: Se quiseres remover a tua obra desta coleção, por favor visita a tua
+ %{approved_items_link}.
+ text: 'Se quiseres remover a tua obra desta coleção, por favor visita a tua
+ página Approved Collection Items (Itens de Coleção Aprovados): %{approved_items_url}.'
+ subject: "[%{app_name}][%{collection_title}] Uma pessoa arquivista do Open Doors
+ (Portas Abertas) adicionou a tua obra a uma coleção"
+ work_added:
+ html: As pessoas responsáveis pela coleção %{collection_link} adicionaram
+ a tua obra %{work_link} à sua coleção!
+ text: As pessoas responsáveis pela coleção "%{collection_title}" (%{collection_url})
+ adicionaram a tua obra "%{work_title}" (%{work_url}) à sua coleção!
+ challenge_assignment_notification:
+ any: Qualquer
+ assignment:
+ html: Foi-te atribuída a seguinte tarefa no desafio %{link} no AO3!
+ description: 'Descrição:'
+ due: 'O prazo para esta tarefa é:'
+ html:
+ footer: Estás a receber este email porque te inscreveste no desafio %{title}.
+ Para mais informação acerca deste desafio e os detalhes de contacto das
+ pessoas moderadoras, por favor visita %{footer_link}.
+ footer_link: a página de perfil do desafio
+ look_up: Podes encontrar esta tarefa %{link}.
+ look_up_link: na tua página Assignments (Tarefas)
+ optional_tags: 'Tags Opcionais:'
+ prompts: 'Prompts:'
+ prompt_url: 'URL da prompt:'
+ recipient: 'Destinada a:'
+ recipient_missing: 'Ninguém: contacta uma pessoa moderadora para obter ajuda!'
+ subject: "[%{app_name}][%{collection_title}] A Tua Tarefa!"
+ text:
+ assignment: Foi-te atribuída a seguinte tarefa no desafio "%{collection_title}"
+ (%{collection_url}) no AO3!
+ footer: Estás a receber este email porque te inscreveste no desafio %{title}
+ (%{url}). Para mais informação acerca deste desafio e os detalhes de contacto
+ das pessoas moderadoras, por favor visita %{profile_url}.
+ look_up: Podes encontrar esta tarefa na tua página Assignments (Tarefas) em
+ %{link}.
+ change_email:
+ changed:
+ html: "%{login}, o endereço de email associado à tua conta foi mudado para
+ %{email}"
+ text: "%{login}, o endereço de email associado à tua conta foi mudado para
+ %{email}"
+ subject: "[%{app_name}] Mudança de email"
+ collection_notification:
+ assignments_sent:
+ complete: Já foram enviadas todas as tarefas.
+ subject: Tarefas enviadas
+ html:
+ received_message: 'Recebeste uma mensagem acerca da tua coleção %{collection_link}:'
+ text:
+ received_message: 'Recebeste uma mensagem acerca da tua coleção "%{collection_title}"
+ (%{collection_url}):'
+ creatorship_notification:
+ explanation: Quando és pessoa cocriadora numa obra, podes ser adicionada a novos
+ capítulos, independentemente das tuas definições de cocriação. Podes ainda
+ ser adicionada a quaisquer séries às quais a obra for adicionada.
+ html:
+ creation: "%{creation_link} por %{pseud_links}"
+ edit_chapter: editar o capítulo
+ edit_series: editar a série
+ remove_chapter: Se te adicionaram por engano ou não quiseres aparecer como
+ pessoa criadora, podes %{edit_chapter_link} para te removeres como pessoa
+ criadora.
+ remove_series: Caso a tua adição tenha sido por erro, ou não queiras aparecer
+ como pessoa criadora, podes %{edit_series_link} para te retirares como pessoa
+ criadora.
+ intro_chapter: 'A pessoa utilizadora %{adding_user} listou o teu pseudónimo
+ %{pseud} como cocriador no seguinte capítulo:'
+ intro_series: 'A pessoa utilizadora %{adding_user} listou o teu pseudónimo %{pseud}
+ como cocriador no na seguinte série:'
+ subject: "[%{app_name}] Notificação de Pessoa Cocriadora"
+ text:
+ creation: "%{title} (%{url}) por %{pseuds}"
+ remove_chapter: 'Se te adicionaram por engano ou não quiseres aparecer como
+ pessoa criadora, podes editar o capítulo para te removeres como pessoa criadora:
+ %{url}'
+ remove_series: 'Caso a tua adição tenha sido por erro, ou não queiras aparecer
+ como pessoa criadora, podes editar a série para te retirares como pessoa
+ criadora: %{url}'
+ creatorship_notification_archivist:
+ explanation: Como está a agir na sua capacidade como arquivista do Open Doors
+ (Portas Abertas), esta pessoa está autorizada a adicionar-te sem um pedido,
+ mesmo se tiveres a opção de co-criação desativada.
+ html:
+ creation: "%{creation_link} por %{pseud_links}"
+ edit_chapter: editar o capítulo
+ edit_series: editar a série
+ edit_work: editar a obra
+ remove_chapter: Se te adicionaram por engano ou não quiseres aparecer como
+ pessoa criadora, podes %{edit_chapter_link} para te removeres como pessoa
+ criadora.
+ remove_series: Se te adicionaram por engano ou não quiseres aparecer como
+ pessoa criadora, podes %{edit_series_link}para te removeres como pessoa
+ criadora.
+ remove_work: Se te adicionaram por engano ou não quiseres aparecer como pessoa
+ criadora, podes %{edit_work_link} para te removeres como pessoa criadora.
+ intro_chapter: 'A pessoa utilizadora %{archivist} adicionou o teu pseudónimo
+ %{pseud} como pessoa co-criadora do seguinte capítulo:'
+ intro_series: 'A pessoa utilizadora %{archivist} adicionou o teu pseudónimo
+ %{pseud} como pessoa co-criadora da seguinte série:'
+ intro_work: 'A pessoa utilizadora %{archivist} adicionou o teu pseudónimo %{pseud}
+ como pessoa co-criadora da seguinte obra:'
+ subject: "[%{app_name}] Notificação de adição como pessoa co-criadora por arquivista"
+ text:
+ creation: "%{title} (%{url}) por %{pseuds}"
+ remove_chapter: 'Se te adicionaram por engano ou não quiseres aparecer como
+ pessoa criadora, podes editar o capítulo para para te removeres como pessoa
+ criadora: %{url}'
+ remove_series: 'Se te adicionaram por engano ou não quiseres aparecer como
+ pessoa criadora, podes editar a série para te removeres como pessoa criadora:
+ %{url}'
+ remove_work: 'Se te adicionaram por engano ou não quiseres aparecer como pessoa
+ criadora, podes editar a obra para te removeres como pessoa criadora: %{url}'
+ creatorship_request:
+ html:
+ creation: "%{creation_link} por %{pseud_links}"
+ instructions: Podes aceitar ou rejeitar este pedido na tua página de %{page_name}.
+ page_name: Co-Creator Requests (Pedidos de Pessoa Co-Criadora)
+ intro_chapter: 'A pessoa utilizadora %{inviting_user} convidou o teu pseudónimo
+ %{pseud} para ser listado como pessoa co-criadora do seguinte capítulo:'
+ intro_series: 'A pessoa utilizadora %{inviting_user} convidou o teu pseudónimo
+ %{pseud} para ser listado como pessoa co-criadora da seguinte série:'
+ intro_work: 'A pessoa utilizadora %{inviting_user} convidou o teu pseudónimo
+ %{pseud} para ser listado como pessoa co-criadora da seguinte obra:'
+ subject: "[%{app_name}] Pedido de pessoa co-criadora"
+ text:
+ creation: "%{title} (%{url}) por %{pseuds}"
+ instructions: 'Podes aceitar ou rejeitar este pedido na tua página de Co-Creator
+ Requests (Pedidos de Pessoa Co-Criadora): %{url}'
+ delete_work_notification:
+ attachment: Segue em anexo uma cópia da tua obra para tua referência.
+ deleted_other:
+ html: A tua obra %{title} foi eliminada a pedido de %{pseud}.
+ text: A tua obra "%{title}" foi eliminada a pedido de %{pseud}.
+ deleted_yourself:
+ html: A tua obra %{title} foi eliminada a teu pedido.
+ text: A tua obra "%{title}" foi eliminada a teu pedido.
+ questions:
+ html: Se tiveres alguma pergunta, por favor %{support}.
+ text: Se tiveres alguma questão, por favor %{support} (%{url}).
+ subject: "[%{app_name}] A tua obra foi eliminada"
+ support: contacta a equipa de Suporte
+ invitation_to_claim:
+ access:
+ text: Dependendo do arquivo, as tuas obras podem ter sido importadas com restrições
+ para que apenas pessoas utilizadoras registadas as possam ver (de modo a
+ não serem visíveis em pesquisas no Google). Se for este o caso, as obras
+ irão apenas estar acessíveis a pessoas utilizadoras que tenham sessão iniciada,
+ a não ser que escolhas torná-las completamente visíveis. Para obteres ajuda
+ para desbloquear, orfanar ou eliminar as tuas obras, por favor entra em
+ contacto com a equipa de Suporte.
+ claim_or_remove:
+ html: Reivindica ou elimina as tuas obras aqui.
+ text: 'Reivindica ou elimina as tuas obras aqui: %{claim_url}'
+ email_tips: Se quiseres entrar em contacto connosco, por favor inclui os emails
+ de @transformativeworks.org na tua lista de permissões e verifica as tuas
+ pastas de spam para veres a nossa resposta.
+ html:
+ ao3_news: Notícias do AO3
+ contact_open_doors: contacta o Open Doors (Portas Abertas)
+ contact_support: contacta a equipa de Suporte do AO3
+ faq_page: página de Perguntas Frequentes (FAQs)
+ tutorial_page: página do tutorial
+ introduction:
+ text: Estás a receber este email porque um arquivo foi recentemente importado
+ pelo Open Doors (Portas Abertas) (%{open_doors_link}) para o %{app_name}
+ (%{app_short_name} - %{app_url}), e cremos que as seguintes obras de fãs
+ te pertencem. Gostaríamos de te dar uma oportunidade para reivindicar (ou
+ eliminar/orfanar) estas obras se assim o desejares. E se não tiveres já
+ uma conta com um email diferente, gostaríamos de te convidar!
+ mistake:
+ text: Se recebeste este email por engano e estas não são as tuas obras, por
+ favor não as apagues! Entra em contacto com o Portas Abertas (%{open_doors_link})
+ e iremos resolver o problema.
+ more_info:
+ text: 'Podes ler as publicações acerca das mudanças de arquivos recentes nas
+ Notícias do AO3 (%{news_link}), e podes aceder a informação adicional sobre
+ o Portas Abertas na página de Perguntas Frequentes (FAQs) (%{open_doors_faq_link})
+ ou na página de tutoriais (%{open_doors_tutorial_link}). Para quaisquer
+ questões não respondidas nas Perguntas Frequentes, nos tutoriais ou neste
+ email, por favor entra em contacto com a equipa de Suporte: %{support_link}.'
+ other_works:
+ text: Se tens outras obras no arquivo importado com um endereço de email ao
+ qual já não tens acesso, por favor entra em contacto com o Portas Abertas
+ com toda a informação que possa ajudar a verificar a tua identidade.
+ questions:
+ text: Para outras questões, por favor entra em contacto com a equipa de Suporte
+ do AO3 em %{support_link}.
+ redirects: Para preservar listas de recomendações e favoritos, os endereços
+ dos arquivos importados podem redirecionar para a cópia importada destas obras
+ durante algum tempo (consulta a publicação que anuncia a importação do arquivo
+ para confirmares). Se já fizeste upload de uma cópia destas obras e NÃO usaste
+ a função de importar de um URL, irão existir duas cópias da mesma obra no
+ arquivo.
+ subject: "[%{app_name}] Convite para reivindicar obras"
+ unwanted:
+ text: 'Se estas obras são tuas mas não as queres, podes orfaná-las (para que
+ continuem no AO3, mas sem o teu nome) ou eliminá-las (para que sejam completamente
+ removidas do AO3). Não precisas de adicionar estas obras a nenhuma conta
+ para poderes orfaná-las ou eliminá-las -- podes fazê-lo diretamente através
+ do link de reivindicação acima. (Para obteres ajuda, por favor entra em
+ contacto com a equipa de Suporte: %{support_link}.)'
+ update_redirect:
+ text: Se queres que o Portas Abertas atualize a redireção para que vá diretamente
+ para a tua obra já existente, por favor elimina a cópia importada, e entra
+ em contacto com o Portas Abertas em %{open_doors_link} com o teu nome de
+ pessoa utilizadora do AO3, o nome da tua conta no arquivo importado, e o
+ título e URL da obra de fã para onde queres que a redireção vá. (Se tens
+ várias obras que gostarias que fossem redirecionadas, podes simplesmente
+ listá-las num único email.)
+ uploaded_list: 'Foi feito upload das seguintes obras:'
+ invite_increase_notification:
+ html:
+ body:
+ one: Queremos apenas informar-te de que tens %{count} novo convite, que
+ pode ser utilizado para criar uma nova conta no AO3. Podes convidar uma
+ pessoa amiga a partir da %{invitation_page_link}.
+ other: Queremos apenas informar-te de que tens %{count} novos convites,
+ que podem ser utilizados para criar novas contas no AO3. Podes convidar
+ pessoas amigas a partir da %{invitation_page_link}.
+ invitation_page_link_text: tua página de convites
+ subject: "[%{app_name}] Novos Convites"
+ text:
+ body:
+ one: Queremos apenas informar-te de que tens %{count} novo convite, que
+ pode ser utilizado para criar uma nova conta no AO3. Podes convidar uma
+ pessoa amiga a partir da tua página Invitations (Convites) (%{invitation_page_url}).
+ other: Queremos apenas informar-te de que tens %{count} novos convites,
+ que podem ser utilizados para criar novas contas no AO3. Podes convidar
+ pessoas amigas a partir da tua página Invitations (Convites) (%{invitation_page_url}).
+ invite_request_declined:
+ main:
+ one: Lamentamos informar que, de momento, não podemos satisfazer o teu pedido
+ de um novo convite.
+ other: Lamentamos informar que, de momento, não podemos satisfazer o teu pedido
+ de %{count} novos convites.
+ reason: 'O teu pedido foi:'
+ subject: "[%{app_name}] Pedido de Código de Convite Adicional Recusado"
+ recipient_notification:
+ html:
+ collection: Foi publicada uma obra presente para ti na coleção %{collection_link}
+ no AO3!
+ no_collection: Foi publicada uma obra presente para ti no AO3!
+ subject:
+ collection: "[%{app_name}][%{collection_title}] Uma obra presente para ti
+ de %{collection_title}"
+ no_collection: "[%{app_name}] Uma obra presente para ti"
+ text:
+ collection: Foi publicada uma obra presente para ti na coleção "%{collection_title}"
+ (%{collection_url}) no AO3!
+ signup_notification:
+ activate:
+ html: Por favor, %{activate_account_link}.
+ text: 'Por favor, segue este link para ativar a tua conta: %{activate_account_url}'
+ activate_your_account: segue este link para ativar a tua conta
+ admin_posts: Notícias do AO3
+ bye: Esperamos que gostes de utilizar o Arquivo.
+ contact_support: contacta o Suporte
+ faq: FAQs (Perguntas Frequentes)
+ features:
+ html: Assim que a tua conta estiver a funcionar, podes publicar as tuas obras
+ de fã, configurar subscrições de email para receberes atualizações das tuas
+ pessoas criadoras ou obras favoritas, definir preferências para personalizares
+ o aspeto e o funcionamento do site, manter registos das obras que acedeste
+ no Arquivo através do teu histórico e muito mais.
+ text: Assim que a tua conta estiver a funcionar, podes publicar obras de fã,
+ configurar subscrições de email para receberes atualizações das tuas pessoas
+ criadoras ou obras favoritas, definir preferências para personalizares o
+ aspeto e o funcionamento do site, manter registos das obras que acedeste
+ no Arquivo através do teu histórico e muito mais.
+ information:
+ html: Há muita informação e muitos conselhos sobre a utilização do Arquivo
+ nas nossas %{faq_link}. Encontrarás as últimas notícias sobre os desenvolvimentos
+ do site nas nossas %{admin_posts_link}. Se precisares de mais ajuda, encontrares
+ um bug ou tiveres questões ou comentários, por favor, %{contact_support_link},
+ que está sempre disponível para te ajudar.
+ text: 'Há muita informação e muitos conselhos sobre a utilização do Arquivo
+ nas nossas %{faq_url}. Encontrarás as últimas notícias sobre os desenvolvimentos
+ do site nas notícias do AO3 no %{admin_posts_url}. Se precisares de mais
+ ajuda, encontrares um bug ou tiveres questões ou comentários, por favor,
+ contacta a nossa equipa de Suporte, que está sempre disponível para te ajudar:
+ %{contact_support_url}.'
+ welcome: Damos-te as boas vindas ao Archive of Our Own, %{login}!
diff --git a/config/locales/phrase-exports/ro.yml b/config/locales/phrase-exports/ro.yml
new file mode 100755
index 0000000..64b026c
--- /dev/null
+++ b/config/locales/phrase-exports/ro.yml
@@ -0,0 +1,672 @@
+---
+ro:
+ activerecord:
+ attributes:
+ archive_warning:
+ name_with_colon:
+ few: 'Avertismente:'
+ one: 'Avertisment:'
+ other: 'Avertismente:'
+ category:
+ name_with_colon:
+ few: 'Categorii:'
+ one: 'Categorie:'
+ other: 'Categorii:'
+ character:
+ name_with_colon:
+ few: 'Personaje:'
+ one: 'Personaj:'
+ other: 'Personaje:'
+ fandom:
+ name_with_colon:
+ few: 'Fandomuri:'
+ one: 'Fandom:'
+ other: 'Fandomuri:'
+ freeform:
+ name_with_colon:
+ few: 'Tag-uri adiționale:'
+ one: 'Tag adițional:'
+ other: 'Tag-uri adiționale:'
+ rating:
+ name_with_colon: 'Rating:'
+ relationship:
+ name_with_colon:
+ few: 'Relații:'
+ one: 'Relație:'
+ other: 'Relații:'
+ work:
+ chapter_total_display: Capitole
+ summary: Rezumat
+ models:
+ archive_warning:
+ few: Avertismente
+ one: Avertisment
+ other: Avertismente
+ category:
+ few: Categorii
+ one: Categorie
+ other: Categorii
+ chapter:
+ few: Capitole
+ one: Capitole
+ other: Capitole
+ character:
+ few: Personaje
+ one: Personaj
+ other: Personaje
+ fandom:
+ few: Fandomuri
+ one: Fandom
+ other: Fandomuri
+ freeform:
+ few: Tag-uri adiționale
+ one: Tag adițional
+ other: Tag-uri adiționale
+ rating:
+ few: Ratinguri
+ one: Rating
+ other: Ratinguri
+ relationship:
+ few: Relații
+ one: Relație
+ other: Relații
+ series:
+ few: Serii
+ one: Serie
+ other: Serii
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ few: "%{count} vizitatori"
+ one: un vizitator
+ other: "%{count} de vizitatori"
+ left_kudos:
+ html:
+ few: "%{givers_list} au lăsat kudos pentru %{commentable_link}."
+ one: "%{givers_list} a lăsat kudos pentru %{commentable_link}."
+ other: "%{givers_list} au lăsat kudos pentru %{commentable_link}."
+ text:
+ few: "%{givers_list} au lăsat kudos pentru %{commentable_title} (%{commentable_url})."
+ one: "%{givers_list} a lăsat kudos pentru %{commentable_title} (%{commentable_url})."
+ other: "%{givers_list} au lăsat kudos pentru %{commentable_title} (%{commentable_url})."
+ single_guest:
+ giver: Un vizitator
+ html: "%{giver} a lăsat kudos pentru %{commentable_link}."
+ text: Un vizitator a lăsat kudos pentru %{commentable_title} (%{commentable_url}).
+ subject: "[%{app_name}] Ai primit kudos!"
+ mailer:
+ general:
+ closing:
+ formal: Cu drag,
+ informal: Cu drag,
+ creation:
+ link_with_word_count: "%{creation_link} (%{word_count})"
+ title_with_chapter_number: Capitolul %{position} din %{title}
+ title_with_word_count: „%{creation_title}" (%{word_count})
+ word_count:
+ few: "%{count} cuvinte"
+ one: "%{count} cuvânt"
+ other: "%{count} de cuvinte"
+ footer:
+ general:
+ about:
+ html: AO3 este o arhivă administrată și susținută de fani care se bazează
+ pe %{donate_link}.
+ text: 'AO3 este o arhivă administrată și susținută de fani care se bazează
+ pe donațiile tale: %{donate_url}.'
+ html:
+ donate_link_text: donațiile tale
+ support_link_text: să contactezi echipa de Suport Tehnic
+ unwanted_email:
+ html: Dacă ai primit acest mesaj din greșeală te rugăm %{support_link}.
+ text: Dacă ai primit acest mesaj din greșeală te rugăm să contactezi echipa
+ de Suport Tehnic la %{support_url}.
+ sent_at: Trimis %{sent_at}.
+ greeting:
+ formal_html: Bună %{name},
+ informal:
+ addressed_html: Bună %{name}!
+ unaddressed: Salut!
+ introductory: Salutări de la Archive of Our Own - AO3 (Arhiva Noastră)!
+ metadata_label_indicator: ":"
+ signature:
+ abuse_team: Echipa de Politici și Abuz AO3
+ app_short_name: AO3
+ open_doors: Echipa Open Doors (Uși Deschise)
+ parent_org: Organization for Transformative Works (Organizația pentru Lucrări
+ Transformative)
+ support: Echipa de Suport Tehnic AO3
+ users:
+ mailer:
+ reset_password_instructions:
+ expiration: Dacă nu folosești acest link în următoarele șapte zile pentru
+ a-ți reseta parola, va trebui să ceri un link nou.
+ intro: 'Cineva a solicitat resetarea parolei pentru contul tău. Pentru a-ți
+ schimba parola, urmează link-ul de mai jos și introdu o parolă nouă:'
+ link_title: Schimbă parola.
+ subject: "[%{app_name}] Resetează-ți parola"
+ unrequested: Dacă nu ai solicitat schimbarea parolei, ignoră acest e-mail
+ și continuă să-ți folosești parola actuală.
+ user_mailer:
+ admin_deleted_work_notification:
+ bye: Îți trimitem în atașament o copie a lucrării tale, ca reper.
+ contact_abuse: contactează Comitetul de Politici și Abuz
+ deleted:
+ html: Lucrarea ta %{title} a fost ștearsă din Arhivă de către un administrator
+ al site-ului.
+ text: Lucrarea ta %{title} a fost ștearsă din Arhivă de către un administrator
+ al site-ului.
+ html:
+ tos_violation: Dacă e posibil ca lucrarea ta să fi încălcat Termenii de Servicii
+ ai Arhivei, %{contact_abuse_link}.
+ import_project:
+ html: Dacă lucrarea ta făcea parte dintr-un proiect de importare gestionat
+ de echipa noastră Open Doors (Uși Deschise), te rugăm %{opendoors_link}
+ dacă ai întrebări suplimentare.
+ text: Dacă lucrarea ta făcea parte dintr-un proiect de importare gestionat
+ de echipa noastră Open Doors (Uși Deschise), te rugăm contactează Open Doors
+ (%{opendoors_link}) dacă ai întrebări suplimentare.
+ opendoors: contactează Open Doors
+ subject: "[%{app_name}] Lucrarea ta a fost ștearsă de către un administrator"
+ text:
+ tos_violation: Dacă e posibil ca lucrarea ta să fi încălcat Termenii de Servicii
+ ai Arhivei, te rugăm contactează Comitetul de Politici și Abuz (%{contact_abuse_url}).
+ admin_hidden_work_notification:
+ access: Cât timp lucrarea ta este ascunsă, vei putea în continuare să o accesezi
+ prin link-ul furnizat mai sus, dar nu va fi listată pe pagina ta de lucrări
+ și nu va fi disponibilă pentru alți utilizatori ai AO3.
+ check_email: Te rugăm să îți verifici e-mailul, inclusiv folderul de spam, deoarece
+ este posibil ca echipa Politici și Abuz să te fi contactat deja, explicând
+ de ce lucrarea ta a fost ascunsă.
+ contact_abuse: să contactezi echipa de Politici și Abuz
+ html:
+ help: Dacă nu ești sigur/ă de ce lucrarea ta a fost ascunsă și nu ai fost
+ contactat/ă cu privire la această problemă, te rugăm %{contact_abuse_link}
+ direct.
+ hidden: Lucrarea ta %{title} a fost ascunsă de echipa de Politici și Abuz
+ și nu mai este accesibilă publicului.
+ tos_violation: Dacă lucrarea ta a fost ascunsă din cauza încălcării %{tos_link}
+ ai AO3, ți se va cere să iei măsuri pentru a corecta încălcarea. Dacă lucrarea
+ ta în continuare nu respecta Termenii de Servicii, acest lucru poate conduce
+ la ștergerea lucrării tale de pe AO3.
+ subject: "[%{app_name}] Lucrarea ta a fost ascunsă de echipa de Politici și
+ Abuz"
+ text:
+ help: Dacă nu ești sigur/ă de ce lucrarea ta a fost ascunsă și nu ai fost
+ contactat/ă cu privire la această problemă, te rugăm să contactezi echipa
+ de Politici și Abuz %{contact_abuse_url} direct.
+ hidden: Lucrarea ta "%{title}" (%{work_url}) a fost ascunsă de echipa de Politici
+ și Abuz și nu mai este accesibilă publicului.
+ tos_violation: Dacă lucrarea ta a fost ascunsă din cauza încălcării (%{tos_url})
+ ai AO3, ți se va cere să iei măsuri pentru a corecta încălcarea. Dacă lucrarea
+ ta în continuare nu respecta Termenii de Servicii, acest lucru poate conduce
+ la ștergerea lucrării tale de pe AO3.
+ tos: Termenilor de Servicii
+ anonymous_or_unrevealed_notification:
+ anonymous_info: Lucrările anonime sunt incluse pe listele generate de tag-uri,
+ dar nu și pe pagina ta de lucrări. Pe lucrare, numele tău de utilizator va
+ fi înlocuit cu "Anonymous" („Anonim”).
+ anonymous_unrevealed_info: Administratorii colecției pot dezvălui lucrarea ta
+ ulterior, dar sa o lase anonima. Persoanele care sunt abonate la pagina ta
+ nu vor primit notificare despre aceasta schimbare. Odată dezvăluită, lucrarea
+ va fi inclusă pe listele generate de tag-uri, dar nu și pe pagina ta de lucrări.
+ Pe lucrare numele tău de utilizator va fi înlocuit cu "Anonymous" (Anonim).
+ changed_status:
+ anonymous:
+ html: Administratorii colecției %{collection_link} au marcat ca anonim statusul
+ lucrării tale %{work_link}.
+ text: Administratorii colecției „%{collection_title}” (%{collection_url})
+ au schimbat statusul lucrării tale „%{work_title}” (%{work_url}) în anonimă.
+ anonymous_unrevealed:
+ html: Administratorii colecției %{collection_link} au marcat ca anonim și
+ nedezvăluit statusul lucrării tale %{work_link}.
+ text: Administratorii colecției „%{collection_title}” (%{collection_url})
+ au schimbat statusul lucrării tale „%{work_title}” (%{work_url}) în anonimă
+ și nedezvăluită.
+ unrevealed:
+ html: Administratorii colecției %{collection_link} au marcat ca nedezvăluit
+ statusul lucrării tale %{work_link}.
+ text: Administratorii colecției „%{collection_title}” (%{collection_url})
+ au schimbat statusul lucrării tale „%{work_title}” (%{work_url}) în nedezvăluită.
+ collection_items_link_text: pagina Approved Collection Items (Lucrări Aprobate
+ pentru Colecții)
+ do_not_want:
+ anonymous:
+ html: Dacă nu dorești ca lucrarea ta să fie anonimă, te rugăm vizitează
+ %{collection_items_link} pentru a o șterge din aceasta colecție.
+ text: 'Dacă nu dorești ca lucrarea ta să fie anonimă, te rugăm vizitează
+ pagina Approved Collection Items (Lucrări Aprobate pentru Colecții) pentru
+ a o șterge din aceasta colecție: %{collection_items_url}'
+ anonymous_unrevealed:
+ html: Dacă nu dorești ca lucrarea ta să fie nedezvăluită și anonimă, te
+ rugăm vizitează %{collection_items_link} pentru a o șterge din aceasta
+ colecție
+ text: 'Dacă nu dorești ca lucrarea ta să fie nedezvăluită și anonimă, te
+ rugăm vizitează pagina Approved Collection Items (Lucrări Aprobate pentru
+ Colecții) pentru a o șterge din aceasta colecție: %{collection_items_url}'
+ unrevealed:
+ html: Dacă nu dorești ca lucrarea ta să fie nedezvăluită, te rugăm vizitează
+ %{collection_items_link} pentru a o șterge din aceasta colecție.
+ text: 'Dacă nu dorești ca lucrarea ta să fie nedezvăluită, te rugăm vizitează
+ pagina Approved Collection Items (Lucrări Aprobate pentru Colecții) pentru
+ a o șterge din aceasta colecție: %{collection_items_url}'
+ faq_link_text: Întrebări Frecvente despre Colecții
+ more_info:
+ html: Pentru mai multe informații vizitează %{faq_link}.
+ text: 'Pentru mai multe informații vizitează Întrebări Frecvente despre Colecții:
+ %{faq_url}'
+ subject:
+ anonymous: "[%{app_name}] Lucrarea ta a fost marcată ca anonimă"
+ anonymous_unrevealed: "[%{app_name}] Lucrarea ta a fost marcată ca anonimă
+ și nedezvăluită"
+ unrevealed: "[%{app_name}] Lucrarea ta a fost marcată ca nedezvăluită"
+ unrevealed_info: Lucrările nedezvăluite nu sunt incluse pe listele generate
+ de tag-uri sau pe pagina ta de lucrări. Oricine folosește un link către lucrare
+ va primi o notificare că nu este dezvăluită în prezent și nu va putea accesa
+ conținutul acesteia.
+ archivist_added_to_collection_notification:
+ approved_collection_items_page: Pagina ta de Approved Collection Items (Lucrări
+ Aprobate pentru Colecții)
+ archivist_notice: Deoarece întreținătorii colecției acționează în calitate oficială
+ de arhivari ai Open Doors (Uși Deschise), au permisiunea să adauge lucrarea
+ ta la această colecție, chiar dacă ai dezactivat invitațiile la colecții.
+ Arhivarii vor adăuga o lucrare la o colecție doar dacă aceasta a fost găzduită
+ pe o arhivă importată.
+ removal_instructions:
+ html: Dacă dorești să elimini lucrarea ta din această colecție, te rugăm să
+ vizitezi %{approved_items_link}.
+ text: 'Dacă dorești să elimini lucrarea ta din această colecție, te rugăm
+ să vizitezi pagina ta de Approved Collection Items (Lucrări Aprobate pentru
+ Colecții): %{approved_items_url}.'
+ subject: "[%{app_name}][%{collection_title}] Un arhivar Open Doors (Uși Deschise)
+ a adăugat lucrarea ta la o colecție"
+ work_added:
+ html: Întreținătorii colecției %{collection_link} au adăugat lucrarea ta %{work_link}
+ la colecția lor!
+ text: Întreținătorii colecției „%{collection_title}" (%{collection_url}) au
+ adăugat lucrarea ta „%{work_title}" (%{work_url}) la colecția lor!
+ challenge_assignment_notification:
+ any: Oricare
+ assignment:
+ html: Ai primit sarcina de mai jos în provocarea %{link} pe AO3!
+ description: 'Descriere:'
+ due: 'Aceasta sarcină trebuie îndeplinită până pe:'
+ html:
+ footer: Ai primit acest email pentru că te-ai înscris în provocarea %{title}.
+ Pentru mai multe detalii despre această provocare și detaliile de contact
+ ale moderatorilor/ moderatoarelor, te rugăm să accesezi %{footer_link}.
+ footer_link: pagina de profil a provocării
+ look_up: Poți să verifici această sarcină pe %{link}.
+ look_up_link: pagina ta de Assignments (Sarcini)
+ optional_tags: 'Tag-uri opționale:'
+ prompts: 'Sugestii:'
+ prompt_url: 'Link pentru sugestie:'
+ recipient: 'Destinatar:'
+ recipient_missing: 'Nici unul/ una: contactează un moderator/ o moderatoare
+ pentru ajutor!'
+ subject: "[%{app_name}][%{collection_title}] Sarcina ta!"
+ text:
+ assignment: Ai primit sarcina de mai jos în provocarea "%{collection_title}"
+ (%{collection_url}) pe AO3!
+ footer: Ai primit acest email pentru că te-ai înscris în provocarea %{title}
+ (%{url}). Pentru mai multe detalii despre această provocare și detaliile
+ de contact ale moderatorilor/ moderatoarelor, te rugăm să accesezi %{profile_url}.
+ look_up: Poți să verifici această sarcină pe pagina ta de Assignments (Sarcini),
+ accesând %{link}.
+ change_email:
+ changed:
+ html: "%{login}, adresa de e-mail asociată cu contul tău a fost schimbată
+ la %{email}"
+ text: "%{login}, adresa de e-mail asociată cu contul tău a fost schimbată
+ la %{email}"
+ subject: "[%{app_name}] Adresa de e-mail schimbată"
+ claim_notification:
+ access:
+ contact_support: contactează echipa de Suport Tehnic AO3
+ html: În funcție de arhivă, este posibil ca lucrările tale să fi fost importate
+ cu acces limitat doar la utilizatorii/oarele înregistrați/e (pentru a le
+ ascunde de căutările Google). Dacă ăsta este cazul, lucrările vor fi accesibile
+ numai utilizatorilor/elor conectați/e, cu excepția cazului în care alegi
+ să le faci pe deplin vizibile. Pentru ajutor cu deblocarea, abandonarea
+ sau ștergerea lucrărilor tale, te rugăm %{contact_support_link}.
+ text: În funcție de arhivă, este posibil ca lucrările tale să fi fost importate
+ cu acces limitat doar la utilizatorii/oarele înregistrați/e (pentru a le
+ ascunde de căutările Google). Dacă ăsta este cazul, lucrările vor fi accesibile
+ numai utilizatorilor/elor conectați/e, cu excepția cazului în care alegi
+ să le faci pe deplin vizibile. Pentru ajutor cu deblocarea, abandonarea
+ sau ștergere a lucrărilor tale, te rugăm contactează echipa de Suport Tehnic
+ AO3 pe %{support_url}.
+ email_tips: Dacă ne contactezi, te rugăm să adaugi adrese de email de la @transformativeworks.org
+ la lista ta de contacte sigure și să verifici folderul de spam pentru răspunsul
+ nostru.
+ introduction:
+ ao3_name: Archive of Our Own – AO3 (Arhiva Noastră)
+ html: Primești acest email deoarece aveai lucrări într-o arhivă de lucrări
+ fanice care a fost importată de %{open_doors_name_link} pe %{app_link}.
+ Deoarece această adresă de email este conectată la o adresă înscrisă pe
+ arhiva importată, lucrările fanice asociate (enumerate mai jos) au fost
+ adăugate automat în contul tău AO3.
+ open_doors_name: Open Doors (Uși Deschise)
+ text: 'Primești acest email deoarece aveai lucrări într-o arhivă de lucrări
+ fanice care a fost importată de Open Doors (Uși Deschise) (%{open_doors_url})
+ pe Archive of Our Own – AO3 (Arhiva Noastră): %{app_url}. Deoarece această
+ adresă de email este conectată la o adresă înscrisă pe arhiva importată,
+ lucrările fanice asociate (enumerate mai jos) au fost adăugate automat în
+ contul tău AO3.'
+ mistake:
+ contact_open_doors: contactează echipa Uși Deschise
+ html: Dacă e o greșeală și acestea nu sunt lucrările tale, te rugăm să nu
+ le ștergi! Te rugăm doar %{contact_open_doors_link} și vom rezolva problema.
+ text: Dacă e o greșeală și acestea nu sunt lucrările tale, te rugăm să nu
+ le ștergi! Te rugăm doar contactează echipa Uși Deschise (%{open_doors_url})
+ și vom rezolva problema.
+ more_info:
+ ao3_news: Știri AO3
+ contact_support: contactează echipa de Suport Tehnic AO3
+ faq_page: pagina de Întrebări Frecvente
+ html: Poți citi anunțuri despre mutarea recentă a arhivei pe %{ao3_news_link}
+ și poți găsi informații suplimentare pe %{faq_page_link} sau %{tutorial_page_link}
+ ale Open Doors. Pentru orice întrebări la care nu ai găsit răspuns în Întrebările
+ Frecvente, tutoriale sau în acest email, te rugăm %{contact_support_link}.
+ text: Poți citi anunțuri despre mutarea recentă a arhivei pe AO3 News (%{news_url})
+ și poți găsi informații suplimentare pe pagina de Întrebări Frecvente (%{open_doors_faq_url})
+ sau pagina de tutoriale ale Open Doors. Pentru orice întrebări la care nu
+ ai găsit răspuns în Întrebările Frecvente, tutoriale sau in acest email,
+ te rugăm contactează echipa de Suport Tehnic la %{support_url}.
+ tutorial_page: pagina de tutorial
+ other_works:
+ contact_open_doors: contactează echipa Uși Deschise
+ html: Dacă ai avut alte lucrări în arhiva importată sub o adresă de e-mail
+ pe care nu o mai poți accesa, te rugăm %{contact_open_doors_link} cu orice
+ informații care te pot ajuta să îți dovedești identitatea.
+ text: Dacă ai avut alte lucrări în arhiva importată sub o adresă de e-mail
+ pe care nu o mai poți accesa, te rugăm contactează echipa Uși Deschise cu
+ orice informații care te pot ajuta să îți dovedești identitatea.
+ questions:
+ contact_support: contactează echipa de Suport Tehnic AO3
+ html: Pentru alte întrebări, te rugăm %{contact_support_link}.
+ text: Pentru alte întrebări, te rugăm contactează echipa de Suport Tehnic
+ AO3 la %{support_url}.
+ redirects:
+ html: Pentru a păstra listele de recomandări și semnele de carte, adresele
+ web ale arhivei importate pot redirecționa către copia importată a acestor
+ lucrări pentru un timp limitat (verifică postarea de anunț pentru arhivata
+ pentru a fi sigur/ă). Dacă ai încărcat deja o copie a acestor lucrări și
+ %{negation} ai folosit funcția de import din URL, vor exista două copii
+ ale aceleiași lucrări pe AO3.
+ subject: "[%{app_name}] Lucrări încărcate"
+ update_redirect:
+ contact_open_doors: contactează echipa Uși Deschise
+ html: Dacă dorești ca Open Doors să actualizeze redirecționarea pentru a ajunge
+ la lucrarea ta existentă, te rugăm să ștergi copia importată și %{contact_open_doors_link}
+ } cu numele contului tău AO3, numele contului tău din arhiva importată și
+ titlul și adresa URL a lucrării fanice la care ai dori să conducă redirecționarea.
+ (Dacă ai mai multe lucrări pentru care dorești să modifici redirecționările,
+ le poți enumera într-un singur email.)
+ text: Dacă dorești ca Open Doors să actualizeze redirecționarea pentru a ajunge
+ la lucrarea ta existentă, te rugăm să ștergi copia importată și contactează
+ echipa Uși Deschise la %{open_doors_url} cu numele contului tău AO3, numele
+ contului tău din arhiva importată și titlul și adresa URL a lucrării fanice
+ la care ai dori să conducă redirecționarea. (Dacă ai mai multe lucrări pentru
+ care dorești să modifici redirecționările, le poți enumera într-un singur
+ e-mail.)
+ works_by: 'Aceste lucrări au fost scrise sub e-mailul: %{email}'
+ work_info:
+ html: "%{work_link} (%{fandom})"
+ text:
+ no_fandom: "- %{work_title} %{work_url}"
+ with_fandom: "- %{work_title} %{work_url} (%{fandom})"
+ collection_notification:
+ assignments_sent:
+ complete: Toate sarcinile au fost acum trimise.
+ subject: Sarcinile au fost trimise
+ html:
+ received_message: 'Ai primit un mesaj despre colecția ta %{collection_link}:'
+ text:
+ received_message: 'Ai primit un mesaj despre colecția ta "%{collection_title}"
+ (%{collection_url}):'
+ creatorship_notification:
+ explanation: Când apari drept co-creator/ co-creatoare la o lucrare, poți fi
+ adăugat(ă) la capitole noi, indiferent de setările tale pentru co-creator.
+ De asemenea, vei fi adăugat(ă) la orice serie la care se adaugă lucrarea.
+ html:
+ creation: "%{creation_link} de %{pseud_links}"
+ edit_chapter: edita capitolul
+ edit_series: edita seria
+ remove_chapter: Dacă ai fost adăugat(ă) din greșeală sau nu dorești să apari
+ drept creator/ creatoare, poți %{edit_chapter_link} pentru a te scoate de
+ pe lista de creatori.
+ remove_series: Dacă ai fost adăugat(ă) din greșeală sau nu dorești să apari
+ drept creator/ creatoare, poți %{edit_series_link} pentru a te scoate de
+ pe lista de creatori.
+ intro_chapter: 'Utilizatorul/ utilizatoarea %{adding_user} a adăugat pseudonimul
+ tău %{pseud} drept co-creator/ co-creatoare la următorul capitol:'
+ intro_series: 'Utilizatorul/ utlizatoarea %{adding_user} a adăugat pseudonimul
+ tău %{pseud} drept co-creator/ co-creatoare la următoarea serie:'
+ subject: "[%{app_name}] Notificare co-creator/ co-creatoare"
+ text:
+ creation: "%{title} (%{url}) de %{pseuds}"
+ remove_chapter: 'Dacă ai fost adăugat(ă) din greșeală sau nu dorești să apari
+ drept creator/ creatoare, poți edita capitolul pentru a te scoate de pe
+ lista de creatori: %{url}'
+ remove_series: 'Dacă ai fost adăugat(ă) din greșeală sau nu dorești să apari
+ drept creator/ creatoare, poți edita seria pentru a te scoate de pe lista
+ de creatori: %{url}'
+ creatorship_notification_archivist:
+ explanation: Deoarece își exercită capacitatea oficială de arhivar al Open Doors
+ (Uși Deschise), poate să te adauge fără a trimite o solicitare, chiar dacă
+ ai funcția de co-creație dezactivată.
+ html:
+ creation: "%{creation_link} de %{pseud_links}"
+ edit_chapter: editezi capitolul
+ edit_series: editezi seria
+ edit_work: editezi lucrarea
+ remove_chapter: Dacă ai fost adăugat(ă) din greșeală sau nu dorești să apari
+ pe lista de creatori, poți să %{edit_chapter_link} pentru a te elimina din
+ lista de creatori.
+ remove_series: Dacă ai fost adăugat(ă) din greșeală sau nu dorești să apari
+ pe lista de creatori, poți să %{edit_series_link} pentru a te elimina din
+ lista de creatori.
+ remove_work: Dacă ai fost adăugat(ă) din greșeală sau nu dorești să apari
+ pe lista de creatori, poți să %{edit_work_link} pentru a te elimina din
+ lista de creatori.
+ intro_chapter: 'Utilizatorul %{archivist} ți-a adăugat pseudonimul %{pseud}
+ drept co-creator la următorul capitol:'
+ intro_series: 'Utilizatorul %{archivist} ți-a adăugat pseudonimul %{pseud} drept
+ co-creator la următoarea serie:'
+ intro_work: 'Utilizatorul %{archivist} ți-a adăugat pseudonimul %{pseud} drept
+ co-creator la următoarele lucrări:'
+ subject: "[%{app_name}] Notificare de co-creator de la arhivar"
+ text:
+ creation: "%{title} (%{url}) de %{pseuds}"
+ remove_chapter: 'Dacă ai fost adăugat(ă) din greșeală sau nu dorești să apari
+ pe lista de creatori, poți să editezi capitolul pentru a te elimina din
+ lista de creatori: %{url}'
+ remove_series: 'Dacă ai fost adăugat(ă) din greșeală sau nu dorești să apari
+ pe lista de creatori, poți să editezi seria pentru a te elimina din lista
+ de creatori: %{url}'
+ remove_work: 'Dacă ai fost adăugat(ă) din greșeală sau nu dorești să apari
+ pe lista de creatori, poți să editezi lucrarea pentru a te elimina din lista
+ de creatori: %{url}'
+ creatorship_request:
+ html:
+ creation: "%{creation_link} de %{pseud_links}"
+ instructions: Poți să accepți sau să respingi această cerere pe pagina ta
+ de %{page_name}
+ page_name: Co-Creator Requests (Cerere de co-creator/ co-creatoare)
+ intro_chapter: 'Utilizatorul/ utilizatoarea %{inviting_user} a invitat pseudonimul
+ tău %{pseud} să fie adaugat la lista de co-creatori/ co-creatoare pentru capitolul
+ următor:'
+ intro_series: 'Utilizatorul/ utilizatoarea %{inviting_user} a invitat pseudonimul
+ tău%{pseud} să fie adaugat la lista de co-creatori/ co-creatoare pentru seria
+ următoare:'
+ intro_work: 'Utilizatorul/ utilizatoarea %{inviting_user} a invitat pseudonimul
+ tău %{pseud} să fie adaugat la lista de co-creatori/ co-creatoare pentru lucrarea
+ următoare:'
+ subject: "[%{app_name}] Cerere de co-creator/ co-creatoare"
+ text:
+ creation: "%{title} (%{url}) de %{pseuds}"
+ instructions: Poți să accepți sau să respingi această cerere pe pagina de
+ Co-Creator Requests (Cereri de co-creator) %{url}
+ delete_work_notification:
+ attachment: Atașată este o copie a lucrării tale ca și referință.
+ deleted_other:
+ html: Lucrarea ta %{title} a fost ștearsă la cererea lui %{pseud}.
+ text: Lucrarea ta "%{title}" a fost ștearsă la cererea lui %{pseud}.
+ deleted_yourself:
+ html: Lucrarea ta %{title} a fost ștearsă la cererea ta.
+ text: Lucrarea ta "%{title}" a fost ștearsă la cererea ta.
+ questions:
+ html: Dacă ai întrebări, te rugăm %{support}.
+ text: Dacă ai întrebări, te rugăm %{support} (%{url}).
+ subject: "[%{app_name}] Lucrarea ta a fost ștearsă"
+ support: să contactezi echipa de Suport Tehnic
+ invitation_to_claim:
+ access:
+ text: În funcție de arhivă, este posibil ca lucrările tale să fi fost importate
+ în așa fel încât să fie accesibile numai utilizatorilor înregistrați (pentru
+ a le păstra în afara căutărilor Google). În cazul acesta, lucrările vor
+ putea fi accesate doar de către utilizatorii conectați, dacă nu alegi să
+ le faci vizibile pentru oricine. Pentru ajutor cu deblocarea, abandonarea
+ sau ștergerea lucrărilor tale, te rugăm să contactezi echipa de Suport Tehnic
+ AO3.
+ claim_or_remove:
+ html: Revendică sau șterge-ți lucrările aici.
+ text: 'Revendică sau șterge-ti lucrările aici: %{claim_url}'
+ email_tips: Dacă ne contactezi, te rugăm să adaugi adresele de e-mail de la
+ @transformativeworks.org la lista ta autorizată și să verifici folderele de
+ spam pentru a găsi răspunsul de la noi.
+ html:
+ ao3_news: Știri AO3
+ contact_open_doors: să contactezi Uși Deschise
+ contact_support: să contactezi echipa de Suport Tehnic AO3
+ faq_page: pagina Întrebări Frecvente
+ tutorial_page: pagina de tutorial
+ introduction:
+ text: Ai primit acest e-mail pentru că o arhivă a fost importată recent de
+ către Open Doors (Uși Deschise) (%{open_doors_link}) în %{app_name} (%{app_short_name}
+ - %{app_url}) și considerăm că următoarele lucrări îți aparțin. Dorim să
+ îți oferim opțiunea de a revendica (sau șterge/abandona) aceste lucrări,
+ dacă vrei. Și dacă nu ai deja un cont înregistrat pe o adresă de e-mail
+ diferită, dorim să te invităm la bord!
+ mistake:
+ text: Dacă am greșit și aceste lucrări nu îți aparțin, te rugăm să nu le ștergi!
+ Te rugăm doar să contactezi Uși Deschise (%{open_doors_link}) și ne vom
+ ocupa noi de ele.
+ more_info:
+ text: Poți citi anunțuri despre mutări recente ale arhivelor la Știri AO3
+ (%{news_link}) și informații suplimentare pe pagina Întrebări Frecvente
+ (%{open_doors_faq_link}) sau pagina de tutoriale (%{open_doors_tutorial_link})
+ ale Uși Deschise. Pentru orice întrebări care nu sunt incluse în Întrebările
+ Frecvente, tutoriale sau în acest e-mail, te rugăm să contactezi echipa
+ de Suport Tehnic AO3 la %{support_link}.
+ other_works:
+ text: Dacă ai în arhiva importată alte lucrări înregistrate cu adrese de e-mail
+ pe care nu le mai poți accesa, te rugăm să contactezi Uși Deschise și să
+ furnizezi orice informație care ne poate ajuta să-ți verificăm identitatea.
+ questions:
+ text: Pentru alte întrebări, te rugăm contactează echipa de Suport Tehnic
+ AO3 la %{support_link}.
+ redirects: 'Pentru a păstra listele de recomandări sau semnele de carte, adresele
+ web ale arhivei importate pot redirecționa către copia importată a acestor
+ lucrări doar pentru o perioadă limitată de timp (verifică postarea anunțului
+ pentru arhiva ta ca să te asiguri). Dacă ai încărcat deja o copie a acestor
+ lucrări și NU ai folosit funcția de importare din URL, vor exista două copii
+ ale aceleiași lucrări în arhivă. '
+ subject: "[%{app_name}] Invitație de a revendica lucrări"
+ unwanted:
+ text: Dacă aceste lucrări îți aparțin, dar nu le vrei, le poți abandona (vor
+ rămâne pe AO3, dar numele tău va fi șters) sau șterge (vor fi înlăturate
+ complet de pe AO3). Nu trebuie să adaugi aceste lucrări la niciun cont ca
+ să le abandonezi sau să le ștergi--poți să faci acest lucru direct prin
+ accesarea link-ului de revendicare de mai sus. (Pentru ajutor, te rugăm
+ să contactezi echipa de Suport Tehnic AO3 la %{support_link}.)
+ update_redirect:
+ text: Dacă dorești ca Uși Deschise să actualizeze redirecționarea către lucrările
+ tale existente, te rugăm să ștergi copia importată și să contactezi Uși
+ Deschise la %{open_doors_link} cu numele contului tău de pe AO3, numele
+ contului tău în arhiva importată și titlul și URL-ul lucrării către care
+ ți-ai dori să se facă redirecționarea. (Dacă ai mai multe lucrări la care
+ dorești să schimbi redirecționările, le poți lista pe toate într-un singur
+ e-mail.)
+ uploaded_list: 'Lucrările încărcate includ:'
+ invite_increase_notification:
+ html:
+ body:
+ few: Vrem să te anunțăm că ai %{count} invitații noi care pot fi folosite
+ pentru a crea conturi noi de arhivă. Poți invita un prieten accesând %{invitation_page_link}.
+ one: Vrem să te anunțăm că ai %{count} invitație nouă care poate fi folosită
+ pentru a crea un cont nou de arhivă. Poți invita un prieten accesând %{invitation_page_link}.
+ other: Vrem să te anunțăm că ai %{count} de invitații noi care pot fi folosite
+ pentru a crea conturi noi de arhivă. Poți invita un prieten accesând %{invitation_page_link}.
+ invitation_page_link_text: pagina ta de Invitations (Invitații)
+ subject: "[%{app_name}] Invitații noi"
+ text:
+ body:
+ few: Vrem să te anunțăm că ai %{count} invitații noi care pot fi folosite
+ pentru a crea conturi noi de arhivă. Poți invita un prieten accesând pagina
+ Invitations (Invitații) (%{invitation_page_url}).
+ one: Vrem să te anunțăm că ai %{count} invitație nouă care poate fi folosită
+ pentru a crea un cont nou de arhivă. Poți invita un prieten accesând pagina
+ Invitations (Invitații) (%{invitation_page_url}).
+ other: Vrem să te anunțăm că ai %{count} de invitații noi care pot fi folosite
+ pentru a crea conturi noi de arhivă. Poți invita un prieten accesând pagina
+ Invitations (Invitații) (%{invitation_page_url}).
+ invite_request_declined:
+ main:
+ few: Te informăm cu regret că solicitarea pentru %{count} invitații noi nu
+ poate fi procesată pentru moment.
+ one: Te informăm cu regret că solicitarea pentru o nouă invitație nu poate
+ fi procesată pentru moment.
+ other: Te informăm cu regret că solicitarea pentru %{count} de invitații noi
+ nu poate fi procesată pentru moment.
+ reason: 'Solicitarea ta a fost:'
+ subject: "[%{app_name}] Solicitarea pentru codul de invitație suplimentar a
+ fost respinsă"
+ recipient_notification:
+ html:
+ collection: O lucrare cadou a fost postată pentru tine în colecția %{collection_link}
+ pe AO3!
+ no_collection: O lucrare cadou a fost postată pentru tine pe AO3!
+ subject:
+ collection: "[%{app_name}][%{collection_title}] O lucrare cadou pentru tine
+ de la %{collection_title}"
+ no_collection: "[%{app_name}] O lucrare cadou pentru tine"
+ text:
+ collection: O lucrare cadou a fost postată pentru tine în colecția „%{collection_title}"
+ (%{collection_url}) pe AO3!
+ signup_notification:
+ activate:
+ html: Te rugăm %{activate_account_link}.
+ text: 'Te rugăm să accesezi acest link pentru a-ți activa contul: %{activate_account_url}'
+ activate_your_account: să accesezi acest link pentru a-ți activa contul.
+ admin_posts: Știri AO3
+ bye: Sperăm că o să-ți facă plăcere să folosești Arhiva.
+ contact_support: iei legătura cu echipa de Suport Tehnic
+ faq: Întrebări Frecvente
+ features:
+ html: După ce contul tău a fost înființat și funcționează, poți să postezi
+ lucrările tale de fan, să îți stabilești notificări pe e-mail pentru a fi
+ înștiințat/-ă când creatorii sau lucrările tale preferate au fost actualizate,
+ să îți definești preferințele pentru a personaliza modul în care site-ul
+ arată și funcționează pentru tine, să ții evidența lucrărilor pe care le-ai
+ accesat pe arhivă prin intermediul istoricului tău și multe altele.
+ text: După ce contul tău a fost înființat și funcționează, poți să postezi
+ lucrările tale de fan, să îți stabilești notificări pe e-mail pentru a fi
+ înștiințat/-ă când creatorii sau lucrările tale preferate au fost actualizate,
+ să îți definești preferințele pentru a personaliza modul în care site-ul
+ arată și funcționează pentru tine, să ții evidența lucrărilor pe care le-ai
+ accesat pe AO3 prin intermediul istoricului tău și multe altele.
+ information:
+ html: Găsești mai multe informații și sfaturi despre cum să folosești Arhiva
+ în secțiunea %{faq_link} și cele mai recente știri despre îmbunătățirile
+ aduse site-ului în %{admin_posts_link}. Dacă ai nevoie de mai mult ajutor,
+ sau ai descoperit ceva ce nu merge sau dacă ai întrebări sau comentarii,
+ te rugăm să %{contact_support_link}, care este oricând dornică să ajute.
+ text: 'Găsești mai multe informații și sfaturi despre cum să folosești Arhiva
+ în secțiunea %{faq_url} și cele mai recente știri despre îmbunătățirile
+ aduse site-ului în secțiunea de Știri AO3: %{admin_posts_url}. Dacă ai nevoie
+ de mai mult ajutor, sau ai descoperit ceva ce nu merge sau dacă ai întrebări
+ sau comentarii, te rugăm să contactezi echipa de Suport Tehnic, care este
+ oricând dornică să ajute: %{contact_support_url}.'
+ welcome: Bine ai venit pe Archive of Our Own, %{login}!
diff --git a/config/locales/phrase-exports/ru.yml b/config/locales/phrase-exports/ru.yml
new file mode 100755
index 0000000..d82ab54
--- /dev/null
+++ b/config/locales/phrase-exports/ru.yml
@@ -0,0 +1,640 @@
+---
+ru:
+ activerecord:
+ attributes:
+ archive_warning:
+ name_with_colon:
+ few: 'Предупреждения:'
+ many: 'Предупреждения:'
+ one: 'Предупреждение:'
+ category:
+ name_with_colon:
+ few: 'Категории:'
+ many: 'Категории:'
+ one: 'Категория:'
+ character:
+ name_with_colon:
+ few: 'Персонажи:'
+ many: 'Персонажи:'
+ one: 'Персонаж:'
+ fandom:
+ name_with_colon:
+ few: 'Фандомы:'
+ many: 'Фандомы:'
+ one: 'Фандом:'
+ freeform:
+ name_with_colon:
+ few: 'Дополнительные теги:'
+ many: 'Дополнительные теги:'
+ one: 'Дополнительный тег:'
+ rating:
+ name_with_colon: 'Рейтинг:'
+ relationship:
+ name_with_colon:
+ few: 'Отношения:'
+ many: 'Отношения:'
+ one: 'Отношения:'
+ work:
+ chapter_total_display: Главы
+ summary: Содержание
+ models:
+ archive_warning:
+ few: Предупреждения
+ many: Предупреждения
+ one: Предупреждение
+ category:
+ few: Категории
+ many: Категории
+ one: Категория
+ chapter:
+ few: Главы
+ many: Главы
+ one: Глава
+ character:
+ few: Персонажи
+ many: Персонажи
+ one: Персонаж
+ fandom:
+ few: Фандомы
+ many: Фандомы
+ one: Фандом
+ freeform:
+ few: Дополнительные теги
+ many: Дополнительные теги
+ one: Дополнительный тег
+ rating:
+ few: Рейтинги
+ many: Рейтинги
+ one: Рейтинг
+ relationship:
+ few: Отношения
+ many: Отношения
+ one: Отношения
+ series:
+ few: Циклы
+ many: Циклы
+ one: Цикл
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ few: "%{count} гостей"
+ many: "%{count} гостей"
+ one: "%{count} гость"
+ left_kudos:
+ html:
+ few: "%{givers_list} дарят вам кудос за %{commentable_link}."
+ many: "%{givers_list} дарят вам кудос за %{commentable_link}."
+ one: "%{givers_list} дарит вам кудос за %{commentable_link}."
+ text:
+ few: "%{givers_list} дарят вам кудос за %{commentable_title} (%{commentable_url})."
+ many: "%{givers_list} дарят вам кудос за %{commentable_title} (%{commentable_url})."
+ one: "%{givers_list} дарит вам кудос за %{commentable_title} (%{commentable_url})."
+ single_guest:
+ giver: Один гость
+ html: "%{giver} дарит вам кудос за %{commentable_link}."
+ text: Один гость подарил вам кудос за %{commentable_title} (%{commentable_url}).
+ subject: "[%{app_name}] Вы получили кудос!"
+ mailer:
+ general:
+ closing:
+ formal: С уважением,
+ informal: Счастливо,
+ creation:
+ link_with_word_count: "%{creation_link} (%{word_count})"
+ title_with_chapter_number: Глава %{position} работы «%{title}»
+ title_with_word_count: '"%{creation_title}" (%{word_count})'
+ word_count:
+ few: "%{count} слова"
+ many: "%{count} слов"
+ one: "%{count} слово"
+ footer:
+ general:
+ about:
+ html: AO3 – это архив, созданный и существующий благодаря фанатам, и он
+ полагается на %{donate_link}.
+ text: 'AO3 – это архив, созданный и существующий благодаря фанатам, и
+ он полагается на ваши пожертвования: %{donate_url}.'
+ html:
+ donate_link_text: ваши пожертвования
+ support_link_text: обратитесь в Поддержку
+ unwanted_email:
+ html: Если вы получили это сообщение по ошибке, пожалуйста, %{support_link}.
+ text: Если вы получили это сообщение по ошибке, пожалуйста, обратитесь
+ в Поддержку – %{support_url}.
+ sent_at: Отправлено в %{sent_at}.
+ greeting:
+ formal_html: Здравствуйте, %{name},
+ informal:
+ addressed_html: Привет, %{name}!
+ unaddressed: Привет!
+ introductory: Привет от Archive of Our Own – AO3 (Нашего Архива)!
+ metadata_label_indicator: ":"
+ signature:
+ abuse_team: Команда Политики и нарушений АО3
+ app_short_name: АО3
+ open_doors: Команда Open Doors (Открытые двери)
+ parent_org: Organization for Transformative Works – OTW (Организация Трансформационных
+ Работ)
+ support: Команда Технической поддержки АО3
+ users:
+ mailer:
+ reset_password_instructions:
+ expiration: Если вы не воспользуетесь этой ссылкой для сброса пароля в течение
+ недели, срок ее действия закончится, и вам придется запросить новый.
+ intro: 'Кто-то запросил сброс пароля для вашей учетной записи. Чтобы изменить
+ пароль своей учетной записи, перейдите по ссылке ниже и введите новый пароль:'
+ link_title: Изменить пароль.
+ subject: "[%{app_name}] Сброс пароля"
+ unrequested: Если вы не запрашивали сброс пароля, вы можете проигнорировать
+ это письмо. Ваш действующий пароль продолжит работать.
+ user_mailer:
+ admin_deleted_work_notification:
+ bye: К письму приложена копия вашей работы.
+ contact_abuse: свяжитесь с нашим комитетом Политик и нарушений
+ deleted:
+ html: Ваша работа %{title} была удалена администратором AO3.
+ text: Ваша работа "%{title}" была удалена администратором AO3.
+ html:
+ tos_violation: Если есть вероятность того, что данная работа нарушает Пользовательское
+ соглашение AO3, пожалуйста, %{contact_abuse_link}.
+ import_project:
+ html: Если данная работа была импортирована на AO3 как часть проекта Открытые
+ двери, пожалуйста, %{opendoors_link}, если возникнут дополнительные вопросы.
+ text: Если данная работа была импортирована на AO3 как часть проекта Открытые
+ двери, пожалуйста, свяжитесь с командой Открытых дверей (%{opendoors_link}),
+ если возникнут дополнительные вопросы.
+ opendoors: свяжитесь с командой Открытых дверей
+ subject: "[%{app_name}] Ваша работа была удалена администратором сайта"
+ text:
+ tos_violation: Если есть вероятность того, что данная работа нарушает Пользовательское
+ соглашение AO3, пожалуйста, свяжитесь с нашим комитетом Политик и нарушений
+ (%{contact_abuse_url}).
+ admin_hidden_work_notification:
+ access: Пока ваша работа скрыта, она остается доступной вам по ссылке выше,
+ но она не будет отображаться в списке ваших работ, и будет недоступна другим
+ пользователям AO3.
+ check_email: Пожалуйста, проверьте вашу почту, включая папку со спамом - возможно,
+ комитет Политик и Нарушений уже связался с вами с объяснением, почему ваша
+ работа была скрыта.
+ contact_abuse: свяжитесь с комитетом Политик и Нарушений
+ html:
+ help: Если вы не уверены, почему ваша работа была скрыта, и с вами не связывались
+ по этому поводу, пожалуйста %{contact_abuse_link} напрямую.
+ hidden: Ваша работа %{title} была скрыта комитетом Политик и Нарушений и больше
+ не находится в публичном доступе.
+ tos_violation: Если ваша работа была скрыта, потому что нарушает %{tos_link}
+ AO3, вы должны будете исправить нарушение - иначе ваша работа может быть
+ удалена с AO3.
+ subject: "[%{app_name}] Комитет Политик и Нарушений скрыл вашу работу"
+ text:
+ help: 'Если вы не уверены, почему ваша работа была скрыта, и с вами не связывались
+ по этому поводу, пожалуйста свяжитесь с комитетом Политик и Нарушений напрямую:
+ %{contact_abuse_url}.'
+ hidden: Ваша работа "%{title}" (%{work_url}) была скрыта комитетом Политик
+ и Нарушений и больше не находится в публичном доступе.
+ tos_violation: Если ваша работа была скрыта потому что она нарушает Пользовательское
+ соглашение AO3 (%{tos_url}), вы должны будете исправить нарушение - иначе
+ ваша работа может быть удалена с AO3
+ tos: Пользовательское соглашение
+ anonymous_or_unrevealed_notification:
+ anonymous_info: Анонимные работы можно найти по тегам, но они не отображаются
+ в списке ваших работ. В самой работе ваше имя пользователя будет заменено
+ на "Anonymous" (Анонимный автор).
+ anonymous_unrevealed_info: В будущем кураторы коллекции могут открыть вашу работу,
+ но оставить ее анонимной. Ваши подписчики не будут об этом уведомлены. Когда
+ работа будет открыта, ее можно будет найти по тегам, но она не будет отображаться
+ в списке ваших работ. В самой работе ваше имя пользователя будет заменено
+ на "Anonymous" (Анонимный автор).
+ changed_status:
+ anonymous:
+ html: Кураторы коллекции %{collection_link} изменили статус вашей работы
+ %{work_link} на анонимный.
+ text: Кураторы коллекции "%{collection_title}" (%{collection_url}) изменили
+ статус вашей работы "%{work_title}" (%{work_url}) на анонимный.
+ anonymous_unrevealed:
+ html: Кураторы коллекции %{collection_link} изменили статус вашей работы
+ %{work_link} на анонимный и скрытый.
+ text: Кураторы коллекции "%{collection_title}" (%{collection_url}) изменили
+ статус вашей работы "%{work_title}" (%{work_url}) на анонимный и скрытый.
+ unrevealed:
+ html: Кураторы колекции %{collection_link} изменили статус вашей работы
+ %{work_link} на скрытый.
+ text: Кураторы коллекции "%{collection_title}" (%{collection_url}) изменили
+ статус вашей работы "%{work_title}" (%{work_url}) на скрытый.
+ collection_items_link_text: страницу Approved Collection Items (Утвержденные
+ Работы в Коллекциях)
+ do_not_want:
+ anonymous:
+ html: Если вы не хотите, чтобы ваша работа была анонимной, пожалуйста, перейдите
+ на %{collection_items_link}, чтобы удалить ее из этой коллекции.
+ text: 'Если вы не хотите, чтобы ваша работа оставалась анонимной, пожалуйста,
+ перейдите на страницу Approved Collection Items (Утвержденные Работы в
+ Коллекциях), чтобы удалить ее из этой коллекции: %{collection_items_url}'
+ anonymous_unrevealed:
+ html: Если вы не хотите, чтобы ваша работа оставалась анонимной и скрытой,
+ пожалуйста, перейдите на %{collection_items_link}, чтобы удалить ее из
+ этой коллекции.
+ text: 'Если вы не хотите, чтобы ваша работа оставалась анонимной и скрытой,
+ пожалуйста, перейдите на страницу Approved Collection Items (Утвержденные
+ Работы в Коллекциях),чтобы удалить ее из этой коллекции: %{collection_items_url}'
+ unrevealed:
+ html: Если вы не хотите, чтобы ваша работа оставалась скрытой, пожалуйста,
+ перейдите на %{collection_items_link}, чтобы удалить ее из этой коллекции.
+ text: 'Если вы не хотите, чтобы ваша работа оставалась скрытой, пожалуйста,
+ перейдите на страницу Approved Collection Items (Утвержденные Работы в
+ Коллекциях), чтобы удалить ее из этой коллекции: %{collection_items_url}'
+ faq_link_text: FAQ Коллекций
+ more_info:
+ html: Более подробную информацию можно найти в %{faq_link}.
+ text: 'Более подробную информацию можно найти в нашем FAQ Коллекций: %{faq_url}'
+ subject:
+ anonymous: "[%{app_name}] Ваша работа теперь анонимна"
+ anonymous_unrevealed: "[%{app_name}] Ваша работа теперь скрыта и анонимна"
+ unrevealed: "[%{app_name}] Ваша работа теперь скрыта"
+ unrevealed_info: Скрытые работы нельзя найти по тегам, и они не отображаются
+ в списке ваших работ. При нажатии на ссылку работы пользователь будет уведомлен,
+ что работа скрыта, и не сможет увидеть ее содержания.
+ archivist_added_to_collection_notification:
+ approved_collection_items_page: Approved Collection Items (Одобренные предметы
+ коллекции)
+ archivist_notice: Поскольку хранители коллекции выступают в официальном качестве
+ архивариусов Open Doors (Открытых дверей), им разрешено добавлять ваши работы
+ в эту коллекцию, даже если у вас отключены приглашения в коллекции. Архивариусы
+ добавят произведение в коллекцию только в том случае, если оно было размещено
+ в импортируемом архиве.
+ removal_instructions:
+ html: Если вы желаете удалить свою работу из этой коллекции, посетите страницу
+ %{approved_items_link}.
+ text: Если вы желаете удалить свою работу из этой коллекции, посетите страницу
+ Approved Collection Items (Одобренные предметы коллекции) %{approved_items_url}.
+ subject: "[%{app_name}][%{collection_title}] Архивариус комитета Open Doors
+ (Открытые двери) добавил вашу работу в коллекцию."
+ work_added:
+ html: Хранители коллекции %{collection_link} добавили вашу работу %{work_link}
+ в свою коллекцию.
+ text: Хранители коллекции "%{collection_title}" (%{collection_url}) добавили
+ вашу работу "%{work_title}" (%{work_url} в свою коллекцию!
+ challenge_assignment_notification:
+ any: Любой
+ assignment:
+ html: Вам было поручено выполнение следующей заявки челленджа %{link} на AO3!
+ description: 'Описание:'
+ due: 'Задание необходимо выполнить до:'
+ html:
+ footer: Вы получили это письмо, потому что записались на участие в челлендже
+ %{title}. Чтобы узнать больше об этом челлендже и получить контактные данные
+ модераторов, пожалуйста, посетите %{footer_link}.
+ footer_link: профиль челленджа
+ look_up: Вы можете уточнить детали этого задания на %{link}.
+ look_up_link: своей странице Assignments (Задания)
+ optional_tags: 'Дополнительные теги:'
+ prompts: 'Заявки:'
+ prompt_url: 'URL заявки:'
+ recipient: 'Получатель:'
+ recipient_missing: 'Отсутствует: свяжитесь с модератором!'
+ subject: "[%{app_name}][%{collection_title}] Ваше задание!"
+ text:
+ assignment: Вам было поручено выполнение следующей заявки челленджа "%{collection_title}"
+ (%{collection_url}) на AO3!
+ footer: Вы получили это письмо, потому что записались на участие в челлендже
+ %{title} (%{url}).Чтобы узнать больше об этом челлендже и получить контактные
+ данные модераторов, пожалуйста, посетите %{profile_url}.
+ look_up: Вы можете найти это задание на своей странице Assignments (Задания)
+ по адресу %{link}.
+ change_email:
+ changed:
+ html: "%{login}, адрес электронной почты, привязанный к вашему аккаунту, был
+ изменен на %{email}"
+ text: "%{login}, адрес электронной почты, привязанный к вашему аккаунту, был
+ изменен на %{email}"
+ subject: "[%{app_name}] Изменение адреса электронной почты"
+ claim_notification:
+ access:
+ contact_support: свяжитесь с поддержкой АО3
+ html: В зависимости от архива ваши работы могли быть импортированы только
+ для зарегистрированных пользователей (чтобы они индексировались Google).
+ В этом случае работы будут доступны только зарегистрированным пользователям,
+ если вы не решите сделать их полностью видимыми. Для помощи в разблокировке,
+ отказе или удалении ваших работ, пожалуйста, %{contact_support_link}.
+ text: В зависимости от архива ваши работы могли быть импортированы только
+ для зарегистрированных пользователей (чтобы они не попадали в поиск Google).
+ В этом случае работы будут доступны только зарегистрированным пользователям,
+ если вы не решите сделать их полностью видимыми. Для помощи в разблокировке,
+ отказе или удалении ваших работ, пожалуйста, свяжитесь с Поддержкой АО3
+ по ссылке %{support_url}.
+ email_tips: Если вы связываетесь с нами, пожалуйста добавьте адреса с @transformativeworks.org
+ в белый список и проверяйте вашу папку Спам.
+ introduction:
+ ao3_name: Archive of Our Own – AO3 (Наш архив)
+ html: Вы получили это письмо, потому что ваши работы были в архиве, который
+ был перенесен %{open_doors_name_link} в %{app_link}. Поскольку этот адрес
+ электронной почты связан с тем, который использовался в переносимом архиве,
+ соответствующие фан-работы (перечисленные ниже) были автоматически добавлены
+ в вашу учетную запись на AO3.
+ open_doors_name: Open Doors (Открытые двери)
+ text: 'Вы получили это письмо, потому что у вас были произведения в архиве
+ фан-работ, которые были перенесены Open Doors (Открытыми дверями) (%{open_doors_url})
+ в Archive of Our Own – AO3 (Наш архив): %{app_url}. Поскольку этот адрес
+ электронной почты связан с тем, который зарегистрирован в переносимом архиве,
+ соответствующие фан-работы (перечисленные ниже) были автоматически добавлены
+ в вашу учетную запись AO3.'
+ mistake:
+ contact_open_doors: свяжитесь с Открытыми дверями
+ html: Если произошла ошибка и это не ваши работы, пожалуйста, не удаляйте
+ их. Пожалуйста, %{contact_open_doors_link} и мы во всем разберемся.
+ text: Если произошла ошибка и это не ваши работы, пожалуйста, не удаляйте
+ их. Пожалуйста, свяжитесь с Открытыми дверями (%{open_doors_url}) и мы во
+ всем разберемся.
+ more_info:
+ ao3_news: новости АО3
+ contact_support: связаться с поддержкой АО3
+ faq_page: FAQ
+ html: Вы можете прочитать новости о недавних перемещениях архива по адресу
+ %{ao3_news_link} и найти дополнительную информацию на странице Открытых
+ дверей %{faq_page_link} или %{tutorial_page_link}. По другим вопросам, которые
+ не упомянуты в FAQ или этом письме, пожалуйста, %{contact_support_link}.
+ text: Вы можете прочитать объявления о недавних перемещениях архива в новостях
+ АО3 (%{news_url}) и найти дополнительную информацию на странице FAQ Открытых
+ дверей (%{open_doors_faq_url}) или на странице руководств (%{open_doors_tutorial_url}).
+ По вопросам, на которые нет ответов в FAQ, руководства или этом письме,
+ пожалуйста, свяжитесь с Поддержкой АО3 по ссылке %{support_url}.
+ tutorial_page: руководство
+ other_works:
+ contact_open_doors: свяжитесь с Открытыми дверями
+ html: Если у вас есть другие работы в переносимом архиве с электронным адресом,
+ к которому у вас больше нет доступа, %{contact_open_doors_link} с любой
+ информацией, которая поможет подтвердить вашу личность.
+ text: Если у вас есть другие работы в переносимом архиве с электронным адресом,
+ к которому у вас больше нет доступа, свяжитесь с Открытыми дверями с любой
+ информацией, которая поможет подтвердить вашу личность.
+ questions:
+ contact_support: свяжитесь с Поддержкой АО3
+ html: По другим вопросам, пожалуйста, %{contact_support_link}.
+ text: по другим вопросам, пожалуйста, свяжитесь с Поддержкой АО3 по ссылке
+ %{support_url}.
+ redirects:
+ html: Для сохранения списков рекомендаций и закладок, ссылки импортированного
+ архива будут переадресовывать на импортированную копию этих произведений
+ в течение ограниченного времени (чтобы убедиться, проверьте сообщение с
+ объявлением для вашего архива). Если вы уже загрузили копию этих работ и
+ %{negation} использовали функцию импорта из URL, на AO3 будет две копии
+ одной и той же работы.
+ subject: "[%{app_name}] Работы загружены"
+ update_redirect:
+ contact_open_doors: свяжитесь с Открытыми дверями
+ html: Если вы хотите, чтобы Открытые двери обновили ссылки для переадресации,
+ указав вашу существующую работу, удалите импортированную копию и %{contact_open_doors_link}
+ с именем вашей учетной записи AO3, именем вашей учетной записи в импортированном
+ архиве, а также названием и URL-адресом фан-работы, на которые вы хотите
+ сделать переадресацию. (Если у вас есть несколько работ, для которых вы
+ хотите изменить переадресацию, вы можете перечислить их в одном электронном
+ письме.)
+ text: Если вы хотите, чтобы Открытые двери обновили ссылки для переадресации,
+ указав на вашу существующую работу, удалите импортированную копию и свяжитесь
+ с Открытыми дверями по ссылке %{open_doors_url} с именем вашей учетной записи
+ AO3, именем вашей учетной записи в импортированном архиве, а также названием
+ и URL-адресом фан-работы, на которые вы хотите создать переадресацию. (Если
+ у вас есть несколько работ, для которых вы хотите изменить ссылки, вы можете
+ перечислить их в одном электронном письме.)
+ works_by: 'Эти работы были написаны с указанием почтового адреса: %{email}'
+ work_info:
+ html: "%{work_link} (%{fandom})"
+ text:
+ no_fandom: "- %{work_title} %{work_url}"
+ with_fandom: "- %{work_title} %{work_url} (%{fandom})"
+ collection_notification:
+ assignments_sent:
+ complete: Все задания были отправлены.
+ subject: Задания отправлены
+ html:
+ received_message: 'Вы получили сообщение о вашей коллекции %{collection_link}:'
+ text:
+ received_message: 'Вы получили сообщение о вашей коллекции "%{collection_title}"
+ (%{collection_url}):'
+ creatorship_notification:
+ explanation: Если вы указаны как соавтор для работы, вас могут добавить в соавторы
+ следующих глав вне зависимости от ваших настроек. Вы также будете указаны
+ как соавтор к любому циклу, которому принадлежит эта работа.
+ html:
+ creation: "%{creation_link} за авторством %{pseud_links}"
+ edit_chapter: редактировать главу
+ edit_series: Редактировать серию
+ remove_chapter: Если вы были добавлены по ошибке или вы не хотите быть соавтором,
+ вы можете %{edit_chapter_link} отредактировать соавторство.
+ remove_series: Если вы были добавлены по ошибке или вы не хотите быть соавтором,
+ вы можете %{edit_series_link} отредактировать соавторство.
+ intro_chapter: 'Пользователь %{adding_user} предложил указать ваш псевдоним
+ %{pseud} в качестве соавтора следующей работы:'
+ intro_series: 'Пользователь %{adding_user} предложил указать ваш псевдоним %{pseud}
+ в качестве соавтора следующей серии:'
+ subject: "[%{app_name}] Уведомление о соавторстве"
+ text:
+ creation: "%{title} (%{url}) за авторством %{pseuds}"
+ remove_chapter: 'Если вы были добавлены по ошибке или вы не хотите быть автором,
+ вы можете отредактировать главу, чтобы убрать авторство: %{url}'
+ remove_series: 'Если вы были добавлены по ошибке или вы не хотите быть автором,
+ вы можете отредактировать серию, чтобы убрать авторство: %{url}'
+ creatorship_notification_archivist:
+ explanation: Действуя в рамках своих официальных полномочий, архивариус Открытых
+ дверей может добавить вас без предварительного запроса, даже если у вас отключены
+ приглашения к соавторству.
+ html:
+ creation: "%{creation_link} за авторством %{pseud_links}"
+ edit_chapter: отредактировать главу
+ edit_series: отредактировать цикл
+ edit_work: отредактировать работу
+ remove_chapter: Если вас добавили по ошибке или если вы не хотите быть указаны
+ автором, вы можете %{edit_chapter_link}, чтобы убрать себя из списка авторов.
+ remove_series: Если вас добавили по ошибке или если вы не хотите быть указаны
+ автором, вы можете %{edit_series_link}, чтобы убрать себя из списка авторов.
+ remove_work: Если вас добавили по ошибке или если вы не хотите быть указаны
+ автором, вы можете %{edit_work_link}, чтобы убрать себя из списка авторов.
+ intro_chapter: 'Пользователь %{archivist} добавил ваш псевдоним %{pseud} соавтором
+ к следующей главе:'
+ intro_series: 'Пользователь %{archivist} добавил ваш псевдоним %{pseud} соавтором
+ к следующему циклу работ:'
+ intro_work: 'Пользователь %{archivist} добавил ваш псевдоним %{pseud} соавтором
+ к следующей работе:'
+ subject: "[%{app_name}] Уведомление о добавлении в соавторы архивариусом"
+ text:
+ creation: "%{title} (%{url}) за авторством %{pseuds}"
+ remove_chapter: 'Если вас добавили по ошибке или если вы не хотите быть указаны
+ автором, вы можете отредактировать главу, чтобы убрать себя из списка авторов:
+ %{url}'
+ remove_series: 'Если вас добавили по ошибке или если вы не хотите быть указаны
+ автором, вы можете отредактировать цикл, чтобы убрать себя из списка авторов:
+ %{url}'
+ remove_work: 'Если вас добавили по ошибке или вы не не хотите быть указаны
+ соавтором, вы можете отредактировать работу, чтобы убрать себя из списка
+ авторов: %{url}'
+ creatorship_request:
+ html:
+ creation: "%{creation_link} за авторством %{pseud_links}"
+ instructions: Вы можете принять или отклонить этот запрос на странице %{page_name}
+ page_name: Co-Creator Requests (Запросы на соавторство)
+ intro_chapter: 'Пользователь %{inviting_user} предлагает включить ваш псевдоним
+ %{pseud} в список соавторов следующей работы:'
+ intro_series: 'Пользователь %{inviting_user} предлагает включить ваш псевдоним
+ %{pseud} в список соавторов следующего цикла работ:'
+ intro_work: 'Пользователь %{inviting_user} предлагает включить ваш псевдоним
+ %{pseud} в список соавторов следующей работы:'
+ subject: "[%{app_name}] Запрос на соавторство"
+ text:
+ creation: "%{title} (%{url}) за авторством %{pseuds}"
+ instructions: 'Вы можете принять или отклонить этот запрос на странице Co-Creator
+ Requests (Запросы на соавторство): %{url}'
+ delete_work_notification:
+ attachment: В приложении - копия вашей работы для ознакомления.
+ deleted_other:
+ html: Ваша работа %{title} была удалена по запросу %{pseud}.
+ text: Ваша работа "%{title}" была удалена по запросу %{pseud}.
+ deleted_yourself:
+ html: Ваша работа %{title} была удалена по вашему запросу.
+ text: Ваша работа "%{title}" была удалена по вашему запросу.
+ questions:
+ html: Если у вас есть вопросы, пожалуйста %{support}.
+ text: Если у вас есть вопросы, пожалуйста %{support} (%{url}).
+ subject: "[%{app_name}] Ваша работа была удалена"
+ support: свяжитесь с Поддержкой
+ invitation_to_claim:
+ access:
+ text: В зависимости от архива, ваши работы могли быть импортированы так, что
+ теперь они доступны только зарегистрированным пользователям (так их нельзя
+ найти с помощью Google). В этом случае работы будут доступны только вошедшим
+ в аккаунт пользователям, если вы не решите сделать их полностью видимыми.
+ Для помощи в разблокировке, отказе от работ или их удалении, пожалуйста,
+ свяжитесь с Поддержкой AO3.
+ claim_or_remove:
+ html: Заявите об авторстве или удалите свои работы здесь.
+ text: 'Заявите об авторстве или удалите свои работы здесь: %{claim_url}'
+ email_tips: Если вы связываетесь с нами, пожалуйста, добавьте в белый список
+ / контакты адреса на @transformativeworks.org и проверяйте папки со спамом
+ на предмет нашего ответа.
+ html:
+ ao3_news: Новостях AO3
+ contact_open_doors: свяжитесь с Открытыми Дверями
+ contact_support: свяжитесь с Поддержкой AO3
+ faq_page: странице FAQ
+ tutorial_page: руководствах
+ introduction:
+ text: Вы получили это письмо, потому что Open Doors (Открытые Двери) (%{open_doors_link})
+ недавно импортировали архив на %{app_name} (%{app_short_name} - %{app_url}),
+ и мы предполагаем, что некоторые работы принадлежат вам. Мы хотели бы предоставить
+ вам шанс заявить о вашем авторстве (или удалить/отказаться от) этих работ,
+ если вы захотите. И если у вас еще нет аккаунта, привязанного к другому
+ адресу электронной почты, мы хотели бы пригласить вас зарегистрироваться!
+ mistake:
+ text: Если это ошибка и это не ваши работы, пожалуйста, не удаляйте их! Просто
+ свяжитесь с Открытыми Дверями (%{open_doors_link}), и мы разберемся с этим.
+ more_info:
+ text: 'Вы можете прочитать анонс недавнего импорта архива в Новостях AO3 (%{news_link})
+ и найти дополнительную информацию на странице FAQ Открытых Дверей (%{open_doors_faq_link})
+ или в руководствах (%{open_doors_tutorial_link}). Если у вас есть вопросы,
+ ответов на которые нет в FAQ, руководствах или этом письме, пожалуйста,
+ свяжитесь с Поддержкой по ссылке: %{support_link}.'
+ other_works:
+ text: Если у вас были другие работы на импортированном архиве, связанные с
+ адресом электронной почты, к которому вы больше не имеете доступа, пожалуйста
+ свяжитесь с Открытыми Дверьми и предоставьте любую информацию, которая поможет
+ подтвердить вашу личность.
+ questions:
+ text: По другим вопросам, пожалуйста, свяжитесь с Поддержкой AO3 на %{support_link}.
+ redirects: Чтобы сохранить списки рекомендаций и закладки, веб-адреса импортированного
+ архива могут перенаправлять на импортированную копию этих произведений в течение
+ ограниченного времени (проверьте, пожалуйста, анонс импорта для вашего архива).
+ Если вы уже загрузили копию этих работ и НЕ использовали функцию импорта из
+ URL, в Архиве будет две копии одной и той же работы.
+ subject: "[%{app_name}] Приглашение к заявлению прав на работу"
+ unwanted:
+ text: 'Если эти работы принадлежат вам, но вы не хотите заявлять на них права,
+ вы можете отказаться от них (тогда они остануться на AO3, но ваше имя будет
+ с них удален) или удалить их (тогда они будут полностью удалены с AO3).
+ Вам не нужно добавлять эти работы в какую-либо учетную запись, чтобы отказаться
+ от них или удалить их — вы можете сделать это прямо по ссылке выше. (Для
+ помощи, пожалуйста, свяжитесь с Поддержкой по ссылке: %{support_link}.)'
+ update_redirect:
+ text: Если вы хотите, чтобы Открытые Двери обновили перенаправление так, чтобы
+ оно указывало на уже существовавшую работу, удалите импортированную копию
+ и свяжитесь с Открытыми Дверями через %{open_doors_link}, указав имя своей
+ учетной записи AO3, имя своей учетной записи в импортированном архиве, а
+ также название и ссылку на фан-работу, на которую вы бы хотели сделать перенаправление.
+ (Если у вас есть несколько работ, для которых вы хотите изменить перенаправления,
+ вы можете перечислить их в одном письме.)
+ uploaded_list: 'Загруженные работы включают в себя:'
+ invite_increase_notification:
+ html:
+ body:
+ few: Мы просто хотели сказать, что у вас %{count} новых приглашения, которые
+ можно использовать для создания новых аккаунтов в архиве. Пригласить друзей
+ можно на %{invitation_page_link}.
+ many: Мы просто хотели сказать, что у вас %{count} новых приглашений, которые
+ можно использовать для создания новых аккаунтов в архиве. Пригласить друзей
+ можно на %{invitation_page_link}.
+ one: Мы просто хотели сказать, что у вас %{count} новое приглашение, которое
+ можно использовать для создания новых аккаунтов в архиве. Пригласить друзей
+ можно на %{invitation_page_link}.
+ invitation_page_link_text: странице "Invitations" (Приглашения)
+ subject: "[%{app_name}] Новые приглашения"
+ text:
+ body:
+ few: Мы просто хотели сказать, что у вас %{count} новых приглашения, которые
+ можно использовать для создания новых аккаунтов в архиве. Пригласить друзей
+ можно на (%{invitation_page_url}).
+ many: Мы просто хотели сказать, что у вас %{count} новых приглашений, которые
+ можно использовать для создания новых аккаунтов в архиве. Пригласить друзей
+ можно на (%{invitation_page_url}).
+ one: Мы просто хотели сказать, что у вас %{count} новое приглашение, которое
+ можно использовать для создания новых аккаунтов в архиве. Пригласить друзей
+ можно на (%{invitation_page_url}).
+ invite_request_declined:
+ main:
+ few: К сожалению, мы вынуждены сообщить вам, что ваш запрос на %{count} новых
+ приглашения не может быть выполнен в настоящее время.
+ many: К сожалению, мы вынуждены сообщить вам, что ваш запрос на %{count} новых
+ приглашений не может быть выполнен в настоящее время.
+ one: К сожалению, мы вынуждены сообщить вам, что ваш запрос на %{count} новое
+ приглашение не может быть выполнен в настоящее время.
+ reason: 'Ваш запрос был следующим:'
+ subject: "[%{app_name}] Отказ в получении дополнительного кода приглашения"
+ recipient_notification:
+ html:
+ collection: Подарочная работа для вас была опубликована в коллекции %{collection_link}
+ на AO3!
+ no_collection: Для вас была опубликована подарочная работа на AO3!
+ subject:
+ collection: "[%{app_name}][%{collection_title}] Подарочная работа для вас
+ от %{collection_title}"
+ no_collection: "[%{app_name}] Подарок для вас"
+ text:
+ collection: Подарочная работа для вас была опубликована в коллекции "%{collection_title}"
+ (%{collection_url}) на AO3!
+ signup_notification:
+ activate:
+ html: Пожалуйста, %{activate_account_link}.
+ text: 'Пожалуйста, пройдите по ссылке для активации своего аккаунта: %{activate_account_url}'
+ activate_your_account: пройдите по ссылке для активации аккаунта
+ admin_posts: Новости АО3
+ bye: Надеемся, что вам понравится использовать Наш Архив.
+ contact_support: свяжитесь с Технической поддержкой
+ faq: FAQ
+ features:
+ html: Как только ваш аккаунт будет активирован, вы сможете публиковать свои
+ фан-работы, получать уведомления об обновлениях любимых авторов и работ
+ по электронной почте, настраивать внешний вид и работу сайта, отслеживать
+ работы, к которым вы обращались на AO3 через свою историю, и многое другое.
+ text: Как только ваш аккаунт будет активирован, вы сможете публиковать свои
+ фан-работы, получать уведомления об обновлениях любимых авторов и работ
+ по электронной почте, отслеживать работы, к которым вы обращались на AO3
+ через свою историю, и многое другое.
+ information:
+ html: Информацию и советы по использованию Архива можно найти в нашем %{faq_link}.
+ Ознакомиться с последними новостями по развитию сайта можно в %{admin_posts_link}.
+ Чтобы обратиться за помощью, сообщить о баге на сайте, задать вопрос или
+ внести свое предложение, пожалуйста, %{contact_support_link}, они всегда
+ рады помочь.
+ text: 'Информацию и советы по использованию Архива можно найти в нашем FAQ
+ по ссылке %{faq_url}. Вы найдете последние новости по развитию сайта в Новостях
+ АО3 по ссылке %{admin_posts_url}. Чтобы обратиться за помощью, сообщить
+ о баге на сайте, задать вопрос или внести свое предложение, пожалуйста,
+ свяжитесь с нашей командой Поддержки, они всегда рады помочь: %{contact_support_url}.'
+ welcome: Добро пожаловать на Наш Архив, %{login}!
diff --git a/config/locales/phrase-exports/scr.yml b/config/locales/phrase-exports/scr.yml
new file mode 100755
index 0000000..d6dac5f
--- /dev/null
+++ b/config/locales/phrase-exports/scr.yml
@@ -0,0 +1,518 @@
+---
+scr:
+ activerecord:
+ attributes:
+ archive_warning:
+ name_with_colon:
+ few: 'Upozorenja:'
+ one: 'Upozorenje:'
+ other: 'Upozorenja:'
+ category:
+ name_with_colon:
+ few: 'Категорије:'
+ one: 'Kategorija:'
+ other: 'Kategorije:'
+ character:
+ name_with_colon:
+ few: 'Likovi:'
+ one: 'Lik:'
+ other: 'Likovi:'
+ fandom:
+ name_with_colon:
+ few: 'Fandomi:'
+ one: 'Fandom:'
+ other: 'Fandomi:'
+ freeform:
+ name_with_colon:
+ few: 'Dodatni tagovi:'
+ one: 'Dodatan tag:'
+ other: 'Dodatni tagovi:'
+ rating:
+ name_with_colon: 'Ocena primerenosti sadržaja:'
+ relationship:
+ name_with_colon:
+ few: 'Odnosi između likova:'
+ one: 'Odnos između likova:'
+ other: 'Odnosi između likova:'
+ work:
+ chapter_total_display: Poglavlja
+ summary: Sažetak
+ models:
+ archive_warning:
+ few: Upozorenja
+ one: Upozorenje
+ other: Upozorenja
+ category:
+ few: Kategorije
+ one: Kategorija
+ other: Kategorije
+ chapter:
+ few: Glave
+ one: Glava
+ other: Glave
+ character:
+ few: Likovi
+ one: Lik
+ other: Ликови
+ fandom:
+ few: Fandomi
+ one: Fandom
+ other: Fandomi
+ freeform:
+ few: Dodatni tagovi
+ one: Dodatan tag
+ other: Dodatni tagovi
+ rating:
+ few: Ocene primerenosti zadržaja
+ one: Ocena primerenosti zadržaja
+ other: Ocene primerenosti zadržaja
+ relationship:
+ few: Odnosi između likova
+ one: Odnos između likova
+ other: Odnosi između likova
+ series:
+ few: Serijali
+ one: Serijal
+ other: Serijali
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ few: "%{count} госта"
+ one: "%{count} гост"
+ other: "%{count} гостију"
+ left_kudos:
+ html:
+ few: "%{givers_list} су похвалили %{commentable_link}."
+ one: "%{givers_list} је похвалио/ла %{commentable_link}."
+ other: "%{givers_list} су похвалили %{commentable_link}."
+ text:
+ few: "%{givers_list} су похвалили %{commentable_title} (%{commentable_url})."
+ one: "%{givers_list} је похвалио/ла %{commentable_title} (%{commentable_url})."
+ other: "%{givers_list} су похвалили %{commentable_title} (%{commentable_url})."
+ single_guest:
+ giver: Гост/гошћа
+ html: "%{giver} је оставио/ла похвалу на %{commentable_link}."
+ text: Гост/гошћа је оставио/ла похвалу на %{commentable_title} (%{commentable_url}).
+ subject: "[%{app_name}] Добили сте похвале!"
+ mailer:
+ general:
+ closing:
+ formal: Srdačan pozdrav,
+ informal: Sve najbolje,
+ creation:
+ link_with_word_count: "%{creation_link} (%{word_count})"
+ title_with_chapter_number: "%{title}: Поглавље %{position}"
+ title_with_word_count: '"%{creation_title}" (%{word_count})'
+ word_count:
+ few: "%{count} reči"
+ one: "%{count} reč"
+ other: "%{count} reči"
+ footer:
+ general:
+ about:
+ html: AO3 je arhiva koju vode i podržavaju fanovi, a oslanja se na %{donate_link}.
+ text: 'AO3 je arhiva koju vode i podržavaju fanovi, a oslanja se na Vaše
+ donacije: %{donate_url}.'
+ html:
+ donate_link_text: Vaše donacije
+ support_link_text: kontaktirajte Korisničku podršku
+ unwanted_email:
+ html: Ako ste greškom primili ovu poruku, molimo Vas %{support_link}.
+ text: Ako ste greškom primili ovu poruku, molimo Vas kontaktirajte Korisničku
+ podršku na %{support_url}.
+ sent_at: Poslato u %{sent_at}.
+ greeting:
+ formal_html: Poštovani/a %{name},
+ informal:
+ addressed_html: Zdravo, %{name}!
+ unaddressed: Zdravo!
+ introductory: Pozdrav od Archive of Our Own – AO3-a (Naše sopstvene arhive)!
+ metadata_label_indicator: ":"
+ signature:
+ abuse_team: Tim Politike i zloupotrebe AO3-a
+ app_short_name: AO3
+ open_doors: Tim Open Doors (Otvorenih vrata)
+ parent_org: Organization for Transformative Works – OTW (Organizacija za transformativne
+ radove)
+ support: Tim Korisničke podrške AO3-a
+ users:
+ mailer:
+ reset_password_instructions:
+ expiration: Ако не искористите овај линк да ресетујете своју лозинку у року
+ од недељу дана, истећи ће и мораћете затражити нови.
+ intro: 'неко је затражио ресетовање лозинке за Bаш налог. Можете променити
+ лозинку Bашег налога тако што ћете пратити линк испод и унети Вашу нову
+ лозинку:'
+ link_title: Промени моју лозинку.
+ subject: "[%{app_name}] Ресетујте своју лозинку"
+ unrequested: Уколико нисте затражили ово ресетовање лозинке, можете занемарити
+ овај имејл и Ваша претходна лозинка ће наставити да ради.
+ user_mailer:
+ admin_deleted_work_notification:
+ bye: У прилогу је копија Вашег дела за Вашу референцу.
+ contact_abuse: контактирајте наш Одбор за злоупотребу
+ deleted:
+ html: Ваше дело %{title} је избрисано са АО3-a од стране администратора сајта.
+ text: Ваше дело "%{title}" је избрисано са AO3-a од стране администратора
+ сајта.
+ html:
+ tos_violation: Ако постоји могућност да је Ваше дело прекршило Услове коришћења
+ AO3-a, молимо Вас, %{contact_abuse_link}.
+ import_project:
+ html: Ако је Ваше дело било део увезеног пројекта којим се бави наш тим Open
+ Doors (Отворених врата), молимо Вас, %{opendoors_link} ако имате додатна
+ питања.
+ text: Ако је Ваше дело било део увезеног пројекта којим се бави наш тим Open
+ Doors (Отворених врата), молимо Вас, контактирајте Отворена врата (%{opendoors_link})
+ ако имате додатна питања.
+ opendoors: контактирајте Отворена врата
+ subject: "[%{app_name}] Ваше дело је избрисано од стране администратора"
+ text:
+ tos_violation: Ако постоји могућност да је Ваше дело прекршило Услове коришћења
+ АО3-а, молимо Вас, контактирајте наш Одбор за злоупотребу (%{contact_abuse_url}).
+ admin_hidden_work_notification:
+ access: Иако је Ваше дело сакривено, и даље ћете моћи да му приступите кроз
+ линк изнад, али неће бити наведено на Вашој страници дела, и неће бити доступно
+ другим корисницима АО3-а.
+ check_email: Молимо проверите Ваш имејл, укључујући фолдер за спам, јер Вас
+ је тим Политике и злоупотребе можда већ контактирао и објаснио зашто је Ваше
+ дело сакривено.
+ contact_abuse: контактирајте Политику и злоупотребу
+ html:
+ help: Ако нисте сигурни зашто је Ваше дело сакривено, и нисте примили више
+ информација о овоме, молимо директно %{contact_abuse_link}.
+ hidden: Ваше дело %{title} је сакривено од стране тима Политике и злоупотребе
+ и више није јавно доступно.
+ tos_violation: Ако је Ваше дело сакривено јер је прекршило АО3-ове %{tos_link},
+ мораћете да исправите прекршај. Ако Ваше дело не доведете у сагласност са
+ Условима коришћења, то може довести до брисања Вашег дела са АО3-а.
+ subject: "[%{app_name}] Ваше дело је сакривено од стране тима Политике и злоупотребе"
+ text:
+ help: 'Ако нисте сигурни зашто је Ваше дело сакривено, и нисте примили више
+ информација о овоме, молимо директно контактирајте Политику и злоупотребу:
+ %{contact_abuse_url}.'
+ hidden: Ваше дело "%{title}" (%{work_url}) је сакривено од стране тима Политике
+ и злоупотребе и више није јавно доступно.
+ tos_violation: Ако је Ваше дело сакривено јер је прекршило АО3-ове Услове
+ коришћења (%{tos_url}), мораћете да исправите прекршај. Ако Ваше дело не
+ доведете у сагласност са Условима коришћења, то може довести до брисања
+ Вашег дела са АО3-а.
+ tos: Услове коришћења
+ anonymous_or_unrevealed_notification:
+ anonymous_info: Анонимна дела су укључена у излистане тагове, али нису на Вашој
+ страници радова. На самом делу, Ваше корисничко име ће бити замењено са "Anonymous"
+ (Анонимно).
+ anonymous_unrevealed_info: Одржаваоци збирке могу касније открити Ваше дело,
+ али га оставити анонимним. Људи који Вас буду пратили неће бити обавештени
+ када ова промена ступи на снагу. Након што је откривено, Ваше дело ће бити
+ укључено у излистане тагове, али неће бити на страници Ваших радова. На самом
+ делу, Ваше корисничко име ће бити замењено са "Anonymous" (Анонимно).
+ changed_status:
+ anonymous:
+ html: Одржаваоци %{collection_link} збирке су променили статус Вашег дела
+ %{work_link} на анонимно.
+ text: Одржаваоци "%{collection_title}" (%{collection_url}) збирке су променили
+ статус Вашег дела "%{work_title}" (%{work_url}) на анонимно.
+ anonymous_unrevealed:
+ html: Одржаваоци %{collection_link} збирке су променили статус Вашег дела
+ %{work_link} на анонимно и неоткривено.
+ text: Одржаваоци "%{collection_title}" (%{collection_url}) збирке су променили
+ статус Вашег дела "%{work_title}" (%{work_url}) на анонимно и неоткривено.
+ unrevealed:
+ html: Одржаваоци %{collection_link} збирке су променили статус Вашег дела
+ %{work_link} на неоткривено.
+ text: Одржаваоци "%{collection_title}" (%{collection_url}) збирке су променили
+ статус Вашег дела "%{work_title}" (%{work_url}) на неоткривено.
+ collection_items_link_text: страницу Approved Collection Items (Одобрени предмети
+ из збирке)
+ do_not_want:
+ anonymous:
+ html: Ако не желите да Ваше дело буде анонимно, молимо Вас да посетите Вашу
+ %{collection_items_link} како бисте га уклонили из ове збирке.
+ text: 'Ако не желите да Ваше дело буде анонимно, молимо Вас да посетите
+ Вашу страницу Approved Collection Items (Одобрени предмети из збирке)
+ како бисте га уклонили из ове збирке: %{collection_items_url}'
+ anonymous_unrevealed:
+ html: Ако не желите да Ваше дело буде анонимно и неоткривено, молимо Вас
+ да посетите Вашу %{collection_items_link} како бисте га уклонили из ове
+ збирке.
+ text: 'Ако не желите да Ваше дело буде анонимно и неоткривено, молимо Вас
+ да посетите Вашу страницу Approved Collection Items (Одобрени предмети
+ из збирке) како бисте га уклонили из ове збирке: %{collection_items_url}'
+ unrevealed:
+ html: Ако не желите да Ваше дело буде неоткривено, молимо Вас да посетите
+ Вашу %{collection_items_link} како бисте га уклонили из ове збирке.
+ text: 'Ако не желите да Ваше дело буде неоткривено, молимо Вас да посетите
+ Вашу страницу Approved Collection Items (Одобрени предмети из збирке)
+ како бисте га уклонили из ове збирке: %{collection_items_url}'
+ faq_link_text: Често постављена питања о збиркама
+ more_info:
+ html: За више информација, посетите наша %{faq_link}.
+ text: 'За више информација, посетите наша Често постављена питања о збиркама:
+ %{faq_url}'
+ subject:
+ anonymous: "[%{app_name}] Ваше дело је начињено анонимним"
+ anonymous_unrevealed: "[%{app_name}] Ваше дело је начињено анонимним и неоткривеним"
+ unrevealed: "[%{app_name}] Ваше дело је начињено неоткривеним"
+ unrevealed_info: Неоткривена дела нису укључена у излистане тагове и нису на
+ страници Ваших радова. Свако ко буде пратио линк до Вашег дела ће добити обавештење
+ да је дело тренутно неоткривено, и да није могуће да се приступи његовом садржају.
+ challenge_assignment_notification:
+ any: Bilo koji
+ assignment:
+ html: Dodeljen Vam je sledeći zahtev u okviru izazova %{link} na AO3-u!
+ description: 'Opis:'
+ due: 'Rok za ovaj zadatak je:'
+ html:
+ footer: Primili ste ovaj mejl jer ste se prijavili za izazov %{title}. Za
+ više informacija o izazovu i kontakt informacije moderatora, molimo Vas
+ da posetite %{footer_link}.
+ footer_link: profilnu stranicu izazova
+ look_up: Ovaj zadatak možete potražiti na %{link}.
+ look_up_link: Vašoj stranici Assignments (Zadaci)
+ optional_tags: 'Proizvoljni tagovi:'
+ prompts: 'Ideje (prompts):'
+ prompt_url: 'Link ideje:'
+ recipient: 'Primalac:'
+ recipient_missing: 'Nepoznato: kontaktirajte moderatora za pomoć!'
+ subject: "[%{app_name}][%{collection_title}] Vaš zadatak!"
+ text:
+ assignment: Dodeljen Vam je sledeći zahtev u okviru izazova "%{collection_title}"
+ (%{collection_url}) na AO3-u!
+ footer: Primili ste ovaj mejl jer ste se prijavili za izazov %{title} (%{url}).
+ Za više informacija o izazovu i kontakt informacije moderatora, molimo Vas
+ da posetite %{profile_url}.
+ look_up: Ovaj zadatak možete potražiti na Vašoj stranici Assignments (Zadaci)
+ na %{link}.
+ change_email:
+ changed:
+ html: "%{login}, имејл који је повезан са Вашим налогом је промењен у %{email}"
+ text: "%{login}, имејл који је повезан са Вашим налогом је промењен у %{email}"
+ subject: "[%{app_name}] Имејл је промењен"
+ claim_notification:
+ access:
+ contact_support: kontaktirajte Korisničku podršku AO3-a
+ html: Zavisno od arhive, Vaša dela su možda unesena kao ograničena samo za
+ registrovane korisnike (kako bi bila sakrivena od Gugl pretraga). Ako je
+ ovo slučaj, dela će biti dostupna samo ulogovanim korisnicima osim ako Vi
+ ne odlučite da ih učinite potpuno vidljivim. Za pomoć oko otključavanja,
+ odricanja od ili brisanja dela, molimo %{contact_support_link}.
+ text: Zavisno od arhive, Vaša dela su možda unesena kao ograničena samo za
+ registrovane korisnike (kako bi bila sakrivena od Gugl pretraga). Ako je
+ ovo slučaj, dela će biti dostupna samo ulogovanim korisnicima osim ako Vi
+ ne odlučite da ih učinite potpuno vidljivim. Za pomoć oko otključavanja,
+ odricanja od ili brisanja dela, molimo kontaktirajte Korisničku podršku
+ AO3-a na %{support_url}.
+ email_tips: Ako nas kontaktirate, molimo dodajte imejl adrese od @transformativeworks.org
+ na Vašu listu bezbednih kontakata i proverite Vaše spam foldere za naš odgovor.
+ introduction:
+ ao3_name: Archive of Our Own – AO3 (Našu sopstvenu arhivu)
+ html: Primili ste ovaj imejl jer ste imali dela u arhivi fan-dela koju su
+ %{open_doors_name_link} unela na %{app_link}. Pošto je ova imejl adresa
+ povezana sa jednom koja je registrovana na unesenoj arhivi, povezana fan-dela
+ (navedena ispod) su automatski dodata na Vaš AO3 nalog.
+ open_doors_name: Open Doors (Otvorena vrata)
+ text: 'Primili ste ovaj imejl jer ste imali dela u arhivi fan-dela koju su
+ Open Doors (Otvorena vrata) (%{open_doors_url}) unela na Archive of Our
+ Own – AO3 (Našu sopstvenu arhivu): %{app_url}. Pošto je ova imejl adresa
+ povezana sa jednom koja je registrovana na unesenoj arhivi, povezana fan-dela
+ (navedena ispod) su automatski dodata na Vaš AO3 nalog.'
+ mistake:
+ contact_open_doors: kontaktirate Otvorena vrata
+ html: Ako je ovo greška i ovo nisu Vaša dela, molimo Vas da ih ne obrišete!
+ Molimo Vas da samo %{contact_open_doors_link} i mi ćemo rešiti problem.
+ text: Ako je ovo greška i ovo nisu Vaša dela, molimo Vas da ih ne obrišete!
+ Molimo Vas da samo kontaktirate Otvorena vrata (%{open_doors_url}) i mi
+ ćemo rešiti problem.
+ more_info:
+ ao3_news: AO3 vestima
+ contact_support: kontaktirajte Korisničku podršku AO3-a
+ faq_page: stranici Često postavljenih pitanja
+ html: Možete pročitati objave o skorašnjim prenosima arhiva na %{ao3_news_link},
+ i pronaći još informacija na %{faq_page_link} ili %{tutorial_page_link}
+ Otvorenih vrata. Za pitanja na koja nije odgovoreno u Često postavljenim
+ pitanjima, tutorijalima ili ovom imejlu, molimo %{contact_support_link}.
+ text: Možete pročitati objave o skorašnjim prenosima arhiva na AO3 vestima
+ (%{news_url}), i pronaći još informacija na stranici Često postavljenih
+ pitanja (%{open_doors_faq_url}) ili stranici tutorijala (%{open_doors_tutorial_url})
+ Otvorenih vrata. Za pitanja na koja nije odgovoreno u Često postavljenim
+ pitanjima, tutorijalima ili ovom imejlu, molimo kontaktirajte Korisničku
+ podršku AO3-a na %{support_url}.
+ tutorial_page: stranici tutorijala
+ other_works:
+ contact_open_doors: kontaktirajte Otvorena vrata
+ html: Ako ste imali druga dela na unesenoj arhivi pod imejl adresom kojoj
+ više ne možete da pristupite, molimo Vas, %{contact_open_doors_link} sa
+ bilo kojim informacijama koje mogu da pomognu u potvrđivanju Vašeg identiteta.
+ text: Ako ste imali druga dela na unesenoj arhivi pod imejl adresom kojoj
+ više ne možete da pristupite, molimo Vas, kontaktirajte Otvorena vrata sa
+ bilo kojim informacijama koje mogu da pomognu u potvrđivanju Vašeg identiteta.
+ questions:
+ contact_support: kontaktirajte Korisničku podršku AO3-a
+ html: Za druga pitanja, molimo %{contact_support_link}.
+ text: Za druga pitanja, molimo kontaktirajte Korisničku podršku AO3-a na %{support_url}.
+ redirects:
+ html: Kako bi očuvali liste preporuka i obeleživače, veb adrese unesene arhive
+ će možda voditi do unesenih kopija ovih dela na ograničeno vreme (proverite
+ objavu za Vašu arhivu kako biste bili sigurni). Ako ste Vi već objavili
+ kopiju ovih dela i %{negation} koristili opciju unosa putem URL-a, na AO3-u
+ će postojati dve kopije istog dela.
+ subject: "[%{app_name}] Dodata dela"
+ update_redirect:
+ contact_open_doors: kontaktirate Otvorena vrata
+ html: Ako želite da Otvorena vrata ažuriraju preusmerenje kako bi vodilo do
+ Vašeg već postojećeg dela, molimo da obrišete unesenu kopiju i %{contact_open_doors_link}
+ sa imenom Vašeg AO3 naloga, imenom Vašeg naloga na unesenoj arhivi, i naslovom
+ i URL-om fan-dela prema kom biste želeli da vodi veb adresa. (Ako imate
+ više dela za koja biste želeli da promenite preusmerenje, možete da ih navedete
+ u jednom imejlu.)
+ text: Ako želite da Otvorena vrata ažuriraju preusmerenje kako bi vodilo do
+ Vašeg već postojećeg dela, molimo da obrišete unesenu kopiju i kontaktirate
+ Otvorena vrata na %{open_doors_url} sa imenom Vašeg AO3 naloga, imenom Vašeg
+ naloga na unesenoj arhivi, i naslovom i URL-om fan-dela prema kom biste
+ želeli da vodi veb adresa. (Ako imate više dela za koja biste želeli da
+ promenite preusmerenje, možete da ih navedete u jednom imejlu.)
+ works_by: 'Ova dela su bila napisana sa imejla: %{email}'
+ work_info:
+ html: "%{work_link} (%{fandom})"
+ text:
+ no_fandom: "- %{work_title} %{work_url}"
+ with_fandom: "- %{work_title} %{work_url} (%{fandom})"
+ collection_notification:
+ assignments_sent:
+ complete: Сви задаци су послати.
+ subject: Задаци послати
+ html:
+ received_message: 'Примили сте поруку о својој збирци %{collection_link}:'
+ text:
+ received_message: 'Примили сте поруку о својој збирци "%{collection_title}"
+ (%{collection_url}):'
+ creatorship_notification:
+ explanation: Када сте коаутор неког дела, можете бити додани новим главама без
+ обзира на Ваша подешавања о коауторству. Бићете додани и на сваки серијал
+ у који је тај рад додан.
+ html:
+ creation: "%{creation_link} од %{pseud_links}"
+ edit_chapter: уредити поглавље
+ edit_series: уредити серијал
+ remove_chapter: Ако сте додани грешком или не желите да будете наведени као
+ аутор, можете %{edit_chapter_link} да бисте уклонили себе са листе аутора.
+ remove_series: Ако сте додани грешком или не желите да будете наведени као
+ аутор, можете %{edit_series_link} да бисте уклонили себе са листе аутора.
+ intro_chapter: 'Корисник %{adding_user} је навео Ваш псеудоним %{pseud} као
+ коаутора следећег поглавља:'
+ intro_series: 'Корисник %{adding_user} је навео Ваш псеудоним %{pseud} као коаутора
+ следећег серијала:'
+ subject: "[%{app_name}] Обавештење о коауторству"
+ text:
+ creation: "%{title} (%{url}) од %{pseuds}"
+ remove_chapter: 'Ако сте додани грешком или не желите да будете наведени као
+ аутор, можете уредити поглавље дa бисте уклонили себе као аутора: %{url}'
+ remove_series: 'Ако сте додани грешком или не желите да будете наведени као
+ аутор, можете уредити серијал дa бисте уклонили себе као аутора: %{url}'
+ creatorship_notification_archivist:
+ explanation: Pošto postupaju po svojoj zvaničnoj nadležnosti kao arhivar Otvorenih
+ vrata, dozvoljeno im je da Vas dodaju bez zahteva, čak i ako Vam je opcija
+ koautorstva onemogućena.
+ html:
+ creation: "%{creation_link} оd %{pseud_links}"
+ edit_chapter: uredite poglavlje
+ edit_series: uredite serijal
+ edit_work: uredite delo
+ remove_chapter: Ako ste dodati greškom ili ne želite da budete navedeni kao
+ autor, možete da %{edit_chapter_link} da biste uklonili sebe kao autora.
+ remove_series: Ako ste dodati greškom ili ne želite da budete navedeni kao
+ autor, možete da %{edit_series_link} da biste uklonili sebe kao autora.
+ remove_work: Ako ste dodati greškom ili ne želite da budete navedeni kao autor,
+ možete da %{edit_work_link} da biste uklonili sebe kao autora.
+ intro_chapter: 'Korisnik %{archivist} je dodao Vaš pseudonim %{pseud} kao koautora
+ na sledeće poglavlje:'
+ intro_series: 'Korisnik %{archivist} je dodao Vaš pseudonim %{pseud} kao koautora
+ na sledeći serijal:'
+ intro_work: 'Korisnik %{archivist} je dodao Vaš pseudonim %{pseud} kao koautora
+ na sledeće delo:'
+ subject: "[%{app_name}] Arhivarsko obaveštenje o koautorstvu"
+ text:
+ creation: "%{title} (%{url}) od %{pseuds}"
+ remove_chapter: 'Ako ste dodati greškom ili ne želite da budete navedeni kao
+ autor, možete da uredite poglavlje da biste uklonili sebe kao autora: %{url}'
+ remove_series: 'Ako ste dodati greškom ili ne želite da budete navedeni kao
+ autor, možete da uredite serijal da biste uklonili sebe kao autora: %{url}'
+ remove_work: 'Ako ste dodati greškom ili ne želite da budete navedeni kao
+ autor, možete da uredite delo da biste uklonili sebe kao autora: %{url}'
+ creatorship_request:
+ html:
+ creation: "%{creation_link} од стране %{pseud_links}"
+ instructions: Можете да прихватите или одбијете овај захтев на Вашој страници
+ %{page_name}.
+ page_name: Co-Creator Requests (Захтеви за коаутора)
+ intro_chapter: 'Корисник %{inviting_user} је позвао Ваш псеудоним %{pseud} да
+ буде на листи коаутора следећег поглавља:'
+ intro_series: 'Корисник %{inviting_user} је позвао Ваш псеудоним %{pseud} да
+ буде на листи коаутора следећег серијала:'
+ intro_work: 'Корисник %{inviting_user} је позвао Ваш псеудоним %{pseud} да буде
+ на листи коаутора на следећем делу:'
+ subject: "[%{app_name}] Захтев за коаутора"
+ text:
+ creation: "%{title} (%{url}) од стране %{pseuds}"
+ instructions: 'Можете да прихватите или одбијете овај захтев на Вашој страници
+ Co-Creator Requests (Захтеви за коаутора): %{url}'
+ delete_work_notification:
+ attachment: У прилогу је копија Вашег дела ако желите да га прегледате.
+ deleted_other:
+ html: Ваше дело %{title} је избрисано на захтев %{pseud}.
+ text: Ваше дело "%{title}" је избрисано на захтев %{pseud}.
+ deleted_yourself:
+ html: Ваше дело %{title} је избрисано на Ваш захтев.
+ text: Ваше дело %{title} је избрисано на Ваш захтев.
+ questions:
+ html: Ако имате питања, молимо Вас %{support}.
+ text: Ако имате питања, молимо Вас %{support} (%{url}).
+ subject: "[%{app_name}] Ваше дело је избрисано"
+ support: контактирајте Корисничку подршку
+ invite_increase_notification:
+ html:
+ body:
+ few: Samo smo hteli da Vas obavestimo da imate %{count} nove pozivnice,
+ koje se mogu koristiti za kreiranje novih naloga na AO3-u. Možete pozvati
+ prijatelja na %{invitation_page_link}.
+ one: Samo smo hteli da Vas obavestimo da imate %{count} novu pozivnicu,
+ koja se može koristiti za kreiranje novih naloga na AO3-u. Možete pozvati
+ prijatelja na %{invitation_page_link}.
+ other: Samo smo hteli da Vas obavestimo da imate %{count} novih pozivnica,
+ koje se mogu koristiti za kreiranje novih naloga na AO3-u. Možete pozvati
+ prijatelja na %{invitation_page_link}.
+ invitation_page_link_text: Vašoj stranici Invitations (Pozivnice)
+ subject: "[%{app_name}] Nove pozivnice"
+ text:
+ body:
+ few: Samo smo hteli da Vas obavestimo da imate %{count} novе pozivnicе,
+ koje se mogu koristiti za kreiranje novih naloga na AO3-u. Možete pozvati
+ prijatelja na Vašoj stranici Invitations (Pozivnice) (%{invitation_page_url}).
+ one: Samo smo hteli da Vas obavestimo da imate %{count} novu pozivnicu,
+ koja se može koristiti za kreiranje novih naloga na AO3-u. Možete pozvati
+ prijatelja na Vašoj stranici Invitations (Pozivnice) (%{invitation_page_url}).
+ other: Samo smo hteli da Vas obavestimo da imate %{count} novih pozivnica,
+ koje se mogu koristiti za kreiranje novih naloga na AO3-u. Možete pozvati
+ prijatelja na Vašoj stranici Invitations (Pozivnice) (%{invitation_page_url}).
+ invite_request_declined:
+ main:
+ few: Са жаљењем Вас обавештавамо да се Ваш захтев за %{count} нове позивнице
+ не може тренутно испунити.
+ one: Са жаљењем Вас обавештавамо да се Ваш захтев за %{count} нову позивницу
+ не може тренутно испунити.
+ other: Са жаљењем Вас обавештавамо да се Ваш захтев за %{count} нових позивница
+ не може тренутно испунити.
+ reason: 'Ваш захтев је био:'
+ subject: "[%{app_name}] Захтев за додатни позивни код је одбијен"
+ recipient_notification:
+ html:
+ collection: Поклон дело за Вас је објављено у %{collection_link} збирци на
+ АО3-у!
+ no_collection: Поклон дело за Вас је објављено на АО3-у!
+ subject:
+ collection: "[%{app_name}][%{collection_title}] Поклон дело за Вас из %{collection_title}"
+ no_collection: "[%{app_name}] Поклон дело за Вас"
+ text:
+ collection: Поклон дело за Вас је објављено у "%{collection_title}" збирци
+ (%{collection_url}) на АО3-у!
diff --git a/config/locales/phrase-exports/sk.yml b/config/locales/phrase-exports/sk.yml
new file mode 100755
index 0000000..581f68b
--- /dev/null
+++ b/config/locales/phrase-exports/sk.yml
@@ -0,0 +1,2 @@
+---
+sk: {}
diff --git a/config/locales/phrase-exports/sl.yml b/config/locales/phrase-exports/sl.yml
new file mode 100755
index 0000000..74d4e45
--- /dev/null
+++ b/config/locales/phrase-exports/sl.yml
@@ -0,0 +1,647 @@
+---
+sl:
+ activerecord:
+ attributes:
+ archive_warning:
+ name_with_colon:
+ few: 'Opozorila:'
+ one: 'Opozorilo:'
+ other: 'Opozorila:'
+ two: 'Opozorili:'
+ category:
+ name_with_colon:
+ few: 'Kategorije:'
+ one: 'Kategorija:'
+ other: 'Kategorije:'
+ two: 'Kategoriji:'
+ character:
+ name_with_colon:
+ few: 'Liki:'
+ one: 'Lik:'
+ other: 'Liki:'
+ two: 'Lika:'
+ fandom:
+ name_with_colon:
+ few: 'Fandomi:'
+ one: 'Fandom:'
+ other: 'Fandomi:'
+ two: 'Fandoma:'
+ freeform:
+ name_with_colon:
+ few: 'Dodatne oznake:'
+ one: 'Dodatna oznaka:'
+ other: 'Dodatne oznake:'
+ two: 'Dodatni oznaki:'
+ rating:
+ name_with_colon: 'Ocena primernosti:'
+ relationship:
+ name_with_colon:
+ few: 'Razmerja:'
+ one: 'Razmerje:'
+ other: 'Razmerja:'
+ two: 'Razmerji:'
+ work:
+ chapter_total_display: Poglavja
+ summary: Povzetek
+ models:
+ archive_warning:
+ few: Opozorila
+ one: Opozorilo
+ other: Opozorila
+ two: Opozorili
+ category:
+ few: Kategorije
+ one: Kategorija
+ other: Kategorije
+ two: Kategoriji
+ chapter:
+ few: Poglavja
+ one: Poglavje
+ other: Poglavja
+ two: Poglavji
+ character:
+ few: Liki
+ one: Lik
+ other: Liki
+ two: Lika
+ fandom:
+ few: Fandomi
+ one: Fandom
+ other: Fandomi
+ two: Fandoma
+ freeform:
+ few: Dodatne oznake
+ one: Dodatna oznaka
+ other: Dodatne oznake
+ two: Dodatni oznaki
+ rating:
+ few: Ocene primernosti
+ one: Ocena primernosti
+ other: Ocene primernosti
+ two: Oceni primernosti
+ relationship:
+ few: Razmerja
+ one: Razmerje
+ other: Razmerja
+ two: Razmerji
+ series:
+ few: Serije
+ one: Serija
+ other: Serije
+ two: Seriji
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ few: "%{count} gosti/je"
+ one: "%{count} gost/ja"
+ other: "%{count} gostov/ij"
+ two: "%{count} gosta/ji"
+ left_kudos:
+ html:
+ few: "%{givers_list} so pohvalili/e %{commentable_link}."
+ one: "%{givers_list} je pohvalil/a %{commentable_link}."
+ other: "%{givers_list} je pohvalilo %{commentable_link}."
+ two: "%{givers_list} sta pohvalila/i %{commentable_link}."
+ text:
+ few: "%{givers_list} so pohvalili/e %{commentable_title} (%{commentable_url})."
+ one: "%{givers_list} je pohvalil/a %{commentable_title} (%{commentable_url})."
+ other: "%{givers_list} je pohvalilo %{commentable_title} (%{commentable_url})."
+ two: "%{givers_list} sta pohvalila/i %{commentable_title} (%{commentable_url})."
+ single_guest:
+ giver: Gost/ja
+ html: "%{giver} je pohvalil/a %{commentable_link}."
+ text: Gost/ja je pohvalil/a %{commentable_title} (%{commentable_url}).
+ subject: "[%{app_name}] Dobili ste pohvalo!"
+ mailer:
+ general:
+ closing:
+ formal: S spoštovanjem,
+ informal: Z lepimi pozdravi,
+ creation:
+ link_with_word_count: "%{creation_link} (%{word_count})"
+ title_with_chapter_number: Poglavje %{position}, delo %{title}
+ title_with_word_count: '"%{creation_title}" (%{word_count})'
+ word_count:
+ few: "%{count} besede"
+ one: "%{count} beseda"
+ other: "%{count} besed"
+ two: "%{count} besedi"
+ footer:
+ general:
+ about:
+ html: Naš lastni arhiv upravljajo in vzdržujejo fani, zato se zanaša na
+ %{donate_link}.
+ text: 'Naš lastni arhiv upravljajo in vzdržujejo fani, zato se zanaša
+ na vaše donacije: %{donate_url}.'
+ html:
+ donate_link_text: vaše donacije
+ support_link_text: kontaktirajte Pomoč uporabnikom
+ unwanted_email:
+ html: Če ste to sporočilo prejeli po pomoti, prosimo %{support_link}.
+ text: Če ste to sporočilo prejeli po pomoti, prosimo kontaktirajte Pomoč
+ uporabnikom preko %{support_url}.
+ sent_at: Poslano dne %{sent_at}.
+ greeting:
+ formal_html: Spoštovani %{name},
+ informal:
+ addressed_html: Zdravo, %{name}!
+ unaddressed: Zdravo!
+ introductory: Lep pozdrav od Archive Of Our Own - AO3!
+ metadata_label_indicator: ":"
+ signature:
+ abuse_team: Ekipa za pravila in zlorabo AO3
+ app_short_name: AO3
+ open_doors: Ekipa Open Doors (Odprta vrata)
+ parent_org: Organization for Transformative Works - OTW (Organizacija za tranformativna
+ dela)
+ support: AO3 ekipa za pomoč uporabnikom
+ users:
+ mailer:
+ reset_password_instructions:
+ expiration: Če povezave za spremembo gesla ne uporabite v roku enega tedna,
+ bo ta potekla in morali boste zahtevati novo.
+ intro: 'Nekdo je zahteval ponastavitev vašega gesla. Svoje geslo lahko spremenite
+ tako, da sledite slednji povezavi in vnesete novo geslo:'
+ link_title: Spremeni moje geslo.
+ subject: "[%{app_name}] Ponastavite svoje geslo"
+ unrequested: Če ponastavitve gesla niste zahtevali, lahko prezrete to elektronsko
+ sporočilo in vaše trenutno geslo bo ostalo veljavno.
+ user_mailer:
+ admin_deleted_work_notification:
+ bye: V priponki lahko najdete kopijo svojega dela.
+ contact_abuse: stopite v stik z Odborom za pravila in zlorabo.
+ deleted:
+ html: Administrator je vaše delo %{title} odstranil iz AO3.
+ text: Administrator je vaše delo %{title} odstranil iz AO3.
+ html:
+ tos_violation: Če je vaše delo morda kršilo Pogoje uporabe AO3, prosimo %{contact_abuse_link}.
+ import_project:
+ html: Če je bilo vaše delo del uvoženega projekta pod nadzorom naše ekipe
+ Open Doors (Odprta vrata), prosimo %{opendoors_link} z vsemi nadaljnjimi
+ vprašanji.
+ text: Če je bilo vaše delo del uvoženega projekta pod nadzorom naše ekipe
+ Open Doors (Odprta vrata), prosimo kontaktirajte Odprta vrata (%{opendoors_link})
+ z vsemi nadaljnjimi vprašanji.
+ opendoors: kontaktirajte Odprta vrata
+ subject: "[%{app_name}] Administrator je odstranil vaše delo"
+ text:
+ tos_violation: Če je vaše delo morda kršilo Pogoje uporabe AO3, prosimo stopite
+ v stik z Odborom za pravila in zlorabo (%{contact_abuse_url}).
+ admin_hidden_work_notification:
+ access: Medtem ko je vaše delo skrito, do njega še vedno lahko dostopate preko
+ zgornje povezave; ne bo pa navedeno na vaši strani z deli in ne bo dostopno
+ drugim uporabnikom AO3-ja.
+ check_email: Prosimo preverite vašo elektronsko pošto, vključno s predalom za
+ vsiljeno pošto, saj je ekipa za pravila in zlorabo morda že stopila v stik
+ z vami in razložila, zakaj je bilo vaše delo skrito.
+ contact_abuse: stopite v stik z Ekipo za pravila in zlorabo
+ html:
+ help: Če ne veste, zakaj je bilo vaše delo skrito in niste dobili nobenega
+ nadaljnjega sporočila o tej zadevi, prosimo %{contact_abuse_link}.
+ hidden: Ekipa za pravila in zlorabo je skrila vaše delo %{title}, ki zdaj
+ ni več javno dostopno.
+ tos_violation: Če je bilo vaše delo skrito, ker je kršilo %{tos_link} AO3,
+ boste morali to kršitev odpraviti. Če tega ne storite, bo vaše delo morda
+ odstranjeno iz AO3.
+ subject: "[%{app_name}] Ekipa za pravila in zlorabo je skrila vaše delo"
+ text:
+ help: 'Če ne veste, zakaj je bilo vaše delo skrito in niste dobili nobenega
+ nadaljnjega sporočila o tej zadevi, prosimo kontaktirajte Ekipo za pravila
+ in zlorabo: %{contact_abuse_url}.'
+ hidden: Ekipa za pravila in zlorabo je skrila vaše delo "%{title}" (%{work_url}),
+ ki zdaj ni več javno dostopno.
+ tos_violation: Če je bilo vaše delo skrito, ker je kršilo Pogoje uporabe (%{tos_url})
+ AO3, boste morali to kršitev odpraviti. Če tega ne storite, bo vaše delo
+ morda odstranjeno iz AO3.
+ tos: Pogoje uporabe
+ anonymous_or_unrevealed_notification:
+ anonymous_info: Anonimna dela so vključena na sezname oznak, ne pa na vaši strani
+ z deli. Na delu bo vaše uporabniško ime nadomeščeno z "Anonymous" (Anonimno).
+ anonymous_unrevealed_info: Vzdrževalci zbirke bodo morda kasneje razkrili vaše
+ delo in ga hkrati pustili anonimnega. Kdor je naročen na vaše vsebine o tej
+ spremembi ne bo obveščen. Ko je delo enkrat razkrito, bo prikazano na seznamih
+ oznak, vendar ga ne bo na vaši strani z deli. Na delu bo vaše uporabniško
+ ime nadomeščeno z "Anonymous" (Anonimno).
+ changed_status:
+ anonymous:
+ html: Vzdrževalci zbirke %{collection_link} so spremenili status vašega
+ dela %{work_link} v anonimnega.
+ text: Vzdrževalci zbirke "%{collection_title}" (%{collection_url}) so status
+ vašega dela "%{work_title}" (%{work_url}) spremenili v anonimnega.
+ anonymous_unrevealed:
+ html: Vzdrževalci zbirke %{collection_link} so spremenili status vašega
+ dela %{work_link} v anonimnega in nerazkritega.
+ text: Vzdrževalci zbirke "%{collection_title}" (%{collection_url}) so status
+ vašega dela "%{work_title}" (%{work_url}) spremenili v anonimnega in nerazkritega.
+ unrevealed:
+ html: Vzdrževalci zbirke %{collection_link} so spremenili status vašega
+ dela %{work_link} v nerazkritega.
+ text: Vzdrževalci zbirke "%{collection_title}" (%{collection_url}) so status
+ vašega dela "%{work_title}" (%{work_url}) spremenili v nerazkritega.
+ collection_items_link_text: stran Approved Collection Items (Odobreni elementi
+ v zbirki)
+ do_not_want:
+ anonymous:
+ html: Če ne želite, da je vaše delo anonimno, prosimo obiščite vašo %{collection_items_link},
+ da ga odstranite iz te zbirke.
+ text: 'Če ne želite, da je vaše delo anonimno, obiščite stran Approved Collection
+ Items (Odobreni elementi v zbirki), da ga odstranite iz te zbirke: %{collection_items_url}'
+ anonymous_unrevealed:
+ html: Če ne želite, da je vaše delo anonimno in nerazkrito, obiščite vašo
+ %{collection_items_link}, da ga odstranite iz te zbirke.
+ text: 'Če ne želite, da je vaše delo anonimno in nerazkrito, obiščite stran
+ Approved Collection Items page (Odobreni elementi v zbirki), da ga odstranite
+ iz te zbirke: %{collection_items_url}'
+ unrevealed:
+ html: Če ne želite, da je vaše delo nerazkrito, obiščite vašo %{collection_items_link},
+ da ga odstranite iz te zbirke.
+ text: 'Če ne želite, da je vaše delo narazkrito, obiščite stran Approved
+ Collection Items (Odobreni elementi v zbirki), da ga odstranite iz te
+ zbirke: %{collection_items_url}'
+ faq_link_text: Pogosta vprašanja o zbirkah
+ more_info:
+ html: Za več informacij obiščite naša %{faq_link}.
+ text: 'Za več informacij obiščite naša pogosta vprašanja o zbirkah: %{faq_url}'
+ subject:
+ anonymous: "[%{app_name}] Vaše delo je postalo anonimno"
+ anonymous_unrevealed: "[%{app_name}] Vaše delo je postalo anonimno in nerazkrito"
+ unrevealed: "[%{app_name}] Vaše delo je postalo nerazkrito"
+ unrevealed_info: Nerazkrita dela niso vključena v sezname oznak ali navedena
+ na strani vaših del. Kdorkoli sledi povezavi do dela, bo prejel obvestilo,
+ da je delo trenutno nerazkrito in da dostop do vsebine ni mogoč.
+ archivist_added_to_collection_notification:
+ approved_collection_items_page: Approved Collection Items (odobrena dela v zbirkah)
+ archivist_notice: Ker vzdrževalci_ke zbirke arhivirajo v imenu Open Doors (Odprtih
+ vrat), lahko vaše delo dodajo tej zbirki, tudi če ste onemogočili povabila
+ v zbirke. Dela se na ta način zbirkam dodajajo samo, če jih je prej gostil
+ arhiv, ki smo ga uvozili na AO3.
+ removal_instructions:
+ html: Če želite delo iz te zbirke odstraniti, prosimo, obiščite svojo stran
+ %{approved_items_link}.
+ text: 'Če želite delo iz te zbirke odstraniti, prosimo, obiščite svojo stran
+ Approved Collection Items (odobrena dela v zbirkah): %{approved_items_url}.'
+ subject: "[%{app_name}][%{collection_title}] Arhivist_ka Open Doors (Odprtih
+ vrat) je vaše delo dodal_a zbirki"
+ work_added:
+ html: Vzdrževalci_ke zbirke %{collection_link} so vaše delo %{work_link} dodali_e
+ svoji zbirki!
+ text: Vzdrževalci zbirke "%{collection_title}" (%{collection_url}) so vaše
+ delo "%{work_title}" (%{work_url}) dodali svoji zbirki!
+ challenge_assignment_notification:
+ any: Po želji
+ assignment:
+ html: Dodeljena vam je bila naslednja zahteva v izzivu %{link} na AO3!
+ description: 'Opis:'
+ due: 'Rok za oddajo naloge:'
+ html:
+ footer: To sporočilo ste prejeli, ker ste se prijavili na izziv %{title}.
+ Za več informacij o tem izzivu in za kontaktne podatke moderatork_jev, prosimo,
+ obiščite %{footer_link}.
+ footer_link: profilno stran izziva
+ look_up: Nalogo si lahko ogledate na %{link}.
+ look_up_link: svoji strani Assignments (Naloge)
+ optional_tags: 'Neobvezne oznake:'
+ prompts: 'Iztočnice (prompti):'
+ prompt_url: 'URL iztočnice (prompta):'
+ recipient: 'Prejemnik_ca:'
+ recipient_missing: 'Nihče: kontaktirajte moderatorja_ko za pomoč!'
+ subject: "[%{app_name}][%{collection_title}] Vaša naloga!"
+ text:
+ assignment: Dodeljena vam je bila naslednja naloga v izzivu "%{collection_title}"
+ (%{collection_url}) na AO3!
+ footer: To sporočilo ste prejeli, ker ste se prijavili na izziv %{title} (%{url}).
+ Za več informacij o tem izzivu in za kontaktne podatke moderatorjev, prosimo,
+ obiščite %{profile_url}.
+ look_up: Nalogo si lahko ogledate na svoji strani Assignments (Naloge) na
+ %{link}.
+ change_email:
+ changed:
+ html: "%{login}, elektronski naslov povezan z vašim računom je bil spremenjen
+ v %{email}"
+ text: "%{login}, elektronski naslov povezan z vašim računom je bil spremenjen
+ v %{email}"
+ subject: "[%{app_name}] Sprememba elektronskega naslova"
+ claim_notification:
+ access:
+ contact_support: podporo AO3
+ html: Od posameznega arhiva je odvisno, ali bodo dela kot privzeto na voljo
+ zgolj prijavljenim uporabnikom (v izogib pojavljanja med zadetki iskanja
+ v Googlu). V tem primeru bodo dela na voljo le prijavljenim članom, razen
+ če se odločite, da jih naredite vidne vsem uporabnikom. Za pomoč pri spreminjanju
+ vidnosti, opuščanju ali brisanju del se obrnite na %{contact_support_link}.
+ text: Od posameznega arhiva je odvisno, ali bodo dela kot privzeto na voljo
+ zgolj prijavljenim uporabnikom (v izogib pojavljanja med zadetki iskanja
+ v Googlu). V tem primeru bodo dela na voljo le prijavljenim članom, razen
+ če se odločite, da jih naredite vidne vsem uporabnikom. Za pomoč pri spreminjanju
+ vidnosti, opuščanju ali brisanju del se obrnite na podporo AO3 na %{support_url}.
+ email_tips: Če se nam odločite pisati, prosimo, dodajte @transformativeworks.org
+ med zaupanja vredne stike in za našim odgovorom pobrskajte po mapi z vsiljeno
+ pošto.
+ introduction:
+ ao3_name: Archive of Our Own – AO3 (Naš lastni arhiv)
+ html: To e-poštno sporočilo ste prejeli, ker so vaša dela gostovala na arhivu
+ fanovskih del, ki so ga na %{app_link} uvozila %{open_doors_name_link}.
+ Ker je ta e-poštni naslov povezan z e-poštnim naslovom, registriranim na
+ uvoženem arhivu, smo fanovska dela, ki ste jih z njim naložili (našteta
+ spodaj), avtomatično dodali vašemu računu AO3.
+ open_doors_name: Open Doors (Odprta vrata)
+ text: To e-poštno sporočilo ste prejeli, ker so vaša dela gostovala na arhivu
+ fanovskih del, ki so ga na Archive of Our Own – AO3 (Naš lastni arhiv) (%{app_url})
+ uvozila Open Doors (Odprta vrata) (%{open_doors_url}). Ker je ta e-poštni
+ naslov povezan z e-poštnim naslovom, registriranim na uvoženem arhivu, smo
+ fanovska dela, ki ste jih z njim naložili (našteta spodaj), avtomatično
+ dodali vašemu računu AO3.
+ mistake:
+ contact_open_doors: stopite v stik z Odprtimi vrati
+ html: Če je prišlo do pomote in dela niso vaša, jih nikar ne brišite. Prosimo,
+ da %{contact_open_doors_link}, ki vam bodo priskočila na pomoč.
+ text: Če je prišlo do pomote in dela niso vaša, jih nikar ne brišite. Prosimo,
+ da stopite v stik z Odprtimi vrati (%{open_doors_url}), ki vam bodo priskočila
+ na pomoč.
+ more_info:
+ ao3_news: novicah AO3
+ contact_support: podporo AO3
+ faq_page: pogostimi vprašanji
+ html: O aktualnih uvozih arhivov si lahko preberete v %{ao3_news_link}, dodatne
+ informacije pa poiščete med %{faq_page_link} o Odprtih vratih ali na %{tutorial_page_link}.
+ Vsa vprašanja, na katera ne najdete odgovora v pogostih vprašanjih, vodičih
+ ali tem e-poštnem sporočilu, lahko naslovite na %{contact_support_link}.
+ text: O aktualnih uvozih arhivov si lahko preberete v novicah AO3 (%{news_url}),
+ dodatne informacije pa poiščete med pogostimi vprašanji o Odprtih vratih
+ (%{open_doors_faq_url}) ali na strani z vodičem (%{open_doors_tutorial_url}).
+ Vsa vprašanja, na katera ne najdete odgovora v pogostih vprašanjih, vodičih
+ ali tem e-poštnem sporočilu, lahko naslovite na podporo uporabnikom na %{support_url}.
+ tutorial_page: strani z vodičem
+ other_works:
+ contact_open_doors: stopite v stik z Odprtimi vrati
+ html: Če ste imeli na uvoženem arhivu pod e-poštnim naslovom, do katerega
+ ste izgubili dostop, objavljena druga dela, prosimo, %{contact_open_doors_link}
+ z vsakršno informacijo, ki nam bo pomagala overiti vašo identiteto.
+ text: Če ste imeli na uvoženem arhivu pod e-poštnim naslovom, do katerega
+ ste izgubili dostop, objavljena druga dela, prosimo, stopite v stik z Odprtimi
+ vrati z vsakršno informacijo, ki nam bo pomagala overiti vašo identiteto.
+ questions:
+ contact_support: podporo AO3
+ html: Ostala vprašanja lahko naslovite na %{contact_support_link}.
+ text: Ostala vprašanja lahko naslovite na podporo uporabnikom AO3 na %{support_url}.
+ redirects:
+ html: Da se ohranijo seznami priporočil in zaznamki, bodo spletni naslovi
+ uvoženega arhiva nekaj časa morda preusmerjeni na uvožene kopije teh del
+ (če se želite prepričati o tem, si preberite oznanilo o uvozu za vaš arhiv).
+ Če ste kopije zgoraj navedenih del na AO3 že naložili sami in jih %{negation}
+ uvozili prek hiperpovezave, bosta na AO3 obstajali po dve kopiji vsakega
+ posameznega dela.
+ subject: Dela uvožena na [%{app_name}]
+ update_redirect:
+ contact_open_doors: Stopite v stik z Odprtimi vrati
+ html: Če želite, da Odprta vrata namesto na novo kopijo preusmerjajo na to,
+ ki ste jo že naložili, prosimo, izbrišite neželeno novo in %{contact_open_doors_link}
+ z vašima uporabniškima računoma na AO3 ter na uvoženem arhivu in z naslovom
+ dela ter hiperpovezavo, na katero naj po novem vodi preusmeritev. (Če želite
+ preusmeritve spremeniti za več del, jih lahko naštejete v enem samem e-poštnem
+ sporočilu.)
+ text: Če želite, da Odprta vrata namesto na novo kopijo preusmerjajo na to,
+ ki ste jo že naložili, prosimo, izbrišite neželeno novo in prek %{open_doors_url}
+ stopite v stik z Odpritimi vrati z vašima uporabniškima računoma na AO3
+ ter na uvoženem arhivu in z naslovom dela ter hiperpovezavo, na katero naj
+ po novem vodi preusmeritev. (Če želite preusmeritve spremeniti za več del,
+ jih lahko naštejete v enem samem e-poštnem sporočilu.)
+ works_by: Sledeča dela so bila povezana z e-poštnim naslovom %{email}
+ work_info:
+ html: "%{work_link} (%{fandom})"
+ text:
+ no_fandom: "- %{work_title} %{work_url}"
+ with_fandom: "- %{work_title} %{work_url} (%{fandom})"
+ collection_notification:
+ assignments_sent:
+ complete: Vse naloge so bile poslane.
+ subject: Naloge so bile poslane
+ html:
+ received_message: 'Prejeli ste obvestilo glede svoje zbirke %{collection_link}:'
+ text:
+ received_message: 'Prejeli ste obvestilo glede svoje zbirke "%{collection_title}"
+ (%{collection_url}):'
+ creatorship_notification:
+ explanation: Kot soustvarjalec_ka dela ste lahko dodani v nova poglavja ne glede
+ na vaše nastavitve o soustvarjalstvu. Dodani boste tudi v vse serije, v katere
+ je delo dodano.
+ html:
+ creation: "%{creation_link} od %{pseud_links}"
+ edit_chapter: uredite poglavje
+ edit_series: uredite serijo
+ remove_chapter: Če ste bili dodani po pomoti ali ne želite biti navedeni kot
+ ustvarjalec_ka, lahko %{edit_chapter_link} in se odstranite.
+ remove_series: Če ste bili dodani po pomoti ali ne želite biti navedeni kot
+ ustvarjalec_ka, %{edit_series_link} in se odstranite.
+ intro_chapter: 'Uporabnik_ca %{adding_user} je dodal_a vaš pseudonim %{pseud}
+ kot soustvarjalca_ko sledečega poglavja:'
+ intro_series: 'Uporabnik_ca %{adding_user} je vaš psevdonim %{pseud} navedel_a
+ kot soustvarjalca_ko naslednje serije:'
+ subject: "[%{app_name}] Obvestilo o soustvarjalstvu"
+ text:
+ creation: "%{title} (%{url}) od %{pseuds}"
+ remove_chapter: 'Če ste bili dodani po pomoti ali ne želite biti navedeni
+ kot ustvarjalec_ka, lahko uredite poglavje in odstranite navedbo: %{url}'
+ remove_series: Če ste bili dodani po pomoti ali ne želite biti navedeni kot
+ ustvarjalec_ka, lahko uredite serijo in odstranite navedbo %{url}
+ creatorship_notification_archivist:
+ explanation: Ker deluje v imenu Open Doors (Odprtih vrat), vas arhivist_ka lahko
+ doda brez povabila, tudi če imate soustvarjanje onemogočeno.
+ html:
+ creation: "%{creation_link} od %{pseud_links}"
+ edit_chapter: poglavje uredite
+ edit_series: serijo uredite
+ edit_work: delo uredite
+ remove_chapter: Če ste bili dodani po pomoti ali ne želite biti navedeni kot
+ ustvarjalec_ka, lahko %{edit_chapter_link} in se odstranite.
+ remove_series: Če ste bili dodani po pomoti ali ne želite biti navedeni kot
+ ustvarjalec_ka, lahko %{edit_series_link} in se odstranite.
+ remove_work: Če ste bili dodani po pomoti ali ne želite biti navedeni kot
+ ustvarjalec_ka, lahko %{edit_work_link} in se odstranite.
+ intro_chapter: 'Uporabnik_ca %{archivist} je dodal_a vaš psevdonim %{pseud}
+ kot soustvarjalca_ko sledečega poglavja:'
+ intro_series: 'Uporabnik_ca %{archivist} je dodal_a vaš psevdonim %{pseud} kot
+ soustvarjalca_ko sledeče serije:'
+ intro_work: 'Uporabnik_ca %{archivist} je dodal_a vaš psevdonim %{pseud} kot
+ soustvarjalko_ca sledečega dela:'
+ subject: Obvestilo arhivista_ke [%{app_name}] o soustvarjalstvu
+ text:
+ creation: "%{title} (%{url}) od %{pseuds}"
+ remove_chapter: 'Če ste bili dodani po pomoti ali ne želite biti navedeni
+ kot ustvarjalec, lahko uredite poglavje in se odstranite: %{url}'
+ remove_series: 'Če ste bili dodani po pomoti ali ne želite biti navedeni kot
+ ustvarjalec, lahko uredite serijo in se odstranite: %{url}'
+ remove_work: 'Če ste bili dodani po pomoti ali ne želite biti navedeni kot
+ ustvarjalec, lahko uredite delo in se odstranite: %{url}'
+ creatorship_request:
+ html:
+ creation: "%{creation_link}, avtorstvo: %{pseud_links}"
+ instructions: Prošnjo lahko sprejmete ali zavrnete na vaši strani %{page_name}.
+ page_name: Co-Creator Requests (Prošnje za status soustvarjalca)
+ intro_chapter: 'Uporabnik_ca %{inviting_user} je povabil_a vaš psevdonim %{pseud}
+ za navedbo kot soustvarjalca_ko sledečega poglavja:'
+ intro_series: 'Uporabnik_ca %{inviting_user} je povabil_a vaš psevdonim %{pseud}
+ za navedbo kot soustvarjalca_ko sledeče serije:'
+ intro_work: 'Uporabnik_ca %{inviting_user} je povabil_a vaš psevdonim %{pseud}
+ za navedbo kot soustvarjalca_ko sledečega dela:'
+ subject: "[%{app_name}] Prošnja za status soustvarjalca"
+ text:
+ creation: "%{title} (%{url}), avtorstvo: %{pseuds}"
+ instructions: 'Prošnjo lahko sprejmete ali zavrnete na vaši strani Co-Creator
+ Requests (Prošnje za status soustvarjalca): %{url}'
+ delete_work_notification:
+ attachment: V priponki lahko najdete kopijo svojega dela.
+ deleted_other:
+ html: Vaše delo %{title} je bilo izbrisano na zahtevo %{pseud}.
+ text: Vaše delo "%{title}" je bilo izbrisano na zahtevo %{pseud}.
+ deleted_yourself:
+ html: Vaše delo %{title} je bilo izbrisano na vašo zahtevo.
+ text: Vaše delo "%{title}" je bilo izbrisano na vašo zahtevo.
+ questions:
+ html: Če imate vprašanja, prosimo, %{support}.
+ text: Če imate vprašanja, prosimo, %{support} (%{url}).
+ subject: "[%{app_name}] Vaše delo je bilo izbrisano"
+ support: kontaktirajte pomoč uporabnikom
+ invitation_to_claim:
+ access:
+ text: Glede na arhiv so bila vaša dela morda uvožena tako, da so dostopna
+ samo registriranim uporabnikom (da niso dostopna na google iskanju). V tem
+ primeru so ta dela dostopna samo prijavljenim uporabnikom, razen če se odločite,
+ da bodo popolnoma vidna. Če potrebujete pomoč pri odklepanju, opuščanju
+ ali izbrisanju vašim del, prosimo, kontaktirajte AO3 podporo uporabnikom.
+ claim_or_remove:
+ html: Kliknite tu, da si prilastite ali odstranite svoja dela.
+ text: 'Kliknite tu, da si prilastite ali odstranite svoja dela: %{claim_url}'
+ email_tips: 'Če nas kontaktirate, prosimo dodajte e-mail naslove @transformativeworks.org
+ na seznam dovoljenih naslovov in preverite, če je bil naš odgovor preusmerjen
+ v vašo mapo z vsiljeno pošto. '
+ html:
+ ao3_news: AO3 novice
+ contact_open_doors: kontaktirajte Odprta vrata
+ contact_support: kontaktirajte podporo uporabnikom AO3
+ faq_page: Pogosta vprašanja
+ tutorial_page: vodič
+ introduction:
+ text: Ta e-mail ste prejeli, ker so pred kratkim pri Odprtih vratih (%{open_doors_link})
+ uvozili arhiv v %{app_name} (%{app_short_name} - %{app_url}). Naslednja
+ fan-dela naj bi pripadala vam. Radi bi vam dali priložnost, da si ta dela,
+ če tako želite, prilastite (ali da jih izbrišete oz. da jih opustite). Če
+ še nimate uporabniškega računa pod drugim e-mailom, bi vas radi povabili,
+ da se nam pridružite!
+ mistake:
+ text: Če je prišlo do napake in ta dela niso vaša, vas naprošamo, da jih ne
+ izbrišite! Prosimo, kontaktirajte Odprta vrata (%{open_doors_link}) in razrešili
+ bomo težavo.
+ more_info:
+ text: Obvestila o nedavnih premikih arhivov lahko preberete na AO3 novicah
+ (%{news_link}), dodatne informacije pa lahko najdete na strani Odprtih vratih
+ s pogostimi vprašanji (%{open_doors_faq_link}) ali v vodiču (%{open_doors_tutorial_link}).
+ Za vprašanja, na katere ni odgovora na strani z pogostimi vprašanji, v vodiču
+ ali v tem e-mailu, prosimo, kontaktirajte podporo uporabnikom na %{support_link}.
+ other_works:
+ text: Če ste imeli na uvoženem arhivu druga dela pod e-poštnim naslovom, do
+ katerega nimate več dostopa, prosimo, kontaktirajte Odprta vrata z kakršnimi
+ koli informacijami, ki bi nam lahko pomagale potrditi vašo identiteto.
+ questions:
+ text: Za druga vprašanja, prosimo, kontaktirajte AO3 podporo uporabnikom na
+ %{support_link}.
+ redirects: Zato, da ohranjamo sezname priporočil in zaznamke, lahko spletni
+ naslovi uvoženega arhiva za določen čas preusmerijo na uvoženo kopijo teh
+ del (da se prepričate o tem, preverite objavo z obvestilom za vaš arhiv).
+ Če ste že naložili kopijo teh del in NISTE uporabili funkcije uvoza iz URL-ja,
+ bosta v arhivu dve kopiji istega dela.
+ subject: "[%{app_name}] Povabilo k zahtevku za lastništvo dela"
+ unwanted:
+ text: Če so ta dela vaša, ampak jih ne želite lastiti, jih lahko opustite
+ oz. osirotite (tako bodo ostala na AO3, ampak brez vašega imena). Če želite
+ opustiti ali popolnoma izbrisati ta dela, za to ne potrebujete dodati nobenega
+ uporabniškega računa, temveč lahko to storite direktno na zgornji povezavi
+ (za pomoč, prosimo, kontaktirajte podporo uporabnikom na %{support_link}).
+ update_redirect:
+ text: Če želite, da Odprta vrata posodobijo preusmeritev, da kaže na vaše
+ obstoječe delo, izbrišite uvoženo kopijo in kontaktirajte Odprta vrata na
+ %{open_doors_link} z imenom vašega AO3 računa, z imenom računa na uvoženem
+ arhivu in z naslovom ter URL-jem fan-dela, kamor želite, da vodi preusmeritev.
+ (Če imate več del, za katera bi radi spremenili preusmeritve, jih lahko
+ navedete v enem e-mailu.)
+ uploaded_list: 'Objavljena dela vključujejo:'
+ invite_increase_notification:
+ html:
+ body:
+ few: Sporočamo vam, da imate %{count} nova vabila za ustvarjanje novega
+ uporabniškega računa na AO3. Podarite jih lahko preko %{invitation_page_link}.
+ one: Sporočamo vam, da imate %{count} novo vabilo za ustvarjanje novega
+ uporabniškega računa na AO3. Podarite ga lahko preko %{invitation_page_link}.
+ other: Sporočamo vam, da imate %{count} novih vabil za ustvarjanje novega
+ uporabniškega računa na AO3. Podarite jih lahko preko %{invitation_page_link}.
+ two: Sporočamo vam, da imate %{count} novi vabili za ustvarjanje novega
+ uporabniškega računa na AO3. Podarite ju lahko preko %{invitation_page_link}.
+ invitation_page_link_text: vaše strani Invitations (Vabila)
+ subject: "[%{app_name}] Nova vabila"
+ text:
+ body:
+ few: Sporočamo vam, da imate %{count} nova vabila za ustvarjanje novega
+ uporabniškega računa na AO3. Podarite jih lahko preko %{invitation_page_url}.
+ one: Sporočamo vam, da imate %{count} novo vabilo za ustvarjanje novega
+ uporabniškega računa na AO3. Prijatelje lahko povabite preko vaše strani
+ Invitations (Vabila) (%{invitation_page_url}).
+ other: Sporočamo vam, da imate %{count} novih vabil za ustvarjanje novega
+ uporabniškega računa na AO3. Podarite jih lahko preko %{invitation_page_url}.
+ two: Sporočamo vam, da imate %{count} novi vabili za ustvarjanje novega
+ uporabniškega računa na AO3. Prijatelje lahko povabite preko vaše strani
+ Invitations (Vabila) (%{invitation_page_url}).
+ invite_request_declined:
+ main:
+ few: Žal vam sporočamo, da vam trenutno ne moremo posredovati %{count} kod
+ za povabila.
+ one: Žal vam sporočamo, da vam trenutno ne moremo posredovati %{count} nove
+ kode za povabilo.
+ other: Žal vam sporočamo, da vam trenutno ne moremo posredovati %{count} kod
+ za povabila.
+ two: Žal vam sporočamo, da vam trenutno ne moremo posredovati %{count} kod
+ za povabila.
+ reason: 'Vaša prošnja:'
+ subject: "[%{app_name}] Prošnja za dodatne kode za povabila zavrnjena"
+ recipient_notification:
+ html:
+ collection: Na AO3 je bilo v zbirki %{collection_link} objavljeno delo, ki
+ vam je bilo podarjeno!
+ no_collection: Na AO3 je bilo objavljeno delo, ki vam je bilo podarjeno!
+ subject:
+ collection: "[%{app_name}][%{collection_title}] Podarjeno delo za vas iz %{collection_title}"
+ no_collection: "[%{app_name}] Podarjeno delo za vas"
+ text:
+ collection: Na AO3 je bilo v zbirki "%{collection_title}" (%{collection_url})
+ objavljeno delo, ki vam je bilo podarjeno!
+ signup_notification:
+ activate:
+ html: Prosimo, %{activate_account_link}.
+ text: 'Prosimo, kliknite na naslednji link, da aktivirate svoj račun: %{activate_account_url}.'
+ activate_your_account: kliknite na naslednjo povezavo, da aktivirate vaš račun
+ admin_posts: AO3 Novice
+ bye: Upamo, da boste uživali v uporabi AO3.
+ contact_support: kontaktirajte pomoč uporabnikom
+ faq: pogostih vprašanjih
+ features:
+ html: Ko aktivirate svoj račun, lahko objavljate svoja fan-dela, nastavite
+ e-poštne naročnine, da boste obveščeni o najnovejših posodobitvah vaših
+ najljubših ustvarjalcev in del, nastavite preference, da si prilagodite
+ delovanje in izgled strani, vodite evidenco del, do katerih ste dostopali
+ na AO3 preko vaše zgodovine, in še veliko več.
+ text: Ko aktivirate svoj račun, lahko objavljate svoja fan-dela, nastavite
+ e-poštne naročnine, da boste obveščeni o najnovejših posodobitvah vaših
+ najljubših ustvarjalcev in del, nastavite preference, da si prilagodite
+ delovanje in izgled strani, vodite evidenco del, do katerih ste dostopali
+ na AO3 preko vaše zgodovine, in še veliko več.
+ information:
+ html: Veliko informacij in nasvetov o tem kako uporabljati AO3 najdete v naših
+ %{faq_link}. Najnovejše novice o razvoju strani najdete v %{admin_posts_link}.
+ Če potrebujete pomoč, naletite na napako, ali imate vprašanja ali komentarje,
+ prosimo, %{contact_support_link}, katere člani so vedno na voljo, da vam
+ pomagajo.
+ text: Veliko informacij in nasvetov o tem kako uporabljati AO3 najdete na
+ strani s pogostimi vprašanji v %{faq_url}. Najnovejše informacije o razvoju
+ strani AO3 Novic najdete v %{admin_posts_url}. Če potrebujete pomoč, naletite
+ na napako, ali imate vprašanja ali komentarje, prosimo, %{contact_support_url},
+ katere člani so vedno na voljo, da vam pomagajo.
+ welcome: Pozdravljeni v Archive of Our Own, %{login}!
diff --git a/config/locales/phrase-exports/sv.yml b/config/locales/phrase-exports/sv.yml
new file mode 100755
index 0000000..0c3432b
--- /dev/null
+++ b/config/locales/phrase-exports/sv.yml
@@ -0,0 +1,617 @@
+---
+sv:
+ activerecord:
+ attributes:
+ archive_warning:
+ name_with_colon:
+ one: 'Varning:'
+ other: 'Varningar:'
+ category:
+ name_with_colon:
+ one: 'Kategori:'
+ other: 'Kategorier:'
+ character:
+ name_with_colon:
+ one: 'Karaktär:'
+ other: 'Karaktärer:'
+ fandom:
+ name_with_colon:
+ one: 'Fandom:'
+ other: 'Fandoms:'
+ freeform:
+ name_with_colon:
+ one: 'Ytterligare tagg:'
+ other: 'Ytterligare taggar:'
+ rating:
+ name_with_colon: 'Åldersgräns:'
+ relationship:
+ name_with_colon:
+ one: 'Relation:'
+ other: 'Relationer:'
+ work:
+ chapter_total_display: Kapitel
+ summary: Sammanfattning
+ models:
+ archive_warning:
+ one: Varning
+ other: Varningar
+ category:
+ one: Kategori
+ other: Kategorier
+ chapter:
+ one: Kapitel
+ other: Kapitel
+ character:
+ one: Karaktär
+ other: Karaktärer
+ fandom:
+ one: Fandom
+ other: Fandoms
+ freeform:
+ one: Ytterligare tagg
+ other: Ytterligare taggar
+ rating:
+ one: Åldersgräns
+ other: Åldersgränser
+ relationship:
+ one: Relation
+ other: Relationer
+ series:
+ one: Serie
+ other: Serier
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ one: en gäst
+ other: "%{count} gäster"
+ left_kudos:
+ html:
+ one: "%{givers_list} berömde %{commentable_link}."
+ other: "%{givers_list} berömde %{commentable_link}."
+ text:
+ one: "%{givers_list} berömde %{commentable_title} (%{commentable_url})."
+ other: "%{givers_list} berömde %{commentable_title} (%{commentable_url})."
+ single_guest:
+ giver: En gäst
+ html: "%{giver} berömde %{commentable_link}."
+ text: En gäst berömde %{commentable_title} (%{commentable_url}).
+ subject: "[%{app_name}] Du har fått beröm!"
+ mailer:
+ general:
+ closing:
+ formal: Med vänliga hälsningar,
+ informal: Vänliga hälsningar,
+ creation:
+ link_with_word_count: "%{creation_link} (%{word_count})"
+ title_with_chapter_number: Kapitel %{position} av %{title}
+ title_with_word_count: '"%{creation_title}" (%{word_count})'
+ word_count:
+ one: "%{count} ord"
+ other: "%{count} ord"
+ footer:
+ general:
+ about:
+ html: AO3 är ett arkiv som drivs och stöds av fans och som är beroende
+ av %{donate_link}.
+ text: 'AO3 är ett arkiv som drivs och stöds av fans och som är beroende
+ av era gåvor: %{donate_url}.'
+ html:
+ donate_link_text: era gåvor
+ support_link_text: kontakta Support
+ unwanted_email:
+ html: Om du felaktigt har fått detta meddelande, vänligen %{support_link}.
+ text: Om du felaktigt har fått detta meddelande, vänligen kontakta Support
+ på %{support_url}.
+ sent_at: Skickat %{sent_at}.
+ greeting:
+ formal_html: Bästa %{name},
+ informal:
+ addressed_html: Hej, %{name}!
+ unaddressed: Hej!
+ introductory: Hej från Archive of Our Own – AO3 (Vårt eget arkiv)!
+ metadata_label_indicator: ":"
+ signature:
+ abuse_team: AO3:s team för Policy och missbruk
+ app_short_name: AO3
+ open_doors: Open Doors (Öppna dörrar)-teamet
+ parent_org: Organization for Transformative Works – OTW (Organisationen för
+ transformativa verk)
+ support: AO3:s Support-team
+ users:
+ mailer:
+ reset_password_instructions:
+ expiration: Om du inte använder den här länken för att återställa ditt lösenord
+ inom en vecka kommer den att gå ut och du behöver begära en ny länk.
+ intro: 'Någon har begärt en återställning av ditt kontos lösenord. Du kan
+ ändra ditt lösenord genom att följa länken nedan och skriva in ditt nya
+ lösenord:'
+ link_title: Ändra mitt lösenord.
+ subject: "[%{app_name}] Återställ ditt lösenord"
+ unrequested: Om det inte var du som begärde återställning av ditt lösenord
+ så kan du bortse från det här mailet. Ditt tidigare lösenord kommer att
+ fortsätta fungera.
+ user_mailer:
+ admin_deleted_work_notification:
+ bye: Som hänvisning har vi bifogat en kopia av ditt verk.
+ contact_abuse: kontakta vår kommitté för Policy och missbruk
+ deleted:
+ html: Ditt verk %{title} har blivit raderat från AO3 av en av webbplatsens
+ administratörer.
+ text: Ditt verk "%{title}" har blivit raderat från AO3 av en av webbplatsens
+ administratörer.
+ html:
+ tos_violation: Om det är möjligt att ditt verk bröt mot AO3:s användarvillkor,
+ var vänlig %{contact_abuse_link}.
+ import_project:
+ html: Om ditt verk var en del av ett importprojekt som hanteras av vårt Öppna
+ dörrar-team, var vänlig %{opendoors_link} om du har frågor.
+ text: Om ditt verk var en del av ett importprojekt som hanteras av vårt Öppna
+ dörrar-team, var vänlig kontakta Öppna dörrar (%{opendoors_link}) om du
+ har frågor.
+ opendoors: kontakta Öppna dörrar
+ subject: "[%{app_name}] Ditt verk har blivit raderat av en administratör"
+ text:
+ tos_violation: Om det är möjligt att ditt verk bröt mot AO3:s användarvillkor,
+ var vänlig kontakta vår kommitté för Policy och missbruk (%{contact_abuse_url}).
+ admin_hidden_work_notification:
+ access: Fastän ditt verk är dolt kan du fortfarande nå det genom länken ovan,
+ men det kommer inte att finnas med på listan över dina verk och det är inte
+ tillgängligt för andra som använder AO3.
+ check_email: Var vänlig kolla din e-post, inklusive din mapp för skräppost,
+ eftersom teamet för Policy och missbruk redan kan ha kontaktat dig för att
+ förklara varför ditt verk är dolt.
+ contact_abuse: kontakta Policy och missbruk
+ html:
+ help: Om du är osäker på varför ditt verk blivit dolt och vi inte har kommunicerat
+ med dig på annat sätt angående detta, var vänlig %{contact_abuse_link} direkt.
+ hidden: Ditt verk %{title} har blivit dolt av teamet för Policy och missbruk
+ och är inte längre tillgängligt för allmänheten.
+ tos_violation: Om ditt verk har dolts på grund av ett brott mot AO3:s %{tos_link},
+ så kommer du att behöva åtgärda problemet. Om du inte korrigerar ditt verk
+ så att det stämmer överens med användarvillkoren så kan verket komma att
+ raderas från AO3.
+ subject: "[%{app_name}] Ditt verk har blivit dolt av teamet för Policy och missbruk"
+ text:
+ help: 'Om du är osäker på varför ditt verk har blivit dolt och vi inte har
+ kommunicerat med dig på annat sätt angående detta, var vänlig kontakta Policy
+ och missbruk direkt: %{contact_abuse_url}.'
+ hidden: Ditt verk "%{title}" (%{work_url}) har blivit dolt av teamet för Policy
+ och missbruk och är inte längre tillgängligt för allmänheten.
+ tos_violation: Om ditt verk har dolts på grund av ett brott mot AO3:s användarvillkor
+ (%{tos_url}), så kommer du att behöva åtgärda problemet. Om du inte korrigerar
+ ditt verk så att det stämmer överens med användarvillkoren så kan verket
+ komma att raderas från AO3.
+ tos: användarvillkor
+ anonymous_or_unrevealed_notification:
+ anonymous_info: Anonyma verk ingår i tagglistor, men inte på din sida med verk.
+ På verket kommer ditt användarnamn ersättas med “Anonymous” (Anonym).
+ anonymous_unrevealed_info: De ansvariga för samlingen kan senare välja att avslöja
+ ditt verk, men låta det förbli anonymt. Användare som följer dig kommer inte
+ bli meddelade om denna förändring. När ditt verk har blivit avslöjat kommer
+ verket finnas med i taggsidor, men inte på din sida med verk. På verket kommer
+ ditt användarnamn att ersättas med "Anonymous" (Anonymt).
+ changed_status:
+ anonymous:
+ html: De som är ansvariga för samlingen %{collection_link} har ändrat status
+ på ditt verk %{work_link} till anonym.
+ text: De ansvariga för samlingen "%{collection_title}" (%{collection_url})
+ har ändrat status på ditt verk "%{work_title}" (%{work_url}) till anonym.
+ anonymous_unrevealed:
+ html: De som är ansvariga för samlingen %{collection_link} har ändrat status
+ på ditt verk %{work_link} till anonymt och dolt.
+ text: De ansvariga för samlingen "%{collection_title}" (%{collection_url})
+ har ändrat status på ditt verk "%{work_title}" (%{work_url}) till anonymt
+ och dolt.
+ unrevealed:
+ html: De som är ansvariga för samlingen %{collection_link} har ändrat status
+ på ditt verk %{work_link} till dolt.
+ text: De ansvariga för samlingen "%{collection_title}" (%{collection_url})
+ har ändrat status på ditt verk "%{work_title}" (%{work_url}) till dolt.
+ collection_items_link_text: sidan för Approved Collection Items (Godkända samlingsobjekt)
+ do_not_want:
+ anonymous:
+ html: Om du inte vill att ditt verk ska vara anonymt, var god besök %{collection_items_link}
+ för att ta bort det från den här samlingen.
+ text: 'Om du inte vill att ditt verk ska vara anonymt, var god besök sidan
+ Approved Collection Items (Godkända samlingsobjekt) för att ta bort det
+ från samlingen: %{collection_items_url}'
+ anonymous_unrevealed:
+ html: Om du inte vill att ditt verk ska vara anonymt och dolt, var god besök
+ %{collection_items_link} för att ta bort det från den här samlingen.
+ text: 'Om du inte vill att ditt verk ska vara anonymt eller dolt, var god
+ besök sidan Approved Collection Items (Godkända samlingsobjekt) för att
+ ta bort den från samlingen: %{collection_items_url}'
+ unrevealed:
+ html: Om du inte vill att ditt verk ska vara dolt, var god besök %{collection_items_link}
+ för att ta bort det från den här samlingen.
+ text: 'Om du inte vill att ditt verk ska döljas, var god besök sidan Approved
+ Collection Items (Godkända samlingsobjekt) för att ta bort den från samlingen:
+ %{collection_items_url}'
+ faq_link_text: Vanliga frågor om samlingar
+ more_info:
+ html: För mer information, var god läs våra %{faq_link}.
+ text: 'För mer information var god läs våra Vanliga frågor om samlingar: %{faq_url}'
+ subject:
+ anonymous: "[%{app_name}] Ditt verk har anonymiserats"
+ anonymous_unrevealed: "[%{app_name}] Ditt verk har anonymiserats och dolts"
+ unrevealed: "[%{app_name}] Ditt verk har dolts"
+ unrevealed_info: Dolda verk finns inte med på tagglistor eller sidor med verk.
+ Alla som följer en länk till verket kommer få ett meddelande om att verket
+ har dolts och att de inte kan komma åt verkets innehåll.
+ archivist_added_to_collection_notification:
+ approved_collection_items_page: Approved Collection Items (Godkända samlingsobjekt)
+ archivist_notice: Eftersom de ansvariga/moderatorerna agerar i sin officiella
+ kapacitet som Open Doors (Öppna dörrar) arkivarier, så är det tillåtet för
+ dem att lägga till ditt verk till denna samling, även om du som skapare avaktiverat
+ inbjudningar till samlingar. Arkivarier kommer bara att lägga till verk i
+ samlingar om verket fanns på ett importerat arkiv.
+ removal_instructions:
+ html: Om du skulle vilja ta bort ditt verk från denna samling, var god se
+ din sida för %{approved_items_link}.
+ text: Om du skulle vilja ta bort ditt verk från denna samling, gå till din
+ sida för Approved Collection Items (Godkända samlingsobjekt) :%{approved_items_url}.
+ subject: "[%{app_name}][%{collection_title}] En Open Doors (Öppna dörrar) arkivarie
+ har lagt till ditt verk i en samling"
+ work_added:
+ html: De ansvariga för samlingen %{collection_link} har lagt till ditt verk
+ %{work_link} till sin samling!
+ text: De ansvariga för samlingen "%{collection_title}" (%{collection_url})
+ har lagt till ditt verk "%{work_title}" (%{work_url}) till sin samling!
+ challenge_assignment_notification:
+ any: Vilka som helst
+ assignment:
+ html: Du har blivit tilldelad följande förfrågan i %{link}-utmaningen på AO3!
+ description: 'Beskrivning:'
+ due: 'Det här uppdraget ska lämnas in:'
+ html:
+ footer: Du får det här mailet för att du har anmält dig till %{title}-utmaningen.
+ För mer information om den här utmaningen och kontaktuppgifter till moderatorerna,
+ vänligen besök %{footer_link}.
+ footer_link: uppdragets profilsida
+ look_up: Du kan nå ditt uppdrag via %{link}.
+ look_up_link: din sida för Assignments (Uppdrag)
+ optional_tags: 'Valfria taggar:'
+ prompts: 'Prompts:'
+ prompt_url: 'Prompt-URL:'
+ recipient: 'Mottagare:'
+ recipient_missing: 'Ingen: kontakta en moderator för att få hjälp!'
+ subject: "[%{app_name}][%{collection_title}] Ditt uppdrag!"
+ text:
+ assignment: Du har blivit tilldelad följande förfrågan i "%{collection_title}"-utmaningen
+ (%{collection_url}) på AO3!
+ footer: Du får det här mailet för att du har anmält dig till %{title}-utmaningen
+ (%{url}). För mer information om den här utmaningen och kontaktuppgifter
+ till moderatorerna, vänligen besök %{profile_url}.
+ look_up: Du kan nå ditt uppdrag via din sida för Assignments (Uppdrag) på
+ %{link}.
+ change_email:
+ changed:
+ html: "%{login}, e-postadressen som är knuten till ditt konto har ändrats
+ till %{email}"
+ text: "%{login}, e-postadressen som är knuten till ditt konto har ändrats
+ till %{email}"
+ subject: "[%{app_name}] E-postadressen har ändrats"
+ claim_notification:
+ access:
+ contact_support: kontakta AO3 support
+ html: Beroende på arkivet kan det vara så att dina verk har importerats och
+ begränsats till endast registrerade användare (för att gömma dem från Googles
+ sökresultat). Om det är så, kommer verken bara finnas tillgängliga för inloggade
+ användare, såvida du inte väljer att göra dem synliga för alla. För hjälp
+ med att låsa upp, anonymisera eller radera dina verk, vänligen %{contact_support_link}.
+ text: Beroende på arkivet kan det vara så att dina verk har importerats och
+ begränsats till endast registrerade användare (för att gömma dem från Googles
+ sökresultat). Om det är så kommer verken bara finnas tillgängliga för inloggade
+ användare, såvida du inte väljer att göra dem synliga för alla. För hjälp
+ med att låsa upp, anonymisera eller radera dina verk, vänligen kontakta
+ AO3 Support på %{support_url}.
+ email_tips: Om du kontaktar oss, var snäll och lägg till mailadresser från @transformativeworks.org
+ på din lista med säkra kontakter och håll koll på din skräppost för att säkerställa
+ att du ser vårt svar.
+ introduction:
+ ao3_name: Archive of Our Own – AO3 (Vårt eget arkiv)
+ html: Du får det här mailet eftersom du hade verk på ett arkiv för verk av
+ fans som har importerats av %{open_doors_name_link} till %{app_link}. Eftersom
+ den här e-postadressen är kopplad till en användare på det importerade arkivet
+ så kommer de tillhörande verken (listade nedan) automatiskt läggas till
+ på ditt konto på AO3.
+ open_doors_name: Open Doors (Öppna dörrar)
+ text: 'Du får det här mailet eftersom du hade verk på ett arkiv för verk av
+ fans som har importerats av Open Doors (Öppna dörrar) (%{open_doors_url})
+ till Archive of Our Own – AO3 (Vårt eget arkiv): %{app_url}. Eftersom den
+ här epostadressen är kopplat till en användare på det importerade arkivet
+ så kommer de tillhörande verken (listade nedan) automatiskt läggas till
+ på ditt konto på AO3.'
+ mistake:
+ contact_open_doors: kontakta Öppna dörrar
+ html: Om detta är ett misstag och verken inte är dina, vänligen radera dem
+ inte! Var snäll och %{contact_open_doors_link} så löser vi det.
+ text: Om detta är ett misstag och verken inte är dina, vänligen radera dem
+ inte! Var snäll och kontakta Öppna dörrar (%{open_doors_url}) så löser vi
+ det.
+ more_info:
+ ao3_news: Nyheter på AO3
+ contact_support: kontakta AO3 support
+ faq_page: Vanliga frågor
+ html: Du kan läsa tillkännagivanden om nya arkivöverföringar på %{ao3_news_link}
+ och du hittar ytterligare information på Öppna dörrars %{faq_page_link}
+ eller %{tutorial_page_link}. För frågor som inte finns med i Vanliga frågor,
+ guider eller detta mail, vänligen %{contact_support_link}.
+ text: Du kan läsa tillkännagivanden om nya arkivöverföringar på AO3 Nyheter
+ (%{news_url}), och du hittar ytterligare information på Öppna dörrars sida
+ för Vanliga frågor (%{open_doors_faq_url}) eller sidan för guider (%{open_doors_tutorial_url}).
+ För frågor som inte finns med i Vanliga frågor, guider eller detta mail,
+ vänligen kontakta Support på %{support_url}.
+ tutorial_page: guider
+ other_works:
+ contact_open_doors: kontakta Öppna dörrar
+ html: Om du hade verk på det importerade arkivet under en mailadress som du
+ inte längre har tillgång till, vänligen %{contact_open_doors_link} med någon
+ form av information som kan hjälpa oss verifiera din identitet.
+ text: Om du hade verk på det importerade arkivet under en mailadress som du
+ inte längre har tillgång till, vänligen kontakta Öppna dörrar med någon
+ form av information som kan hjälpa oss verifiera din identitet.
+ questions:
+ contact_support: kontakta AO3 support
+ html: För andra frågor, vänligen %{contact_support_link}.
+ text: För andra frågor, vänligen kontakta AO3:s Support på %{support_url}.
+ redirects:
+ html: För att bevara rekommendationslistor och bokmärken kan det importerade
+ arkivets webbaddresser dirigera om till den importerade kopian av dessa
+ verk under en begränsad tid (se inlägget med tillkännagivandet för ditt
+ arkiv för att vara säker). Om du redan laddat upp en kopia av dessa verk
+ och %{negation} använt funktionen "importera från URL", så kommer det att
+ finnas två kopior av samma verk på AO3.
+ subject: "[%{app_name}] Uppladdade verk"
+ update_redirect:
+ contact_open_doors: kontakta Öppna dörrar
+ html: Om du vill att Öppna dörrar uppdaterar omdirigeringen till ditt redan
+ existerande verk, vänligen radera kopian och %{contact_open_doors_link}
+ med ditt användarnamn på AO3, ditt användarnamn på det importerade arkivet,
+ samt titel och länk till verket du vill att omdirigeringen ska leda till.
+ (Om du har flera verk du skulle vilja ändra omdirigeringen på, så kan du
+ skriva alla i samma mail.)
+ text: Om du vill att Öppna dörrar uppdaterar omdirigeringen till ditt redan
+ existerande verk, vänligen radera kopian och kontakta Öppna dörrar på %{open_doors_url}
+ med ditt användarnamn på AO3, ditt användarnamn på det importerade arkivet,
+ samt titel och länk till verket du vill att omdirigeringen ska leda till.
+ (Om du har flera verk du skulle vilja ändra omdirigeringen på, kan du skriva
+ alla i samma mail.)
+ works_by: 'Dessa verk skapades under e-postadressen: %{email}'
+ work_info:
+ html: "%{work_link} (%{fandom})"
+ text:
+ no_fandom: "- %{work_title} %{work_url}"
+ with_fandom: "- %{work_title} %{work_url} (%{fandom})"
+ collection_notification:
+ assignments_sent:
+ complete: Alla uppdrag har nu skickats ut.
+ subject: Uppdragen utskickade
+ html:
+ received_message: 'Du har fått ett meddelande om din samling %{collection_link}:'
+ text:
+ received_message: 'Du har fått ett meddelande om din samling "%{collection_title}"
+ (%{collection_url}):'
+ creatorship_notification:
+ explanation: När du är medskapare till ett verk kan du bli tillagd på nya kapitel
+ oavsett vilka medskaparinställningar du har. Du kommer även att bli tillagd
+ som medskapare av eventuella serier som verket läggs till i.
+ html:
+ creation: "%{creation_link} av %{pseud_links}"
+ edit_chapter: redigera kapitlet
+ edit_series: redigera serien
+ remove_chapter: Om du blivit felaktigt tillagd eller om du inte vill vara
+ listad som skapare till verket kan du %{edit_chapter_link} för att ta bort
+ dig själv.
+ remove_series: Om du har blivit felaktigt tillagd eller inte vill vara listad
+ som skapare av verket, kan du %{edit_series_link} för att ta bort dig själv
+ som skapare.
+ intro_chapter: 'Användaren %{adding_user} har lagt till din pseudonym %{pseud}
+ som medskapare till följande kapitel:'
+ intro_series: 'Användaren %{adding_user} har angett din pseudonym %{pseud} som
+ medskapare av denna serie:'
+ subject: "[%{app_name}] Avisering: Du är tillagd som medskapare"
+ text:
+ creation: "%{title} (%{url}) av %{pseuds}"
+ remove_chapter: 'Om du blivit felaktigt tillagd eller om du inte vill vara
+ listad som skapare till verket kan du redigera kapitlet för att ta bort
+ dig själv: %{url}'
+ remove_series: 'Om du blivit felaktigt tillagd eller om du inte vill vara
+ listad som skapare till verket kan du redigera serien för att ta bort dig
+ själv: %{url}'
+ creatorship_notification_archivist:
+ explanation: Eftersom hen agerar i sin roll som officiell arkivarie för Open
+ Doors (Öppna dörrar) så får hen lägga till dig utan att be om lov, även om
+ du har stängt av medskapande.
+ html:
+ creation: "%{creation_link} av %{pseud_links}"
+ edit_chapter: redigera kapitlet
+ edit_series: redigera serien
+ edit_work: redigera verket
+ remove_chapter: Om du blivit tillagd av misstag eller inte vill vara listad
+ som skapare kan du %{edit_chapter_link} för att ta bort dig själv som skapare.
+ remove_series: Om du blivit tillagd av misstag eller inte vill vara listad
+ som skapare kan du %{edit_series_link} för att ta bort dig själv som skapare.
+ remove_work: Om du blivit tillagd av misstag eller inte vill vara listad som
+ skapare kan du %{edit_work_link} för att ta bort dig själv som skapare.
+ intro_chapter: 'Användaren %{archivist} har lagt till din pseudonym %{pseud}
+ som medskapare till följande kapitel:'
+ intro_series: 'Användaren %{archivist} har lagt till din pseudonym %{pseud}
+ som medskapare till följande serie:'
+ intro_work: 'Användaren %{archivist} har lagt till din pseudonym %{pseud} som
+ medskapare till följande verk:'
+ subject: "[%{app_name}] En arkivarie har lagt till dig som medskapare"
+ text:
+ creation: "%{title} (%{url}) av %{pseuds}"
+ remove_chapter: 'Om du blivit tillagd av misstag eller inte vill vara listad
+ som skapare kan du redigera kapitlet för att ta bort dig själv som skapare:
+ %{url}'
+ remove_series: 'Om du blivit tillagd av misstag eller inte vill vara listad
+ som skapare kan du redigera serien för att ta bort dig själv som skapare:
+ %{url}'
+ remove_work: 'Om du blivit tillagd av misstag eller inte vill vara listad
+ som skapare kan du redigera verket för att ta bort dig själv som skapare:
+ %{url}'
+ creatorship_request:
+ html:
+ creation: "%{creation_link} av %{pseud_links}"
+ instructions: Du kan acceptera eller avslå förfrågan på din sida för %{page_name}.
+ page_name: Co-Creator Requests (Medskaparförfrågan)
+ intro_chapter: 'Användaren %{inviting_user} har bjudit in din pseudonym %{pseud}
+ att listas som medskapare på följande kapitel:'
+ intro_series: 'Användaren %{inviting_user} har bjudit in din pseudonym %{pseud}
+ att listas som medskapare på följande serier:'
+ intro_work: 'Användaren %{inviting_user} har bjudit in din pseudonym %{pseud}
+ att listas som medskapare på följande verk:'
+ subject: "[%{app_name}] Medskaparförfrågan"
+ text:
+ creation: "%{title} (%{url}) av %{pseuds}"
+ instructions: 'Du kan acceptera eller avslå förfrågan på din sida för Co-Creator
+ Requests (Medskaparförfrågan): %{url}'
+ delete_work_notification:
+ attachment: Bifogat finns en kopia av ditt verk för din kännedom.
+ deleted_other:
+ html: Ditt verk "%{title}" togs bort på begäran av %{pseud}.
+ text: Ditt verk "%{title}" togs bort på begäran av %{pseud}.
+ deleted_yourself:
+ html: Ditt verk %{title} togs bort på din begäran.
+ text: Ditt verk "%{title}" togs bort på din begäran.
+ questions:
+ html: Om du har frågor, vänligen %{support}.
+ text: Om du har frågor, vänligen %{support} (%{url}).
+ subject: "[%{app_name}] Ditt verk har tagits bort"
+ support: kontakta Support
+ invitation_to_claim:
+ access:
+ text: Beroende på arkivtyp kan dina verk ha blivit tillgängliga endast för
+ registrerade användare (för att exkludera dem från googlesökningar) vid
+ importen. Om så är fallet kommer verken endast vara tillgängliga för inloggade
+ användare om du inte väljer att göra dem synliga för alla. För hjälp med
+ att låsa upp, anonymisera eller radera dina verk, vänligen kontakta AO3
+ support.
+ claim_or_remove:
+ html: Gör anspråk på eller ta bort dina verk här.
+ text: 'Gör anspråk på eller ta bort dina verk här: %{claim_url}'
+ email_tips: Om du kontaktar oss, vänligen vitlista mailadresser från @transformativeworks.org
+ och kontrollera om vårt svar finns i din skräppost.
+ html:
+ ao3_news: AO3 nyheter
+ contact_open_doors: kontakta Öppna dörrar
+ contact_support: kontakta AO3 support
+ faq_page: Vanliga frågor och svar
+ tutorial_page: handledningssidan
+ introduction:
+ text: Du har fått det här mailet för att ett arkiv nyligen har blivit importerat
+ av Öppna dörrar (%{open_doors_link}) till %{app_name} (%{app_short_name}
+ - %{app_url}), och vi tror att följande verk tillhör dig. Vi vill ge dig
+ möjlighet att göra anspråk på (eller radera/anonymisera) dessa verk. Och
+ om du inte redan har ett konto med en annan mailadress vill vi välkomna
+ dig ombord!
+ mistake:
+ text: Om det har skett ett misstag och det här inte är dina verk, snälla radera
+ dem inte! Vänligen kontakta (%{open_doors_link}) så löser vi det.
+ more_info:
+ text: Du kan läsa tillkännagivanden rörande arkivflyttar på AO3 nyheter (%{news_link}),
+ och hitta ytterligare information på Öppna dörrars sida med vanliga frågor
+ och svar %{open_doors_faq_link} eller på handledningssidan %{open_doors_tutorial_link}.
+ Om du har frågor som inte besvarats i detta mail eller länkarna ovan, vänligen
+ kontakta Support via %{support_link}.
+ other_works:
+ text: Om du hade andra verk i det importerade arkivet under en mailadress
+ du inte längre har tillgång till, vänligen kontakta Öppna dörrar med information
+ som kan hjälpa till att verifiera din identitet.
+ questions:
+ text: För andra frågor, vänligen kontakta AO3 support via %{support_link}.
+ redirects: För att bevara rekommendationslistor och bokmärken kan det importerade
+ arkivets webbadresser under en begränsad tid omdirigeras till en importerad
+ kopia av dessa verk (se inlägget med tillkännagivelser om ditt arkiv för att
+ vara på den säkra sidan). Om du redan har laddat upp en kopia av dessa verk
+ och INTE använde funktionen för import av URL kommer det att finnas två kopior
+ av samma verk i arkivet.
+ subject: "[%{app_name}] Inbjudan att göra anspråk på verk"
+ unwanted:
+ text: Om dessa verk tillhör dig, men du inte vill ha kvar dem kan du anonymisera
+ dem (så att de finns kvar på AO3 men utan ditt namn) eller radera dem (så
+ att de tas bort helt från AO3). Du behöver inte lägga till dessa verk till
+ något konto för att kunna anonymisera eller radera dem -- du kan göra det
+ direkt från anspråkslänken ovan. (För hjälp, vänligen kontakta oss på Support
+ via %{support_link}.)
+ update_redirect:
+ text: Om du vill att Öppna dörrar ska uppdatera länken så att den riktas mot
+ ett av dina verk som redan finns, vänligen radera den importerade kopian
+ och kontakta Öppna dörrar %{open_doors_link} med namnet på ditt AO3-konto,
+ ditt namn på det importerade arkivet, samt titel och URL på verket du vill
+ omdirigera länken till. (Om du har många verk du vill rikta om kan du skriva
+ dessa i samma mail.)
+ uploaded_list: 'De uppladdade verken inkluderar:'
+ invite_increase_notification:
+ html:
+ body:
+ one: Vi vill bara berätta för dig att du har %{count} ny inbjudan som kan
+ användas för att skapa ett nytt konto på AO3. Du kan bjuda in en vän via
+ %{invitation_page_link}.
+ other: Vi vill bara berätta för dig att du har %{count} nya inbjudningar
+ som kan användas för att skapa nya konton på AO3. Du kan bjuda in en vän
+ via %{invitation_page_link}.
+ invitation_page_link_text: Invitations (din sida för inbjudningar)
+ subject: "[%{app_name}] Nya inbjudningar"
+ text:
+ body:
+ one: Vi vill bara berätta för dig att du har %{count} ny inbjudan som kan
+ användas för att skapa ett nytt konto på AO3. Du kan bjuda in en vän via
+ Invitations (din sida för inbjudningar)(%{invitation_page_url}).
+ other: Vi vill bara berätta för dig att du har %{count} nya inbjudningar
+ som kan användas för att skapa nya konton på AO3. Du kan bjuda in en vän
+ via Invitations (din sida för inbjudningar)(%{invitation_page_url}).
+ invite_request_declined:
+ main:
+ one: Vi måste tyvärr informera dig om att din förfrågan om en ny inbjudningskod
+ inte kan uppfyllas just nu.
+ other: Vi måste tyvärr informera dig om att din förfrågan om %{count} nya
+ inbjudningskoder inte kan uppfyllas just nu.
+ reason: 'Din förfrågan var:'
+ subject: "[%{app_name}] Ansökan om ytterligare inbjudningskod nekad"
+ recipient_notification:
+ html:
+ collection: Ett verk som är tillägnat dig har publicerats i samlingen %{collection_link}
+ på AO3!
+ no_collection: Ett verk som är tillägnat dig har blivit publicerat på AO3!
+ subject:
+ collection: "[%{app_name}][%{collection_title}] Ett verk är tillägnat dig
+ i %{collection_title}"
+ no_collection: "[%{app_name}] Ett verk tillägnat dig"
+ text:
+ collection: Ett verk som är tillägnat dig har publicerats i samlingen "%{collection_title}"
+ (%{collection_url}) på AO3!
+ signup_notification:
+ activate:
+ html: Var vänlig %{activate_account_link}.
+ text: 'Var vänlig följ denna länk för att aktivera ditt konto: %{activate_account_url}'
+ activate_your_account: Följ på denna länk för att aktivera ditt konto
+ admin_posts: AO3 Nyheter
+ bye: Vi hoppas att du trivs med att använda Arkivet.
+ contact_support: kontakta Support
+ faq: Vanliga frågor
+ features:
+ html: När ditt konto väl är färdigställt kan du lägga upp dina verk, ordna
+ mail-prenumerationer så att du vet när dina favoritskapare eller favoritverk
+ uppdateras, göra egna inställningar för hur sajten ska se ut och fungera
+ för dig, hålla koll på vilka verk du har gått in på eller läst på Arkivet
+ via din historik, och mycket mer.
+ text: När ditt konto väl är färdigställt kan du lägga upp dina verk, ordna
+ mail-prenumerationer så att du vet när dina favoritskapare eller favoritverk
+ uppdateras, göra egna inställningar för hur sajten ska se ut och fungera
+ för dig, hålla koll på vilka verk du har gått in på eller läst på Arkivet
+ via din historik, och mycket mer.
+ information:
+ html: Det finns massor av information och råd om hur du använder Arkivet i
+ våra %{faq_link}. Du hittar de senaste nyheterna om sajtens utveckling i
+ våra %{admin_posts_link}. Om du behöver mer hjälp, stöter på en bugg, eller
+ har frågor eller kommentarer, %{contact_support_link}, som alltid är glada
+ att hjälpa till.
+ text: 'Det finns massor av information och råd om hur du använder Arkivet
+ i våra Vanliga frågor här: %{faq_url}. Du hittar de senaste nyheterna om
+ sajtens utveckling på AO3 Nyheter här: %{admin_posts_url}. Om du behöver
+ mer hjälp, stöter på en bugg, eller har frågor eller kommentarer, var vänlig
+ kontakta vårt Support-team, som alltid är glada att hjälpa till: %{contact_support_url}.'
+ welcome: Välkommen till AO3, %{login}!
diff --git a/config/locales/phrase-exports/th.yml b/config/locales/phrase-exports/th.yml
new file mode 100755
index 0000000..40bbbff
--- /dev/null
+++ b/config/locales/phrase-exports/th.yml
@@ -0,0 +1,459 @@
+---
+th:
+ activerecord:
+ attributes:
+ archive_warning:
+ name_with_colon:
+ other: 'คำเตือน:'
+ category:
+ name_with_colon:
+ other: 'ประเภท:'
+ character:
+ name_with_colon:
+ other: 'ตัวละคร:'
+ fandom:
+ name_with_colon:
+ other: 'แฟนดอม:'
+ freeform:
+ name_with_colon:
+ other: 'แท็กเพิ่มเติม:'
+ rating:
+ name_with_colon: 'เรตติ้ง:'
+ relationship:
+ name_with_colon:
+ other: 'ความสัมพันธ์:'
+ work:
+ chapter_total_display: บท
+ summary: คำโปรย
+ models:
+ archive_warning:
+ other: คำเตือน
+ category:
+ other: ประเภท
+ chapter:
+ other: บท
+ character:
+ other: ตัวละคร
+ fandom:
+ other: แฟนดอม
+ freeform:
+ other: แท็กเพิ่มเติม
+ rating:
+ other: เรตติ้ง
+ relationship:
+ other: ความสัมพันธ์
+ series:
+ other: ซีรีย์
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ other: "%{count} ผู้เข้าชม"
+ left_kudos:
+ html:
+ other: "%{givers_list} ได้มอบคำชมเชยบน %{commentable_link}."
+ text:
+ other: "%{givers_list} ได้มอบคำชมเชยบน %{commentable_title} (%{commentable_url})."
+ single_guest:
+ giver: ผู้เข้าชมคนหนึ่ง
+ html: "%{giver} ได้มอบคำชมเชยบน %{commentable_link}."
+ text: ผู้เข้าชมคนหนึ่งได้มอบคำชมเชยบน %{commentable_title} (%{commentable_url}).
+ subject: "[%{app_name}] คุณได้รับคำชมเชย!"
+ mailer:
+ general:
+ closing:
+ formal: ด้วยความเคารพ
+ informal: ด้วยความนับถือ
+ creation:
+ link_with_word_count: "%{creation_link} (%{word_count})"
+ title_with_chapter_number: ตอนที่ %{position} ของเรื่อง %{title}
+ title_with_word_count: '"%{creation_title}" (%{word_count})'
+ word_count:
+ other: "%{count} คำ"
+ footer:
+ general:
+ about:
+ html: AO3 คือคลังข้อมูลที่ดำเนินการโดยแฟนๆ และสนับสนุนแฟนๆ ซึ่งขึ้นอยู่กับ
+ %{donate_link}
+ text: 'AO3 คือคลังข้อมูลที่ดำเนินการโดยแฟนๆ และสนับสนุนแฟนๆ ซึ่งขึ้นอยู่กับการบริจาคของคุณ:
+ %{donate_url}'
+ html:
+ donate_link_text: การบริจาคของคุณ
+ support_link_text: ติดต่อฝ่ายช่วยเหลือ
+ unwanted_email:
+ html: หากคุณได้รับข้อความนี้โดยความผิดพลาด กรุณาติดต่อ %{support_link}
+ text: หากคุณได้รับข้อความนี้โดยความผิดพลาด กรุณาติดต่อฝ่ายช่วยเหลือที่
+ %{support_url}
+ sent_at: ส่งเมื่อ %{sent_at}
+ greeting:
+ formal_html: เรียน %{name},
+ informal:
+ addressed_html: สวัสดี %{name}!
+ unaddressed: สวัสดี!
+ introductory: ขอทักทายจาก Archive of Our Own – AO3 (คลังเก็บข้อมูลของเราเอง)!
+ metadata_label_indicator: ":"
+ signature:
+ abuse_team: ทีมนโยบายป้องกันการละเมิด
+ app_short_name: AO3
+ open_doors: ทีม The Open Doors (โอเพนดอร์ส)
+ parent_org: Organization for Transformative Works – OTW (องค์กรเพื่อผลงานทรานสฟอร์เมทีฟ)
+ support: ทีมช่วยเหลือ AO3
+ users:
+ mailer:
+ reset_password_instructions:
+ expiration: หากคุณไม่กดเข้าลิงก์นี้เพื่อทำการเปลี่ยนรหัสผ่านภายในหนี่งสัปดาห์
+ ลิงก์นี้จะหมดอายุการใช้งานและคุณจะต้องส่งคำขอเปลี่ยนแปลงรหัสผ่านใหม่อีกครั้ง
+ intro: 'มีผู้ใช้งานส่งคำร้องขอเปลี่ยนรหัสผ่านสำหรับบัญชีของคุณ คุณสามารถเปลี่ยนแปลงรหัสผ่านของคุณโดยการกดลิงก์ข้างใต้นี้และใส่รหัสผ่านใหม่:'
+ link_title: เปลี่ยนรหัสผ่านของฉัน
+ subject: "[%{app_name}] เปลี่ยนรหัสผ่านของคุณ"
+ unrequested: หากคุณไม่ได้ส่งคำร้องขอเปลี่ยนแปลงรหัสผ่านนี้ คุณสามารถปล่อยผ่านอีเมลฉบับนี้และรหัสผ่านเดิมของคุณจะสามารถใช้งานได้ตามปกติ
+ user_mailer:
+ admin_deleted_work_notification:
+ bye: สิ่งที่แนบมานี้คือสำเนาผลงานของคุณเพื่อการอ้างอิง
+ contact_abuse: โปรดติดต่อฝ่ายนโยบายป้องกันการละเมิด
+ deleted:
+ html: "%{title} ผลงานของคุณถูกลบจาก AO3 โดยแอดมินของเว็บไซต์"
+ text: ผลงานของคุณ "%{title}" ถูกลบจาก AO3 โดยแอดมินเว็บไซต์
+ html:
+ tos_violation: หากผลงานของคุณมีความเป็นไปได้ที่จะละเมิดกฎข้อตกลงของ AO3 %{contact_abuse_link}
+ import_project:
+ html: หากผลงานของคุณคือส่วนหนึ่งของโปรเจ็กต์สำคัญที่ดูแลโดย Open Doors (โอเพนดอร์ส)
+ ของเรา โปรดเข้าไปที่ %{opendoors_link} สำหรับข้อสงสัยเพิ่มเติม
+ text: หากผลงานของคุณคือส่วนหนึ่งของโปรเจ็กต์สำคัญที่ดูแลโดย Open Doors (โอเพนดอร์ส)
+ ของเรา โปรดติดต่อโอเพนดอร์ส (%{opendoors_link})สำหรับข้อสงสัยเพิ่มเติม
+ opendoors: ติดต่อโอเพนดอร์ส
+ subject: "[%{app_name}] ผลงานของคุณถูกลบโดยแอดมิน"
+ text:
+ tos_violation: หากผลงานของคุณมีความเป็นไปได้ที่จะละเมิดกฎข้อตกลงของ AO3 โปรดติดต่อฝ่ายนโยบายป้องกันการละเมิด
+ (%{contact_abuse_url})
+ admin_hidden_work_notification:
+ access: ขณะที่ผลงานของคุณถูกซ่อน คุณยังสามารถเข้าถึงมันได้ผ่านลิงค์ด้านบน แต่มันจะไม่อยู่ในหน้าเพจผลงานของคุณ
+ และผู้ใช้ AO3 คนอื่นจะไม่สามารถเข้าถึงมันได้
+ check_email: โปรดตรวจสอบอีเมลของคุณรวมทั้งโฟลเดอร์สแปม ทีมนโยบายและการละเมิดอาจติดต่อคุณเพื่ออธิบายว่าเหตุใดผลงานคุณจึงถูกซ่อนไปแล้ว
+ contact_abuse: ติดต่อนโยบายและการละเมิด
+ html:
+ help: หากไม่มั่นใจว่าเหตุใดผลงานคุณจึงถูกซ่อนและยังไม่ได้รับการติดต่อในเรื่องนี้
+ โปรดเข้าไปที่ %{contact_abuse_link} โดยตรง
+ hidden: ผลงานของคุณเรื่อง %{title} ถูกซ่อนไว้โดยทีมนโยบายและการละเมิดและคนทั่วไปไม่สามารถเข้าถึงมันได้
+ tos_violation: หากผลงานคุณถูกซ่อนเนื่องจากการละเมิด %{tos_link} ของ AO3 คุณต้องแก้ไขการละเมิดนั้น
+ หากไม่ทำให้ผลงานเป็นไปตามเงื่อนไขการให้บริการ อาจทำให้ผลงานคุณถูกลบจาก AO3
+ ได้
+ subject: "[%{app_name}] ผลงานของคุณถูกซ่อนไว้โดยทีมนโยบายและการละเมิด"
+ text:
+ help: 'หากไม่มั่นใจว่าเหตุใดผลงานคุณจึงถูกซ่อนและยังไม่ได้รับการติดต่อในเรื่องนี้
+ โปรดติดต่อนโยบายและการละเมิดโดยตรง: %{contact_abuse_url}'
+ hidden: ผลงานของคุณเรื่อง "%{title}" (%{work_url}) ถูกซ่อนไว้โดยทีมนโยบายและการละเมิดและคนทั่วไปไม่สามารถเข้าถึงมันได้
+ tos_violation: หากผลงานคุณถูกซ่อนเนื่องจากการละเมิดเงื่อนไขการให้บริการของ
+ AO3 (%{tos_url}) คุณต้องแก้ไขการละเมิดนั้น หากไม่ทำให้ผลงานเป็นไปตามเงื่อนไขการให้บริการ
+ อาจทำให้ผลงานคุณถูกลบจาก AO3 ได้
+ tos: เงื่อนไขการให้บริการ
+ anonymous_or_unrevealed_notification:
+ anonymous_info: ผลงานนิรนามจะรวมอยู่ในรายชื่อแท็กแต่ไม่ปรากฎบนหน้าเพจผลงานของคุณ
+ ชื่อผู้ใช้ของคุณบนผลงานจะถูกแทนที่ด้วย "Anonymous" (นิรนาม)
+ anonymous_unrevealed_info: ผู้ดูแลคอลเล็กชันอาจเปิดเผยผลงานของคุณในภายหลังแต่คงสถานะนิรนามไว้
+ คนอื่นที่ติดตามคุณจะไม่ได้รับการแจ้งเตือนเรื่องการเปลี่ยนแปลงนี้ เมื่อผลงานถูกเปิดเผยแล้ว
+ ผลงานของคุณจะรวมอยู่ในรายชื่อแท็กแต่ไม่อยู่ในหน้าเพจผลงานของคุณ ชื่อผู้ใช้ของคุณบนผลงานจะถูกแทนที่ด้วย
+ "Anonymous" (นิรนาม)
+ changed_status:
+ anonymous:
+ html: ผู้ดูแลคอลเล็กชัน %{collection_link} ได้เปลี่ยนสถานะผลงาน %{work_link}
+ ของคุณเป็นแบบนิรนาม
+ text: ผู้ดูแลคอลเล็กชัน "%{collection_title}" (%{collection_url}) ได้เปลี่ยนสถานะผลงาน
+ "%{work_title}" (%{work_url}) ของคุณเป็นแบบนิรนาม
+ anonymous_unrevealed:
+ html: ผู้ดูแลคอลเล็กชัน %{collection_link} ได้เปลี่ยนสถานะ %{work_link}
+ ผลงานของคุณเป็นแบบนิรนามและไม่เปิดเผย
+ text: ผู้ดูแลคอลเล็กชัน "%{collection_title}" (%{collection_url}) ได้เปลี่ยนสถานะผลงาน
+ "%{work_title}" (%{work_url}) ของคุณเป็นแบบนิรนามและไม่เปิดเผย
+ unrevealed:
+ html: ผู้ดูแลคอลเล็กชัน %{collection_link} ได้เปลี่ยนสถานะผลงาน %{work_link}
+ ของคุณเป็นแบบไม่เปิดเผย
+ text: ผู้ดูแลคอลเล็กชัน "%{collection_title}" (%{collection_url}) ได้เปลี่ยนสถานะผลงาน
+ "%{work_title}" (%{work_url}) ของคุณเป็นแบบไม่เปิดเผย
+ collection_items_link_text: หน้าเพจ Approved Collection Items (ไอเท็มคอลเล็กชันที่ได้รับการอนุญาต)
+ do_not_want:
+ anonymous:
+ html: หากไม่ต้องการให้ผลงานของคุณเป็นแบบนิรนาม ไปที่ %{collection_items_link}
+ เพื่อลบมันออกจากคอลเล็กชันนี้
+ text: 'หากไม่ต้องการให้ผลงานของคุณเป็นแบบนิรนาม ไปที่หน้าเพจ Approved Collection
+ Items (ไอเท็มคอลเล็กชันที่ได้รับการอนุญาต) เพื่อลบมันออกจากคอลเล็กชันนี้:
+ %{collection_items_url}'
+ anonymous_unrevealed:
+ html: หากไม่ต้องการให้ผลงานของคุณเป็นแบบนิรนามและไม่ถูกเปิดเผย ไปที่ %{collection_items_link}
+ เพื่อลบมันออกจากคอลเล็กชันนี้
+ text: 'หากไม่ต้องการให้ผลงานของคุณเป็นแบบนิรนามและไม่ถูกเปิดเผย ไปที่หน้าเพจ
+ Approved Collection Items (ไอเท็มคอลเล็กชันที่ได้รับการอนุญาต) เพื่อลบมันออกจากคอลเล็กชันนี้:
+ %{collection_items_url}'
+ unrevealed:
+ html: หากไม่ต้องการให้ผลงานของคุณเป็นแบบไม่เปิดเผย ไปที่ %{collection_items_link}
+ เพื่อลบมันออกจากคอลเล็กชันนี้
+ text: 'หากไม่ต้องการให้ผลงานของคุณเป็นแบบไม่เปิดเผย ไปที่หน้าเพจ Approved
+ Collection Items (ไอเท็มคอลเล็กชันที่ได้รับการอนุญาต) เพื่อลบมันออกจากคอลเล็กชันนี้:
+ %{collection_items_url}'
+ faq_link_text: คำถามที่พบบ่อยเกี่ยวกับคอลเล็กชัน
+ more_info:
+ html: อ่านข้อมูลเพิ่มเติมได้ที่ %{faq_link}
+ text: 'ดูเพิ่มเติมได้ที่คำถามที่พบบ่อยเกี่ยวกับคอลเล็กชัน: %{faq_url}'
+ subject:
+ anonymous: "[%{app_name}] ผลงานของคุณถูกตั้งค่าเป็นนิรนามแล้ว"
+ anonymous_unrevealed: "[%{app_name}] ผลงานของคุณถูกตั้งค่าเป็นแบบนิรนามและไม่เปิดเผย"
+ unrevealed: "[%{app_name}] ผลงานของคุณถูกตั้งค่าเป็นไม่เปิดเผย"
+ unrevealed_info: ผลงานที่ไม่ถูกเปิดเผยจะไม่ปรากฎในรายชื่อแท็กและบนหน้าเพจผลงานของคุณ
+ ผู้ที่มาตามลิงก์ผลงานจะได้รับการแจ้งเตือนว่าตอนนี้ผลงานไม่ถูกเปิดเผย และพวกเขาไม่สามารถเข้าถึงเนื้อหาได้
+ archivist_added_to_collection_notification:
+ approved_collection_items_page: เพจ Approved Collection Items (คอลเลกชันที่ได้รับการอนุมัติ)
+ archivist_notice: เนื่องจากผู้ดูแลคอลเลกชันนับเป็นผู้จัดเก็บและดูแลข้อมูลของ
+ Open Doors (โอเพนดอร์ส) ดังนั้นพวกเขาจึงสามารถเพิ่มผลงานของคุณเข้าไปในคอลเลกชันนี้ได้
+ แม้ว่าคุณจะได้ทำการปิดฟังก์ชันรับคำเชิญเข้าร่วมของคอลเลกชันก็ตาม ผู้จัดเก็บและดูแลข้อมูลจะเพิ่มผลงานของคุณเข้าไปในคอลเลกชัน
+ ก็ต่อเมื่อผลงานถูกโฮสต์ในคลังข้อมูลที่ถูกนำเข้ามา
+ removal_instructions:
+ html: หากคุณต้องการที่จะลบผลงานของคุณออกจากคอลเลกชันนี้ ให้ไปที่ %{approved_items_link}.
+ text: 'หากคุณต้องการลบผลงานของคุณออกจากคอลเลกชันนี้ โปรดไปที่หน้า Approved
+ Collection Items (คอลเลกชันที่อนุมัติ) ของคุณ: %{approved_items_url}.'
+ subject: "[%{app_name}][%{collection_title}] ผู้เก็บและดูแลข้อมูลของ Open Doors
+ (โอเพนดอร์ส) ได้เพิ่มผลงานของคุณเข้าไปในคอลเลกชัน"
+ work_added:
+ html: ผู้ดูแลของคอลเลกชัน %{collection_link} ได้เพิ่มผลงานของคุณ %{work_link}
+ เข้าไปในคอลเลกชัน!
+ text: ผู้ดูแลคอลเลกชันของ "%{collection_title}" (%{collection_url}) ได้เพิ่มงานของคุณ
+ "%{work_title}" (%{work_url}) เข้าไปในคอลเลกชันของพวกเขา!
+ challenge_assignment_notification:
+ any: อะไรก็ได้
+ assignment:
+ html: คุณได้รับมอบหมายให้ทำตามคำขอจากชาเลนจ์ %{link} ที่ AO3!
+ description: 'คำนิยาม:'
+ due: 'กำหนดส่งงาน:'
+ html:
+ footer: คุณได้รับอีเมล์นี้เนื่องจากคุณได้ลงทะเบียนสำหรับชาเลนจ์ %{title} สำหรับข้อมูลเพิ่มเติมเกี่ยวกับชาเลนจ์นี้และช่องทางติดต่อมอเดอเรเตอร์
+ กรุณาไปที่ %{footer_link}
+ footer_link: เพจประวัติของชาเลนจ์
+ look_up: คุณสามารถค้นหางานนี้ได้จาก %{link}
+ look_up_link: เพจ Assignments (งานที่ได้รับมอบหมาย) ของคุณ
+ optional_tags: 'แท็กเพิ่มเติม:'
+ prompts: 'โจทย์เรื่อง:'
+ prompt_url: 'URL โจทย์เรื่อง:'
+ recipient: 'ผู้รับ:'
+ recipient_missing: 'ไม่มี: ขอความช่วยเหลือจากมอเดอเรเตอร์!'
+ subject: "[%{app_name}][%{collection_title}] งานของคุณ!"
+ text:
+ assignment: คุณได้รับมอบหมายให้ทำตามคำขอจาก %{collection_title} ชาเลนจ์ (%{collection_url})
+ ที่ AO3!
+ footer: คุณได้รับอีเมล์นี้เนื่องจากคุณได้ลงทะเบียนสำหรับชาเลนจ์ %{title} (%{url})
+ สำหรับข้อมูลเพิ่มเติมเกี่ยวกับชาเลนจ์นี้และช่องทางติดต่อมอเดอเรเตอร์ กรุณาไปที่
+ %{profile_url}
+ look_up: คุณสามารถค้นหางานนี้ได้จากเพจ Assignments (งานที่ได้รับมอบหมาย) ของคุณที่
+ %{link}
+ change_email:
+ changed:
+ html: "%{login}, อีเมล์ที่เกี่ยวข้องกับบัญชีของคุณถูกเปลี่ยนเป็น %{email}"
+ text: "%{login}, อีเมล์ที่เกี่ยวข้องกับบัญชีของคุณถูกเปลี่ยนเป็น %{email}"
+ subject: "[%{app_name}] อีเมล์ถูกเปลี่ยนแล้ว"
+ claim_notification:
+ access:
+ contact_support: ติดต่อฝ่ายบริการช่วยเหลือ
+ html: ผลงานของคุณอาจถูกนำเข้ามาให้เฉพาะผู้ใช้ที่ลงทะเบียนแล้วอ่านได้เท่านั้น
+ ขึ้นอยู่กับการตั้งค่าบนไฟล์ต้นฉบับ (เพื่อป้องกันไม่ให้ถูกค้นหาจาก Google
+ ได้) ในกรณีนี้ ผู้ใช้งานที่เข้าสู่ระบบเท่านั้นที่จะสามารถเข้าถึงผลงานได้
+ เว้นแต่คุณจะเลือกให้มองเห็นผลงานได้ทั้งหมด หากต้องการความช่วยเหลือในการปลดล็อก
+ ทอดทิ้งผลงาน หรือลบผลงานของคุณ โปรด %{contact_support_link}
+ text: ผลงานของคุณอาจถูกนำเข้ามาให้เฉพาะผู้ใช้ที่ลงทะเบียนแล้วอ่านได้เท่านั้น
+ ขึ้นอยู่กับการตั้งค่าบนไฟล์ต้นฉบับ (เพื่อป้องกันไม่ให้ถูกค้นหาจาก Google
+ ได้) ในกรณีนี้ ผู้ใช้ที่เข้าสู่ระบบเท่านั้นที่จะสามารถเข้าถึงผลงานได้ เว้นแต่คุณจะเลือกให้มองเห็นผลงานได้ทั้งหมด
+ หากต้องการความช่วยเหลือในการปลดล็อก ทอดทิ้งผลงาน หรือลบผลงานของคุณ โปรดติดต่อฝ่ายบริการช่วยเหลือที่
+ %{support_url}
+ email_tips: หากคุณกำลังติดต่อเรา โปรดเพิ่มที่อยู่อีเมลจาก @transformativeworks.org
+ ลงในรายชื่อผู้ติดต่อที่ปลอดภัย และตรวจสอบโฟลเดอร์สแปมของคุณเพื่อค้นหาอีเมลตอบกลับของเรา
+ introduction:
+ ao3_name: Archive of Our Own – AO3 (คลังเก็บข้อมูลของเราเอง)
+ html: คุณได้รับอีเมลนี้เนื่องจากคุณมีผลงานที่อยู่ในคลังแฟนเวิร์กที่ถูกนำเข้ามา
+ %{app_link} โดย %{open_doors_name_link} เพราะอีเมลนี้เชื่อมโยงกับอีเมลที่ลงทะเบียนไว้ในคลังผลงานที่ถูกนำเข้ามา
+ แฟนเวิร์กที่เกี่ยวข้อง (ตามรายชื่อด้านล่างนี้) จึงถูกเพิ่มเข้าไปยังบัญชี
+ AO3 ของคุณโดยอัตโนมัติ
+ open_doors_name: Open Doors (โอเพนดอร์ส)
+ text: 'คุณได้รับอีเมลนี้เนื่องจากคุณมีผลงานที่อยู่ในคลังแฟนเวิร์กที่ถูกนำเข้ามาใน
+ Archive of Our Own – AO3 (คลังเก็บข้อมูลของเราเอง): %{app_url} โดย Open
+ Doors (โอเพนดอร์ส) (%{open_doors_url}) เนื่องจากอีเมลนี้เชื่อมโยงกับอีเมลที่ลงทะเบียนไว้ในคลังผลงานที่ถูกนำเข้ามา
+ แฟนเวิร์กที่เกี่ยวข้อง (ตามรายชื่อด้านล่างนี้) จึงถูกเพิ่มเข้าไปยังบัญชี
+ AO3 ของคุณโดยอัตโนมัติ'
+ mistake:
+ contact_open_doors: ติดต่อโอเพนดอร์ส
+ html: หากมีข้อผิดพลาดและผลงานเหล่านี้ไม่ใช่ของคุณ กรุณาอย่าลบมัน! โปรด %{contact_open_doors_link}
+ แล้วเราจะดำเนินการแก้ไขต่อไป
+ text: หากมีข้อผิดพลาดและผลงานเหล่านี้ไม่ใช่ของคุณ กรุณาอย่าลบมัน! โปรดติดต่อ
+ Open Doors (%{open_doors_url}) แล้วเราจะดำเนินการแก้ไขต่อไป
+ more_info:
+ ao3_news: ข่าวสาร AO3
+ contact_support: ติดต่อฝ่ายบริการช่วยเหลือ
+ faq_page: หน้าคำถามที่พบบ่อย
+ html: คุณสามารถอ่านประกาศเกี่ยวกับการย้ายเอกสารสำคัญล่าสุดได้ที่ %{ao3_news_link}
+ และค้นหาข้อมูลเพิ่มเติมได้ทาง %{faq_page_link} หรือ %{tutorial_page_link}
+ ของ Open Doors สำหรับคำถามใดๆ ที่ไม่พบคำตอบในหน้าคำถามที่พบบ่อย วิธีการใช้งาน
+ หรือในอีเมลนี้ โปรด %{contact_support_link}
+ text: คุณสามารถอ่านประกาศเกี่ยวกับการย้ายเอกสารล่าสุดได้ที่ AO3 News (%{news_url})
+ และค้นหาข้อมูลเพิ่มเติมได้ที่หน้าคำถามที่พบบ่อยของ Open Doors (%{open_doors_faq_url})
+ หรือหน้าวิธีการใช้งาน (%{open_doors_tutorial_url}) สำหรับคำถามใดๆ ที่ไม่พบคำตอบในหน้าคำถามที่พบบ่อย
+ วิธีการใช้งาน หรือในอีเมลนี้ โปรดติดต่อฝ่ายบริการช่วยเหลือที่ %{support_url}
+ tutorial_page: หน้าวิธีการใช้งาน
+ other_works:
+ contact_open_doors: ติดต่อโอเพนดอร์ส
+ html: หากคุณมีผลงานอื่นในไฟล์ที่ถูกนำเข้าภายใต้ที่อยู่อีเมลที่คุณไม่สามารถเข้าถึงได้อีกต่อไป
+ โปรด %{contact_open_doors_link} พร้อมข้อมูลใดๆ ที่สามารถช่วยยืนยันตัวตนของคุณได้
+ text: หากคุณมีผลงานอื่นในไฟล์ที่ถูกนำเข้าภายใต้ที่อยู่อีเมลที่คุณไม่สามารถเข้าถึงได้อีกต่อไป
+ โปรดติดต่อ Open Doors พร้อมข้อมูลใดๆ ที่สามารถช่วยยืนยันตัวตนของคุณได้
+ questions:
+ contact_support: ติดต่อฝ่ายบริการช่วยเหลือ
+ html: หากมีข้อสงสัยอื่นๆ โปรด %{contact_support_link}
+ text: หากมีข้อสงสัยอื่นๆ โปรดติดต่อฝ่ายบริการช่วยเหลือ AO3 ที่ %{support_url}
+ redirects:
+ html: เพื่อสงวนรายการแนะนำและบุ๊คมาร์คให้อยู่บนเว็บไซต์ต่อไป ที่อยู่เว็บของไฟล์ที่ถูกนำเข้าอาจเปลี่ยนเส้นทางไปยังสำเนาที่ถูกนำเข้าของผลงานเหล่านี้ในระยะเวลาที่จำกัด
+ (ตรวจสอบโพสต์ประกาศเกี่ยวกับไฟล์ของคุณอีกครั้ง) หากคุณได้อัปโหลดสำเนาของงานเหล่านี้แล้ว
+ และคุณ%{negation}ได้ ใช้ฟีเจอร์การนำเข้าจาก URL จะมีสำเนาของผลงานเดียวกันนี้สองชุดใน
+ AO3
+ subject: "[%{app_name}] ผลงานถูกอัปโหลดแล้ว"
+ update_redirect:
+ contact_open_doors: ติดต่อโอเพนดอร์ส
+ html: หากคุณต้องการให้ Open Doors อัปเดตการเปลี่ยนเส้นทางให้ชี้ไปยังผลงานที่มีอยู่แล้วของคุณ
+ โปรดลบสำเนาที่ถูกนำเข้า และ %{contact_open_doors_link} พร้อมชื่อบัญชี AO3
+ ของคุณ ชื่อบัญชีของคุณในไฟล์ที่ถูกนำเข้า และชื่อกับ URL ของแฟนเวิร์คที่คุณต้องการเปลี่ยนเส้นทางไปหา
+ (หากคุณมีผลงานหลายชิ้นที่คุณต้องการเปลี่ยนเส้นทาง คุณสามารถแสดงรายการเหล่านี้ไว้ในอีเมลฉบับเดียวได้)
+ text: หากคุณต้องการให้ Open Doors อัปเดตการเปลี่ยนเส้นทางให้ชี้ไปยังงานที่มีอยู่แล้วของคุณ
+ โปรดลบสำเนาที่นำเข้า และติดต่อ Open Doors ที่ %{open_doors_url} พร้อมชื่อบัญชี
+ AO3 ของคุณ ชื่อบัญชีของคุณในไฟล์เก็บถาวรที่นำเข้า และ ชื่อกับ URL ของแฟนเวิร์คที่คุณต้องการให้เปลี่ยนเส้นทางไป
+ (หากคุณมีผลงานหลายชิ้นที่คุณต้องการเปลี่ยนเส้นทาง คุณสามารถแสดงรายการเหล่านี้ไว้ในอีเมลฉบับเดียว)
+ works_by: 'ผลงานเหล่านี้เขียนขึ้นภายใต้อีเมล: %{email}'
+ work_info:
+ html: "%{work_link} (%{fandom})"
+ text:
+ no_fandom: "- %{work_title} %{work_url}"
+ with_fandom: "- %{work_title} %{work_url} (%{fandom})"
+ collection_notification:
+ assignments_sent:
+ complete: ผลงานที่ได้รับมอบหมายทั้งหมดถูกส่งออกไปแล้ว
+ subject: ผลงานที่ได้รับมอบหมายถูกส่งออกไปแล้ว
+ html:
+ received_message: 'คุณได้รับข้อความเกี่ยวกับคอลเลกชันของคุณ %{collection_link}:'
+ text:
+ received_message: 'คุณได้รับข้อความเกี่ยวกับคอลเลกชันของคุณ "%{collection_title}"
+ (%{collection_url}):'
+ creatorship_notification:
+ explanation: เมื่อคุณได้รับมอบหมายให้เป็นผู้ร่วมสร้างผลงาน คุณจะถูกเพิ่มเข้าบทถัดๆ
+ ไปของผลงานนั้นได้ ไม่ว่าคุณจะตั้งค่าผู้ร่วมสร้างผลงานไว้แบบไหนก็ตาม และถ้าผลงานนั้นถูกเพิ่มเข้าไปในซีรีย์ใดๆ
+ คุณก็จะถูกเพิ่มไปยังซีรีย์นั้นเช่นกัน
+ html:
+ creation: "%{creation_link} โดย %{pseud_links}"
+ edit_chapter: แก้ไขบท
+ edit_series: แก้ไขซีรีย์
+ remove_chapter: หากมีความผิดพลาดเกิดขึ้นและคุณไม่ต้องการถูกมอบหมายให้เป็นผู้สร้างผลงานแล้ว
+ คุณสามารถ %{edit_chapter_link} เพื่อถอนตัวออกจากการเป็นผู้สร้างผลงานได้
+ remove_series: หากมีความผิดพลาดเกิดขึ้นและคุณไม่ต้องการถูกมอบหมายให้เป็นผู้สร้างผลงานแล้ว
+ คุณสามารถ %{edit_series_link} เพื่อถอนตัวออกจากการเป็นผู้สร้างผลงานได้
+ intro_chapter: 'ผู้ใช้ %{adding_user} ได้มอบหมายให้นามแฝง %{pseud} ของคุณเป็นผู้ร่วมสร้างผลงานของบทต่อไปนี้:'
+ intro_series: 'ผู้ใช้ %{adding_user} ได้มอบหมายให้นามแฝง %{pseud} ของคุณให้เป็นผู้ร่วมสร้างผลงานของซีรีย์ต่อไปนี้:'
+ subject: "[%{app_name}] ข้อความแจ้งเตือนผู้ร่วมสร้างผลงาน"
+ text:
+ creation: "%{title} (%{url}) โดย %{pseuds}"
+ remove_chapter: 'หากมีความผิดพลาดเกิดขึ้นและคุณไม่ต้องการถูกมอบหมายให้เป็นผู้สร้างผลงานแล้ว
+ คุณสามารถแก้ไขบทเพื่อถอนตัวออกจากการเป็นผู้สร้างผลงานได้: %{url}'
+ remove_series: 'หากมีความผิดพลาดเกิดขึ้นและคุณไม่ต้องการถูกมอบหมายให้เป็นผู้สร้างผลงานแล้ว
+ คุณสามารถแก้ไขซีรีย์เพื่อถอนตัวออกจากการเป็นผู้สร้างผลงานได้: %{url}'
+ creatorship_notification_archivist:
+ explanation: เพราะพวกเขาอยู่ในฐานะผู้จัดเก็บเอกสารของ Open Doors (โอเพ่นดอร์)
+ พวกเขาจึงได้รับอนุญาตให้เพิ่มคุณเป็นผู้ร่วมสร้างผลงานได้โดยไม่จำเป็นต้องส่งคำขอ
+ แม้ว่าคุณจะปิดตัวเลือกสำหรับการสร้างผลงานร่วมไว้ก็ตาม
+ html:
+ creation: "%{creation_link} โดย %{pseud_links}"
+ edit_chapter: แก้ไขตอน
+ edit_series: แก้ไขซีรีส์
+ edit_work: แก้ไขผลงาน
+ remove_chapter: หากคุณถูกเพิ่มเข้ามาอย่างไม่ได้ตั้งใจหรือไม่ต้องการลงรายชื่อ
+ คุณสามารถ %{edit_chapter_link} เพื่อลบชื่อคุณออกจากการเป็นผู้สร้างผลงาน
+ remove_series: หากคุณถูกเพิ่มเข้ามาอย่างไม่ได้ตั้งใจหรือไม่ต้องการลงรายชื่อ
+ คุณสามารถ %{edit_series_link} เพื่อลบชื่อคุณออกจากการเป็นผู้สร้างผลงาน
+ remove_work: หากคุณถูกเพิ่มเข้ามาอย่างไม่ได้ตั้งใจหรือไม่ต้องการลงรายชื่อ
+ คุณสามารถ %{edit_work_link} เพื่อลบชื่อคุณออกจากการเป็นผู้สร้างผลงาน
+ intro_chapter: 'ผู้ใช้งาน %{archivist} ได้เพิ่มนามแฝง %{pseud} ของคุณเป็นผู้ร่วมสร้างผลงานตอนต่อไปนี้:'
+ intro_series: 'ผู้ใช้งาน %{archivist} ได้เพิ่มนามแฝง %{pseud} ของคุณเป็นผู้ร่วมสร้างผลงานซีรีส์ต่อไปนี้:'
+ intro_work: 'ผู้ใช้งาน %{archivist} ได้เพิ่มนามแฝง %{pseud} ของคุณเป็นผู้ร่วมสร้างผลงานดังต่อไปนี้:'
+ subject: "[%{app_name}] การแจ้งเตือนจากผู้รวบรวมผลงานของผู้ร่วมสร้างผลงาน"
+ text:
+ creation: "%{title} (%{url}) โดย %{pseuds}"
+ remove_chapter: 'หากคุณถูกเพิ่มเข้ามาอย่างไม่ได้ตั้งใจหรือไม่ต้องการลงรายชื่อ
+ คุณสามารถแก้ไขตอนเพื่อลบชื่อคุณออกจากการเป็นผู้สร้างได้: %{url}'
+ remove_series: 'หากคุณถูกเพิ่มเข้ามาอย่างไม่ได้ตั้งใจหรือไม่ต้องการลงรายชื่อ
+ คุณสามารถแก้ไขซีรีส์เพื่อลบชื่อคุณออกจากการเป็นผู้สร้างได้: %{url}'
+ remove_work: 'หากคุณถูกเพิ่มเข้ามาอย่างไม่ได้ตั้งใจหรือไม่ต้องการลงรายชื่อ
+ คุณสามารถแก้ไขผลงานเพื่อลบชื่อคุณออกจากการเป็นผู้สร้างได้: %{url}'
+ creatorship_request:
+ html:
+ creation: "%{creation_link} โดย %{pseud_links}"
+ instructions: คุณสามารถยอมรับหรือปฏิเสธคำขอนี้ได้บนเพจ %{page_name} ของคุณ
+ page_name: Co-Creator Requests (คำขอเป็นผู้ร่วมสร้างผลงาน)
+ intro_chapter: 'ผู้ใช้งาน %{inviting_user} ได้ส่งคำเชิญให้นามแฝง %{pseud} ของคุณเป็นผู้ร่วมสร้างผลงานตอนต่อไปนี้:'
+ intro_series: 'ผู้ใช้งาน %{inviting_user} ได้ส่งคำเชิญให้นามแฝง %{pseud} ของคุณเป็นผู้ร่วมสร้างซีรีส์ต่อไปนี้:'
+ intro_work: 'ผู้ใช้งาน %{inviting_user} ได้ส่งคำเชิญให้นามแฝง %{pseud} ของคุณเป็นผู้ร่วมสร้างผลงานดังต่อไปนี้:'
+ subject: "[%{app_name}] คำขอเป็นผู้ร่วมสร้างผลงาน"
+ text:
+ creation: "%{title} (%{url}) โดย %{pseuds}"
+ instructions: 'คุณสามารถยอมรับหรือปฏิเสธคำขอนี้ได้บนเพจ Co-Creator Requests
+ (คำขอเป็นผู้ร่วมสร้างผลงาน) ของคุณ: %{url}'
+ delete_work_notification:
+ attachment: สิ่งที่แนบมาด้วยคือสำเนาผลงานของคุณสำหรับอ้างอิง
+ deleted_other:
+ html: ผลงาน %{title} ของคุณถูกลบตามคำขอของ %{pseud}
+ text: ผลงาน "%{title}" ของคุณถูกลบตามคำขอของ %{pseud}
+ deleted_yourself:
+ html: ผลงาน %{title} ของคุณถูกลบตามที่คุณขอแล้ว
+ text: ผลงาน "%{title}" ของคุณถูกลบตามที่คุณขอแล้ว
+ questions:
+ html: หากมีข้อสงสัยใดๆ กรุณา %{support}
+ text: หากมีข้อสงสัยใดๆ กรุณาติดต่อ %{support} (%{url})
+ subject: "[%{app_name}] ผลงานของคุณถูกลบแล้ว"
+ support: ติดต่อฝ่ายบริการช่วยเหลือ
+ invite_increase_notification:
+ html:
+ body:
+ other: เราขอแจ้งให้ทราบว่าคุณมี %{count} คำเชิญใหม่ซึ่งสามารถใช้สร้างบัญชีใหม่ใน
+ AO3 ได้ คุณสามารถเชิญเพื่อนหนึ่งคนได้ที่ %{invitation_page_link}
+ invitation_page_link_text: หน้าเพจ Invitations (คำเชิญ) ของคุณ
+ subject: "[%{app_name}] คำเชิญใหม่"
+ text:
+ body:
+ other: เราขอแจ้งให้ทราบว่าคุณมี %{count} คำเชิญใหม่ซึ่งสามารถใช้สร้างบัญชีใหม่ใน
+ AO3 ได้ คุณสามารถเชิญเพื่อนหนึ่งคนได้ที่เพจ Invitations (คำเชิญ) (%{invitation_page_url})
+ invite_request_declined:
+ main:
+ other: ขออภัยที่ไม่สามารถตอบรับคำขอของคุณสำหรับ %{count} คำเชิญใหม่ได้ในขณะนี้
+ reason: 'คำขอของคุณ:'
+ subject: "[%{app_name}] คำขอคำเชิญเพิ่มเติมของคุณถูกปฏิเสธ"
+ recipient_notification:
+ html:
+ collection: ผลงานของขวัญได้ถูกโพสต์ให้คุณแล้วในคอลเล็กชัน %{collection_link}
+ ที่ AO3!
+ no_collection: ผลงานของขวัญได้ถูกโพสต์ให้คุณแล้วใน AO3!
+ subject:
+ collection: "[%{app_name}][%{collection_title}] ผลงานของขวัญสำหรับคุณจาก %{collection_title}"
+ no_collection: "[%{app_name}] ผลงานของขวัญสำหรับคุณ"
+ text:
+ collection: ผลงานของขวัญได้ถูกโพสต์ให้คุณแล้วในคอลเล็กชัน "%{collection_title}"
+ (%{collection_url}) ที่ AO3!
+ signup_notification:
+ activate:
+ text: 'กรุณาคลิกลิงค์นี้เพื่อเปิดใช้บัญชีของคุณ: %{activate_account_url}'
+ admin_posts: ข่าว AO3
+ bye: ขอให้คุณมีความสุขกับการใช้ AO3 นะ
+ contact_support: ติดต่อฝ่ายช่วยเหลือ
+ faq: FAQ
+ features:
+ html: เมื่อเปิดใช้งานบัญชีได้แล้ว คุณสามารถโพสต์แฟนเวิร์คของคุณ, ตั้งค่าการติดตามผ่านอีเมลที่จะแจ้งเตือนคุณเมื่อผู้แต่งหรือผลงานมีอัปเดต,
+ ตั้งค่าการใช้งานหน้าตาเว็บไซต์และวิธีทำงานของมัน, ติดตามผลงานที่คุณเคยเข้าถึงใน
+ AO3 ผ่านประวัติส่วนตัวและอีกมากมายได้เลย
+ text: เมื่อเปิดใช้งานบัญชีได้แล้ว คุณสามารถโพสต์แฟนเวิร์คของคุณ, ตั้งค่าการติดตามผ่านอีเมลที่จะแจ้งเตือนคุณเมื่อผู้แต่งหรือผลงานมีอัปเดต,
+ ตั้งค่าการใช้งานหน้าตาเว็บไซต์และวิธีทำงานของมัน, ติดตามผลงานที่คุณเคยเข้าถึงใน
+ AO3 ผ่านประวัติส่วนตัวและอีกมากมายได้เลย
+ information:
+ html: มีข้อมูลและคำแนะนำในการใช้ AO3 มากมายใน %{faq_link} ของเรา อ่านข่าวล่าสุดเกี่ยวกับการพัฒนาเว็บไซต์ได้ทาง
+ %{admin_posts_link} ถ้าคุณต้องการความช่วยเหลือเพิ่มเติมเช่น บังเอิญเจอบั๊กหรือมีคำถามหรือมีความเห็น
+ กรุณา %{contact_support_link} ซึ่งยินดีช่วยเหลือตลอดเวลา
+ text: 'มีข้อมูลและคำแนะนำในการใช้ AO3 มากมายใน %{faq_url} อ่านข่าวล่าสุดเกี่ยวกับการพัฒนาเว็บไซต์ได้ในข่าว
+ AO3 ทาง %{admin_posts_url} ถ้าคุณต้องการความช่วยเหลือเพิ่มเติมเช่น บังเอิญเจอบั๊กหรือมีคำถามหรือมีความเห็น
+ กรุณาติดต่อทีมช่วยเหลือของเราซึ่งยินดีช่วยเหลือตลอดเวลา: %{contact_support_url}'
+ welcome: ยินดีต้อนรับสู่ AO3 %{login} !
diff --git a/config/locales/phrase-exports/tr.yml b/config/locales/phrase-exports/tr.yml
new file mode 100755
index 0000000..2d916c9
--- /dev/null
+++ b/config/locales/phrase-exports/tr.yml
@@ -0,0 +1,520 @@
+---
+tr:
+ activerecord:
+ attributes:
+ archive_warning:
+ name_with_colon:
+ one: 'Uyarı:'
+ other: 'Uyarılar:'
+ category:
+ name_with_colon:
+ one: 'Kategori:'
+ other: 'Kategoriler:'
+ character:
+ name_with_colon:
+ one: 'Karakter:'
+ other: 'Karakterler:'
+ fandom:
+ name_with_colon:
+ one: 'Fandom:'
+ other: 'Fandomlar:'
+ freeform:
+ name_with_colon:
+ one: 'İlave Etiket:'
+ other: 'İlave Etiketler:'
+ rating:
+ name_with_colon: 'Kitle:'
+ relationship:
+ name_with_colon:
+ one: 'İlişki:'
+ other: 'İlişkiler:'
+ work:
+ chapter_total_display: Bölümler
+ summary: Özet
+ models:
+ archive_warning:
+ one: Uyarı
+ other: Uyarılar
+ category:
+ one: Kategori
+ other: Kategoriler
+ chapter:
+ one: Bölüm
+ other: Bölümler
+ character:
+ one: Karakter
+ other: Karakterler
+ fandom:
+ one: Fandom
+ other: Fandomlar
+ freeform:
+ one: İlave Etiket
+ other: İlave Etiketler
+ rating:
+ one: Kitle
+ other: Kitle
+ relationship:
+ one: İlişki
+ other: İlişkiler
+ series:
+ one: Dizi
+ other: Diziler
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ one: bir ziyaretçi
+ other: "%{count} ziyaretçi"
+ left_kudos:
+ html:
+ one: "%{givers_list}, %{commentable_link} eserinize kalp bıraktı."
+ other: "%{givers_list}, %{commentable_link} eserinize kalp bıraktı."
+ text:
+ one: "%{givers_list}, %{commentable_title} (%{commentable_url}) eserinize
+ kalp bıraktı."
+ other: "%{givers_list}, %{commentable_title} (%{commentable_url}) eserinize
+ kalp bıraktı."
+ single_guest:
+ giver: Bir ziyaretçi
+ html: "%{giver}, %{commentable_link} eserinize kalp bıraktı."
+ text: Bir ziyaretçi %{commentable_title} (%{commentable_url}) eserinize kalp
+ bıraktı.
+ subject: "[%{app_name}] Kalp aldınız!"
+ mailer:
+ general:
+ closing:
+ formal: En içten dileklerimizle,
+ informal: İyi dileklerimizle,
+ creation:
+ link_with_word_count: "%{creation_link} (%{word_count})"
+ title_with_chapter_number: "%{title} 'ın %{position} . bölümü"
+ title_with_word_count: '"%{creation_title}" (%{word_count})'
+ word_count:
+ one: "%{count} kelime"
+ other: "%{count} kelime"
+ footer:
+ general:
+ about:
+ html: AO3 hayranlar tarafından yönetilen ve desteklenen bir arşivdir,
+ varlığını sürdürmesini sağlayan şey %{donate_link}.
+ text: 'AO3 hayranlar tarafından yönetilen, hayranlar tarafından desteklenen
+ ve sizin bağışlarınızla varlığını sürdüren bir arşivdir: %{donate_url}.'
+ html:
+ donate_link_text: sizin bağışlarınızdır
+ support_link_text: Teknik Destek ile iletişime geçin
+ unwanted_email:
+ html: Eğer bu mesajı bir hata sonucu aldıysanız lütfen %{support_link}.
+ text: Eğer bu mesajı bir hata sonucu aldıysanız lütfen %{support_url}
+ adresinden Teknik Destek ile iletişime geçin.
+ sent_at: "%{sent_at} tarihinde gönderilmiştir."
+ greeting:
+ formal_html: Sayın %{name},
+ informal:
+ addressed_html: Merhaba %{name}!
+ unaddressed: Merhaba!
+ introductory: Archive of Our Own – AO3 (Kendimize Ait Bir Arşiv)ten selamlar!
+ metadata_label_indicator: ":"
+ signature:
+ abuse_team: AO3 Poliçe ve Kötüye Kullanım ekibi
+ app_short_name: AO3
+ open_doors: Open Doors (Açık Kapılar) ekibi
+ parent_org: Organization for Transformative Works – OTW (Transformatif Eserler
+ Derneği)
+ support: AO3 Teknik Destek ekibi
+ users:
+ mailer:
+ reset_password_instructions:
+ expiration: Şifrenizi sıfırlamak için yukarıdaki linki bir hafta içinde kullanmadığınız
+ takdirde linkin süresi dolacak ve yeni bir talepte bulunmanız gerekecektir.
+ intro: 'Hesabınız için şifre sıfırlama talebinde bulunuldu. Aşağıdaki bağlantıyı
+ takip edip yeni şifrenizi girerek hesabınızın şifresini değiştirebilirsiniz:'
+ link_title: Şifremi değiştir.
+ subject: "[%{app_name}] Şifrenizi sıfırlayın"
+ unrequested: Eğer şifre sıfırlama talebinde bulunmadıysanız bu e-postayı görmezden
+ gelerek önceki şifrenizi kullanmaya devam edebilirsiniz.
+ user_mailer:
+ admin_deleted_work_notification:
+ bye: Referans için eserinizin bir kopyası ektedir.
+ contact_abuse: Poliçe ve Kötüye Kullanım Komitesi ile iletişime geçin
+ deleted:
+ html: Eseriniz, %{title}, bir site yöneticisi tarafından AO3’ten silinmiştir.
+ text: Eseriniz "%{title}" bir site yöneticisi tarafından AO3’ten silinmiştir.
+ html:
+ tos_violation: Eğer eserinizin AO3’ün Kullanım Şartlarını ihlal etmiş olma
+ ihtimali var ise lütfen %{contact_abuse_link}.
+ import_project:
+ html: Eğer eseriniz, Open Doors (Açık Kapılar) ekibimiz tarafından yönetilen
+ bir içe aktarma projesinin parçası ise lütfen sorularınız için %{opendoors_link}.
+ text: Eğer eseriniz Open Doors (Açık Kapılar) ekibimiz tarafından yönetilen
+ bir içe aktarma projesinin parçası ise lütfen sorularınız için Açık Kapılar
+ (%{opendoors_link}) ile iletişime geçin.
+ opendoors: Açık Kapılar ile iletişime geçin
+ subject: "[%{app_name}] Eseriniz bir yönetici tarafından silinmiştir"
+ text:
+ tos_violation: Eğer eserinizin AO3’ün Kullanım Şartlarını ihlal etmiş olma
+ ihtimali var ise lütfen Poliçe ve Kötüye Kullanım Komitesi (%{contact_abuse_url})
+ ile iletişime geçin.
+ admin_hidden_work_notification:
+ access: Eseriniz gizliyken de yukarıda verilen link üzerinden ona erişim sağlamanız
+ mümkün olacak, fakat eser çalışma sayfanızda listelenmeyecek ve AO3'ün diğer
+ kullanıcıları esere erişemiyor olacak.
+ check_email: Poliçe ve Kötüye Kullanım Komitesi eserinizin neden gizlendiğini
+ açıklamak için sizinle daha önce iletişime geçmiş olabilir. Bu nedenle lütfen,
+ spam klasörünüz de dahil olmak üzere, e-postalarınızı kontrol edin.
+ contact_abuse: Poliçe ve Kötüye Kullanım Komitesi’yle iletişime geçin
+ html:
+ help: Eserinizin neden gizlendiğinden emin değilseniz ve bu konuyla ilgili
+ herhangi başka bilgi almadıysanız, lütfen doğrudan %{contact_abuse_link}.
+ hidden: "%{title} adlı eseriniz Poliçe ve Kötüye Kullanım Komitesi tarafından
+ gizlenmiştir ve artık herkese açık değildir."
+ tos_violation: Eseriniz, AO3’ün %{tos_link} ihlali nedeniyle gizlendiyse,
+ ihlali düzeltmek için sizin aksiyon almanız gerekecektir. Eserinizin Kullanım
+ Şartları’na uygun hale getirilmemesi, eserinizin AO3'ten silinmesine neden
+ olabilir.
+ subject: "[%{app_name}] eseriniz Poliçe ve Kötüye Kullanım Komitesi tarafından
+ gizlenmiştir"
+ text:
+ help: 'Eserinizin neden gizlendiğinden emin değilseniz ve bu konuyla ilgili
+ başka bilgi almadıysanız, lütfen doğrudan Poliçe ve Kötüye Kullanım Komitesi’yle
+ iletişime geçin: %{contact_abuse_url}.'
+ hidden: '"%{title}" (%{work_url}) adlı eseriniz Poliçe ve Kötüye Kullanım
+ Komitesi tarafından gizlenmiştir ve artık herkese açık değildir.'
+ tos_violation: Eseriniz, AO3 Kullanım Şartları’nın (%{tos_url}) ihlali nedeniyle
+ gizlendiyse, ihlali düzeltmek için sizin aksiyon almanız gerekecektir. Eserinizin
+ Kullanım Şartları’na uygun hale getirilmemesi, eserinizin AO3'ten silinmesine
+ neden olabilir.
+ tos: Kullanım Şartları
+ anonymous_or_unrevealed_notification:
+ anonymous_info: Anonim eserler etiket listelerine dahil edilir, ancak sizin
+ eserler sayfanızda gösterilmez. Eserde bulunan kullanıcı adınız ise "Anonymous"
+ (Anonim) olarak değiştirilir.
+ anonymous_unrevealed_info: İlerleyen dönemlerde koleksiyon sorumluları eserinizin
+ durumunu gizli olmaktan çıkarıp eseri genele açabilir, ancak eserin anonim
+ durumunu koruyabilir. Size abone olan kişiler böylesi bir değişiklikten haberdar
+ edilmez. Eser genel erişime açıldığında, eseriniz etiket listelerine dahil
+ edilir, ancak sizin eserler sayfanızda gösterilmez. Eserde kullanıcı adınız
+ "Anonymous" (Anonim) ile değiştirilir.
+ changed_status:
+ anonymous:
+ html: "%{collection_link} koleksiyonunu sorumluları, %{work_link} eserinizin
+ durumunu anonim olarak değiştirdi."
+ text: '"%{collection_title}" (%{collection_url}) koleksiyonunun sorumluları
+ "%{work_title}" (%{work_url}) eserinizin durumunu anonim olarak değiştirdi.'
+ anonymous_unrevealed:
+ html: "%{collection_link}’ın koleksiyon sorumluları, %{work_link} eserinizin
+ durumunu anonim ve gizli olarak değiştirdi."
+ text: '"%{collection_title}" (%{collection_url}) koleksiyonunun sorumluları"%{work_title}"
+ (%{work_url}) eserinizin durumunu anonim ve gizli olarak değiştirdi.'
+ unrevealed:
+ html: "%{collection_link}’ın koleksiyon sorumluları, %{work_link} eserinizin
+ durumunu gizli olarak değiştirdi."
+ text: '"%{collection_title}" (%{collection_url}) koleksiyonunun sorumluları
+ "%{work_title}" (%{work_url}) eserinizin durumunu gizli olarak değiştirdi.'
+ collection_items_link_text: Approved Collection Items (Onaylı Koleksiyon Öğeleri)
+ sayfası
+ do_not_want:
+ anonymous:
+ html: Eserinizin anonim olmasını istemiyorsanız, eserinizi bu koleksiyondan
+ çıkarmak için %{collection_items_link} sayfasını ziyaret ediniz.
+ text: 'Eserinizin anonim olmasını istemiyorsanız, eserinizi bu koleksiyondan
+ çıkarmak için Approved Collection Items (Onaylı Koleksiyon Öğeleri) sayfanızı
+ ziyaret edebilirsiniz: %{collection_items_url}'
+ anonymous_unrevealed:
+ html: Eserinizin anonim ve gizli olmasını istemiyorsanız, eserinizi bu koleksiyondan
+ çıkarmak için lütfen %{collection_items_link} sayfasını ziyaret ediniz.
+ text: 'Eserinizin anonim ve gizli olmasını istemiyorsanız, eserinizi bu
+ koleksiyondan çıkarmak için Approved Collection Items (Onaylı Koleksiyon
+ Öğeleri) sayfanızı ziyaret edebilirsiniz: %{collection_items_url}'
+ unrevealed:
+ html: Eserinizin gizli olmasını istemiyorsanız, eserinizi bu koleksiyondan
+ çıkarmak için lütfen %{collection_items_link} sayfasını ziyaret ediniz.
+ text: 'Eserinizin gizli olmasını istemiyorsanız, eserinizi bu koleksiyondan
+ çıkarmak için Approved Collection Items (Onaylı Koleksiyon Öğeleri) sayfanızı
+ ziyaret edebilirsiniz: %{collection_items_url}'
+ faq_link_text: Koleksiyonlar SSS
+ more_info:
+ html: Daha fazla bilgi için, %{faq_link} sayfasına göz atabilirsiniz.
+ text: 'Daha fazla bilgi için, Koleksiyonlar SSS sayfasına göz atabilirsiniz:
+ %{faq_url}'
+ subject:
+ anonymous: "[%{app_name}] Eseriniz anonim hale getirildi"
+ anonymous_unrevealed: "[%{app_name}] Eseriniz anonim ve gizli hale getirildi"
+ unrevealed: "[%{app_name}] Eseriniz gizli hale getirildi"
+ unrevealed_info: Gizli eserler etiket listelerinde veya eser sayfanızda yer
+ almaz. Eser bağlantısını takip eden herkes, eserin şu anda gizli olduğuna
+ dair bir bildirimle karşılaşır ve içeriğine erişim sağlayamazlar.
+ archivist_added_to_collection_notification:
+ approved_collection_items_page: Approved Collection Items (Onaylanan Koleksiyon
+ Ögeleri) sayfası
+ archivist_notice: Siz koleksiyon davetlerini devre dışı bırakmış olsanız bile,
+ koleksiyon yöneticilerinin Open Doors (Açık Kapılar) arşivciliği kapsamındaki
+ yetkileri dahilinde eserinizi bu koleksiyona ekleme izni bulunmaktadır. Arşivciler
+ bir eseri yalnızca eser içe aktarılan bir arşivde barındırılıyorsa bir koleksiyona
+ eklemektedir.
+ removal_instructions:
+ html: Eserinizi bu koleksiyondan çıkarmak isterseniz, lütfen %{approved_items_link}
+ linkini ziyaret edin.
+ text: 'Eserinizi bu koleksiyondan çıkarmak isterseniz, lütfen Approved Collection
+ Items (Onaylanan Koleksiyon Ögeleri) sayfanızı ziyaret edin: %{approved_items_url}.'
+ subject: "[%{app_name}][%{collection_title}] Bir Open Doors (Açık Kapılar) arşivcisi
+ eserinizi bir koleksiyona ekledi"
+ work_added:
+ html: "%{collection_link} koleksiyonunun yöneticileri %{work_link} eserinizi
+ koleksiyona ekledi!"
+ text: '"%{collection_title}" (%{collection_url}) koleksiyonunun yöneticileri
+ "%{work_title}" (%{work_url}) eserinizi koleksiyonlarına ekledi!'
+ challenge_assignment_notification:
+ any: Herhangi bir
+ assignment:
+ html: AO3'teki %{link} oyununda size aşağıdaki istek atandı!
+ description: 'Tanım:'
+ due: 'Bu görevin teslim tarihi:'
+ html:
+ footer: Bu e-postayı, %{title} oyununa kaydolduğunuz için aldınız. Bu oyun
+ hakkında daha fazla bilgi ve moderatörlerin iletişim bilgileri için lütfen
+ %{footer_link} adresini ziyaret edin.
+ footer_link: oyun profil sayfası
+ look_up: Bu görevi %{link} adresinden arayabilirsiniz.
+ look_up_link: Assignments (Görevler) sayfanız
+ optional_tags: 'İsteğe Bağlı Etiketler:'
+ prompts: 'Fikirler:'
+ prompt_url: 'Fikir URL''si:'
+ recipient: 'Alıcı:'
+ recipient_missing: 'Hiç kimse: yardım için bir moderatörle iletişime geçin!'
+ subject: "[%{app_name}][%{collection_title}] Göreviniz!"
+ text:
+ assignment: AO3'teki "%{collection_title}" oyununda (%{collection_url}) size
+ aşağıdaki istek atandı!
+ footer: Bu e-postayı, %{title} oyununa (%{url}) kaydolduğunuz için aldınız.
+ Bu oyun hakkında daha fazla bilgi ve moderatörlerin iletişim bilgileri için
+ lütfen %{profile_url} adresini ziyaret edin.
+ look_up: Bu görevi, %{link} adresindeki Assignments (Görevler) sayfanızdan
+ arayabilirsiniz.
+ change_email:
+ changed:
+ html: "%{login}, hesabınızla ilişkili e-posta adresiniz %{email} olarak değiştirilmiştir."
+ text: "%{login}, hesabınızla ilişkili e-posta adresiniz %{email} olarak değiştirilmiştir."
+ subject: "[%{app_name}] E-posta adresiniz değiştirildi"
+ claim_notification:
+ access:
+ contact_support: AO3 Teknik Destek Komitesi ile iletişime geçin
+ html: Arşive bağlı olarak eserleriniz yalnızca kayıtlı kullanıcılara açık
+ olacak şekilde aktarılmış olabilir (Bu Google aramalarında gözükmesini engellemek
+ için yapılır). Eğer durum böyleyse, siz tamamen görünür yapmadığınız takdirde
+ eserlerinize yalnızca giriş yapmış kullanıcılar erişebilecek. Kilidi kaldırmak,
+ terk etmek veya eserlerinizi silmek konusunda yardım için, lütfen %{contact_support_link}.
+ text: Arşive bağlı olarak eserleriniz yalnızca kayıtlı kullanıcılara açık
+ olacak şekilde aktarılmış olabilir (Bu Google aramalarında gözükmesini engellemek
+ için yapılır). Eğer durum böyleyse, siz tamamen görünür yapmadığınız takdirde
+ eserlerinize yalnızca giriş yapmış kullanıcılar erişebilecek. Kilidi kaldırmak,
+ terk etmek veya eserlerinizi silmek konusunda yardım için, lütfen %{support_url}'den
+ AO3 Teknik Destek Komitesi ile iletişime geçin.
+ email_tips: Bizimle iletişime geçiyorsanız lütfen @transformativeworks.org'daki
+ e-posta adreslerini güvenli kişiler listenize ekleyin ve yanıtımız için spam
+ klasörlerinizi kontrol edin.
+ introduction:
+ ao3_name: Archive of Our Own – AO3 (Kendimize Ait Bir Arşiv)
+ html: Bu e-postayı alıyorsunuz çünkü bir hayran eseri arşivinde %{open_doors_name_link}
+ tarafından %{app_link}'e aktarılan eserleriniz bulunmakta. Bu e-posta adresi
+ aktarılan arşivde kayıtlı olan bir adrese bağlı olduğundan, ilgili hayran
+ eserleri (aşağıda listelenmiştir) otomatik olarak AO3 hesabınıza eklenmiştir.
+ open_doors_name: Open Doors (Açık Kapılar)
+ text: 'Bu e-postayı alıyorsunuz çünkü bir hayran eseri arşivinde Open Doors
+ (Açık Kapılar) (%{open_doors_url}) tarafından Archive of Our Own – AO3 (Kendimize
+ Ait Bir Arşiv): %{app_url}''e aktarılan eserleriniz bulunmakta. Bu e-posta
+ adresi aktarılan arşivde kayıtlı olan bir adrese bağlı olduğundan, ilgili
+ hayran eserleri (aşağıda listelenmiştir) otomatik olarak AO3 hesabınıza
+ eklenmiştir.'
+ mistake:
+ contact_open_doors: Açık Kapılar ile iletişime geçin
+ html: Eğer bu bir hataysa ve bunlar sizin eserleriniz değilse, lütfen onları
+ silmeyin! Lütfen %{contact_open_doors_link} ve biz düzeltelim.
+ text: Eğer bu bir hataysa ve bunlar sizin eserleriniz değilse, lütfen onları
+ silmeyin! Lütfen Açık Kapılar ile iletişime geçin (%{open_doors_url}) ve
+ biz düzeltelim.
+ more_info:
+ ao3_news: AO3 Duyuruları
+ contact_support: AO3 Teknik Destek Komitesi ile iletişime geçin.
+ faq_page: SSS Sayfası
+ html: Yakın zamandaki arşiv hareketleri hakkındaki duyuruları %{ao3_news_link}
+ adresinden okuyabilir ve daha fazla bilgiyi Açık Kapılar'ın %{faq_page_link}
+ veya %{tutorial_page_link} adresinde bulabilirsiniz. SSS'lerde, eğitimlerde
+ ya da bu e-postada yanıtlanmayan sorularınız için, lütfen %{contact_support_link}
+ text: Yakın zamandaki arşiv hareketleri hakkındaki duyuruları AO3 Duyurularından
+ (%{news_url}) okuyabilir ve daha fazla bilgiyi Açık Kapılar'ın SSS sayfası
+ (%{open_doors_faq_url}) veya eğitimler sayfasından (%{open_doors_tutorial_url})
+ bulabilirsiniz. SSS'lerde, eğitimlerde ya da bu e-postada yanıtlanmayan
+ sorularınız için, %{support_url}'den Teknik Destek Komitesi ile iletişime
+ geçin.
+ tutorial_page: eğitim sayfası
+ other_works:
+ contact_open_doors: Açık Kapılar ile iletişime geçin
+ html: Eğer aktarılan arşivde artık erişemediğiniz bir e-posta altında eserleriniz
+ varsa, lütfen kimliğinizi onaylamaya yardımcı bilgiler ile %{contact_open_doors_link}.
+ text: Eğer aktarılan arşivde artık erişemediğiniz bir e-posta altında eserleriniz
+ varsa, lütfen kimliğinizi onaylamaya yardımcı bilgilerle Açık Kapılar ile
+ iletişime geçin.
+ questions:
+ contact_support: AO3 Teknik Destek Komitesi ile iletişime geçin
+ html: Diğer sorularınız için lütfen %{contact_support_link}.
+ text: Diğer sorularınız için lütfen %{support_url}'den AO3 Teknik Destek Komitesi
+ ile iletişime geçin.
+ redirects:
+ html: Öneri listeleri ve yer işaretlerini korumak için, aktarılan arşivin
+ web adresi kısa bir süreliğine bu eserlerin aktarılan bir kopyasına yönlendirebilir
+ (emin olmak için arşivinizdeki duyuru gönderilerini kontrol ediniz). Eğer
+ çoktan bu eserin bir kopyasını yüklediyseniz ve URL'den aktar özelliğini
+ %{negation}, AO3'te aynı eserin iki kopyası olacaktır.
+ subject: "[%{app_name}] Yüklenen eserler"
+ update_redirect:
+ contact_open_doors: Açık Kapılar ile iletişime geçin
+ html: Açık Kapılar'ın yönlendirmeyi önceden var olan eserinize işaret edecek
+ şekilde güncellemesini istiyorsanız, lütfen aktarılan kopyayı silin ve AO3
+ hesap adınız, aktarılan arşivdeki hesap adınız ve yönlendirmek istediğiniz
+ hayran eserinin adını ve URL'si ile %{contact_open_doors_link}. (Yönlendirmelerini
+ değiştirmek istediğiniz birden fazla eser varsa, bunları tek e-postada belirtebilirsiniz.)
+ text: Açık Kapılar'ın yönlendirmeyi önceden var olan eserinize işaret edecek
+ şekilde güncellemesini istiyorsanız, lütfen aktarılan kopyayı silin ve AO3
+ hesap adınız, aktarılan arşivdeki hesap adınız ve yönlendirmek istediğiniz
+ hayran eserinin adını ve URL'si ile %{open_doors_url} linkinden Açık Kapılar
+ ile iletişime geçin. (Yönlendirmelerini değiştirmek istediğiniz birden fazla
+ eser varsa, bunları tek e-postada belirtebilirsiniz.)
+ works_by: 'Eserler bu e-posta altında yazılmıştır: %{email}'
+ work_info:
+ html: "%{work_link} (%{fandom})"
+ text:
+ no_fandom: "- %{work_title} %{work_url}"
+ with_fandom: "- %{work_title} %{work_url} (%{fandom})"
+ collection_notification:
+ assignments_sent:
+ complete: Bütün atamalar gönderildi.
+ subject: Atamalar gönderildi
+ html:
+ received_message: "%{collection_link} koleksiyonunuz hakkında bir mesaj aldınız:"
+ text:
+ received_message: '"%{collection_title}" koleksiyonunuz (%{collection_url})
+ hakkında bir mesaj aldınız:'
+ creatorship_notification:
+ explanation: Bir eserin ortak yazarı olduğunuzda, ortak yazarlık ayarlarınızdan
+ bağımsız olarak yeni bölümlere ekleyebilirsiniz. Ayrıca çalışmanın eklendiği
+ herhangi bir diziye de ekleneceksiniz.
+ html:
+ creation: "%{pseud_links} tarafından %{creation_link}"
+ edit_chapter: bölümü düzenleyerek
+ edit_series: diziyi düzenleyerek
+ remove_chapter: Yanlışlıkla eklendiyseniz veya yazar olarak listelenmek istemiyorsanız,
+ %{edit_chapter_link} kendinizi yazarlıktan kaldırabilirsiniz.
+ remove_series: Yanlışlıkla eklendiyseniz veya yazar olarak listelenmek istemiyorsanız,
+ %{edit_series_link} kendinizi yazarlıktan kaldırabilirsiniz.
+ intro_chapter: "%{adding_user} kullanıcısı %{pseud} takma adınızı aşağıdaki
+ bölümde ortak yazar olarak listeledi:"
+ intro_series: "%{adding_user} kullanıcısı %{pseud} takma adınızı aşağıdaki dizide
+ ortak yazar olarak listeledi:"
+ subject: "[%{app_name}] Ortak yazar bildirimi"
+ text:
+ creation: "%{pseuds} tarafından %{title} (%{url})"
+ remove_chapter: 'Yanlışlıkla eklendiyseniz veya yazar olarak listelenmek istemiyorsanız,
+ kendinizi yazar olarak kaldırmak için bölümü düzenleyebilirsiniz: %{url}'
+ remove_series: 'Yanlışlıkla eklendiyseniz veya yazar olarak listelenmek istemiyorsanız,
+ kendinizi yazar olarak kaldırmak için diziyi düzenleyebilirsiniz: %{url}'
+ creatorship_notification_archivist:
+ explanation: Open Doors (Açık Kapılar) arşivcileri, onlara verilen yetki dahilinde
+ ortaklığı devre dışı bırakmış olsanız bile sizi talep olmadan ortak yaratıcı
+ olarak ekleme hakkına sahiptirler.
+ html:
+ creation: "%{pseud_links} tarafından %{creation_link}"
+ edit_chapter: bölümü düzenleyebilirsiniz
+ edit_series: seriyi düzenleyebilirsiniz
+ edit_work: eseri düzenleyebilirsiniz
+ remove_chapter: Eğer yanlışlıkla eklendiyseniz veya yaratıcı olarak listelenmek
+ istemiyorsanız kendinizi yaratıcılıktan çıkarmak için %{edit_chapter_link}.
+ remove_series: Eğer yanlışlıkla eklendiyseniz veya yaratıcı olarak listelenmek
+ istemiyorsanız kendinizi yaratıcılıktan çıkarmak için %{edit_series_link}.
+ remove_work: Eğer yanlışlıkla eklendiyseniz veya yaratıcı olarak listelenmek
+ istemiyorsanız kendinizi yaratıcılıktan çıkarmak için %{edit_work_link}.
+ intro_chapter: "%{archivist} kullanıcısı %{pseud} takma adınızı ortak yaratıcı
+ olarak aşağıdaki bölüme ekledi:"
+ intro_series: "%{archivist} kullanıcısı %{pseud} takma adınızı ortak yaratıcı
+ olarak aşağıdaki seriye ekledi:"
+ intro_work: "%{archivist} kullanıcısı %{pseud} takma adınızı ortak yaratıcı
+ olarak aşağıdaki esere ekledi:"
+ subject: "[%{app_name}] Ortak yaratıcı arşivci bildirimi"
+ text:
+ creation: "%{pseuds} tarafından %{title} (%{url})"
+ remove_chapter: 'Eğer yanlışlıkla eklendiyseniz veya yaratıcı olarak listelenmek
+ istemiyorsanız kendinizi yaratıcılıktan çıkarmak için bölümü düzenleyebilirsiniz:
+ %{url}'
+ remove_series: 'Eğer yanlışlıkla eklendiyseniz veya yaratıcı olarak listelenmek
+ istemiyorsanız kendinizi yaratıcılıktan çıkarmak için seriyi düzenleyebilirsiniz:
+ %{url}'
+ remove_work: 'Eğer yanlışlıkla eklendiyseniz veya yaratıcı olarak listelenmek
+ istemiyorsanız kendinizi yaratıcılıktan çıkarmak için eseri düzenleyebilirsiniz:
+ %{url}'
+ creatorship_request:
+ html:
+ creation: "%{pseud_links} tarafından %{creation_link}"
+ instructions: Bu isteği %{page_name} sayfanızdan kabul edebilir veya reddedebilirsiniz.
+ page_name: Co-Creator Requests (Eş-Yazar İstekleri)
+ intro_chapter: 'Kullanıcı %{inviting_user}, %{pseud} takma adınızı eş-yazar
+ olarak aşağıdaki bölüme ekleme isteğinde bulundu:'
+ intro_series: 'Kullanıcı %{inviting_user}, %{pseud} takma adınızı eş-yazar olarak
+ aşağıdaki seriye ekleme isteğinde bulundu:'
+ intro_work: 'Kullanıcı %{inviting_user}, %{pseud} takma adınızı eş-yazar olarak
+ aşağıdaki esere ekleme isteğinde bulundu:'
+ subject: "[%{app_name}] Eş-yazar isteği"
+ text:
+ creation: "%{pseuds} tarafından %{title} (%{url})"
+ instructions: 'Bu isteği Co-Creator Requests (Eş-Yazar İstekleri) sayfanızdan
+ kabul edebilir veya reddedebilirsiniz: %{url}'
+ delete_work_notification:
+ attachment: Ekte referansınız için eserinizin bir kopyası bulunmaktadır.
+ deleted_other:
+ html: "%{title} eseriniz %{pseud} kullanıcısının isteği üzerine silindi."
+ text: '"%{title}" eseriniz %{pseud} kullanıcısının isteği üzerine silinmiştir.'
+ deleted_yourself:
+ html: "%{title} eseriniz isteğiniz üzerine silindi."
+ text: '"%{title}" eseriniz isteğiniz üzerine silinmiştir.'
+ questions:
+ html: Sorularınız varsa, lütfen %{support}.
+ text: Sorularınız varsa, lütfen %{support} (%{url}).
+ subject: "[%{app_name}] Eseriniz silinmiştir"
+ support: Teknik Destek Komitesi ile iletişime geçin
+ invite_increase_notification:
+ html:
+ body:
+ one: AO3'te yeni bir hesap oluşturmak için kullanabileceğiniz %{count} yeni
+ davetiyeniz olduğunu size bildirmek istedik. Dilerseniz %{invitation_page_link}
+ üzerinden bir arkadaşınızı davet edebilirsiniz.
+ other: AO3'te yeni hesaplar oluşturmak için kullanabileceğiniz %{count}
+ yeni davetiyeniz olduğunu size bildirmek istedik. Dilerseniz %{invitation_page_link}
+ üzerinden bir arkadaşınızı davet edebilirsiniz.
+ invitation_page_link_text: Invitations (Davetiyeler) sayfanız
+ subject: "[%{app_name}] Yeni Davetiyeler"
+ text:
+ body:
+ one: AO3'te yeni bir hesap oluşturmak için kullanabileceğiniz %{count} yeni
+ davetiyeniz olduğunu size bildirmek istedik. Dilerseniz Invitations (Davetiyeler)
+ sayfası (%{invitation_page_url}) üzerinden bir arkadaşınızı davet edebilirsiniz.
+ other: AO3'te yeni hesaplar oluşturmak için kullanabileceğiniz %{count}
+ yeni davetiyeniz olduğunu size bildirmek istedik. Dilerseniz Invitations
+ (Davetiyeler) sayfası (%{invitation_page_url}) üzerinden bir arkadaşınızı
+ davet edebilirsiniz.
+ invite_request_declined:
+ main:
+ one: Yeni bir davete ilişkin talebinizi şu anda yerine getiremeyeceğimizi
+ üzülerek bildiriyoruz.
+ other: "%{count} yeni davet talebinizi şu anda yerine getiremeyeceğimizi üzülerek
+ bildiriyoruz."
+ reason: 'Talebiniz şöyleydi:'
+ subject: "[%{app_name}] İlave Davet Kodu İsteğiniz Reddedildi"
+ recipient_notification:
+ html:
+ collection: AO3'te %{collection_link} koleksiyonunda sizin için hediye bir
+ hediye eser yayınlandı!
+ no_collection: AO3'te sizin için bir hediye eser yayınlandı!
+ subject:
+ collection: "[%{app_name}][%{collection_title}] %{collection_title} tarafından
+ size hediye edilmiş bir eser"
+ no_collection: "[%{app_name}] Size hediye edilmiş bir eser"
+ text:
+ collection: AO3'te "%{collection_title}" koleksiyonunda (%{collection_url})
+ sizin için hediye bir eser yayınlandı!
diff --git a/config/locales/phrase-exports/uk.yml b/config/locales/phrase-exports/uk.yml
new file mode 100755
index 0000000..d97f23d
--- /dev/null
+++ b/config/locales/phrase-exports/uk.yml
@@ -0,0 +1,667 @@
+---
+uk:
+ activerecord:
+ attributes:
+ archive_warning:
+ name_with_colon:
+ few: 'Попередження:'
+ many: 'Попередження:'
+ one: 'Попередження:'
+ category:
+ name_with_colon:
+ few: 'Категорії:'
+ many: 'Категорії:'
+ one: 'Категорія:'
+ character:
+ name_with_colon:
+ few: 'Персонажі:'
+ many: 'Персонажі:'
+ one: 'Персонаж:'
+ fandom:
+ name_with_colon:
+ few: 'Фандоми:'
+ many: 'Фандоми:'
+ one: 'Фандом:'
+ freeform:
+ name_with_colon:
+ few: 'Додаткові теги:'
+ many: 'Додаткові теги:'
+ one: 'Додатковий тег:'
+ rating:
+ name_with_colon: 'Рейтинг:'
+ relationship:
+ name_with_colon:
+ few: 'Стосунки:'
+ many: 'Стосунки:'
+ one: 'Стосунки:'
+ work:
+ chapter_total_display: Розділи
+ summary: Опис
+ models:
+ archive_warning:
+ few: Попередження
+ many: Попередження
+ one: Попередження
+ category:
+ few: Категорії
+ many: Категорії
+ one: Категорія
+ chapter:
+ few: Розділи
+ many: Розділи
+ one: Розділ
+ character:
+ few: Персонаж
+ many: Персонажі
+ one: Персонаж
+ fandom:
+ few: Фандоми
+ many: Фандоми
+ one: Фандом
+ freeform:
+ few: Додаткові теги
+ many: Додаткові теги
+ one: Додатковий тег
+ rating:
+ few: Рейтинги
+ many: Рейтинги
+ one: Рейтинг
+ relationship:
+ few: Стосунки
+ many: Стосунки
+ one: Стосунки
+ series:
+ few: Серії
+ many: Серії
+ one: Серія
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ few: "%{count} гостей"
+ many: "%{count} гостей"
+ one: "%{count} гостя"
+ left_kudos:
+ html:
+ few: Вподобайка для %{commentable_link} від %{givers_list}.
+ many: Вподобайка для %{commentable_link} від %{givers_list}.
+ one: Вподобайка для %{commentable_link} від %{givers_list}.
+ text:
+ few: Вподобайка для %{commentable_title} (%{commentable_url}) від %{givers_list}.
+ many: Вподобайка для %{commentable_title} (%{commentable_url}) від %{givers_list}.
+ one: Вподобайка для %{commentable_title} (%{commentable_url}) від %{givers_list}.
+ single_guest:
+ giver: 1-го гостя
+ html: Вподобайка для %{commentable_link} від %{giver}.
+ text: Вподобайка для %{commentable_title} (%{commentable_url}) від 1-го гостя.
+ subject: "[%{app_name}] Ви отримали вподобайку!"
+ mailer:
+ general:
+ closing:
+ formal: Щиро,
+ informal: Вcього найкращого,
+ creation:
+ link_with_word_count: "%{creation_link} (%{word_count})"
+ title_with_chapter_number: Розділ %{position} роботи %{title}
+ title_with_word_count: '"%{creation_title}" (%{word_count})'
+ word_count:
+ few: "%{count} слова"
+ many: "%{count} слів"
+ one: "%{count} слово"
+ footer:
+ about:
+ html: АО3 – це керований та забезпечуваний фанатами архів, який залежить від %{your_donations_link}.
+ text: 'АО3 – це керований та забезпечуваний фанатами архів, який залежить від Ваших пожертв: %{your_donations_url}.'
+ your_donations: Ваших пожертв
+ sent_at: Відправлено %{sent_at}.
+ why_policy_abuse:
+ contact_policy_abuse: 'зв''яжіться з Політикою та Зловживаннями'
+ html: Якщо Ви не розумієте, чому Ви отримали це повідомлення, будь ласка, %{contact_policy_abuse_link}.
+ text: 'Якщо Ви не розумієте, чому Ви отримали це повідомлення, будь ласка, зв''яжіться з Політикою та Зловживаннями: %{contact_policy_abuse_url}.'
+ why_support:
+ contact_support: 'зв''яжіться з Підтримкою'
+ html: Якщо Ви не розумієте, чому Ви отримали це повідомлення, будь ласка, %{contact_support_link}.
+ text: 'Якщо Ви не розумієте, чому Ви отримали це повідомлення, будь ласка, зв''яжіться з Підтримкою: %{contact_support_url}.'
+ greeting:
+ formal:
+ addressed_html: Вітаємо, %{name},
+ unaddressed: Вітаємо,
+ informal:
+ addressed_html: Привіт, %{name}!
+ unaddressed: Привіт!
+ introductory: Вітання з Archive of Our Own – AO3 (Нашого Власного Архіву)!
+ metadata_label_indicator: ":"
+ signature:
+ abuse_team: Команда Політики та зловживань AO3
+ app_short_name: AO3
+ open_doors: Команда Open Doors (Відкритих Дверей)
+ parent_org: Organization for Transformative Works – OTW (Організація Перетворчих Робіт)
+ support: Команда Підтримки АО3
+ users:
+ mailer:
+ reset_password_instructions:
+ expiration: Якщо Ви не використаєте це посилання протягом одного тижня, воно
+ стане недійсним, і Вам потрібно буде надіслати новий запит на зміну пароля.
+ intro: 'Хтось надіслав запит на зміну пароля для Вашого облікового запису.
+ Ви можете змінити пароль Вашого облікового запису, якщо натиснете на посилання
+ нижче і введете новий пароль:'
+ link_title: Змінити пароль
+ subject: "[%{app_name}] Запит на зміну пароля"
+ unrequested: Якщо Ви не подавали запит на зміну пароля, Ви можете проігнорувати
+ цей лист. Ваш минулий пароль продовжить бути дійсним.
+ user_mailer:
+ admin_deleted_work_notification:
+ bye: Для ознайомлення прикріплена копія Вашої роботи.
+ contact_abuse: зв'яжіться з нашим комітетом Політики і зловживань
+ deleted:
+ html: Вашу роботу %{title} було видалено з АО3 адміністратором сайту.
+ text: Вашу роботу “%{title}” було видалено з АО3 адміністратором сайту.
+ html:
+ tos_violation: Якщо можливо, що Ваша робота порушила Умови використання АО3,
+ будь ласка, %{contact_abuse_link}.
+ import_project:
+ html: Якщо Ваша робота була частиною проекту імпортування, організованого
+ Open Doors (Відкритими Дверима), будь ласка, %{opendoors_link} для роз’яснення
+ будь-яких подальших питань.
+ text: Якщо Ваша робота була частиною проекту імпортування, організованого
+ Open Doors (Відкритими Дверима), будь ласка, зверніться до Відкритих Дверей
+ (%{opendoors_link}) для роз’яснення будь-яких подальших питань.
+ opendoors: зверніться до Відкритих Дверей
+ subject: "[%{app_name}] Вашу роботу було видалено адміністратором"
+ text:
+ tos_violation: Якщо можливо, що Ваша робота порушила Умови використання АО3,
+ будь ласка, зв'яжіться з нашим комітетом Політики та зловживань (%{contact_abuse_url}).
+ admin_hidden_work_notification:
+ access: Хоча Вашу роботу приховано, Ви все одно маєте доступ до неї за посиланням,
+ наданим вище, але вона не відображатиметься на сторінці Ваших робіт, а інші
+ користувачі АО3 не матимуть до неї доступу.
+ check_email: Будь ласка, перевірте Вашу електронну пошту, включно зі спамом,
+ оскільки команда Політики та зловживань вже могла зв'язатися з Вами, щоб пояснити
+ чому Вашу роботу було приховано.
+ contact_abuse: Політикою та зловживаннями
+ html:
+ help: Якщо Ви не впевнені чому Вашу роботу було приховано, і Ви не отримали
+ жодних листів стосовно цього питання, будь ласка, зв'яжіться напряму з %{contact_abuse_link}.
+ hidden: Вашу роботу %{title} було приховано командою Політики та зловживань,
+ і до неї більше немає публічного доступу.
+ tos_violation: Якщо Вашу роботу було приховано через порушення %{tos_link}
+ AO3, Вам потрібно буде виправити ці порушення. Якщо Ви не зміните роботу
+ відповідно до вимог Умов використання, це може призвести до її видалення
+ з АО3.
+ subject: "[%{app_name}] Вашу роботу було приховано командою Політики та зловживань"
+ text:
+ help: 'Якщо Ви не впевнені чому Вашу роботу було приховано, і Ви не отримали
+ жодних листів стосовно цього питання, будь ласка, зв''яжіться напряму з
+ Політикою та зловживаннями: %{contact_abuse_url}.'
+ hidden: Вашу роботу "%{title}" (%{work_url}) було приховано командою Політики
+ та зловживань, і до неї більше немає публічного доступу.
+ tos_violation: Якщо Вашу роботу було приховано через порушення Умов використання
+ AO3 (%{tos_url}), Вам потрібно буде виправити ці порушення. Якщо Ви не зміните
+ роботу відповідно до вимог Умов використання, це може призвести до її видалення
+ з АО3.
+ tos: Умов використання
+ anonymous_or_unrevealed_notification:
+ anonymous_info: 'Анонімні роботи відображаються на сторінках тегів, але не на
+ Вашій сторінці робіт. На самій роботі Ваше ім''я користувача буде змінено
+ на "Anonymous" (Анонім).
+
+ '
+ anonymous_unrevealed_info: Утримувачі колекції можуть в майбутньому відкрити
+ Вашу роботу, але залишити її анонімною. Користувачі, які на Вас підписались,
+ не отримають повідомлення про цю зміну. Після того, як її відкриють, Ваша
+ робота з’явиться на сторінках тегів, але не на Вашій сторінці робіт. На самій
+ роботі Ваше ім'я користувача буде змінено на "Anonymous" (Анонім).
+ changed_status:
+ anonymous:
+ html: Утримувачі колекції %{collection_link} змінили статус Вашої роботи
+ %{work_link} на анонімний.
+ text: Утримувачі колекції "%{collection_title}" (%{collection_url}) змінили
+ статус Вашої роботи "%{work_title}" (%{work_url}) на анонімний.
+ anonymous_unrevealed:
+ html: Утримувачі колекції %{collection_link} змінили статус Вашої роботи
+ %{work_link} на анонімний та закритий.
+ text: Утримувачі колекції "%{collection_title}" (%{collection_url}) змінили
+ статус Вашої роботи "%{work_title}" (%{work_url}) на анонімний та закритий.
+ unrevealed:
+ html: Утримувачі колекції %{collection_link} змінили статус Вашої роботи
+ %{work_link} на закритий.
+ text: Утримувачі колекції "%{collection_title}" (%{collection_url}) змінили
+ статус Вашої роботи "%{work_title}" (%{work_url}) на закритий.
+ collection_items_link_text: Approved Collection Items (Підтверджених робіт в
+ колекціях)
+ do_not_want:
+ anonymous:
+ html: Якщо Ви не бажаєте, щоб Ваша робота була анонімною, перейдіть, будь
+ ласка, на Вашу сторінку %{collection_items_link}, щоб видалити її з цієї
+ колекції.
+ text: 'Якщо Ви не бажаєте, щоб Ваша робота була анонімною, перейдіть, будь
+ ласка, на Вашу сторінку Approved Collection Items (Підтверджених робіт
+ в колекціях), щоб видалити її з цієї колекції: %{collection_items_url}'
+ anonymous_unrevealed:
+ html: Якщо Ви не бажаєте, щоб Ваша робота була анонімною і закритою, перейдіть,
+ будь ласка, на Вашу сторінку %{collection_items_link}, щоб видалити її
+ з цієї колекції.
+ text: 'Якщо Ви не бажаєте, щоб Ваша робота була анонімною і закритою, перейдіть,
+ будь ласка, на Вашу сторінку Approved Collection Items (Підтверджених
+ робіт в колекціях), щоб видалити її з цієї колекції: %{collection_items_url}'
+ unrevealed:
+ html: Якщо Ви не бажаєте, щоб Ваша робота була закритою, перейдіть, будь
+ ласка, на Вашу сторінку %{collection_items_link}, щоб видалити її з цієї
+ колекції.
+ text: 'Якщо Ви не бажаєте, щоб Ваша робота була закритою, перейдіть на Вашу
+ сторінку Approved Collection Items (Підтверджених робіт в колекціях),
+ щоб видалити її з цієї колекції: %{collection_items_url}'
+ faq_link_text: FAQ Колекцій
+ more_info:
+ html: Для додаткової інформації відвідайте наше %{faq_link}.
+ text: 'Для додаткової інформації відвідайте наше FAQ Колекцій: %{faq_url}'
+ subject:
+ anonymous: "[%{app_name}] Вашу роботу зробили анонімною"
+ anonymous_unrevealed: "[%{app_name}] Вашу роботу зробили анонімною і закритою"
+ unrevealed: "[%{app_name}] Вашу роботу зробили закритою"
+ unrevealed_info: Закриті роботи не відображаються ані на сторінках тегів, ані
+ на Вашій сторінці робіт. Кожен, хто перейде за посиланням на Вашу роботу,
+ отримає повідомлення, що вона є закритою, і вони не зможуть переглянути її
+ вміст.
+ archivist_added_to_collection_notification:
+ approved_collection_items_page: сторінки Approved Collection Items (Деталі затвердженої
+ колекції)
+ archivist_notice: Оскільки розпорядники колекції виступають у ролі архіваріусів
+ Open Doors (Відкритих Дверей), їм дозволено додати Вашу роботу до цієї колекції,
+ навіть якщо у Вас вимкнено опцію запрошення до колекції. Архіваріуси додадуть
+ роботу до колекції, лише якщо вона розміщена в імпортованому архіві.
+ removal_instructions:
+ html: Якщо Ви хочете прибрати свою роботу з цієї колекції, будь ласка, перейдіть
+ до Вашої %{approved_items_link}.
+ text: 'Якщо Ви хочете прибрати свою роботу з цієї колекції, будь ласка, перейдіть
+ до Вашої сторінки Approved Collection Items (Деталі затвердженої колекції):
+ %{approved_items_url}.'
+ subject: "[%{app_name}][%{collection_title}] Архіваріус Open Doors (Відкритих
+ Дверей) додав Вашу роботу до колекції"
+ work_added:
+ html: Розпорядники колекції %{collection_link} додали Вашу роботу %{work_link}
+ до їхньої колекції!
+ text: Розпорядники колекції "%{collection_title}" (%{collection_url}) додали
+ Вашу роботу "%{work_title}" (%{work_url}) до їхньої колекції!
+ challenge_assignment_notification:
+ any: Будь-які
+ assignment:
+ html: Вам було призначено наступну заявку в челенджі %{link} на AO3!
+ description: 'Опис:'
+ due: 'Кінцевий термін цього завдання:'
+ html:
+ footer: Ви отримали цей електронний лист, тому що Ви записались до челенджу
+ %{title}. Щоб отримати більше інформації про цей челендж і контактну інформацію
+ модераторів, перегляньте %{footer_link}.
+ footer_link: сторінку профілю челенджу
+ look_up: Ви можете переглянути це завдання на %{link}.
+ look_up_link: сторінці з Вашими Assignments (Завданнями)
+ optional_tags: 'Теги на вибір:'
+ prompts: 'Заявки:'
+ prompt_url: 'Посилання на заявку:'
+ recipient: 'Отримувач:'
+ recipient_missing: 'Немає: зв''яжіться з модератором, щоб отримати допомогу!'
+ subject: "[%{app_name}][%{collection_title}] Ваше завдання!"
+ text:
+ assignment: Вам було призначено наступну заявку в челенджі "%{collection_title}"
+ (%{collection_url}) на AO3!
+ footer: Ви отримали цей електронний лист, тому що Ви записались до челенджу
+ %{title} (%{url}). Щоб отримати більше інформації про цей челендж і контактну
+ інформацію модераторів, перегляньте %{profile_url}.
+ look_up: Ви можете переглянути це завдання на сторінці з Вашими Assignments
+ (Завданнями) за посиланням %{link}.
+ change_email:
+ changed:
+ html: "%{login}, електронну адресу, що пов'язана з Вашим обліковим записом,
+ було змінено на %{email}"
+ text: "%{login}, електронну адресу, що пов'язана з Вашим обліковим записом,
+ було змінено на %{email}"
+ subject: "[%{app_name}] Електронну адресу змінено"
+ claim_notification:
+ access:
+ contact_support: зверніться до Служби Підтримки АО3.
+ html: Залежно від архіву, Ваші роботи могли бути імпортовані з доступом лише
+ для зареєстрованих користувачів (аби їх не було видно в пошуку Google).
+ У такому випадку, тільки залогінені користувачі матимуть можливість переглядати
+ Ваші роботи, якщо Ви самі не вирішите відкрити до них доступ повністю. Щоб
+ отримати допомогу з налаштуванням доступу до робіт, видаленням або відмовою
+ від них, будь ласка, %{contact_support_link}.
+ text: 'Залежно від архіву, Ваші роботи могли бути імпортовані з доступом лише
+ для зареєстрованих користувачів (аби їх не було видно в пошуку Google).
+ В такому випадку, тільки залогінені користувачі матимуть можливість переглядати
+ Ваші роботи, якщо Ви самі не вирішили відкрити до них доступ повністю. Щоб
+ отримати допомогу з відкриттям робіт, видаленням або відмовою від них, будь
+ ласка, зверніться до Служби Підтримки за посиланням: %{support_url}.'
+ email_tips: Якщо Ви вирішили написати нам, будь ласка, додайте @transformativeworks.org
+ до Вашого списку надійних контактів і перевіряйте папку зі спамом на наявність
+ нашої відповіді.
+ introduction:
+ ao3_name: Archive of Our Own – AO3 (Нашого Власного Архіву)
+ html: Ви отримали цей електронний лист, тому що Ваші роботи перебували в архіві
+ фанробіт, який %{open_doors_name_link} імпортували до %{app_link}. Оскільки
+ ця електронна адреса збігається з адресою, зареєстрованою в імпортованому
+ архіві, пов'язані фанроботи (перелічені нижче) було автоматично додано до
+ Вашого облікового запису на АО3.
+ open_doors_name: Open Doors (Відкриті Двері)
+ text: 'Ви отримали цей електронний лист, тому що Ваші роботи перебували в
+ архіві фанробіт, який Open Doors (Відкриті Двері) (%{open_doors_url}) імпортували
+ до Archive of Our Own – AO3 (Нашого Власного Архіву): %{app_url}. Оскільки
+ ця електронна адреса збігається з адресою, зареєстрованою в імпортованому
+ архіві, пов''язані фанроботи (перелічені нижче) було автоматично додано
+ до Вашого облікового запису на АО3.'
+ mistake:
+ contact_open_doors: зв'яжіться з Відкритими Дверима
+ html: Якщо виникла помилка, і ці роботи Вам не належать, будь ласка, не видаляйте
+ їх! Просто %{contact_open_doors_link}, і ми вирішимо цю проблему.
+ text: Якщо виникла помилка, і ці роботи Вам не належать, будь ласка, не видаляйте
+ їх! Просто зв'яжіться з Відкритими Дверима (%{open_doors_url}), і ми вирішимо
+ цю проблему.
+ more_info:
+ ao3_news: Новин АО3
+ contact_support: зверніться до Служби Підтримки АО3.
+ faq_page: сторінці FAQ
+ html: Ви можете прочитати оголошення про нещодавні переміщення архіву на сторінці
+ %{ao3_news_link}, а також ознайомитися з додатковою інформацією на %{faq_page_link}
+ Відкритих Дверей або на %{tutorial_page_link}. Якщо Ви не знайдете відповіді
+ на своє питання у наших посібниках, FAQ або у цьому листі, будь ласка, %{contact_support_link}.
+ text: 'Ви можете прочитати оголошення про нещодавні переміщення архіву на
+ сторінці Новин АО3 (%{news_url}), а також ознайомитися з додатковою інформацією
+ на сторінці FAQ Відкритих Дверей (%{open_doors_faq_url}) або на сторінці
+ з посібниками (%{open_doors_tutorial_url}). Якщо Ви не знайдете відповіді
+ на своє запитання у наших посібниках, FAQ або в цьому листі, будь ласка,
+ зверніться до Служби Підтримки за посиланням: %{support_url}.'
+ tutorial_page: сторінці з посібниками
+ other_works:
+ contact_open_doors: зв'яжіться з Відкритими Дверима
+ html: Якщо у Вас є інші роботи в імпортованому архіві, але у Вас немає доступу
+ до електронної адреси, під якою вони були написані, будь ласка, %{contact_open_doors_link}
+ та надайте будь-яку інформацію, яка могла б допомогти верифікувати Вас.
+ text: Якщо у Вас є інші роботи в імпортованому архіві, але у Вас немає доступу
+ до електронної адреси, під якою вони були написані, будь ласка, зв'яжіться
+ з Відкритими Дверима та надайте будь-яку інформацію, яка могла б допомогти
+ верифікувати Вас.
+ questions:
+ contact_support: зверніться до Служби Підтримки АО3.
+ html: Для інших запитів, будь ласка, %{contact_support_link}.
+ text: 'Для інших запитів, будь ласка, звертайтеся до Служби Підтримки АО3
+ за посиланням: %{support_url}.'
+ redirects:
+ html: Для збереження списків і закладок, протягом обмеженого часу веб-адреса
+ імпортованого архіву може перенаправляти користувачів до імпортованої копії
+ цих робіт (перегляньте оголошення про Ваш архів, аби знати точну інформацію).
+ Якщо Ви вже завантажили копію цих робіт і %{negation} використали функцію
+ імпорту з URL-адреси, на AO3 буде дві копії однієї роботи.
+ subject: "[%{app_name}] Роботи завантажені"
+ update_redirect:
+ contact_open_doors: зверніться до Відкритих Дверей
+ html: Якщо Ви хочете, щоб Відкриті Двері оновили перенаправлення на Вашу вже
+ наявну роботу, будь ласка, видаліть імпортовану копію та %{contact_open_doors_link},
+ вказавши ім'я Вашого облікового запису на АО3, ім'я Вашого облікового запису
+ в імпортованому архіві, а також назву та URL- адресу фанроботи, на яку Ви
+ б хотіли зробити перенаправлення.
+ text: 'Якщо Ви хочете, щоб Відкриті Двері оновили перенаправлення на Вашу
+ вже наявну роботу, будь ласка, видаліть імпортовану копію та зверніться
+ до Відкритих Дверей за посиланням: %{open_doors_url}, вказавши ім''я Вашого
+ облікового запису на АО3, ім''я Вашого облікового запису в імпортованому
+ архіві, а також назву та URL- адресу фанроботи, на яку Ви б хотіли зробити
+ перенаправлення.'
+ works_by: 'Ці роботи були написані під електронною адресою: %{email}'
+ work_info:
+ html: "%{work_link} (%{fandom})"
+ text:
+ no_fandom: "- %{work_title} %{work_url}"
+ with_fandom: "- %{work_title} %{work_url} (%{fandom})"
+ collection_notification:
+ assignments_sent:
+ complete: Усі завдання було щойно розіслано.
+ subject: Завдання надіслано
+ html:
+ received_message: 'Ви отримали повідомлення про Вашу колекцію %{collection_link}:'
+ text:
+ received_message: 'Ви отримали повідомлення про Вашу колекцію "%{collection_title}"
+ (%{collection_url}):'
+ creatorship_notification:
+ explanation: Коли Ви - співтворець роботи, Вас можна додати до нових розділів
+ незалежно від Ваших налаштувань. Також Ви будете доданими до будь-якої серії,
+ до якої додадуть роботу.
+ html:
+ creation: "%{creation_link} авторства %{pseud_links}"
+ edit_chapter: відредагувати розділ
+ edit_series: відредагувати серію
+ remove_chapter: Якщо Вас помилково зробили співтворцем, або Ви не хочете бути
+ вказаними в якості творця, Ви можете %{edit_chapter_link}, щоб видалити
+ себе як творця.
+ remove_series: Якщо Вас помилково зробили співтворцем, або Ви не хочете бути
+ вказаними в якості творця, Ви можете %{edit_series_link}, щоб видалити себе
+ як творця.
+ intro_chapter: 'Користувач %{adding_user} додав Вас, %{pseud}, у якості співтворця
+ наступного розділу:'
+ intro_series: 'Користувач %{adding_user} додав Вас, %{pseud}, в якості співтворця
+ до наступної серії:'
+ subject: "[%{app_name}] Повідомлення про співавторство"
+ text:
+ creation: "%{title} (%{url}) авторства %{pseuds}"
+ remove_chapter: 'Якщо Вас помилково зробили співтворцем, або Ви не хочете
+ бути вказаними в якості творця, Ви можете відредагувати розділ, щоб видалити
+ себе як творця: %{url}'
+ remove_series: 'Якщо Вас помилково зробили співтворцем, або Ви не хочете бути
+ вказаними в якості творця, Ви можете відредагувати серію, щоб видалити себе
+ як творця: %{url}'
+ creatorship_notification_archivist:
+ explanation: Оскільки даний користувач виступає у ролі архіваріуса Open Doors
+ (Відкритих Дверей), цей користувач має можливість додати Вас без запиту, навіть
+ якщо Ви вимкнули можливість співавторства.
+ html:
+ creation: "%{creation_link} авторства %{pseud_links}"
+ edit_chapter: відредагувати розділ
+ edit_series: відредагувати серію
+ edit_work: відредагувати роботу
+ remove_chapter: Якщо Вас помилково зробили співтворцем, або Ви не хочете бути
+ вказаними в якості творця, Ви можете %{edit_chapter_link}, щоб видалити
+ себе як творця.
+ remove_series: Якщо Вас помилково зробили співтворцем, або Ви не хочете бути
+ вказаними в якості творця, Ви можете %{edit_series_link}, щоб видалити себе
+ як творця.
+ remove_work: Якщо Вас помилково зробили співтворцем, або Ви не хочете бути
+ вказаними в якості творця, Ви можете %{edit_work_link}, щоб видалити себе
+ як творця.
+ intro_chapter: 'Користувач %{archivist} додав Ваc, %{pseud}, у якості співтворця
+ в наступному розділі:'
+ intro_series: 'Користувач %{archivist} додав Ваc, %{pseud}, у якості співтворця
+ в наступній серії:'
+ intro_work: 'Користувач %{archivist} додав Ваc, %{pseud}, у якості співтворця
+ в наступній роботі:'
+ subject: "[%{app_name}] Повідомлення від архіваріуса про співавторство"
+ text:
+ creation: "%{title} (%{url}) авторства %{pseuds}"
+ remove_chapter: 'Якщо Вас помилково зробили співтворцем, або Ви не хочете
+ бути вказаними в якості творця, Ви можете відредагувати розділ, щоб видалити
+ себе як творця: %{url}'
+ remove_series: 'Якщо Вас помилково зробили співтворцем, або Ви не хочете бути
+ вказаними в якості творця, Ви можете відредагувати серію, щоб видалити себе
+ як творця: %{url}'
+ remove_work: 'Якщо Вас помилково зробили співтворцем, або Ви не хочете бути
+ вказаними в якості творця, Ви можете відредагувати роботу, щоб видалити
+ себе як творця: %{url}'
+ creatorship_request:
+ html:
+ creation: "%{creation_link} авторства %{pseud_links}"
+ instructions: Ви можете прийняти або відхилити цей запит на Вашій сторінці
+ %{page_name}.
+ page_name: Co-Creator Requests (Запитів на співавторство)
+ intro_chapter: 'Користувач %{inviting_user} запросив Вас, %{pseud}, стати співтворцем
+ наступного розділу:'
+ intro_series: 'Користувач %{inviting_user} запросив Вас, %{pseud}, стати співтворцем
+ наступної серії:'
+ intro_work: 'Користувач %{inviting_user} запросив Вас, %{pseud}, стати співтворцем
+ наступної роботи:'
+ subject: "[%{app_name}] Запит на додання співтворця"
+ text:
+ creation: "%{title} (%{url}) авторства %{pseuds}"
+ instructions: 'Ви можете прийняти або відхилити цей запит на Вашій сторінці
+ Co-Creator Requests (Запитів на співавторство): %{url}.'
+ delete_work_notification:
+ attachment: Для ознайомлення прикріплена копія Вашої роботи.
+ deleted_other:
+ html: Вашу роботу %{title} було видалено на прохання користувача %{pseud}.
+ text: Вашу роботу "%{title}" було видалено на прохання користувача %{pseud}.
+ deleted_yourself:
+ html: Вашу роботу %{title} було видалено на Ваше прохання.
+ text: Вашу роботу "%{title}" було видалено на Ваше прохання.
+ questions:
+ html: Якщо у Вас виникнуть запитання, будь ласка, %{support}.
+ text: Якщо у Вас виникнуть запитання, будь ласка, %{support} (%{url}).
+ subject: "[%{app_name}] Вашу роботу було видалено"
+ support: зв'яжіться з Підтримкою
+ invitation_to_claim:
+ access:
+ text: Залежно від архіву, під час імпорту ваші роботи могли бути обмеженими
+ для перегляду лише зареєстрованими користувачами (щоб вони не з’являлись
+ під часу пошуку в Google). Якщо це відбулось, ваші роботи зможуть переглядати
+ лише залогінені користувачі, поки ви не зробите їх видимими для всіх. Для
+ допомоги з відкриттям, відмовою від або видаленням ваших робіт, просимо
+ вас сконтактуватись зі службою Підтримки.
+ claim_or_remove:
+ html: Підтвердіть власність або відмовтесь від ваших робіт тут.
+ text: 'Підтвердіть власність або відмовтесь від ваших робіт тут: %{claim_url}'
+ email_tips: Якщо ви з нам сконтактувались, внесіть будь ласка електронні адреси
+ з домени @transformativeworks.org до білого списку і перевірте, чи наша відповідь
+ не потрапила у вашу папку Спам.
+ html:
+ ao3_news: Новин AO3
+ contact_open_doors: сконтактуйтесь з Відкритими Дверима
+ contact_support: сконтактуватись зі службою Підтримки АО3
+ faq_page: сторінці FAQ
+ tutorial_page: сторінці посібника
+ introduction:
+ text: Ви отримали це повідомлення оскільки нещодавно архів був заімпортованим
+ Комітетом Open Doors (Відкритих Дверей) (%{open_doors_link}) в %{app_name}
+ (%{app_short_name} - %{app_url}) і нам здається, що наведені нижче роботи
+ належать вам. Ми б хотіли дати вам можливість підтвердити власність (або
+ видалити/відмовитись від) цих робіт, якщо ви хочете. Крім того, якщо у вас
+ немає акаунту пов’язаного з іншою адресою електронної пошти, ми б хотіли
+ вас до себе запросити!
+ mistake:
+ text: Якщо ви отримали це повідомлення помилково або це не ваші роботи, ми
+ б просили їх не видаляти! Будь ласка, сконтактуйтесь з Відкритими Дверима
+ (%{open_doors_link}), і ми з цим розберемось.
+ more_info:
+ text: Ви можете ознайомитись з нещодавніми перенесеннями архіву на сторінці
+ Новин АО3 (%{news_link}), а також знайти додаткову інформацію на сторінці
+ FAQ (%{open_doors_faq_link}) або сторінці посібника (%{open_doors_tutorial_link})
+ Відкритих Дверей. Якщо у вас виникли питання, відповідей на які немає в
+ FAQ, посібнику чи цьому листі, просимо вас сконтактуватись зі службою Підтримки,
+ перейшовши за посиланням %{support_link}.
+ other_works:
+ text: Якщо ви мали інші роботи на заімпортованому архіві, розміщенні під іншою
+ адресою електронної пошти, до якої ви більше не маєте доступу, будь ласка,
+ сконтактуйтесь з Відкритими Дверима, надаючи усю можливу інформацію, яка
+ допоможе підтвердити вашу особистість.
+ questions:
+ text: Просимо вас сконтактуватись зі службою Підтримки AO3, перейшовши за
+ посиланням %{support_link}, якщо ви маєте інші питання.
+ redirects: Задля збереження списків рекомендацій і закладок, веб-адреси імпортованих
+ архівів протягом деякого часу будуть перенаправляти на імпортовану копію цих
+ робіт (перевірте оголошення на вашому архіві, щоб у цьому впевнитись). Якщо
+ ви вже опублікували копію цієї роботи і НЕ використовували для цього функцію
+ “імпортувати з URL-адреси”, в архіві будуть знаходитись дві копії однієї і
+ тієї ж роботи.
+ subject: "[%{app_name}] Запрошення до підтвердження власності роботи"
+ unwanted:
+ text: Якщо ці роботи дійсно належать вам, але вони вам не потрібні, ви можете
+ відмовитись від них (в цьому випадку вони залишаться на АО3, але ваше ім’я
+ буде усунутим) або видалити їх (в цьому випадку вони повністю будуть усунутими
+ з АО3). Вам не потрібно додавати ці роботи до якогось акаунту, щоб відмовитись
+ від них або їх усунути - ви можете це зробити безпосередньо, перейшовши
+ за посиланням на підтвердження власності, що знаходиться вище. (Якщо вам
+ потрібна допомога, просимо вас сконтактуватись зі службою Підтримки, перейшовши
+ за посиланням %{support_link}).
+ update_redirect:
+ text: Якщо ви б хотіли, щоб Відкриті Двері перенесли перенаправлення на вашу
+ уже існуючу роботу, усуньте, будь ласка, імпортовану копію і сконтактуйтесь
+ з Відкритими Дверима за посиланням %{open_doors_link}, подаючи ім’я вашого
+ акаунту на АО3, ім’я вашого акаунту на заімпортованому архіві та назву і
+ посилання на роботу, на яку ви хочете перенести перенаправлення. (Якщо ви
+ хочете змінити перенаправлення для кількох робіт, ви можете вислати їх усі
+ в одному листі).
+ uploaded_list: 'Список завантажених робіт складається з:'
+ invite_increase_notification:
+ html:
+ body:
+ few: Ми всього лише хотіли повідомити, що Ви отримали %{count} новi запрошення.
+ Їх можна використати для створення нових облікових записів на AO3. Ви
+ можете запросити друга через %{invitation_page_link}.
+ many: Ми всього лише хотіли повідомити, що Ви отримали %{count} нових запрошень.
+ Їх можна використати для створення нових облікових записів на AO3. Ви
+ можете запросити друга через %{invitation_page_link}.
+ one: Ми всього лише хотіли повідомити, що Ви отримали %{count} нове запрошення.
+ Його можна використати для створення нового облікового запису на AO3.
+ Ви можете запросити друга через %{invitation_page_link}.
+ invitation_page_link_text: сторінку Ваших Invitations (Запрошень)
+ subject: "[%{app_name}] Нові запрошення"
+ text:
+ body:
+ few: Ми всього лише хотіли повідомити, що Ви отримали %{count} новi запрошення.
+ Їх можна використати для створення нових облікових записів на AO3. Ви
+ можете запросити друга через сторінку Ваших Invitations (Запрошень) (%{invitation_page_url}).
+ many: Ми всього лише хотіли повідомити, що Ви отримали %{count} нових запрошень.
+ Їх можна використати для створення нових облікових записів на AO3. Ви
+ можете запросити друга через через сторінку Ваших Invitations (Запрошень)
+ (%{invitation_page_url}).
+ one: Ми всього лише хотіли повідомити, що Ви отримали %{count} нове запрошення.
+ Його можна використати для створення нового облікового запису на AO3.
+ Ви можете запросити друга через сторінку Ваших Invitations (Запрошень)
+ (%{invitation_page_url}).
+ invite_request_declined:
+ main:
+ few: 'На жаль, ми змушені повідомити Вам, що наразі Ваш запит на %{count}
+ нові запрошення не може бути виконаним. '
+ many: 'На жаль, ми змушені повідомити Вам, що наразі Ваш запит на %{count}
+ нових запрошень не може бути виконаним. '
+ one: 'На жаль, ми змушені повідомити Вам, що наразі Ваш запит на %{count}
+ нове запрошення не може бути виконаним. '
+ reason: 'Ваш запит:'
+ subject: "[%{app_name}] Вам відмовлено у додаткових запрошеннях"
+ recipient_notification:
+ html:
+ collection: Для Вас на АО3 було опубліковано роботу-подарунок у колекції %{collection_link}!
+ no_collection: Для Вас на АО3 було опубліковано роботу-подарунок!
+ subject:
+ collection: "[%{app_name}][%{collection_title}] Подарунок для Вас від %{collection_title}"
+ no_collection: "[%{app_name}] Подарунок для Вас"
+ text:
+ collection: Для Вас на АО3 було опубліковано роботу-подарунок у колекції %{collection_title}
+ (%{collection_url})!
+ signup_notification:
+ activate:
+ html: " Будь ласка, %{activate_account_link}."
+ text: 'Будь ласка, перейдіть за цим посиланням, щоб активувати Ваш обліковий
+ запис: %{activate_account_url}'
+ activate_your_account: перейдіть за цим посиланням, щоб активувати Ваш обліковий
+ запис
+ admin_posts: Новин АО3
+ bye: Сподіваємось, що Вам сподобається AO3!
+ contact_support: зв’яжіться з Підтримкою
+ faq: FAQ
+ features:
+ html: Як тільки Ваш профіль буде активовано, Ви зможете публікувати власні
+ фанроботи, налаштувати емейл-підписки, щоб дізнатися, коли Ваш улюблений
+ автор обновить свою роботу; налаштувати інтерфейс сайту згідно Вашим вподобанням;
+ слідкувати, які роботи Ви переглянули в історії Архіву, та багато інших
+ речей.
+ text: Як тільки Ваш профіль буде активовано, Ви зможете публікувати власні
+ фанроботи, налаштувати емейл-підписки, щоб дізнатися коли Ваш улюблений
+ автор обновить свою роботу; налаштувати інтерфейс сайту згідно Вашим вподобанням;
+ слідкувати, які роботи Ви переглянули в історії Архіву, та багато інших
+ речей.
+ information:
+ html: Інформацію про те, як користуватись Архівом, можна знайти у розділі %{faq_link}.
+ Найновіші оновлення сайту та новини знаходяться на сторінці %{admin_posts_link}.
+ Якщо Вам потрібна допомога, Ви знайшли баг, маєте питання чи застереження,
+ будь ласка %{contact_support_link}, котрі завжди з радістю Вам допоможуть.
+ text: 'Інформацію про те, як користуватись Архівом, можна знайти у розділі
+ FAQ за посиланням %{faq_url}. Найновіші оновлення сайту та новини знаходяться
+ на сторінці Новин АО3 за посиланням %{admin_posts_url}. Якщо Вам потрібна
+ допомога, Ви знайшли баг, маєте питання чи застереження, зв’яжіться з командою
+ Підтримки, перейшовши з цим посиланням: %{contact_support_url}, котрі завжди
+ з радістю Вам допоможуть.'
+ welcome: Ласкаво просимо до АО3, %{login}!
diff --git a/config/locales/phrase-exports/vi.yml b/config/locales/phrase-exports/vi.yml
new file mode 100755
index 0000000..d05a820
--- /dev/null
+++ b/config/locales/phrase-exports/vi.yml
@@ -0,0 +1,613 @@
+---
+vi:
+ activerecord:
+ attributes:
+ archive_warning:
+ name_with_colon:
+ other: 'Cảnh Báo:'
+ category:
+ name_with_colon:
+ other: 'Hạng Mục:'
+ character:
+ name_with_colon:
+ other: 'Nhân Vật:'
+ fandom:
+ name_with_colon:
+ other: 'Fandom:'
+ freeform:
+ name_with_colon:
+ other: 'Từ Khoá Bổ Sung:'
+ rating:
+ name_with_colon: 'Giới Hạn Độ Tuổi:'
+ relationship:
+ name_with_colon:
+ other: 'Mối Quan Hệ:'
+ work:
+ chapter_total_display: Chương
+ summary: Tóm Tắt
+ models:
+ archive_warning:
+ other: Cảnh Báo
+ category:
+ other: Hạng Mục
+ chapter:
+ other: Chương
+ character:
+ other: Nhân Vật
+ fandom:
+ other: Fandom
+ freeform:
+ other: Từ Khoá Bổ Sung
+ rating:
+ other: Giới Hạn Độ Tuổi
+ relationship:
+ other: Mối Quan Hệ
+ series:
+ other: Bộ
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ other: "%{count} người đọc khách"
+ left_kudos:
+ html:
+ other: "%{givers_list} đã tán dương tác phẩm %{commentable_link}."
+ text:
+ other: "%{givers_list} đã tán dương tác phẩm %{commentable_title} (%{commentable_url})."
+ single_guest:
+ giver: Một người đọc khách
+ html: "%{giver} đã tán dương tác phẩm %{commentable_link}."
+ text: Một người đọc khách đã tán dương tác phẩm %{commentable_title} (%{commentable_url}).
+ subject: "[%{app_name}] Tán dương gửi đến bạn!"
+ mailer:
+ general:
+ closing:
+ formal: Trân trọng,
+ informal: Trân trọng,
+ creation:
+ link_with_word_count: "%{creation_link} (%{word_count})"
+ title_with_chapter_number: Chương %{position} của tác phẩm %{title}
+ title_with_word_count: '"%{creation_title}" (%{word_count})'
+ word_count:
+ other: "%{count} từ"
+ footer:
+ general:
+ about:
+ html: AO3 là một kho tàng lưu trữ được điều hành và vận hành bởi fan,
+ và duy trì nhờ %{donate_link}.
+ text: 'AO3 là một kho tàng lưu trữ được điều hành và vận hành bởi fan,
+ và duy trì nhờ quyên góp của bạn: %{donate_url}.'
+ html:
+ donate_link_text: quyên góp của bạn
+ support_link_text: liên hệ Ban Hỗ Trợ Kỹ Thuật
+ unwanted_email:
+ html: Nếu thông báo này có sai sót hoặc nhầm lẫm, xin hãy %{support_link}.
+ text: Nếu thông báo này có sai sót hoặc nhầm lẫn, xin hãy liên hệ ban
+ Hỗ Trợ Kỹ Thuật tại %{support_url}.
+ sent_at: Gửi vào lúc %{sent_at}.
+ greeting:
+ formal_html: Thân gửi %{name},
+ informal:
+ addressed_html: Xin chào, %{name}!
+ unaddressed: Xin chào!
+ introductory: Hello from the Archive of Our Own – AO3 (Xin gửi lời chào từ
+ Kho Tàng Lưu Trữ của Chúng Ta)!
+ metadata_label_indicator: ":"
+ signature:
+ abuse_team: Đội ngũ Chính Sách & Xử Lý Lạm Quyền của AO3
+ app_short_name: AO3
+ open_doors: Đội ngũ Open Doors (Cửa Mở)
+ parent_org: Organization for Transformative Works – OTW (Tổ Chức cho các Tác
+ Phẩm Được Biến Đổi)
+ support: Đội ngũ Hỗ Trợ Kỹ Thuật của AO3
+ users:
+ mailer:
+ reset_password_instructions:
+ expiration: Trong thời hạn 1 tuần, nếu bạn không dùng đường link này để đặt
+ lại mật khẩu, đường link sẽ mất hiệu lực; bạn sẽ phải yêu cầu đường link
+ mới.
+ intro: Ai đó đã yêu cầu đặt lại mật khẩu cho tài khoản của bạn. Bạn có thể
+ thay đổi mật khẩu bằng cách nhấn vào đường link bên dưới và điền mật khẩu
+ mới.
+ link_title: Thay đổi mật khẩu của tôi.
+ subject: "[%{app_name}] Đặt lại mật khẩu"
+ unrequested: Nếu bạn không yêu cầu đặt lại mật khẩu, bạn có thể bỏ qua email
+ này; mật khẩu cũ của bạn vẫn sẽ tiếp tục có hiệu lực.
+ user_mailer:
+ admin_deleted_work_notification:
+ bye: Bản sao tác phẩm của bạn được đính kèm trong thư này.
+ contact_abuse: liên hệ đội ngũ Xử Lý Lạm Quyền của chúng tôi.
+ deleted:
+ html: Quản trị viên đã xóa tác phẩm %{title} của bạn khỏi AO3.
+ text: Quản lý trang web đã xóa tác phẩm %{title} của bạn quản lý trang web
+ khỏi AO3.
+ html:
+ tos_violation: Nếu tác phẩm của bạn vi phạm Điều Khoản Dịch Vụ của AO3, vui
+ lòng %{contact_abuse_link}.
+ import_project:
+ html: Nếu tác phẩm của bạn nằm trong dự án đăng lại thuộc quyền quản lý của
+ đội ngũ Open Doors (Cửa Mở), hãy %{opendoors_link} để biết thêm thông tin
+ chi tiết.
+ text: Nếu tác phẩm của bạn nằm trong dự án đăng lại thuộc quyền quản lý của
+ đội ngũ Open Doors (Cửa Mở), hãy liên hệ Open Doors (%{opendoors_link})
+ để biết thêm thông tin chi tiết.
+ opendoors: liên hệ Open Doors
+ subject: "[%{app_name}] Quản trị viên đã xóa tác phẩm của bạn"
+ text:
+ tos_violation: Nếu tác phẩm của bạn vi phạm Điều Khoản Dịch Vụ AO3, hãy liên
+ hệ đội ngũ Xử Lý Lạm Quyền của chúng tôi (%{contact_abuse_url}).
+ admin_hidden_work_notification:
+ access: Bạn vẫn có thể truy cập tác phẩm bị ẩn của mình qua đường dẫn trên;
+ tuy nhiên, tác phẩm này sẽ không xuất hiện tại trang tác phẩm của bạn, đồng
+ thời sẽ bị ẩn với những người dùng AO3 khác.
+ check_email: Ban Xử Lý Lạm Quyền AO3 có thể đã liên lạc với bạn về quyết định
+ và lý do ẩn tác phẩm; xin vui lòng kiểm tra hộp thư của bạn, kể cả hộp thư
+ rác.
+ contact_abuse: liên hệ Ban Xử Lý Lạm Quyền
+ html:
+ help: Nếu bạn không rõ vì sao tác phẩm của mình bị ẩn và chưa được thông báo
+ gì thêm về việc này, vui lòng trực tiếp %{contact_abuse_link}.
+ hidden: Ban Xử Lý Lạm Quyền đã ẩn tác phẩm %{title} của bạn; tác phẩm này
+ không còn ở chế độ mở truy cập công khai.
+ tos_violation: Nếu tác phẩm của bạn bị ẩn do vi phạm %{tos_link} của AO3,
+ bạn sẽ được yêu cầu có hành động điều chỉnh để khắc phục. Không xử lý vi
+ phạm về Điều Khoản Dịch Vụ có thể dẫn tới việc tác phẩm của bạn bị xóa khỏi
+ AO3.
+ subject: "[%{app_name}] Ban Xử Lý Lạm Quyền đã ẩn tác phẩm của bạn"
+ text:
+ help: 'Nếu bạn không rõ vì sao tác phẩm của mình bị ẩn và chưa được thông
+ báo gì thêm về việc này, vui lòng trực tiếp liên hệ Ban Xử Lý Lạm Quyền:
+ %{contact_abuse_url}.'
+ hidden: Ban Xử Lý Lạm Quyền đã ẩn tác phẩm "%{title}" (%{work_url}) của bạn;
+ tác phẩm này không còn ở chế độ mở truy cập công khai.
+ tos_violation: Nếu tác phẩm của bạn bị ẩn do vi phạm Điều Khoản Dịch Vụ (%{tos_url})
+ của AO3, bạn sẽ được yêu cầu có hành động điều chỉnh để khắc phục. Không
+ xử lý vi phạm về Điều Khoản Dịch Vụ có thể dẫn tới việc tác phẩm của bạn
+ bị xóa khỏi AO3.
+ tos: Điều Khoản Dịch Vụ
+ anonymous_or_unrevealed_notification:
+ anonymous_info: Tác phẩm ẩn danh vẫn sẽ được liệt kê trong danh mục từ khóa,
+ nhưng sẽ không xuất hiện trên trang tác phẩm của bạn. Tên người dùng hiển
+ thị cùng tác phẩm sẽ trở thành "Anonymous" ("Ẩn danh").
+ anonymous_unrevealed_info: Trong tương lai, tác phẩm của bạn có thể sẽ được
+ người quản lý bộ sưu tập công khai, song vẫn giữ trạng thái ẩn danh. Những
+ người đăng ký theo dõi bạn sẽ không được thông báo về thay đổi này. Sau khi
+ công khai, tác phẩm của bạn sẽ được liệt kê trong danh mục từ khóa, nhưng
+ sẽ không xuất hiện trên trang tác phẩm của bạn. Tên người dùng hiển thị cùng
+ tác phẩm sẽ trở thành "Anonymous" ("Ẩn danh").
+ changed_status:
+ anonymous:
+ html: Người quản lý bộ sưu tập %{collection_link} đã chuyển trạng thái tác
+ phẩm %{work_link} của bạn thành ẩn danh.
+ text: Người quản lý của bộ sưu tập "%{collection_title}" (%{collection_url})
+ đã chuyển trạng thái tác phẩm "%{work_title}" (%{work_url}) của bạn sang
+ trạng thái ẩn danh.
+ anonymous_unrevealed:
+ html: Người quản lý bộ sưu tập %{collection_link} đã chuyển trạng thái tác
+ phẩm %{work_link} của bạn thành ẩn danh và chưa công khai.
+ text: Người quản lý của bộ sưu tập "%{collection_title}" (%{collection_url})
+ đã chuyển trạng thái tác phẩm "%{work_title}" (%{work_url}) của bạn sang
+ trạng thái ẩn danh và chưa công khai.
+ unrevealed:
+ html: Người quản lý bộ sưu tập %{collection_link} đã chuyển trạng thái tác
+ phẩm %{work_link} của bạn thành chưa công khai.
+ text: Người quản lý của bộ sưu tập "%{collection_title}" (%{collection_url})
+ đã chuyển trạng thái tác phẩm "%{work_title}" (%{work_url}) của bạn sang
+ trạng thái chưa công khai.
+ collection_items_link_text: Trang Approved Collection Items (Tác Phẩm Được Duyệt
+ cho Bộ Sưu Tập)
+ do_not_want:
+ anonymous:
+ html: Nếu bạn không muốn ẩn danh tác phẩm này, vui lòng vào %{collection_items_link}
+ của bạn để rút tác phẩm khỏi bộ sưu tập.
+ text: 'Nếu bạn không muốn tác phẩm của bạn ẩn danh, vui lòng vào trang Approved
+ Collection Items (Tác Phẩm Được Duyệt cho Bộ Sưu Tập) của bạn để rút tác
+ phẩm khỏi bộ sưu tập: %{collection_items_url}'
+ anonymous_unrevealed:
+ html: Nếu bạn muốn công khai và không ẩn danh tác phẩm, vui lòng vào %{collection_items_link}
+ của bạn để rút tác phẩm khỏi bộ sưu tập.
+ text: 'Nếu bạn muốn công khai và không ẩn danh tác phẩm, vui lòng vào trang
+ Approved Collection Items (Tác Phẩm Được Duyệt cho Bộ Sưu Tập) của bạn
+ để rút tác phẩm khỏi bộ sưu tập: %{collection_items_url}'
+ unrevealed:
+ html: Nếu bạn muốn công khai tác phẩm, vui lòng vào %{collection_items_link}
+ của bạn để rút tác phẩm khỏi bộ sưu tập.
+ text: 'Nếu bạn muốn công khai tác phẩm, vui lòng vào trang Approved Collection
+ Items (Tác Phẩm Được Duyệt cho Bộ Sưu Tập) của bạn để rút tác phẩm khỏi
+ bộ sưu tập: %{collection_items_url}'
+ faq_link_text: Câu hỏi thường gặp về Bộ Sưu Tập
+ more_info:
+ html: Để biết thêm thông tin, vui lòng tham khảo %{faq_link}.
+ text: 'Để biết thêm thông tin, vui lòng tham khảo Câu hỏi thường gặp về Bộ
+ Sưu Tập: %{faq_url}'
+ subject:
+ anonymous: "[%{app_name}] Tác phẩm của bạn đã chuyển sang trạng thái ẩn danh"
+ anonymous_unrevealed: "[%{app_name}] Tác phẩm của bạn đã chuyển sang trạng
+ thái ẩn danh và chưa công khai"
+ unrevealed: "[%{app_name}] Tác phẩm của bạn đã chuyển sang trạng thái chưa
+ công khai"
+ unrevealed_info: Tác phẩm chưa công khai sẽ không được liệt kê trong danh mục
+ từ khóa hay trang tác phẩm của bạn. Bất kỳ ai truy cập qua đường link tác
+ phẩm sẽ nhận được thông báo rằng tác phẩm hiện chưa được công khai, và nội
+ dung hiện chưa thể truy cập.
+ archivist_added_to_collection_notification:
+ approved_collection_items_page: Trang Approved Collection Items (Bộ Sưu Tập
+ đã Phê Duyệt)
+ archivist_notice: Bởi vì quản lý bộ sưu tập còn có vai trò là người lưu trữ
+ của Open Doors (Cửa Mở), họ được phép thêm tác phẩm của bạn vào bộ sưu tập
+ này, kể cả khi bạn đã tắt lời mời tham gia bộ sưu tập. Người lưu trữ chỉ thêm
+ một tác phẩm vào bộ sưu tập khi tác phẩm đó có mặt trên một kho tàng được
+ sáp nhập.
+ removal_instructions:
+ html: Nếu bạn muốn xóa tác phẩm của mình khỏi bộ sưu tập này, vui lòng truy
+ cập %{approved_items_link} của bạn.
+ text: 'Nếu bạn muốn xóa tác phẩm của mình khỏi bộ sưu tập này, vui lòng truy
+ cập trang Approved Collection Items (Bộ sưu tập đã phê duyệt) của bạn: %{approved_items_url}.'
+ subject: "[%{app_name}][%{collection_title}] Một người lưu trữ của Open Doors
+ (Cửa Mở) đã thêm tác phẩm của bạn vào một bộ sưu tập"
+ work_added:
+ html: Quản lý bộ sưu tập %{collection_link} đã thêm tác phẩm %{work_link}
+ của bạn vào bộ sưu tập của họ!
+ text: Quản lý bộ sưu tập "%{collection_title}" (%{collection_url}) đã thêm
+ tác phẩm "%{work_title}" (%{work_url}) của bạn vào bộ sưu tập của họ!
+ challenge_assignment_notification:
+ any: Bất kỳ
+ assignment:
+ html: Đây là nhiệm vụ của bạn trong thử thách %{link} tại AO3!
+ description: 'Mô tả:'
+ due: 'Thời hạn hoàn thành nhiệm vụ:'
+ html:
+ footer: Bạn nhận được email này vì bạn đã đăng ký tham gia thử thách %{title}.
+ Để biết thêm chi tiết về thử thách này và thông tin liên hệ của điều phối
+ viên, vui lòng truy cập %{footer_link}.
+ footer_link: trang chính của thử thách
+ look_up: Bạn có thể tìm hiểu thêm về nhiệm vụ này ở %{link}.
+ look_up_link: trang Assignments (Nhiệm Vụ) của bạn
+ optional_tags: 'Từ khóa tùy chọn:'
+ prompts: 'Chủ đề gợi ý:'
+ prompt_url: 'Địa chỉ URL của chủ đề gợi ý:'
+ recipient: 'Người nhận:'
+ recipient_missing: 'Không có người nhận: hãy liên hệ điều phối viên để được
+ giúp đỡ!'
+ subject: "[%{app_name}][%{collection_title}] Nhiệm Vụ của Bạn!"
+ text:
+ assignment: Đây là nhiệm vụ của bạn trong thử thách "%{collection_title}"
+ (%{collection_url}) tại AO3!
+ footer: Bạn nhận được email này vì bạn đã đăng ký tham gia thử thách %{title}
+ (%{url}). Để biết thêm chi tiết về thử thách này và thông tin liên hệ của
+ điều phối viên, vui lòng truy cập %{profile_url}.
+ look_up: Bạn có thể tìm hiểu thêm về nhiệm vụ này tại ở Assignments (Nhiệm
+ Vụ) của bạn tại %{link}.
+ change_email:
+ changed:
+ html: "%{login}, email dùng để đăng ký tài khoản của bạn đã được đổi thành
+ %{email}"
+ text: "%{login}, email dùng để đăng ký tài khoản của bạn đã được đổi thành
+ %{email}"
+ subject: "[%{app_name}] Thay đổi email"
+ claim_notification:
+ access:
+ contact_support: liên lạc Ban Hỗ Trợ của AO3
+ html: Tùy thuộc vào kho tàng gốc, sau khi được nhập vào, các tác phẩm của
+ bạn có thể chỉ hiển thị với người dùng đã đăng ký (để tránh những tác phẩm
+ ấy hiển thị khi dùng chức năng tìm kiếm của Google). Trong trường hợp đó,
+ chỉ những người dùng đã đăng nhập mới có thể nhìn thấy tác phẩm, trừ khi
+ bạn cho phép hiển thị với mọi người. Để mở khóa, bỏ quyền sở hữu hoặc xóa
+ tác phẩm, hãy %{contact_support_link}.
+ text: Tùy thuộc vào kho tàng gốc, sau khi được nhập vào, các tác phẩm của
+ bạn có thể chỉ hiển thị với người dùng đã đăng ký (để tránh những tác phẩm
+ ấy hiển thị khi dùng chức năng tìm kiếm của Google). Trong trường hợp đó,
+ chỉ những người dùng đã đăng nhập mới có thể nhìn thấy tác phẩm, trừ khi
+ bạn cho phép hiển thị với mọi người. Để mở khóa, bỏ quyền sở hữu hoặc xóa
+ tác phẩm, hãy liên lạc với Ban Hỗ Trợ AO3 ở %{support_url}.
+ email_tips: Để đảm bảo liên lạc giữa hai bên, hãy thêm @transformativeworks.org
+ vào danh sách email an toàn của bạn và kiểm tra hòm thư spam nếu không tìm
+ thấy phản hồi của chúng tôi.
+ introduction:
+ ao3_name: Archive of Our Own – AO3 (Kho Tàng Lưu Trữ của Chúng Ta)
+ html: Bạn nhận được thư này vì bạn có tác phẩm đăng tải ở trang kho tàng lưu
+ trữ tác phẩm fan được %{open_doors_name_link} nhập vào %{app_link}. Vì địa
+ chỉ e-mail này liên kết trực tiếp với địa chỉ e-mail đăng ký trong dữ liệu
+ trang kho tàng được nhập, các tác phẩm tương ứng (được liệt kê sau đây)
+ đã được tự động thêm vào tài khoản AO3 của bạn.
+ open_doors_name: Open Doors (Dự án Cửa Mở)
+ text: 'Bạn nhận được email này vì những tác phẩm của bạn trong kho tàng lưu
+ trữ tác phẩm của fan đã được nhập bởi Open Doors (Cửa Mở) (%{open_doors_url}
+ đến Archive of Our Own – AO3 (Kho tàng lưu trữ của chúng ta): %{app_url}..
+ Vì địa chỉ email này được liên kết với email đăng ký ở kho tàng đó, nên
+ những tác phẩm liên quan (được liệt kê bên dưới) được tự động thêm vào tài
+ khoản AO3 của bạn.'
+ mistake:
+ contact_open_doors: liên lạc với Open Doors
+ html: Nếu các tác phẩm kể trên không phải của bạn, xin vui lòng đừng xóa tác
+ phẩm, mà hãy %{contact_open_doors_link} để chúng tôi tìm phương án giải
+ quyết.
+ text: Nếu đây không phải những tác phẩm của bạn, xin đừng xóa chúng đi! Hãy
+ liên lạc với Open Doors (%{open_doors_url}) và chúng tôi sẽ tìm phương án
+ giải quyết.
+ more_info:
+ ao3_news: Tin tức AO3
+ contact_support: liên lạc với đội ngũ Hỗ Trợ của AO3
+ faq_page: trang Hỏi Đáp
+ html: Bạn có thể tìm đọc thông báo về các đợt nhập lưu trữ dữ liệu gần đây
+ tại trang %{ao3_news_link} và tìm hiểm thêm thông tin tại %{faq_page_link}
+ hoặc %{tutorial_page_link} của dự án Open Doors. Với những vấn đề chưa được
+ giải đáp trong trang Hỏi Đáp và trang Hướng Dẫn, vui lòng %{contact_support_link}.
+ text: Bạn có thể đọc thông báo về việc nhập dữ liệu từ kho tàng khác tại Tin
+ Tức AO3 (%{news_url}), và đọc thêm thông tin tại Trang Hỏi Đáp của Cửa Mở
+ (%{open_doors_faq_url}) hoặc Trang Hướng dẫn (%{open_doors_tutorial_url}).
+ Với những câu hỏi chưa được giải đáp trong Trang Hướng Đáp, Trang Hướng
+ dẫn và email này, hãy liên lạc Ban Hỗ Trợ tại %{support_url}.
+ tutorial_page: trang Hướng Dẫn
+ other_works:
+ contact_open_doors: liên lạc với Cửa Mở
+ html: Nếu bạn còn những tác phẩm khác đã được nhập vào nhưng lại liên kết
+ với email bạn không còn đăng nhập được, hãy %{contact_open_doors_link} đính
+ kèm thông tin chứng minh bạn là người sáng tác.
+ text: Nếu bạn còn những tác phẩm khác đã được nhập vào nhưng lại liên kết
+ với email bạn không còn đăng nhập được, hãy liên lạc với Cửa Mở đính kèm
+ thông tin chứng minh bạn là người sáng tác.
+ questions:
+ contact_support: liên lạc Ban Hỗ Trợ của AO3
+ html: Với những câu hỏi khác, hãy %{contact_support_link}.
+ text: Nếu bạn có câu hỏi nào khác, hãy liên lạc Ban Hỗ Trợ của AO3 tại %{support_url}.
+ redirects:
+ html: Để giữ danh sách đề cử và dấu trang, đường link những kho tàng được
+ nhập liệu có thể sẽ được chuyển hướng đến bản sao chép được nhập liệu của
+ những tác phẩm ấy trong một khoảng thời gian giới hạn (kiểm tra thông báo
+ về kho tàng để biết về thời gian). Nếu bạn đã đăng tải những tác phẩm này
+ và %{negation} dùng tính năng nhập liệu từ URL, tác phẩm đấy sẽ có 2 bản
+ trên AO3.
+ subject: "[%{app_name}] Tác phẩm đã đăng tải"
+ update_redirect:
+ contact_open_doors: liên lạc với Cửa Mở
+ html: Nếu bạn muốn Cửa Mở cập nhật đường link chuyển hướng sang tác phẩm đã
+ có sẵn của bạn, hãy xóa tác phẩm được nhập liệu, và %{contact_open_doors_link}
+ cùng tên tài khoản AO3, tên tài khoản trên kho tàng được nhập liệu của bạn,
+ tên và URL của tác phẩm mà bạn muốn được chuyển hướng đến. Nếu bạn có nhiều
+ tác phẩm cùng muốn chuyển hướng, bạn có thể liệt kê trong cùng một mail.
+ text: Nếu bạn muốn Cửa Mở cập nhật đường link chuyển hướng sang tác phẩm đã
+ có sẵn của bạn, hãy xóa tác phẩm được nhập liệu, và liên lạc với Cửa Mở
+ tại %{open_doors_url} cùng tên tài khoản AO3, tên tài khoản trên kho tàng
+ được nhập liệu của bạn, tên và URL của tác phẩm mà bạn muốn được chuyển
+ hướng đến. Nếu bạn có nhiều tác phẩm cùng muốn chuyển hướng, bạn có thể
+ liệt kê trong cùng một mail.
+ works_by: 'Những tác phẩm dưới đây được liên kết với email: %{email}'
+ work_info:
+ html: "%{work_link} (%{fandom})"
+ text:
+ no_fandom: "- %{work_title} %{work_url}"
+ with_fandom: "- %{work_title} %{work_url} (%{fandom})"
+ collection_notification:
+ assignments_sent:
+ complete: Tất cả nhiệm vụ đã được gửi.
+ subject: Các nhiệm vụ đã được gửi
+ html:
+ received_message: 'Bạn có một tin nhắn về bộ sưu tập %{collection_link} của
+ bạn:'
+ text:
+ received_message: 'Bạn có một tin nhắn về bộ sưu tập "%{collection_title}"
+ (%{collection_url}) của bạn:'
+ creatorship_notification:
+ explanation: Nếu bạn là đồng tác giả của một tác phẩm nào đó, bút danh của bạn
+ sẽ được đưa vào danh sách tác giả của các chương mới, bất kể thiết lập cá
+ nhân của bạn về đồng sáng tác. Bạn cũng sẽ được liệt kê vào danh sách tác
+ giả trong mọi bộ tác phẩm có chứa tác phẩm này.
+ html:
+ creation: "%{creation_link} bởi %{pseud_links}"
+ edit_chapter: chỉnh sửa chương truyện
+ edit_series: chỉnh sửa bộ tác phẩm
+ remove_chapter: Nếu bị thêm nhầm hoặc không muốn được liệt kê là đồng tác
+ giả, bạn có thể %{edit_chapter_link} để loại bỏ mình khỏi danh sách tác
+ giả.
+ remove_series: Nếu bạn bị thêm nhầm hoặc không muốn được liệt kê là đồng tác
+ giả, bạn có thể %{edit_series_link} để loại bỏ mình khỏi danh sách tác giả.
+ intro_chapter: 'Người dùng %{adding_user} đã liệt kê bút danh %{pseud} của bạn
+ làm đồng tác giả của chương truyện sau:'
+ intro_series: 'Người dùng %{adding_user} đã thêm bút danh %{pseud} của bạn làm
+ đồng tác giả của bộ tác phẩm:'
+ subject: "[%{app_name}] Thông báo đồng sáng tác"
+ text:
+ creation: "%{title} (%{url}) bởi %{pseuds}"
+ remove_chapter: 'Nếu bạn bị thêm nhầm hoặc không muốn được liệt kê là đồng
+ tác giả, bạn có thể chỉnh sửa chương truyện để loại bỏ mình khỏi danh sách
+ tác giả: %{url}'
+ remove_series: 'Nếu bạn bị thêm nhầm hoặc không muốn được liệt kê là đồng
+ tác giả, bạn có thể chỉnh sửa bộ tác phẩm để loại bỏ mình khỏi danh sách
+ tác giả: %{url}'
+ creatorship_notification_archivist:
+ explanation: Người dùng này hiện đang là chuyên viên lưu trữ của Open Doors
+ (Cửa Mở), vì vậy, họ được phép đưa bạn vào tác phẩm mà không cần yêu cầu,
+ kể cả khi bạn đã tắt chức năng đồng sáng tác.
+ html:
+ creation: "%{creation_link} bởi %{pseud_links}"
+ edit_chapter: chỉnh sửa chương truyện
+ edit_series: chỉnh sửa bộ tác phẩm
+ edit_work: chỉnh sửa tác phẩm
+ remove_chapter: Nếu bạn bị thêm nhầm hoặc không muốn được liệt kê là tác giả,
+ bạn có thể %{edit_chapter_link} để loại bỏ mình khỏi danh sách tác giả
+ remove_series: Nếu bạn bị thêm nhầm hoặc không muốn được liệt kê là người
+ sáng tạo, bạn có thể %{edit_series_link} để loại bỏ mình khỏi danh sách
+ tác giả.
+ remove_work: Nếu bạn bị thêm nhầm hoặc không muốn được liệt kê là đồng tác
+ giả, bạn có thể %{edit_work_link} để loại bỏ mình khỏi danh sách đồng tác
+ giả.
+ intro_chapter: 'Người dùng %{archivist} đã thêm bút danh %{pseud} của bạn làm
+ đồng tác giả của chương truyện sau:'
+ intro_series: 'Người dùng %{archivist} đã thêm bút danh %{pseud} của bạn làm
+ đồng tác giả trong bộ tác phẩm sau:'
+ intro_work: 'Người dùng %{archivist} đã thêm bút danh %{pseud} của bạn làm đồng
+ tác giả của tác phẩm sau:'
+ subject: "[%{app_name}] Thông báo của chuyên viên lưu trữ về tư cách đồng tác
+ giả"
+ text:
+ creation: "%{title} (%{url}) bởi %{pseuds}"
+ remove_chapter: 'Nếu bạn bị thêm nhầm hoặc không muốn được liệt kê là đồng
+ tác giả, bạn có thể chỉnh sửa chương truyện để loại bỏ mình khỏi danh sách
+ tác giả: %{url}'
+ remove_series: 'Nếu bạn bị thêm nhầm hoặc không muốn được liệt kê là tác giả,
+ bạn có thể chỉnh sửa bộ tác phẩm để loại bỏ mình khỏi danh sách tác giả:
+ %{url}'
+ remove_work: 'Nếu bạn bị thêm nhầm hoặc không muốn được liệt kê là đồng tác
+ giả, bạn có thể chỉnh sửa tác phẩm để loại bỏ mình khỏi danh sách tác giả:
+ %{url}'
+ creatorship_request:
+ html:
+ creation: "%{creation_link} bởi %{pseud_links}"
+ instructions: Bạn có thể chấp nhận hoặc từ chối lời mời này tại trang %{page_name}
+ của bạn.
+ page_name: Co-Creator Requests (Lời Mời Hợp Tác)
+ intro_chapter: 'Người dùng %{inviting_user} đã mời bút danh %{pseud} của bạn
+ làm đồng tác giả của chương truyện sau:'
+ intro_series: 'Người dùng %{inviting_user} đã mời bút danh %{pseud} của bạn
+ làm đồng tác giả của bộ tác phẩm sau:'
+ intro_work: 'Người dùng %{inviting_user} đã mời bút danh %{pseud} của bạn làm
+ đồng tác giả của tác phẩm sau:'
+ subject: "[%{app_name}] Lời mời hợp tác"
+ text:
+ creation: "%{title} (%{url}) bởi %{pseuds}"
+ instructions: 'Bạn có thể chấp nhận hoặc từ chối lời mời này tại trang Co-Creator
+ Requests (Lời Mời Hợp Tác) của bạn: %{url}'
+ delete_work_notification:
+ attachment: Một bản sao tác phẩm của bạn được đính kèm nhằm phục vụ mục đích
+ tham khảo.
+ deleted_other:
+ html: Tác phẩm %{title} đã được xóa theo yêu cầu của %{pseud}.
+ text: Tác phẩm "%{title}" đã được xóa theo yêu cầu của %{pseud}.
+ deleted_yourself:
+ html: Tác phẩm %{title} đã được xóa theo yêu cầu của bạn.
+ text: Tác phẩm "%{title}" đã được xóa theo yêu cầu của bạn.
+ questions:
+ html: Nếu bạn có thắc mắc, vui lòng %{support}.
+ text: Nếu bạn có thắc mắc, vui lòng %{support} (%{url}).
+ subject: "[%{app_name}] Quản trị viên đã xóa tác phẩm của bạn"
+ support: liên hệ ban Hỗ Trợ Kỹ Thuật
+ invitation_to_claim:
+ access:
+ text: Tùy thuộc vào kho lưu trữ, các tác phẩm của bạn có thể đã được nhập
+ chỉ giới hạn cho người dùng đã đăng ký (để chúng không hiển thị trên các
+ tìm kiếm của Google). Trong trường hợp này, chỉ người dùng đã đăng nhập
+ có thể được truy cập vào tác phẩm trừ khi bạn chọn hiển thị chúng cho tất
+ cả mọi người. Nếu cần hỗ trợ mở khoá, tách riêng, hoặc xoá tác phẩm, vui
+ lòng liên hệ ban Hỗ trợ Kỹ thuật AO3.
+ claim_or_remove:
+ html: Nhận hoặc xóa tác phẩm của bạn ở đây.
+ text: 'Nhận hoặc xóa tác phẩm của bạn ở đây: %{claim_url}'
+ email_tips: Nếu bạn liên hệ với chúng tôi, vui lòng cho phép nhận email từ địa
+ chỉ @transformativeworks.org và kiểm tra mục thư rác để tìm phản hồi từ chúng
+ tôi.
+ html:
+ ao3_news: Tin tức AO3
+ contact_open_doors: liên hệ Open Doors
+ contact_support: liên hệ ban Hỗ trợ Kỹ thuật AO3
+ faq_page: trang Câu Hỏi Thường Gặp
+ tutorial_page: trang hướng dẫn
+ introduction:
+ text: Bạn nhận được e-mail này vì gần đây một kho lưu trữ đã được nhập vào
+ %{app_name} (%{app_short_name} - %{app_url}) bởi Cửa Mở (%{open_doors_link}),
+ và chúng tôi tin rằng các tác phẩm sau đây thuộc về bạn. Chúng tôi muốn
+ cho bạn cơ hội để nhận (hoặc xóa/tách riêng) những tác phẩm này nếu bạn
+ muốn. Nếu bạn chưa có tài khoản đăng ký dưới e-mail khác, chúng tôi muốn
+ mời bạn tạo tài khoản!
+ mistake:
+ text: Nếu chúng tôi nhầm lẫn và đây không phải tác phẩm của bạn, xin đừng
+ xóa chúng! Vui lòng liên hệ Open Doors (%{open_doors_link}) và chúng tôi
+ sẽ xử lý vấn đề này.
+ more_info:
+ text: Bạn có thể đọc thông báo về các lần nhập kho lưu trữ gần đây tại trang
+ tin tức của AO3 (%{news_link}), và tìm thêm thông tin ở trang FAQ (%{open_doors_faq_link})
+ hoặc trang hướng dẫn (%{open_doors_tutorial_link}) của Open Doors. Đối với
+ bất kỳ câu hỏi nào không được trả lời trong trang FAQ, các hướng dẫn, hoặc
+ e-mail này, vui lòng liên hệ ban Hỗ trợ Kỹ thuật tại %{support_link}.
+ other_works:
+ text: Nếu bạn còn các tác phẩm nào khác trong kho lưu trữ được nhập dưới một
+ email bạn không thể truy cập được nữa, xin vui lòng liên hệ Open Doors với
+ bất kỳ thông tin nào có thể giúp xác minh danh tính của bạn.
+ questions:
+ text: Nếu còn thắc mắc nào khác, vui lòng liên hệ ban Hỗ trợ Kỹ thuật AO3
+ tại %{support_link}.
+ redirects: Để lưu danh sách đề xuất và tác phẩm được đánh dấu, các địa chỉ của
+ kho lưu trữ đã nhập có thể sẽ chuyển hướng đến bản sao đã nhập của các tác
+ phẩm này trong một khoảng thời gian giới hạn (xem bài thông báo về kho lưu
+ trữ của bạn để xác nhận). Nếu bạn đã tải lên bản sao của các tác phẩm này
+ và bạn KHÔNG sử dụng tính năng nhập từ URL, sẽ có hai bản sao của cùng một
+ tác phẩm trong kho lưu trữ.
+ subject: "[%{app_name}] Lời mời nhận tác phẩm"
+ unwanted:
+ text: Nếu những tác phẩm này thuộc về bạn nhưng bạn không muốn nhận chúng,
+ bạn có thể tách riêng (chúng sẽ vẫn tồn tại trên AO3, nhưng bút danh của
+ bạn sẽ được xóa) hoặc xóa chúng (chúng sẽ bị gỡ bỏ hoàn toàn khỏi AO3).
+ Bạn không cần thêm tác phẩm vào bất kỳ tài khoản nào để tách riêng hoặc
+ xóa chúng - bạn có thể thực hiện việc này trực tiếp qua đường link nhận
+ tác phẩm ở trên. (Nếu cần trợ giúp, xin liên lạc ban Hỗ trợ qua %{support_link}.)
+ update_redirect:
+ text: Nếu bạn muốn Open Doors cập nhật đường link chuyển hướng để dẫn đến
+ tác phẩm đã có trước đó của bạn, vui lòng xóa bản copy đã được nhập và liên
+ lạc Open Doors qua %{open_doors_link} với tên tài khoản AO3 của bạn, tên
+ tài khoản của bạn trên kho lưu trữ đã được nhập, cùng với tiêu đề và URL
+ của tác phẩm bạn muốn chuyển hướng đến. (Nếu bạn muốn đổi đường link chuyển
+ hướng cho nhiều tác phẩm, bạn có thể liệt kê chúng trong cùng một email.)
+ uploaded_list: 'Tác phẩm được đăng tải bao gồm:'
+ invite_increase_notification:
+ html:
+ body:
+ other: 'Chúng tôi xin thông báo: bạn hiện đang có %{count} thư mời mới có
+ thể sử dụng để tạo tài khoản mới ở AO3. Bạn có thể gửi thư mời cho bạn
+ bè qua %{invitation_page_link}.'
+ invitation_page_link_text: trang Invitations (Thư Mời) của mình
+ subject: "[%{app_name}] Thư mời mới"
+ text:
+ body:
+ other: 'Chúng tôi xin thông báo: bạn hiện đang có %{count} thư mời mới có
+ thể sử dụng để tạo tài khoản mới ở AO3. Bạn có thể gửi thư mời cho bạn
+ bè qua %{invitation_page_url}.'
+ invite_request_declined:
+ main:
+ other: 'Chúng tôi rất tiếc phải thông báo: chúng tôi không thể thực hiện yêu
+ cầu thêm %{count} thư mời mới của bạn tại thời điểm này.'
+ reason: 'Yêu cầu của bạn là:'
+ subject: "[%{app_name}] Từ chối Yêu cầu Thêm Thư mời"
+ recipient_notification:
+ html:
+ collection: Một tác phẩm dành tặng bạn đã được đăng tải trong bộ sưu tập %{collection_link}
+ tại AO3!
+ no_collection: Một tác phẩm dành tặng bạn đã được đăng tải trên AO3!
+ subject:
+ collection: "[%{app_name}][%{collection_title}] Một tác phẩm dành tặng bạn
+ trong %{collection_title}"
+ no_collection: "[%{app_name}] Một tác phẩm dành tặng bạn"
+ text:
+ collection: Một tác phẩm dành tặng bạn đã được đăng tải trong bộ sưu tập"%{collection_title}"
+ (%{collection_url}) tại AO3!
+ signup_notification:
+ activate:
+ html: Hãy %{activate_account_link}.
+ text: 'Hãy đi theo đường link để kích hoạt tài khoản của bạn: %{activate_account_url}'
+ activate_your_account: theo đường link này để kích hoạt tài khoản của bạn
+ admin_posts: Tin tức AO3
+ bye: Chúng tôi mong rằng bạn sẽ có trải nghiệm tốt nhất với AO3.
+ contact_support: liên hệ đội ngũ Hỗ trợ của chúng tôi
+ faq: FAQ
+ features:
+ html: Một khi tài khoản của bạn được xác nhận, bạn có thể đăng tác phẩm của
+ bạn, đăng kí theo dõi qua email để nhận được thông báo mỗi khi tác giả/tác
+ phẩm yêu thích của bạn có chương mới, cài đặt giao diện mới cho trang web,
+ lưu lại những tác phẩm bạn đã truy cập qua AO3 ở mục lịch sử, và nhiều hơn
+ thế.
+ text: Một khi tài khoản của bạn được xác nhận, bạn sẽ có thể đăng tác phẩm
+ của bạn, đăng kí theo dõi qua email để nhận được thông báo mỗi khi tác giả/tác
+ phẩm yêu thích của bạn có chương mới, cài đặt giao diện mới cho trang web,
+ lưu lại những tác phẩm bạn đã truy cập trên AO3 ở mục lịch sử, và nhiều
+ hơn thế.
+ information:
+ html: Có rất nhiều thông tin và lời khuyên về cách sử dụng AO3 trong mục %{faq_link}.
+ Bạn có thể tìm đọc những tin tức mới về quá trình phát triển trang web tại
+ mục %{admin_posts_link}. Nếu bạn cần trợ giúp, gặp trục trặc khi sử dụng
+ web, hoặc có câu hỏi hay lời góp ý nào, hãy %{contact_support_link}. Chúng
+ tôi luôn sẵn lòng giúp đỡ.
+ text: 'Xem thông tin thêm và hướng dẫn sử dụng Kho tàng trong mục ‘Những câu
+ hỏi thường gặp’ %{faq_url}. Bạn có thể tìm đọc những tin tức mới về quá
+ trình phát triển trang web ở mục AO3 News (Tin tức AO3) tại %{admin_posts_url}.
+ Nếu bạn cần giúp đỡ, gặp trục trặc khi sử dụng trang web, hoặc có câu hỏi
+ hay lời góp ý nào, hãy liên hệ đội ngũ Hỗ trợ của chúng tôi. Chúng tôi luôn
+ sẵn lòng giúp đỡ: %{contact_support_url}.'
+ welcome: Chào mừng đến với AO3, %{login}!
diff --git a/config/locales/phrase-exports/zh-CN.yml b/config/locales/phrase-exports/zh-CN.yml
new file mode 100755
index 0000000..729bc83
--- /dev/null
+++ b/config/locales/phrase-exports/zh-CN.yml
@@ -0,0 +1,360 @@
+---
+zh-CN:
+ activerecord:
+ attributes:
+ archive_warning:
+ name_with_colon:
+ other: 警告:
+ category:
+ name_with_colon:
+ other: 性向/性向分类:
+ character:
+ name_with_colon:
+ other: 角色:
+ fandom:
+ name_with_colon:
+ other: 同人圈:
+ freeform:
+ name_with_colon:
+ other: 其他标签:
+ rating:
+ name_with_colon: 分级:
+ relationship:
+ name_with_colon:
+ other: 配对:
+ work:
+ chapter_total_display: 章节
+ summary: 简介
+ models:
+ archive_warning:
+ other: 警告
+ category:
+ other: 性向/性向分类
+ chapter:
+ other: 章节
+ character:
+ other: 角色
+ fandom:
+ other: 同人圈
+ freeform:
+ other: 其他标签
+ rating:
+ other: 分级
+ relationship:
+ other: 配对
+ series:
+ other: 系列
+ kudo_mailer:
+ batch_kudo_notification:
+ guest:
+ other: "%{count}名访客"
+ left_kudos:
+ html:
+ other: "%{givers_list}给您的作品%{commentable_link}点了赞。"
+ text:
+ other: "%{givers_list}给您的作品%{commentable_title}(%{commentable_url})点了赞。"
+ single_guest:
+ giver: 一名访客
+ html: "%{giver}给您的作品%{commentable_link}点了赞。"
+ text: 一名访客给您的作品%{commentable_title}(%{commentable_url})点了赞。
+ subject: "[%{app_name}] 您收到了点赞!"
+ layouts:
+ proxy_notice:
+ faux_heading: 重要提示:
+ point1: 您使用的是第三方开发的反向代理网站,此网站并非Archive of Our Own - AO3(AO3作品库)原站。
+ point2: 代理网站的开发者能够获取您上传至该站点的全部内容,包括您的ip地址。如您通过代理登录AO3,对方将获得您的密码。
+ mailer:
+ general:
+ closing:
+ formal: 此致敬礼
+ informal: 祝好
+ creation:
+ link_with_word_count: "%{creation_link}(%{word_count})"
+ title_with_chapter_number: "%{title}的第%{position}章"
+ title_with_word_count: '"%{creation_title}"(%{word_count})'
+ word_count:
+ other: "%{count} 字符"
+ footer:
+ general:
+ about:
+ html: AO3是由同人爱好者运作及支持、并由%{donate_link}资助运行的作品库。
+ text: AO3是由同人爱好者运作及支持、并由您的捐赠资助运行的作品库:%{donate_url}。
+ html:
+ donate_link_text: 您的捐赠
+ support_link_text: 联系支持团队
+ unwanted_email:
+ html: 如果这封邮件被误发给了您,请%{support_link}。
+ text: 如果这封邮件被误发给了您,请于此联系支持团队:%{support_url}。
+ sent_at: 于%{sent_at}发送。
+ greeting:
+ formal_html: 尊敬的%{name},
+ informal:
+ addressed_html: 您好,%{name}!
+ unaddressed: 您好!
+ introductory: Archive of Our Own – AO3(AO3作品库)向您问好!
+ metadata_label_indicator: ":"
+ signature:
+ abuse_team: AO3条款执行和违规行为处理团队
+ app_short_name: AO3
+ open_doors: Open Doors(Open Doors拯救计划)团队
+ parent_org: Organization for Transformative Works – OTW(OTW再创作组织)
+ support: AO3支持委员会团队
+ users:
+ mailer:
+ reset_password_instructions:
+ expiration: 此链接一周后失效,如果您届时仍未修改密码,则需要申请新的链接。
+ intro: 有人提出了重设您的账号密码的申请。您可以进入下方的链接对密码进行更改:
+ link_title: 更改我的密码。
+ subject: "[%{app_name}] 重设您的密码"
+ unrequested: 如果您没有申请重设密码,请忽略此邮件,继续使用您原本的密码。
+ user_mailer:
+ admin_deleted_work_notification:
+ bye: 附件中为该作品副本,以供参考。
+ contact_abuse: 联系条款执行与违反行为处理委员会
+ deleted:
+ html: 您的作品%{title}已被网站管理员从AO3作品库中删除。
+ text: 网站管理员从AO3作品库中删除您的作品%{title}。
+ html:
+ tos_violation: 如果您的作品违反了AO3作品库的网站服务条款,请%{contact_abuse_link}。
+ import_project:
+ html: 如果您的作品属于Open Doors(Open Doors拯救计划)的导入项目,请%{opendoors_link}提出有关疑问。
+ text: 如果您的作品属于Open Doors(Open Doors拯救计划)的导入项目,请联系Open Doors拯救计划(%{opendoors_link})。
+ opendoors: 联系Open Doors拯救计划
+ subject: "[%{app_name}] 您的作品已被管理员删除"
+ text:
+ tos_violation: 如果您的作品违反了AO3作品库的网站服务条款,请联系条款执行和违反行为处理委员会(%{contact_abuse_url})。
+ admin_hidden_work_notification:
+ access: 在作品被隐藏期间,您依然可以通过上方链接访问该作品,但它将不会显示在您的作品页,且其他AO3用户将无法访问。
+ check_email: 条款执行和违反行为处理委员会可能已经联系您并对您作品被隐藏的原因进行了解释,因此请检查您的邮箱,包括垃圾邮件列表。
+ contact_abuse: 联系条款执行和违反行为处理委员会
+ html:
+ help: 如果您不确定为什么您的作品会被隐藏,并且没有收到有关此事的进一步联系,请直接%{contact_abuse_link}。
+ hidden: 您的作品%{title}已被条款执行和违反行为处理委员会隐藏且无法再被公开访问。
+ tos_violation: 如果您的作品是因为违反AO3的%{tos_link}而被隐藏,您会被要求采取行动以纠正违规行为。如果您无法让您的作品符合服务条款,您的作品可能会被从AO3作品库中删除。
+ subject: "[%{app_name}] 您的作品已被条款执行和违反行为处理委员会隐藏"
+ text:
+ help: 如果您不确定为什么您的作品会被隐藏,并且没有收到有关此事的进一步联系,请直接联系条款执行和违反行为处理委员会:%{contact_abuse_url}。
+ hidden: 您的作品"%{title}"(%{work_url})已被条款执行和违反行为处理委员会隐藏且无法再被公开访问。
+ tos_violation: 如果您的作品是因为违反AO3的服务条款(%{tos_url})而被隐藏,您会被要求采取行动以纠正违规行为。如果您无法让您的作品符合服务条款,您的作品可能会被从AO3作品库中删除。
+ tos: 服务条款
+ anonymous_or_unrevealed_notification:
+ anonymous_info: 匿名作品在标签列表中可见,但不会显示在您的作品页面上。在作品页,您的用户名将被替换为"Anonymous"(佚名)。
+ anonymous_unrevealed_info: 管理员随后可能会公开您的作品,但仍保留匿名。订阅您账户的用户不会收到相关改动提示。作品一经公开,即会被收录到标签列表中,但不会显示在您的作品页面。在作品页,您的用户名将被替换为"Anonymous"(佚名)。
+ changed_status:
+ anonymous:
+ html: 作品集%{collection_link}的管理员已将您的作品%{work_link}设为匿名状态。
+ text: 作品集%{collection_title}(%{collection_url})的管理员已将您的作品%{work_title}(%{work_url})设为匿名状态。
+ anonymous_unrevealed:
+ html: 作品集%{collection_link}的管理员已将您的作品%{work_link} 设为匿名与非公开状态.
+ text: 作品集%{collection_title}(%{collection_url})的管理员已将您的作品%{work_title}(%{work_url})设为匿名与非公开状态。
+ unrevealed:
+ html: 作品集%{collection_link}的管理员已将您的作品%{work_link}设为非公开状态。
+ text: 作品集%{collection_title}(%{collection_url})的管理员已将您的作品%{work_title}(%{work_url})设为非公开状态。
+ collection_items_link_text: Approved Collection Items(已通过作品集条目)页
+ do_not_want:
+ anonymous:
+ html: 如果您想取消作品的匿名状态,请访问%{collection_items_link}将其从该作品集中移除。
+ text: 如果您不希望作品保持匿名状态,请访问Approved Collection Items(已通过作品集条目)页,将其从该作品集中移除:%{collection_items_url}
+ anonymous_unrevealed:
+ html: 如果您不希望您的作品保持匿名与非公开状态,请访问%{collection_items_link}将其从作品集中移除。
+ text: 如果您不希望作品保持匿名与非公开状态,请访问Approved Collection Items(已通过作品集条目)页,将其从该作品集中移除:%{collection_items_url}
+ unrevealed:
+ html: 如果您不希望您的作品保持非公开状态,请访问%{collection_items_link}将其从作品集中移除。
+ text: 如果您不希望作品保持非公开状态,请访问Approved Collection Items(已通过作品集条目)页,将其从该作品集中移除:%{collection_items_url}
+ faq_link_text: 同人作品集常见问题
+ more_info:
+ html: 详情请查阅%{faq_link}。
+ text: 详情请查阅同人作品集常见问题:%{faq_url}
+ subject:
+ anonymous: "[%{app_name}] 您的作品被设为匿名状态"
+ anonymous_unrevealed: "[%{app_name}] 您的作品被设为匿名与非公开状态"
+ unrevealed: "[%{app_name}] 您的作品被设为非公开状态"
+ unrevealed_info: 非公开作品不会显示在标签列表或您的作品页面上。如果点击作品链接,用户将收到一则提示:作品当前未公开,无法获取内容。
+ archivist_added_to_collection_notification:
+ approved_collection_items_page: Approved Collection Items (已批准作品集的作品) 页面
+ archivist_notice: 因为作品集维护员拥有Open Doors (Open Doors拯救计划) 作品库管理员官方身份,所以即使您禁用了作品集邀请,他们也可以将您的作品添加到此作品集中。管理员只会将被导入作品库中的作品添加到作品集中。
+ removal_instructions:
+ html: 如果您想从此作品集中删除您的作品,请访问您的%{approved_items_link}。
+ text: 如果您想从此作品集中删除您的作品,请访问您的Approved Collection Items (已批准作品集的作品) 页面:%{approved_items_url}.
+ subject: "[%{app_name}][%{collection_title}]一位Open Doors (Open Doors拯救计划) 作品库管理员已将您的作品添加到一个作品集。"
+ work_added:
+ html: "%{collection_link}的作品集维护员已将您的作品%{work_link} 添加到他们的作品集。"
+ text: '"%{collection_title}" (%{collection_url})的作品集维护员已将您的作品"%{work_title}"(%{work_url})添加到他们的作品集!'
+ challenge_assignment_notification:
+ any: 任意
+ assignment:
+ html: 您在AO3的%{link}写手挑战中收到了以下点文!
+ description: 描述:
+ due: 本篇点文的截止日期为:
+ html:
+ footer: 您之所以会收到这封邮件,是因为您报名参加了%{title}写手挑战。如果想了解本次挑战的相关信息以及管理员的联系方式,请前往%{footer_link}。
+ footer_link: 写手挑战详情页面
+ look_up: 查看本篇点文详情,请前往%{link}。
+ look_up_link: 您的Assignments(点文)页面
+ optional_tags: 可选标签:
+ prompts: 点梗:
+ prompt_url: 点梗链接:
+ recipient: 受赠者:
+ recipient_missing: 无受赠者:请联系写手挑战管理员。
+ subject: "[%{app_name}][%{collection_title}] 您的点文!"
+ text:
+ assignment: 您在AO3的%{collection_title}写手挑战(%{collection_url})中收到了以下点文!
+ footer: 您之所以会收到这封邮件,是因为您报名参加了%{title}写手挑战(%{url})。如果您想了解本次挑战的相关信息以及管理员的联系方式,请前往%{profile_url}。
+ look_up: 查看本篇点文详情,请前往您的Assignments(点文)页面%{link}。
+ change_email:
+ changed:
+ html: "%{login},与您账号关联的邮箱地址已改为 %{email}"
+ text: "%{login},与您账号关联的邮箱地址已改为 %{email}"
+ subject: "[%{app_name}] 邮箱地址更换"
+ claim_notification:
+ access:
+ contact_support: 联系AO3支持委员会
+ html: 出于被搬运作品库的要求,您的作品被搬运至AO3后可能仅向登录用户开放(以防它们出现在谷歌检索结果中)。在这种情况下,除非您选择面向所有访客打开权限,否则仅登录用户可以查看这些作品。关于如何解锁、遗弃或删除您的作品,请%{contact_support_link}。
+ text: 出于被搬运作品库的要求,您的作品被搬运至AO3后可能仅向登录用户开放(以防它们出现在谷歌检索结果中)。在这种情况下,除非您选择面向所有访客打开权限,否则仅登录用户可以查看这些作品。关于如何解锁、遗弃或删除您的作品,请使用%{support_url},联系AO3支持委员会。
+ email_tips: 如欲与我们取得联系,请将使用@transformativeworks.org这一域名的电邮地址添加至安全联系人列表,并检查您的垃圾邮件确认其中是否有我们的回复。
+ introduction:
+ ao3_name: Archive of Our Own – AO3(AO3作品库)
+ html: 本邮件旨在通知您,包含您作品的同人作品库已被%{open_doors_name_link}搬运至 %{app_link}。由于此电邮地址与被搬运作品库的注册用户相关联,相关同人作品(如下)已被自动添加至您的AO3账号。
+ open_doors_name: Open Doors(Open Doors拯救计划)
+ text: 本邮件旨在通知您,包含您作品的同人作品库已被Open Doors(Open Doors拯救计划)(%{open_doors_url})搬运至Archive
+ of Our Own – AO3(AO3作品库):%{app_url}。由于此电邮地址与被搬运作品库的注册用户相关联,相关同人作品(如下)已被自动添加至您的AO3账号。
+ mistake:
+ contact_open_doors: 联系Open Doors拯救计划
+ html: 如果存在错误,并且下列作品并非您的作品,请勿将其删除!请%{contact_open_doors_link},我们会帮助您解决。
+ text: 如果存在错误,并且下列作品并非您的作品,请勿将其删除!请联系Open Doors拯救计划(%{open_doors_url}),我们会帮助您解决。
+ more_info:
+ ao3_news: AO3新闻
+ contact_support: 联系AO3支持委员会
+ faq_page: 常见问题页面
+ html: 您可以点击%{ao3_news_link}阅读近期的作品库搬运公告,亦可前往Open Doors拯救计划的%{faq_page_link}或%{tutorial_page_link}了解更多信息。对于常见问题、教程或此封邮件中未予解答的任何问题,请%{contact_support_link}。
+ text: 您可以点击AO3新闻(%{news_url})阅读近期的作品库搬运公告,亦可前往Open Doors拯救计划的常见问题页面(%{open_doors_faq_url})或教程页面(%{open_doors_tutorial_url}),了解更多信息。对于常见问题、教程或此封邮件中未予解答的任何问题,请使用%{support_url}联系支持委员会。
+ tutorial_page: 教程页面
+ other_works:
+ contact_open_doors: 联系Open Doors拯救计划
+ html: 如果您在被搬运作品库还拥有其他作品,但已无法访问上传时所用的电邮地址,请%{contact_open_doors_link}并提供任何有助于核验您身份的信息。
+ text: 如果您在被搬运作品库还拥有其他作品,但已无法访问上传时所用的电邮地址,请联系Open Doors拯救计划并提供任何有助于核验您身份的信息。
+ questions:
+ contact_support: 联系AO3支持委员会
+ html: 如有其他疑问,请%{contact_support_link}。
+ text: 如有其他疑问,请使用%{support_url}联系AO3支持委员会。
+ redirects:
+ html: 为保留推荐列表和收藏,在有限的时间内,被搬运作品库的网址可能会被重定向至这些作品在AO3上的搬运副本(请查阅作品库搬运公告帖确认具体情况)。如果您已上传这些作品的副本,并且%{negation}使用"通过URL地址搬运"这一功能,同一作品在AO3上将有两份副本。
+ subject: "[%{app_name}]作品上传通知"
+ update_redirect:
+ contact_open_doors: 联系Open Doors拯救计划
+ html: 如果您希望由Open Doors拯救计划来更新重定向链接,导向您自行上传的作品副本,请删除搬运副本,并%{contact_open_doors_link},说明您的AO3账号用户名、您在被搬运作品库使用的用户名,以及希望重定向链接导向的同人作品标题和URL地址。(如果涉及多份作品,您可以在同一封邮件中全部列出。)
+ text: 如果您希望由Open Doors拯救计划来更新重定向链接,导向您已自行上传的作品副本,请删除搬运副本,并使用%{open_doors_url}联系Open
+ Doors拯救计划,说明您的AO3账号用户名、您在被搬运作品库使用的用户名,以及希望重定向链接导向的同人作品标题和URL地址。(如果涉及多份作品,您可以在同一封邮件中全部列出。)
+ works_by: 此邮箱(%{email})关联的作品如下:
+ work_info:
+ html: "%{work_link} (%{fandom})"
+ text:
+ no_fandom: "- %{work_title} %{work_url}"
+ with_fandom: "- %{work_title} %{work_url} (%{fandom})"
+ collection_notification:
+ assignments_sent:
+ complete: 所有任务已发送。
+ subject: 任务已发送
+ html:
+ received_message: 您收到一则有关您的作品集%{collection_link}的信息:
+ text:
+ received_message: 您收到一则有关您的作品集"%{collection_title}"(%{collection_url})的信息:
+ creatorship_notification:
+ explanation: 作为某一作品的共同创作者, 无论您在个人设置中是否允许他人邀请您成为共同创作者,您都可能被列为该作品新章节的创作者, 也会被列为该作品所属系列的创作者之一。
+ html:
+ creation: 由%{pseud_links}所作的%{creation_link}
+ edit_chapter: 编辑章节
+ edit_series: 编辑系列
+ remove_chapter: 如果您被误列为共同创作者,或者不想被列为创作者之一,您可以通过%{edit_chapter_link}将自己从创作者名单中移除。
+ remove_series: 如果您被误列为共同创作者,或者不想被列为创作者之一,您可以通过%{edit_series_link}将自己从创作者名单中移除。
+ intro_chapter: 用户%{adding_user}将您 以别名%{pseud}列为了下述章节的共同创作者:
+ intro_series: 用户%{adding_user}将您以别名%{pseud}列为了下述系列的共同创作者:
+ subject: "[%{app_name}] 共同创作者提示"
+ text:
+ creation: 由%{pseuds}所作的%{title}(%{url})
+ remove_chapter: 如果您被误列为共同创作者,或者不想被列为创作者之一,您可以通过编辑章节将自己从创作者名单中移除:%{url}
+ remove_series: 如果您被误列为共同创作者,或者不想被列为创作者之一,您可以通过编辑系列将自己从创作者名单中移除:%{url}
+ creatorship_notification_archivist:
+ explanation: 该用户作为Open Doors(Open Doors拯救计划)的作品库管理员,在相关工作需要时,可在不发送请求的情况下将您添加为共同创作者,即使您可能在个人设置中禁止了其他用户邀请您成为共同创作者。
+ html:
+ creation: "%{creation_link},作者:%{pseud_links}"
+ edit_chapter: 编辑章节
+ edit_series: 编辑系列
+ edit_work: 编辑作品
+ remove_chapter: 如果您被误列为共同创作者,或者不想被列为创作者之一,您可以通过%{edit_chapter_link},将自己从创作者名单中移除。
+ remove_series: 如果您被误列为共同创作者,或者不想被列为创作者之一,您可以通过%{edit_series_link}将自己从创作者名单中移除。
+ remove_work: 如果您被误列为共同创作者,或者不想被列为创作者之一,您可以通过%{edit_work_link},将自己从创作者名单中移除。
+ intro_chapter: 用户%{archivist} 已将您以别名%{pseud}添加为下述章节的共同创作者:
+ intro_series: 用户%{archivist} 已将您以别名%{pseud}添加为下述系列的共同创作者:
+ intro_work: 用户%{archivist}已将您以别名%{pseud}添加为下述作品的共同创作者:
+ subject: "[%{app_name}]档案管理员添加共同创作者通知"
+ text:
+ creation: "%{title}(%{url}),作者:%{pseuds}"
+ remove_chapter: 如果您被误列为共同创作者,或者不想被列为创作者之一,您可以通过编辑章节将自己从创作者名单中移除:%{url}
+ remove_series: 如果您被误列为共同创作者,或者不想被列为创作者之一,您可以通过编辑系列将自己从创作者名单中移除:%{url}
+ remove_work: 如果您被误列为共同创作者,或者不想被列为创作者之一,您可以通过编辑作品将自己从创作者名单中移除:%{url}
+ creatorship_request:
+ html:
+ creation: 由%{pseud_links}所作的%{creation_link}
+ instructions: 您可以在您的%{page_name}页面接受或拒绝此邀请。
+ page_name: Co-Creator Requests(共同创作者邀请)
+ intro_chapter: 用户%{inviting_user}向您发送了一则邀请,希望将您以别名%{pseud}列为下述章节的共同创作者:
+ intro_series: 用户%{inviting_user}向您发送了一则邀请,希望将您以别名%{pseud}列为下述系列的共同创作者:
+ intro_work: 用户%{inviting_user}向您发送了一则邀请,希望将您以别名%{pseud}列为下述作品的共同创作者:
+ subject: "[%{app_name}] 共同创作者邀请"
+ text:
+ creation: 由%{pseuds}所作的%{title}(%{url})
+ instructions: 您可以在您的Co-Creator Requests(共同创作者邀请)页面接受或拒绝此邀请:%{url}
+ delete_work_notification:
+ attachment: 附件中为该作品副本,以供参考。
+ deleted_other:
+ html: 应%{pseud}的要求,您的作品%{title}已被删除。
+ text: 应%{pseud}的要求,您的作品%{title}已被删除。
+ deleted_yourself:
+ html: 应您的要求,您的作品%{title}已被删除。
+ text: 应您的要求,您的作品%{title}已被删除。
+ questions:
+ html: 如果您有任何疑问,请%{support}。
+ text: 如果您有任何疑问,请%{support} (%{url})。
+ subject: "[%{app_name}] 您的作品已被删除"
+ support: 联系支持委员会
+ invite_increase_notification:
+ html:
+ body:
+ other: 我们想通知您,您有%{count}份新邀请帖,该邀请帖用于创建AO3作品库的新帐户。您可以在%{invitation_page_link}邀请朋友。
+ invitation_page_link_text: 您的Invitations(邀请帖)页面
+ subject: "[%{app_name}] 新邀请帖"
+ text:
+ body:
+ other: 我们想通知您,您有%{count}份新邀请帖,该邀请帖用于创建AO3作品库的新帐户。您可以在(%{invitation_page_url})邀请朋友。
+ invite_request_declined:
+ main:
+ other: 我们很遗憾地通知您,您提出的%{count}份新邀请码申请未能获批。
+ reason: 您的申请为:
+ subject: "[%{app_name}] 额外邀请码申请拒函"
+ recipient_notification:
+ html:
+ collection: 您在AO3上收到了一篇来自于作品集 %{collection_link} 的赠文!
+ no_collection: 您在AO3上收到了一篇赠文!
+ subject:
+ collection: "[%{app_name}][%{collection_title}]来自于作品集 %{collection_title}
+ 的赠文"
+ no_collection: "[%{app_name}]您收到了一篇赠文"
+ text:
+ collection: 您在AO3上收到了一篇来自于作品集%{collection_title} (%{collection_url})的赠文!
+ signup_notification:
+ activate:
+ html: 请%{activate_account_link}。
+ text: 请点击以下链接激活您的账户: %{activate_account_url}
+ activate_your_account: 点击以下链接激活您的账户
+ admin_posts: AO3新闻
+ bye: 祝您使用愉快。
+ contact_support: 联系支持委员会
+ faq: 常见问题
+ features:
+ html: 激活账户后,即可使用本站诸多功能,包括发布同人作品;设置邮件订阅,在喜爱的创作者或作品更新时获取即时通知;进行偏好设置,定制网站界面和操作方式;保留浏览历史,追踪之前在AO3上阅读的作品。
+ text: 激活账户后,即可使用本站诸多功能,包括发布同人作品;设置邮件订阅,在喜爱的创作者或作品更新时获取即时通知;进行偏好设置,定制网站界面和操作方式;保留浏览历史,追踪之前在AO3上阅读的作品。
+ information:
+ html: 您可浏览本站%{faq_link},详细了解关于AO3使用方法的信息与建议;进入%{admin_posts_link}板块,了解网站建设的最新消息。如您需要更多帮助,遇到程序漏洞,存在疑问或想提出意见,请%{contact_support_link},该团队将竭诚为您服务。
+ text: 您可浏览本站%{faq_url},详细了解关于AO3使用方法的信息与建议;进入%{admin_posts_url}板块,了解网站建设的最新消息。如您需要更多帮助,遇到程序漏洞,存在疑问或想提出意见,请%{contact_support_url},该团队将竭诚为您服务。
+ welcome: 欢迎来到AO3, %{login}!
diff --git a/config/locales/rails-i18n/locale/fil.yml b/config/locales/rails-i18n/locale/fil.yml
new file mode 100755
index 0000000..68fe74a
--- /dev/null
+++ b/config/locales/rails-i18n/locale/fil.yml
@@ -0,0 +1,202 @@
+---
+fil:
+ activerecord:
+ errors:
+ messages:
+ record_invalid: 'Nabigo ang pagpapatunay: %{errors}'
+ date:
+ abbr_day_names:
+ - Lin
+ - Lun
+ - Mar
+ - Miy
+ - Huw
+ - Biy
+ - Sab
+ abbr_month_names:
+ -
+ - Ene
+ - Peb
+ - Mar
+ - Abr
+ - May
+ - Hun
+ - Hul
+ - Ago
+ - Set
+ - Okt
+ - Nob
+ - Dis
+ day_names:
+ - Linggo
+ - Lunes
+ - Martes
+ - Miyerkules
+ - Huwebes
+ - Biyernes
+ - Sabado
+ formats:
+ default: "%d/%m/%Y"
+ long: ika-%d ng %B, %Y
+ short: ika-%d ng %b
+ month_names:
+ -
+ - Enero
+ - Pebrero
+ - Marso
+ - Abril
+ - Mayo
+ - Hunyo
+ - Hulyo
+ - Agosto
+ - Setyembre
+ - Oktubre
+ - Nobyembre
+ - Disyembre
+ order:
+ - :year
+ - :month
+ - :day
+ datetime:
+ distance_in_words:
+ about_x_hours:
+ one: humigit-kumulang isang oras
+ other: humigit-kumulang %{count} oras
+ about_x_months:
+ one: humigit-kumulang isang buwan
+ other: humigit-kumulang %{count} buwan
+ about_x_years:
+ one: humigit-kumulang isang taon
+ other: humigit-kumulang %{count} taon
+ almost_x_years:
+ one: halos isang taon
+ other: halos %{count} taon
+ half_a_minute: kalahating minuto
+ less_than_x_seconds:
+ one: mas mababa sa isang segundo
+ other: mas mababa sa %{count} segundo
+ less_than_x_minutes:
+ one: mas mababa sa isang minuto
+ other: mas mababa sa %{count} minuto
+ over_x_years:
+ one: higit sa isang taon
+ other: higit %{count} taon
+ x_seconds:
+ one: isang segundo
+ other: "%{count} segundo"
+ x_minutes:
+ one: isang minuto
+ other: "%{count} minuto"
+ x_days:
+ one: isang araw
+ other: "%{count} araw"
+ x_months:
+ one: isang buwan
+ other: "%{count} buwan"
+ prompts:
+ second: segundo
+ minute: minuto
+ hour: oras
+ day: araw
+ month: buwan
+ year: taon
+ errors:
+ format: "%{attribute} ay %{message}"
+ messages:
+ accepted: dapat na tanggapin
+ blank: hindi maaaring walang laman
+ confirmation: hindi tumutugma ang pagpapatunay
+ empty: hindi maaaring walang laman
+ equal_to: dapat na katumba sa %{count}
+ even: dapat maging even
+ exclusion: nakalaan na
+ greater_than: dapat na mas higit sa %{count}
+ greater_than_or_equal_to: dapat na mas higit sa o katumbas ng %{count}
+ inclusion: hindi kasama sa listahan
+ invalid: hindi wasto
+ less_than: dapat na mas mababa sa %{count}
+ less_than_or_equal_to: dapat na mas mababa sa o katumbas ng %{count}
+ not_a_number: hindi isang numero
+ not_an_integer: dapat na isang integer
+ odd: dapat maging odd
+ taken: ginagamit na
+ too_long:
+ one: masyadong mahaba (pinakamadami ay %{count} character)
+ other: masyadong mahaba (pinakamadami ay %{count} character)
+ too_short:
+ one: masyadong maikli (pinakakonti ay %{count} character)
+ other: masyadong maikli (pinakakonti ay %{count} character)
+ wrong_length:
+ one: ang maling haba (ito ay dapat eksaktong %{count} character)
+ other: ang maling haba (ito ay dapat eksaktong %{count} character)
+ template:
+ body: 'May mga problema sa mga sumusunod na patlang:'
+ header:
+ one: hindi maaaring i-save ang %{model} na ito dahil sa isang error
+ other: hindi maaaring i-save ang %{model} na ito dahil sa %{count} error
+ helpers:
+ select:
+ prompt: Mangyaring pumili
+ submit:
+ create: lumikha ng %{model}
+ submit: isumite ang %{model}
+ update: i-update ang %{model}
+ number:
+ currency:
+ format:
+ delimiter: ","
+ format: "%n %u"
+ precision: 2
+ separator: "."
+ significant: false
+ strip_insignificant_zeros: false
+ unit: "₱"
+ format:
+ delimiter: ","
+ precision: 3
+ separator: "."
+ significant: false
+ strip_insignificant_zeros: false
+ human:
+ decimal_units:
+ format: "%n %u"
+ units:
+ billion: bilyon
+ million: milyon
+ quadrillion: kuwadrilyon
+ thousand: libo
+ trillion: trilyon
+ unit: ''
+ format:
+ delimiter: ''
+ precision: 1
+ significant: true
+ strip_insignificant_zeros: true
+ storage_units:
+ format: "%n %u"
+ units:
+ byte:
+ one: Byte
+ other: Bytes
+ gb: GB
+ kb: KB
+ mb: MB
+ tb: TB
+ percentage:
+ format:
+ delimiter: ''
+ precision:
+ format:
+ delimiter: ''
+ support:
+ array:
+ last_word_connector: ", at"
+ two_words_connector: " at "
+ words_connector: ","
+ time:
+ am: AM
+ formats:
+ default: "%A, ika-%d ng %B ng %Y %H:%M:%S %z"
+ long: ika-%d ng %B ng %Y %H:%M
+ short: "%d ng %b %H:%M"
+ pm: PM
\ No newline at end of file
diff --git a/config/locales/rails-i18n/locale/mr.yml b/config/locales/rails-i18n/locale/mr.yml
new file mode 100755
index 0000000..515126f
--- /dev/null
+++ b/config/locales/rails-i18n/locale/mr.yml
@@ -0,0 +1,207 @@
+---
+mr:
+ activerecord:
+ errors:
+ messages:
+ record_invalid: 'प्रमाणीकरण अयशस्वी: %{errors}'
+ restrict_dependent_destroy:
+ has_one: अवलंबून %{record} अस्तित्वात असल्याने रेकॉर्ड हटवू शकत नाही
+ has_many: अवलंबून %{record} अस्तित्वात असल्याने रेकॉर्ड हटवू शकत नाही
+ date:
+ abbr_day_names:
+ - सोम
+ - मंगळ
+ - बुध
+ - गुरु
+ - शुक्र
+ - शनि
+ - रवि
+ abbr_month_names:
+ -
+ - जाने
+ - फेब्रु
+ - मार्च
+ - एप्रि
+ - मे
+ - जून
+ - जुलै
+ - ऑग
+ - सेप्टें
+ - ऑक्टोबर
+ - नोव्हें
+ - डिसे
+ day_names:
+ - सोमवार
+ - मंगळवार
+ - बुधवार
+ - गुरुवार
+ - शुक्रवार
+ - शनिवार
+ - रविवार
+ formats:
+ default: "%d-%m-%Y"
+ long: "%d %B %Y"
+ short: "%d %b"
+ month_names:
+ -
+ - जानेवारी
+ - फेब्रुवारी
+ - मार्च
+ - एप्रिल
+ - मे
+ - जून
+ - जुलै
+ - ऑगस्ट
+ - सप्टेंबर
+ - ऑक्टोबर
+ - नोव्हेंबर
+ - डिसेंबर
+ order:
+ - :day
+ - :month
+ - :year
+ datetime:
+ distance_in_words:
+ about_x_hours:
+ one: सुमारे एक तास
+ other: सुमारे %{count} तास
+ about_x_months:
+ one: सुमारे %{count} महीना
+ other: सुमारे %{count} महिना
+ about_x_years:
+ one: सुमारे %{count} वर्ष
+ other: सुमारे %{count} वर्ष
+ almost_x_years:
+ one: जवळजवळ एक वर्ष
+ other: जवळजवळ %{count} वर्ष
+ half_a_minute: अर्धा मिनिट
+ less_than_x_seconds:
+ one: एक सेकंद पेक्षा कमी
+ other: "%{count} सेकंद पेक्षा कमी"
+ less_than_x_minutes:
+ one: एका मिनिटापेक्षा कमी
+ other: "%{count} मिनिटापेक्षा कमी"
+ over_x_years:
+ one: एका वर्षापेक्षा जास्त काळ
+ other: "%{count} वर्षापेक्षा जास्त काळ"
+ x_seconds:
+ one: एक सेकंद
+ other: "%{count} सेकंद"
+ x_minutes:
+ one: एक मिनिट
+ other: "%{count} मिनिट"
+ x_days:
+ one: एक दिवस
+ other: "%{count} दिवस"
+ x_months:
+ one: एक महिना
+ other: "%{count} महिना"
+ prompts:
+ second: सेकंद
+ minute: मिनिट
+ hour: तास
+ day: दिवस
+ month: महिना
+ year: वर्ष
+ errors:
+ format: "%{attribute} %{message}"
+ messages:
+ accepted: मान्य केले पाहिजे
+ blank: रिक्त ठेवता येणार नाही
+ confirmation: "%{attribute} जुळत नाही"
+ empty: रिक्त असू शकत नाही
+ equal_to: "%{count} समान असणे आवश्यक"
+ even: समांक असणे आवश्यक आहे
+ exclusion: राखीव आहे
+ greater_than: "%{count} पेक्षा जास्त असणे आवश्यक आहे"
+ greater_than_or_equal_to: "%{count} पेक्षा मोठे किंवा समान असणे आवश्यक आहे"
+ inclusion: यादीत समाविष्ट नाही
+ invalid: अवैध आहे
+ less_than: "%{count} पेक्षा कमी असणे आवश्यक"
+ less_than_or_equal_to: "%{count} पेक्षा कमी किंवा समान असणे आवश्यक आहे"
+ not_a_number: क्रमांक नाही
+ not_an_integer: पूर्णांक असणे आवश्यक आहे
+ odd: विषम संख्या असणे आवश्यक आहे
+ other_than: "%{count} पेक्षा इतर असणे आवश्यक आहे"
+ present: रिक्त असणे आवश्यक आहे
+ taken: यापूर्वीच घेतले गेले आहे
+ too_long:
+ one: खूप लांब आहे (जास्तीत जास्त एक वर्ण परवानगी आहे)
+ other: खूप लांब आहे (जास्तीत जास्त %{count} वर्ण परवानगी आहे)
+ too_short:
+ one: खूप लहान आहे (किमान एक वर्ण परवानगी आहे)
+ other: खूप लहान आहे (किमान %{count} वर्ण परवानगी आहे)
+ wrong_length:
+ one: लांबी चुक आहे (एक वर्ण असणे आवश्यक आहे)
+ other: लांबी चुक आहे (%{count} वर्ण असणे आवश्यक आहे)
+ template:
+ body: 'खालील फील्ड सह समस्या होते:'
+ header:
+ one: एक चूक ह्या %{model} ला जतन करण्यापासून प्रतिबंधित करत आहे
+ other: "%{count} चुका ह्या %{model} ला जतन करण्यापासून प्रतिबंधित करत आहे"
+ helpers:
+ select:
+ prompt: कृपया निवडा
+ submit:
+ create: "%{model} निर्माण करा"
+ submit: "%{model} जतन करा"
+ update: "%{model} अद्यतनित करा"
+ number:
+ currency:
+ format:
+ delimiter: ","
+ format: "%u%n"
+ precision: 2
+ separator: "."
+ significant: false
+ strip_insignificant_zeros: false
+ unit: "₹"
+ format:
+ delimiter: ","
+ precision: 3
+ separator: "."
+ significant: false
+ strip_insignificant_zeros: false
+ human:
+ decimal_units:
+ format: "%n %u"
+ units:
+ billion: अब्ज
+ million: दशलक्ष
+ quadrillion: एकावर १५ शून्य इतकी संख्या
+ thousand: हजार
+ trillion: एकावर १२ शून्ये इतकी संख्या
+ unit: ''
+ format:
+ delimiter: ''
+ precision: 3
+ significant: true
+ strip_insignificant_zeros: true
+ storage_units:
+ format: "%n %u"
+ units:
+ byte:
+ one: Byte
+ other: Bytes
+ gb: GB
+ kb: KB
+ mb: MB
+ tb: TB
+ percentage:
+ format:
+ delimiter: ''
+ precision:
+ format:
+ delimiter: ''
+ support:
+ array:
+ last_word_connector: ", आणि "
+ two_words_connector: " आणि "
+ words_connector: ", "
+ time:
+ am: म.पू.
+ formats:
+ default: "%a, %d %b %Y %H:%M:%S %z"
+ long: "%d %B %Y %H:%M"
+ short: "%d %b %H:%M"
+ pm: म.नं.
\ No newline at end of file
diff --git a/config/locales/rails-i18n/pluralization/fil.rb b/config/locales/rails-i18n/pluralization/fil.rb
new file mode 100755
index 0000000..0229a24
--- /dev/null
+++ b/config/locales/rails-i18n/pluralization/fil.rb
@@ -0,0 +1,3 @@
+require "rails_i18n/common_pluralizations/one_with_zero_other"
+
+::RailsI18n::Pluralization::OneWithZeroOther.with_locale(:fil)
diff --git a/config/locales/rails-i18n/pluralization/hr.rb b/config/locales/rails-i18n/pluralization/hr.rb
new file mode 100755
index 0000000..75eea29
--- /dev/null
+++ b/config/locales/rails-i18n/pluralization/hr.rb
@@ -0,0 +1,41 @@
+# Croatian has categories "one", "few", and "other", according to the CLDR
+# plural rules used by Phrase. However, the rails-i18n implementation also
+# requires "many".
+#
+# Note that Croatian has rules for fraction digits, but like rails-i18n
+# we will only handle integers for now.
+#
+# https://unicode-org.github.io/cldr-staging/charts/latest/supplemental/language_plural_rules.html#hr
+
+module RailsI18n
+ module Pluralization
+ module Croatian
+ def self.rule
+ lambda do |n|
+ n ||= 0
+ mod10 = n % 10
+ mod100 = n % 100
+
+ if mod10 == 1 && mod100 != 11
+ :one
+ elsif [2, 3, 4].include?(mod10) && ![12, 13, 14].include?(mod100)
+ :few
+ else
+ :other
+ end
+ end
+ end
+ end
+ end
+end
+
+{
+ hr: {
+ i18n: {
+ plural: {
+ keys: [:one, :few, :other],
+ rule: RailsI18n::Pluralization::Croatian.rule
+ }
+ }
+ }
+}
diff --git a/config/locales/rails-i18n/pluralization/mr.rb b/config/locales/rails-i18n/pluralization/mr.rb
new file mode 100755
index 0000000..17fb400
--- /dev/null
+++ b/config/locales/rails-i18n/pluralization/mr.rb
@@ -0,0 +1,3 @@
+require "rails_i18n/common_pluralizations/one_with_zero_other"
+
+::RailsI18n::Pluralization::OneWithZeroOther.with_locale(:mr)
diff --git a/config/locales/validators/en.yml b/config/locales/validators/en.yml
new file mode 100755
index 0000000..1bb5e43
--- /dev/null
+++ b/config/locales/validators/en.yml
@@ -0,0 +1,8 @@
+---
+en:
+ validators:
+ email:
+ blacklist: has been blocked at the owner's request. That means it can't be used in guest comments. Please check the address to make sure it's yours to use and contact AO3 Support if you have any questions.
+ format:
+ allow_blank: should look like an email address. Please use a different address or leave blank.
+ no_blank: should look like an email address.
diff --git a/config/locales/views/en.yml b/config/locales/views/en.yml
new file mode 100755
index 0000000..10e56be
--- /dev/null
+++ b/config/locales/views/en.yml
@@ -0,0 +1,2565 @@
+---
+en:
+ a11y:
+ navigation: Navigation
+ abuse_reports:
+ new:
+ do_not_spam:
+ delay: it may take a while.
+ paragraph_html: "Our volunteer team is small, so it may take a while."
+ split: Please only report one user at a time, and do not submit multiple reports about the same user.
+ form:
+ comment:
+ content_policy: Content Policy
+ description_html: Explain how the content you are reporting violates the %{content_policy_link} or other parts of the %{tos_link}. Please be as specific as possible and %{include_link}. All information provided will remain confidential.
+ error: Please describe what you are reporting and why you are reporting it.
+ include: include all relevant links and other information in your report
+ label: Description of the content you are reporting (required)
+ tos: Terms of Service
+ email:
+ description: We cannot contact you if the email address you provide is invalid.
+ label: Your email (required)
+ landmark:
+ send: Send to Abuse Team
+ language:
+ label: Select language (required)
+ legend:
+ abuse: Link and Describe Abuse
+ link:
+ description: Please ensure this link leads to the page you intend to report. Enter only one URL here and include any other links in the description field below.
+ error: Please enter the link to the page you are reporting.
+ label: Link to the page you are reporting (required)
+ name:
+ label: Your name or username (optional)
+ submit:
+ active: Submit
+ summary:
+ description: Please specify why you are contacting us and/or what part of the Terms of Service is relevant to your complaint. (For example, "harassment", "not a fanwork", "commercial activities", etc.)
+ error: Please enter a subject line for your report.
+ label: Brief summary of Terms of Service violation (required)
+ heading:
+ page_title: Policy Questions & Abuse Reports
+ include:
+ evidence_html: links to any relevant sources or screenshots
+ intro: 'What to include in your report description:'
+ other_content: links to any of their other content that you would like to report
+ quote: a quote from or summary of the content that violates the Terms of Service
+ reported_username: username of the person you are reporting
+ sources: sources
+ username_html: .
+ languages:
+ delay: There will be an additional delay for responses in any language other than English.
+ intro_html: We can answer Abuse reports in %{list_html}.
+ page_content_landmark: Main Text
+ purview:
+ about_html: Please use this form for questions or reports about violations of the Terms of Service. For more information, please refer to the FAQ section 2.
+ contact_support_html: If you need technical support, want to report a work tagged with an incorrect language, or something else, please %{support_link}.
+ dmca:
+ abbreviated: DMCA
+ full: Digital Millennium Copyright Act
+ dmca_takedown_html: If you would like to file a DMCA request, please email me at femslashclub@proton.me.
+ fnok: Fannish Next-of-Kin
+ legal: review our DMCA Policy and contact our Legal team
+ support: contact our Support team
+ tos: Sunset Terms of Service
+ tos_faq: Terms of Service FAQ
+ reportable:
+ allowed: You would like to know whether specific content is allowed on Sunset.
+ content_policy: Content Policy
+ email: an email explaining why
+ hack: You believe that your Sunset account was hacked.
+ harassment: You have experienced or witnessed harassment taking place on Sunset.
+ intro_html: 'Potential reasons to submit a report to the Policy and Abuse team:'
+ pac: Policy & Abuse
+ suspended_html: You have been suspended or your work has been removed, and you have not received an email..
+ tos: Terms of Service
+ violation_html: You have encountered content on Sunset that violates the %{content_policy_link} or other parts of the %{tos_link}.
+ admin:
+ activities:
+ index:
+ activities_table:
+ action: Action
+ admin: Admin
+ caption: Admin Activity
+ date: Date
+ summary: List of admin activity with links to full summaries.
+ target: Target
+ page_heading: Admin Activities
+ show:
+ action: Action
+ admin: Admin
+ date: Date
+ landmark:
+ details: Action Details
+ navigation:
+ index: Admin Activities
+ page_heading: Admin Activity
+ summary: Summary
+ target: Target
+ admin_invitations:
+ find:
+ email: All or part of an email address
+ invitations_for_html: Invitations for %{user_invitations_link}
+ page_heading: Track invitations
+ search: Search
+ token: Invite token
+ username: Username
+ index:
+ find:
+ email: All or part of an email address
+ heading: Track invitations
+ invite_token: Invite token
+ landmark_submit: Submit
+ search: Search
+ username: Username
+ grant_invites:
+ all: All
+ generate_invitations: Generate invitations
+ heading: Give invite codes to current users
+ landmark_submit: Submit
+ number_of_invitations: Number of invitations
+ users: Users
+ with_no_unused: With no unused invitations
+ invite_from_queue:
+ heading_html: Send invite codes to people in our %{invitations_queue_link}
+ invitations_queue: Invitations queue
+ invite_from_queue: Invite from queue
+ number_to_invite: Number of people to invite
+ requests_in_queue:
+ one: There is %{count} request in the queue.
+ other: There are %{count} requests in the queue.
+ navigation:
+ queue: Manage Queue
+ requests: Manage Requests
+ page_heading: Invite New Users
+ send_to_email:
+ description: Email address
+ heading: Send to Email
+ invite_by_email_title: Invite by email
+ invite_user: Invite user
+ admin_nav:
+ ao3_news: Sunset News
+ archive_faq: Archive FAQ
+ faq:
+ reorder_questions: Reorder Questions
+ known_issues: Known Issues
+ landmark: Admin Navigation
+ news:
+ delete_post: Delete Post
+ post_ao3_news: Post Sunset News
+ wrangling_guidelines: Wrangling Guidelines
+ admin_options:
+ delete:
+ bookmark: Delete Bookmark
+ confirmation: Are you sure you want to delete this?
+ external_work: Delete External Work
+ series: Delete Series
+ work: Delete Work
+ edit:
+ external_work: Edit External Work
+ edit_tags: Edit Tags and Language
+ hide:
+ bookmark: Hide Bookmark
+ external_work: Hide External Work
+ series: Hide Series
+ work: Hide Work
+ landmark: Admin Actions
+ not_spam: Mark Not Spam
+ remove_pseud: Remove Pseud
+ remove_pseud_confirmation: Are you sure you want to remove the creator's pseud from this work?
+ spam: Mark As Spam
+ unhide:
+ bookmark: Make Bookmark Visible
+ external_work: Make External Work Visible
+ series: Make Series Visible
+ work: Make Work Visible
+ admin_users:
+ confirm_delete_user_creations:
+ caution_html: Are you sure you want to delete all the works and comments created by this user, along with their %{bookmarks} bookmarks, %{series} series, and %{collections} collections? This cannot be undone.
+ confirm: Are you sure? Remember this will destroy ALL these objects!
+ page_heading: Delete Spammer Creations
+ submit: Yes, Delete All Spammer Creations
+ creations:
+ navigation:
+ admin_user: User Administration
+ creations: Creations
+ no_creations: This user has no works or comments.
+ page_heading: Works and Comments by %{user}
+ page_title: "%{login} - User Creations"
+ history:
+ heading: User History
+ table:
+ caption: History of User Actions
+ creation:
+ action: Account Created
+ details: System Generated
+ head:
+ action: Action
+ date: Date/Time
+ details: Details
+ sign_in:
+ current:
+ action: Current Login
+ no_details: No login recorded
+ details: 'IP Address: %{ip}'
+ last:
+ action: Previous Login
+ no_details: No previous login recorded
+ summary: Shows what notable actions, such as creating an account, a user performed, and when they did them.
+ index:
+ role: Role
+ settings:
+ label: Search settings
+ past: Include past usernames and emails
+ show:
+ fnok:
+ form:
+ email: Fannish next of kin's email
+ name: Fannish next of kin's username
+ submit: Update Fannish Next of Kin
+ heading: Fannish Next of Kin
+ info:
+ email: 'Email:'
+ heading: User Information
+ invitation: 'Invitation:'
+ no_invitation: Created without invitation
+ no_role: No roles
+ past_emails:
+ one: 'Past email:'
+ other: 'Past emails:'
+ past_logins:
+ one: 'Past username:'
+ other: 'Past usernames:'
+ role:
+ one: 'Role:'
+ other: 'Roles:'
+ navigation:
+ activate: Activate
+ creations: Creations
+ invitations:
+ add: Add Invitations
+ manage: Manage Invitations
+ rename: Rename
+ roles: Manage Roles
+ troubleshoot: Troubleshoot
+ note: To fix common errors with this user's Subscriptions and Stats pages, and to reindex their works and bookmarks, choose "Troubleshoot."
+ page_heading: 'User: %{login} (%{id})'
+ page_title: "%{login} - User Administration"
+ status:
+ form:
+ admin_action:
+ ban: Suspend permanently (ban user)
+ legend: Warnings and Suspensions
+ note: Record note
+ spamban: 'Spammer: ban and delete all creations'
+ suspend: 'Suspend: enter a whole number of days'
+ unban: Lift permanent suspension, effective immediately.
+ unsuspend: Lift temporary suspension, effective immediately.
+ warn: Record warning
+ notes:
+ legend: Notes
+ required: Required when adding or removing a warning or suspension to an account.
+ submit: Update
+ heading: Record Warnings, Suspensions, or Notes
+ api:
+ edit:
+ page_title: Edit API Token
+ index:
+ actions:
+ find: Find
+ page_heading: API Tokens
+ page_title: API Tokens
+ search_box:
+ label: Name
+ search_by_name: Search for an API token by name
+ table:
+ actions:
+ edit: Edit
+ caption: API Tokens
+ headings:
+ actions: Actions
+ banned: Banned?
+ created: Created
+ name: Name
+ token: Token
+ updated: Updated
+ summary: Existing API tokens along with the dates they were created and updated and options for editing them.
+ new:
+ page_title: New API Token
+ banners:
+ index:
+ actions:
+ active: Active
+ confirm_delete: Are you sure you want to delete this banner?
+ delete: Delete
+ edit: Edit
+ page_heading: Banners
+ navigation:
+ confirm_delete: Are you sure you want to delete this banner?
+ delete: Delete Banner
+ edit: Edit Banner
+ index: Banners
+ new: New Banner
+ blacklist:
+ emails_found:
+ one: one email found
+ other: "%{count} emails found"
+ blacklisted_emails:
+ create:
+ browser_title: Create Banned Email
+ index:
+ browser_title: Banned Emails
+ confirm_remove: Are you sure you want to remove %{email} from the banned emails list?
+ form:
+ new:
+ label: Email to ban
+ submit: Ban Email
+ search:
+ submit: Search Banned Emails
+ heading:
+ new: Ban email address
+ search: Find banned emails
+ notes:
+ canonical_format: All emails are stored in a single canonical format and common variants of the same address are not allowed (for instance, foo+whatever@example.com will not be allowed if foo@example.com is banned).
+ guest_comments: Banned email addresses cannot be used in guest comments and cannot be added to the invitation queue.
+ unaffected_users: They will not affect users with that address or prevent invitations sent to that email address from being used.
+ page_heading: Manage Banned Emails
+ header:
+ nav:
+ activities: Activities
+ api_tokens: API Tokens
+ banned_emails: Banned Emails
+ banners: Banners
+ invitations:
+ invitations: Invitations
+ new: Invite New Users
+ queue: Manage Queue
+ requests: Manage Requests
+ label: Admin
+ locales: Locales
+ posts:
+ admin_posts: Admin Posts
+ faqs: Archive FAQ
+ known_issues: Known Issues
+ news: Sunset News
+ post_news: Post Sunset News
+ wrangling_guidelines: Wrangling Guidelines
+ settings: Settings
+ skins:
+ approved: Approved Skins
+ queue: Approval Queue
+ rejected: Rejected Skins
+ skins: Skins
+ spam: Spam
+ users:
+ email_search: Bulk Email Search
+ manage: Manage Users
+ search: Find Users
+ wrangling: Tag Wrangling
+ passwords:
+ edit:
+ describedby:
+ password_length: "%{minimum} to %{maximum} characters"
+ label:
+ confirmation: Confirm new password
+ password: New password
+ landmark:
+ submit: Submit
+ page_heading: Set My Admin Password
+ submit: Set Admin Password
+ new:
+ instructions: If you've forgotten or would like to change your admin password, we can send instructions that will allow you to reset it. Please tell us the username for your admin account.
+ page_heading: Forgotten your admin password?
+ reset_login_html: Admin username
+ submit: Reset Admin Password
+ sessions:
+ new:
+ label:
+ login: Admin username
+ password: Admin password
+ landmark:
+ reset: Reset password
+ page_heading: Log In as Admin
+ reset_link: Forgot admin password?
+ submit: Log In as Admin
+ settings:
+ index:
+ fields:
+ account_age_threshold_for_comment_spam_check: How old (in days) accounts should be before we stop checking comments for spam
+ account_age_threshold_for_comment_spam_check_note: Enter 0 to turn off comment spam checks for all logged in users.
+ account_creation_enabled: Account creation enabled
+ cache_expiration: How often (in minutes) should we refresh caching
+ creation_requires_invite: Account creation requires invitation
+ days_to_purge_unactivated: How many weeks you have to activate your account before we purge it
+ disable_support_form: Turn off support form
+ disabled_support_form_text: Disabled support form text
+ downloads_enabled: Allow downloads
+ enable_test_caching: Turn on caching (currently experimental)
+ guest_comments_off: Turn off guest comments across the site
+ hide_spam: Automatically hide spam works
+ invite_from_queue_enabled: Invite from queue enabled (People can add themselves to the queue and invitations are sent out automatically)
+ invite_from_queue_frequency: How often (in hours) should we invite people from the queue
+ invite_from_queue_number: Number of people to invite from the queue at once
+ request_invite_enabled: Users can request invitations
+ suspend_filter_counts: Suspend some filter tracking due to high posting volume
+ tag_wrangling_off: Turn off tag wrangling for non-admins
+ heading: Archive Settings
+ last_updated_by_admin: Settings last updated on %{updated_at} by %{admin_name}.
+ legend:
+ account_and_invitations: Accounts and Invitations
+ actions: Actions
+ content: Content
+ disable_support_form: Turn Off Support Form
+ performance_and_misc: Performance and Misc
+ queue_status:
+ one: "%{count} person is scheduled to be sent an invitation at %{time}."
+ other: "%{count} people are scheduled to be sent invitations at %{time}."
+ queue_status_help: "(The automatic task that invites people from the queue runs at most every hour. Don't be alarmed if the time you see is within the last hour. Do be alarmed if the time you see is more than one hour in the past and the queue is supposed to be active.)"
+ update: Update
+ update:
+ success: Archive settings were successfully updated.
+ user_creations:
+ confirm_remove_pseud:
+ caution: Are you sure you want to remove the creator's pseud from this work?
+ choose: Please choose which creators' pseuds you would like to remove from this work.
+ page_heading: Remove Pseud
+ submit_multiple: Remove Pseuds
+ submit_one: Yes, Remove Pseud
+ admin_posts:
+ admin_post_form:
+ comment_permissions:
+ label: Who can comment on this news post
+ language:
+ label: Choose a language
+ tags:
+ label: Tags
+ translated_post:
+ footnote_comment_permissions: Comment permissions from the selected post will replace any permissions selected on this page.
+ footnote_tags: Tags from the selected post will replace any tags entered on this page.
+ label: Translation of
+ show:
+ navigation:
+ admin:
+ confirm_delete: Are you sure you want to delete this news post?
+ delete: Delete Post
+ edit: Edit Post
+ landmark: Admin Actions
+ unreviewed_comments:
+ one: Unreviewed Comments (%{count})
+ other: Unreviewed Comments (%{count})
+ landmark: News Post Navigation
+ next: Next Post
+ previous: Previous Post
+ page_heading: Sunset News
+ admins:
+ index:
+ confidentiality_reminder: You are now logged in as an admin. That means you will probably encounter information that is personal or confidential (e.g. usernames, email and IP addresses, creator names on anonymous works, etc). Please do not use this information in ways unrelated to your OTW role. If you have questions about what you can or cannot do with information you see here, contact your committee chair(s).
+ log_out_reminder: Please remember to log out before resuming your normal site activity!
+ page_title: Hi, %{login}!
+ responsibility: With great power comes great responsibility.
+ roles:
+ heading: 'Your admin roles:'
+ none: You currently have no admin roles assigned to you.
+ archive_faqs:
+ admin_index:
+ confirm_delete: Are you sure you want to delete this FAQ category?
+ created_date: Created at %{date_created}
+ delete: Delete
+ edit: Edit
+ manage_faqs: Manage Archive FAQs
+ new_faq_category: New FAQ Category
+ page_heading: Archive FAQ
+ reorder_faqs: Reorder FAQs
+ show: Show
+ show:
+ edit: Edit
+ elasticsearch_news: news post announcing the search and filter updates
+ elasticsearch_update_notice_html: Our search engine has recently been updated, and this FAQ is based on our old version. We're working on bringing you more up-to-date information, but in the meantime, you can find out more in our %{elasticsearch_news_link}!
+ no_category_entries: We're sorry, there are currently no entries in this category.
+ page_heading: Archive FAQ
+ screencast: 'Screencast:'
+ blocked:
+ block: Block
+ unblock: Unblock
+ users:
+ confirm_block:
+ block: block
+ button: Yes, Block User
+ cancel: Cancel
+ mute_users_instead_html: To hide a user's works, bookmarks, series, and comments from you, visit %{muted_users_link}.
+ muted_users_link_text: your Muted Users page
+ sure_html: Are you sure you want to %{block} %{username}?
+ title: Block %{name}
+ will:
+ commenting: commenting or leaving kudos on your works
+ gifting: giving you gift works outside of challenge assignments and claimed prompts
+ intro: 'Blocking a user prevents them from:'
+ replying: replying to your comments anywhere on the site
+ will_not:
+ comments_elsewhere: hide their comments elsewhere on the site
+ comments_on_works: delete or hide comments they previously left on your works; you can delete these individually
+ hide_works: hide their works or bookmarks from you
+ intro: 'Blocking a user will not:'
+ confirm_unblock:
+ button: Yes, Unblock User
+ cancel: Cancel
+ resume:
+ commenting: commenting or leaving kudos on your works
+ gifting: giving you gift works outside of challenge assignments and claimed prompts
+ intro: 'Unblocking a user allows them to resume:'
+ replying: replying to your comments anywhere on the site
+ sure_html: Are you sure you want to %{unblock} %{username}?
+ title: Unblock %{name}
+ unblock: unblock
+ index:
+ blocked_users: Blocked Users
+ button: Block
+ heading:
+ landmark:
+ blocked_users: Listing Blocked Users
+ label: User
+ legend: Block a user
+ mute_users_instead_html: To hide a user's works, bookmarks, series, and comments from you, visit %{muted_users_link}.
+ muted_users_link_text: your Muted Users page
+ none: You have not blocked any users.
+ title: Blocked Users
+ will:
+ commenting: commenting or leaving kudos on your works
+ gifting: giving you gift works outside of challenge assignments and claimed prompts
+ intro:
+ one: 'You can block up to %{block_limit} user. Blocking a user prevents them from:'
+ other: 'You can block up to %{block_limit} users. Blocking a user prevents them from:'
+ replying: replying to your comments anywhere on the site
+ will_not:
+ comments_elsewhere: hide their comments elsewhere on the site
+ comments_on_works: delete or hide comments they previously left on your works; you can delete these individually
+ hide_works: hide their works or bookmarks from you
+ intro: 'Blocking a user will not:'
+ bookmarks:
+ bookmark_user_module:
+ bookmarked_by_html: Bookmarked by %{pseud_link}
+ index:
+ advanced_search: try our advanced search
+ choose_fandom: choose a fandom
+ recent_bookmarks_html: These are some of the latest bookmarks created on the Archive. To find more bookmarks, %{choose_fandom_link} or %{advanced_search_link}.
+ challenge:
+ gift_exchange:
+ challenge_signups:
+ close_offers_html: Close Offers ↑
+ close_requests_html: Close Requests ↑
+ heading:
+ for_collection: Sign-ups for %{collection}
+ for_search:
+ one: Sign-up
+ other: Sign-ups
+ navigation:
+ download_csv: Download (CSV)
+ search: Search
+ search_by_pseud: Search by Pseud
+ no_sign_ups_yet: No sign-ups yet!
+ offers_html: Offers ↓
+ requests_html: Requests ↓
+ challenge_assignments:
+ to_default:
+ button: Default
+ confirmation: Are you sure? This will mark you as having defaulted and notify the collection maintainer! It cannot be undone.
+ to_fulfill:
+ button: Fulfill
+ challenge_signups:
+ signup_form:
+ notice:
+ preference:
+ gift_exchange: Signing up for this challenge will allow assigned users to gift works to you regardless of your preference settings. If you wish to receive additional gifts from users who are not assigned to you, please %{preferences_link} to allow gifts from anyone. You can always %{refuse_link}.
+ preferences_link_text: update your preferences
+ prompt_meme: Signing up for this challenge will allow any user who claims your prompt to gift you a work in response to your prompt regardless of your preference settings. If you wish to receive additional gifts from users who have not claimed your prompts, please %{preferences_link} to allow gifts from anyone. You can always %{refuse_link}.
+ refuse_link_text: refuse a gift
+ chapters:
+ chapter_form:
+ content_policy: Content Policy
+ post_notice_html: All works you post on Sunset must comply with our %{content_policy_link}. For more information, please refer to our %{tos_faq_link}.
+ tos_faq: Terms of Service FAQ
+ preview:
+ content_policy: Content Policy
+ post_notice_html: All works you post on Sunset must comply with our %{content_policy_link}. For more information, please refer to our %{tos_faq_link}.
+ tos_faq: Terms of Service FAQ
+ collection_items:
+ collection_item_form:
+ add: Add
+ add_bookmark_header: Add Bookmark to collections
+ add_work_header_html: Add %{title} to collections
+ invite: Invite
+ invite_header_html: Invite %{title} to collections
+ index:
+ collection:
+ notice:
+ unreviewed_by_user: Works and bookmarks listed here have been invited to this collection. Once a work's creator has approved inclusion in this collection, the work will be moved to "Approved."
+ page_heading_html: Items in %{collection_link}
+ navigation:
+ approved: Approved
+ rejected_by_collection: Rejected by Collection
+ rejected_by_user: Rejected by User
+ unreviewed_by_collection: Awaiting Collection Approval
+ unreviewed_by_user: Awaiting User Approval
+ no_items: Nothing to review here!
+ user:
+ notice:
+ unreviewed_by_collection: Works and bookmarks listed here have been added to a collection but need approval from a collection moderator before they are listed in the collection.
+ page_heading: Items by %{username} in Collections
+ collection_profile:
+ show:
+ meta:
+ collection_tags: 'Collection tags:'
+ collections:
+ collection_blurb:
+ collection_tags: 'Collection Tags:'
+ form:
+ icon:
+ delete: Delete collection icon and revert to our default. This will also remove the icon alt text and comment text.
+ multifandom:
+ footnote: Use this if your collection is not fandom-specific.
+ label: Multifandom
+ index:
+ page_heading:
+ challenges_subcollections_in: Challenges/Subcollections in %{collection}
+ collections_in_the: Collections in the %{archive}
+ collections_including: Collections including %{work}
+ users_collections: "%{user}'s Collections"
+ sidebar:
+ bookmarks: Bookmarked Items (%{count})
+ challenge_settings: Challenge Settings
+ collection_settings: Collection Settings
+ dashboard: Dashboard
+ fandoms: Fandoms (%{count})
+ faq: FAQ
+ landmark:
+ choices: Choices
+ contents: Contents
+ dashboard: Dashboard
+ manage_items: Manage Items
+ parent_collection: Parent Collection
+ people: People
+ profile: Profile
+ random_items: Random Items
+ rules: Rules
+ subcollections: Subcollections (%{count})
+ tags: Tags
+ works: Works (%{count})
+ comments:
+ comment_form:
+ anonymous_creator: Anonymous Creator
+ anonymous_forewarning: While this work is anonymous, comments you post will also be listed anonymously.
+ cancel_action: Cancel
+ choose_name_field_title: Choose Name
+ comment_as: Comment as
+ comment_field_title: Enter Comment
+ comment_too_long: must be less than %{count} characters long.
+ comment_too_short: Brevity is the soul of wit, but we need your comment to have text in it.
+ guest_email: Guest email
+ guest_email_failure: Please enter your email address.
+ guest_instructions: All fields are required. Your email address will not be published.
+ guest_name: Guest name
+ guest_name_failure: Please enter your name.
+ inbox_reference_html: to %{commentable_creator} on %{commentable_link}
+ landmark:
+ comment: Comment
+ note: Note
+ legend: Post Comment
+ processing_message: Please wait...
+ commentable:
+ actions:
+ comment: Comment
+ add_to_collections_link: Add to Collections
+ blocked: Sorry, you have been blocked by one or more of this work's creators.
+ guest_comments_disabled: Sorry, the Archive doesn't allow guests to comment right now.
+ invite_to_collections_link: Invite To Collections
+ logged_as_admin: Please log out of your admin account to comment.
+ permissions:
+ admin_post:
+ alt_action: You can however %{support_link} with any feedback or questions.
+ disable_all: Sorry, this news post doesn't allow comments.
+ disable_anon: Sorry, this news post doesn't allow non-Archive users to comment.
+ support_link: contact Support
+ moderated_commenting:
+ disable: Disable comment moderation
+ enable: Enable comment moderation
+ keep_current: Keep current comment moderation settings
+ label: Comment moderation
+ notice:
+ admin_post: Comments on this news post are moderated. Your comment will not appear until it has been approved.
+ work: This work's creator has chosen to moderate comments on the work. Your comment will not appear until it has been approved by the creator.
+ options:
+ disable_all: No one can comment
+ disable_anon: Only registered users can comment
+ enable_all: Registered users and guests can comment
+ keep_current: Keep current comment settings
+ label: Who can comment on this work
+ multiple_works_label: Who can comment on these works
+ work:
+ alt_action: You can however still leave Kudos!
+ disable_all: Sorry, this work doesn't allow comments.
+ disable_anon: Sorry, this work doesn't allow non-Archive users to comment.
+ hidden: Sorry, you can't add or edit comments on a hidden work.
+ unrevealed: Sorry, you can't add or edit comments on an unrevealed work.
+ edit:
+ back: Back
+ page_heading: Editing comment
+ show: Show
+ update: Update
+ show:
+ comment_on_html: Comment on %{commentable_link}
+ single_comment:
+ on_chapter_html: on %{commentable_link}
+ unreviewed:
+ approve_all: Approve All Unreviewed Comments
+ no_unreviewed: No unreviewed comments.
+ note:
+ admin_post: Please note that comments cannot be unapproved once you have approved them. After you delete any comments you do not wish to appear on the news post, you can approve all that remain.
+ work: Please note that comments cannot be unapproved once you have reviewed them. After you delete any comments you do not wish to appear on your work, you can approve all that remain.
+ page_heading_html: Unreviewed Comments on %{commentable_link}
+ downloads:
+ download_afterword:
+ afterword: Afterword
+ comment: drop by the Archive and comment
+ end_notes: End Notes
+ inspired_by:
+ restricted_html: "[Restricted Work] by %{creator_link}"
+ revealed_html: "%{work_link} by %{creator_link}"
+ title: Works inspired by this one
+ unrevealed: A work in an unrevealed collection
+ please_comment_html: Please %{comment_link} to let the creator know if you enjoyed their work!
+ download_chapter:
+ chapter_byline_html: Chapter by %{byline_names_link}
+ chapter_end_notes_with_chapter_notes: more notes
+ chapter_end_notes_without_chapter_notes: notes
+ chapter_notes: Chapter Notes
+ chapter_summary: Chapter Summary
+ section_chapter_end_notes: Chapter End Notes
+ see_chapter_end_notes_html: See the end of the chapter for %{chapter_end_notes_link}
+ download_preface:
+ byline_html: by %{byline_names_link}
+ chapters: 'Chapters: %{chapter_total_display}'
+ collections: 'Collections:'
+ completed: 'Completed: %{date}'
+ end_notes_with_work_notes: more notes
+ end_notes_without_work_notes: notes
+ inspired_by:
+ restricted_html: Inspired by [Restricted Work] by %{creator_link}
+ revealed_html: Inspired by %{work_link} by %{creator_link}
+ unrevealed: Inspired by a work in an unrevealed collection
+ language: 'Language:'
+ notes: Notes
+ originally_posted_html: Posted originally on the %{archive_link} at %{work_url}.
+ preface: Preface
+ published: 'Published: %{date}'
+ see_end_notes_html: See the end of the work for %{end_notes_link}
+ series: 'Series:'
+ series_list_html: Part %{position} of %{series_link}
+ stats: 'Stats:'
+ summary: Summary
+ tag_type: "%{tag_type}:"
+ translated_to:
+ restricted_html: 'Translation into %{language} available: [Restricted Work] by %{creator_link}'
+ revealed_html: 'Translation into %{language} available: %{work_link} by %{creator_link}'
+ unrevealed_html: 'Translation into %{language} available: A work in an unrevealed collection'
+ translation_of:
+ restricted_html: A translation of [Restricted Work] by %{creator_link}
+ revealed_html: A translation of %{work_link} by %{creator_link}
+ unrevealed: A translation of a work in an unrevealed collection
+ updated: 'Updated: %{date}'
+ words: 'Words: %{count_with_delimiters}'
+ errors:
+ timeout_error:
+ contact_support: contact Support
+ html: If you still get this error after a few tries, you can %{contact_support_link}.
+ page_heading: Timeout Error
+ subtitle: The page was responding too slowly. Please try again after a few minutes.
+ external_works:
+ notice: This work isn't hosted on the Archive so this blurb might not be complete or accurate.
+ fandoms:
+ index:
+ page_heading: Fandoms
+ feedbacks:
+ new:
+ abuse:
+ contact: contact our Policy and Abuse team
+ reports: For reports of violations of the Terms of Service such as harassment, spam, or plagiarism, or if you believe your account has been hacked, please %{contact_link} instead. We cannot act on these reports or provide information about Policy and Abuse cases.
+ do_not_spam_html: "We respond to every report we receive, but we are a small volunteer team. For this reason, we ask you to not submit multiple reports regarding any one issue, or encourage other people to report the same issue, unless there is additional information to offer."
+ form:
+ comment:
+ description: Please be as specific as possible, including error messages and/or links
+ error: Please enter your feedback
+ label: Your question or problem (required)
+ email:
+ label: Your email (required)
+ language:
+ label: Select language (required)
+ legend:
+ contact_info: Contact Information
+ feedback: Describe Your Feedback
+ send: Send Your Feedback
+ name:
+ label: Your name (optional)
+ submit:
+ active: Send
+ disabled: Please wait...
+ summary:
+ error: Please enter a brief summary of your message
+ label: Brief summary of your question or problem (required)
+ heading:
+ instructions: Please use this form for questions about how to use the Archive and for reporting any technical problems.
+ landmark:
+ reference: Reference Links
+ page_title: Support and Feedback
+ languages_html: "We can answer Support inquiries in %{list}. Please allow for additional delay for responses in any language other than English."
+ navigation:
+ faqs: FAQs & Tutorials
+ known_issues: Known Issues
+ release_notes: Release Notes
+ reportable:
+ account_creation: Problems setting up your account
+ bugs: Bugs, errors, or unexpected site behavior
+ fnok:
+ concerning_html: Setting up, changing, or activating your %{fnok_link}
+ fnok: Fannish Next of Kin
+ intro: 'Some issues you can contact Support about include:'
+ lost_access: Lost password or email preventing access to your account
+ new_features: Feature requests for future development
+ orphaned_works: Questions about orphaned works
+ policy_questions: General site policy questions
+ site_questions: Questions about how to use the site
+ tag_changes: Requests to canonize or change tags
+ work_problems: Works labeled with the wrong language or duplicate works
+ status:
+ current: For current updates on Archive performance or downtime, please check the %{twitter_link} or %{tumblr_link}.
+ tumblr: femslashclub Tumblr
+ twitter: "@AO3_Status Twitter feed"
+ home:
+ about:
+ ao3:
+ github_repository: GitHub repository
+ html: The Sunset offers a noncommercial and nonprofit central hosting place for fanworks using open-source archiving software.
+ jira_project: Jira project
+ details: I love hosting shit and I basically just deployed this to see if I could do it. This is, disclaimer, NOT intended to be the same kind of site as AO3. It is a small personal archive with invitations moderated tightly. Please follow my rules and have a good time!
+ general: This is a site I made and host on an HP Envy in my bedroom. One singular person runs this website so be nice to me thanks.
+ heading:
+ html: About the %{otw}
+ otw: Sunset
+ otw_long: Sunset
+ major_projects:
+ fanlore: Link I don't know how to get rid of
+ fanlore_details_html: "%{fanlore_link} I don't know how to get rid of this"
+ legal_advocacy: Link I don't know how to get rid of
+ legal_details_html: "%{legal_advocacy_link} I don't know how to get rid of this."
+ open_doors: Link I don't know how to get rid of
+ open_doors_details_html: "%{open_doors_link}, I don't know how to get rid of this."
+ title: 'Thank you hehe'
+ twc: Link I don't know how to get rid of
+ twc_details_html: "%{twc_link}, I don't know how to get rid of this I'll figure it out later"
+ more_info:
+ communications_team: Communications team
+ faq_page: FAQ page
+ html: You can learn more about me and my other projects at alien.town.
+ transformative_works: transformativeworks.org
+ page_title: About the Sunset
+ content:
+ cc_attribution_4_0_international: Creative Commons Attribution 4.0 International License
+ commercial_promotion:
+ heading: II.C. Commercial Promotion
+ not_allowed: Promotion, solicitation, and advertisement of commercial products or activities are not allowed.
+ tos_faq: FAQ for Section II.C
+ tos_faq_in_parens_html: "(%{tos_faq_link})"
+ tos_faq_link_label: Commercial Promotion FAQ
+ content_policy: Content Policy and Rules
+ content_policy_heading: II. Content Policy
+ copyright_infringement:
+ epigraphs_small_quotations_allowed: Epigraphs and short quotations are allowed, as is Content that is set within or based on an existing work.
+ heading: II.D. Copyright Infringement
+ not_allowed: Reproductions of large excerpts of copyrighted works are not allowed without the consent of the copyright owner. This includes stories, artwork, songs, poems, transcripts, and other copyrighted material. Crediting the original creator does not give you the right to upload, podfic, or translate someone else's work without permission, regardless of whether the source is a fanwork or a professionally published work.
+ tos_faq: FAQ for Section II.D
+ tos_faq_in_parens_html: "(%{tos_faq_link})"
+ tos_faq_link_label: Copyright Infringement and Plagiarism FAQ
+ transformative_fanworks: transformative fanworks
+ transformative_works_legal_html: We believe that %{transformative_fanworks_link} are legal. Complaints about the mere existence of fanworks that mention trademarks or are based on copyrighted material will not be pursued.
+ effective: 'Effective: November 19, 2024'
+ fanworks:
+ bookmarks: Bookmarks
+ bookmarks_only_fanworks_html: "%{bookmarks_link} must only be created for fanworks. You may create %{external_bookmarks_link} for fanworks hosted on third-party sites."
+ external_bookmarks: external bookmarks
+ heading: II.B. Fanworks
+ must_be_fanworks_html: Works must be fanworks. Posting a Work that primarily consists of %{non_fanwork_content_link} is not allowed.
+ non_fanwork_content: non-fanwork Content
+ tos_faq: FAQ for Section II.B
+ tos_faq_in_parens_html: "(%{tos_faq_link})"
+ tos_faq_link_label: Fanworks and Non-Fanwork Content FAQ
+ harassment:
+ advocating_harm:
+ heading: Advocating Harm
+ text: Content that advocates specific, real, harmful actions towards real people is not allowed. This includes, but is not limited to, directing death threats or slurs at people outside of fiction, as well as encouraging others to harass or harm specific people or groups.
+ applies_to_all: it applies to all aspects of the Service
+ blocking: blocking
+ definition: Harassment is any behavior that produces a generally hostile environment for its target. Examples include bullying, threats, and personal attacks by or towards individuals or groups of people.
+ filtering: filtering
+ heading: II.H. Harassment
+ muting: muting
+ not_allowed_and_context: Harassment is not allowed. When judging whether a specific incident or item of Content constitutes harassment and/or when determining the appropriate severity of a penalty, the Policy & Abuse committee will consider relevant context. This includes whether the behavior was repeated, targeted, difficult to avoid encountering, or related to a general pattern of harassment by an individual or a group, among other factors. Additionally, submitting repeated and/or baseless complaints, particularly those targeting a specific user or group, may be considered harassment.
+ otw:
+ abbreviated: OTW
+ full: Organization for Transformative Works
+ policy_applicability_html: The Harassment Policy is primarily focused on user conduct and non-fictional Content. However, %{applies_to_all_link}, including interactions with the Policy & Abuse committee, the Support committee, and other AO3 or %{otw_abbreviation} volunteers.
+ rpf:
+ heading: Real-Person Fiction (RPF)
+ text: Creating RPF never constitutes harassment in and of itself. Posting works where someone dies, is subjected to slurs, or is otherwise harmed as part of the plot is usually not a violation of the Harassment Policy. However, deliberately posting such Content in a manner designed to be seen by the subject of the work, such as by gifting them the work, may result in a judgment of harassment.
+ threatening_versus_annoying_html: In general, threatening Content will be considered harassment, while Content that is merely rude or annoying will be allowed. Not everyone agrees about what is offensive and unacceptable. Users are encouraged to use tools such as %{blocking_link}, %{muting_link}, and %{filtering_link} to control their own environment on AO3.
+ tos_faq: FAQ for Section II.H
+ tos_faq_in_parens_html: "(%{tos_faq_link})"
+ tos_faq_link_label: Harassment FAQ
+ illegal_inappropriate_content:
+ automated_spam_check_html: We may use automated means to filter out spam. If you submit Content that is erroneously caught in a spam filter, please %{contact_ao3_administrators_link}.
+ conduct_threatening_technical_integrity_html: Conduct that threatens the %{technical_integrity_link} of AO3, for example attempting to hack AO3 or spread viruses through it, is prohibited. Uploading technically misnamed files or Content (such as non-image files with an image file extension used to disguise their actual format) constitutes a threat to the technical integrity of the site.
+ contact_ao3_administrators: contact AO3 administrators
+ heading: II.K. Illegal and Inappropriate Content
+ images_of_real_children: photographic or photorealistic images of real children
+ no_illegal_content_html: You may not upload Content that appears or purports to contain, link to, or provide instructions for obtaining sexually explicit or suggestive %{images_of_real_children_link}; malware, warez, cracks, hacks, or other executable files and their associated utilities; or trade secrets, restricted technologies, or other classified information.
+ report_it_to_us: email me at symphonicstarship at gmail.com to report it.
+ spamming_behavior: Spamming behavior is prohibited. Repeated identical or nearly identical posts in multiple places may be considered spam regardless of commercial content.
+ technical_integrity: technical integrity
+ tos_faq: FAQ for Section II.K
+ tos_faq_in_parens_html: "(%{tos_faq_link})"
+ tos_faq_link_label: Offensive Content vs Illegal Content FAQ
+ violates_us_law_html: If you encounter Content that you believe violates a specific law of the United States, you can %{report_it_to_us_link}.
+ impersonation:
+ function: function
+ heading: II.G. Impersonation
+ html: You may not impersonate or misrepresent your affiliation with any person, entity, or %{function_link}. Fiction clearly marked as such is not considered impersonation.
+ tos_faq: FAQ for Section II.G
+ tos_faq_in_parens_html: "(%{tos_faq_link})"
+ tos_faq_link_label: Fannish Identities and Impersonation FAQ
+ intro:
+ all_content_must_comply_html: All %{content_link} on Sunset must comply with our Content Policy.
+ archive_description: The Sunset exists to host transformative, non-commercial works created by fans from all over the world.
+ content: content
+ maximum_inclusiveness: maximum inclusiveness of fanwork content
+ our_goal_html: hi here are my site rules. wip
+ report_it_to_us: email me at symphonicstarship at gmail.com to report it.
+ review_before_posting_html: "%{all_content_must_comply_bold} Please review this page carefully before you begin posting. Answers to common questions about the Content Policy can be found in the %{tos_faq_link}."
+ tos_faq: TOS FAQ
+ we_do_not_prescreen: We do not prescreen content on Sunset.
+ you_can_report_html: If you encounter content that you believe violates our Content Policy, you can email me at symphonicstarship at gmail.com. %{we_do_not_prescreen_bold}
+ license_html: The Sunset %{terms_of_service_link}, including the %{content_policy_link} and %{privacy_policy_link}, are licensed under the %{cc_attribution_4_0_international_link}.
+ mandatory_tags:
+ any_archive_warning: any Archive Warning
+ ao3_may_designate_html: Sunset software may designate some tag fields (e.g. Rating, Archive Warnings, Fandom, or Language) as mandatory in order to post, import, or edit a Work using the appropriate forms. Tags entered into mandatory fields must meet the %{minimum_criteria_link} described in the TOS FAQ.
+ applying_nonspecific_tag_html: Applying a non-specific tag (such as "Not Rated", "Creator Chose Not To Use Archive Warnings", or "Unspecified Fandom") is always considered sufficient tagging for that field. A Work that is labeled with a non-specific Rating may contain Content of the highest rating. A Work that is labeled with a non-specific Archive Warning may contain Content pertaining to %{any_archive_warning_link}.
+ archive_warning: Archive Warning
+ choose_no_warnings_html: A creator may choose not to apply specific %{rating_link} and/or %{archive_warning_link} tags to their Work, but they must signal such choices by applying Sunset's %{non_specific_tags_link} indicating that they have opted out of choosing a specific Rating and/or Archive Warning.
+ heading: II.J. Mandatory Tags
+ minimum_criteria: minimum criteria
+ non_specific_tags: non-specific tag(s)
+ not_available: not available
+ rating: Rating
+ tags_applied_automatically_html: Some tags may be automatically applied to Works. In addition, Sunset administrators may determine that tags in a mandatory tag field on a Work are inaccurate or insufficient. In such cases, Sunset administrators may remove inaccurate tags; add non-specific tags to the Work; add specific tags to the Work in fields where non-specific tags are %{not_available_link}; require the creator to appropriately adjust the Work's tags; hide the Work; or take other appropriate action to address the matter. Refer to the %{tos_faq_link} for details about when Sunset administrators may enforce the presence or removal of tags in mandatory fields.
+ tos_faq: TOS FAQ
+ tos_faq_endnote: FAQ for Section II.J
+ tos_faq_in_parens_html: "(%{tos_faq_link})"
+ tos_faq_link_label: Ratings and Archive Warnings FAQ
+ offensive_content:
+ heading: II.A. Offensive Content
+ removal_not_just_offensiveness: Unless it violates some other policy, we will not remove Content for offensiveness.
+ tos_faq: FAQ for Section II.A
+ tos_faq_in_parens_html: "(%{tos_faq_link})"
+ tos_faq_link_label: Offensive Content vs Illegal Content FAQ
+ page_content_landmark: Main Text
+ page_heading: Content Policy
+ personal_information:
+ disclosing_personal_data_html: disclosing personal data, including %{special_categories_of_personal_data_link}.
+ heading: II.F. Personal Information and Fannish Identities
+ linking_fannish_identity: linking someone's fannish identity (such as their Sunset username) to their legal or professional identity (such as their "real life" name);
+ not_allowed: 'You may not upload Content that contains seemingly accurate, non-public information about another individual without authorization. This includes:'
+ orphaned: Orphaned
+ revealing_orphaned_creator_html: revealing the identity of the creator of an %{orphaned_link} fanwork;
+ right_to_hide_delete: We reserve the right to delete, hide, or otherwise make such Content unavailable.
+ rpf_exception: As Real-Person Fiction (RPF) is fictional, Content in RPF that would normally be deemed personal data (e.g. full names, usernames on social media services, city of residence, or birth date) will not ordinarily be considered as such.
+ sharing_sufficient_information: sharing information sufficient to identify someone else's legal or professional identity or their physical location (e.g. phone numbers, email addresses, residential addresses, or hotel room numbers); and
+ special_categories_of_personal_data: Special Categories of Personal Data
+ tos_faq: FAQ for Section II.F
+ tos_faq_in_parens_html: "(%{tos_faq_link})"
+ tos_faq_link_label: Fannish Identities and Impersonation FAQ
+ plagiarism:
+ heading: II.E. Plagiarism
+ html: Plagiarism is the use of someone else's words, or %{their_expressions_of_their_ideas_link}, without attribution. Minor alterations (such as replacing names, substituting synonyms, or rearranging a few words) are insufficient to make a work your own. Plagiarism is not allowed. Deliberately creating a work using the same general idea as another work is not plagiarism, but citation may be appropriate.
+ their_expressions_of_their_ideas: their expressions of their ideas
+ tos_faq: FAQ for Section II.E
+ tos_faq_in_parens_html: "(%{tos_faq_link})"
+ tos_faq_link_label: Copyright Infringement and Plagiarism FAQ
+ privacy_policy: Privacy Policy
+ terms_of_service: Terms of Service
+ toc:
+ header: Table of Contents for the Terms of Service (TOS)
+ intro: The Content Policy is the second part of the AO3 Terms of Service.
+ user_icons:
+ heading: II.I. User Icons
+ text: User icons must be appropriate for general audiences. They must not depict genital nudity, explicit sexual activity, hate symbols, or imagery that promotes, advocates, or causes harm.
+ tos_faq: FAQ for Section II.I
+ tos_faq_in_parens_html: "(%{tos_faq_link})"
+ tos_faq_link_label: Usernames, Icons, and Profiles FAQ
+ diversity_statement:
+ archive_description_html: This archive is a permanent, panfandom place for fanworks, built by fans for fans. Whichever way you use the Archive, you're part of this, powering it, shaping it through your use and %{your_feedback_link}.
+ archive_for_you: 'No matter your appearance, circumstances, configuration or take on the world: if you enjoy consuming, creating or commenting on fanworks, the Archive is for you.'
+ archive_team: the Archive team
+ diversity_statement: Diversity Statement
+ dreamwidth: Dreamwidth's
+ dreamwidth_remix_html: This is a remix of %{dreamwidth_link} %{diversity_statement_link}.
+ few_restrictions: few restrictions
+ license:
+ creative_commons_by_sa: Creative Commons Attribution-Share Alike 3.0 Unported License
+ html: This work is licensed under a %{creative_commons_by_sa_link}.
+ image_alt: Creative Commons License
+ some_essential_parts: some essential parts
+ still_missing_html: 'We know that there are %{some_essential_parts_link} that are still missing to make the Archive truly panfandom: the ability to host fanworks other than text, an interface in languages other than English, and more ways for you to connect with each other, to name just a few. But with your support, we''ll get there.'
+ terms_of_service: Terms of Service
+ we_build_for: We are building this archive for you. Come be a part of it.
+ welcome_header: You are welcome at the Archive of Our Own.
+ what_we_do_html: We, %{archive_team_link}, know that we won't get everything right on the first try, and we won't be able to make everyone equally happy. But we strive to find a good balance, and we promise to respectfully consider your feedback and to take it seriously.
+ why_we_build: We are building this archive because we believe it's possible for people of all opinions and persuasions to come together and share with each other.
+ you_can_html: You are free to express your creativity within the %{few_restrictions_link} needed to keep the service viable for other users. The Archive strives to protect your rights to free expression and privacy; you can read about the details in our %{terms_of_service_link}.
+ your_feedback: your feedback
+ dmca:
+ appeal:
+ cannot: cannot
+ copyright: Copyright infringement
+ dispute_html: you %{cannot_italic} reupload or repost the content unless you have filed a DMCA counternotice to dispute the takedown
+ heading: I was notified that my work was removed from AO3 due to a DMCA takedown notice. What can I do?
+ lumen: Lumen
+ notify: remove the content and notify you by email
+ plagiarism: plagiarism
+ removal_html: If the OTW receives a valid DMCA takedown notice for content you uploaded to AO3, we will %{notify_link}. We may also send a redacted version of the DMCA takedown notice to %{lumen_link} and other third parties at our discretion.
+ reupload_html: If your content was removed due to a DMCA takedown notice, %{dispute_bold}. If you reupload or repost the content without filing a DMCA counternotice, we may %{suspend_link}.
+ suspend: suspend your account
+ tos: Terms of Service
+ tos_faq: Terms of Service FAQ
+ violations_html: "%{copyright_link} and %{plagiarism_link} are violations of Sections II.D and II.E of our %{tos_link}, respectively. For more information, please refer to our %{tos_faq_link}."
+ counternotice_instructions:
+ address_attn: 'Attention: Legal'
+ address_org: Organization for Transformative Works
+ address_state: New York, NY 10003-1502
+ address_street: '228 Park Ave S #18156'
+ consider_html: If you are considering whether to file a DMCA counternotice, we recommend that you contact an intellectual property lawyer licensed to practice in your jurisdiction, so that you are aware of your legal rights and obligations. You can also review the %{lumen_faq_link}.
+ contact_legal: the Legal committee's contact form
+ defend: you are willing to defend your use of the original copyrighted material in court
+ dispute_html: If you believe that your content does not infringe on the copyright owner's rights, you can file a DMCA counternotice to dispute the takedown. By filing a counternotice, you are indicating that %{defend_bold} if the party who submitted the original takedown notice chooses to pursue legal action against you.
+ heading: Filing a DMCA counternotice
+ initiate_html: 'To initiate a formal DMCA counter-notification request, contact the OTW''s designated agent using %{contact_legal_link}, via email to legal@transformativeworks.org, or by mailing a letter to:'
+ lumen_faq: Lumen DMCA Explanation and FAQ
+ prefer: We prefer to receive DMCA requests through the contact form or by email.
+ time_limit: You can file a DMCA counternotice at any point after your content has been removed; there is no time limit.
+ counternotice_process:
+ heading: DMCA counter-notification process
+ lumen: Lumen
+ notification_html: The original submitter will have 10 business days to prevent the restoration of your content by filing a lawsuit against you. If the original submitter does not file a lawsuit within 10 business days, the OTW will restore your content (or, if your content has already been deleted, allow you to reupload it) between 10 and 14 business days after we receive the counternotice. We may also send a redacted version of the DMCA counternotice to %{lumen_link} and other third parties at our discretion.
+ valid: If the OTW receives a valid DMCA counternotice, we will make reasonable efforts to notify the party who submitted the original takedown notice.
+ counternotice_requirements:
+ consent: 'a statement that you consent to the jurisdiction of a U.S. federal court in either:'
+ contact: your name, address, and phone number;
+ heading: Requirements of a DMCA counternotice
+ intro: 'The counternotice must substantially comply with the requirements of 17 U.S.C. § 512(g)(3). You are also required to consent to the jurisdiction of a United States court. The legal requirements of a valid DMCA counternotice include:'
+ liability: Per 17 U.S.C. § 512(f), you may be subject to liability if you knowingly and materially misrepresent your claim that the content was removed due to a mistake or misidentification.
+ mistake_html: a statement, %{perjury_bold}, that the content was removed due to a mistake or misidentification;
+ new_york: Manhattan, New York
+ non_us_resident_html: the district where the OTW is located (%{new_york_link}), if you are not in the U.S.; and
+ perjury: made under penalty of perjury
+ service: a statement that you will accept service of process from the party who submitted the original takedown notice.
+ signature: your physical or electronic signature;
+ url: the URL(s) of the content that was removed;
+ usa_resident: the district where you live, if you are in the U.S.; or
+ intro:
+ contact_legal: contact the OTW Legal committee
+ legality_html: The OTW will respond promptly to notices of alleged copyright infringement that substantially comply with all %{requirements_link}. However, we believe that %{transformative_works_legal_link}, and we reserve the right to review the allegedly infringing content and independently determine whether it is infringing. If you have any questions about our DMCA Policy, please %{contact_legal_link}.
+ purpose_html: The Digital Millennium Copyright Act (DMCA) establishes the procedure the Organization for Transformative Works (OTW) must follow when users of our sites, including the Archive of Our Own (AO3), %{reproduce_unauthorized_link}. Specifically, %{uscode_link} protects the OTW from potential liability when infringing content is present on our sites.
+ reproduce_unauthorized: reproduce copyrighted material without authorization
+ requirements: legal requirements
+ transformative_works_legal: transformative fanworks are legal
+ uscode: 17 U.S.C. § 512
+ license:
+ cc_license: Creative Commons Attribution-ShareAlike 4.0 International License
+ credit_html: AO3's DMCA policy is licensed under the %{cc_license_link}. Material in this policy has been drawn from Dreamwidth, the Electronic Frontier Foundation, and Wikipedia.
+ page_heading: DMCA Policy
+ removal:
+ abuse_report_html: "%{submit_abuse_bold} Abuse reports are evaluated by the Policy & Abuse committee for violations of the %{tos_link}, and held to a very high %{confidentiality_link}. When submitting an Abuse report about copyright infringement, you must provide a link to the violating content on AO3 and clearly identify the original source in your report description. For more information, please refer to the %{tos_faq_link}."
+ confidentiality: standard of confidentiality
+ dmca_html: "%{file_dmca_bold} DMCA takedown notices can only be filed by the copyright owner or someone legally authorized to act on their behalf. DMCA notices must be sent to the OTW's designated agent and comply with all %{requirements_link}. DMCA notices are %{not_confidential_link}. The OTW reserves the right to make public all DMCA notices that we receive, though some information may be redacted for privacy."
+ do_not: do not
+ do_not_spam_html: Please %{do_not_bold} submit an Abuse report if you intend to file a DMCA notice. Submitting both an Abuse report and a DMCA notice about the same content may delay the processing of both requests.
+ file_dmca: File a DMCA takedown notice
+ file_dmca_html: "%{file_dmca_link}:"
+ heading: My work was posted on AO3 without my permission. What can I do?
+ intro: 'If you encounter content on AO3 that you believe infringes on your copyright, you have the following options:'
+ not_confidential: not subject to confidentiality
+ requirements: legal requirements
+ submit_abuse: Submit an Abuse report
+ submit_abuse_html: "%{submit_abuse_link}:"
+ tos: Sunset Terms of Service
+ tos_faq: Terms of Service FAQ
+ repeat:
+ heading: Repeat offenses
+ required_html: As required by 17 U.S.C. § 512(i)(1)(A), we may %{suspend_link} users who repeatedly violate others' copyright. The OTW has the discretion to decide what qualifies as a repeated offense.
+ suspend: suspend
+ takedown_instructions:
+ address_attn: 'Attention: Legal'
+ address_org: Organization for Transformative Works
+ address_state: New York, NY 10003-1502
+ address_street: '228 Park Ave S #18156'
+ contact_legal: the Legal committee's contact form
+ heading: Filing a DMCA takedown notice
+ initiate_html: 'To initiate a formal DMCA takedown request, contact the OTW''s designated agent using %{contact_legal_link}, via email to legal@transformativeworks.org, or by mailing a letter to:'
+ prefer: We prefer to receive DMCA requests through the contact form or by email.
+ takedown_process:
+ counternotify: If the OTW receives a valid DMCA counternotice, we will make reasonable efforts to notify you. You will have 10 business days to file a lawsuit to prevent the restoration of the content. If you do not notify us within 10 business days that you have filed a lawsuit, we will allow the content to be restored in accordance with 17 U.S.C. § 512(g)(2)(B)-(C).
+ dispute: If the account owner believes that their content does not infringe upon your copyright, they may file a DMCA counternotice disputing the takedown. The account owner can file a DMCA counternotice at any point after the content has been removed; there is no time limit.
+ heading: DMCA takedown process
+ lumen: Lumen
+ notification_html: When the content is removed from AO3, the information you provided in the DMCA takedown notice will be forwarded to the owner of the account responsible for posting the content. We may also send a redacted version of the DMCA takedown notice to %{lumen_link} and other third parties at our discretion.
+ requirements: legal requirements
+ valid_html: If the OTW receives a DMCA takedown notice that fulfills all %{requirements_link}, we will remove the content once we review the validity of the infringement claim.
+ takedown_requirements:
+ accurate: a statement that the information you have provided is accurate.
+ authorized_html: a statement, %{perjury_bold}, that you are authorized to act on behalf of the copyright owner; and
+ contact: your contact information;
+ good_faith: a statement that you have a good-faith belief that the use of the content is not authorized by the copyright owner(s), their agent, or the law;
+ heading: Requirements of a DMCA takedown notice
+ intro: 'The takedown notice must substantially comply with the requirements of 17 U.S.C. § 512(c)(3)(A). You are also required to consent to the jurisdiction of a United States court. The legal requirements of a valid DMCA takedown notice include:'
+ liability: Per 17 U.S.C. § 512(f), you may be subject to liability if you knowingly and materially misrepresent your claim that the content infringes on your copyright.
+ perjury: made under penalty of perjury
+ signature: your physical or electronic signature;
+ source: identification of the copyrighted work being infringed upon (for example, a URL or publication listing);
+ url: the URL(s) of the specific content on our site that you believe to be infringing;
+ toc:
+ appeal: I was notified that my work was removed from AO3 due to a DMCA takedown notice. What can I do?
+ counternotice_instructions: Filing a DMCA counternotice
+ counternotice_process: DMCA counter-notification process
+ counternotice_requirements: Requirements of a DMCA counternotice
+ heading: Table of Contents
+ penalties: Repeat offenses
+ removal: My work was posted on AO3 without my permission. What can I do?
+ takedown_instructions: Filing a DMCA takedown notice
+ takedown_process: DMCA takedown process
+ takedown_requirements: Requirements of a DMCA takedown notice
+ donate:
+ general:
+ text: You can support Sunset financially.
+ title: Donations
+ money:
+ details_html: Sunset has ongoing running costs - electricity for the servers, mail servers, - and one-off costs. Any donation is a big help. You can donate at ko-fi.com/AgnestheAlien. (Don't worry, we'll never connect your Sunset username and your financial information.)
+ donation: donation to the OSunset
+ title: Donating Financially
+ page_title: Donate
+ time:
+ contribute_html: We also welcome community contributions to %{github_repository_link} for open tasks in our %{jira_project_link}. We also encourage you to browse through our %{volunteer_listings_link}, sign up to receive all our %{news_by_email_link} which includes our calls for volunteers, and apply for any volunteer roles that match your qualifications and interests.
+ github_repository: our GitHub repository
+ info_html: The %{otw_link} is the parent organization of the Archive of Our Own (AO3). We are often looking for volunteers to contribute to %{projects_link}. If you are interested in volunteering specifically to help the Archive of Our Own, the committees to look out for include Accessibility, Design, and Technology (AD&T); AO3 Documentation; Policy & Abuse; Support; Tag Wrangling; and Translation.
+ jira_project: Jira project
+ news_by_email: news by email
+ otw: Organization for Transformative Works
+ projects: our projects
+ title: Donating Your Time
+ volunteer_listings: volunteer position listings
+ fandoms:
+ all_fandoms: All Fandoms
+ first_login_help:
+ additional_info:
+ header: Additional Browsing Information
+ history_mark_later:
+ header: History and Mark for Later
+ history: History
+ history_faq: History and Mark for Later FAQ
+ html: You can access and manage your history by going to %{your_dashboard_link} and selecting the "%{history_link}" link in the side menu in the default browser skin or on top of the page in mobile devices. You can clear your history, or delete individual entries if you don't want these entries to show up in your history. You can also add works to the "Marked for Later" list in your history. For more information on History, please visit the %{history_faq_link}.
+ your_dashboard: your Dashboard
+ subscriptions:
+ header: Subscriptions
+ html: You can subscribe to a work, collection, series of works, or user by selecting the "Subscribe" link at the top or bottom of a work, or at the top or bottom of a collection or series page, or the user's profile page. You can check your subscriptions from %{your_dashboard_link} by selecting the "Subscriptions" link. For more information on Subscriptions, please visit the %{subscriptions_feed_faq_link}.
+ subscriptions_feed_faq: Subscriptions and Feeds FAQ
+ your_dashboard: your Dashboard
+ bookmarking_works:
+ bookmarks_faq: Bookmarks FAQ
+ header: Bookmarking Works
+ html: You can bookmark works on the Archive as well as works hosted on other sites. You can choose to make your bookmarks public or private. You can also mark a public bookmark as a rec (recommendation). Additionally, you can add notes and tags to the bookmark, and/or add it to a collection. For more instructions on managing your Bookmarks, please visit the %{bookmarks_faq_link}.
+ browsing:
+ all_fandoms: All Fandoms
+ fandoms: Fandoms
+ header: Browsing
+ html: You can start browsing works by going to the "%{fandoms_link}" tab at the top of any Archive page in the default skin and selecting either "%{all_fandoms_link}" or one of the subsets such as "%{movies_link}". Alternatively, you can use the %{search_feature_link} to look for specific fandoms, works, or users. You can use the "Sort and Filter" form to narrow down the results in any fandom page. For more instructions, please visit %{search_browse_faq_link} and the %{search_browse_tutorial_link}.
+ movies: Movies
+ search_browse_faq: Search and Browse FAQ
+ search_browse_tutorial: Searching and browsing tutorial
+ search_feature: search feature
+ editing_profile:
+ edit_my_profile: Edit My Profile
+ header: Editing Your Profile, Password, and Preferences
+ html: To add your info, go to %{your_dashboard_link}, select the "%{profile_link}" tab, and select "%{edit_my_profile_link}" from the range of options at the end of the page. Here, you can enter some basic personal information. It's also where to go to change your password. Refer to the %{profile_faq_link} for more information.
+ profile: Profile
+ profile_faq: Profile FAQ
+ your_dashboard: your Dashboard
+ logging_in_out:
+ forgot_password: forgot your password
+ header: Logging In and Logging Out
+ html: To log in, locate the login link and fill in your Username and Password. The link will be located at the top right of the screen and as indicated by your screenreader if you're using one. If you forget your password, then select "%{forgot_password_link}". To log out, select "Log Out" on the top right corner in the default browser skin.
+ posting_works:
+ header: Posting Works
+ html: To open the Post New Work page, just select the %{post_new_link} link from the menu of the Post tab located at the top right of the page in the default browser skin. Visit the %{posting_editing_faq_link} or check out our %{tutorial_link} for more information.
+ post_new: Post New
+ posting_editing_faq: Posting and Editing FAQ
+ tutorial: 'Tutorial: Posting a Work on AO3'
+ preferences:
+ edit_my_profile: Edit My Profile
+ header: Preferences
+ html: '"%{set_my_preferences_link}" is located next to the "%{edit_my_profile_link}" button, and allows you to adjust certain settings to personalize your experience on the site. The Preferences area controls both the Archive''s behavior (such as whether your history is saved) and the Archive''s appearance. Go to the %{preferences_faq_link} for more details.'
+ preferences_faq: Preferences FAQ
+ set_my_preferences: Set My Preferences
+ skins_detail_html: For more information on how to customize the skins and interface of the Archive, refer to the %{skins_faq_link} and %{tutorials_list_link}.
+ skins_faq: Skins and Archive Interface FAQ
+ tutorials_list: List of Tutorials
+ pseuds:
+ header: Pseuds
+ html: Pseuds are like pen names linked to your account. You can use different pseuds to post your works under the appropriate name while still managing them through the same account. You can %{manage_your_pseuds_link} through your profile page. For more information, please visit the %{pseuds_faq_link}.
+ manage_your_pseuds: manage your pseuds
+ pseuds_faq: Pseuds FAQ
+ support_and_feedback:
+ archive_faq: Archive FAQ
+ contact_support: contact Support
+ header: Support and Feedback
+ html: Some frequently asked questions about the Archive are answered in the broader %{archive_faq_link}. You may also like to check out our %{known_issues_link}. If you need more help, please %{contact_support_link}. If you want to know more about some user-created tools that work with the Archive, please visit the %{unofficial_tools_faq_link}.
+ known_issues: Known Issues
+ unofficial_tools_faq: Unofficial Browser Tools FAQ
+ table_of_contents: Table of Contents
+ tags:
+ header: Tags
+ html: All tags on the Archive—including those for fandoms, relationships, and characters—start out user-created. You can always use the existing tags by selecting from the autocomplete list. If you cannot find the tags you want to use, feel free to create new tags for your works. Behind the scenes, our tag wrangling team will match the tags up with their synonyms, so that people can find your work whether you tag it "AMTDI", "Aliens Made Them Do It", or "Sex Pollen". Refer to the %{tags_faq_link} to learn more.
+ tags_faq: Tags FAQ
+ tips_to_start: Here are some tips to help you get started.
+ tos:
+ additional_questions_html: If you have additional questions that are not covered here, please %{contact_abuse_link}.
+ contact_abuse: contact our Policy & Abuse team
+ content_policy: Content Policy
+ header: Terms of Service
+ info_html: You can learn about our policies and procedures by reviewing the %{tos_link}, including the %{content_policy_link} and %{privacy_policy_link}. Answers to common questions are available in the %{tos_faq_link}.
+ privacy_policy: Privacy Policy
+ tos: Terms of Service
+ tos_faq: Terms of Service FAQ
+ warnings:
+ archive_specific_warnings: Archive-specific warnings
+ description_html: 'The Archive defines four "primary" %{archive_specific_warnings_link}: "Graphic Depictions Of Violence", "Major Character Death", "Rape/Non-Con", and "Underage Sex". When posting a work, you have the option to explicitly select warnings for this content, deny the presence of such content ("No Archive Warnings Apply"), or choose not to apply warnings regardless of whether or not these warnings are applicable ("Creator Chose Not To Use Archive Warnings"). Remember, "No Archive Warnings Apply" only refers to the listed primary warnings.'
+ header: Warnings
+ symbols_html: When browsing the Archive, warnings will be displayed in the blurb of each work. Official warning tags are displayed in bold. A four square grid in the top left corner of each work's blurb indicates the work's rating, completion status, pairing category, and any Archive warnings that apply to it. Refer to the %{symbols_key_chart_link} for more information.
+ symbols_key_chart: Symbols Key Chart
+ welcome_header: Welcome to the %{app_name}!
+ index:
+ browse_or_favorite:
+ one: Browse fandoms by media or favorite up to %{count} tag to have it listed here!
+ other: Browse fandoms by media or favorite up to %{count} tags to have them listed here!
+ find_your_favorites: Find your favorites
+ media_navigation_label: Media
+ readings:
+ heading:
+ history_link: My History
+ title: Is it later already?
+ note: Some works you've marked for later.
+ social:
+ heading: Follow us
+ note_html: Follow the Archive on Twitter or Tumblr for status updates, and don't forget to check out the %{other_outlets_link} for updates on our other projects!
+ other_outlets: Organization for Transformative Works' news outlets
+ tumblr: femslashclub on Tumblr
+ twitter: "@AO3_Status on Twitter"
+ privacy:
+ account_termination:
+ backup_copies_html: '"Active records" do not include the backup copies of Content created for legal and/or disaster recovery purposes (refer to the %{general_principles_link}).'
+ deletion_after_termination_html: If for any reason you %{terminate_your_account_link}, as soon as reasonably possible we will destroy active records containing your Personal Information that are visible to the public or to Sunset users, with the exception of Personal Information entered as Content that you have not deleted. "Reasonably" here means no more than thirty business days from the termination of the account.
+ general_principles: General Principles
+ heading: III.G. Termination of account
+ legal_enforcement_retention: We will retain some Personal Information accessible to Sunset administrators for legal and TOS enforcement purposes. For example, if we terminate your service or suspend your account, we may retain enough Personal Information to prevent you from creating an account or using Sunset in the future.
+ orphan: Orphan
+ orphans_excluded_html: If you choose to %{orphan_link} your works during the account deletion process, and choose to keep your %{pseud_link} associated with the works when Orphaning them, then your pseud will remain visible on the works.
+ pseud: pseud
+ terminate_your_account: terminate your account with us
+ aggregate_anonymous_info:
+ anonymous_non_personal: Nothing in this Privacy Policy restricts our use of anonymous information or other information or data that does not qualify as "Personal Information".
+ heading: III.D. Aggregate and anonymous information
+ understand_ao3_usage: We may use Personal Information in the aggregate to understand how our users use Sunset. This is necessary for us to carry out our legitimate interests as a host of Content and a non-profit entity, including managing, maintaining, and ensuring the security of AO3 and our servers. Note that de-identified or aggregate information that is not linked with your other Personal Information, and does not otherwise identify you, will not be treated as Personal Information.
+ applicability:
+ consent_to_us_processing: By agreeing to this Privacy Policy, you consent to the processing of your Personal Information in the United States and in other jurisdictions in connection with our provision of AO3 and its related services to you.
+ global_subprocessors_html: While the OTW is a United States 501(c)(3) corporation, our users are global. Your use of AO3 may result in the transfer of Personal Information across international boundaries. For example, Personal Information collected within the European Economic Area (EEA) and Switzerland may be transferred to and processed in a country outside of the EEA and Switzerland, or by third-party %{subprocessors_link} located outside of the EEA and Switzerland, where you may have different legal rights in relation to your Personal Information.
+ heading: III.A. Applicability
+ policy_covers: This Privacy Policy covers the treatment of Personal Information submitted to AO3 or which we collect when you use our services. Except as otherwise stated in these Terms of Service, "Personal Information" (as used in this Privacy Policy) means information related to you that qualifies as "personal data" or "personal information" under the data privacy laws applicable to the Organization for Transformative Works (OTW). Changes to this Privacy Policy will only apply to the Personal Information that is processed by AO3 after the effective date of those changes. If you are concerned about how your Personal Information is used, you should review this Privacy Policy periodically.
+ subprocessors: Subprocessors
+ transfers_necessary_html: We make such transfers to the United States and the jurisdictions of our service providers with your consent or because it is necessary for the performance of a contract with you. %{consent_to_us_processing_bold}
+ cc_attribution_4_0_international: Creative Commons Attribution 4.0 International License
+ contact_us:
+ contact_pac: contact the Policy & Abuse committee
+ heading: III.I. Contact us
+ html: If you have any questions, concerns, or complaints about this Privacy Policy, or would like to submit a data request, please %{contact_pac_link}.
+ content_policy: Content Policy
+ effective: 'Effective: November 19, 2024'
+ information_scope:
+ collect_through_use: We may collect your Personal Information (such as your IP address and email address) when you request an AO3 invitation, register for an AO3 account, or otherwise access or use AO3.
+ content: Content
+ heading: III.B. Scope of Personal Information we process
+ information_in_content_html: Any Personal Information you include in your %{content_link} (including %{special_categories_link} and other types of Personal Information) may be accessible by AO3 administrators. It may also be accessible by the general public if the Content is made public, or by AO3 users if the Content is made available to AO3 users.
+ special_categories: Special Categories of Personal Data
+ intro:
+ answers_common_questions_html: Answers to common questions about the Privacy Policy can be found in the %{tos_faq_link}.
+ ao3_exists_to_host_html: 'AO3 exists to host content by and for fans from all over the world. To do so, we process certain data and information, including %{personal_information_link}. This is so that we can:'
+ archive_description: The Archive of Our Own (AO3) is a project of the Organization for Transformative Works (OTW), which is committed to fan privacy.
+ details_how_and_why_html: This Privacy Policy details how and why we collect and process this information. %{common_questions_bold}
+ enable_post_information: Enable you to post comments, kudos, bookmarks, and other information designed to be seen by other users
+ host_your_fanworks: Host or share your fanworks
+ personal_information: Personal Information
+ show_you_works: Show you works by other people
+ tos_faq: TOS FAQ
+ license_html: The AO3 %{terms_of_service_link}, including the %{content_policy_link} and %{privacy_policy_link}, are licensed under the %{cc_attribution_4_0_international_link}.
+ page_content_landmark: Main Text
+ page_heading: Privacy Policy
+ privacy_policy: Privacy Policy
+ privacy_policy_heading: III. Privacy Policy
+ retention_of_information:
+ heading: III.H. Retention of Personal Information
+ text: We will retain your Personal Information for no longer than reasonably necessary and proportionate to fulfill the purposes for which we originally collected or processed it, as set out in this Privacy Policy or as required by law. Once the purpose(s) for which the Personal Information was collected is/are no longer applicable, we will delete or otherwise dispose of that Personal Information.
+ terms_of_service: Terms of Service
+ third_parties:
+ do_not_sell_information: We will not sell, trade, or rent your Personal Information. We will not use your Personal Information to market third-party products and services to you. This means that we do not collect or use your Personal Information for targeted advertising, nor do we process Personal Information for automated decision-making purposes. If we begin to do so, that change will be reflected in this Privacy Policy, and you will have the right to restrict the use of that Personal Information accordingly.
+ heading: III.F. Information shared with third parties
+ sharing_exceptions:
+ challenge_signup:
+ challenge: Challenge
+ heading_html: 'If you sign up for a %{challenge_link}:'
+ text: We may share your email address with the maintainers of the Challenge.
+ external_processing:
+ heading: 'For external processing:'
+ html: We may use third-party services to store, process, or transmit data, or to perform other technical functions related to operating AO3. For example, we may use third-party email services. A list of third-party services is provided in our %{subprocessor_list_link}. We cannot guarantee other services' technical performance. We or the services we use may store or process your Personal Information in data centers which may be located in the United States or elsewhere.
+ subprocessor_list: Subprocessor List
+ handle_complaints:
+ dmca_notice: Digital Millennium Copyright Act (DMCA) notice
+ heading: 'To handle complaints submitted by you about TOS violations:'
+ html: If we receive a %{dmca_notice_link}, we reserve the right to disclose the information provided by the copyright owner or their legal representative to the subject of the complaint. If we receive a complaint from you about other matters, that information will be subject to the %{pac_confidentiality_policy_link}.
+ pac_confidentiality_policy: Policy & Abuse Confidentiality Policy
+ intro: 'We will not share your Personal Information with any third parties without your prior consent, except for the following cases:'
+ legal_reasons:
+ attempt_to_notify: We will attempt to notify you any time we disclose your Personal Information to a third party for legal reasons, unless legally prohibited from doing so or if, in our sole judgment, notification might hinder an ongoing investigation. In some cases, the Personal Information we have, such as an IP address, may be insufficient for us to notify you.
+ cooperating_law_enforcement: are cooperating with law enforcement authorities.
+ good_faith_comply: have a good-faith belief that such action is necessary to comply with a current judicial proceeding, court order, or legal process served on the OTW; or
+ heading: 'For legal reasons:'
+ intro: 'We may share Personal Information if we:'
+ law_enforcement_cooperation_details: We will cooperate with all investigations conducted by law enforcement authorities within the United States when legally required to do so. Cooperation with law enforcement authorities from other countries and cooperation when it is not legally required are at our sole discretion. Our discretion looks favorably on freedom and justice, and unfavorably on oppression and violence.
+ legally_compelled: are legally compelled to do so;
+ open_doors_import:
+ heading_html: 'If your work is imported via %{open_doors_link}:'
+ open_doors: Open Doors
+ text: If the email address associated with your AO3 account matches an email address that you used in connection with a non-AO3 archive that is being imported to AO3 via Open Doors, then we may share your email address with the entity that manages that imported archive.
+ third_party_tools: Third parties have developed applications, software, scripts, browser extensions, or other tools for use in connection with AO3. Such third parties may receive Personal Information from you if you use their tools to access AO3. This Privacy Policy does not cover your use of such tools.
+ toc:
+ header: Table of Contents for the Terms of Service (TOS)
+ intro: The Privacy Policy is the third part of the AO3 Terms of Service.
+ types_of_information:
+ cookies:
+ heading: 'Cookies:'
+ text: We and our Subprocessors use cookies to collect and store visitors' preferences; customize web pages' content based on visitors' preferences or other Personal Information that the visitor sends; prevent attacks on our servers; and record activity at AO3 in order to provide better service when visitors return to our site. AO3 has no access to cookies set by other sites. If you block or disable cookies, you may be unable to access or use AO3.
+ emails:
+ address_usage_html: We will use your email address internally for the purposes of managing AO3 and maintaining site integrity. We may occasionally send emails to you about AO3, your Content and account, or news that we reasonably believe to be of interest to our registered users. We will also send you your invitation to register for AO3 via email. By creating and maintaining an account on AO3, you consent to receiving such emails. We reserve the right to send you notice of complaints raised against you, or alleged violations of the TOS by you, as well as to reply to any email message you send to AO3 and/or its personnel. If you choose to participate in a %{challenge_link}, the Challenge maintainer(s) may have access to your email address for the purposes of communicating with you.
+ challenge: Challenge
+ collect_process_retain: We collect, process, and retain the email addresses of and from those who communicate with us via email, and any Content or Personal Information included in emails to us. We need this Personal Information so we can respond to you; so we can handle complaints about AO3 and any users who may have violated the TOS or other policies; and for other legal and accounting/audit reasons, including maintaining the integrity of AO3 and the Content that we host.
+ heading: 'Emails:'
+ unsubscribe: Although it is possible to unsubscribe from some emails (such as kudos updates and responses to comments), you cannot unsubscribe from receiving emails that we, in our sole discretion, deem necessary for legal purposes or to maintain the safety and integrity of your account or our services, including other OTW sites.
+ fnok:
+ heading: 'Fannish next-of-kin:'
+ text: We collect the contact information you and/or your fannish next-of-kin provide, and use it only to implement the fannish next-of-kin function.
+ heading: III.C. Types of Personal Information we collect and process
+ ip_addresses:
+ heading: 'IP addresses:'
+ text: We and our Subprocessors collect, process, and retain the IP addresses of AO3 visitors, including registered users. IP information may also be collected by our servers for logging purposes and used for limited technical assessments of AO3. We need this Personal Information so we can provide you with the Content that you are requesting; to allow you to submit Content to us; and for other legal, TOS enforcement, and accounting/audit reasons, including maintaining the integrity of AO3 and the Content that we host.
+ logs:
+ heading: 'Logs:'
+ text: We and our Subprocessors collect, process, and retain logs of server interactions. We need this Personal Information for legal, TOS enforcement, and accounting/audit reasons, including maintaining the integrity of AO3 and the Content that we host.
+ other_information:
+ heading: 'Other user-specific information:'
+ to_maintain_integrity: We and our Subprocessors collect, process, and retain Personal Information about what pages you access or visit, including your interactions with integral AO3 features such as comments, kudos, and bookmarks; referral information (i.e. data about what site you are coming to AO3 from); and whether there are errors in displaying Content to you. We need this Personal Information to maintain the integrity of AO3 and the Content that we host; to provide you with the Content that you are seeking; to prevent spam and abuse; and for other legal and accounting/audit reasons.
+ to_make_content_available_html: We and our Subprocessors collect, process, and retain Personal Information about Content that you submit to us. You consent to the collection of your Personal Information (such as your IP address) when you access AO3, including when you are not logged in. We use this Personal Information to make the Content you submit available to people who use AO3. By submitting Content to us, you are requesting that we make it available to those people and consenting to the use of your Content. For explanations of site features that use your Content and/or Personal Information and how they are used, please refer to the %{tos_faq_link}.
+ tos_faq: TOS FAQ
+ your_rights:
+ applicable_jurisdiction: applicable jurisdiction
+ heading: III.E. Your rights under applicable data privacy laws
+ other_rights: other rights regarding your Personal Information
+ potential_other_rights_html: You may have %{other_rights_link} under the laws of the jurisdiction in which you are located. We will not discriminate against you for making a request under the data privacy laws of applicable jurisdictions.
+ request_data_html: You can request that the OTW assemble the data that AO3 has about you, and provide you with a copy in electronic format. We agree to provide such data to you within a reasonable time if you are a resident or citizen of the European Union or another %{applicable_jurisdiction_link}.
+ require_user_specific_proof: We reserve the right to require user-specific proof of identity before providing Personal Information to a requester. This is necessary to protect the safety and privacy of our users and personnel.
+ site_map:
+ about:
+ ao3_news: Sunset News
+ archive_faq: Archive FAQ
+ content_policy: Content Policy
+ header: About the Sunset Archive
+ known_issues: Known Issues
+ privacy_policy: Privacy Policy
+ project_of_otw_html: The Sunset is a project of the %{otw_link}
+ terms_of_service: Terms of Service
+ tos_faq: Terms of Service FAQ
+ access_your_account:
+ drafts: Drafts
+ header: Access your account
+ my_bookmarks: My Bookmarks
+ my_collections_and_challenges: My Collections and Challenges
+ my_history: My History
+ my_home: My Home
+ my_inbox: My Inbox
+ my_series: My Series
+ my_subscriptions: My Subscriptions
+ my_works: My Works
+ post_new: Post New
+ set_my_preferences: Set My Preferences
+ change_your_account_settings:
+ delete_account_confirmation: This will permanently delete your account and cannot be undone. Are you sure?
+ delete_my_account: Delete My Account
+ edit_my_profile: Edit My Profile
+ header: Change your account settings
+ manage_my_pseuds: Manage My Pseuds
+ my_profile: My Profile
+ contact_us:
+ donations: Donations
+ header: Contact Us
+ policy_questions_and_abuse_reports: Policy Questions & Abuse Reports
+ technical_support_and_feedback: Technical Support & Feedback
+ explore:
+ additional_tags_cloud: Additional Tags Cloud
+ bookmarks: Bookmarks
+ collections_and_challenges: Collections and Challenges
+ fandoms: Fandoms
+ header: Explore
+ homepage: Homepage
+ languages: Languages
+ people: People
+ recent_works: Recent Works
+ otw:
+ abbreviated: Sunset
+ full: Sunset
+ page_heading: Site Map
+ tos:
+ abuse_policy:
+ answers_common_questions_html: Answers to common questions about the Abuse Policy can be found in the %{tos_faq_link}.
+ appeals:
+ appeal_decision: appeal a decision to the Policy & Abuse committee
+ heading: Appeals
+ html: The complainant or the subject of a complaint may %{appeal_decision_link}, in which case it will be reviewed by an additional PAC administrator. The original decision will remain in effect while an appeal is being reviewed. We will attempt to resolve appeals as speedily as possible, but we cannot guarantee any particular timeframe for a response. PAC's decisions are final.
+ content_policy: Content Policy
+ heading: I.I. Abuse Policy
+ no_prescreen_html: We do not prescreen Content for violations of the Terms of Service, including violations of the %{content_policy_link}. Complaints are investigated only when they are submitted through the appropriate channels and with the appropriate information.
+ penalties:
+ age_barred_individual: Age-Barred Individual
+ content_policy_ii_k_1: Subsection II.K.1 of the Content Policy
+ edit_post_while_suspended: A suspended user may not post or edit Content or create an account while they are suspended. A user who was suspended solely due to age may post Content or create an account once they are no longer an Age-Barred Individual.
+ heading: Penalties
+ illegal_inappropriate_content_policy: Illegal and Inappropriate Content Policy
+ non_violating_content_html: A suspended user's non-violating Content will not be automatically removed unless the user is an Age-Barred Individual or has violated the %{illegal_inappropriate_content_policy_link}. Suspended users retain the right to delete or Orphan their fanworks by contacting Sunset administrators.
+ open_doors_removal_html: ...
+ remove_resolve_lawsuit_html: We may determine that we need to remove Content to resolve a threatened or pending lawsuit or to mitigate other liability. If so, we will remove the Content. Unless said Content was submitted by an %{age_barred_individual_link} or otherwise violates the TOS, removal for such reasons will not lead to a suspension.
+ tos_faq: TOS FAQ
+ violations_warnings_suspensions_html: Violations of the TOS may result in warnings or suspensions ("penalties") for all accounts owned by the offending user. The determination of appropriate penalties, including the length of any suspensions, is subject to the discretion of the Policy & Abuse committee. PAC's discretion will be informed by the nature of the violation and the behavior of the user. For more information, please refer to the %{tos_faq_link}.
+ resolution_of_complaints:
+ add_or_edit_tags_html: AO3 administrators may also determine that tags need to be added to or edited on a Work, and may add or edit those tags. For more information, please refer to the %{mandatory_tags_policy_link}.
+ administrators_determine_content_removal_html: When AO3 administrators %{determine_removal_link}, it may be hidden from public view or removed from AO3. If the Content is a Work (defined as Content created via the New Work or Import Work forms, or imported via Open Doors) and the creator provided valid contact information, we will inform the creator as soon as possible.
+ determine_removal: determine that Content needs to be removed
+ heading: Resolution of complaints
+ illegal_and_inappropriate_content_policy: Illegal and Inappropriate Content Policy
+ immediate_removal: Non-Work Content, or any Content without valid contact information, that violates the TOS may be immediately removed.
+ mandatory_tags_policy: Mandatory Tags Policy
+ potentially_legitimate_fanwork_html: If the Content that needs to be removed is a Work that includes potentially legitimate fanwork, the default will be to hide the Work. The Policy & Abuse committee has sole discretion to determine necessary exceptions to this default rule, which may include repeated violations of the TOS, any violations of the %{illegal_and_inappropriate_content_policy_link}, or any circumstances where the OTW reasonably believes that the Content is unlawful, including when it contains a true threat.
+ voluntary_removal: When Content (whether a Work or otherwise) is hidden from public view, AO3 administrators may identify the nature of the problem with the Content and set a deadline for voluntary removal of the violating material. If the creator does not remove that material within the given deadline, AO3 administrators may permanently remove the Content. At the discretion of the Policy & Abuse committee, the creator may have the option to resubmit any non-violating material that was also removed.
+ submitting_a_complaint:
+ dmca_policy: DMCA Policy
+ heading: Submitting a complaint
+ html: Complaints may be submitted to the Policy & Abuse committee (PAC) via the %{policy_and_abuse_form_link}, except for Digital Millennium Copyright Act (DMCA) notices filed by the copyright owner or their legal representative. DMCA notices must be submitted to the Legal committee and are not governed by the Abuse Policy. Refer to the %{dmca_policy_link} for more information.
+ policy_and_abuse_form: Policy & Abuse reporting form
+ tos_faq: TOS FAQ
+ treatment_of_complaints:
+ heading: Treatment of complaints
+ html: Complaints are covered by the %{privacy_policy_link} and processed by PAC in accordance with the %{pac_confidentiality_policy_link}. Responses to complaints will be provided at the discretion of the OTW, in accordance with the Privacy Policy and other applicable terms of these TOS.
+ pac_confidentiality_policy: Policy & Abuse Confidentiality Policy
+ privacy_policy: Privacy Policy
+ age_policy:
+ addressing_violations: If AO3 administrators determine that an Age-Barred Individual has created an account or uploaded Content, they may suspend or delete the account, hide or delete the Content, or take other appropriate action to address the matter.
+ age_barred_not_permitted_html: Age-Barred Individuals are %{not_permitted_account_upload_link} to AO3. By submitting Content to AO3, you confirm that you are not an Age-Barred Individual.
+ ask_parent_to_upload: If you are a child under the age of eighteen (18) and not a resident or citizen of the European Union, you may ask your parent or legal guardian to upload your Content through their account.
+ country_disallowing_childrens_data: any country (including a European Economic Area country) which does not allow the processing of personal data from children of that age without written permission from a parent or legal guardian.
+ eu_country_html: a European Union country where the processing of %{special_categories_of_personal_data_link} from children of that age requires the consent of a parent or legal guardian, or
+ heading: I.H. Age Policy
+ individuals_under_13: individuals under the age of eighteen (18), and
+ individuals_under_16: 'individuals under the age of eighteen (18) who are not old enough, in their country of residence or citizenship, to consent to the processing of personal data. This includes individuals under the age of sixteen (16) who are residents or citizens of:'
+ intro: 'AO3 does not knowingly solicit or collect information from Age-Barred Individuals. Age-Barred Individuals are:'
+ not_permitted_account_upload: not permitted to have an account or upload Content
+ special_categories_of_personal_data: Special Categories of Personal Data
+ archive_description: The Sunset is a home for F/F, NB/F and NB/NB fanworks, including fanfiction based on books, movies, TV, comics, other media, and real-person fiction (RPF).
+ cc_attribution_4_0_international: Creative Commons Attribution 4.0 International License
+ content_policy: Content Policy
+ content_you_access:
+ content_policy: Content Policy
+ external_links_html: The OTW or users of its services may provide links to or content via sites that are owned or controlled by third parties, and may use such sites (including those identified %{here_link}) to communicate information about the OTW and its sites. If you follow links away from AO3, you should review those sites' terms and privacy policies, which may differ from AO3's. The OTW has no control over any third-party sites or their terms of use or privacy policies, and you agree that the OTW is not responsible for and does not endorse their content, terms, or availability.
+ heading: I.D. Content you access when using AO3
+ here: here
+ hosted_by_third_party: embedded from and/or hosted by third-party sites
+ no_otw_endorsement_html: You recognize that the OTW does not endorse Content on AO3 in any way, except when such material appears as an %{official_statement_link} of the OTW. No members, volunteers, administrators, officers, or directors of the OTW are acting in an official capacity when they post fanworks, commentary, or other Content of the type generally provided by site users.
+ no_prescreen: You understand that the OTW does not prescreen Content or review it for purposes of compliance with the TOS. This includes (but is not limited to) a work's text, graphics, tags, comments, or any other material. Content, including User-Embedded Content, is the sole responsibility of the submitter. You understand that using AO3 may expose you to material that is offensive, atrocious, immoral, obscene, triggering, blasphemous, bigoted, erroneous, or objectionable in other ways.
+ official_statement: official statement
+ otw_not_liable: The OTW is not liable to you for any Content to which you are exposed on or because of AO3.
+ privacy_policy: Privacy Policy
+ third_party_content_html: Some of the Content displayed on or accessible via AO3 is not hosted on AO3 or by the OTW. Such content can include text, image, audio, or video files %{hosted_by_third_party_link} ("User-Embedded Content"). If you access a page that includes User-Embedded Content, the embedded file may share data with the hosted site as if you were on or at the hosted site. Although User-Embedded Content must comply with our %{content_policy_link}, it is not otherwise governed by these Terms of Service (including the AO3 %{privacy_policy_link}), and is instead governed by the terms of use or privacy policy of the service that hosts it. We reserve the right to provide an indicator to users that User-Embedded Content is present on or visible via your Content.
+ effective: 'Effective: November 19, 2024'
+ general_principles_heading: I. General Principles
+ general_terms:
+ agreement:
+ agreement: 'Agreement:'
+ any_other_form: any other form of content
+ content_policy: Content Policy
+ html: '%{agreement} AO3 hosts and shares content created by fans, for fans. Our %{content_policy_link} and %{privacy_policy_link} are part of these Terms of Service (TOS). By submitting a work; bookmark; comment; tag; link; text, image, audio, or video file; username; fannish next-of-kin; %{personal_information_link} (such as an email address); or %{any_other_form_link} ("Content") to the Archive of Our Own service (hereinafter "Service", "AO3", or "Archive"), or by creating an account and/or accessing Content on AO3, you affirm, confirm, and state that you comply with and assent to the TOS.'
+ personal_information: Personal Information
+ privacy_policy: Privacy Policy
+ entirety_of_agreement:
+ entirety_of_agreement: 'Entirety of agreement:'
+ html: "%{entirety_of_agreement} These Terms of Service constitute the entire agreement between you and the Organization for Transformative Works (OTW) and govern your use of AO3. They replace all prior agreements between you and the OTW concerning your use of AO3. They do not govern your use of any other OTW sites and/or projects, all of which are covered under separate agreements."
+ heading: I.A. General terms
+ jurisdiction:
+ html: "%{jurisdiction} The AO3 TOS, the relationship between you and the OTW, and all disputes arising out of or related to them shall be governed by the laws of the United States and specifically %{the_state_of_new_york_link}, without regard to any conflict-of-law provisions. You and the OTW agree to submit to the personal and exclusive jurisdiction of the courts located within New York County (Manhattan), New York, and to waive any objection to the laying of venue there."
+ jurisdiction: 'Jurisdiction:'
+ the_state_of_new_york: the State of New York
+ limitation_on_claims:
+ html: "%{limitation_on_claims} You agree that, regardless of any statute or law to the contrary, any claim or cause of action arising out of or related to any use of AO3 or its TOS must be filed within one (1) year after such claim or cause of action arose or be forever barred."
+ limitation_on_claims: 'Limitation on claims:'
+ no_assignment:
+ html: "%{no_assignment} These TOS are personal to you. You may not assign or transfer your rights or obligations under these TOS to any other person or entity, and any attempted assignment or transfer is void."
+ no_assignment: 'No assignment:'
+ non_severability:
+ html: "%{non_severability} The OTW's failure to enforce any part of the TOS will not waive the OTW's ability to enforce it. Any waiver with regard to a specific instance shall not constitute a waiver of any other breaches of the TOS, even with regard to the same user. If any provision of the TOS is found by a court of competent jurisdiction to be invalid, you agree that the court should give effect to the party's intentions as reflected in the provision, and that all other provisions of the TOS remain in full force and effect."
+ non_severability: 'Non-severability:'
+ license_html: The AO3 %{terms_of_service_link}, including the %{content_policy_link} and %{privacy_policy_link}, are licensed under the %{cc_attribution_4_0_international_link}.
+ page_content_landmark: Main Text
+ page_heading: Terms of Service
+ potential_problems:
+ account_termination_liability: You agree that the OTW shall not be liable to you or any third party for any limitations on, or termination of, your access to AO3. The OTW may change, put on hiatus, restrict or prohibit access to, or end AO3 or parts of its services at any time.
+ breach_notification: If we learn of a breach which compromises the security or confidentiality of the Personal Information of AO3 users, we will notify affected users and relevant supervisory authorities as required by law as soon as practicable.
+ content_access_liability: You agree that the OTW shall not be liable to you for any claim arising out of Content you make available, your connection to AO3, your use of AO3 or its TOS, or your violation of anyone else's rights. In other words, the OTW is not liable to you for allowing you to post, access, or download Content, or use or interact with AO3. The OTW does not assume whatever legal risks you face by posting, accessing, or doing other things with Content.
+ damage_liability: You expressly agree that, to the maximum extent permitted by law, the OTW shall not be liable to you for any damages of any kind (even if the OTW has been advised of the possibility of such damages) resulting from AO3, including but not limited to your use of or inability to use AO3; unauthorized access to or changes in Content or information you submit; and the actions and statements of third parties who use AO3.
+ disclaim_warranties_html: The OTW expressly disclaims all warranties of any kind, whether express or implied, including (but not limited to) the implied warranties of %{merchantability_link}, %{fitness_for_purpose_link}, and non-infringement. The TOS govern your use of AO3, and therefore no communication from anyone associated with the OTW will create any warranty that isn't expressly stated in the TOS.
+ fitness_for_purpose: fitness for a particular purpose
+ heading: I.C. Potential problems with AO3
+ merchantability: merchantability
+ not_personal_storage_html: AO3 is not intended to be used as a personal storage or file recovery service. %{sole_backup_responsibility}
+ own_risk: Any material you access through AO3 in any way is at your own risk. You will be solely responsible for any damage or loss of data that results from accessing any such material.
+ service_as_is: The OTW provides services, including AO3, on an "as is" and "as available" basis. The OTW does not warrant (that is, does not make a legally binding promise) that our services will meet your requirements; that our services will be uninterrupted, timely, secure, or error-free; or that the results you get from using our services will be accurate, reliable, or satisfactory to you.
+ sole_backup_responsibility: You are solely responsible for backing up any Content that you submit to AO3. The OTW will not be liable for any lost or unrecoverable Content.
+ privacy_policy: Privacy Policy
+ registration_and_email_addresses:
+ agree_current_address_html: As part of your registration, you agree to provide an accurate and current email address and to update the address as necessary. If your email address is inaccurate or not current, we may %{suspend_your_account_link}.
+ email_is_yours_html: By registering or otherwise using an email address in connection with AO3, you assert that the email address is yours and that we may %{lawfully_communicate_with_you_link} as otherwise provided in the TOS.
+ heading: I.G. Registration and email addresses
+ lawfully_communicate_with_you: lawfully communicate with you
+ suspend_your_account: suspend your account
+ terms_of_service: Terms of Service
+ toc:
+ header: Table of Contents for the Terms of Service (TOS)
+ intro: There are three parts to the AO3 Terms of Service. The General Principles are the first part of the TOS.
+ updates_to_the_tos:
+ content_policy: Content Policy
+ heading: I.B. Updates to the Terms of Service
+ html: 'You can learn about changes to the TOS by visiting AO3. The TOS, including the %{content_policy_link} and %{privacy_policy_link}, may be modified at any time through the following process: Proposed changes will first be prominently disclosed on AO3 for a public comment period lasting at least two weeks. After the end of the comment period, proposed changes will be voted on by the OTW Board. If the OTW Board votes in favor, the changes will become effective when posted to the relevant Terms page(s). This is the only means by which the TOS may be altered. The TOS cannot be changed by emails or other communications with you.'
+ privacy_policy: Privacy Policy
+ what_we_believe:
+ ao3_run_by_html: Sunset is run by One Person on an HP Envy in its basement. Be nice to me! Be nice!
+ defending_fanworks: defending fanworks against legal challenges
+ faq:
+ abbreviated: FAQ
+ full: Frequently Asked Questions
+ header: Rules and Terms of Service
+ maximum_inclusiveness: maximum inclusiveness of fanwork content
+ on_the_otw_site: on the OTW site
+ our_goal_html: All existing members under the age of 18 will be grandfathered in; as of Sept 18, 2025, this website is 18+ only. Sorry!
+ readability_html: Don't plagiarize. Don't upload things like links to illegal material I don't want to get in trouble. Also NO KO-FI, PATREON, OR SIMILAR FINANCIAL DONATION LINKS PLEASE. I support you supporting yourself but it can land me in legal trouble. Uh what else. Don't harass anyone else, but hopefully you already know that!
+ tos_faq_html: TOS %{faq_abbreviation}
+ we_do_not_sell_html: DO NOT post sexual/nsfw RPF of minors on this site. DO NOT post hyperrealistic drawings of characters under the age of 14 occurring in sexual contexts. If you're not sure if a drawing will count as hyperrealistic, please email me. I COMPLETELY support freedom of fiction, but these are two things I don't feel comfortable hosting here for a variety of reasons.
+ what_we_do_with_content:
+ agree_otw_can_copy_html: You agree that we can make those copies and show your Content to other people. Specifically, by submitting Content, you grant the OTW a %{worldwide_royalty_free_nonexclusive_license_link} to make your Content available. "Making available" includes distributing, reproducing, performing, displaying, compiling, and modifying or adapting. %{modifying_or_adapting_link} here refers strictly to how your work is displayed, not how it is written, drawn, or otherwise created. User-provided tags may be modified or organized, which is a process we call %{tag_wrangling_link}.
+ challenge: Challenge
+ content_not_completely_controlled_html: You may provide Content to a part of AO3 that you do not completely control. For example, you may decide to %{orphan_link} a work; participate in a %{challenge_link}; comment on someone else's work; or comment on an official AO3 or OTW post, which may be %{subject_to_moderation_link}. Where this is the case, by submitting Content to those parts of AO3, you agree to the %{rules_for_removing_such_content_link} on those parts of AO3.
+ heading: I.E. What we do with Content
+ license_duration: Subject to the next paragraph of this policy, this license exists only for as long as you choose to continue to include such Content on AO3. It will terminate within a reasonable time after you remove (or the OTW removes) such Content from AO3. We will always strive to make your Content unavailable to users as soon as possible should you choose to delete it. However, if the Content is not attached to an account you control, you may be unable to delete that Content. Although removed Content will not be publicly available through AO3, we may retain backup copies for longer periods for legal and disaster recovery purposes. These copies may be accessible to AO3 and OTW administrators. If you delete Content from AO3 or edit Content in a manner that overwrites Content that you had previously submitted, we cannot restore it at your request.
+ modifying_or_adapting: Modifying or adapting
+ no_copyright_ownership_html: The OTW does not claim any copyright in or ownership of your Content. %{we_repeat} Nothing in this agreement changes that in any way. However, running AO3 requires us to make copies, and backup copies, on servers that may be located anywhere around the world.
+ open_doors: Open Doors
+ orphan: Orphan
+ preserve_for_legal_reasons_html: You acknowledge and agree that the OTW may preserve Content and may disclose Content if required to do so by law or in the good-faith belief that such preservation or disclosure is reasonably necessary to comply with legal processes; enforce the TOS; respond to claims that any Content violates the rights of third parties; or protect the rights, property, or personal safety of the OTW, users of the OTW's services, or the public. Refer to the %{privacy_policy_link} for details about when we may preserve and/or disclose your Personal Information.
+ privacy_policy: Privacy Policy
+ rules_for_removing_such_content: rules for removing and retaining such Content
+ some_content_open_doors_html: Some of the Content hosted on AO3 is imported via %{open_doors_link}. Open Doors' agreement with each collection's archivist is separate from AO3's TOS. If a work has been imported via Open Doors and its creator requests that the work be removed from AO3, we will remove it. If an archivist chooses to remove their imported collection, any imported works that have been claimed or Orphaned by their creators will remain on AO3.
+ subject_to_moderation: subject to moderation
+ tag_wrangling: tag wrangling
+ we_repeat: 'We repeat: we do not own your Content.'
+ worldwide_royalty_free_nonexclusive_license: worldwide, royalty-free, nonexclusive license
+ what_you_cant_do:
+ account_if_age_barred_html: to create an account if you are an %{age_barred_individual_link};
+ age_barred_individual: Age-Barred Individual
+ break_applicable_law: to break any law that applies to you, including any rules or regulations having the force of law. As a general rule, AO3 follows U.S. law. Each user is responsible for knowing the laws of their own country.
+ commercial_activity_html: to conduct any commercial activity, whether for direct or indirect commercial advantage, including (without limitation) %{making_available_any_advertising_link}, spam, or other solicitation, or scraping Content in order to commercialize it;
+ comprehensive_trade_embargo: comprehensive trade embargo
+ content_policy: Content Policy
+ content_violating_policy_html: to make available any Content that violates the %{content_policy_link};
+ copyright_infringement_html: to make available any Content that a court has ruled constitutes %{infringement_of_a_copyright_link}, patent, trademark, or trade secret; or that violates any other rights of a third party (as consistent with the OTW's %{position_on_fanwork_legality_link});
+ forge_identifiers: to forge or otherwise manipulate identifiers (such as email headers or other information intended to route or authenticate Content) in order to disguise the origin of any Content transmitted to or through any OTW sites, servers, networks, or services;
+ function: function
+ heading: I.F. What you can't do
+ impersonate_any_person_or_entity: impersonate any person or entity
+ impersonate_person_or_entity_html: to %{impersonate_any_person_or_entity_link} (including, but not limited to, an AO3 or OTW representative or volunteer, or an AO3 %{function_link}), or to falsely state or otherwise misrepresent your affiliation with a person or entity;
+ infringement_of_a_copyright: infringement of a copyright
+ interfere_disrupt_ao3: interfere with or disrupt AO3
+ interfere_disrupt_ao3_html: to %{interfere_disrupt_ao3_link}, any OTW-hosted Content, or any services, sites, servers, or networks connected to OTW sites;
+ making_available_any_advertising: making available any advertising
+ position_on_fanwork_legality: position on fanwork legality
+ resident_embargo_country_html: to create an account if you are a resident or national of any country with which the U.S. has prohibited transactions by mandating a %{comprehensive_trade_embargo_link}, as detailed by the Office of Foreign Assets Control; or
+ software_viruses: to make available any material that contains software viruses or other computer code, files, or programs designed to interrupt, destroy, or limit the functionality of any computer, hardware, or telecommunications equipment;
+ you_agree_not_to: 'You agree not to use AO3 (as well as the email addresses and URLs of OTW sites):'
+ tos_navigation:
+ content: Content Policy
+ privacy: Privacy Policy
+ top: "%{arrow} Top"
+ tos: Terms of Service
+ tos_faq: TOS FAQ
+ tos_toc:
+ content:
+ commercial_promotion: Commercial Promotion
+ copyright_infringement: Copyright Infringement
+ fanworks: Fanworks
+ harassment: Harassment
+ header: Content Policy
+ header_current: Content Policy (current section)
+ illegal_and_inappropriate_content: Illegal and Inappropriate Content
+ impersonation: Impersonation
+ intro: Content Policy Introduction
+ mandatory_tags: Mandatory Tags
+ offensive_content: Offensive Content
+ personal_information_and_fannish_identities: Personal Information and Fannish Identities
+ plagiarism: Plagiarism
+ user_icons: User Icons
+ privacy:
+ aggregate_and_anonymous_information: Aggregate and anonymous information
+ applicability: Applicability
+ contact_us: Contact us
+ header: Privacy Policy
+ header_current: Privacy Policy (current section)
+ information_shared_with_third_parties: Information shared with third parties
+ intro: Privacy Policy Introduction
+ retention_of_personal_information: Retention of Personal Information
+ scope_of_personal_information_we_process: Scope of Personal Information we process
+ termination_of_account: Termination of account
+ types_of_personal_information_we_collect_and_process: Types of Personal Information we collect and process
+ your_rights_under_applicable_data_privacy_laws: Your rights under applicable data privacy laws
+ tos:
+ abuse_policy: Abuse Policy
+ age_policy: Age Policy
+ content_you_access: Content you access when using AO3
+ general_terms: General terms
+ header: General Principles
+ header_current: General Principles (current section)
+ intro: General Principles Introduction
+ potential_problems: Potential problems with AO3
+ registration_and_email_addresses: Registration and email addresses
+ updates_to_the_tos: Updates to the Terms of Service
+ what_we_do_with_content: What we do with Content
+ what_you_cant_do: What you can't do
+ invitations:
+ invitation:
+ copy_and_use: Copy and use
+ copy_link: Copy link
+ created_at: Created at
+ deleted_user: Deleted User
+ email_address_label: Enter an email address
+ invitation_token: Invitation token
+ last_resent_at: Last resent at
+ queue: queue
+ redeemed_at: Redeemed at
+ redeemed_by: Redeemed by
+ sender: Sender
+ sent_at: Sent at
+ sent_to: Sent to
+ user_id_deleted: User %{user_id} (Deleted)
+ user_invitations:
+ table:
+ actions:
+ copy_and_use: Copy and use
+ delete: Delete
+ delete_confirmation: Are you sure you want to delete this invitation?
+ caption: Invitation Information
+ headings:
+ copy_link: Copy Link
+ external_author: External Author
+ sent_to: Sent To
+ token: Token
+ username: Username
+ summary: List of your invitation tokens and information regarding who you shared them with, along with the option to share unused tokens.
+ invite_requests:
+ index_closed:
+ already_requested_html: If you have already requested an invitation, you can %{check_waitlist_position_link}.
+ check_waitlist_position: check your position on the waiting list
+ page_heading: Invitation Requests
+ queue_disabled:
+ closed: New invitation requests are currently closed.
+ html: "%{closed_bold} For more information, please check the %{news_link}."
+ news: '"Invitations" tag on Sunset News'
+ waiting_list_count:
+ one: There is %{count} person remaining on the waiting list.
+ other: There are %{count} people remaining on the waiting list.
+ index_open:
+ add_to_list: Add me to the list
+ already_requested_html: If you have already requested an invitation, you can %{check_waitlist_position_link}.
+ check_waitlist_position: check your position on the waiting list
+ content_policy: Content Policy
+ details_html: To get a free Sunset account, you need an Invitation. By submitting your email address to our invitation queue, you confirm that you are at least 18 years old. We will use the email address you submit only to send you an Invitation and to process/manage your account activation. Please don't request an Invitation unless you've read our %{tos_link}, including the %{content_policy_link}, and agree to abide by those Terms.
+ frequency:
+ one: hour
+ other: "%{count} hours"
+ invitation_number:
+ one: "%{count} invitation"
+ other: "%{count} invitations"
+ invitation_send_rate: We are sending out %{queue_invitation_number} every %{queue_frequency}.
+ page_heading: Invitation Requests
+ privacy_policy: Privacy Policy
+ request_invitation_header: Request an invitation
+ tos: Terms of Service
+ waiting_list_count:
+ one: There is currently %{count} person on the waiting list.
+ other: There are currently %{count} people on the waiting list.
+ invitation:
+ after_cooldown_period:
+ not_resent:
+ one: Because your invitation was sent more than an hour ago, you can have your invitation resent.
+ other: Because your invitation was sent more than %{count} hours ago, you can have your invitation resent.
+ resent_html:
+ one: Because your invitation was resent more than an hour ago, you can have your invitation resent again, or you may want to %{contact_support_link}.
+ other: Because your invitation was resent more than %{count} hours ago, you can have your invitation resent again, or you may want to %{contact_support_link}.
+ before_cooldown_period:
+ one: If it has been more than an hour since you should have received your invitation, but you have not received it after checking your spam folder, you can visit this page to resend the invitation.
+ other: If it has been more than %{count} hours since you should have received your invitation, but you have not received it after checking your spam folder, you can visit this page to resend the invitation.
+ contact_support: contact Support
+ info:
+ not_resent: Your invitation was emailed to this address on %{sent_at}. If you can't find it, please check your email spam folder as your spam filters may have placed it there.
+ resent: Your invitation was emailed to this address on %{sent_at} and resent on %{resent_at}. If you can't find it, please check your email spam folder as your spam filters may have placed it there.
+ resend_button: Resend Invitation
+ title: Invitation Status for %{email}
+ invite_request:
+ date: 'At our current rate, you should receive an invitation on or around: %{date}.'
+ position_html: You are currently number %{position} on our waiting list!
+ title: Invitation Status for %{email}
+ no_invitation:
+ email_not_found: Sorry, we can't find the email address you entered.
+ show:
+ instructions_html: To check on the status of your invitation, go to the %{status_link} and enter your email in the space provided.
+ status:
+ frequency:
+ one: hour
+ other: "%{count} hours"
+ heading: Invitation Request Status
+ invitation_number:
+ one: "%{count} invitation"
+ other: "%{count} invitations"
+ invitation_send_rate: We are sending out %{queue_invitation_number} every %{queue_frequency}.
+ search: Look me up
+ waiting_list:
+ one: There is currently %{count} person on the waiting list.
+ other: There are currently %{count} people on the waiting list.
+ kudos:
+ guest_header:
+ one: "%{count} guest has also left kudos"
+ other: "%{count} guests have also left kudos"
+ user_links:
+ more_link:
+ one: "%{count} more user"
+ other: "%{count} more users"
+ languages:
+ form:
+ abuse_support_available: Abuse support available
+ name: Name
+ required_notice: Required information
+ short: Abbreviation
+ sortable_name: Name for alphabetical sorting
+ submit:
+ create: Create Language
+ update: Update Language
+ support_available: Support available
+ index:
+ language_code_format_html: "(%{code})"
+ navigation:
+ add: Add a Language
+ edit: Edit
+ suggest: Suggest a Language
+ page_heading: Work Languages
+ works_count:
+ one: "%{formatted_count} work"
+ other: "%{formatted_count} works"
+ layouts:
+ banner:
+ hide: hide banner
+ footer:
+ about:
+ content_policy: .
+ diversity_statement: Diversity Statement
+ dmca_policy: DMCA Policy
+ header: About the Archive
+ privacy_policy: .
+ site_map: Site Map
+ tos: Terms of Service
+ contact_us:
+ header: Contact Us
+ pac: Policy Questions & Abuse Reports
+ support: Technical Support & Feedback
+ customize:
+ default: Default
+ header: Customize
+ development:
+ archive_version: otwarchive %{version_number}
+ gpl_2_0_or_later: GPL-2.0-or-later
+ header: Development
+ known_issues: Known Issues
+ license_details_html: "%{license_link} by the OTW"
+ view_license: View License
+ landmark: Footer
+ otw:
+ abbreviated: Sunset
+ full: Sunset
+ header:
+ collections:
+ new: New Collection
+ javascript: While we've done our best to make the core functionality of this site accessible without JavaScript, it will work better with it enabled. Please consider turning it on!
+ login: Log In
+ nav:
+ about: About
+ browse: Browse
+ fandoms: Fandoms
+ label: Site
+ search: Search
+ proxy_notice:
+ button: Dismiss Notice
+ faux_heading: 'Important message:'
+ point1: You are using a proxy site that is not part of the Archive of Our Own.
+ point2: The entity that set up the proxy site can see what you submit, including your IP address. If you log in through the proxy site, it can see your password.
+ tos_prompt:
+ agree_and_consent: I agree/consent to these Terms
+ content: Content
+ content_policy: Content Policy
+ data_processing_agreement: By checking this box, you consent to the processing of your personal data in the United States and other jurisdictions in connection with our provision of Sunset and its related services to you. You acknowledge that the data privacy laws of such jurisdictions may differ from those provided in your jurisdiction. For more information about how your personal data will be processed, please refer to our Privacy Policy.
+ learn_more_html: To learn more, check out our %{tos_link}.
+ page_heading: Sunset
+ privacy_policy: Privacy Policy
+ read_and_understood: I have read & understood the 2025 Terms of Service, including the Content Policy and Privacy Policy.
+ summary_html: On the Sunset, users can create femslash (F/F, NB/F, NB/NB) works, bookmarks, comments, tags, and other %{content_link}. Any information you publish on Sunset may be accessible by the public, Sunset users, and/or Sunset personnel. Be mindful when sharing personal information, including but not limited to your name, email, age, location, personal relationships, gender or sexual identity, racial or ethnic background, religious or political views, and/or account usernames for other sites. You also agree to be nice to the admin, as the admin is one person who runs this website singlehandedly.
+ tos: Terms of Service
+ media:
+ index:
+ all: All %{media_type}...
+ page_heading: Fandoms
+ menu:
+ menu_about:
+ about_us: About Us
+ donate: Donate
+ faq: FAQ
+ news: News
+ wrangling_guidelines: Wrangling Guidelines
+ menu_browse:
+ bookmarks: Bookmarks
+ collections: Collections
+ tags: Tags
+ works: Works
+ menu_fandoms:
+ all: All Fandoms
+ menu_search:
+ bookmarks: Bookmarks
+ people: People
+ tags: Tags
+ works: Works
+ muted:
+ mute: Mute
+ muted_items_notice_html: You have muted some users on the Archive. Some items may not be shown, and any counts may be inaccurate. You can mute or unmute users on %{muted_users_link}.
+ muted_users_link_text: your Muted Users page
+ unmute: Unmute
+ users:
+ confirm_mute:
+ block_users_instead_html: To prevent a user from commenting on your works or replying to your comments elsewhere on the site, visit %{blocked_users_link}.
+ blocked_users_link_text: your Blocked Users page
+ button: Yes, Mute User
+ cancel: Cancel
+ mute: mute
+ restore_site_skin_faq_link_text: instructions for reverting to the default site skin
+ site_skin_warning_html: Please note that if you are not using the default site skin, muting may not work properly. The Skins and Archive Interface FAQ has %{restore_site_skin_faq_link}.
+ sure_html: Are you sure you want to %{mute} %{username}?
+ title: Mute %{name}
+ will:
+ intro: 'Muting a user:'
+ seeing_content: completely hides their works, series, bookmarks, and comments from you; there will be no empty space, placeholder text, or other indication something has been removed
+ will_not:
+ hide_content_for_others: hide their works, series, bookmarks, and comments from anyone else
+ intro: 'Muting a user will not:'
+ prevent_emails: prevent you from receiving comment or subscription emails from this user
+ confirm_unmute:
+ button: Yes, Unmute User
+ cancel: Cancel
+ resume:
+ intro: 'Unmuting a user allows you to:'
+ see_content: see their works, series, bookmarks, and comments on the site
+ sure_html: Are you sure you want to %{unmute} %{username}?
+ title: Unmute %{name}
+ unmute: unmute
+ index:
+ block_users_instead_html: To prevent a user from commenting on your works or replying to your comments elsewhere on the site, visit %{blocked_users_link}.
+ blocked_users_link_text: your Blocked Users page
+ button: Mute
+ heading:
+ landmark:
+ muted_users: Listing Muted Users
+ label: User
+ legend: Mute a user
+ muted_users: Muted Users
+ none: You have not muted any users.
+ restore_site_skin_faq_link_text: instructions for reverting to the default site skin
+ site_skin_warning_html: Please note that if you are not using the default site skin, muting may not work properly. The Skins and Archive Interface FAQ has %{restore_site_skin_faq_link}.
+ title: Muted Users
+ will:
+ intro:
+ one: 'You can mute up to %{mute_limit} user. Muting a user:'
+ other: 'You can mute up to %{mute_limit} users. Muting a user:'
+ seeing_content: completely hides their works, series, bookmarks, and comments from you; there will be no empty space, placeholder text, or other indication something has been removed
+ will_not:
+ hide_content_for_others: hide their works, series, bookmarks, and comments from anyone else
+ intro: 'Muting a user will not:'
+ prevent_emails: prevent you from receiving comment or subscription emails from this user
+ orphans:
+ orphan_user:
+ orphaning_bylines_only_message_html: Orphaning will automatically remove your personal information from bylines only. It will not automatically remove your personal information from anywhere else in the work(s). Social media accounts, contact information, email addresses, names, and any other personal information posted in the title, summary, notes, tags, work body, or comment text will not be automatically removed. Comments posted by other users will also not be affected by Orphaning. If you want information removed from these places, you should edit or delete it prior to Orphaning. Once you Orphan the work(s), you will no longer be able to delete information in these places yourself.
+ orphaning_works_message_html: 'Orphaning will permanently remove your username and/or pseud from the bylines of: the following work(s), their chapters, associated series, and any comments you may have left on them while logged into this account.'
+ pagy:
+ aria_label:
+ nav: Pagination
+ next: Next →
+ prev: "← Previous"
+ preferences:
+ index:
+ browser_page_title_format: Browser page title format
+ collections_challenges_gifts:
+ allow_collection_invitation: Allow others to invite my works to collections.
+ allow_gifts: Allow anyone to gift me works.
+ heading: Collections, Challenges and Gifts
+ legend: Collections, Challenges and Gifts
+ turn_off_collection_emails: Turn off emails from collections.
+ turn_off_collection_inbox: Turn off inbox messages from collections.
+ turn_off_gift_emails: Turn off emails about gift works.
+ comments:
+ guest_replies_off: Do not allow guests to reply to my comments on news posts or other users' works (you can still control the comment settings for your works separately).
+ heading: Comments
+ legend: Comments
+ turn_off_copies_own_comments: Turn off copies of your own comments.
+ turn_off_emails: Turn off emails about comments.
+ turn_off_inbox: Turn off messages to your inbox about comments.
+ turn_off_kudos_emails: Turn off emails about kudos.
+ display:
+ heading: Display
+ hide_additional_tags: Hide additional tags (you can still choose to show them).
+ hide_warnings: Hide warnings (you can still choose to show them).
+ hide_work_skins: Hide work skins (you can still choose to show them).
+ legend: Display
+ show_adult_content: Show me adult content without checking.
+ show_whole_work_default: Show the whole work by default.
+ misc:
+ heading: Misc
+ legend: Misc
+ turn_off_banner_every_page: Turn off the banner showing on every page.
+ turn_on_history: Turn on History.
+ turn_on_new_user_help: Turn the new user help banner back on.
+ navigation:
+ blocked_users: Blocked Users
+ change_email: Change Email
+ change_password: Change Password
+ change_username: Change Username
+ edit_my_profile: Edit My Profile
+ landmark: Navigation
+ manage_my_pseuds: Manage My Pseuds
+ muted_users: Muted Users
+ page_heading: Set My Preferences
+ privacy:
+ allow_co_creator_invite: Allow others to invite me to be a co-creator.
+ heading: Privacy
+ hide_share_buttons: Hide the share buttons on my work.
+ hide_work_from_search_engines: Hide my work from search engines when possible.
+ legend: Privacy
+ public_site_skins: Public Site Skins
+ your_locale: Your locale
+ your_site_skin: Your site skin
+ your_time_zone: Your time zone
+ profile:
+ pseud_list:
+ more_pseuds:
+ one: "%{count} more pseud"
+ other: "%{count} more pseuds"
+ pseuds:
+ delete_preview:
+ cancel: Cancel
+ confirm: Are you sure? This can't be undone!
+ delete:
+ one: Delete this bookmark
+ other: Delete these bookmarks
+ heading: Pseud deletion
+ notice: When you delete your pseud, any works, series, or comments you have created under it will be transferred to your default pseud.
+ saved_bookmarks:
+ one: You have saved %{count} bookmark using this pseud. You can choose whether to delete it or transfer it to your default pseud.
+ other: You have saved %{count} bookmarks using this pseud. You can choose whether to delete them or transfer them to your default pseud.
+ transfer:
+ one: Transfer this bookmark to the default pseud
+ other: Transfer these bookmarks to the default pseud
+ pseud_blurb:
+ confirm_delete: Are you sure?
+ default_pseud: Default Pseud
+ delete_html: Delete%{landmark_span}
+ delete_landmark_text: " %{pseud}"
+ edit_html: Edit%{landmark_span}
+ edit_landmark_text: " %{pseud}"
+ orphan_html: Orphan Works%{landmark_span}
+ orphan_landmark_text: " by %{pseud}"
+ user_actions: User Actions
+ pseuds_form:
+ cannot_change_matching_pseud_html: You cannot change the pseud that matches your username. However, you can %{change_username_link} instead.
+ change_username: change your username
+ description: Description
+ icon: Icon
+ icon_alt: Icon alt text
+ icon_comment: Icon comment text
+ icon_delete: Delete your icon and revert to our default. This will also remove your icon alt text and comment text.
+ icon_notes:
+ current: This is your icon.
+ format: Icons can be in png, jpeg or gif form.
+ limit: You can have one icon for each pseud.
+ size: Icons should be sized 100x100 pixels for best results.
+ icon_upload: Upload a new icon
+ make_default: Make this name default
+ name: Name
+ submit: Submit
+ readings:
+ index:
+ history:
+ landmark: List of History Items
+ page_heading: History
+ marked_for_later:
+ landmark: List of Marked for Later Items
+ page_heading: Marked for Later
+ related_works:
+ index:
+ page_heading: "%{login}'s Related Works"
+ series:
+ series_order:
+ draft_work_title: "%{title} (DRAFT)"
+ skins:
+ confirm_delete:
+ confirm_html: Are you sure you want to delete the skin "%{skin_title}"?
+ revert_skin_form:
+ revert_to_default: Revert to Default Skin
+ subscriptions:
+ confirm_delete_all:
+ caution_html: Are you sure you want to delete all your subscriptions? This cannot be undone.
+ page_heading:
+ all: Delete All Subscriptions
+ series: Delete All Series Subscriptions
+ users: Delete All User Subscriptions
+ works: Delete All Work Subscriptions
+ submit:
+ all: Yes, Delete All Subscriptions
+ series: Yes, Delete All Series Subscriptions
+ users: Yes, Delete All User Subscriptions
+ works: Yes, Delete All Work Subscriptions
+ index:
+ button_html: Unsubscribe from %{name}
+ byline_html: by %{creators}
+ heading:
+ landmark:
+ list: List of Subscriptions
+ navigation:
+ all: All Subscriptions
+ delete_all:
+ all: Delete All Subscriptions
+ series: Delete All Series Subscriptions
+ users: Delete All User Subscriptions
+ works: Delete All Work Subscriptions
+ series: Series Subscriptions
+ user: User Subscriptions
+ work: Work Subscriptions
+ page_heading:
+ all: My Subscriptions
+ series: My Series Subscriptions
+ users: My User Subscriptions
+ works: My Work Subscriptions
+ series: "(Series)"
+ work: "(Work)"
+ tag_wranglers:
+ show:
+ last_wrangled_html: "%{wrangler_login} last wrangled at %{time}."
+ tags_wrangled_csv: Tags Wrangled (CSV)
+ tag_wranglings:
+ wrangler_dashboard:
+ characters_by_fandom: Characters by fandom (%{count})
+ fandoms_by_media: Fandoms by media (%{count})
+ freeforms_by_fandom: Freeforms by fandom (%{count})
+ new_tag: New Tag
+ relationships_by_fandom: Relationships by fandom (%{count})
+ search_tags: Search Tags
+ tag_type:
+ character: Characters
+ fandom: Fandoms
+ freeform: Freeforms
+ merger: Mergers
+ relationship: Relationships
+ subtag: SubTags
+ tag_type_and_count: "%{tag_type} (%{count})"
+ unsorted_tags: Unsorted Tags (%{count})
+ use_type:
+ bookmarks: Bookmarks
+ collections: Collections
+ drafts: Drafts
+ external_works: External Works
+ private_bookmarks: Private Bookmarks
+ taggings_count: Taggings Count
+ works: Works
+ use_type_and_count: "%{use_type} (%{count})"
+ wranglers: Wranglers
+ wrangling_home: Wrangling Home
+ wrangling_tools: Wrangling Tools
+ tags:
+ edit:
+ save_changes: Save changes
+ submit_legend: Submit
+ index:
+ about:
+ popular: These are some of the most popular tags used on the Archive. To find more tags, %{search_tags_link}.
+ popular_in_collection: These are some of the most popular tags used in the collection.
+ random: These are some random tags used on the Archive. To find more tags, %{search_tags_link}.
+ random_in_collection: These are some random tags used in the collection.
+ search_tags: try our tag search
+ search_form:
+ fandoms: Fandoms
+ fandoms_footnote: Find tags wrangled to specific canonical fandoms.
+ search_tags: Search Tags
+ sort_by: Sort by
+ sort_direction: Sort direction
+ status_option:
+ any_status: Any status
+ canonical: Canonical
+ canonical_or_synonymous: Canonical or synonymous
+ noncanonical: Non-canonical
+ noncanonical_and_nonsynonymous: Non-canonical and non-synonymous
+ synonymous: Synonymous
+ tag_name: Tag name
+ type: Type
+ wrangling_status: Wrangling status
+ show:
+ canonical_html: It's a %{canonical_tag_link}. You can use it to %{filter_works_link} and to %{filter_bookmarks_link}.
+ canonical_tag: canonical tag
+ fandom_relationship_tags: Relationship tags in this fandom
+ filter_bookmarks: filter bookmarks
+ filter_works: filter works
+ list_fandom_tags_html: You can also access a list of %{fandom_relationship_tags_link}.
+ time:
+ formats:
+ date_short_html: %a %d %b %Y
+ troubleshooting:
+ show:
+ fix_associations:
+ description: Try to delete invalid associations involving this tag (e.g., parents of the wrong type, children of the wrong type, duplicate parents/children/metatags/subtags, etc.). Most of these impossible relationships appear on tag landing pages.
+ title: Fix Tag Associations
+ fix_counts:
+ description: Try to recalculate some counts associated with this tag (both filter counts and taggings counts). Keep in mind that the tag counts listed in the fandom lists include unrevealed works and drafts, so if the count is only a little off it may just be unrevealed works or drafts.
+ title: Fix Tag Counts
+ fix_meta_tags:
+ description: Try to recalculate inherited metatags. Use this option if this tag has grandparent metatags (i.e., a metatag with its own metatag), and the works aren't showing up in the grandparent's listing. You might also try this if you've removed a metatag, but the works are still showing up in the former metatag's listing. If the works still don't go away, ask a staffer to run "Update Tag Filters" on this tag.
+ title: Fix Metatags
+ page_description:
+ tag: If this tag is exhibiting undesirable behavior, try one of these options to fix it.
+ work: If this work is exhibiting undesirable behavior, try one of these options to fix it.
+ page_title:
+ tag: Troubleshoot Tag
+ work: Troubleshoot Work
+ reindex_tag:
+ description: Reindex this tag and all related works, bookmarks, series, pseuds, and external works. Use this option if none of the others have worked. It will not fix autocomplete issues.
+ title: Reindex Tag
+ reindex_work:
+ description: Send in this work to be reindexed. Use this option if the work isn't appearing in searches, or is appearing in searches that it shouldn't. (e.g. If someone has orphaned their work and it can still be found with the old name, or if it's not behaving as expected when you filter crossovers, completion status, word count, etc.)
+ title: Reindex Work
+ update_tag_filters:
+ description: Recalculate filters for all works directly tagged with this tag or one of its synonyms. Does not include subtags. Use this option if the filters include old canonical tags that have been renamed or old parent tags that works aren't tagged with, or if the tag counts are wildly off. Also use this option if the works from a synonym are not showing on the works page of the canonical within a half hour of the tag being synned. Keep in mind that the tags listed in the filters will never be 100% correct.
+ title: Update Tag Filters
+ update_work_filters:
+ description: Recalculate the filters for this work based on the current set of tags. Use this option if wrangling seems to have gone wrong. (e.g. If you synned one of this work's tags to a canonical but it's not showing up in the tag listings, or if the wrong tags are listed in the sidebar when you drill down to a search that includes only this work.)
+ title: Update Work Filters
+ users:
+ admin_change_username:
+ description: Change the name of this user to a generic name in the format user + their account ID.
+ navigation:
+ manage_user: Manage User
+ new_username_footnote: This username cannot be edited. If you need to change it to a different name, contact your chair.
+ page_heading: Change Username
+ ticket_id: Ticket ID
+ change_email:
+ caution:
+ check_spam_html: "%{must_confirm_bold} Please check your spam folder or %{contact_support_link} if you do not receive the confirmation email."
+ confirm_by: If you don't confirm your request by %{date}, your email address will not be changed.
+ contact_support: contact Support
+ must_confirm: You must use the link in the confirmation email in order to finish confirming your email change.
+ requested_change_html: You have requested to change your email address to %{unconfirmed_email}. A confirmation email has been sent to this address.
+ form:
+ confirm: Confirm New Email
+ current_email: Current email
+ email_again: Enter new email again
+ new_email: New email
+ password: Password
+ submit_landmark: Submit
+ heading: Change Email
+ invalidate: invalidate any pending email change requests
+ must_confirm:
+ one: You must use the link in the confirmation email in order to finish confirming your email change. If you don't confirm your request within %{count} day, the link will expire and your email address will not be changed.
+ other: You must use the link in the confirmation email in order to finish confirming your email change. If you don't confirm your request within %{count} days, the link will expire and your email address will not be changed.
+ request_for_confirmation: Changing your email will send a request for confirmation to your new email address and a notification to your current email address.
+ resubmission_html: Resubmitting this form with a new email address will %{invalidate_bold}.
+ change_username:
+ account_faq: Account FAQ
+ caution: Please use this feature with caution.
+ change_window:
+ one: You can change your username once per day.
+ other: You can change your username once every %{count} days.
+ confirm: Are you sure you want to change your username?
+ contact_support: contact Support
+ create_a_new_pseud: create a new Pseud
+ current_username: Current username
+ heading: Change Username
+ last_renamed: You last changed your username on %{renamed_at}.
+ legend: Change Username
+ more_info_html: For information on how changing your username will affect your account, please check out the %{account_faq_link}. Note that changes to your username may take several days or longer to appear. If you are still seeing your old username on your works, bookmarks, series, or collections after a week, please %{contact_support_link}.
+ new_pseud_instead_html: If that is not what you want, you can %{create_a_new_pseud_link} instead.
+ new_username: New username
+ page_heading: Change My Username
+ password: Password
+ submit: Change Username
+ submit_landmark: Submit
+ username_requirements: "%{minimum} to %{maximum} characters (A-Z, a-z, _, 0-9 only), no spaces, cannot begin or end with underscore (_)"
+ confirm_change_email:
+ are_you_sure_html: Are you sure you want to change your email address to %{unconfirmed_email}? A confirmation email will be sent to this address.
+ cancel: Cancel
+ confirm: Yes, Change Email
+ confirm_via_link_html: You must use the link in the confirmation email in order to finish confirming your email change. %{if_not_confirm_bold}
+ heading: Confirm New Email
+ if_not_confirm:
+ one: If you don't confirm your request within %{count} day, the link will expire and your email address will not be changed.
+ other: If you don't confirm your request within %{count} days, the link will expire and your email address will not be changed.
+ confirmation:
+ contact_support: contact Support
+ go_back: Return to Archive front page
+ important: Important!
+ must_activate:
+ one: You must activate your account within %{count} day, or your account will expire. If this happens, you can sign up again using the same invitation.
+ other: You must activate your account within %{count} days, or your account will expire. If this happens, you can sign up again using the same invitation.
+ no_email_html: If you haven't received this email within 24 hours, and you don't find our email in your spam filter or Social folder, please %{contact_support_link} for help.
+ page_heading: Almost Done!
+ receive_email_html: You should soon receive an activation email at the address you gave us. This will have the link you need to follow in order to activate your account and complete the account creation process. The activation email will come from %{return_address} -- you might want to add this to your address book to make sure you get the email.
+ delete_preview:
+ cancel: Cancel
+ co_creations:
+ legend: Works You Made With Others
+ orphan_info: You are not able to delete these as they are shared works, but you can %{orphan_link} them or remove yourself completely as a co-creator.
+ summary: 'You have %{work_count} work(s) co-created with the following users: %{co_creators}.'
+ confirm: Are you sure? This can't be undone!
+ heading: What do you want to do with your works?
+ options:
+ delete: Delete completely
+ keep_pseud: Leave my pseud but attach to the orphan account
+ orphan_pseud: Change my pseud to "orphan" and attach to the orphan account
+ remove: Remove me completely as co-creator
+ orphan: orphan
+ orphaning: orphaning
+ sole_creations:
+ collections_summary: 'You have %{collection_count} collection(s) under the following pseuds: %{pseuds}.'
+ legend: Works and Collections You Made By Yourself
+ options_info: You can delete them, but please consider %{orphaning_link} them instead!
+ works_summary: 'You have %{work_count} work(s) under the following pseuds: %{pseuds}.'
+ submit: Save
+ edit:
+ about_me: About Me
+ browser_title: Edit Profile
+ change_profile_landmark: Change Profile
+ page_heading: Edit My Profile
+ privacy_policy: Privacy Policy
+ public_information_notice_html: Any personal information you post on your public Sunset profile, including but not limited to your name, email, age, location, personal relationships, gender or sexual identity, racial or ethnic background, religious or political views, and/or account usernames for other sites, will be accessible by the general public. To learn more about what data Sunset collects when you're on the site and how we use it, check out our %{privacy_policy_link}.
+ title: Title
+ update: Update
+ edit_header_navigation:
+ change_email: Change Email
+ change_password: Change Password
+ change_username: Change Username
+ edit_default_pseud_and_icon: Edit Default Pseud and Icon
+ edit_profile: Edit Profile
+ edit_user_navigation:
+ change_email: Change Email
+ change_password: Change Password
+ change_username: Change Username
+ edit_default_pseud_and_icon: Edit Default Pseud and Icon
+ edit_my_profile: Edit My Profile
+ header_navigation:
+ admin_user: User Administration
+ edit_multiple: Edit Works
+ invitations: Invitations
+ new_work: Post New
+ passwords:
+ new:
+ email_or_username_html: Email address %{or} username
+ instructions: If you've forgotten your password, we can send instructions that will allow you to reset it. Please tell us the username or email address you used when you signed up for your Archive account.
+ or: or
+ page_heading: Forgotten your password?
+ reset_password: Reset Password
+ registrations:
+ legal:
+ agreement_confirm: Yes, I have read the Terms of Service, including the Content Policy and Privacy Policy, and agree to them.
+ agreement_required_html: Before you begin using Sunset, you must agree to our %{terms_of_service_link}, including the %{content_policy_link} and %{privacy_policy_link}.
+ content_policy: Content Policy (opens in new window)
+ data_processing_confirm: By checking this box, you consent to the processing of your personal data in the United States and other jurisdictions in connection with our provision of AO3 and its related services to you. You acknowledge that the data privacy laws of such jurisdictions may differ from those provided in your jurisdiction. For more information about how your personal data will be processed, please refer to our Privacy Policy.
+ over_thirteen_confirm: Yes, I am at least 18.
+ over_thirteen_required: You need to be at least 18 years old to become a registered member of the Archive. (Sorry to anyone younger! You'll be more than welcome when the time comes.)
+ privacy_policy: Privacy Policy (opens in new window)
+ terms_of_service: Terms of Service (opens in new window)
+ new:
+ cancel: Cancel
+ heading: Create Account
+ legend:
+ legal: Legal Agreements
+ user: User Details
+ submit: Create Account
+ wait: Please wait...
+ passwd:
+ confirm_password: Confirm password
+ confirm_password_validation: Please enter the same password in both fields.
+ password: Password
+ password_requirements: "%{minimum} to %{maximum} characters"
+ password_validation: Please enter a password! (At least %{minimum} letters long, please.)
+ username: Username
+ username_requirements: "%{minimum} to %{maximum} characters (A-Z, a-z, _, 0-9 only), no spaces, cannot begin or end with underscore (_)"
+ username_validation: You need a username! (At least %{minimum} letters long, please.)
+ valid_email: Valid email
+ sessions:
+ greeting:
+ nav:
+ assignments: My Assignments
+ bookmarks: My Bookmarks
+ collections: My Collections
+ dashboard: My Dashboard
+ greeting: Hi, %{current_user}!
+ history: My History
+ import: Import Work
+ label: User
+ log_out: Log Out
+ new_work: New Work
+ open_doors: Open Doors
+ post: Post
+ post_draft: From Draft
+ preferences: My Preferences
+ sign_ups: My Sign-ups
+ subscriptions: My Subscriptions
+ tag_wrangling: Tag Wrangling
+ works: My Works
+ new:
+ beta_reminder:
+ give_feedback: give us your feedback
+ reminder: 'Reminder:'
+ report_bugs: Please report any pesky bugs and %{give_feedback_link}!
+ warning: This site is in beta. Things may break or crash without notice.
+ login:
+ create_account: Create an account now
+ forgot: Forgot your password or username? %{reset_password_link}.
+ log_in: Log in
+ no_account: Don't have an account? %{join_link}.
+ request_invite: Request an invitation to join
+ reset_password: Reset password
+ restricted:
+ account_exists: If you already have a Sunset account, log in now.
+ commenting_unavailable: Commenting on this work is only available to registered users of the Archive.
+ no_account: If you don't have an account, you can %{request_invite_link}.
+ request_invite: request an invitation to join
+ signup: Or join us for free - it's easy.
+ sorry: Sorry!
+ work_unavailable: This work is only available to registered users of the Archive.
+ passwd:
+ landmark_submit: Submit
+ log_in: Log in
+ password: 'Password:'
+ remember_me: Remember me
+ username_or_email: 'Username or email:'
+ passwd_small:
+ create_an_account: Create an Account
+ forgot_password: Forgot password?
+ get_an_invitation: Get an Invitation
+ log_in: Log In
+ password: 'Password:'
+ remember_me: Remember Me
+ username_or_email: 'Username or email:'
+ show:
+ login_banner:
+ contact_abuse: contact our Policy & Abuse team
+ contact_support: contact our Support team
+ content_policy: Content Policy
+ dismiss: Dismiss permanently
+ help_html: If you need technical support, %{contact_support_link}. If you experience harassment or have questions about our %{tos_link} (including the %{content_policy_link} and %{privacy_policy_link}), %{contact_abuse_link}.
+ hide: Hide first login help banner
+ new_user_tips: useful tips for new users
+ our_faqs: our FAQs
+ privacy_policy: Privacy Policy
+ tos: Terms of Service
+ welcome_html: Hi! It looks like you've just logged in to Sunset for the first time. For help getting started on Sunset, check out some %{new_user_tips_link} or browse through %{our_faqs_link}.
+ sidebar:
+ catch:
+ history: History
+ inbox: Inbox (%{inbox_number})
+ statistics: Statistics
+ subscriptions: Subscriptions
+ choices:
+ all_pseuds: All Pseuds (%{pseud_number})
+ dashboard: Dashboard
+ preferences: Preferences
+ profile: Profile
+ pseud_switcher: Pseud Switcher
+ pseuds: Pseuds
+ skins: Skins
+ landmark:
+ catch: Catch
+ choices: Choices
+ pitch: Pitch
+ switch: Switch
+ pitch:
+ collections: Collections (%{coll_number})
+ drafts: Drafts (%{drafts_number})
+ switch:
+ assignments: Assignments (%{assignment_number})
+ claims: Claims (%{claim_number})
+ co_creator_requests: Co-Creator Requests (%{count})
+ related_works: Related Works (%{related_works_number})
+ sign_ups: Sign-ups (%{signup_number})
+ works:
+ adult:
+ caution: This work could have adult content. If you continue, you have agreed that you are willing to see such content.
+ footnote: If you accept cookies from our site and you choose "Yes, Continue", you will not be asked again during this session (that is, until you close your browser). If you log in you can store your preference and never be asked again.
+ navigation:
+ back: No, Go Back
+ continue: Yes, Continue
+ preferences: Set your preferences now
+ page_title: Adult Content Warning
+ associations:
+ language:
+ choose: Choose a language
+ title: Associations
+ byline:
+ add_co-creators: Add co-creators
+ show_co-creator_options: Add co-creators?
+ edit_tags:
+ content_policy: Content Policy
+ post_notice_html: All works you post on Sunset must comply with our %{content_policy_link}. For more information, please refer to our %{tos_faq_link}.
+ tos_faq: Terms of Service FAQ
+ import:
+ draft_work_title_html: "%{work_link} (Draft)"
+ page_heading: Imported Works
+ success: We were able to successfully upload the following works.
+ index:
+ advanced_search: try our advanced search
+ choose_fandom: choose a fandom
+ recent_works_html: These are some of the latest works posted to the Archive. To find more works, %{choose_fandom_link} or %{advanced_search_link}.
+ meta:
+ original_creators:
+ one: 'Original Creator ID:'
+ other: 'Original Creator IDs:'
+ navigate:
+ page_heading_html: Chapter Index for %{work_link} by %{creators}
+ new_import:
+ content_policy: Content Policy
+ post_notice_html: All works you post on Sunset must comply with our %{content_policy_link}. For more information, please refer to our %{tos_faq_link}.
+ tos_faq: Terms of Service FAQ
+ permissions:
+ privacy: Privacy
+ visibility:
+ import_works_restricted: Only show imported works to registered users
+ keep_current: Keep current visibility settings
+ label: Visibility
+ multiple_works_restricted: Only show to registered users
+ restricted: Only show your work to registered users
+ unrestricted: Show to all
+ preface:
+ title: Preface
+ preview:
+ content_policy: Content Policy
+ post_notice_html: All works you post on Sunset must comply with our %{content_policy_link}. For more information, please refer to our %{tos_faq_link}.
+ tos_faq: Terms of Service FAQ
+ search_box:
+ a11y_label: Work
+ label: Work Search
+ submit: Search
+ tooltip_label: 'tip:'
+ search_form:
+ creator: Creator
+ show:
+ unposted_deletion_notice_html: This work is a draft and has not been posted. The draft will be scheduled for deletion on %{deletion_date}.
+ show_multiple:
+ draft: " (Draft)"
+ no_works: You have no works or drafts to edit.
+ skin:
+ select: Select work skin
+ standard_form:
+ content_policy: Content Policy
+ post_notice_html: All works you post on Sunset must comply with our %{content_policy_link}. For more information, please refer to our %{tos_faq_link}.
+ tos_faq: Terms of Service FAQ
+ work_approved_children:
+ inspired_by:
+ restricted_html: "[Restricted Work] by %{creator_link} (Log in to access.)"
+ revealed_html: "%{work_link} by %{creator_link}"
+ title: Works inspired by this one
+ unrevealed: A work in an unrevealed collection
+ work_header_notes:
+ inspired_by:
+ other_works_inspired_by_this_one: other works inspired by this one
+ restricted_html: Inspired by [Restricted Work] by %{creator_link} (Log in to access.)
+ revealed_html: Inspired by %{work_link} by %{creator_link}
+ unrevealed: Inspired by a work in an unrevealed collection
+ jump:
+ endnotes_and_related_works_html: "(See the end of the work for %{endnotes_link} and %{related_works_link}.)"
+ endnotes_html: "(See the end of the work for %{endnotes_link}.)"
+ more_notes: more notes
+ notes: notes
+ related_works_html: "(See the end of the work for %{related_works_link}.)"
+ translated_to:
+ restricted_html: 'Translation into %{language} available: [Restricted Work] by %{creator_link} (Log in to access.)'
+ revealed_html: 'Translation into %{language} available: %{work_link} by %{creator_link}'
+ unrevealed_html: 'Translation into %{language} available: A work in an unrevealed collection'
+ translation_of:
+ restricted_html: A translation of [Restricted Work] by %{creator_link} (Log in to access.)
+ revealed_html: A translation of %{work_link} by %{creator_link}
+ unrevealed: A translation of a work in an unrevealed collection
+ work_module:
+ draft_deletion_notice_html: This draft will be scheduled for deletion on %{deletion_date}.
+ wrangling_guidelines:
+ show:
+ edit: Edit
+ heading: Wrangling Guidelines
+ wrangling_guideline_form:
+ content_required: You need to post some content here.
+ content_too_long:
+ one: We salute your ambition! But sadly the content must be less than %{count} letter long.
+ other: We salute your ambition! But sadly the content must be less than %{count} letters long.
+ content_too_short:
+ one: Brevity is the soul of wit, but your content does have to be at least %{count} letter long.
+ other: Brevity is the soul of wit, but your content does have to be at least %{count} letters long.
+ guideline_text: Guideline text
+ html_editor: HTML
+ landmark:
+ post: Post
+ post: Post
+ required_information: Required information
+ rich_text_editor: Rich Text
+ rich_text_notes_html: Type or paste formatted text.
+ title: Title
+ title_failure: Please enter a title.
diff --git a/config/locales/views/ru.yml b/config/locales/views/ru.yml
new file mode 100755
index 0000000..780a245
--- /dev/null
+++ b/config/locales/views/ru.yml
@@ -0,0 +1,6 @@
+ru:
+ layouts:
+ proxy_notice:
+ faux_heading: "Важная информация:"
+ point1: "Вы используете прокси-сайт, который не является частью AO3 (Нашего Архива)."
+ point2: "Субъект, настроивший прокси-сайт, может видеть, что вы отправляете, включая ваш IP-адрес. Если вы авторизуетесь через прокси-сайт, он может видеть ваш пароль."
diff --git a/config/locales/views/uk.yml b/config/locales/views/uk.yml
new file mode 100755
index 0000000..ca9f62a
--- /dev/null
+++ b/config/locales/views/uk.yml
@@ -0,0 +1,6 @@
+uk:
+ layouts:
+ proxy_notice:
+ faux_heading: "Важливе повідомлення:"
+ point1: "Ви використовуєте проксі-сайт, який не є частиною Archive of Our Own (Нашого Власного Архіву)."
+ point2: "Творці цього проксі-сайту можуть бачити Ваші дані та дії, а також Вашу IP-адресу. Якщо Ви входите до свого облікового запису, використовуючи проксі-сайт, його творці можуть бачити Ваш пароль."
diff --git a/config/locales/views/zh-CN.yml b/config/locales/views/zh-CN.yml
new file mode 100755
index 0000000..3022729
--- /dev/null
+++ b/config/locales/views/zh-CN.yml
@@ -0,0 +1,6 @@
+zh-CN:
+ layouts:
+ proxy_notice:
+ faux_heading: "重要提示:"
+ point1: "您使用的是第三方开发的反向代理网站,此网站并非Archive of Our Own - AO3(AO3作品库)原站。"
+ point2: "代理网站的开发者能够获取您上传至该站点的全部内容,包括您的ip地址。如您通过代理登录AO3,对方将获得您的密码。"
diff --git a/config/redis-cucumber.conf.example b/config/redis-cucumber.conf.example
new file mode 100755
index 0000000..e72c61e
--- /dev/null
+++ b/config/redis-cucumber.conf.example
@@ -0,0 +1,14 @@
+# RAILS_ROOT/config/redis-cucumber.conf
+
+daemonize yes
+pidfile log/redis-cucumber.pid
+port 6398
+timeout 300
+save 900 1
+save 300 10
+save 60 10000
+dbfilename redis-cucumber-dump.rdb
+dir .
+loglevel debug
+logfile stdout
+databases 16
diff --git a/config/redis.example b/config/redis.example
new file mode 100755
index 0000000..a51967d
--- /dev/null
+++ b/config/redis.example
@@ -0,0 +1,24 @@
+redis_autocomplete:
+ test: dev.ao3.org:6398
+ development: dev.ao3.org:6379
+ production: redis.ao3.org:6379
+redis_general:
+ test: dev.ao3.org:6398
+ development: dev.ao3.org:6379
+ production: redis.ao3.org:6379
+redis_hits:
+ test: dev.ao3.org:6398
+ development: dev.ao3.org:6379
+ production: redis.ao3.org:6379
+redis_kudos:
+ test: dev.ao3.org:6398
+ development: dev.ao3.org:6379
+ production: redis.ao3.org:6379
+redis_resque:
+ test: dev.ao3.org:6398
+ development: dev.ao3.org:6379
+ production: redis.ao3.org:6379
+redis_rollout:
+ test: dev.ao3.org:6398
+ development: dev.ao3.org:6379
+ production: redis.ao3.org:6379
diff --git a/config/resque_schedule.yml b/config/resque_schedule.yml
new file mode 100755
index 0000000..98da4b2
--- /dev/null
+++ b/config/resque_schedule.yml
@@ -0,0 +1,117 @@
+run_main_reindex_queues:
+ every: 1m
+ class: "ScheduledReindexJob"
+ queue: utilities
+ args: main
+ description: "Kick off a reindex of all main content indexing"
+
+run_background_reindex_queue:
+ every: 11m
+ class: "ScheduledReindexJob"
+ queue: utilities
+ args: background
+ description: "Kick off a reindex of all background reindexes"
+
+run_add_counts_to_queue:
+ every: 30m
+ class: "ScheduledTagJob"
+ queue: utilities
+ args: add_counts_to_queue
+ description: "update the cache of counts of usage for tags"
+
+# from https://github.com/resque/resque-scheduler/issues/613#issuecomment-351484064
+run_write_redis_to_database:
+ every: 2m
+ class: ActiveJob::QueueAdapters::ResqueAdapter::JobWrapper
+ queue: tag_counts
+ args:
+ job_class: RedisJobSpawner
+ queue_name: tag_counts
+ arguments: ["TagCountUpdateJob"]
+ description: "Flush the count updates to mysql"
+
+run_stats_reindex_queue:
+ every: 30m
+ class: "ScheduledReindexJob"
+ queue: utilities
+ args: stats
+ description: "Kick off a reindex of works with stats updates"
+
+run_update_filter_counts_small:
+ every: 2m
+ class: "FilterCount"
+ queue: utilities
+ args: update_counts_for_small_queue
+ description: "Update filter counts for small filters"
+
+run_update_filter_counts_large:
+ every: 1h
+ class: "FilterCount"
+ queue: utilities
+ args: update_counts_for_large_queue
+ description: "Update filter counts for large filters"
+
+# from https://github.com/resque/resque-scheduler/issues/613#issuecomment-351484064
+update_stat_counters:
+ cron: "0,30 * * * *"
+ class: ActiveJob::QueueAdapters::ResqueAdapter::JobWrapper
+ queue: stats
+ args:
+ job_class: RedisJobSpawner
+ queue_name: stats
+ arguments: ["StatCounterJob"]
+ description: "Update kudos/bookmark/comment counts on StatCounters."
+
+# from https://github.com/resque/resque-scheduler/issues/613#issuecomment-351484064
+save_recent_counts_to_database:
+ cron: "15,45 * * * *"
+ class: ActiveJob::QueueAdapters::ResqueAdapter::JobWrapper
+ queue: hits
+ args:
+ job_class: RedisJobSpawner
+ queue_name: hits
+ arguments: ["HitCountUpdateJob"]
+ description: "Save recent hit counts to database."
+
+remove_old_hit_count_data:
+ cron: "0 12 * * *"
+ class: "RedisHitCounter"
+ queue: hits
+ args: remove_old_visits
+ description: "Remove old hit count information from redis."
+
+check_invite_queue:
+ every: 1h
+ class: "AdminSetting"
+ queue: utilities
+ args: check_queue
+ description: "Invite users from the queue if it's time to do so."
+
+# from https://github.com/resque/resque-scheduler/issues/613#issuecomment-351484064
+readings_to_database:
+ cron: "55 * * * *"
+ class: ActiveJob::QueueAdapters::ResqueAdapter::JobWrapper
+ queue: readings
+ args:
+ job_class: RedisJobSpawner
+ queue_name: readings
+ arguments: ["ReadingsJob"]
+ description: "Transfer readings from redis to the database."
+
+cleanup_work_original_creators:
+ every: 1h
+ class: "WorkOriginalCreator"
+ queue: utilities
+ args: cleanup
+ description: >-
+ Remove original_creators for works orphaned/moved more than
+ ORIGINAL_CREATOR_TTL_HOURS hours ago.
+
+disable_admin_post_comments:
+ every: 1d
+ class: "AdminPost"
+ queue: utilities
+ args: disable_old_post_comments
+ description: >-
+ Disables all comments on admin (news) posts older than the
+ configured window.
diff --git a/config/routes.rb b/config/routes.rb
new file mode 100755
index 0000000..3a34eea
--- /dev/null
+++ b/config/routes.rb
@@ -0,0 +1,718 @@
+Rails.application.routes.draw do
+ devise_scope :admin do
+ get "admin/logout" => "admin/sessions#confirm_logout"
+
+ # Rails emulates some HTTP methods over POST, so password resets (PUT /admin/password)
+ # look the same as password reset requests (POST /admin/password).
+ #
+ # To rate limit them differently at nginx, we set up an alias for
+ # the first request type.
+ put "admin/password/reset" => "admin/passwords#update"
+ end
+
+ devise_for :admin,
+ module: "admin",
+ only: [:sessions, :passwords],
+ controllers: {
+ sessions: "admin/sessions",
+ passwords: "admin/passwords"
+ },
+ path_names: {
+ sign_in: "login",
+ sign_out: "logout"
+ }
+
+ devise_scope :user do
+ get "signup(/:invitation_token)" => "users/registrations#new", as: "signup"
+ get "users/logout" => "users/sessions#confirm_logout"
+
+ # Rails emulate some HTTP methods over POST, so password resets (PUT /users/password)
+ # look the same as password reset requests (POST /users/password).
+ #
+ # To rate limit them differently at nginx, we set up an alias for
+ # the first request type.
+ put "users/password/reset" => "users/passwords#update"
+ end
+
+ devise_for :users,
+ module: "users",
+ controllers: {
+ sessions: "users/sessions",
+ registrations: "users/registrations",
+ passwords: "users/passwords"
+ },
+ path_names: {
+ sign_in: "login",
+ sign_out: "logout"
+ }
+
+ #### ERRORS ####
+
+ get '/403', to: 'errors#403'
+ get '/404', to: 'errors#404'
+ get '/422', to: 'errors#422'
+ get '/500', to: 'errors#500'
+ get '/auth_error', to: 'errors#auth_error'
+ get "/timeout_error", to: "errors#timeout_error"
+ get '/statuses', to: 'statuses#redirect_to_current_user'
+ get '/statuses/new', to: 'statuses#redirect_to_current_user_new' #### DOWNLOADS ####
+
+ get 'downloads/:id/:download_title.:format' => 'downloads#show', as: 'download'
+
+ #### OPEN DOORS ####
+ namespace :opendoors do
+ resources :tools, only: [:index] do
+ collection do
+ post :url_update
+ end
+ end
+ resources :external_authors do
+ member do
+ post :forward
+ end
+ end
+ end
+
+ #### INVITATIONS ####
+
+ resources :invitations
+ resources :user_invite_requests
+ resources :invite_requests, only: [:index, :create, :destroy] do
+ collection do
+ get :manage
+ get :status
+ post :resend
+ end
+ end
+
+ get 'claim/:invitation_token' => 'external_authors#claim', as: 'claim'
+ post 'complete_claim/:invitation_token' => 'external_authors#complete_claim', as: 'complete_claim'
+
+ #### TAGS ####
+
+ resources :media do
+ resources :fandoms
+ end
+ resources :fandoms do
+ collection do
+ get :unassigned
+ end
+ get :show
+ end
+ resources :tag_wranglings do
+ collection do
+ post :wrangle
+ end
+ end
+ resources :tag_wranglers do
+ member do
+ get :report_csv
+ end
+ end
+ resources :unsorted_tags do
+ collection do
+ post :mass_update
+ end
+ end
+ resources :tags do
+ member do
+ get :feed
+ post :mass_update
+ get :remove_association
+ get :wrangle
+ end
+ collection do
+ get :show_hidden
+ get :search
+ end
+ resources :works
+ resources :bookmarks
+ resources :comments
+ resource :troubleshooting, controller: :troubleshooting, only: [:show, :update]
+ end
+
+ resources :tag_sets, controller: 'owned_tag_sets' do
+ resources :nominations, controller: 'tag_set_nominations' do
+ collection do
+ put :update_multiple
+ delete :destroy_multiple
+ get :confirm_destroy_multiple
+ end
+ member do
+ get :confirm_delete
+ end
+ end
+ resources :associations, controller: 'tag_set_associations', only: [:index] do
+ collection do
+ put :update_multiple
+ end
+ end
+ member do
+ get :batch_load
+ put :do_batch_load
+ get :confirm_delete
+ end
+ collection do
+ get :show_options
+ end
+ end
+ resources :tag_nominations, only: [:update]
+
+ resources :tag_wrangling_requests, only: [:index] do
+ collection do
+ patch :update_multiple
+ end
+ end
+
+ #### ADMIN ####
+ resources :admin_posts do
+ resources :comments do
+ collection do
+ get :unreviewed
+ put :review_all
+ end
+ end
+ end
+
+ namespace :admin do
+ resources :activities, only: [:index, :show]
+ resources :banners do
+ member do
+ get :confirm_delete
+ end
+ end
+ resources :blacklisted_emails, only: [:index, :create, :destroy]
+ resources :settings
+ resources :skins do
+ collection do
+ get :index_rejected
+ get :index_approved
+ end
+ end
+ resources :spam, only: [:index] do
+ collection do
+ post :bulk_update
+ end
+ end
+ resources :user_creations, only: [:destroy] do
+ member do
+ put :hide
+ put :set_spam
+ get :confirm_remove_pseud
+ put :remove_pseud
+ end
+ end
+ resources :users, controller: "admin_users", only: [:index, :show] do
+ member do
+ get :confirm_delete_user_creations
+ post :destroy_user_creations
+ post :activate
+ get :check_user
+ get :creations
+ end
+ collection do
+ get :bulk_search
+ post :bulk_search
+ post :update
+ post :update_status
+ post :update_next_of_kin
+ end
+ end
+ resources :invitations, controller: 'admin_invitations' do
+ collection do
+ post :invite_from_queue
+ post :grant_invites_to_users
+ get :find
+ end
+ end
+ resources :api
+ end
+ resources :admins, only: [:index]
+
+ post '/admin/api/new', to: 'admin/api#create'
+
+ #### USERS ####
+
+ resources :people, only: [:index] do
+ collection do
+ get :search
+ end
+ end
+
+ # When adding new nested resources, please keep them in alphabetical order
+ resources :users, except: [:new, :create] do
+ member do
+ get :change_email
+ put :confirm_change_email
+ post :changed_email
+ get :change_password
+ post :changed_password
+ get :change_username
+ post :changed_username
+ post :end_first_login
+ post :end_banner
+ post :end_tos_prompt
+ get :reconfirm_email
+ end
+ resources :assignments, controller: "challenge_assignments", only: [:index]
+ resources :claims, controller: "challenge_claims", only: [:index]
+ resources :bookmarks
+ resources :collection_items, only: [:index, :update] do
+ collection do
+ patch :update_multiple
+ end
+ end
+ resources :collections, only: [:index]
+ resources :comments do
+ member do
+ put :approve
+ put :reject
+ end
+ end
+ resource :creatorships, controller: "creatorships", only: [:show, :update]
+ resources :external_authors do
+ resources :external_author_names
+ end
+ resources :favorite_tags, only: [:create, :destroy]
+ resources :gifts, only: [:index]
+ resource :inbox, controller: "inbox" do
+ member do
+ get :reply
+ post :delete
+ end
+ end
+ resources :invitations do
+ collection do
+ post :invite_friend
+ end
+ collection do
+ get :manage
+ end
+ end
+ resources :nominations, controller: "tag_set_nominations", only: [:index]
+ resources :preferences, only: [:index, :update]
+ resource :profile, only: [:show], controller: "profile" do
+ collection do
+ get :pseuds
+ end
+ end
+ resources :pseuds do
+ resources :works
+ resources :series
+ resources :bookmarks
+ end
+ resources :readings do
+ collection do
+ post :clear
+ end
+ end
+ resources :related_works
+ resources :series do
+ member do
+ get :manage
+ end
+ resources :serial_works
+ end
+ resources :signups, controller: "challenge_signups", only: [:index]
+ resources :skins, only: [:index]
+ resources :stats, only: [:index]
+ resources :statuses
+ resources :subscriptions, only: [:index, :create, :destroy] do
+ collection do
+ get :confirm_delete_all
+ post :delete_all
+ end
+ end
+ resources :tag_sets, controller: "owned_tag_sets", only: [:index]
+ resources :works do
+ collection do
+ get :drafts
+ get :collected
+ get :show_multiple
+ post :edit_multiple
+ patch :update_multiple
+ post :delete_multiple
+ end
+ end
+ namespace :blocked do
+ resources :users, only: [:index, :create, :destroy] do
+ collection do
+ get :confirm_block
+ end
+ member do
+ get :confirm_unblock
+ end
+ end
+ end
+ namespace :muted do
+ resources :users, only: [:index, :create, :destroy] do
+ collection do
+ get :confirm_mute
+ end
+ member do
+ get :confirm_unmute
+ end
+ end
+ end
+ end
+
+ #### WORKS ####
+
+ resources :works do
+ collection do
+ post :import
+ get :search
+ end
+ member do
+ get :preview
+ post :post
+ put :post_draft
+ get :navigate
+ get :edit_tags
+ get :preview_tags
+ patch :update_tags
+ get :mark_for_later
+ get :mark_as_read
+ get :confirm_delete
+ get :share
+ end
+ resources :bookmarks
+ resources :chapters do
+ collection do
+ get :manage
+ post :update_positions
+ end
+ member do
+ get :preview
+ post :post
+ get :confirm_delete
+ end
+ resources :comments
+ end
+ resources :collections
+ resources :collection_items
+ resources :comments do
+ member do
+ put :approve
+ put :reject
+ end
+ collection do
+ get :unreviewed
+ put :review_all
+ end
+ end
+ resource :hit_count, controller: :hit_count, only: [:create]
+ resources :kudos, only: [:index]
+ resource :troubleshooting, controller: :troubleshooting, only: [:show, :update]
+ end
+
+ resources :chapters do
+ member do
+ get :preview
+ post :post
+ end
+ resources :comments
+ end
+
+ resources :external_works do
+ collection do
+ get :fetch
+ end
+ resources :bookmarks
+ resources :related_works
+ end
+
+ resources :related_works
+ resources :serial_works
+ resources :series do
+ member do
+ get :confirm_delete
+ get :manage
+ post :update_positions
+ end
+ resources :bookmarks
+ end
+
+ #### COLLECTIONS ####
+
+ resources :gifts, only: [:index] do
+ member do
+ post :toggle_rejected
+ end
+ end
+
+ resources :collections do
+ collection do
+ get :list_challenges
+ get :list_ge_challenges
+ get :list_pm_challenges
+ end
+ member do
+ get :confirm_delete
+ end
+ resource :profile, controller: "collection_profile"
+ resources :collections
+ resources :works
+ resources :gifts
+ resources :bookmarks
+ resources :media
+ resources :fandoms
+ resources :people
+ resources :prompts
+ resources :tags do
+ resources :works
+ end
+ resources :participants, controller: "collection_participants", only: [:index, :update, :destroy] do
+ collection do
+ get :add
+ get :join
+ end
+ end
+ resources :items, controller: "collection_items" do
+ collection do
+ patch :update_multiple
+ end
+ end
+ resources :signups, controller: "challenge_signups" do
+ collection do
+ get :summary
+ end
+ member do
+ get :confirm_delete
+ end
+ end
+ resources :assignments, controller: "challenge_assignments", only: [:index, :show] do
+ collection do
+ get :confirm_purge
+ get :generate
+ put :set
+ post :purge
+ get :send_out
+ put :update_multiple
+ get :default_all
+ end
+ member do
+ get :default
+ end
+ end
+ resources :claims, controller: "challenge_claims" do
+ collection do
+ patch :set
+ get :purge
+ end
+ end
+ resources :potential_matches do
+ collection do
+ get :generate
+ get :cancel_generate
+ get :regenerate_for_signup
+ end
+ end
+ resources :requests, controller: "challenge_requests"
+ # challenge types
+ resource :gift_exchange, controller: "challenge/gift_exchange" do
+ member do
+ get :confirm_delete
+ end
+ end
+ resource :prompt_meme, controller: "challenge/prompt_meme" do
+ member do
+ get :confirm_delete
+ end
+ end
+ end
+
+ #### I18N ####
+
+ # should stay below the main works mapping
+ resources :languages, except: [:show] do
+ resources :works
+ resources :admin_posts
+ end
+ get "/languages/:id", to: redirect("/languages/%{id}/works", status: 302)
+ resources :locales, except: :destroy
+
+ #### API ####
+
+ namespace :api do
+ namespace :v2 do
+ resources :bookmarks, only: [:create], defaults: { format: :json }
+ resources :works, only: [:create], defaults: { format: :json }
+ post 'bookmarks/search', to: 'bookmarks#search'
+ post 'works/search', to: 'works#search'
+ end
+ end
+
+ #### MISC ####
+
+ resources :comments do
+ member do
+ put :approve
+ put :freeze
+ put :hide
+ put :reject
+ put :review
+ put :unfreeze
+ put :unhide
+ end
+ collection do
+ get :hide_comments
+ get :show_comments
+ get :add_comment_reply
+ get :cancel_comment_reply
+ get :cancel_comment_edit
+ get :delete_comment
+ get :cancel_comment_delete
+ end
+ resources :comments
+ end
+ resources :bookmarks do
+ collection do
+ get :search
+ end
+ member do
+ get :confirm_delete
+ get :share
+ end
+ resources :collection_items
+ end
+
+ resources :kudos, only: [:create]
+
+ resources :skins do
+ member do
+ get :preview
+ get :set
+ get :confirm_delete
+ end
+ collection do
+ get :unset
+ end
+ end
+ resources :known_issues
+ resources :archive_faqs, path: "faq" do
+ member do
+ get :confirm_delete
+ end
+ collection do
+ get :manage
+ post :update_positions
+ end
+ resources :questions do
+ collection do
+ get :manage
+ post :update_positions
+ end
+ end
+ end
+ resources :wrangling_guidelines do
+ member do
+ get :confirm_delete
+ end
+ collection do
+ get :manage
+ post :update_positions
+ end
+ end
+
+ resource :redirect, controller: "redirect", only: [:show] do
+ member do
+ get :do_redirect
+ end
+ end
+
+ resources :abuse_reports, only: [:new, :create]
+ resources :external_authors do
+ resources :external_author_names
+ end
+ resources :orphans, only: [:index, :new, :create]
+
+ get 'search' => 'works#search'
+ post 'support' => 'feedbacks#create', as: 'feedbacks'
+ get 'support' => 'feedbacks#new', as: 'new_feedback_report'
+ get "content" => "home#content"
+ get "privacy" => "home#privacy"
+ get "tos" => "home#tos"
+ get "tos_faq" => "home#tos_faq"
+ get 'unicorn_test' => 'home#unicorn_test'
+ get 'dmca' => 'home#dmca'
+ get 'diversity' => 'home#diversity'
+ get 'site_map' => 'home#site_map'
+ get 'site_pages' => 'home#site_pages'
+ get 'first_login_help' => 'home#first_login_help'
+ get 'token_dispenser' => 'home#token_dispenser'
+ get 'delete_confirmation' => 'users#delete_confirmation'
+ get 'activate/:id' => 'users#activate', as: 'activate'
+ get 'devmode' => 'devmode#index'
+ get 'donate' => 'home#donate'
+ get 'lost_cookie' => 'home#lost_cookie'
+ get 'about' => 'home#about'
+ get 'menu/browse' => 'menu#browse'
+ get 'menu/fandoms' => 'menu#fandoms'
+ get 'menu/search' => 'menu#search'
+ get 'menu/about' => 'menu#about'
+
+ # The priority is based upon order of creation:
+ # first created -> highest priority.
+ root to: "home#index"
+
+ # See how all your routes lay out with "rake routes"
+
+ # These are allowlisted routes that are proven to be used throughout the
+ # application, which previously relied on a deprecated catch-all route definition
+ # (`get ':controller(/:action(/:id(.:format)))'`) to work.
+ #
+ # They are generally not RESTful and in some cases are *almost* duplicates of
+ # existing routes defined above, but due to how extensively they are used
+ # throughout the application must exist until forms, controllers, and tests
+ # can be refactored to not rely on their existence.
+ #
+ # Note written on August 1, 2017 during upgrade to Rails 5.1.
+ get '/invite_requests/show' => 'invite_requests#show', as: :show_invite_request
+ get '/user_invite_requests/update' => 'user_invite_requests#update'
+
+ patch '/admin/skins/update' => 'admin_skins#update', as: :update_admin_skin
+
+ get "/admin/admin_users/troubleshoot/:id" => "admin/admin_users#troubleshoot", as: :troubleshoot_admin_user
+
+ # TODO: rewrite the autocomplete controller to deal with the fact that
+ # there are 21 different actions going on in there
+ %w[
+ pseud
+ tag
+ fandom
+ character
+ relationship
+ freeform
+ character_in_fandom
+ relationship_in_fandom
+ tags_in_sets
+ associated_tags
+ noncanonical_tag
+ collection_fullname
+ open_collection_names
+ collection_parent_name
+ external_work
+ potential_offers
+ potential_requests
+ owned_tag_sets
+ site_skins
+ admin_posts
+ admin_post_tags
+ ].each do |action|
+ get "/autocomplete/#{action}" => "autocomplete##{action}"
+ end
+ get 'timeline', to: 'statuses#timeline', as: :timeline
+ get '/challenges/no_collection' => 'challenges#no_collection'
+ get '/challenges/no_challenge' => 'challenges#no_challenge'
+
+ get '/works/clean_work_search_params' => 'works#clean_work_search_params'
+ get '/works/collected' => 'works#collected'
+ get '/works/drafts' => 'works#drafts'
+
+ post '/works/edit_multiple/:id' => 'works#edit_multiple'
+ post '/works/confirm_delete_multiple/:id' => 'works#confirm_delete_multiple'
+ post '/works/delete_multiple/:id' => 'works#delete_multiple'
+ put '/works/update_multiple' => 'works#update_multiple'
+end
+
diff --git a/config/s3.example b/config/s3.example
new file mode 100755
index 0000000..34fc506
--- /dev/null
+++ b/config/s3.example
@@ -0,0 +1,4 @@
+bucket: mybucket
+access_key_id: myaccesskey
+secret_access_key: mysecret
+s3_region: us-east-1
diff --git a/config/schedule.rb b/config/schedule.rb
new file mode 100755
index 0000000..d90fcf2
--- /dev/null
+++ b/config/schedule.rb
@@ -0,0 +1,65 @@
+# Use this file to easily define all of your cron jobs.
+#
+# It's helpful, but not entirely necessary to understand cron before proceeding.
+# http://en.wikipedia.org/wiki/Cron
+
+# Example:
+#
+# set :cron_log, "/path/to/my/cron_log.log"
+#
+# every 2.hours do
+# command "/usr/bin/some_great_command"
+# runner "MyModel.some_method"
+# rake "some:great:rake:task"
+# end
+#
+# every 4.days do
+# runner "AnotherModel.prune_old_records"
+# end
+
+# Learn more: http://github.com/javan/whenever
+
+# set your path in cron before the whenever section and use --update-crontab
+set :set_path_automatically, false
+
+set :cron_log, "#{path}/log/whenever.log"
+
+# put a timestamp in the whenever log
+every 1.days, at: 'midnight' do
+ command "date"
+end
+
+# Purge user accounts that haven't been activated
+every 1.days, at: '6:31 am' do
+ rake "admin:purge_unvalidated_users"
+end
+
+# Unsuspend selected users
+every 1.day, at: '6:51 am' do
+ rake "admin:unsuspend_users"
+end
+
+# Delete unused tags
+every 1.day, at: '7:10 am' do
+ rake "Tag:delete_unused"
+end
+
+# Delete old drafts
+every 1.day, at: '7:40 am' do
+ rake "work:purge_old_drafts"
+end
+
+# Send kudos notifications
+every 1.day, at: '10:00 am' do
+ rake "notifications:deliver_kudos"
+end
+
+# Send subscription notifications
+every 1.hour do
+ rake "notifications:deliver_subscriptions"
+end
+
+# Rerun redis jobs
+every 10.minutes do
+ rake "resque:run_failures"
+end
diff --git a/config/schedule_production.rb b/config/schedule_production.rb
new file mode 100755
index 0000000..848ec7b
--- /dev/null
+++ b/config/schedule_production.rb
@@ -0,0 +1,16 @@
+set :set_path_automatically, false
+set :cron_log, "#{path}/log/whenever.log"
+
+# Resend signup emails
+every 1.day, at: '6:41 am' do
+ rake "admin:resend_signup_emails"
+end
+# Send kudos notifications
+every 1.day, at: '10:00 am' do
+ rake "notifications:deliver_kudos"
+end
+
+# Send subscription notifications
+every 1.hour do
+ rake "notifications:deliver_subscriptions"
+end
diff --git a/config/servers.yml b/config/servers.yml
new file mode 100755
index 0000000..0526624
--- /dev/null
+++ b/config/servers.yml
@@ -0,0 +1,76 @@
+# Custom YAML configuration format for server objects in Capistrano per deploy stage.
+#
+# For example, this YAML snippet:
+#
+# production:
+# - host: ao3-app15
+# roles: [app]
+# options:
+# primary: true
+#
+# is the same as adding this declaration in config/deploy/production.rb:
+#
+# server "ao3-app15", :app, primary: true
+#
+# The :primary attribute is used for tasks we only want to run on one machine.
+# Refer to https://capistranorb.com/documentation/advanced-features/properties/.
+
+production:
+ - host: ao3-app01
+ roles: [app, db, schedulers]
+ - host: ao3-app09
+ roles: [app]
+ - host: ao3-app14
+ roles: [app]
+ - host: ao3-app15
+ roles: [app]
+ options:
+ primary: true
+ - host: ao3-app16
+ roles: [app]
+ - host: ao3-app17
+ roles: [app, workers, schedulers]
+ - host: ao3-app18
+ roles: [app, workers, schedulers]
+ - host: ao3-app19
+ roles: [app]
+ - host: ao3-app20
+ roles: [app]
+ - host: ao3-app21
+ roles: [app]
+ - host: ao3-app22
+ roles: [app]
+ - host: ao3-app23
+ roles: [app]
+ - host: ao3-app24
+ roles: [app, workers, schedulers]
+ - host: ao3-app25
+ roles: [app, workers, schedulers]
+ - host: ao3-app26
+ roles: [app, workers, schedulers]
+ - host: ao3-app27
+ roles: [app, workers, schedulers]
+ - host: ao3-app28
+ roles: [app, workers, schedulers]
+ - host: ao3-front07
+ roles: [web]
+ - host: ao3-front08
+ roles: [web]
+ - host: ao3-front09
+ roles: [web]
+ - host: ao3-front10
+ roles: [web]
+
+staging:
+ - host: test-app13
+ roles: [app, db, schedulers]
+ - host: test-app14
+ roles: [app, schedulers]
+ - host: test-app15
+ roles: [app, workers, schedulers]
+ options:
+ primary: true
+ - host: test-front11
+ roles: [web]
+ - host: test-front12
+ roles: [web]
diff --git a/config/storage.yml b/config/storage.yml
new file mode 100755
index 0000000..d3f05f1
--- /dev/null
+++ b/config/storage.yml
@@ -0,0 +1,17 @@
+test:
+ service: Disk
+ root: <%= Rails.root.join("tmp/storage") %>
+
+local:
+ service: Disk
+ root: <%= Rails.root.join("storage") %>
+
+# Example AWS S3 configuration for ActiveStorage.
+# This should be overridden by config/storage/.yml
+# s3:
+ # service: S3
+ # access_key_id: ""
+ # secret_access_key: ""
+ # region: ""
+ # bucket: ""
+ # public: true
diff --git a/docker-compose-example.yml b/docker-compose-example.yml
new file mode 100755
index 0000000..9083c94
--- /dev/null
+++ b/docker-compose-example.yml
@@ -0,0 +1,106 @@
+version: "3"
+volumes:
+ esdata1:
+ redisdata1:
+services:
+ db:
+ image: mariadb:10.5.4-focal
+ environment:
+ - MYSQL_ROOT_PASSWORD= change this
+ ports:
+ - "3306:3306"
+ command:
+ [
+ "mysqld",
+ "--sql-mode=NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION",
+ ]
+ volumes:
+ - .:/var/lib/mysql:rw
+ redis:
+ image: redis:5
+ ports:
+ - "6379:6379"
+ volumes:
+ - .:/var/lib/redis:rw
+ es:
+ image: docker.elastic.co/elasticsearch/elasticsearch:9.1.3
+ ports:
+ - "9200:9200"
+ - "9300:9300"
+ - "9400:9400"
+ volumes:
+ - .:/usr/share/elasticsearch/data:rw
+ environment:
+ - RAILS_ENV=production
+ - cluster.name=change_me
+ - node.name=change_me-node
+ - http.port=9200
+ - bootstrap.memory_lock=false
+ - "ES_JAVA_OPTS=-Xms4g -Xmx4g"
+ - discovery.type=single-node
+ # Silence "security features are not enabled" warnings
+ # https://github.com/elastic/elasticsearch/issues/78500
+ - xpack.security.enabled=false
+ - network.host=0.0.0.0
+ - transport.host=0.0.0.0
+ - http.host=0.0.0.0
+ mc:
+ image: memcached:1.5
+ ports:
+ - "11211:11211"
+ web:
+ links:
+ - "es:elastic"
+ profiles:
+ - dev
+ build:
+ context: .
+ dockerfile: ./config/docker/Dockerfile
+ environment:
+ - RAILS_ENV=production
+ - ES_URL=es:9200
+ command: bash -c "rm -f tmp/pids/server.pid && RAILS_ENV=production bundle exec rails server -p 3345 -b 0.0.0.0"
+ volumes:
+ - ./bundler_gems:/usr/local/bundle/
+ - .:/otwa
+ ports:
+ - "3345:3345"
+ depends_on:
+ - db
+ - redis
+ - es
+ - mc
+ worker:
+ build:
+ context: .
+ dockerfile: ./config/docker/Dockerfile
+ environment:
+ - RAILS_ENV=production
+ - REDIS_URL=redis://redis:6379
+ volumes:
+ - ./bunder_gems:/usr/local/bundle/
+ - .:/otwa
+ depends_on:
+ - db
+ - redis
+ - es
+ - mc
+ entrypoint: ./entrypoints/worker-entrypoint.sh
+ sc:
+ build:
+ context: .
+ dockerfile: ./config/docker/Dockerfile
+ environment:
+ - RAILS_ENV=production
+ - REDIS_URL=redis://redis:6379
+ volumes:
+ - ./bunder_gems:/usr/local/bundle/
+ - .:/otwa
+ depends_on:
+ - db
+ - redis
+ - es
+ - mc
+ - worker
+ entrypoint: ./entrypoints/sc-entrypoint.sh
+
diff --git a/entrypoints/sc-entrypoint.sh b/entrypoints/sc-entrypoint.sh
new file mode 100755
index 0000000..395b576
--- /dev/null
+++ b/entrypoints/sc-entrypoint.sh
@@ -0,0 +1,11 @@
+#!/bin/bash
+
+ set -e
+ bundle install
+
+ if [ -f tmp/pids/server.pid ]; then
+ rm tmp/pids/server.pid
+ fi
+
+ bundle exec rake resque:scheduler
+
diff --git a/entrypoints/worker-entrypoint.sh b/entrypoints/worker-entrypoint.sh
new file mode 100755
index 0000000..bd79c60
--- /dev/null
+++ b/entrypoints/worker-entrypoint.sh
@@ -0,0 +1,11 @@
+#!/bin/bash
+
+ set -e
+ bundle install
+
+ if [ -f tmp/pids/server.pid ]; then
+ rm tmp/pids/server.pid
+ fi
+
+ QUEUE="*" bundle exec rake environment resque:work
+
diff --git a/factories/abuse_reports.rb b/factories/abuse_reports.rb
new file mode 100755
index 0000000..8e9f748
--- /dev/null
+++ b/factories/abuse_reports.rb
@@ -0,0 +1,10 @@
+require 'faker'
+FactoryBot.define do
+ factory :abuse_report do
+ email { Faker::Internet.email }
+ url { "http://archiveofourown.org/tags/2000%20AD%20(Comics)/works" }
+ comment { Faker::Lorem.paragraph(sentence_count: 1) }
+ summary { Faker::Lorem.sentence(word_count: 1) }
+ language { "Francais" }
+ end
+end
diff --git a/factories/admin.rb b/factories/admin.rb
new file mode 100755
index 0000000..2df48ec
--- /dev/null
+++ b/factories/admin.rb
@@ -0,0 +1,36 @@
+require 'faker'
+
+FactoryBot.define do
+ factory :admin do
+ login { generate(:login) }
+ password { SecureRandom.alphanumeric(10) }
+ password_confirmation { |u| u.password }
+ email { Faker::Internet.unique.email }
+
+ factory :superadmin do
+ roles { ["superadmin"] }
+ end
+
+ factory :policy_and_abuse_admin do
+ roles { ["policy_and_abuse"] }
+ end
+
+ factory :support_admin do
+ roles { ["support"] }
+ end
+
+ factory :tag_wrangling_admin do
+ roles { ["tag_wrangling"] }
+ end
+
+ factory :open_doors_admin do
+ roles { ["open_doors"] }
+ end
+ end
+
+ factory :admin_activity do
+ admin
+ action { "update_tags" }
+ summary { "MyActivity" }
+ end
+end
diff --git a/factories/admin_banner.rb b/factories/admin_banner.rb
new file mode 100755
index 0000000..665131c
--- /dev/null
+++ b/factories/admin_banner.rb
@@ -0,0 +1,13 @@
+require "faker"
+
+FactoryBot.define do
+ factory :admin_banner do
+ sequence(:content) { |n| "#{Faker::Lorem.paragraph} (#{n})" }
+
+ active { false }
+
+ trait :active do
+ active { true }
+ end
+ end
+end
diff --git a/factories/admin_blacklisted_email.rb b/factories/admin_blacklisted_email.rb
new file mode 100755
index 0000000..a0121fa
--- /dev/null
+++ b/factories/admin_blacklisted_email.rb
@@ -0,0 +1,7 @@
+require 'faker'
+
+FactoryBot.define do
+ factory :admin_blacklisted_email do
+ email { Faker::Internet.unique.email }
+ end
+end
diff --git a/factories/admin_post.rb b/factories/admin_post.rb
new file mode 100755
index 0000000..109346b
--- /dev/null
+++ b/factories/admin_post.rb
@@ -0,0 +1,10 @@
+require 'faker'
+
+FactoryBot.define do
+ factory :admin_post do
+ language { Language.default }
+ admin_id { FactoryBot.create(:admin).id }
+ title { "AdminPost Title" }
+ content { "AdminPost content long enough to pass validation" }
+ end
+end
diff --git a/factories/api_key.rb b/factories/api_key.rb
new file mode 100755
index 0000000..c8524f1
--- /dev/null
+++ b/factories/api_key.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+require 'faker'
+
+FactoryBot.define do
+ factory :api_key do
+ name { "API key name" }
+ end
+end
diff --git a/factories/archive_faqs.rb b/factories/archive_faqs.rb
new file mode 100755
index 0000000..f6c08f6
--- /dev/null
+++ b/factories/archive_faqs.rb
@@ -0,0 +1,18 @@
+require "faker"
+
+FactoryBot.define do
+ sequence(:faq_title) do |n|
+ "The #{n} FAQ"
+ end
+
+ factory :archive_faq do
+ title { generate(:faq_title) }
+ end
+
+ factory :question do
+ question { Faker::Lorem.sentence }
+ anchor { Faker::Lorem.word }
+ content { Faker::Lorem.paragraph }
+ screencast { Faker::Internet.url }
+ end
+end
diff --git a/factories/bookmarks.rb b/factories/bookmarks.rb
new file mode 100755
index 0000000..9e94868
--- /dev/null
+++ b/factories/bookmarks.rb
@@ -0,0 +1,19 @@
+require 'faker'
+
+FactoryBot.define do
+ factory :bookmark do
+ bookmarkable_type { "Work" }
+ bookmarkable_id { FactoryBot.create(:work).id }
+ pseud_id { FactoryBot.create(:pseud).id }
+
+ factory :external_work_bookmark do
+ bookmarkable_type { "ExternalWork" }
+ bookmarkable_id { FactoryBot.create(:external_work).id }
+ end
+
+ factory :series_bookmark do
+ bookmarkable_type { "Series" }
+ bookmarkable_id { FactoryBot.create(:series_with_a_work).id }
+ end
+ end
+end
diff --git a/factories/challenge_claims.rb b/factories/challenge_claims.rb
new file mode 100755
index 0000000..14a8723
--- /dev/null
+++ b/factories/challenge_claims.rb
@@ -0,0 +1,5 @@
+require 'faker'
+FactoryBot.define do
+ factory :challenge_claim do
+ end
+end
diff --git a/factories/challenges.rb b/factories/challenges.rb
new file mode 100755
index 0000000..0372abd
--- /dev/null
+++ b/factories/challenges.rb
@@ -0,0 +1,54 @@
+require 'faker'
+FactoryBot.define do
+ factory :challenge_assignment do
+ collection { create(:collection, challenge: create(:gift_exchange)) }
+
+ after(:build) do |assignment|
+ assignment.request_signup = create(:challenge_signup, collection: assignment.collection)
+ assignment.offer_signup = create(:challenge_signup, collection: assignment.collection)
+ end
+ end
+
+ factory :challenge_signup, aliases: [:gift_exchange_signup] do
+ pseud { create(:user).default_pseud }
+ collection { create(:collection, challenge: create(:gift_exchange)) }
+ requests_attributes { [attributes_for(:request)] }
+ offers_attributes { [attributes_for(:offer)] }
+ end
+
+ factory :prompt_meme_signup, class: "ChallengeSignup" do
+ pseud { create(:user).default_pseud }
+ collection { create(:collection, challenge: create(:prompt_meme)) }
+ requests_attributes { [attributes_for(:request)] }
+ end
+
+ factory :potential_match do
+ collection { create(:collection, challenge: create(:gift_exchange)) }
+
+ after(:build) do |potential_match|
+ potential_match.offer_signup = create(:challenge_signup, collection: potential_match.collection)
+ potential_match.request_signup = create(:challenge_signup, collection: potential_match.collection)
+ end
+ end
+
+ factory :gift_exchange do
+ association :offer_restriction, factory: :prompt_restriction
+ association :request_restriction, factory: :prompt_restriction
+
+ trait :open do
+ signups_open_at { Time.now - 1.day }
+ signups_close_at { Time.now + 1.day }
+ signup_open { true }
+ end
+
+ trait :closed do
+ signups_open_at { Time.now - 2.days }
+ signups_close_at { Time.now - 1.day }
+ signup_open { false }
+ end
+ end
+
+ factory :prompt_meme do
+ association :request_restriction, factory: :prompt_restriction
+ end
+end
diff --git a/factories/chapters.rb b/factories/chapters.rb
new file mode 100755
index 0000000..960e307
--- /dev/null
+++ b/factories/chapters.rb
@@ -0,0 +1,24 @@
+require 'faker'
+
+FactoryBot.define do
+ factory :chapter do
+ content { "Awesome content!" }
+ work
+ posted { true }
+
+ transient do
+ authors { work.pseuds }
+ end
+
+ after(:build) do |chapter, evaluator|
+ evaluator.authors.each do |pseud|
+ chapter.creatorships.build(pseud: pseud)
+ end
+ end
+
+ trait :draft do
+ content { "Draft content!" }
+ posted { false }
+ end
+ end
+end
diff --git a/factories/collections.rb b/factories/collections.rb
new file mode 100755
index 0000000..12e3ed5
--- /dev/null
+++ b/factories/collections.rb
@@ -0,0 +1,60 @@
+require "faker"
+
+FactoryBot.define do
+ sequence(:collection_name) do |n|
+ "basic_collection_#{n}"
+ end
+
+ sequence(:collection_title) do |n|
+ "Basic Collection #{n}"
+ end
+
+ factory :collection_participant do
+ pseud
+ participant_role { "Owner" }
+ end
+
+ factory :collection_preference do |f|
+ end
+
+ factory :collection_profile do |f|
+ end
+
+ factory :collection do |f|
+ name { generate(:collection_name) }
+ title { generate(:collection_title) }
+
+ transient do
+ owner { build(:pseud) }
+ end
+
+ after(:build) do |collection, evaluator|
+ collection.collection_participants.build(pseud: evaluator.owner, participant_role: "Owner")
+ end
+
+ factory :anonymous_collection do
+ association :collection_preference, anonymous: true
+ end
+
+ factory :unrevealed_collection do
+ association :collection_preference, unrevealed: true
+ end
+
+ factory :anonymous_unrevealed_collection do
+ association :collection_preference, unrevealed: true, anonymous: true
+ end
+
+ trait :closed do
+ association :collection_preference, closed: true
+ end
+
+ trait :moderated do
+ association :collection_preference, moderated: true
+ end
+ end
+
+ factory :collection_item do
+ item_type { "Work" }
+ collection
+ end
+end
diff --git a/factories/comments.rb b/factories/comments.rb
new file mode 100755
index 0000000..ea3cf7f
--- /dev/null
+++ b/factories/comments.rb
@@ -0,0 +1,37 @@
+require 'faker'
+
+FactoryBot.define do
+ factory :comment do
+ comment_content { Faker::Lorem.sentence(word_count: 25) }
+ commentable { create(:work).last_posted_chapter }
+ pseud { create(:user).default_pseud }
+
+ trait :by_guest do
+ pseud { nil }
+ name { Faker::Name.first_name }
+ email { Faker::Internet.email }
+ end
+
+ trait :on_admin_post do
+ commentable { create(:admin_post, comment_permissions: :enable_all) }
+ end
+
+ trait :on_tag do
+ commentable { create(:fandom) }
+ end
+
+ trait :on_work_with_guest_comments_on do
+ commentable { create(:work, :guest_comments_on).first_chapter }
+ end
+
+ trait :unreviewed do
+ commentable { create(:work, moderated_commenting_enabled: true).last_posted_chapter }
+ unreviewed { true }
+ end
+ end
+
+ factory :inbox_comment do
+ user { create(:user) }
+ feedback_comment { create(:comment) }
+ end
+end
diff --git a/factories/fannish_next_of_kin.rb b/factories/fannish_next_of_kin.rb
new file mode 100755
index 0000000..3dc5a39
--- /dev/null
+++ b/factories/fannish_next_of_kin.rb
@@ -0,0 +1,9 @@
+require "faker"
+
+FactoryBot.define do
+ factory :fannish_next_of_kin do
+ user { create(:user) }
+ kin { create(:user) }
+ kin_email { |u| u.kin.email }
+ end
+end
diff --git a/factories/favorite_tags.rb b/factories/favorite_tags.rb
new file mode 100755
index 0000000..21eb904
--- /dev/null
+++ b/factories/favorite_tags.rb
@@ -0,0 +1,8 @@
+require "faker"
+
+FactoryBot.define do
+ factory :favorite_tag do
+ tag_id { FactoryBot.create(:canonical_freeform).id }
+ user_id { FactoryBot.create(:user).id }
+ end
+end
diff --git a/factories/feedback.rb b/factories/feedback.rb
new file mode 100755
index 0000000..94adc12
--- /dev/null
+++ b/factories/feedback.rb
@@ -0,0 +1,10 @@
+require 'faker'
+
+FactoryBot.define do
+ factory :feedback do
+ comment { Faker::Lorem.paragraph(sentence_count: 1) }
+ email { Faker::Internet.email }
+ summary { Faker::Lorem.sentence(word_count: 1) }
+ language { "English" }
+ end
+end
diff --git a/factories/gift.rb b/factories/gift.rb
new file mode 100755
index 0000000..4902540
--- /dev/null
+++ b/factories/gift.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+require "faker"
+
+FactoryBot.define do
+ factory :gift do
+ user { create(:user) }
+ work { create(:work) }
+ end
+end
diff --git a/factories/invitations.rb b/factories/invitations.rb
new file mode 100755
index 0000000..58ff7e5
--- /dev/null
+++ b/factories/invitations.rb
@@ -0,0 +1,13 @@
+require "faker"
+
+FactoryBot.define do
+ factory :invite_request do
+ sequence :email do |n|
+ Faker::Internet.email(name: "#{Faker::Name.first_name}_#{n}")
+ end
+ end
+
+ factory :invitation do
+ invitee_email { "default@email.com" }
+ end
+end
diff --git a/factories/known_issues.rb b/factories/known_issues.rb
new file mode 100755
index 0000000..720c348
--- /dev/null
+++ b/factories/known_issues.rb
@@ -0,0 +1,8 @@
+require "faker"
+
+FactoryBot.define do
+ factory :known_issue do
+ title { Faker::Lorem.sentence }
+ content { Faker::Lorem.paragraph }
+ end
+end
diff --git a/factories/kudos.rb b/factories/kudos.rb
new file mode 100755
index 0000000..9a34776
--- /dev/null
+++ b/factories/kudos.rb
@@ -0,0 +1,6 @@
+FactoryBot.define do
+ factory :kudo do
+ commentable { create(:work) }
+ ip_address { Faker::Internet.unique.public_ip_v4_address }
+ end
+end
diff --git a/factories/language.rb b/factories/language.rb
new file mode 100755
index 0000000..b090dfd
--- /dev/null
+++ b/factories/language.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :language do
+ short { "nl" }
+ name { "Dutch" }
+ end
+end
diff --git a/factories/last_wrangling_activity.rb b/factories/last_wrangling_activity.rb
new file mode 100755
index 0000000..7abb163
--- /dev/null
+++ b/factories/last_wrangling_activity.rb
@@ -0,0 +1,7 @@
+require "faker"
+
+FactoryBot.define do
+ factory :last_wrangling_activity do
+ user
+ end
+end
diff --git a/factories/locales.rb b/factories/locales.rb
new file mode 100755
index 0000000..2ac7c25
--- /dev/null
+++ b/factories/locales.rb
@@ -0,0 +1,17 @@
+require "faker"
+
+FactoryBot.define do
+ sequence(:locale_iso) do |n|
+ "en-WAT#{n}"
+ end
+
+ sequence(:locale_name) do |n|
+ "Locale #{n}"
+ end
+
+ factory :locale do
+ language { Language.default }
+ iso { generate(:locale_iso) }
+ name { generate(:locale_name) }
+ end
+end
diff --git a/factories/prompt.rb b/factories/prompt.rb
new file mode 100755
index 0000000..075d6a0
--- /dev/null
+++ b/factories/prompt.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+require 'faker'
+
+FactoryBot.define do
+ factory :prompt do
+ description { Faker::Lorem.sentence }
+ end
+
+ factory :request, parent: :prompt, class: "Request"
+ factory :offer, parent: :prompt, class: "Offer"
+end
diff --git a/factories/prompt_restriction.rb b/factories/prompt_restriction.rb
new file mode 100755
index 0000000..231a592
--- /dev/null
+++ b/factories/prompt_restriction.rb
@@ -0,0 +1,6 @@
+require 'faker'
+
+FactoryBot.define do
+ factory :prompt_restriction do
+ end
+end
diff --git a/factories/pseuds.rb b/factories/pseuds.rb
new file mode 100755
index 0000000..30ac3a0
--- /dev/null
+++ b/factories/pseuds.rb
@@ -0,0 +1,8 @@
+require 'faker'
+
+FactoryBot.define do
+ factory :pseud do
+ name { Faker::Lorem.characters(number: 8) }
+ user
+ end
+end
diff --git a/factories/readings.rb b/factories/readings.rb
new file mode 100755
index 0000000..0563e1d
--- /dev/null
+++ b/factories/readings.rb
@@ -0,0 +1,14 @@
+require "faker"
+
+FactoryBot.define do
+ factory :reading do
+ user_id { FactoryBot.create(:user).id }
+ work_id { FactoryBot.create(:work).id }
+
+ # A reading with a deleted work means a reading with a work_id that
+ # doesn't exist. Here we use 0 because it will never be used as an id.
+ trait :deleted_work do
+ work_id { 0 }
+ end
+ end
+end
diff --git a/factories/related_works.rb b/factories/related_works.rb
new file mode 100755
index 0000000..17d2fb4
--- /dev/null
+++ b/factories/related_works.rb
@@ -0,0 +1,9 @@
+require 'faker'
+
+FactoryBot.define do
+ factory :related_work do
+ parent_type { "Work" }
+ parent_id { FactoryBot.create(:work).id }
+ work_id { FactoryBot.create(:work).id }
+ end
+end
diff --git a/factories/serial_work.rb b/factories/serial_work.rb
new file mode 100755
index 0000000..5062bb3
--- /dev/null
+++ b/factories/serial_work.rb
@@ -0,0 +1,7 @@
+require 'faker'
+
+FactoryBot.define do
+ factory :serial_work do
+ work_id { FactoryBot.create(:work).id }
+ end
+end
diff --git a/factories/series.rb b/factories/series.rb
new file mode 100755
index 0000000..a30e134
--- /dev/null
+++ b/factories/series.rb
@@ -0,0 +1,27 @@
+require "faker"
+
+FactoryBot.define do
+ sequence(:series_title) do |n|
+ "Awesome Series #{n}"
+ end
+
+ factory :series do
+ title { generate(:series_title) }
+
+ transient do
+ authors { [build(:pseud)] }
+ end
+
+ after(:build) do |series, evaluator|
+ evaluator.authors.each do |pseud|
+ series.creatorships.build(pseud: pseud)
+ end
+ end
+
+ factory :series_with_a_work do
+ after(:create) do |series|
+ create(:work, authors: series.pseuds, series: [series])
+ end
+ end
+ end
+end
diff --git a/factories/skins.rb b/factories/skins.rb
new file mode 100755
index 0000000..9e14ff7
--- /dev/null
+++ b/factories/skins.rb
@@ -0,0 +1,30 @@
+require 'faker'
+FactoryBot.define do
+ sequence :skin_title do |n|
+ "#{Faker::Lorem.word} #{n}"
+ end
+
+ factory :skin do
+ author_id { FactoryBot.create(:user).id }
+ title { generate(:skin_title) }
+
+ trait :public do
+ add_attribute(:public) { true }
+ official { true }
+ end
+ end
+
+ factory :work_skin do
+ author_id { FactoryBot.create(:user).id }
+ title { generate(:skin_title) }
+
+ trait :private do
+ add_attribute(:public) { false }
+ end
+
+ trait :public do
+ add_attribute(:public) { true }
+ official { true }
+ end
+ end
+end
diff --git a/factories/subscriptions.rb b/factories/subscriptions.rb
new file mode 100755
index 0000000..00ace51
--- /dev/null
+++ b/factories/subscriptions.rb
@@ -0,0 +1,10 @@
+require 'faker'
+
+FactoryBot.define do
+
+ factory :subscription do
+ subscribable_type { "Series" }
+ subscribable_id { FactoryBot.create(:series).id }
+ user
+ end
+end
diff --git a/factories/tags.rb b/factories/tags.rb
new file mode 100755
index 0000000..2fbaec5
--- /dev/null
+++ b/factories/tags.rb
@@ -0,0 +1,143 @@
+require "faker"
+
+FactoryBot.define do
+ sequence(:tag_title) do |n|
+ "Owned Tag Set #{n}"
+ end
+
+ sequence(:tag_name) do |n|
+ "The #{n} Tag"
+ end
+
+ factory :common_tagging do
+ association :common_tag, factory: :relationship
+ association :filterable, factory: :canonical_fandom
+ end
+
+ factory :meta_tagging do
+ association :meta_tag, factory: :canonical_freeform
+ association :sub_tag, factory: :canonical_freeform
+ end
+
+ factory :tag_set do
+ tags { [create(:canonical_fandom)] }
+ end
+
+ factory :owned_tag_set do
+ title { generate(:tag_title) }
+ nominated { true }
+ transient do
+ owned_set_taggings { [create(:owned_set_tagging)] }
+ owner { create(:pseud) }
+ tags { [create(:fandom)] }
+ end
+
+ after(:build) do |owned_tag_set, evaluator|
+ owned_tag_set.build_tag_set
+ owned_tag_set.add_owner(evaluator.owner)
+ owned_tag_set.fandom_nomination_limit = 2
+ owned_tag_set.owned_set_taggings << evaluator.owned_set_taggings
+ owned_tag_set.tags << evaluator.tags
+ end
+ end
+
+ factory :owned_set_tagging do
+ set_taggable { create(:prompt_restriction) }
+ end
+
+ factory :tag_set_nomination do
+ association :owned_tag_set
+ association :pseud
+ end
+
+ factory :tag_set_association do
+ association :owned_tag_set
+ association :tag
+ association :parent_tag
+ end
+
+ factory :tag_nomination do
+ type { "FandomNomination" }
+
+ canonical { true }
+ association :owned_tag_set
+
+ after(:build) do |nomination|
+ nomination.tagname ||= Fandom.last.name
+ end
+ end
+
+ factory :tag do
+ name { generate(:tag_name) }
+ end
+
+ factory :unsorted_tag do
+ sequence(:name) { |n| "Unsorted Tag #{n}" }
+ end
+
+ factory :fandom do
+ sequence(:name) { |n| "The #{n} Fandom" }
+
+ # Tags names used in mailer preview should be unique but recognizable as tag names
+ trait :for_mailer_preview do
+ name { "Fandom#{Faker::Alphanumeric.alpha(number: 8)}" }
+ end
+
+ factory :canonical_fandom do
+ canonical { true }
+ end
+ end
+
+ factory :character do
+ sequence(:name) { |n| "Character #{n}" }
+
+ # Tags names used in mailer preview should be unique but recognizable as tag names
+ trait :for_mailer_preview do
+ name { "Character#{Faker::Alphanumeric.alpha(number: 8)}" }
+ end
+
+ factory :canonical_character do
+ canonical { true }
+ end
+ end
+
+ factory :relationship do
+ sequence(:name) { |n| "Jane#{n}/John#{n}" }
+
+ # Tags names used in mailer preview should be unique but recognizable as tag names
+ trait :for_mailer_preview do
+ name { "Relationship#{Faker::Alphanumeric.alpha(number: 8)}" }
+ end
+
+ factory :canonical_relationship do
+ canonical { true }
+ end
+ end
+
+ factory :freeform do
+ sequence(:name) { |n| "Freeform #{n}" }
+
+ # Tags names used in mailer preview should be unique but recognizable as tag names
+ trait :for_mailer_preview do
+ name { "Freeform#{Faker::Alphanumeric.alpha(number: 8)}" }
+ end
+
+ factory :canonical_freeform do
+ canonical { true }
+ end
+ end
+
+ factory :media do
+ sequence(:name) { |n| "Media #{n}" }
+ canonical { true }
+ end
+
+ factory :banned do |f|
+ f.sequence(:name) { |n| "Banned #{n}" }
+ end
+
+ factory :archive_warning do |f|
+ f.sequence(:name) { |n| "Archive Warning #{n}" }
+ canonical { true }
+ end
+end
diff --git a/factories/user_invite_requests.rb b/factories/user_invite_requests.rb
new file mode 100755
index 0000000..7572faf
--- /dev/null
+++ b/factories/user_invite_requests.rb
@@ -0,0 +1,10 @@
+require 'faker'
+FactoryBot.define do
+ factory :user_invite_requests, class: "UserInviteRequest" do
+ user_id { FactoryBot.create(:user).id }
+ quantity { 5 }
+ reason { "Because reasons!" }
+ granted { false }
+ handled { false }
+ end
+end
diff --git a/factories/users.rb b/factories/users.rb
new file mode 100755
index 0000000..36710c6
--- /dev/null
+++ b/factories/users.rb
@@ -0,0 +1,52 @@
+require "faker"
+
+FactoryBot.define do
+ sequence(:login) do |n|
+ "#{Faker::Lorem.characters(number: 8)}#{n}"
+ end
+
+ factory :role do
+ sequence(:name) { |n| "#{Faker::Company.profession}_#{n}" }
+ end
+
+ factory :user do
+ login { generate(:login) }
+ password { SecureRandom.alphanumeric(10) }
+ age_over_13 { "1" }
+ data_processing { "1" }
+ terms_of_service { "1" }
+ password_confirmation(&:password)
+ email { Faker::Internet.unique.email }
+
+ # By default, create activated users who can log in, since we use
+ # devise :confirmable.
+ confirmed_at { Faker::Time.backward }
+
+ trait :unconfirmed do
+ confirmed_at { nil }
+ end
+
+ # Usernames used in mailer preview should be unique but recognizable as usernames
+ trait :for_mailer_preview do
+ login { "User#{Faker::Alphanumeric.alpha(number: 8)}" }
+ end
+
+ # Roles
+
+ factory :archivist do
+ roles { [Role.find_or_create_by(name: "archivist")] }
+ end
+
+ factory :opendoors_user do
+ roles { [Role.find_or_create_by(name: "opendoors")] }
+ end
+
+ factory :tag_wrangler do
+ roles { [Role.find_or_create_by(name: "tag_wrangler")] }
+ end
+
+ factory :official_user do
+ roles { [Role.find_or_create_by(name: "official")] }
+ end
+ end
+end
diff --git a/factories/work_original_creators.rb b/factories/work_original_creators.rb
new file mode 100755
index 0000000..59c08db
--- /dev/null
+++ b/factories/work_original_creators.rb
@@ -0,0 +1,6 @@
+FactoryBot.define do
+ factory :work_original_creator do
+ work
+ user
+ end
+end
diff --git a/factories/works.rb b/factories/works.rb
new file mode 100755
index 0000000..6530499
--- /dev/null
+++ b/factories/works.rb
@@ -0,0 +1,67 @@
+require 'faker'
+FactoryBot.define do
+
+ factory :work do
+ title { "My title is long enough" }
+ fandom_string { "Testing" }
+ rating_string { ArchiveConfig.RATING_DEFAULT_TAG_NAME }
+ archive_warning_string { ArchiveConfig.WARNING_NONE_TAG_NAME }
+ language_id { Language.default.id }
+ chapter_info = { content: "This is some chapter content for my work." }
+ chapter_attributes { chapter_info }
+ posted { true }
+
+ transient do
+ authors { [build(:pseud)] }
+ end
+
+ after(:build) do |work, evaluator|
+ evaluator.authors.each do |pseud|
+ work.creatorships.build(pseud: pseud)
+ end
+ end
+
+ factory :no_authors do
+ authors { [] }
+ end
+
+ factory :custom_work_skin do
+ work_skin_id { 1 }
+ end
+
+ factory :draft do
+ posted { false }
+ end
+
+ trait :guest_comments_on do
+ comment_permissions { :enable_all }
+ end
+ end
+
+ factory :external_work do
+ title { "An External Work" }
+ author { "An Author" }
+ url { "http://www.example.org" }
+
+ after(:build) do |work|
+ work.fandoms = [FactoryBot.build(:fandom)] if work.fandoms.blank?
+ end
+ end
+
+ factory :moderated_work do
+ work_id { create(:work).id }
+ end
+
+ factory :external_author do |f|
+ f.sequence(:email) { |n| "foo#{n}@external.com" }
+ end
+
+ factory :external_author_name do |f|
+ f.association :external_author
+ end
+
+ factory :external_creatorship do |f|
+ f.creation_type { 'Work' }
+ f.association :external_author_name
+ end
+end
diff --git a/factories/wrangling_assignments.rb b/factories/wrangling_assignments.rb
new file mode 100755
index 0000000..1373dc4
--- /dev/null
+++ b/factories/wrangling_assignments.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :wrangling_assignment do
+ user
+ fandom
+ end
+end
diff --git a/factories/wrangling_guideline.rb b/factories/wrangling_guideline.rb
new file mode 100755
index 0000000..9128896
--- /dev/null
+++ b/factories/wrangling_guideline.rb
@@ -0,0 +1,17 @@
+require 'faker'
+
+FactoryBot.define do
+
+ sequence(:wrangling_guideline_title) do |n|
+ "The #{n} Wrangling Guideline"
+ end
+
+ sequence(:wrangling_guideline_content) do |n|
+ "This is the #{n} Wrangling Guideline"
+ end
+
+ factory :wrangling_guideline do |f|
+ title { generate(:wrangling_guideline_title) }
+ content { generate(:wrangling_guideline_content) }
+ end
+end
diff --git a/features/admins/admin_email_blacklist.feature b/features/admins/admin_email_blacklist.feature
new file mode 100755
index 0000000..2ed0068
--- /dev/null
+++ b/features/admins/admin_email_blacklist.feature
@@ -0,0 +1,95 @@
+@admin
+Feature: Admin email blacklist
+ In order to prevent the use of certain email addresses in guest comments
+ As an an admin
+ I want to be able to manage a list of banned email addresses
+
+Scenario: Ban email address
+ Given I am logged in as a "policy_and_abuse" admin
+ Then I should see "Banned Emails"
+ When I follow "Banned Emails"
+ Then I should see "Find banned emails"
+ And I should see "Ban email address"
+ And I should see "Email to ban"
+ And I should see "Email to find"
+ When I fill in "Email to ban" with "foo@bar.com"
+ And I press "Ban Email"
+ Then I should see "Email address foo@bar.com banned."
+ And the address "foo@bar.com" should be banned
+
+Scenario: Remove email address from banned emails list
+ Given I am logged in as a "policy_and_abuse" admin
+ And I have banned the address "foo@bar.com"
+ When I follow "Banned Emails"
+ And I fill in "Email to find" with "bar"
+ And I press "Search Banned Emails"
+ Then I should see "email found"
+ And I should see "foo@bar.com"
+ When I follow "Remove"
+ Then I should see "Email address foo@bar.com removed from banned emails list."
+ And the address "foo@bar.com" should not be banned
+
+Scenario: Banned email addresses should not be usable in guest comments
+ Given I am logged in as a "policy_and_abuse" admin
+ And I have banned the address "foo@bar.com"
+ And I am logged in as "author"
+ And I post the work "New Work"
+ When I post the comment "I loved this" on the work "New Work" as a guest with email "foo@bar.com"
+ Then I should see "has been blocked at the owner's request"
+ And I should not see "Comments (1)"
+ When I fill in "Guest email" with "someone@bar.com"
+ And I press "Comment"
+ Then I should see "Comments (1)"
+
+Scenario: Variants of banned email addresses should not be usable
+ Given I am logged in as a "policy_and_abuse" admin
+ When I have banned the address "foo.bar+gloop@googlemail.com"
+ Then the address "foobar@gmail.com" should be banned
+ When I am logged out
+ Then I should not be able to comment with the address "foobar@gmail.com"
+ And I should not be able to comment with the address "foobar+baz@gmail.com"
+ And I should not be able to comment with the address "foo.bar@gmail.com"
+ And I should be able to comment with the address "whee@gmail.com"
+
+Scenario: Banning a user's email should not affect their ability to post comments
+ Given the user "author" exists and is activated
+ And I am logged in as a "policy_and_abuse" admin
+ And I have banned the address for user "author"
+ When I am logged in as "author"
+ And I post the work "New Work"
+ And I post a comment "here's a great comment"
+ Then I should see "Comment created!"
+
+Scenario: Banning a user's email should prevent requesting invites
+ Given I am logged in as a "policy_and_abuse" admin
+ When I have banned the address "foo@bar.com"
+ And I log out
+ And account creation requires an invitation
+ And the invitation queue is enabled
+ Then I should not be able to add the email "foo@bar.com" to the invite queue
+
+Scenario: Banning a user's email should prevent aliases requesting invites
+ Given I am logged in as a "policy_and_abuse" admin
+ When I have banned the address "foo+bar@gmail.com"
+ And I log out
+ And account creation requires an invitation
+ And the invitation queue is enabled
+ Then I should not be able to add the email "foo@gmail.com" to the invite queue
+ And I should not be able to add the email "fo.o@gmail.com" to the invite queue
+ And I should not be able to add the email "foo+baz@gmail.com" to the invite queue
+ And I should not be able to add the email "foo@googlemail.com" to the invite queue
+
+Scenario: User's email banned after joining invite queue should remove their email
+ Given I am a visitor
+ When I am on the homepage
+ And I follow "Get an Invitation"
+ And I fill in "Email" with "foo+bar@baz.com"
+ And I press "Add me to the list"
+ Then I should see "You've been added to our queue"
+ When I am logged in as a "policy_and_abuse" admin
+ And I go to the manage invite queue page
+ Then I should see "foo+bar@baz.com"
+ And I should not see "foo@baz.com"
+ When I have banned the address "foo+qux@baz.com"
+ And I go to the manage invite queue page
+ Then I should not see "foo+bar@baz.com"
diff --git a/features/admins/admin_invitations.feature b/features/admins/admin_invitations.feature
new file mode 100755
index 0000000..cf03977
--- /dev/null
+++ b/features/admins/admin_invitations.feature
@@ -0,0 +1,520 @@
+@admin
+Feature: Admin Actions to Manage Invitations
+ In order to manage user account creation
+ As an an admin
+ I want to be able to require invitations for new users
+
+ Scenario: Admin can set invite from queue number to a number greater than or equal to 1
+ Given I am logged in as a "policy_and_abuse" admin
+ And I go to the admin-settings page
+ And I fill in "Number of people to invite from the queue at once" with "0"
+ And I press "Update"
+ Then I should see "Invite from queue number must be greater than 0. To disable invites, uncheck the appropriate setting."
+ When I fill in "Number of people to invite from the queue at once" with "1"
+ And I press "Update"
+ Then I should not see "Invite from queue number must be greater than 0."
+
+ Scenario: Account creation enabled, invitations required, users can request invitations, and the queue is enabled
+ Given I am logged in as a super admin
+ And I go to the admin-settings page
+ And I check "Account creation enabled"
+ And I check "Account creation requires invitation"
+ And I check "Users can request invitations"
+ And I check "Invite from queue enabled (People can add themselves to the queue and invitations are sent out automatically)"
+ And I press "Update"
+ And I log out
+ When I go to the home page
+ Then I should see "Get Invited!"
+ And I should see "You can join by getting an invitation from another user or from our automated invite queue. All fans and fanworks are welcome!"
+ And I should not see "Create An Account"
+ And I should not see "Joining the Archive currently requires an invitation; however, we are not accepting new invitation requests at this time."
+
+ Scenario: Account creation enabled, invitations required, users can request invitations, and the queue is disabled
+ Given I am logged in as a super admin
+ And I go to the admin-settings page
+ And I check "Account creation enabled"
+ And I check "Account creation requires invitation"
+ And I check "Users can request invitations"
+ And I uncheck "Invite from queue enabled (People can add themselves to the queue and invitations are sent out automatically)"
+ And I press "Update"
+ And I log out
+ When I go to the home page
+ Then I should not see "Get Invited!"
+ And I should not see "You can join by getting an invitation from another user or from our automated invite queue. All fans and fanworks are welcome!"
+ And I should not see "Create an Account!"
+ And I should see "Joining the Archive currently requires an invitation; however, we are not accepting new invitation requests at this time."
+
+ Scenario: Account creation enabled, invitations required, users cannot request invitations, and the queue is enabled
+ Given I am logged in as a super admin
+ And I go to the admin-settings page
+ And I check "Account creation enabled"
+ And I check "Account creation requires invitation"
+ And I uncheck "Users can request invitations"
+ And I check "Invite from queue enabled (People can add themselves to the queue and invitations are sent out automatically)"
+ And I press "Update"
+ And I log out
+ When I go to the home page
+ Then I should see "Get Invited!"
+ And I should see "You can join by getting an invitation from our automated invite queue. All fans and fanworks are welcome!"
+ And I should not see "Create an Account!"
+ When I go to account creation page
+ Then I should be on invite requests page
+ And I should see "To create an account, you'll need an invitation. One option is to add your name to the automatic queue below."
+ And I should see "Forgot password? Get an Invitation" within "div#small_login"
+ And I should not see "Joining the Archive currently requires an invitation; however, we are not accepting new invitation requests at this time."
+
+ Scenario: Account creation enabled, invitations not required, users can request invitations, and the queue is enabled
+ Given I am logged in as a super admin
+ And I go to the admin-settings page
+ And I check "Account creation enabled"
+ And I uncheck "Account creation requires invitation"
+ And I check "Users can request invitations"
+ And I check "Invite from queue enabled (People can add themselves to the queue and invitations are sent out automatically)"
+ And I press "Update"
+ And I log out
+ When I go to the home page
+ Then I should not see "Get Invited!"
+ And I should not see "You can join by getting an invitation from another user or from our automated invite queue. All fans and fanworks are welcome!"
+ And I should not see "Joining the Archive currently requires an invitation; however, we are not accepting new invitation requests at this time."
+ And I should see "Create an Account!"
+
+ Scenario: Account creation disabled, invitations required, users can request invitations, and the queue is enabled
+ Given I am logged in as a super admin
+ And I go to the admin-settings page
+ And I uncheck "Account creation enabled"
+ And I check "Account creation requires invitation"
+ And I check "Users can request invitations"
+ And I check "Invite from queue enabled (People can add themselves to the queue and invitations are sent out automatically)"
+ And I press "Update"
+ And I log out
+ When I go to the home page
+ Then I should see "Get Invited!"
+ And I should see "You can join by getting an invitation from another user or from our automated invite queue. All fans and fanworks are welcome!"
+ And I should not see "Create an Account!"
+ And I should not see "Joining the Archive currently requires an invitation; however, we are not accepting new invitation requests at this time."
+
+ Scenario: Account creation enabled, invitations required, users cannot request invitations, and the queue is disabled
+ Given I am logged in as a super admin
+ And I go to the admin-settings page
+ And I check "Account creation enabled"
+ And I check "Account creation requires invitation"
+ And I uncheck "Users can request invitations"
+ And I uncheck "Invite from queue enabled (People can add themselves to the queue and invitations are sent out automatically)"
+ And I press "Update"
+ And I log out
+ When I go to the home page
+ Then I should not see "Get Invited!"
+ And I should not see "You can join by getting an invitation from another user or from our automated invite queue. All fans and fanworks are welcome!"
+ And I should not see "Create an Account!"
+ And I should see "Joining the Archive currently requires an invitation; however, we are not accepting new invitation requests at this time."
+ When I go to account creation page
+ Then I should be on the home page
+ And I should see "Account creation currently requires an invitation. We are unable to give out additional invitations at present, but existing invitations can still be used to create an account."
+ And I should see "Forgot password?" within "div#small_login"
+ And I should not see "Get an Invitation" within "div#small_login"
+
+ Scenario: Account creation enabled, invitations not required, users cannot request invitations, and the queue is enabled
+ Given I am logged in as a super admin
+ And I go to the admin-settings page
+ And I check "Account creation enabled"
+ And I uncheck "Account creation requires invitation"
+ And I uncheck "Users can request invitations"
+ And I check "Invite from queue enabled (People can add themselves to the queue and invitations are sent out automatically)"
+ And I press "Update"
+ And I log out
+ When I go to the home page
+ Then I should not see "Get Invited!"
+ And I should not see "You can join by getting an invitation from another user or from our automated invite queue. All fans and fanworks are welcome!"
+ And I should see "Create an Account!"
+ And I should not see "Joining the Archive currently requires an invitation; however, we are not accepting new invitation requests at this time."
+
+ Scenario: Account creation disabled, invitations not required, users can request invitations, and the queue is enabled
+ Given I am logged in as a super admin
+ And I go to the admin-settings page
+ And I uncheck "Account creation enabled"
+ And I uncheck "Account creation requires invitation"
+ And I check "Users can request invitations"
+ And I check "Invite from queue enabled (People can add themselves to the queue and invitations are sent out automatically)"
+ And I press "Update"
+ And I log out
+ When I go to the home page
+ Then I should not see "Get Invited!"
+ And I should not see "You can join by getting an invitation from another user or from our automated invite queue. All fans and fanworks are welcome!"
+ And I should not see "Create an Account!"
+
+ Scenario: Account creation enabled, invitations not required, users can request invitations, and the queue is disabled
+ Given I am logged in as a super admin
+ And I go to the admin-settings page
+ And I check "Account creation enabled"
+ And I uncheck "Account creation requires invitation"
+ And I check "Users can request invitations"
+ And I uncheck "Invite from queue enabled (People can add themselves to the queue and invitations are sent out automatically)"
+ And I press "Update"
+ And I log out
+ When I go to the home page
+ Then I should not see "Get Invited!"
+ And I should not see "You can join by getting an invitation from another user or from our automated invite queue. All fans and fanworks are welcome!"
+ And I should not see "Joining the Archive currently requires an invitation; however, we are not accepting new invitation requests at this time."
+ And I should see "Create an Account!"
+
+ Scenario: Account creation disabled, invitations required, users cannot request invitations, and the queue is enabled
+ Given I am logged in as a super admin
+ And I go to the admin-settings page
+ And I uncheck "Account creation enabled"
+ And I check "Account creation requires invitation"
+ And I uncheck "Users can request invitations"
+ And I check "Invite from queue enabled (People can add themselves to the queue and invitations are sent out automatically)"
+ And I press "Update"
+ And I log out
+ When I go to the home page
+ Then I should see "Get Invited!"
+ And I should see "You can join by getting an invitation from our automated invite queue. All fans and fanworks are welcome!"
+ And I should not see "Create an Account!"
+ And I should not see "Joining the Archive currently requires an invitation; however, we are not accepting new invitation requests at this time."
+
+ Scenario: Account creation enabled, invitations not required, users cannot request invitations, and the queue is disabled
+ Given I am logged in as a super admin
+ And I go to the admin-settings page
+ And I check "Account creation enabled"
+ And I uncheck "Account creation requires invitation"
+ And I uncheck "Users can request invitations"
+ And I uncheck "Invite from queue enabled (People can add themselves to the queue and invitations are sent out automatically)"
+ And I press "Update"
+ And I log out
+ When I go to the home page
+ Then I should not see "Get Invited!"
+ And I should not see "You can join by getting an invitation from another user or from our automated invite queue. All fans and fanworks are welcome!"
+ And I should not see "Joining the Archive currently requires an invitation; however, we are not accepting new invitation requests at this time."
+ And I should see "Create an Account!"
+ When I go to account creation page
+ Then I should be on account creation page
+ And I should see "Create Account"
+
+ Scenario: Account creation disabled, invitations required, users cannot request invitations, and the queue is disabled
+ Given I am logged in as a super admin
+ And I go to the admin-settings page
+ And I uncheck "Account creation enabled"
+ And I check "Account creation requires invitation"
+ And I uncheck "Users can request invitations"
+ And I uncheck "Invite from queue enabled (People can add themselves to the queue and invitations are sent out automatically)"
+ And I press "Update"
+ And I log out
+ When I go to the home page
+ Then I should not see "Get Invited!"
+ And I should not see "You can join by getting an invitation from another user or from our automated invite queue. All fans and fanworks are welcome!"
+ And I should not see "Create an Account!"
+ And I should see "Joining the Archive currently requires an invitation; however, we are not accepting new invitation requests at this time."
+
+ Scenario: Account creation disabled, invitations not required, users can request invitations, and the queue is disabled
+ Given I am logged in as a super admin
+ And I go to the admin-settings page
+ And I uncheck "Account creation enabled"
+ And I uncheck "Account creation requires invitation"
+ And I check "Users can request invitations"
+ And I uncheck "Invite from queue enabled (People can add themselves to the queue and invitations are sent out automatically)"
+ And I press "Update"
+ And I log out
+ When I go to the home page
+ Then I should not see "Get Invited!"
+ And I should not see "You can join by getting an invitation from another user or from our automated invite queue. All fans and fanworks are welcome!"
+ And I should not see "Create an Account!"
+ And I should not see "Joining the Archive currently requires an invitation; however, we are not accepting new invitation requests at this time."
+
+ Scenario: Account creation disabled, invitations not required, users cannot request invitations, and the queue is enabled
+ Given I am logged in as a super admin
+ And I go to the admin-settings page
+ And I uncheck "Account creation enabled"
+ And I uncheck "Account creation requires invitation"
+ And I uncheck "Users can request invitations"
+ And I check "Invite from queue enabled (People can add themselves to the queue and invitations are sent out automatically)"
+ And I press "Update"
+ And I log out
+ When I go to the home page
+ Then I should not see "Get Invited!"
+ And I should not see "You can join by getting an invitation from another user or from our automated invite queue. All fans and fanworks are welcome!"
+ And I should not see "Create an Account!"
+ When I go to account creation page
+ Then I should be on the home page
+ And I should see "Account creation is suspended at the moment. Please check back with us later."
+ And I should see "Forgot password? Get an Invitation" within "div#small_login"
+ And I should not see "Joining the Archive currently requires an invitation; however, we are not accepting new invitation requests at this time."
+
+ Scenario: Account creation disabled, invitations not required, users cannot request invitations, and the queue is disabled
+ Given I am logged in as a super admin
+ And I go to the admin-settings page
+ And I uncheck "Account creation enabled"
+ And I uncheck "Account creation requires invitation"
+ And I uncheck "Users can request invitations"
+ And I uncheck "Invite from queue enabled (People can add themselves to the queue and invitations are sent out automatically)"
+ And I press "Update"
+ And I log out
+ When I go to the home page
+ Then I should not see "Get Invited!"
+ And I should not see "You can join by getting an invitation from another user or from our automated invite queue. All fans and fanworks are welcome!"
+ And I should not see "Create an Account!"
+ And I should not see "Joining the Archive currently requires an invitation; however, we are not accepting new invitation requests at this time."
+
+ Scenario: An admin can send an invitation to a user via email
+ Given I am logged in as an admin
+ And all emails have been delivered
+ When I follow "Invite New Users"
+ And I fill in "invitation[invitee_email]" with "fred@bedrock.com"
+ And I press "Invite user"
+ Then I should see "An invitation was sent to fred@bedrock.com"
+ And 1 email should be delivered
+
+ Scenario: An admin can't create an invite with invalid email
+ Given I am logged in as an admin
+ And all emails have been delivered
+ When I follow "Invite New Users"
+ And I fill in "invitation[invitee_email]" with "abcdefgh"
+ And I press "Invite user"
+ Then I should see "Invitee email should look like an email address. Please use a different address or leave blank."
+ And 0 emails should be delivered
+
+ Scenario: An admin can't create an invite without an email address.
+ Given I am logged in as an admin
+ And all emails have been delivered
+ When I follow "Invite New Users"
+ And I press "Invite user"
+ Then I should see "Please enter an email address"
+ And 0 emails should be delivered
+
+ Scenario: An admin can send an invitation to all existing users
+ Given the following activated users exist
+ | login | password |
+ | dax | lotsaspots |
+ | odo | mybucket9 |
+ And "dax" has "0" invitations
+ And "odo" has "3" invitations
+ And I am logged in as an admin
+ When I follow "Invite New Users"
+ And I fill in "Number of invitations" with "2"
+ And I select "All" from "Users"
+ And I press "Generate invitations"
+ Then "dax" should have "2" invitations
+ And "odo" should have "5" invitations
+
+ Scenario: An admin can send invitations to only existing users who don't have unused invitations
+ Given the following activated users exist
+ | login | password |
+ | dax | lotsaspots |
+ | bashir | heytheredoc |
+ And "dax" has "5" invitations
+ And "bashir" has "0" invitations
+ And I am logged in as an admin
+ When I follow "Invite New Users"
+ And I fill in "Number of invitations" with "2"
+ And I select "With no unused invitations" from "Users"
+ And I press "Generate invitations"
+ Then "dax" should have "7" invitations
+ And "bashir" should have "2" invitations
+
+ Scenario: An admin can see the invitation of an existing user via name or token
+ Given the user "dax" exists and is activated
+ And "dax" has "2" invitations
+ And I am logged in as an admin
+ When I follow "Invite New Users"
+ And I fill in "Username" with "dax"
+ And I press "Search" within "form.invitation.simple.search"
+ Then I should see "Copy and use"
+ And I should see "Delete"
+ When I follow "Invite New Users"
+ And I fill in "Invite token" with "dax's" invite code
+ And I press "Search" within "form.invitation.simple.search"
+ # Only certain admin roles have access to this page; other admins will see the following error message
+ When I follow the first invitation token url
+ Then I should see "Please log out of your admin account first!"
+
+ Scenario: An admin can find all invitations via email partial match
+ Given I am logged in as an admin
+ And an invitation request for "fred@bedrock.com"
+ And an invitation request for "barney@bedrock.com"
+ And all emails have been delivered
+ And I follow "Invite New Users"
+ Then I should see "There are 2 requests in the queue."
+ When I fill in "Number of people to invite" with "2"
+ And I press "Invite from queue"
+ Then I should see "2 people from the invite queue are being invited"
+ When I fill in "All or part of an email address" with "@"
+ And I press "Search" within "form.invitation.simple.search"
+ Then I should see "fred@bedrock.com"
+ And I should see "barney@bedrock.com"
+
+ Scenario: An admin can't find a invitation for a nonexistent user
+ Given I am logged in as an admin
+ And I follow "Invite New Users"
+ When I fill in "Username" with "dax"
+ And I press "Search" within "form.invitation.simple.search"
+ Then I should see "No results were found. Try another search"
+ When I fill in "Username" with ""
+ And I fill in "All or part of an email address" with "nonexistent@domain.com"
+ And I press "Search" within "form.invitation.simple.search"
+ Then I should see "No results were found. Try another search"
+
+ Scenario: An admin can invite people from the queue
+ Given I am logged in as an admin
+ And an invitation request for "fred@bedrock.com"
+ And an invitation request for "barney@bedrock.com"
+ And all emails have been delivered
+ And I follow "Invite New Users"
+ Then I should see "There are 2 requests in the queue."
+ When I fill in "Number of people to invite" with "1"
+ And press "Invite from queue"
+ Then I should see "There is 1 request in the queue."
+ And I should see "1 person from the invite queue is being invited"
+ And 1 email should be delivered to "fred@bedrock.com"
+
+ Scenario: When an admin invites from the queue, the invite is marked as being from the admin
+ Given I am logged in as a "support" admin
+ And an invitation request for "test@example.com"
+ And I follow "Invite New Users"
+ When I fill in "Number of people to invite" with "1"
+ And press "Invite from queue"
+ Then I should see "1 person from the invite queue is being invited"
+ When I press "Search" within "form.invitation.simple.search"
+ And I fill in "All or part of an email address" with "test@example.com"
+ And I press "Search" within "form.invitation.simple.search"
+ Then I should see "Copy and use"
+ And I should see "Delete"
+ When I follow the first invitation token url
+ Then I should see "Sender testadmin-support"
+
+ Scenario: An admin can edit an invitation
+ Given the user "dax" exists and is activated
+ And "dax" has "2" invitations
+ And I am logged in as a "support" admin
+ When I follow "Invite New Users"
+ And I fill in "Username" with "dax"
+ And I press "Search" within "form.invitation.simple.search"
+ Then I should see "Copy and use"
+ And I should see "Delete"
+ When I follow "Invite New Users"
+ And I fill in "Invite token" with "dax's" invite code
+ And I press "Search" within "form.invitation.simple.search"
+ Then I should see "Copy and use"
+ And I should see "Delete"
+ When I follow the first invitation token url
+ Then I should see "Redeemed at -"
+ When I fill in "Enter an email address" with "oldman@ds9.com"
+ And I press "Update Invitation"
+ Then I should see "oldman@ds9.com"
+ And I should see "Invitation was successfully sent."
+
+ Scenario: An admin can search the invitation queue, and search parameters are
+ kept even if deleting without JavaScript
+ Given I am logged in as a "policy_and_abuse" admin
+ And an invitation request for "streamtv@example.com"
+ And an invitation request for "livetv@example.com"
+ And an invitation request for "clearstream@example.com"
+ And an invitation request for "stre.a.mer@example.com"
+ And an invitation request for "dreamer@example.com"
+ When I am on the manage invite queue page
+ And I fill in "query" with "stream"
+ And I press "Search Queue"
+ Then I should see "streamtv@example.com"
+ And I should see "clearstream@example.com"
+ And I should see "stre.a.mer@example.com"
+ But I should not see "livetv@example.com"
+ And I should not see "dreamer@example.com"
+ When I press "Delete"
+ Then the "query" field should contain "stream"
+ And I should not see "dreamer@example.com"
+ And I should not see "livetv@example.com"
+
+ Scenario: The invitations in the queue are paginated correctly
+ Given I am logged in as a "policy_and_abuse" admin
+ And there are 2 invite requests per page
+ And an invitation request for "andy@example.com"
+ And an invitation request for "beatrice@example.com"
+ And an invitation request for "carla@example.com"
+ And an invitation request for "devon@example.com"
+ And an invitation request for "eliot@example.com"
+ When I am on the manage invite queue page
+ Then the invite queue should list the following:
+ | position | email |
+ | 1 | andy@example.com |
+ | 2 | beatrice@example.com |
+ When I follow "Next" within ".pagination"
+ Then the invite queue should list the following:
+ | position | email |
+ | 3 | carla@example.com |
+ | 4 | devon@example.com |
+ When I follow "Next" within ".pagination"
+ Then the invite queue should list the following:
+ | position | email |
+ | 5 | eliot@example.com |
+
+ Scenario: The positions in the queue shift when an invitation is sent out
+ Given I am logged in as a "policy_and_abuse" admin
+ And there are 2 invite requests per page
+ And an invitation request for "andy@example.com"
+ And an invitation request for "beatrice@example.com"
+ And an invitation request for "carla@example.com"
+ And an invitation request for "devon@example.com"
+ And an invitation request for "eliot@example.com"
+ When I follow "Invite New Users"
+ And I fill in "Number of people to invite" with "2"
+ And press "Invite from queue"
+ Then I should see "2 people from the invite queue are being invited"
+ When I am on the manage invite queue page
+ Then the invite queue should list the following:
+ | position | email |
+ | 1 | carla@example.com |
+ | 2 | devon@example.com |
+ When I follow "Next" within ".pagination"
+ Then the invite queue should list the following:
+ | position | email |
+ | 3 | eliot@example.com |
+
+ Scenario: The invitations in the queue are numbered correctly when searching
+ Given I am logged in as a "policy_and_abuse" admin
+ And an invitation request for "andy-jones@example.com"
+ And an invitation request for "beatrice@example.com"
+ And an invitation request for "carla@example.com"
+ And an invitation request for "devon@example.com"
+ And an invitation request for "eliot-jones@example.com"
+ When I am on the manage invite queue page
+ Then the invite queue should list the following:
+ | position | email |
+ | 1 | andy-jones@example.com |
+ | 2 | beatrice@example.com |
+ | 3 | carla@example.com |
+ | 4 | devon@example.com |
+ | 5 | eliot-jones@example.com |
+ When I fill in "query" with "jones"
+ And I press "Search Queue"
+ Then the invite queue should list the following:
+ | position | email |
+ | 1 | andy-jones@example.com |
+ | 5 | eliot-jones@example.com |
+
+ Scenario Outline: Viewing a user's invitation details
+ Given the user "creator" exists and is activated
+ And the user "invitee" exists and is activated
+ And an invitation created by "creator" and used by "invitee"
+ And I am logged in as a "" admin
+ When I go to creator's manage invitations page
+ Then I should see "invitee"
+ When I view the most recent invitation for "creator"
+ Then I should see "invitee"
+ When I follow "invitee"
+ Then I should see "User: invitee"
+ When I am logged in as "invitee"
+ And "invitee" deletes their account
+ And I am logged in as a "" admin
+ And I go to creator's manage invitations page
+ Then I should see "(Deleted)"
+ But I should not see "invitee"
+ When I view the most recent invitation for "creator"
+ Then I should see "User"
+ And I should see "(Deleted)"
+ But I should not see "invitee"
+
+ Examples:
+ | role |
+ | superadmin |
+ | tag_wrangling |
+ | support |
+ | policy_and_abuse |
+ | open_doors |
diff --git a/features/admins/admin_languages.feature b/features/admins/admin_languages.feature
new file mode 100755
index 0000000..7fa078f
--- /dev/null
+++ b/features/admins/admin_languages.feature
@@ -0,0 +1,52 @@
+Feature: Manipulate languages on the Archive
+ In order to be multicultural
+ As as an admin
+ I'd like to be able to manipulate languages on the Archive
+
+Scenario: An admin can add a language
+
+ Given basic languages
+ And I am logged in as a "translation" admin
+ When I go to the languages page
+ And I follow "Add a Language"
+ And I fill in "Name" with "Klingon"
+ And I fill in "Abbreviation" with "tlh"
+ And I press "Create Language"
+ Then I should see "Language was successfully added."
+ And I should see "Work Languages"
+ And I should see "Klingon"
+ And I should see "Klingon (tlh)"
+
+Scenario: Adding Abuse support for a language
+
+ Given the following language exists
+ | name | short |
+ | Arabic | ar |
+ | Espanol | es |
+ When I am logged in as a "policy_and_abuse" admin
+ And I go to the languages page
+ # Languages are sorted by short name, so the first "Edit" is for Arabic
+ And I follow "Edit"
+ And I check "Abuse support available"
+ And I press "Update Language"
+ Then I should see "Language was successfully updated."
+ When I follow "Policy Questions & Abuse Reports"
+ Then I should see "Arabic" within "select#abuse_report_language"
+ And I should not see "Espanol" within "select#abuse_report_language"
+
+Scenario: Adding a language to the Support form
+
+ Given the following language exists
+ | name | short |
+ | Sindarin | sj |
+ | Klingon | tlh |
+ When I am logged in as a "support" admin
+ And I go to the languages page
+ # Languages are sorted by short name, so the first "Edit" is for Sindarin
+ And I follow "Edit"
+ And I check "Support available"
+ And I press "Update Language"
+ Then I should see "Language was successfully updated."
+ When I follow "Technical Support & Feedback"
+ Then I should see "Sindarin" within "select#feedback_language"
+ And I should not see "Klingon" within "select#feedback_language"
diff --git a/features/admins/admin_locales.feature b/features/admins/admin_locales.feature
new file mode 100755
index 0000000..b124953
--- /dev/null
+++ b/features/admins/admin_locales.feature
@@ -0,0 +1,25 @@
+@admin
+Feature: Admin tasks
+
+ Scenario: Add and edit a locale
+ Given the following language exists
+ | name | short |
+ | Dutch | nl |
+ And I am logged in as a "translation" admin
+ When I go to the locales page
+ Then I should see "English (US)"
+ When I follow "New Locale"
+ And I select "Dutch" from "Language"
+ And I fill in "locale_name" with "Dutch - Netherlands"
+ And I fill in "locale_iso" with "nl-nl"
+ And I check "Use this locale to send email"
+ And I press "Create Locale"
+ Then I should see "Dutch - Netherlands nl-nl"
+ When I follow "Edit"
+ And I select "English" from "Language"
+ And I fill in "locale_name" with "English (GB)"
+ And I check "Use this locale to send email"
+ And I check "Use this locale for the interface"
+ And I press "Update Locale"
+ Then I should see "Your locale was successfully updated."
+ And I should see "English (GB) en"
diff --git a/features/admins/admin_post_faqs.feature b/features/admins/admin_post_faqs.feature
new file mode 100755
index 0000000..6e3ab60
--- /dev/null
+++ b/features/admins/admin_post_faqs.feature
@@ -0,0 +1,198 @@
+@admin
+Feature: Admin Actions to Post FAQs
+ As an an admin
+ I want to be able to manage the archive FAQ
+
+ Scenario Outline: Authorized admin posts, edits and deletes a FAQ category
+ When I go to the archive_faqs page
+ Then I should see "Some commonly asked questions about the Archive are answered here"
+ And I should not see "Some text"
+ When I am logged in as a "" admin
+ And I follow "Admin Posts"
+ And I follow "Archive FAQ" within "#header"
+ Then I should not see "Some text"
+ When I follow "New FAQ Category"
+ And I fill in "Question*" with "What is AO3?"
+ And I fill in "Answer*" with "Some text, that is sufficiently long to pass validation."
+ And I fill in "Category name*" with "New subsection"
+ And I fill in "Anchor name*" with "whatisao3"
+ And I press "Post"
+ Then I should see "Archive FAQ was successfully created"
+ When I go to the archive_faqs page
+ And I follow "New subsection"
+ Then I should see "Some text, that is sufficiently long to pass validation" within ".userstuff"
+ When I follow "Edit"
+ And I fill in "Answer*" with "New Content, yay"
+ And I press "Post"
+ Then I should see "New Content, yay"
+ And I should not see "Some text"
+ When I go to the archive_faqs page
+ And I follow "Delete"
+ Then I should see "Are you sure you want to delete the FAQ Category"
+ When I press "Yes, Delete FAQ Category"
+ Then I should not see "New subsection"
+
+ Examples:
+ | role |
+ | support |
+ | superadmin |
+ | docs |
+
+ @javascript
+ Scenario Outline: Authorized admin deletes a FAQ question
+ Given 1 Archive FAQ with 1 question exists
+ And I am logged in as a "" admin
+ And I go to the archive_faqs page
+ And I follow "Edit"
+ And I follow "Remove Question"
+ And I press "Post"
+ Then I should see "Archive FAQ was successfully updated."
+ And I should see "We're sorry, there are currently no entries in this category."
+
+ Examples:
+ | role |
+ | support |
+ | superadmin |
+ | docs |
+
+ Scenario: Post a translated FAQ for a locale, then change the locale's code.
+ Given basic languages
+ And I am logged in as a "superadmin" admin
+
+ # Post "en" FAQ
+ When I go to the archive_faqs page
+ And I follow "New FAQ Category"
+ And I fill in "Question*" with "What is AO3?"
+ And I fill in "Answer*" with "Some text, that is sufficiently long to pass validation."
+ And I fill in "Category name*" with "New subsection"
+ And I fill in "Anchor name*" with "whatisao3"
+ And I press "Post"
+ Then I should see "Archive FAQ was successfully created"
+
+ # Translate FAQ to "de"
+ When I am logged in as a "translation" admin
+ And I follow "Admin Posts"
+ And I follow "Archive FAQ"
+ And I select "Deutsch" from "Language:"
+ And I press "Go" within "div#inner.wrapper"
+ And I follow "Edit"
+ And I fill in "Question*" with "Was ist AO3?"
+ And I fill in "Answer*" with "Einiger Text, der lang genug ist, um die Überprüfung zu bestehen."
+ And I fill in "Category name*" with "Neuer Abschnitt"
+ And I check "Question translated"
+ And I press "Post"
+ Then I should see "Archive FAQ was successfully updated."
+ And I should not see "New subsection"
+ And I should see "Neuer Abschnitt"
+ And I should see "Was ist AO3?"
+ And I should see "Einiger Text"
+
+ # Change locale "de" to "ger"
+ When I go to the locales page
+ And I follow "Edit"
+ Then I should see "Deutsch" in the "Name" input
+ When I fill in "locale_iso" with "ger"
+ And I press "Update Locale"
+ Then I should see "Your locale was successfully updated."
+ And I should see "Deutsch ger"
+
+ # The session preference is "de", which no longer exists; the default locale should be used
+ When I go to the archive_faqs page
+ Then "English (US)" should be selected within "Language:"
+
+ # Log out and view FAQs; the default locale should be used
+ When I log out
+ And I go to the archive_faqs page
+ And I follow "New subsection"
+ Then I should see "What is AO3?"
+ And I should see "Some text"
+
+ # Select "ger"
+ When I go to the archive_faqs page
+ And I select "Deutsch" from "Language:"
+ And I press "Go" within "div#inner.wrapper"
+ And I follow "Neuer Abschnitt"
+ Then I should see "Was ist AO3?"
+ And I should see "Einiger Text"
+
+ Scenario: Links to create, reorder and delete FAQ categories are not shown for non-English language FAQs
+ Given basic languages
+ And 1 Archive FAQ exists
+ And I am logged in as a "superadmin" admin
+ When I go to the archive_faqs page
+ Then I should see "New FAQ Category"
+ And I should see "Reorder FAQs"
+ And I should see "Delete"
+ And I should see "Edit"
+ When I select "Deutsch" from "Language:"
+ And I press "Go" within "div#inner.wrapper"
+ Then I should not see "New FAQ Category"
+ And I should not see "Reorder FAQs"
+ And I should not see "Delete"
+ But I should see "Edit"
+
+ @javascript
+ Scenario: Links to add, reorder and remove FAQ questions are not shown for non-English language FAQs
+ Given basic languages
+ And 1 Archive FAQ with 1 question exists
+ And I am logged in as a "superadmin" admin
+ When I go to the archive_faqs page
+ And I follow "Edit"
+ Then I should see "Reorder Questions"
+ And I should see "Remove Question"
+ And I should see "Add Question"
+ But I should not see "Question translated"
+ When I select "Deutsch" from "Language:"
+ And I press "Go" within "div#inner.wrapper"
+ And I follow "Edit"
+ Then I should not see "Reorder Questions"
+ And I should not see "Remove Question"
+ And I should not see "Add Question"
+ But I should see "Question translated"
+
+ Scenario: Translation admins do not see links to edit English language FAQs
+ Given basic languages
+ And 1 Archive FAQ exists
+ And I am logged in as a "translation" admin
+ When I go to the archive_faqs page
+ Then I should not see "Edit"
+ And I should not see "New FAQ Category"
+ And I should not see "Reorder FAQs"
+ And I should not see "Delete"
+ When I follow "Show"
+ Then I should not see "Edit" within ".header"
+ When I go to the archive_faqs page
+ And I select "Deutsch" from "Language:"
+ And I press "Go" within "div#inner.wrapper"
+ Then I should see "Edit"
+ And I should not see "New FAQ Category"
+ And I should not see "Reorder FAQs"
+ And I should not see "Delete"
+ When I follow "Show"
+ Then I should see "Edit" within ".header"
+
+ Scenario Outline: Links to create and edit FAQs are not shown to unauthorized admins
+ Given an archive FAQ category with the title "Very important FAQ" exists
+ And I am logged in as a "" admin
+ When I follow "Admin Posts"
+ Then I should not see "Archive FAQ" within "#header"
+ When I go to the archive_faqs page
+ Then I should not see "Edit"
+ And I should not see "New FAQ Category"
+ And I should not see "Reorder FAQs"
+ And I should not see "Delete"
+ But I should see "Available Categories"
+ When I follow "Very important FAQ"
+ Then I should not see "Edit"
+
+ Examples:
+ | role |
+ | board |
+ | board_assistants_team |
+ | communications |
+ | development_and_membership |
+ | elections |
+ | legal |
+ | tag_wrangling |
+ | policy_and_abuse |
+ | open_doors |
diff --git a/features/admins/admin_post_issues.feature b/features/admins/admin_post_issues.feature
new file mode 100755
index 0000000..cdedac3
--- /dev/null
+++ b/features/admins/admin_post_issues.feature
@@ -0,0 +1,53 @@
+@admin
+Feature: Admin Actions to Post Known Issues
+ As an an admin
+ I want to be able to report known issues
+
+ Scenario Outline: Authorized admin posts, edits, and deletes known issues
+ Given I am logged in as a "" admin
+ When I follow "Admin Posts"
+ And I follow "Known Issues" within "#header"
+ And I follow "make a new known issues post"
+ And I fill in "known_issue_title" with "First known problem"
+ And I fill in "content" with "This is a bit of a problem"
+ # Suspect related to issue 2458
+ And I press "Post"
+ Then I should see "Known issue was successfully created"
+ And I should see "First known problem"
+ When I follow "Admin Posts"
+ And I follow "Known Issues" within "#header"
+ And I follow "Show"
+ Then I should see "First known problem"
+ When I edit known issues
+ Then I should see "Known issue was successfully updated"
+ And I should not see "First known problem"
+ And I should see "This is a bit of a problem, and this is too"
+ When I delete known issues
+ Then I should not see "First known problem"
+
+ Examples:
+ | role |
+ | support |
+ | superadmin |
+
+ Scenario Outline: Links to edit and create known issues are not shown to unauthorized admins
+ Given I have posted known issues
+ And I am logged in as a "" admin
+ When I follow "Admin Posts"
+ Then I should not see "Known Issues" within "#header"
+ When I go to the known issues page
+ Then I should not see "Edit" within ".actions"
+
+ Examples:
+ | role |
+ | board |
+ | board_assistants_team |
+ | communications |
+ | development_and_membership |
+ | docs |
+ | elections |
+ | legal |
+ | translation |
+ | tag_wrangling |
+ | policy_and_abuse |
+ | open_doors |
diff --git a/features/admins/admin_post_news.feature b/features/admins/admin_post_news.feature
new file mode 100755
index 0000000..8b35264
--- /dev/null
+++ b/features/admins/admin_post_news.feature
@@ -0,0 +1,279 @@
+@admin @comments
+Feature: Admin Actions to Post News
+ In order to post news items
+ As an an admin
+ I want to be able to use the Admin Posts screen
+
+ Scenario: Must be authorized to post
+ Given I am logged in as a "tag_wrangling" admin
+ When I go to the admin-posts page
+ Then I should not see "Post AO3 News"
+
+ Scenario: Make an admin post
+ Given I am logged in as a "communications" admin
+ When I make an admin post
+ Then I should see "Admin Post was successfully created."
+
+ Scenario: Receive comment notifications for comments posted to an admin post
+ Given I have posted an admin post
+
+ # regular user replies to admin post
+ When I am logged in as "happyuser"
+ And I go to the admin-posts page
+ When all emails have been delivered
+ And I follow "Default Admin Post"
+ And I fill in "Comment" with "Excellent, my dear!"
+ And I press "Comment"
+ # notification to the admin list for admin post
+ Then 0 emails should be delivered to "testadmin-communications@example.org"
+ But 1 email should be delivered to "admin@example.org"
+ And the email should contain "Excellent"
+
+ # regular user edits their comment
+ When all emails have been delivered
+ And I follow "Edit"
+ And I press "Update"
+ # notification to the admin list for admin post
+ Then 0 emails should be delivered to "testadmin-communications@example.org"
+ But 1 email should be delivered to "admin@example.org"
+
+ Scenario: User views RSS of admin posts
+
+ Given I have posted an admin post
+ When I am logged in
+ And I go to the admin-posts page
+ Then I should see "RSS Feed"
+ When I follow "RSS Feed"
+ Then I should see "Default Admin Post"
+
+ Scenario: User views RSS of translated admin posts
+ Given I have posted an admin post
+ And basic languages
+ And I am logged in as a "translation" admin
+ When I make a translation of an admin post
+ And I am logged in as "ordinaryuser"
+ And I go to the admin-posts page
+ And I select "Deutsch" from "Language:"
+ And I press "Go" within "div#inner.wrapper"
+ And I follow "RSS Feed"
+ Then I should see "Deutsch Ankuendigung"
+
+ Scenario: Make a translation of an admin post
+ Given I have posted an admin post
+ And basic languages
+ And I am logged in as a "translation" admin
+ When I make a translation of an admin post
+ And I am logged in as "ordinaryuser"
+ Then I should see a translated admin post
+
+ Scenario: Make a translation of an admin post that doesn't exist
+ Given basic languages
+ And I am logged in as a "translation" admin
+ When I make a translation of an admin post
+ Then I should see "Sorry! We couldn't save this admin post because:"
+ And I should see "Translated post does not exist"
+ And the translation information should still be filled in
+
+ Scenario: Make a translation of an admin post stop being a translation
+ Given I have posted an admin post
+ And basic languages
+ And I am logged in as a "translation" admin
+ And I make a translation of an admin post
+ When I follow "Edit Post"
+ And I fill in "Translation of" with ""
+ And I press "Post"
+ When I am logged in as "ordinaryuser"
+ Then I should not see a translated admin post
+
+ Scenario: Log in as an admin and create an admin post with tags
+ Given I am logged in as a "communications" admin
+ When I follow "Admin Posts"
+ And I follow "Post AO3 News"
+ Then I should see "New AO3 News Post"
+ And I should see "Comment permissions from the selected post will replace any permissions selected on this page."
+ And I should see "Tags from the selected post will replace any tags entered on this page."
+ When I fill in "admin_post_title" with "Good news, everyone!"
+ And I fill in "content" with "I've taught the toaster to feel love."
+ And I fill in "Tags" with "quotes, futurama"
+ And I choose "No one can comment"
+ And I press "Post"
+ Then I should see "Admin Post was successfully created."
+ And I should see "toaster" within "div.admin.home"
+ And I should see "futurama" within "dd.tags"
+
+ Scenario: Admin posts can be filtered by tags and languages
+ Given I have posted an admin post with tags "quotes, futurama"
+ And basic languages
+ And I am logged in as a "translation" admin
+ When I make a translation of an admin post
+ And I am logged in as "ordinaryuser"
+ Then I should see a translated admin post with tags "quotes, futurama"
+
+ When I follow "News"
+ Then "futurama" should be an option within "Tag"
+ And "quotes" should be an option within "Tag"
+ And "Deutsch" should be an option within "Language"
+ And "English" should be selected within "Language"
+
+ # No tag selected
+ When I press "Go"
+ Then I should see "Content of the admin post"
+ And I should not see "Deutsch Woerter"
+ And "English" should be selected within "Language"
+
+ When I select "quotes" from "Tag"
+ And I select "Deutsch" from "Language"
+ And I press "Go"
+ Then I should not see "Content of the admin post"
+ And I should see "Deutsch Woerter"
+ And "quotes" should be selected within "Tag"
+ And "Deutsch" should be selected within "Language"
+
+ Scenario: Translation of an admin post keeps tags of original post
+ Given I have posted an admin post with tags "original1, original2"
+ And basic languages
+ And I am logged in as a "translation" admin
+ When I make a translation of an admin post with tags "ooops"
+ Then I should see "original1 original2" within "dd.tags"
+ And I should not see "ooops"
+ When I follow "Edit Post"
+ Then I should not see the input with id "admin_post_tag_list"
+ And I should not see "Tags from the selected post will replace any tags entered on this page."
+ When I go to the admin-posts page
+ Then "ooops" should not be an option within "Tag"
+ When I follow "Edit"
+ Then I should see the input with id "admin_post_tag_list"
+ When I fill in "Tags" with "updated1, updated2"
+ And I press "Post"
+ And I am logged in as "ordinaryuser"
+ Then I should see a translated admin post with tags "updated1, updated2"
+
+ Scenario: If an admin post has characters like & and < and > in the title, the escaped version will not show on the various admin post pages
+ Given I am logged in as a "communications" admin
+ When I follow "Admin Posts"
+ And I follow "Post AO3 News"
+ And I fill in "admin_post_title" with "App News & a Warning"
+ And I fill in "content" with "We're delaying it a week for every question we get."
+ When I press "Post"
+ Then I should see the page title "App News & a Warning"
+ And I should not see "App News & a <strong> Warning"
+ When I go to the admin-posts page
+ Then I should see "App News & a Warning"
+ And I should not see "App News & a <strong> Warning"
+ When I go to the home page
+ Then I should see "App News & a Warning"
+ And I should not see "App News & a <strong> Warning"
+ When I log out
+ And I go to the admin-posts page
+ Then I should see "App News & a Warning"
+ And I should not see "App News & a <strong> Warning"
+
+ Scenario: Admin post should be shown on the homepage
+ Given I have posted an admin post
+ When I am on the homepage
+ Then I should see "News"
+ And I should see "All News"
+ And I should see "Default Admin Post"
+ And I should see "Published:"
+ And I should see "Comments:"
+ And I should see "Content of the admin post."
+ And I should see "Read more..."
+ When I follow "Read more..."
+ Then I should see "Default Admin Post"
+ And I should see "Content of the admin post."
+
+ Scenario: Admin posts without paragraphs should have placeholder preview text on the homepage
+ Given I have posted an admin post without paragraphs
+ When I am on the homepage
+ Then I should see "Admin Post Without Paragraphs"
+ And I should see "No preview is available for this news post."
+
+ Scenario: Edits to an admin post should appear on the homepage
+ Given I have posted an admin post without paragraphs
+ And I am logged in as a "communications" admin
+ When I go to the admin-posts page
+ And I follow "Edit"
+ And I fill in "admin_post_title" with "Edited Post"
+ And I fill in "content" with "
Look! A preview!
"
+ And I press "Post"
+ When I am on the homepage
+ Then I should see "Edited Post"
+ And I should see "Look! A preview!"
+ And I should not see "Admin Post Without Paragraphs"
+ And I should not see "No preview is available for this news post."
+
+ Scenario: A deleted admin post should be removed from the homepage
+ Given I have posted an admin post
+ And I am logged in as a "communications" admin
+ When I go to the admin-posts page
+ And I follow "Delete"
+ When I go to the homepage
+ Then I should not see "Default Admin Post"
+
+ Scenario: Log in as an admin and create an admin post in a rtl (right-to-left) language
+ Given I am logged in as a "communications" admin
+ And Persian language
+ When I follow "Admin Posts"
+ And I follow "Post AO3 News"
+ Then I should see "New AO3 News Post"
+ When I fill in "admin_post_title" with "فارسی"
+ And I fill in "content" with "چیزهایی هست که باید در حین ایجاد یک گزارش از آنها آگاه باشید"
+ And I select "Persian" from "Choose a language"
+ And I press "Post"
+ Then I should see "Admin Post was successfully created."
+ And I should see "باشید" within "div.admin.home div.userstuff"
+ And the user content should be shown as right-to-left
+
+ Scenario: Moderating comments on an admin post
+ Given I am logged in as a "communications" admin
+ When I start to make an admin post
+ And I check "Enable comment moderation"
+ And I choose "Registered users and guests can comment"
+ And I press "Post"
+ Then I should see "Admin Post was successfully created."
+ And I should not see "Unreviewed Comments"
+
+ # Leave a guest comment on a moderated admin post
+ When I log out
+ And I go to the "Default Admin Post" admin post page
+ Then I should see "Comments on this news post are moderated. Your comment will not appear until it has been approved."
+ When I fill in "Comment" with "Perfectly nice comment"
+ And I fill in "Guest name" with "lovely"
+ And I fill in "Guest email" with "email@example.com"
+ And I press "Comment"
+ Then I should see "Your comment was received! It will appear publicly after it has been approved."
+ And I should be on the "Default Admin Post" admin post page
+ And 1 email should be delivered to "admin@example.org"
+
+ # Leave a logged in comment on a moderated admin post
+ When I am logged in as "commenter"
+ And I go to the "Default Admin Post" admin post page
+ Then I should see "Comments on this news post are moderated. Your comment will not appear until it has been approved."
+ When I fill in "Comment" with "Second perfectly nice comment"
+ And I press "Comment"
+ Then I should see "Your comment was received! It will appear publicly after it has been approved."
+ And I should see "Second perfectly nice comment"
+
+ # Access unreviewed comments
+ When I am logged in as a "legal" admin
+ And I go to the "Default Admin Post" admin post page
+ And I follow "Unreviewed Comments (2)"
+ Then I should see "Unreviewed Comments on Default Admin Post"
+ And I should see "Please note that comments cannot be unapproved once you have approved them. After you delete any comments you do not wish to appear on the news post, you can approve all that remain."
+
+ # Approve a single comment
+ When I press "Approve"
+ Then I should see "Comment approved."
+ And I should be on the unreviewed comments page for the admin post "Default Admin Post"
+ When I go to the "Default Admin Post" admin post page
+ Then I should see "Comments (1)"
+ And I should see "Unreviewed Comments (1)"
+
+ # Approve All Unreviewed Comments
+ When I go to the unreviewed comments page for the admin post "Default Admin Post"
+ And I press "Approve All Unreviewed Comments"
+ Then I should see "All moderated comments approved."
+ And I should be on the "Default Admin Post" admin post page
+ And I should see "Comments (2)"
+ And I should not see "Unreviewed Comments"
+
diff --git a/features/admins/admin_reorder_faq.feature b/features/admins/admin_reorder_faq.feature
new file mode 100755
index 0000000..0073cb5
--- /dev/null
+++ b/features/admins/admin_reorder_faq.feature
@@ -0,0 +1,19 @@
+@admin @faqs
+Feature: Rearrange Archive FAQs
+ In order to manage the Archive FAQs
+ As an admin
+ I want to be able to reorder the FAQs
+
+ Scenario: Rearrange FAQs
+ Given I am logged in as a "superadmin" admin
+ And 3 Archive FAQs exist
+ When I go to the FAQ reorder page
+ And I fill in "archive_faqs_1" with "3"
+ And I fill in "archive_faqs_2" with "1"
+ And I fill in "archive_faqs_3" with "2"
+ And I press "Update Positions"
+ Then I should see "Archive FAQs order was successfully updated"
+ When I follow "Reorder FAQs"
+ Then I should see "1. The 2 FAQ"
+ And I should see "2. The 3 FAQ"
+ And I should see "3. The 1 FAQ"
diff --git a/features/admins/admin_reorder_faq_questions.feature b/features/admins/admin_reorder_faq_questions.feature
new file mode 100755
index 0000000..2142bcc
--- /dev/null
+++ b/features/admins/admin_reorder_faq_questions.feature
@@ -0,0 +1,28 @@
+@admin
+Feature: Admin Actions to re-order questions in a FAQ Category
+ As an an admin
+ I want to be able to re-order the questions in a FAQ Category
+
+Scenario: Re-order the questions in a FAQ Category
+ Given I am logged in as a "superadmin" admin
+ And I make a multi-question FAQ post
+ When I go to the archive_faqs page
+ And I follow "Standard FAQ Category"
+ And I follow "Edit"
+ And I follow "Reorder Questions"
+ # First confirm the current order of the questions
+ Then I should see "1. Number 1 Question."
+ And I should see "2. Number 2 Question."
+ And I should see "3. Number 3 Question."
+ # Flip the order of the questions
+ And I fill in "questions_1" with "3"
+ And I fill in "questions_2" with "2"
+ And I fill in "questions_3" with "1"
+ And I press "Update Positions"
+ When I follow "Edit"
+ And I follow "Reorder Questions"
+ # Confirm the questions are in the new reversed order
+ Then I should see "1. Number 3 Question."
+ And I should see "2. Number 2 Question."
+ And I should see "3. Number 1 Question."
+
diff --git a/features/admins/admin_settings.feature b/features/admins/admin_settings.feature
new file mode 100755
index 0000000..beb50a7
--- /dev/null
+++ b/features/admins/admin_settings.feature
@@ -0,0 +1,169 @@
+@admin
+Feature: Admin Settings Page
+ In order to improve performance
+ As an admin
+ I want to be able to control downloading, tag wrangling and guest comments.
+
+ Scenario: Turn off downloads
+ Given downloads are off
+ And I have a work "Storytime"
+ When I log out
+ And I view the work "Storytime"
+ Then I should not see "Download"
+ When I am logged in as "tester"
+ And I view the work "Storytime"
+ Then I should not see "Download"
+
+ Scenario: Turn off tag wrangling
+ Given tag wrangling is off
+ And the following activated tag wrangler exists
+ | login |
+ | dizmo |
+ And a canonical character "Ianto Jones"
+ When I am logged in as "dizmo"
+ And I edit the tag "Ianto Jones"
+ Then I should see "Wrangling is disabled at the moment. Please check back later."
+ And I should not see "Synonym of"
+
+ Scenario: Turn off Support form
+ Given the support form is disabled and its text field set to "Please don't contact us"
+ When I am logged in as a random user
+ And I go to the support page
+ Then I should see "Please don't contact us"
+
+ Scenario: Turn on Support form
+ Given the support form is enabled
+ When I am logged in as a random user
+ And I go to the support page
+ Then I should see "We can answer Support inquiries in"
+
+ Scenario Outline: Guests can comment when guest coments are enabled
+ Given guest comments are on
+ And I am logged out
+ And
+ And with guest comments enabled
+ And I view with comments
+ When I post a guest comment
+ Then I should see a link "Reply"
+
+ Examples:
+ | commentable |
+ | the work "Generic Work" |
+ | the admin post "Generic Post" |
+
+ Scenario Outline: Guests cannot comment when guest comments are disabled, even if works or admin posts allow comments
+ Given guest comments are off
+ And I am logged out
+ And
+ And with guest comments enabled
+ And a guest comment on
+ When I view with comments
+ Then I should see "Sorry, the Archive doesn't allow guests to comment right now."
+ And I should not see a link "Reply"
+ When I am logged in
+ And I view with comments
+ Then I should not see "Sorry, the Archive doesn't allow guests to comment right now."
+ When I am logged in as a super admin
+ And I view with comments
+ Then I should not see "Sorry, the Archive doesn't allow guests to comment right now."
+
+ Examples:
+ | commentable |
+ | the work "Generic Work" |
+ | the admin post "Generic Post" |
+
+ Scenario: Turn off guest comments (when the work itself does not allow guest comments)
+ Given guest comments are off
+ And I am logged in as "author"
+ And I set up the draft "Generic Work"
+ And I choose "Only registered users can comment"
+ And I post the work without preview
+ And a comment "Nice job" by "user" on the work "Generic Work"
+ When I am logged out
+ And I view the work "Generic Work" with comments
+ Then I should see "Sorry, the Archive doesn't allow guests to comment right now."
+ And I should not see a link "Reply"
+ When I am logged in
+ And I view the work "Generic Work" with comments
+ Then I should not see "Sorry, the Archive doesn't allow guests to comment right now."
+ When I am logged in as a super admin
+ And I view the work "Generic Work" with comments
+ Then I should not see "Sorry, the Archive doesn't allow guests to comment right now."
+
+ Scenario: Turn off guest comments (when the admin post itself does not allow guest comments)
+ Given guest comments are off
+ And I have posted an admin post with guest comments disabled
+ And a comment "Nice job" by "user" on the admin post "Default Admin Post"
+ When I view the admin post "Default Admin Post" with comments
+ Then I should see "Sorry, the Archive doesn't allow guests to comment right now."
+ And I should not see a link "Reply"
+ When I am logged in
+ And I view the admin post "Default Admin Post" with comments
+ Then I should not see "Sorry, the Archive doesn't allow guests to comment right now."
+ When I am logged in as a super admin
+ And I view the admin post "Default Admin Post" with comments
+ Then I should not see "Sorry, the Archive doesn't allow guests to comment right now."
+
+ Scenario: Turn off guest comments (when work itself does not allow any comments)
+ Given guest comments are off
+ And I am logged in as "author"
+ And I post the work "Generic Work"
+ And a guest comment on the work "Generic Work"
+ And I edit the work "Generic Work"
+ And I choose "No one can comment"
+ And I press "Post"
+ When I am logged out
+ And I view the work "Generic Work" with comments
+ Then I should see "Sorry, the Archive doesn't allow guests to comment right now."
+ And I should not see a link "Reply"
+ When I am logged in
+ And I view the work "Generic Work" with comments
+ Then I should not see "Sorry, the Archive doesn't allow guests to comment right now."
+ When I am logged in as a super admin
+ And I view the work "Generic Work" with comments
+ Then I should not see "Sorry, the Archive doesn't allow guests to comment right now."
+
+ Scenario: Turn off guest comments (when the admin post itself does not allow any comments)
+ Given guest comments are off
+ And I have posted an admin post with comments disabled
+ And a comment "Nice job" by "user" on the admin post "Default Admin Post"
+ When I view the admin post "Default Admin Post" with comments
+ Then I should see "Sorry, the Archive doesn't allow guests to comment right now."
+ And I should not see a link "Reply"
+ When I am logged in
+ And I view the admin post "Default Admin Post" with comments
+ Then I should not see "Sorry, the Archive doesn't allow guests to comment right now."
+ When I am logged in as a super admin
+ And I view the admin post "Default Admin Post" with comments
+ Then I should not see "Sorry, the Archive doesn't allow guests to comment right now."
+
+ Scenario: Tag comments are not affected when guest comments are turned off
+ Given guest comments are off
+ And a fandom exists with name: "Stargate SG-1", canonical: true
+ When I am logged in as a tag wrangler
+ And I view the tag "Stargate SG-1" with comments
+ Then I should not see "Sorry, the Archive doesn't allow guests to comment right now."
+ When I post the comment "Sent you a syn" on the tag "Stargate SG-1"
+ Then I should see "Comment created!"
+
+ Scenario: Timestamp and admin for last update is not affected by invitation sending
+ Given time is frozen at 2025-04-12 17:00
+ And the invitation queue is enabled
+ And an invitation request for "invitee@example.org"
+ And an invitation request for "invitee2@example.org"
+ And an invitation request for "invitee3@example.org"
+ And I am logged in as a "superadmin" admin
+ When I go to the admin-settings page
+ And I fill in "Number of people to invite from the queue at once" with "2"
+ And I fill in "How often (in hours) should we invite people from the queue" with "1"
+ And I press "Update"
+ Then I should see "Settings last updated on 2025-04-12 17:00:00 UTC by testadmin-superadmin."
+ And I should see "2 people are scheduled to be sent invitations at April 12, 2025 18:00."
+ When time is frozen at 2025-04-14 03:00
+ And I go to the admin-settings page
+ Then I should see "Settings last updated on 2025-04-12 17:00:00 UTC by testadmin-superadmin."
+ And I should see "2 people are scheduled to be sent invitations at April 12, 2025 18:00."
+ When the scheduled check_invite_queue job is run
+ And I go to the admin-settings page
+ Then I should see "Settings last updated on 2025-04-12 17:00:00 UTC by testadmin-superadmin."
+ And I should see "2 people are scheduled to be sent invitations at April 14, 2025 04:00."
diff --git a/features/admins/admin_skins.feature b/features/admins/admin_skins.feature
new file mode 100755
index 0000000..a4b1359
--- /dev/null
+++ b/features/admins/admin_skins.feature
@@ -0,0 +1,151 @@
+@set-default-skin
+Feature: Admin manage skins
+
+ Scenario: Users should not be able to see the admin skins page
+ Given I am logged in as "skinner"
+ When I go to admin's skins page
+ Then I should see "I'm sorry, only an admin can look at that area"
+
+ Scenario: Admin can cache and uncache a public skin
+ Given basic skins
+ And the approved public skin "public skin"
+ And I am logged in as a "superadmin" admin
+ When I follow "Approved Skins"
+ And I check "Cache"
+ Then I press "Update"
+ And I should see "The following skins were updated: public skin"
+ When I follow "Approved Skins"
+ And I check "Uncache"
+ And I press "Update"
+ Then I should see "The following skins were updated: public skin"
+
+ Scenario: Admin can add a public skin to the chooser and then remove it
+ Given the approved public skin "public skin"
+ And the skin "public skin" is cached
+ When I am logged in as a "superadmin" admin
+ Then I should not see the skin chooser
+ When I follow "Approved Skins"
+ And I check "Chooser"
+ And I press "Update"
+ Then I should see "The following skins were updated: public skin"
+ And I should see the skin "public skin" in the skin chooser
+ When I follow "Approved Skins"
+ And I check "Not In Chooser"
+ And I press "Update"
+ Then I should see "The following skins were updated: public skin"
+ And I should not see the skin chooser
+
+ Scenario: An admin can reject and unreject a skin
+ Given the unapproved public skin "public skin"
+ And I am logged in as a "superadmin" admin
+ When I go to admin's skins page
+ And I check "make_rejected_public_skin"
+ And I submit
+ Then I should see "The following skins were updated: public skin"
+ When I follow "Rejected Skins"
+ Then I should see "public skin"
+ When I check "make_unrejected_public_skin"
+ And I submit
+ Then I should see "The following skins were updated: public skin"
+ When I follow "Rejected Skins"
+ Then I should not see "public skin"
+
+ Scenario: An admin can feature and unfeature skin
+ Given the approved public skin "public skin"
+ And I am logged in as a "superadmin" admin
+ When I follow "Approved Skins"
+ And I check "Feature"
+ And I submit
+ Then I should see "The following skins were updated: public skin"
+ When I follow "Approved Skins"
+ And I check "Unfeature"
+ And I submit
+ Then I should see "The following skins were updated: public skin"
+
+ Scenario: Admins should be able to see public skins in the admin skins page
+ Given the unapproved public skin "public skin"
+ And I am logged in as a "superadmin" admin
+ When I go to admin's skins page
+ Then I should see "public skin" within "table#unapproved_skins"
+
+ Scenario: Admins should not be able to edit unapproved skins
+ Given the unapproved public skin "public skin"
+ And I am logged in as a "superadmin" admin
+ When I go to "public skin" skin page
+ Then I should not see "Edit"
+ And I should not see "Delete"
+ When I go to "public skin" edit skin page
+ Then I should see "Sorry, you don't have permission"
+
+ Scenario: Admins should be able to approve public skins
+ Given the unapproved public skin "public skin"
+ And I am logged in as a "superadmin" admin
+ When I go to admin's skins page
+ And I check "public skin"
+ And I submit
+ Then I should see "The following skins were updated: public skin"
+ When I follow "Approved Skins"
+ Then I should see "public skin" within "table#approved_skins"
+
+ Scenario: Admins should be able to edit but not delete public approved skins
+ Given the approved public skin "public skin" with css "#title { text-decoration: blink;}"
+ And I am logged in as a "superadmin" admin
+ When I go to "public skin" skin page
+ Then I should see "Edit"
+ But I should not see "Delete"
+ When I follow "Edit"
+ And I fill in "CSS" with "#greeting.logged-in { text-decoration: blink;}"
+ And I fill in "Description" with "Blinky love (admin modified)"
+ And I submit
+ Then I should see an update confirmation message
+ And I should see "(admin modified)"
+ And I should see "#greeting.logged-in"
+ And I should not see "#title"
+ Then the cache of the skin on "public skin" should expire after I save the skin
+
+ Scenario: Admins should be able to unapprove public skins, which should also
+ remove them from preferences
+ Given "skinuser" is using the approved public skin "public skin" with css "#title { text-decoration: blink;}"
+ When I unapprove the skin "public skin"
+ Then I should see "The following skins were updated: public skin"
+ And I should see "public skin" within "table#unapproved_skins"
+ When I am logged in as "skinuser"
+ And I am on skinuser's preferences page
+ Then "Default" should be selected within "preference_skin_id"
+ And I should not see "#title"
+ And I should not see "text-decoration: blink;"
+
+ Scenario: Admin can change the default skin
+ Given basic skins
+ And the approved public skin "strange skin" with css "#title { text-decoration: underline;}"
+ And the approved public skin "public skin" with css "#title { text-decoration: blink;}"
+ And I am logged in as "skinner"
+ And the user "KnownUser" exists and is activated
+ When I am on skinner's preferences page
+ And I select "strange skin" from "preference_skin_id"
+ And I submit
+ Then I should see "{ text-decoration: underline; }" in the page style
+ When I am logged in as a "superadmin" admin
+ Then I should not see "{ text-decoration: blink; }" in the page style
+ When I follow "Approved Skins"
+ And I fill in "set_default" with "public skin"
+ And I press "Update"
+ Then I should see "Default skin changed to public skin"
+ And I should see "{ text-decoration: blink; }" in the page style
+ When I am logged in as "skinner"
+ Then I should see "{ text-decoration: underline; }" in the page style
+ # A user created before changing the default skin will still have the same skin
+ When I am logged in as "KnownUser"
+ Then I should not see "{ text-decoration: blink; }" in the page style
+
+ Scenario: Admin can edit a skin with the word "archive" in the title
+ Given the approved public skin "official archive skin" has reserved words in the title
+ And I am logged in as a "superadmin" admin
+ When I go to "official archive skin" skin page
+ And I follow "Edit"
+ And I fill in "CSS" with "#greeting.logged-in { text-decoration: blink;}"
+ And I fill in "Description" with "all your skin are belong to us"
+ And I submit
+ Then I should see an update confirmation message
+ And I should see "all your skin are belong to us"
+ And I should see "#greeting.logged-in"
diff --git a/features/admins/admin_spam.feature b/features/admins/admin_spam.feature
new file mode 100755
index 0000000..f3946ac
--- /dev/null
+++ b/features/admins/admin_spam.feature
@@ -0,0 +1,74 @@
+@admin
+Feature: Admin spam management
+ In order to manage spam works
+ As an an admin
+ I want to be able to view and update works marked as spam
+
+Scenario: Review spam when spam works are already hidden
+ Given the following admin settings are configured:
+ | hide_spam | 1 |
+ And the spam work "Spammity Spam"
+ And the spam work "Totally Legit"
+ And the work "Spammity Spam" should be hidden
+ And the work "Totally Legit" should be hidden
+ And I am logged in as a "superadmin" admin
+ And all emails have been delivered
+ Then I should see "Spam"
+ When I follow "Spam"
+ Then I should see "Works Marked as Spam"
+ And I should see "Spammity"
+ And I should see "Totally Legit"
+ When I check "spam_1"
+ And I check "ham_2"
+ And I press "Update Works"
+ Then I should not see "Spammity"
+ And I should not see "Totally Legit"
+ And the work "Spammity Spam" should be hidden
+ And the work "Totally Legit" should not be hidden
+ And 0 emails should be delivered
+
+
+Scenario: Review spam when spam works are not already hidden
+ Given the following admin settings are configured:
+ | hide_spam | 0 |
+ And the spam work "Spammity Spam"
+ And the spam work "Totally Legit"
+ And the work "Spammity Spam" should not be hidden
+ And the work "Totally Legit" should not be hidden
+ And I am logged in as a "superadmin" admin
+ And all emails have been delivered
+ Then I should see "Spam"
+ When I follow "Spam"
+ Then I should see "Works Marked as Spam"
+ And I should see "Spammity"
+ And I should see "Totally Legit"
+ When I check "spam_3"
+ And I check "ham_4"
+ And I press "Update Works"
+ Then I should not see "Spammity"
+ And I should not see "Totally Legit"
+ And the work "Spammity Spam" should be hidden
+ And the work "Totally Legit" should not be hidden
+ And 1 email should be delivered
+ And the email should contain "has been flagged by our automated system as spam"
+
+Scenario: Translated work hidden as spam email
+ Given I am logged in as "spammer"
+ And the work "Spammity Spam Work" by "spammer"
+ And a locale with translated emails
+ And the user "spammer" enables translated emails
+ And I add the co-author "Another" to the work "Spammity Spam Work"
+ When I am logged in as a "policy_and_abuse" admin
+ And all emails have been delivered
+ And I view the work "Spammity Spam Work"
+ Then I should see "Mark As Spam"
+ When I follow "Mark As Spam"
+ Then I should see "marked as spam and hidden"
+ And I should see "Mark Not Spam"
+ And the work "Spammity Spam Work" should be marked as spam
+ And the work "Spammity Spam Work" should be hidden
+ And 2 emails should be delivered
+ And the email to "spammer" should contain "has been flagged by our automated system as spam"
+ And the email to "spammer" should be translated
+ And the email to "Another" should contain "has been flagged by our automated system as spam"
+ And the email to "Another" should be non-translated
diff --git a/features/admins/admin_tasks.feature b/features/admins/admin_tasks.feature
new file mode 100755
index 0000000..b53ee80
--- /dev/null
+++ b/features/admins/admin_tasks.feature
@@ -0,0 +1,12 @@
+@admin
+Feature: Admin tasks
+ Scenario: admin goes to the Support page
+
+ Given I am logged in as an admin
+ Then cookie "admin_credentials" should be like "1"
+ When I go to the support page
+ Then I should see "Support and Feedback"
+ And I should see "testadmin@example.org" in the "feedback_email" input
+ When I log out
+ And I am on the home page
+ Then cookie "admin_credentials" should be deleted
diff --git a/features/admins/admin_works.feature b/features/admins/admin_works.feature
new file mode 100755
index 0000000..c1bbf5e
--- /dev/null
+++ b/features/admins/admin_works.feature
@@ -0,0 +1,641 @@
+@admin
+Feature: Admin Actions for Works, Comments, Series, Bookmarks
+ As an admin
+ I should be able to perform special actions
+
+ Scenario: Can troubleshoot works
+ Given the work "Just a work you know"
+ When I am logged in as a "support" admin
+ And I view the work "Just a work you know"
+ And I follow "Troubleshoot"
+ And I check "Reindex Work"
+ And I press "Troubleshoot"
+ Then I should see "Work sent to be reindexed."
+
+ Scenario Outline: Can hide works
+ Given I am logged in as "regular_user"
+ And I post the work "ToS Violation"
+ And a locale with translated emails
+ And the user "regular_user" enables translated emails
+ And I add the co-author "Another" to the work "ToS Violation"
+ When I am logged in as a "" admin
+ And all emails have been delivered
+ And I view the work "ToS Violation"
+ And I follow "Hide Work"
+ Then I should see "Item has been hidden."
+ And the work "ToS Violation" should be hidden
+ And "regular_user" should see their work "ToS Violation" is hidden
+ And 2 emails should be delivered
+ And the email to "regular_user" should contain "you will be required to take action to correct the violation"
+ And the email to "regular_user" should be translated
+ And the email to "Another" should contain "you will be required to take action to correct the violation"
+ And the email to "Another" should be non-translated
+
+ Examples:
+ | role |
+ | superadmin |
+ | legal |
+ | policy_and_abuse |
+
+ Scenario Outline: Can hide works already marked as spam
+ Given the work "ToS Violation + Spam" by "regular_user"
+ And the work "ToS Violation + Spam" is marked as spam
+ When I am logged in as a "" admin
+ And all emails have been delivered
+ And I view the work "ToS Violation + Spam"
+ And I follow "Hide Work"
+ Then I should see "Item has been hidden."
+ And logged out users should not see the hidden work "ToS Violation + Spam" by "regular_user"
+ And logged in users should not see the hidden work "ToS Violation + Spam" by "regular_user"
+ And "regular_user" should see their work "ToS Violation + Spam" is hidden
+ And 1 email should be delivered
+ And the email should contain "you will be required to take action to correct the violation"
+
+ Examples:
+ | role |
+ | superadmin |
+ | legal |
+ | policy_and_abuse |
+
+ Scenario Outline: Can unhide works
+ Given the work "ToS Violation" by "regular_user"
+ When I am logged in as a "" admin
+ And I view the work "ToS Violation"
+ And I follow "Hide Work"
+ And all indexing jobs have been run
+ And all emails have been delivered
+ Then I should see "Item has been hidden."
+ When I follow "Make Work Visible"
+ And all indexing jobs have been run
+ Then I should see "Item is no longer hidden."
+ And logged out users should see the unhidden work "ToS Violation" by "regular_user"
+ And logged in users should see the unhidden work "ToS Violation" by "regular_user"
+ And 0 emails should be delivered
+
+ Examples:
+ | role |
+ | superadmin |
+ | legal |
+ | policy_and_abuse |
+
+ Scenario: Deleting works as a Policy & Abuse admin
+ Given I am logged in as "regular_user"
+ And I post the work "ToS Violation"
+ And a locale with translated emails
+ And the user "regular_user" enables translated emails
+ And I add the co-author "Another" to the work "ToS Violation"
+ When I am logged in as a "policy_and_abuse" admin
+ # Don't let the admin password email mess up the count.
+ And all emails have been delivered
+ And I view the work "ToS Violation"
+ And I follow "Delete Work"
+ And all indexing jobs have been run
+ Then I should see "Item was successfully deleted."
+ And 2 emails should be delivered
+ And the email to "regular_user" should contain "deleted from the Archive by a site admin"
+ And the email to "regular_user" should be translated
+ And the email to "Another" should contain "deleted from the Archive by a site admin"
+ And the email to "Another" should be non-translated
+ When I visit the last activities item
+ Then I should see "destroy"
+ And I should see "#" admin
+ And I am on bad_user's bookmarks page
+ When I follow "Hide Bookmark"
+ And all indexing jobs have been run
+ Then I should see "Item has been hidden."
+ And I should see "Make Bookmark Visible"
+ And I should see "Rude comment"
+ When I am logged in as "regular_user" with password "password1"
+ And I am on bad_user's bookmarks page
+ Then I should not see "Rude comment"
+
+ Examples:
+ | role |
+ | superadmin |
+ | legal |
+ | policy_and_abuse |
+
+ Scenario Outline: Deleting bookmarks
+ Given the work "A Nice Work"
+ When I am logged in as "bad_user"
+ And I view the work "A Nice Work"
+ When I follow "Bookmark"
+ And I fill in "bookmark_notes" with "Rude comment"
+ And I press "Create"
+ And all indexing jobs have been run
+ Then I should see "Bookmark was successfully created"
+ When I am logged in as a "" admin
+ And I am on bad_user's bookmarks page
+ And I follow "Delete Bookmark"
+ Then I should see "Item was successfully deleted."
+ When I am logged in as "bad_user"
+ And I am on bad_user's bookmarks page
+ Then I should not see "Rude comment"
+
+ Examples:
+ | role |
+ | superadmin |
+ | legal |
+ | policy_and_abuse |
+
+ Scenario: Can edit tags on works
+ Given I am logged in as "regular_user"
+ And I post the work "Changes" with fandom "User-Added Fandom" with freeform "User-Added Freeform" with category "M/M"
+ When I am logged in as a "policy_and_abuse" admin
+ And I view the work "Changes"
+ And I follow "Edit Tags and Language"
+ When I select "Mature" from "Rating"
+ And I uncheck "No Archive Warnings Apply"
+ And I check "Choose Not To Use Archive Warnings"
+ And I fill in "Fandoms" with "Admin-Added Fandom"
+ And I fill in "Relationships" with "Admin-Added Relationship"
+ And I fill in "Characters" with "Admin-Added Character"
+ And I fill in "Additional Tags" with "Admin-Added Freeform"
+ And I uncheck "M/M"
+ And I check "Other"
+ When I press "Post"
+ Then I should not see "User-Added Fandom"
+ And I should see "Admin-Added Fandom"
+ And I should not see "User-Added Freeform"
+ And I should see "Admin-Added Freeform"
+ And I should not see "M/M"
+ And I should see "Other"
+ And I should not see "No Archive Warnings Apply"
+ And I should see "Creator Chose Not To Use Archive Warnings"
+ And I should not see "Not Rated"
+ And I should see "Mature"
+ And I should see "Admin-Added Relationship"
+ And I should see "Admin-Added Character"
+ When I follow "Activities"
+ Then I should see "Admin Activities"
+ When I visit the last activities item
+ Then I should see "No Archive Warnings Apply"
+ And I should see "Old tags"
+ And I should see "User-Added Fandom"
+ And I should not see "Admin-Added Fandom"
+
+ Scenario: Can edit external works
+ Given basic languages
+ And I am logged in as "regular_user"
+ And I bookmark the external work "External Changes"
+ When I am logged in as a "policy_and_abuse" admin
+ And I view the external work "External Changes"
+ And I follow "Edit External Work"
+ When I fill in "Creator" with "Admin-Added Creator"
+ And I fill in "Title" with "Admin-Added Title"
+ And I fill in "Creator's Summary" with "Admin-added summary"
+ And I select "Mature" from "Rating"
+ And I check "No Archive Warnings Apply"
+ And I fill in "Fandoms" with "Admin-Added Fandom"
+ And I fill in "Relationships" with "Admin-Added Relationship"
+ And I fill in "Characters" with "Admin-Added Character"
+ And I fill in "Additional Tags" with "Admin-Added Freeform"
+ And I check "M/M"
+ And I select "Deutsch" from "Language"
+ And it is currently 1 second from now
+ When I press "Update External work"
+ Then I should see "Admin-Added Creator"
+ And I should see "Admin-Added Title"
+ And I should see "Admin-added summary"
+ And I should see "Mature"
+ And I should see "No Archive Warnings"
+ And I should see "Admin-Added Fandom"
+ And I should see "Admin-Added Character"
+ And I should see "Admin-Added Freeform"
+ And I should see "M/M"
+ And I should see "Language: Deutsch"
+
+ Scenario Outline: Hiding and un-hiding external works
+ Given I am logged in as "regular_user"
+ And I bookmark the external work "External Changes"
+ When I am logged in as a "" admin
+ And I view the external work "External Changes"
+ And I follow "Hide External Work"
+ Then I should see "Item has been hidden."
+ And I should see "Make External Work Visible"
+ When I follow "Make External Work Visible"
+ Then I should see "Item is no longer hidden."
+
+ Examples:
+ | role |
+ | superadmin |
+ | legal |
+ | policy_and_abuse |
+
+ Scenario Outline: Deleting external works
+ Given I am logged in as "regular_user"
+ And I bookmark the external work "External Changes"
+ When I am logged in as a "" admin
+ And I view the external work "External Changes"
+ And I follow "Delete External Work"
+ Then I should see "Item was successfully deleted."
+
+ Examples:
+ | role |
+ | superadmin |
+ | legal |
+ | policy_and_abuse |
+
+ Scenario: Can mark a comment as spam
+ Given I have no works or comments
+ And the following activated users exist
+ | login | password |
+ | author | password |
+ | commenter | password |
+
+ # set up a work with a genuine comment
+
+ When I am logged in as "author"
+ And I post the work "The One Where Neal is Awesome"
+ When I am logged in as "commenter"
+ And I view the work "The One Where Neal is Awesome"
+ And I fill in "Comment" with "I loved this!"
+ And I press "Comment"
+ Then I should see "Comment created!"
+
+ # comment from registered user cannot be marked as spam.
+ # If registered user is spamming, this goes to Abuse team as ToS violation
+ When I am logged in as a "policy_and_abuse" admin
+ Then I should see "Successfully logged in"
+ When I view the work "The One Where Neal is Awesome"
+ And I follow "Comments (1)"
+ Then I should not see "Spam" within "#feedback"
+
+ # now mark a comment as spam
+ When I post the comment "Would you like a genuine rolex" on the work "The One Where Neal is Awesome" as a guest
+ And I am logged in as a "policy_and_abuse" admin
+ And I view the work "The One Where Neal is Awesome"
+ And I follow "Comments (2)"
+ Then I should see "rolex"
+ And I should see "Spam" within "#feedback"
+ And it is currently 1 second from now
+ When I follow "Spam" within "#feedback"
+ # Can see link to unmark
+ Then I should see "Not Spam"
+ And I should see "Hide Comments (1)"
+ # Admin can still see spam comment
+ And I should see "rolex"
+ And I should see "This comment has been marked as spam."
+ # proper content should still be there
+ And I should see "I loved this!"
+
+ # user can't see spam comment
+ When I log out
+ And I view the work "The One Where Neal is Awesome"
+ Then I should see "Comments (1)"
+ When I follow "Comments (1)"
+ Then I should not see "rolex"
+ And I should see "I loved this!"
+
+ # author can't see spam comment
+ When I am logged in as "author" with password "password"
+ And I view the work "The One Where Neal is Awesome"
+ Then I should see "Comments (1)"
+ When I follow "Comments (1)"
+ Then I should not see "rolex"
+ And I should see "I loved this!"
+
+ # now mark comment as not spam
+ When I am logged in as a "policy_and_abuse" admin
+ And I view the work "The One Where Neal is Awesome"
+ And I follow "Comments (1)"
+ And it is currently 1 second from now
+ And I follow "Not Spam"
+ Then I should see "Hide Comments (2)"
+ And I should not see "Not Spam"
+ And I should not see "This comment has been marked as spam."
+
+ # user can see comment again
+ When I log out
+ And I view the work "The One Where Neal is Awesome"
+ Then I should see "Comments (2)"
+ When I follow "Comments (2)"
+ Then I should see "rolex"
+ And I should not see "This comment has been marked as spam."
+
+ # author can see comment again
+ When I am logged in as "author" with password "password"
+ And I view the work "The One Where Neal is Awesome"
+ Then I should see "Comments (2)"
+ When I follow "Comments (2)"
+ Then I should see "rolex"
+ And I should not see "This comment has been marked as spam."
+
+ Scenario: Moderated comments cannot be approved by admin
+ Given the moderated work "Moderation" by "author"
+ And I am logged in as "commenter"
+ And I post the comment "Test comment" on the work "Moderation"
+ When I am logged in as a "superadmin" admin
+ And I view the work "Moderation"
+ Then I should see "Unreviewed Comments (1)"
+ And the comment on "Moderation" should be marked as unreviewed
+ When I follow "Unreviewed Comments (1)"
+ Then I should see "Test comment"
+ And I should not see an "Approve All Unreviewed Comments" button
+ And I should not see an "Approve" button
+
+ Scenario: Admin can edit language on works when posting without previewing
+ Given basic languages
+ And the work "Wrong Language"
+ When I am logged in as a "policy_and_abuse" admin
+ And I view the work "Wrong Language"
+ And I follow "Edit Tags and Language"
+ Then I should see "Edit Work Tags and Language for "
+ When I select "Deutsch" from "Choose a language"
+ And I press "Post"
+ Then I should see "Deutsch"
+ And I should not see "English"
+
+ Scenario: Admin can edit language on works when previewing first
+ Given basic languages
+ And the work "Wrong Language"
+ When I am logged in as a "policy_and_abuse" admin
+ And I view the work "Wrong Language"
+ And I follow "Edit Tags and Language"
+ When I select "Deutsch" from "Choose a language"
+ And I press "Preview"
+ Then I should see "Preview Tags and Language"
+ When I press "Update"
+ Then I should see "Deutsch"
+ And I should not see "English"
+
+ Scenario: can mark a work as spam
+ Given the work "Spammity Spam"
+ And I am logged in as a "policy_and_abuse" admin
+ And I view the work "Spammity Spam"
+ And all emails have been delivered
+ Then I should see "Mark As Spam"
+ When I follow "Mark As Spam"
+ Then I should see "marked as spam and hidden"
+ And I should see "Mark Not Spam"
+ And the work "Spammity Spam" should be marked as spam
+ And the work "Spammity Spam" should be hidden
+ And 1 email should be delivered
+ And the email should contain "has been flagged by our automated system as spam"
+
+
+ Scenario: can mark a spam work as not-spam
+ Given the spam work "Spammity Spam"
+ And I am logged in as a "policy_and_abuse" admin
+ And I view the work "Spammity Spam"
+ Then I should see "Mark Not Spam"
+ When I follow "Mark Not Spam"
+ Then I should see "marked not spam and unhidden"
+ And I should see "Mark As Spam"
+ And the work "Spammity Spam" should not be marked as spam
+ And the work "Spammity Spam" should not be hidden
+
+ Scenario Outline: Admin can hide a series (e.g. if the series description or notes contain a TOS Violation)
+ Given I am logged in as "tosser"
+ And I add the work "Legit Work" to series "Violation"
+ When I am logged in as a "" admin
+ And I view the series "Violation"
+ And I follow "Hide Series"
+ Then I should see "Item has been hidden."
+ And I should see the image "title" text "Hidden by Administrator"
+ And I should see "Make Series Visible"
+ When I log out
+ And I go to tosser's series page
+ Then I should see "Series (0)"
+ And I should not see "Violation"
+ When I view the series "Violation"
+ Then I should see "Sorry, you don't have permission to access the page you were trying to reach."
+ When I am logged in as "other_user"
+ And I go to tosser's series page
+ Then I should see "Series (0)"
+ And I should not see "Violation"
+ When I view the series "Violation"
+ Then I should see "Sorry, you don't have permission to access the page you were trying to reach."
+ When I am logged in as "tosser"
+ And I go to tosser's series page
+ Then I should see "Series (0)"
+ And I should not see "Violation"
+ When I view the series "Violation"
+ Then I should see the image "title" text "Hidden by Administrator"
+
+ Examples:
+ | role |
+ | superadmin |
+ | legal |
+ | policy_and_abuse |
+
+ Scenario Outline: Admin can un-hide a series
+ Given I am logged in as "tosser"
+ And I add the work "Legit Work" to series "Violation"
+ And I am logged in as a "" admin
+ And I view the series "Violation"
+ And I follow "Hide Series"
+ When I follow "Make Series Visible"
+ Then I should see "Item is no longer hidden."
+ And I should not see the image "title" text "Hidden by Administrator"
+ And I should see "Hide Series"
+ When I log out
+ And I go to tosser's series page
+ Then I should see "Series (1)"
+ And I should see "Violation"
+ When I view the series "Violation"
+ Then I should see "Violation"
+ When I am logged in as "other_user"
+ And I go to tosser's series page
+ Then I should see "Series (1)"
+ And I should see "Violation"
+ When I view the series "Violation"
+ Then I should see "Violation"
+ When I am logged in as "tosser"
+ And I go to tosser's series page
+ Then I should see "Series (1)"
+ And I should see "Violation"
+ When I view the series "Violation"
+ Then I should see "Violation"
+
+ Examples:
+ | role |
+ | superadmin |
+ | legal |
+ | policy_and_abuse |
+
+ Scenario Outline: Deleting series
+ Given I am logged in as "tosser"
+ And I add the work "Legit Work" to series "Violation"
+ And I am logged in as a "" admin
+ When I view the series "Violation"
+ And I follow "Delete Series"
+ Then I should see "Item was successfully deleted."
+ When I log out
+ And I go to tosser's series page
+ Then I should see "Series (0)"
+ And I should not see "Violation"
+
+ Examples:
+ | role |
+ | superadmin |
+ | legal |
+ | policy_and_abuse |
+
+ Scenario: Admins can see when a work has too many tags
+ Given the user-defined tag limit is 7
+ And the work "Under the Limit"
+ And the work "Over the Limit"
+ And the work "Over the Limit" has 2 fandom tags
+ And the work "Over the Limit" has 2 character tags
+ And the work "Over the Limit" has 2 relationship tags
+ And the work "Over the Limit" has 2 freeform tags
+ When I am logged in as an admin
+ And I view the work "Under the Limit"
+ Then I should see "Over Tag Limit: No"
+ When I view the work "Over the Limit"
+ Then I should see "Over Tag Limit: Yes"
+
+ Scenario Outline: Certain admins can see original work creators
+ Given a work "Orphaned" with the original creator "orphaneer"
+ When I am logged in as a "" admin
+ And I view the work "Orphaned"
+ Then I should see the original creator "orphaneer"
+
+ Examples:
+ | role |
+ | superadmin |
+ | legal |
+ | policy_and_abuse |
+
+ Scenario Outline: Certain admins can remove orphan_account pseuds from works
+ Given I have an orphan account
+ And the work "Bye" by "Leaver"
+ And "Leaver" orphans and keeps their pseud on the work "Bye"
+ When I am logged in as a "" admin
+ And I view the work "Bye"
+ Then I should see "Remove Pseud"
+ When I follow "Remove Pseud"
+ Then I should see "Are you sure you want to remove the creator's pseud from this work?"
+ # Expire byline cache
+ When it is currently 1 second from now
+ And I press "Yes, Remove Pseud"
+ Then I should see "Successfully removed pseud Leaver (orphan_account) from this work."
+ And I should see "orphan_account" within ".byline"
+ But I should not see "Leaver" within ".byline"
+
+ Examples:
+ | role |
+ | superadmin |
+ | policy_and_abuse |
+ | support |
+
+ @javascript
+ Scenario Outline: Removing orphan_account pseuds from works with JavaScript shows a confirmation pop-up instead of a page
+ Given I have an orphan account
+ And the work "Bye" by "Leaver"
+ And "Leaver" orphans and keeps their pseud on the work "Bye"
+ When I am logged in as a "" admin
+ And I view the work "Bye"
+ Then I should see "Remove Pseud"
+ # Expire byline cache
+ When it is currently 1 second from now
+ And I follow "Remove Pseud"
+ And I confirm I want to remove the pseud
+ Then I should see "Successfully removed pseud Leaver (orphan_account) from this work."
+ And I should see "orphan_account" within ".byline"
+ But I should not see "Leaver" within ".byline"
+
+ Examples:
+ | role |
+ | superadmin |
+ | policy_and_abuse |
+ | support |
+
+ Scenario: When removing orphan_account pseuds from a work with multiple pseuds admins choose which pseud to remove
+ Given I have an orphan account
+ And I am logged in as "Leaver"
+ And I post the work "Bye"
+ And I add the co-author "Another" to the work "Bye"
+ And it is currently 1 second from now
+ And I add the co-author "Third" to the work "Bye"
+ And "Leaver" orphans and keeps their pseud on the work "Bye"
+ And "Another" orphans and keeps their pseud on the work "Bye"
+ And "Third" orphans and keeps their pseud on the work "Bye"
+ When I am logged in as a "policy_and_abuse" admin
+ And I view the work "Bye"
+ Then I should see "Remove Pseud"
+ When I follow "Remove Pseud"
+ Then I should see "Please choose which creators' pseuds you would like to remove from this work."
+ And I should see "Third (orphan_account)"
+ When I check "Leaver (orphan_account)"
+ And I check "Another (orphan_account)"
+ # Expire byline cache
+ And it is currently 1 second from now
+ And I press "Remove Pseud"
+ Then I should see "Successfully removed pseuds Leaver (orphan_account) and Another (orphan_account) from this work."
+ And I should see "orphan_account, " within ".byline"
+ And I should see "Third (orphan_account)" within ".byline"
+ But I should not see "Leaver" within ".byline"
+ And I should not see "Another" within ".byline"
+ When I go to the admin-activities page
+ Then I should see 1 admin activity log entry
+ And I should see "remove orphan_account pseuds"
+
+ Scenario: The Remove pseud option is only shown on orphaned works with non-default pseuds
+ Given I have an orphan account
+ And the work "Bye" by "Leaver"
+ And "Leaver" orphans and takes their pseud off the work "Bye"
+ And the work "Hey" by "Leaver"
+ When I am logged in as a "superadmin" admin
+ And I view the work "Hey"
+ Then I should not see "Remove Pseud"
+ When I view the work "Bye"
+ Then I should not see "Remove Pseud"
+
+ Scenario Outline: The Remove pseud option is not shown to admins who don't have permissions to remove pseuds
+ Given I have an orphan account
+ And the work "Bye" by "Leaver"
+ And "Leaver" orphans and keeps their pseud on the work "Bye"
+ When I am logged in as a "" admin
+ And I view the work "Bye"
+ Then I should not see "Remove Pseud"
+
+ Examples:
+ | role |
+ | board |
+ | board_assistants_team |
+ | communications |
+ | development_and_membership |
+ | docs |
+ | elections |
+ | legal |
+ | translation |
+ | tag_wrangling |
+ | open_doors |
diff --git a/features/admins/authenticate_admins.feature b/features/admins/authenticate_admins.feature
new file mode 100755
index 0000000..8877c70
--- /dev/null
+++ b/features/admins/authenticate_admins.feature
@@ -0,0 +1,163 @@
+@admin
+Feature: Authenticate Admin Users
+
+ Scenario: Admin cannot log in as an ordinary user.
+ Given the following admin exists
+ | login | password |
+ | Zooey | adminpassword |
+ When I go to the home page
+ And I fill in "Username or email" with "Zooey"
+ And I fill in "Password" with "adminpassword"
+ And I press "Log In"
+ Then I should see "The password or username you entered doesn't match our records"
+
+ Scenario: Ordinary user cannot log in or reset password as admin.
+ Given the following activated user exists
+ | login | password |
+ | dizmo | wrangulator |
+ When I go to the admin login page
+ And I fill in "Admin username" with "dizmo"
+ And I fill in "Admin password" with "wrangulator"
+ And I press "Log In as Admin"
+ Then I should not see "Successfully logged in"
+ And I should see "The password or admin username you entered doesn't match our records."
+ When I am logged in as "dizmo" with password "wrangulator"
+ And I go to the new admin password page
+ Then I should be on the homepage
+ And I should see "Please log out of your user account first!"
+ When I go to the edit admin password page
+ Then I should be on the homepage
+ And I should see "Please log out of your user account first!"
+
+ Scenario: Admin gets email with password reset link on account creation.
+ Given the following admin exists
+ | login | email |
+ | admin | admin@example.com |
+ Then 1 email should be delivered to admin@example.com
+ When I follow "follow this link to set your password" in the email
+ Then I should see "Set My Admin Password"
+ When I fill in "New password" with "newpassword"
+ And I fill in "Confirm new password" with "newpassword"
+ And I press "Set Admin Password"
+ Then I should see "Your password has been changed successfully. You are now signed in."
+ And I should see "Hi, admin!"
+
+ Scenario: Set password link expires.
+ Given the following admin exists
+ | login | password | email |
+ | admin | testpassword | admin@example.com |
+ Then 1 email should be delivered to "admin@example.com"
+ When it is past the admin password reset token's expiration date
+ And I follow "follow this link to set your password" in the email
+ Then I should see "Set My Admin Password"
+ When I fill in "New password" with "newpassword"
+ And I fill in "Confirm new password" with "newpassword"
+ And I press "Set Admin Password"
+ Then I should see "Reset password token has expired, please request a new one"
+
+ Scenario: Admin can log in.
+ Given I have no users
+ And the following admin exists
+ | login | password |
+ | Zooey | adminpassword |
+ When I go to the admin login page
+ And I fill in "Admin username" with "Zooey"
+ And I fill in "Admin password" with "adminpassword"
+ And I press "Log In as Admin"
+ Then I should see "Successfully logged in"
+
+ Scenario: Admin username is case insensitive.
+ Given the following admin exists
+ | login | password |
+ | TheMadAdmin | adminpassword |
+ When I go to the admin login page
+ And I fill in "Admin username" with "themadadmin"
+ And I fill in "Admin password" with "adminpassword"
+ And I press "Log In as Admin"
+ Then I should see "Successfully logged in"
+
+ Scenario: Admin cannot log in with wrong password.
+ Given the following admin exists
+ | login | password |
+ | Zooey | adminpassword |
+ When I go to the admin login page
+ And I fill in "Admin username" with "Zooey"
+ And I fill in "Admin password" with "wrongpassword"
+ And I press "Log In"
+ Then I should see "The password or username you entered doesn't match our records."
+
+ Scenario: Admin resets password.
+ Given the following admin exists
+ | login | password | email |
+ | admin | testpassword | admin@example.com |
+ And all emails have been delivered
+ And it is currently 2025-04-12 17:00 UTC
+ When I go to the admin login page
+ And I follow "Forgot admin password?"
+ Then I should see "Forgotten your admin password?"
+ When I fill in "Admin username" with "admin"
+ And I press "Reset Admin Password"
+ Then I should see "Check your email for instructions on how to reset your password."
+ And 1 email should be delivered to "admin@example.com"
+ When I follow "Change my password" in the email
+ And all emails have been delivered
+ Then I should see "Set My Admin Password"
+ When I fill in "New password" with "newpassword"
+ And I fill in "Confirm new password" with "newpassword"
+ And I press "Set Admin Password"
+ Then I should see "Your password has been changed successfully. You are now signed in."
+ And I should see "Hi, admin!"
+ And 1 emails should be delivered to "admin@example.com"
+ And the email should have "Your admin password has been changed" in the subject
+ And the email should contain "The password for your AO3 admin account was changed on Sat, 12 Apr 2025 17:00:\d+ \+0000"
+
+ Scenario: Reset password link expires.
+ Given the following admin exists
+ | login | password | email |
+ | admin | testpassword | admin@example.com |
+ And all emails have been delivered
+ When I go to the admin login page
+ And I follow "Forgot admin password?"
+ Then I should see "Forgotten your admin password?"
+ When I fill in "Admin username" with "admin"
+ And I press "Reset Admin Password"
+ Then I should see "Check your email for instructions on how to reset your password."
+ And 1 email should be delivered to "admin@example.com"
+ When it is past the admin password reset token's expiration date
+ And I follow "Change my password" in the email
+ Then I should see "Set My Admin Password"
+ When I fill in "New password" with "newpassword"
+ And I fill in "Confirm new password" with "newpassword"
+ And I press "Set Admin Password"
+ Then I should see "Reset password token has expired, please request a new one"
+
+ Scenario: Locked admin cannot sign in.
+ Given the admin "admin" is locked
+ When I go to the admin login page
+ And I fill in "Admin username" with "admin"
+ And I fill in "Admin password" with "adminpassword"
+ And I press "Log In as Admin"
+ Then I should see "Your account is locked."
+ And I should not see "Hi, admin!"
+
+ Scenario: Locked admin is not automatically logged in after password change.
+ Given the admin "admin" is locked
+ And all emails have been delivered
+ And I am on the admin login page
+ When I follow "Forgot admin password?"
+ And I fill in "Admin username" with "admin"
+ And I press "Reset Admin Password"
+ Then I should see "Check your email for instructions on how to reset your password."
+ And 1 email should be delivered
+ When I follow "Change my password" in the email
+ Then I should see "Set My Admin Password"
+ When I fill in "New password" with "newpassword"
+ And I fill in "Confirm new password" with "newpassword"
+ And I press "Set Admin Password"
+ Then I should see "Your password has been changed successfully. Your account is locked."
+ When the admin "admin" is unlocked
+ And I fill in "Admin username" with "admin"
+ And I fill in "Admin password" with "newpassword"
+ And I press "Log In as Admin"
+ Then I should see "Successfully logged in."
+ And I should see "Hi, admin!"
diff --git a/features/admins/users/admin_abuse_users.feature b/features/admins/users/admin_abuse_users.feature
new file mode 100755
index 0000000..a0a8547
--- /dev/null
+++ b/features/admins/users/admin_abuse_users.feature
@@ -0,0 +1,242 @@
+@admin
+Feature: Admin Abuse actions
+ In order to manage user accounts
+ As an admin
+ I want to be able to manage abusive users
+
+ Background:
+ Given the user "mrparis" exists and is activated
+ And I have an orphan account
+ And I am logged in as a "policy_and_abuse" admin
+ When I go to the user administration page for "mrparis"
+
+ Scenario: An admin adds a note to a user
+ Given I choose "Record note"
+ And I fill in "Notes" with "This user is suspicious."
+ When I press "Update"
+ Then I should see "Note was recorded."
+ And I should see "Note Added"
+ And I should see "This user is suspicious."
+
+ Scenario: A user is given a warning with a note
+ Given I choose "Record warning"
+ And I fill in "Notes" with "Next time, the brig."
+ When I press "Update"
+ Then I should see "Warning was recorded."
+ And I should see "Warned"
+ And I should see "Next time, the brig."
+
+ Scenario: A user cannot be given a warning without a note
+ Given I choose "Record warning"
+ When I press "Update"
+ Then I should see "You must include notes in order to perform this action."
+
+ Scenario: orphan_account cannot get a note
+ When I go to the user administration page for "orphan_account"
+ And I choose "Record note"
+ And I fill in "Notes" with "This user is suspicious."
+ When I press "Update"
+ Then I should see "orphan_account cannot be warned, suspended, or banned."
+
+ Scenario: orphan_account cannot be warned
+ When I go to the user administration page for "orphan_account"
+ And I choose "Record warning"
+ And I fill in "Notes" with "Next time, the brig."
+ When I press "Update"
+ Then I should see "orphan_account cannot be warned, suspended, or banned."
+
+ Scenario: orphan_account cannot be suspended
+ When I go to the user administration page for "orphan_account"
+ And I choose "Suspend: enter a whole number of days"
+ And I fill in "suspend_days" with "30"
+ And I fill in "Notes" with "Disobeyed orders."
+ When I press "Update"
+ Then I should see "orphan_account cannot be warned, suspended, or banned."
+
+ Scenario: orphan_account cannot be banned
+ When I go to the user administration page for "orphan_account"
+ And I choose "Suspend permanently (ban user)"
+ And I fill in "Notes" with "To the New Zealand penal colony with you."
+ When I press "Update"
+ Then I should see "orphan_account cannot be warned, suspended, or banned."
+
+ Scenario: orphan_account cannot be spambanned
+ When I go to the user administration page for "orphan_account"
+ And I choose "Spammer: ban and delete all creations"
+ When I press "Update"
+ Then I should see "orphan_account cannot be warned, suspended, or banned."
+
+ Scenario: A user is given a suspension with a note and number of days
+ Given I choose "Suspend: enter a whole number of days"
+ And I fill in "suspend_days" with "30"
+ And I fill in "Notes" with "Disobeyed orders."
+ When I press "Update"
+ Then I should see "User has been temporarily suspended."
+ And I should see "Suspended until"
+ And I should see "Disobeyed orders."
+
+ Scenario: A user cannot be given a suspension without a number of days
+ Given I choose "Suspend: enter a whole number of days"
+ And I fill in "Notes" with "Disobeyed orders."
+ When I press "Update"
+ Then I should see "Please enter the number of days for which the user should be suspended."
+
+ Scenario: A user cannot be given a suspension with a date but without a note
+ Given I choose "Suspend: enter a whole number of days"
+ And I fill in "suspend_days" with "30"
+ When I press "Update"
+ Then I should see "You must include notes in order to perform this action."
+
+ Scenario: A user is banned with a note
+ Given I choose "Suspend permanently (ban user)"
+ And I fill in "Notes" with "To the New Zealand penal colony with you."
+ When I press "Update"
+ Then I should see "User has been permanently suspended."
+ And I should see "Suspended Permanently"
+ And I should see "To the New Zealand penal colony with you."
+ When all indexing jobs have been run
+ And I follow "Manage Users"
+ And I fill in "Name" with "mrparis"
+ And I press "Find"
+ Then I should see "1 user found"
+ When I follow "Details"
+ Then I should see "To the New Zealand penal colony with you."
+
+ Scenario: A user cannot be banned without a note
+ Given the user "mrparis" exists and is activated
+ And I am logged in as a "policy_and_abuse" admin
+ When I go to the user administration page for "mrparis"
+ And I choose "Suspend permanently (ban user)"
+ When I press "Update"
+ Then I should see "You must include notes in order to perform this action."
+
+ Scenario: A user's suspension is lifted with a note
+ Given the user "mrparis" is suspended
+ And I am logged in as a "policy_and_abuse" admin
+ When I go to the user administration page for "mrparis"
+ And I choose "Lift temporary suspension, effective immediately."
+ And I fill in "Notes" with "Good behavior."
+ When I press "Update"
+ Then I should see "Suspension has been lifted."
+ And I should see "Suspension Lifted"
+ And I should see "Good behavior."
+
+ Scenario: A user's suspension cannot be lifted without a note
+ Given the user "mrparis" is suspended
+ And I am logged in as a "policy_and_abuse" admin
+ When I go to the user administration page for "mrparis"
+ And I choose "Lift temporary suspension, effective immediately."
+ When I press "Update"
+ Then I should see "You must include notes in order to perform this action."
+
+ Scenario: A user's ban is lifted with a note
+ Given the user "mrparis" is banned
+ And I am logged in as a "policy_and_abuse" admin
+ When I go to the user administration page for "mrparis"
+ And I choose "Lift permanent suspension, effective immediately."
+ And I fill in "Notes" with "Need him to infiltrate the Maquis."
+ When I press "Update"
+ Then I should see "Suspension has been lifted."
+ And I should see "Suspension Lifted"
+ And I should see "Need him to infiltrate the Maquis."
+
+ Scenario: A user's ban cannot be lifted without a note
+ Given the user "mrparis" is banned
+ And I am logged in as a "policy_and_abuse" admin
+ When I go to the user administration page for "mrparis"
+ And I choose "Lift permanent suspension, effective immediately."
+ When I press "Update"
+ Then I should see "You must include notes in order to perform this action."
+
+ Scenario: A spammer can be permabanned and all their creations destroyed
+ Given I have a work "Not Spam"
+ And I am logged in as "Spamster"
+ And I post the work "Loads of Spam"
+ And I post the work "Even More Spam"
+ And I post the work "Spam 3: Tokyo Drift"
+ And I create the collection "Spam Collection"
+ And I bookmark the work "Not Spam"
+ And I add the work "Loads of Spam" to series "One Spam After Another"
+ And I post the comment "I like spam" on the work "Not Spam"
+ And I am logged in as a "policy_and_abuse" admin
+ When I go to the user administration page for "Spamster"
+ And I choose "Spammer: ban and delete all creations"
+ And I press "Update"
+ Then I should see "permanently suspended"
+ And the user "Spamster" should be permanently banned
+ And the page should have a dashboard sidebar
+ And I should see "Are you sure you want to delete"
+ And I should see "1 bookmarks"
+ And I should see "1 collections"
+ And I should see "1 series"
+ And I should see "Loads of Spam"
+ And I should see "Even More Spam"
+ And I should see "Spam 3: Tokyo Drift"
+ And I should see "I like spam"
+ When I press "Yes, Delete All Spammer Creations"
+ Then I should see "All creations by user Spamster have been deleted."
+ And the work "Loads of Spam" should be deleted
+ And the work "Even More Spam" should be deleted
+ And the work "Spam 3: Tokyo Drift" should be deleted
+ And "Spamster" should receive 3 emails
+ And the collection "Spam Collection" should be deleted
+ And the series "One Spam After Another" should be deleted
+ And the work "Not Spam" should not be deleted
+ And there should be no bookmarks on the work "Not Spam"
+ And there should be no comments on the work "Not Spam"
+
+ Scenario: A permabanned spammer's comments' replies from others should stay visible
+ Given I have a work "Generic Work"
+ And a comment "I like spam" by "Spamster" on the work "Generic Work"
+ And a reply "I don't :(" by "NotSpamster" on the work "Generic Work"
+ And a comment "A thread of spams" by "Spamster" on the work "Generic Work"
+ And a reply "more spam" by "Spamster" on the work "Generic Work"
+ When I am logged in as a "policy_and_abuse" admin
+ And I go to the user administration page for "Spamster"
+ And I choose "Spammer: ban and delete all creations"
+ And I press "Update"
+ Then I should see "permanently suspended"
+ And the user "Spamster" should be permanently banned
+ And I should see "I like spam"
+ When I press "Yes, Delete All Spammer Creations"
+ Then I should see "All creations by user Spamster have been deleted."
+ When I go to the work comments page for "Generic Work"
+ Then I should not see "I like spam"
+ And I should see "(Previous comment deleted.)"
+ And I should see "I don't :("
+ And I should not see "A thread of spams"
+ And I should not see "more spam"
+
+ Scenario: A user's works cannot be destroyed unless they are banned
+ Given I am logged in as "Spamster"
+ And I post the work "Loads of Spam"
+ And I am logged in as a "policy_and_abuse" admin
+ And I go to the user administration page for "Spamster"
+ And I choose "Spammer: ban and delete all creations"
+ And I press "Update"
+ And the user "Spamster" is unbanned in the background
+ And I press "Yes, Delete All Spammer Creations"
+ Then I should see "That user is not banned"
+ And the work "Loads of Spam" should not be deleted
+
+ Scenario: An already-banned user can have their works destroyed
+ Given the user "Spamster" is banned
+ And I am logged in as a "policy_and_abuse" admin
+ When I go to the user administration page for "Spamster"
+ And I choose "Spammer: ban and delete all creations"
+ And I press "Update"
+ Then I should see "Are you sure you want to delete"
+ When I press "Yes, Delete All Spammer Creations"
+ Then I should see "All creations by user Spamster have been deleted."
+
+ Scenario: Rename a user with an inappropriate username
+ Given the user "otheruserstinks" exists and is activated
+ And I am logged in as a "policy_and_abuse" admin
+ And an abuse ticket ID exists
+ And all emails have been delivered
+ When I visit the change username page for otheruserstinks
+ And I fill in "Ticket ID" with "480000"
+ And I press "Change Username"
+ Then I should see "Username has been successfully updated."
+ But I should not see "otheruserstinks" within "h2.heading"
+ And 0 emails should be delivered
diff --git a/features/admins/users/admin_find_users.feature b/features/admins/users/admin_find_users.feature
new file mode 100755
index 0000000..ef28502
--- /dev/null
+++ b/features/admins/users/admin_find_users.feature
@@ -0,0 +1,160 @@
+Feature: Admin Find Users page
+
+ Background:
+ Given the following activated users exist
+ | login | email |
+ | userA | a@ao3.org |
+ | userB | b@bo3.org |
+ | userCB | cb@bo3.org |
+ And the user "userB" exists and has the role "archivist"
+ And I am logged in as a super admin
+ And all emails have been delivered
+ And I go to the manage users page
+
+ Scenario: The Find Users page shows no results before searching and all results with blank search
+ Then I should not see "userA"
+ And I should not see "userB"
+ And I should not see "userCB"
+ And I should not see "found"
+ When I submit
+ Then I should see "userA"
+ And I should see "userB"
+ And I should see "userCB"
+
+ Scenario: The Find Users page performs a partial match on name with * wildcard
+ When I fill in "Name" with "u*er*"
+ And I submit
+ Then I should see "userA"
+ And I should see "userB"
+ And I should see "userCB"
+
+ Scenario: The Find Users page performs an exact match on name by default
+ When I fill in "Name" with "user"
+ And I submit
+ Then I should see "0 users found"
+ When I fill in "Name" with "userA"
+ And I submit
+ Then the field labeled "user_email" should contain "a@ao3.org"
+ But I should not see "UserB"
+
+ Scenario: The Find Users page searches past logins only if the option is selected
+ When I am logged in as "userA"
+ And I visit the change username page for userA
+ And I fill in "New username" with "userD"
+ And I fill in "Password" with "password"
+ And I press "Change"
+ Then I should get confirmation that I changed my username
+ When I am logged in as a "support" admin
+ And I go to the manage users page
+ And I fill in "Name" with "userA"
+ And I submit
+ Then I should see "0 users found"
+ When I check "Include past usernames and emails"
+ And I submit
+ Then I should see "1 user found"
+ And I should see "userD"
+ # Only selected admins can search past logins
+ When I am logged in as a "translation" admin
+ And I go to the manage users page
+ Then I should not see "Include past usernames and emails"
+
+ Scenario: The Find Users page performs a partial match by email with * wildcard
+ When I fill in "Email" with "*bo3*"
+ And I submit
+ Then I should see "userB"
+ And I should see "userCB"
+ But I should not see "userA"
+
+ Scenario: The Find Users page performs an exact match on email by default
+ When I fill in "Email" with "ao3"
+ And I submit
+ Then I should see "0 users found"
+ When I fill in "Email" with "a@ao3.org"
+ And I submit
+ Then I should see "userA"
+ But I should not see "UserB"
+
+ Scenario: The Find Users page searches past emails if the option is selected
+ When I am logged in as "userA"
+ And I change my email to "d@ao3.org"
+ And I am logged in as a "policy_and_abuse" admin
+ And I go to the manage users page
+ And I fill in "Email" with "a@ao3.org"
+ And I submit
+ Then I should see "0 users found"
+ When I check "Include past usernames and emails"
+ And I submit
+ Then I should see "1 user found"
+ And I should see "userA"
+ And the field labeled "user_email" should contain "d@ao3.org"
+
+ Scenario: The Find Users page performs an exact match by role
+ When I select "Archivist" from "Role"
+ And I submit
+ Then I should see "userB"
+ But I should not see "userA"
+ And I should not see "userCB"
+
+ Scenario: The Find Users page performs an exact match by ID in addition to any other criteria
+ When the search criteria contains the ID for "userB"
+ And I submit
+ Then I should see "1 user found"
+ And I should see "userB"
+ But I should not see "userA"
+ And I should not see "userCB"
+ When I fill in "Name" with "*A"
+ And I submit
+ Then I should see "0 users found"
+ When I fill in "Name" with "*B"
+ And I submit
+ Then I should see "1 user found"
+ And I should see "userB"
+
+ # Bulk email search
+ Scenario: The Bulk Email Search page finds all existing matching users
+ When I go to the Bulk Email Search page
+ And I fill in "Email addresses *" with
+ """
+ b@bo3.org
+ a@ao3.org
+ """
+ And I press "Find"
+ Then I should see "userB"
+ And I should see "userA"
+ But I should not see "userCB"
+ And I should not see "Not found"
+
+ Scenario: The Bulk Email Search page lists emails found, not found and duplicates
+ When I go to the Bulk Email Search page
+ And I fill in "Email addresses *" with
+ """
+ b@bo3.org
+ a@ao3.org
+ c@co3.org
+ C@CO3.org
+ """
+ And I press "Find"
+ Then I should see "2 found"
+ And I should see "1 not found"
+ And I should see "1 duplicate"
+ And I should see "Not found"
+
+ Scenario: The Bulk Email Search page finds an exact match
+ When I go to the Bulk Email Search page
+ And I fill in "Email addresses *" with "b@bo3.org"
+ And I press "Find"
+ Then I should see "userB"
+ But I should not see "userA"
+ And I should not see "userCB"
+ And I should not see "Not found"
+
+ Scenario: Admins can download a CSV of found emails
+ When I go to the Bulk Email Search page
+ And I fill in "Email addresses *" with
+ """
+ b@bo3.org
+ a@ao3.org
+ c@co3.org
+ """
+ And I press "Download CSV"
+ Then I should download a csv file with 4 rows and the header row "Email Username"
diff --git a/features/admins/users/admin_fnok.feature b/features/admins/users/admin_fnok.feature
new file mode 100755
index 0000000..2181816
--- /dev/null
+++ b/features/admins/users/admin_fnok.feature
@@ -0,0 +1,124 @@
+@admin
+Feature: Admin Fannish Next Of Kind actions
+ In order to manage user accounts
+ As an an admin
+ I want to be able to manage fannish next of kin for users
+
+ Scenario: A valid Fannish Next of Kin is added for a user
+ Given the following activated users exist
+ | login | password |
+ | harrykim | diesalot |
+ | libby | stillalive |
+ And I am logged in as a "support" admin
+ When I go to the user administration page for "harrykim"
+ And I fill in "Fannish next of kin's username" with "libby"
+ And I fill in "Fannish next of kin's email" with "testy@foo.com"
+ And I press "Update Fannish Next of Kin"
+ Then I should see "Fannish next of kin was updated."
+ And the history table should show that "libby" was added as next of kin
+
+ When I go to the manage users page
+ And I fill in "Name" with "harrykim"
+ And I press "Find"
+ Then I should see "libby"
+
+ When I follow "libby"
+ Then I should be on libby's user page
+
+ When I go to the user administration page for "libby"
+ Then the history table should show they were added as next of kin of "harrykim"
+
+ Scenario: An invalid Fannish Next of Kin username is added
+ Given the fannish next of kin "libby" for the user "harrykim"
+ And I am logged in as a "support" admin
+ When I go to the user administration page for "harrykim"
+ And I fill in "Fannish next of kin's username" with "userididnotcreate"
+ And I press "Update Fannish Next of Kin"
+ Then I should see "Kin can't be blank"
+
+ Scenario: A blank Fannish Next of Kin username can't be added
+ Given the fannish next of kin "libby" for the user "harrykim"
+ And I am logged in as a "support" admin
+ When I go to the user administration page for "harrykim"
+ And I fill in "Fannish next of kin's username" with ""
+ And I press "Update Fannish Next of Kin"
+ Then I should see "Kin can't be blank"
+
+ Scenario: A blank Fannish Next of Kin email can't be added
+ Given the fannish next of kin "libby" for the user "harrykim"
+ And I am logged in as a "support" admin
+ When I go to the user administration page for "harrykim"
+ And I fill in "Fannish next of kin's email" with ""
+ And I press "Update Fannish Next of Kin"
+ Then I should see "Kin email can't be blank"
+
+ Scenario: A Fannish Next of Kin is edited
+ Given the fannish next of kin "libby" for the user "harrykim"
+ And the user "newlibby" exists and is activated
+ And I am logged in as a "support" admin
+ When I go to the user administration page for "harrykim"
+ And I fill in "Fannish next of kin's username" with "newlibby"
+ And I fill in "Fannish next of kin's email" with "newlibby@foo.com"
+ And I press "Update Fannish Next of Kin"
+ Then I should see "Fannish next of kin was updated."
+
+ Scenario: A Fannish Next of Kin is removed
+ Given the fannish next of kin "libby" for the user "harrykim"
+ And I am logged in as a "support" admin
+ When I go to the user administration page for "harrykim"
+ And I fill in "Fannish next of kin's username" with ""
+ And I fill in "Fannish next of kin's email" with ""
+ And I press "Update Fannish Next of Kin"
+ Then I should see "Fannish next of kin was removed."
+ And the history table should show that "libby" was removed as next of kin
+ When I go to the user administration page for "libby"
+ Then the history table should show they were removed as next of kin of "harrykim"
+
+ Scenario: A Fannish Next of Kin updates when the next of kin user changes their username
+ Given the fannish next of kin "libby" for the user "harrykim"
+ And I am logged in as "libby"
+ When I visit the change username page for libby
+ And I fill in "New username" with "newlibby"
+ And I fill in "Password" with "password"
+ And I press "Change Username"
+ Then I should get confirmation that I changed my username
+ When I am logged in as a "support" admin
+ And I go to the manage users page
+ And I fill in "Name" with "harrykim"
+ And I press "Find"
+ Then I should see "newlibby"
+
+ Scenario: A Fannish Next of Kin stays with the user when the user changes their username
+ Given the fannish next of kin "libby" for the user "harrykim"
+ And I am logged in as "harrykim"
+ When I visit the change username page for harrykim
+ And I fill in "New username" with "harrykim2"
+ And I fill in "Password" with "password"
+ And I press "Change Username"
+ Then I should get confirmation that I changed my username
+ When I am logged in as a "support" admin
+ And I go to the manage users page
+ And I fill in "Name" with "harrykim2"
+ And I press "Find"
+ Then I should see "libby"
+
+ Scenario: A Fannish Next of Kin can update even after an invalid user is entered
+ Given the fannish next of kin "libby" for the user "harrykim"
+ And the user "harrysmom" exists and is activated
+ And I am logged in as a "support" admin
+ When I go to the user administration page for "harrykim"
+ And I fill in "Fannish next of kin's username" with "libbylibby"
+ And I fill in "Fannish next of kin's email" with "libbylibby@example.com"
+ And I press "Update Fannish Next of Kin"
+ Then I should see "Kin can't be blank"
+ And the "Fannish next of kin's username" input should be blank
+ And I should see "libbylibby@example.com" in the "Fannish next of kin's email" input
+ When I go to the user administration page for "harrykim"
+ And I should see "libby" in the "Fannish next of kin's username" input
+ And I should see "fnok@example.com" in the "Fannish next of kin's email" input
+ When I fill in "Fannish next of kin's username" with "harrysmom"
+ And I fill in "Fannish next of kin's email" with "harrysmom@example.com"
+ And I press "Update Fannish Next of Kin"
+ Then I should see "Fannish next of kin was updated."
+ And the "Fannish next of kin's username" field should contain "harrysmom"
+ And the "Fannish next of kin's email" field should contain "harrysmom@example.com"
diff --git a/features/admins/users/admin_manage_users.feature b/features/admins/users/admin_manage_users.feature
new file mode 100755
index 0000000..f6070f1
--- /dev/null
+++ b/features/admins/users/admin_manage_users.feature
@@ -0,0 +1,130 @@
+@admin
+Feature: Admin Actions to manage users
+ In order to manage user accounts
+ As an an admin
+ I want to be able to edit individual users
+
+ Scenario: Admin can update a user's email address and roles
+ Given the following activated user exists
+ | login | password |
+ | dizmo | wrangulator |
+ And the role "tag_wrangler"
+ When I am logged in as a super admin
+ And I go to the manage users page
+ And I fill in "Name" with "dizmo"
+ And I press "Find"
+ Then I should see "dizmo" within "#admin_users_table"
+
+ # change user email
+ When I fill in "user_email" with "not even an email"
+ And I press "Update"
+ Then I should see "The user dizmo could not be updated: Email is invalid"
+
+ When I fill in "user_email" with "dizmo@fake.com"
+ And I press "Update"
+ Then the "user_email" field should contain "dizmo@fake.com"
+ And I should see "User was successfully updated."
+
+ # Adding and removing roles
+ When I check the "tag_wrangler" role checkbox
+ And I press "Update"
+ # Then show me the html
+ Then I should see "User was successfully updated"
+ And the "tag_wrangler" role checkbox should be checked
+ When I follow "Details"
+ Then I should see "Role: Tag Wrangler" within ".meta"
+ And I should see "Role Added: tag_wrangler" within "#user_history"
+ And I should see "Change made by testadmin-superadmin"
+ When I follow "Manage Roles"
+ And I uncheck the "tag_wrangler" role checkbox
+ And I press "Update"
+ Then I should see "User was successfully updated"
+ And the "tag_wrangler" role checkbox should not be checked
+ When I follow "Details"
+ Then I should see "Roles: No roles" within ".meta"
+ And I should see "Role Removed: tag_wrangler" within "#user_history"
+
+ Scenario: Troubleshooting a user displays a message
+ Given the user "mrparis" exists and is activated
+ And I am logged in as a "support" admin
+ When I go to the user administration page for "mrparis"
+ And I follow "Troubleshoot"
+ Then I should see "User account troubleshooting complete."
+
+ Scenario: A admin can activate a user account
+ Given the user "mrparis" exists and is not activated
+ And I am logged in as a "support" admin
+ When I go to the user administration page for "mrparis"
+ And I press "Activate"
+ Then I should see "User Account Activated"
+ And the user "mrparis" should be activated
+
+ Scenario: An admin can view a user's last login date
+ Given the user "new_user" exists and is activated
+ And I am logged in as a "support" admin
+ When I go to the user administration page for "new_user"
+ Then I should see "Current Login No login recorded"
+ And I should see "Previous Login No previous login recorded"
+ When time is frozen at 1/1/2019
+ And I am logged in as "new_user"
+ And I am logged out
+ And I jump in our Delorean and return to the present
+ And I am logged in as a "support" admin
+ And I go to the user administration page for "new_user"
+ Then I should not see "No login recorded"
+ And I should see "2019-01-01 12:00:00 UTC Current Login IP Address: 127.0.0.1"
+ And I should see "2019-01-01 12:00:00 UTC Previous Login IP Address: 127.0.0.1"
+
+ Scenario: An admin can view a user's email address and invitation
+ Given the user "user" with the email "user@example.com" exists
+ And the user "user2" was created using an invitation
+ When I am logged in as a "superadmin" admin
+ And I go to the user administration page for "user"
+ Then I should see "Email: user@example.com"
+ And I should see "Invitation: Created without invitation"
+ When I go to the user administration page for "user2"
+ Then I should see the invitation id for the user "user2"
+
+ Scenario: Admins can view past emails and usernames
+ Given the following activated user exists
+ | login | email |
+ | cats | d@fake.com |
+ And I am logged in as "cats"
+ And I want to edit my profile
+ And I change my email to "new@example.com"
+ And all emails have been delivered
+ And I change my username to "new_user"
+ When I am logged in as a super admin
+ And I go to the user administration page for "new_user"
+ Then I should see "Past email: d@fake.com" within ".meta"
+ And I should see "Past username: cats" within ".meta"
+ When I am logged in as a "translation" admin
+ And I go to the user administration page for "new_user"
+ Then I should not see "Past email: d@fake.com"
+ And I should not see "Past username: cats"
+
+ Scenario: An admin can access a user's creations from their administration page
+ Given there is 1 user creation per page
+ And the user "lurker" exists and is activated
+ And I am logged in as "troll"
+ And I post the work "Creepy Gift"
+ And I post the work "NFW"
+ And I post the comment "Neener" on the work "Creepy Gift"
+ When I am logged in as a "support" admin
+ And I go to the user administration page for "lurker"
+ Then the page should have a dashboard sidebar
+ And I should not see "Creations"
+ When I am logged in as a "policy_and_abuse" admin
+ And I go to the user administration page for "lurker"
+ And I follow "Creations"
+ Then I should see "Works and Comments by lurker"
+ And I should see "This user has no works or comments."
+ And the page should have a dashboard sidebar
+ When I go to the user administration page for "troll"
+ And I follow "Creations"
+ Then I should see "Works and Comments by troll"
+ And I should see "1 - 1 of 2 Works" within "#works-summary"
+ And I should see "Creepy Gift" within "#works-summary"
+ And I should see "1 Comment" within "#comments-summary"
+ And I should see "Comment on the work Creepy Gift" within "#comments-summary"
+ And I should see "
Neener
" within "#comments-summary"
diff --git a/features/bookmarks/bookmark_browse.feature b/features/bookmarks/bookmark_browse.feature
new file mode 100755
index 0000000..01853ef
--- /dev/null
+++ b/features/bookmarks/bookmark_browse.feature
@@ -0,0 +1,25 @@
+@bookmarks @search
+Feature: Browse Bookmarks
+
+ Scenario: Bookmarks appear on both the user's bookmark page and on the bookmark page for the pseud they used create the bookmark
+
+ Given I am logged in as "ethel"
+ And "ethel" creates the pseud "aka"
+ And I bookmark the work "Bookmarked with Default Pseud"
+ And I bookmark the work "Bookmarked with Other Pseud" as "aka"
+ When I go to ethel's bookmarks page
+ Then I should see "Bookmarked with Default Pseud"
+ And I should see "Bookmarked with Other Pseud"
+ When I go to the bookmarks page for user "ethel" with pseud "ethel"
+ Then I should see "Bookmarked with Default Pseud"
+ And I should not see "Bookmarked with Other Pseud"
+ When I go to the bookmarks page for user "ethel" with pseud "aka"
+ Then I should see "Bookmarked with Other Pseud"
+ And I should not see "Bookmarked with Default Pseud"
+
+ Scenario: Bookmark blurb includes an HTML comment containing the unix epoch of the updated time
+ Given time is frozen at 2025-04-12 17:00 UTC
+ And I am logged in as "ethel"
+ And I bookmark the work "Test"
+ When I go to ethel's bookmarks page
+ Then I should see an HTML comment containing the number 1744477200 within "li.bookmark.blurb"
diff --git a/features/bookmarks/bookmark_create.feature b/features/bookmarks/bookmark_create.feature
new file mode 100755
index 0000000..a5c3e97
--- /dev/null
+++ b/features/bookmarks/bookmark_create.feature
@@ -0,0 +1,460 @@
+@bookmarks
+Feature: Create bookmarks
+ In order to have an archive full of bookmarks
+ As a humble user
+ I want to bookmark some works
+
+Scenario: Create a bookmark
+ Given I am logged in as "first_bookmark_user"
+ When I am on first_bookmark_user's user page
+ Then I should see "have anything posted under this name yet"
+ When I am logged in as "another_bookmark_user"
+ And I post the work "Revenge of the Sith"
+ When I go to the bookmarks page
+ Then I should not see "Revenge of the Sith"
+ When I am logged in as "first_bookmark_user"
+ And I go to the works page
+ And I follow "Revenge of the Sith"
+ Then I should see "Bookmark"
+ When I follow "Bookmark"
+ And I fill in "bookmark_notes" with "I liked this story"
+ And I fill in "bookmark_tag_string" with "This is a tag, and another tag,"
+ And I check "bookmark_rec"
+ And I press "Create"
+ And all indexing jobs have been run
+ Then I should see "Bookmark was successfully created"
+ And I should see "My Bookmarks"
+ When I am logged in as "another_bookmark_user"
+ And I go to the bookmarks page
+ Then I should see "Revenge of the Sith"
+ And I should see "This is a tag"
+ And I should see "and another tag"
+ And I should see "I liked this story"
+ When I am logged in as "first_bookmark_user"
+ And I go to first_bookmark_user's user page
+ Then I should not see "You don't have anything posted under this name yet"
+ And I should see "Revenge of the Sith"
+ When I edit the bookmark for "Revenge of the Sith"
+ And I check "bookmark_private"
+ And I press "Update"
+ And all indexing jobs have been run
+ Then I should see "Bookmark was successfully updated"
+ When I go to the bookmarks page
+ Then I should not see "I liked this story"
+ When I go to first_bookmark_user's bookmarks page
+ Then I should see "I liked this story"
+
+ # privacy check for the private bookmark '
+ When I am logged in as "another_bookmark_user"
+ And I go to the bookmarks page
+ Then I should not see "I liked this story"
+ When I go to first_bookmark_user's user page
+ Then I should not see "I liked this story"
+
+ Scenario: Create bookmarks and recs on restricted works, check how they behave from various access points
+ Given the following activated users exist
+ | login |
+ | first_bookmark_user |
+ | another_bookmark_user |
+ And a fandom exists with name: "Stargate SG-1", canonical: true
+ And I am logged in as "first_bookmark_user"
+ And I post the locked work "Secret Masterpiece"
+ And I post the locked work "Mystery"
+ And I post the work "Public Masterpiece"
+ And I post the work "Publicky"
+ When I am logged in as "another_bookmark_user"
+ And I view the work "Secret Masterpiece"
+ And I follow "Bookmark"
+ And I check "bookmark_rec"
+ And I press "Create"
+ Then I should see "Bookmark was successfully created"
+ And I should see the image "title" text "Restricted"
+ And I should see "Rec" within ".rec"
+ When I view the work "Public Masterpiece"
+ And I follow "Bookmark"
+ And I check "bookmark_rec"
+ And I press "Create"
+ Then I should see "Bookmark was successfully created"
+ And I should not see the image "title" text "Restricted"
+ When I view the work "Mystery"
+ And I follow "Bookmark"
+ And I press "Create"
+ Then I should see "Bookmark was successfully created"
+ And I should not see "Rec"
+ When I view the work "Publicky"
+ And I follow "Bookmark"
+ And I press "Create"
+ And all indexing jobs have been run
+ Then I should see "Bookmark was successfully created"
+ When I log out
+ And I go to the bookmarks page
+ Then I should not see "Secret Masterpiece"
+ And I should not see "Mystery"
+ But I should see "Public Masterpiece"
+ And I should see "Publicky"
+ When I go to another_bookmark_user's bookmarks page
+ Then I should not see "Secret Masterpiece"
+ When I am logged in as "first_bookmark_user"
+ And I go to another_bookmark_user's bookmarks page
+ Then I should see "Bookmarks (4)"
+ And I should see "Secret Masterpiece"
+
+Scenario: extra commas in bookmark form (Issue 2284)
+
+ Given I am logged in as "bookmarkuser"
+ And I post the work "Some Work"
+ When I follow "Bookmark"
+ And I fill in "Your tags" with "Good tag, ,, also good tag, "
+ And I press "Create"
+ Then I should see "created"
+
+Scenario: Bookmark notes do not display images
+ Given I am logged in as "bookmarkuser"
+ And I post the work "Some Work"
+ When I follow "Bookmark"
+ And I fill in "Notes" with "Fantastic!"
+ And I press "Create"
+ And all indexing jobs have been run
+ Then I should see "Bookmark was successfully created"
+ When I go to the bookmarks page
+ Then I should not see the image "src" text "http://example.com/icon.svg"
+ And I should see "Fantastic!"
+
+Scenario: bookmark added to moderated collection has flash notice only when not approved
+ Given the following activated users exist
+ | login | password |
+ | workauthor | password |
+ | bookmarker | password |
+ | otheruser | password |
+ And I have a moderated collection "Five Pillars" with name "five_pillars"
+ And I am logged in as "workauthor" with password "password"
+ And I post the work "Fire Burn, Cauldron Bubble"
+ When I log out
+ And I am logged in as "bookmarker" with password "password"
+ And I view the work "Fire Burn, Cauldron Bubble"
+ And I follow "Bookmark"
+ And I fill in "bookmark_collection_names" with "five_pillars"
+ And I press "Create"
+ And all indexing jobs have been run
+ Then I should see "Bookmark was successfully created"
+ And I should see "The collection Five Pillars is currently moderated."
+ When I go to bookmarker's bookmarks page
+ Then I should see "The collection Five Pillars is currently moderated."
+ When I log out
+ And I am logged in as "moderator" with password "password"
+ # Delay before approving to make sure the cache is expired
+ And it is currently 1 second from now
+ And I approve the first item in the collection "Five Pillars"
+ And all indexing jobs have been run
+ And I am logged in as "bookmarker" with password "password"
+ And I go to bookmarker's bookmarks page
+ Then I should not see "The collection Five Pillars is currently moderated."
+
+Scenario: bookmarks added to moderated collections appear correctly
+ Given the following activated users exist
+ | login | password |
+ | workauthor | password |
+ | bookmarker | password |
+ | otheruser | password |
+ And I have a moderated collection "JBs Greatest" with name "jbs_greatest"
+ And I have a moderated collection "Bedknobs and Broomsticks" with name "beds_and_brooms"
+ And I have a moderated collection "Death by Demographics" with name "death_by_demographics"
+ And I have a moderated collection "Murder a la Mode" with name "murder_a_la_mode"
+ And I have the collection "Mrs. Pots" with name "mrs_pots"
+ And I am logged in as "workauthor" with password "password"
+ And I post the work "The Murder of Sherlock Holmes"
+ When I log out
+ And I am logged in as "bookmarker" with password "password"
+ And I view the work "The Murder of Sherlock Holmes"
+ And I follow "Bookmark"
+ And I fill in "bookmark_collection_names" with "jbs_greatest"
+ And I press "Create"
+ And all indexing jobs have been run
+ Then I should see "Bookmark was successfully created"
+ And I should see "The collection JBs Greatest is currently moderated. Your bookmark must be approved by the collection maintainers before being listed there."
+ # UPDATE the bookmark and add it to a second MODERATED collection and
+ # recheck all the things
+ When I follow "Edit"
+ And I fill in "bookmark_collection_names" with "jbs_greatest,beds_and_brooms"
+ And I press "Update"
+ And all indexing jobs have been run
+ Then I should see "Bookmark was successfully updated."
+ And I should see "to the moderated collection 'Bedknobs and Broomsticks'."
+ When I follow "Edit"
+ And I fill in "bookmark_collection_names" with "jbs_greatest,beds_and_brooms,death_by_demographics,murder_a_la_mode"
+ And I press "Update"
+ And all indexing jobs have been run
+ Then I should see "You have submitted your bookmark to moderated collections (Death by Demographics, Murder a la Mode)."
+ When I go to bookmarker's bookmarks page
+ And I should see "The Murder of Sherlock Holmes"
+ And I should see "Bookmarker's Collections: JBs Greatest"
+ And I should see "The collection JBs Greatest is currently moderated. Your bookmark must be approved by the collection maintainers before being listed there."
+ When I go to the bookmarks page
+ And I should see "The Murder of Sherlock Holmes"
+ And I should see "Bookmarker's Collections: JBs Greatest"
+ And I should see "The collection JBs Greatest is currently moderated. Your bookmark must be approved by the collection maintainers before being listed there."
+ When I log out
+ # Users who do not own the bookmark should not see the notice, or see that it
+ # has been submitted to a specific collection
+ And I am logged in as "otheruser" with password "password"
+ And I go to bookmarker's bookmarks page
+ Then I should see "The Murder of Sherlock Holmes"
+ And I should not see "Bookmarker's Collections: JBs Greatest"
+ And I should not see "The collection JBs Greatest is currently moderated. Your bookmark must be approved by the collection maintainers before being listed there."
+ When I go to the bookmarks page
+ Then I should see "The Murder of Sherlock Holmes"
+ And I should not see "Bookmarker's Collections: JBs Greatest"
+ And I should not see "The collection JBs Greatest is currently moderated. Your bookmark must be approved by the collection maintainers before being listed there."
+ # Edit the bookmark and add it to a second, unmoderated collection, and recheck
+ # all the things
+ When I log out
+ And I am logged in as "bookmarker" with password "password"
+ And I view the work "The Murder of Sherlock Holmes"
+ And I follow "Edit Bookmark"
+ And I fill in "bookmark_collection_names" with "jbs_greatest,beds_and_brooms,mrs_pots"
+ And I press "Update" within "div#bookmark-form"
+ And all indexing jobs have been run
+ Then I should see "Bookmark was successfully updated."
+ And I should see "The collection JBs Greatest is currently moderated."
+ When I go to bookmarker's bookmarks page
+ Then I should see "The Murder of Sherlock Holmes"
+ And I should see "JBs Greatest" within "ul.meta"
+ And I should see "Mrs. Pots" within "ul.meta"
+ And I should see "The collection JBs Greatest is currently moderated."
+ When I go to the bookmarks page
+ Then I should see "The Murder of Sherlock Holmes"
+ And I should see "JBs Greatest" within "ul.meta"
+ And I should see "Mrs. Pots" within "ul.meta"
+ And I should see "The collection JBs Greatest is currently moderated."
+ When I log out
+ And I am logged in as "otheruser" with password "password"
+ And I go to bookmarker's bookmarks page
+ Then I should see "The Murder of Sherlock Holmes"
+ And I should not see "JBs Greatest" within "ul.meta"
+ And I should see "Bookmarker's Collections: Mrs. Pots"
+ And I should not see "The collection JBs Greatest is currently moderated."
+ When I go to the bookmarks page
+ Then I should see "The Murder of Sherlock Holmes"
+ And I should not see "JBs Greatest" within "ul.meta"
+ And I should see "Bookmarker's Collections: Mrs. Pots"
+ And I should not see "The collection JBs Greatest is currently moderated."
+
+Scenario: Adding bookmark to non-existent collection (AO3-4338)
+ Given I am logged in as "moderator" with password "password"
+ And I post the work "Programmed for Murder"
+ And I view the work "Programmed for Murder"
+ And I follow "Bookmark"
+ And I press "Create"
+ And I should see "Bookmark was successfully created"
+ Then I follow "Edit"
+ And I fill in "bookmark_collection_names" with "some_nonsense_collection"
+ And I press "Update"
+ And I should see "does not exist."
+
+Scenario: Adding bookmarks to closed collections (Issue 3083)
+ Given I am logged in as "moderator"
+ And I have a closed collection "Unsolved Mysteries" with name "unsolved_mysteries"
+ And I have a closed collection "Rescue 911" with name "rescue_911"
+ And I am logged in as "moderator"
+ And I post the work "Hooray for Homicide"
+ And I post the work "Sing a Song of Murder"
+ And I go to "Unsolved Mysteries" collection's page
+ # As a moderator, create a bookmark in a closed collection
+ When I view the work "Hooray for Homicide"
+ And I follow "Bookmark"
+ And I fill in "bookmark_collection_names" with "unsolved_mysteries"
+ And I press "Create"
+ Then I should see "Bookmark was successfully created"
+ # Now, with the exising bookmark, as a mod, add it to a different closed collection
+ When I follow "Edit"
+ And I fill in "bookmark_collection_names" with "rescue_911"
+ And I press "Update"
+ Then I should see "Bookmark was successfully updated"
+ When I view the work "Sing a Song of Murder"
+ And I follow "Bookmark"
+ And I press "Create"
+ Then I should see "Bookmark was successfully created"
+ # Use the 'Add To Collections' button to add the bookmark to a closed collection AFTER creating said bookmark
+ When I follow "Add To Collection"
+ And I fill in "collection_names" with "unsolved_mysteries"
+ And I press "Add"
+ Then I should see "Added to collection(s): Unsolved Mysteries"
+ # Still as the moderator, try to edit the bookmark which is IN a closed collection already
+ When I follow "Edit"
+ And I fill in "bookmark_notes" with "This is my edited bookmark"
+ And I press "Update"
+ Then I should see "Bookmark was successfully updated."
+ # Log in as a regular (totally awesome!) user
+ When I am logged in as "RobertStack"
+ And I view the work "Sing a Song of Murder"
+ And I follow "Bookmark"
+ And I fill in "bookmark_collection_names" with "rescue_911"
+ And I press "Create"
+ Then I should see "Sorry! We couldn't save this bookmark because:"
+ And I should see "The collection rescue_911 is not currently open."
+ When I view the work "Hooray for Homicide"
+ And I follow "Bookmark"
+ And I press "Create"
+ Then I should see "Bookmark was successfully created"
+ Then I follow "Add To Collection"
+ And I fill in "collection_names" with "rescue_911"
+ And I press "Add"
+ Then I should see "We couldn't add your submission to the following collection(s): Rescue 911 is closed to new submissions."
+ # Now, as a regular user try to add that existing bookmark to a closed collection from the 'Edit' page of a bookmark
+ When I follow "Edit"
+ And I fill in "bookmark_collection_names" with "rescue_911"
+ And I press "Update"
+ Then I should see "We couldn't add your submission to the following collections: Rescue 911 is closed to new submissions."
+ # Create a collection, put a bookmark in it, close the collection, then try
+ # to edit that bookmark
+ When I open the collection with the title "Rescue 911"
+ And I am logged in as "Scott"
+ And I view the work "Sing a Song of Murder"
+ And I follow "Bookmark"
+ And I fill in "bookmark_collection_names" with "rescue_911"
+ And I press "Create"
+ Then I should see "Bookmark was successfully created"
+ When I close the collection with the title "Rescue 911"
+ And I am logged in as "Scott"
+ And I view the work "Sing a Song of Murder"
+ And I follow "Edit Bookmark"
+ And I fill in "bookmark_notes" with "This is a user editing a closed collection bookmark"
+ And I press "Update"
+ Then I should see "Bookmark was successfully updated."
+
+Scenario: Delete bookmarks of a work and a series
+ Given the following activated users exist
+ | login | password |
+ | wahlly | password |
+ | markymark | password |
+ And I am logged in as "wahlly"
+ And I add the work "A Mighty Duck" to series "The Funky Bunch"
+ And I add the work "A Mighty Duck2 the sequel" to series "The Funky Bunch"
+ When I log out
+ And I am logged in as "markymark"
+ And I view the work "A Mighty Duck2 the sequel"
+ And I follow "Bookmark"
+ And I press "Create"
+ And I view the work "A Mighty Duck"
+ And I follow "Bookmark"
+ And I press "Create"
+ And all indexing jobs have been run
+ Then I should see "Bookmark was successfully created."
+ And I should see "Delete"
+ When I follow "Delete"
+ And I press "Yes, Delete Bookmark"
+ And all indexing jobs have been run
+ Then I should see "Bookmark was successfully deleted."
+ When I view the series "The Funky Bunch"
+ And I follow "Bookmark Series"
+ And I press "Create"
+ And all indexing jobs have been run
+ Then I should see "Bookmark was successfully created."
+ When I follow "Delete"
+ And I press "Yes, Delete Bookmark"
+ And all indexing jobs have been run
+ Then I should see "Bookmark was successfully deleted."
+ When I go to markymark's bookmarks page
+ Then I should see "A Mighty Duck2 the sequel"
+ When I log out
+ And I am logged in as "wahlly"
+ And I delete the work "A Mighty Duck2 the sequel"
+ And all indexing jobs have been run
+ Then I should see "A Mighty Duck2 the sequel was deleted."
+ When I log out
+ And I am logged in as "markymark"
+ And I go to markymark's bookmarks page
+ Then I should see "This has been deleted, sorry!"
+ And I follow "Edit"
+ And I check "bookmark_private"
+ And I press "Update"
+ And all indexing jobs have been run
+ Then I should see "Bookmark was successfully updated"
+ When I follow "Delete"
+ And I press "Yes, Delete Bookmark"
+ Then I should see "Bookmark was successfully deleted."
+
+Scenario: Editing a bookmark's tags should expire the bookmark cache
+ Given I am logged in as "some_user"
+ And I post the work "Really Good Thing"
+ When I am logged in as "bookmarker"
+ And I view the work "Really Good Thing"
+ And I follow "Bookmark"
+ And I fill in "bookmark_notes" with "I liked this story"
+ And I fill in "bookmark_tag_string" with "Tag 1, Tag 2"
+ And I press "Create"
+ Then I should see "Bookmark was successfully created"
+ And the cache of the bookmark on "Really Good Thing" should not expire if I have not edited the bookmark
+ And the cache of the bookmark on "Really Good Thing" should expire after I edit the bookmark tags
+
+Scenario: User can't bookmark same work twice
+ Given the work "Haven"
+ And I am logged in as "Mara"
+ And "Mara" creates the pseud "Audrey"
+ And I bookmark the work "Haven" as "Mara"
+ When I bookmark the work "Haven" as "Mara" from new bookmark page
+ Then I should see "You have already bookmarked that."
+ When I bookmark the work "Haven" as "Audrey" from new bookmark page
+ Then I should see "You have already bookmarked that."
+
+Scenario: I cannot create a bookmark that I don't own
+ Given the work "Random Work"
+ When I attempt to create a bookmark of "Random Work" with a pseud that is not mine
+ Then I should not see "Bookmark was successfully created"
+ And I should see "You can't bookmark with that pseud."
+
+Scenario: I cannot edit an existing bookmark to transfer it to a pseud I don't own
+ Given I am logged in as "original_bookmarker"
+ And I have a bookmark for "Random Work"
+ When I attempt to transfer my bookmark of "Random Work" to a pseud that is not mine
+ Then I should not see "Bookmark was successfully updated"
+ And I should see "You can't bookmark with that pseud."
+
+Scenario: A bookmark with duplicate tags other than capitalization has only first version of tag saved
+ Given I am logged in as "bookmark_user"
+ When I post the work "Revenge of the Sith"
+ And I follow "Bookmark"
+ And I fill in "Your tags" with "my tags,My Tags"
+ And I press "Create"
+ Then I should see "Bookmark was successfully created"
+ And I should see "Bookmarker's Tags: my tags"
+ And I should not see "Bookmarker's Tags: My Tags"
+
+ Scenario: Users can bookmark a work with too many tags
+ Given the user-defined tag limit is 2
+ And the work "Over the Limit"
+ And the work "Over the Limit" has 3 fandom tags
+ And I am logged in as "bookmarker"
+ When I bookmark the work "Over the Limit"
+ Then I should see "Bookmark was successfully created"
+
+ Scenario: Users can bookmark a pre-existing external work with too many tags
+ Given the user-defined tag limit is 2
+ And I am logged in as "bookmarker1"
+ And I bookmark the external work "Over the Limit"
+ And the external work "Over the Limit" has 3 fandom tags
+ And I am logged in as "bookmarker2"
+ When I go to bookmarker1's bookmarks page
+ And I follow "Save"
+ And I press "Create"
+ Then I should see "Bookmark was successfully created"
+
+ Scenario: Users cannot bookmark a new external work with too many tags
+ Given the user-defined tag limit is 5
+ And I am logged in as "bookmarker"
+ When I set up an external work
+ And I fill in "Fandoms" with "Fandom 1, Fandom 2"
+ And I fill in "Characters" with "Character 1, Character 2"
+ And I fill in "Relationships" with "Relationship 1, Relationship 2"
+ And I press "Create"
+ Then I should see "Fandom, relationship, and character tags must not add up to more than 5. You have entered 6 of these tags, so you must remove 1 of them."
+
+ Scenario: Archivists can add bookmarks to collections
+ Given I have an archivist "archivist"
+ And I am logged in as "archivist"
+ And I create the collection "My Collection" with name "MyCollection"
+ When I open a bookmarkable work
+ And I follow "Bookmark"
+ And I fill in "bookmark_collection_names" with "MyCollection"
+ And I press "Create"
+ Then I should see "Bookmark was successfully created"
diff --git a/features/bookmarks/bookmark_external.feature b/features/bookmarks/bookmark_external.feature
new file mode 100755
index 0000000..4fb4034
--- /dev/null
+++ b/features/bookmarks/bookmark_external.feature
@@ -0,0 +1,212 @@
+@bookmarks
+Feature: Create bookmarks of external works
+ In order to have an archive full of bookmarks
+ As a humble user
+ I want to bookmark some works
+
+ Scenario: A user can bookmark an external work using all the Creator's Tags fields (fandoms, rating, category, relationships, character)
+ Given basic tags
+ And mock websites with no content
+ And I am logged in as "bookmarker"
+ And I am on the new external work page
+ When I fill in "URL" with "http://example.org/200"
+ And I fill in "Creator" with "ao3testing"
+ And I fill in "Title" with "Some External Work"
+ And I fill in "Fandoms" with "Test Fandom"
+ And I select "General Audiences" from "Rating"
+ And I check "M/M"
+ And I fill in "Relationships" with "Character 1/Character 2"
+ And I fill in "Characters" with "Character 3, Character 4"
+ And I press "Create"
+ Then I should see "Bookmark was successfully created."
+ And I should see "Some External Work"
+ And I should see "ao3testing"
+ And I should see "Test Fandom"
+ And I should see "General Audiences"
+ And I should see "M/M"
+ And I should see "Character 1/Character 2"
+ And I should see "Character 3"
+ And I should see "Character 4"
+
+ Scenario: A user must enter a fandom to create a bookmark on an external work
+ Given basic tags
+ And mock websites with no content
+ And I am logged in as "first_bookmark_user"
+ When I go to first_bookmark_user's bookmarks page
+ Then I should not see "Stuck with You"
+ When I follow "Bookmark External Work"
+ And I fill in "Creator" with "Sidra"
+ And I fill in "Title" with "Stuck with You"
+ And I fill in "URL" with "http://example.org/200"
+ And I press "Create"
+ Then I should see "Fandom tag is required"
+ When I fill in "Fandoms" with "Popslash"
+ And I press "Create"
+ And all indexing jobs have been run
+ Then I should see "This work isn't hosted on the Archive"
+ When I go to first_bookmark_user's bookmarks page
+ Then I should see "Stuck with You"
+
+ Scenario: A user must enter a valid URL to create a bookmark on an external work
+ Given I am logged in as "first_bookmark_user"
+ And the default ratings exist
+ And mock websites with no content
+ When I go to first_bookmark_user's bookmarks page
+ Then I should not see "Stuck with You"
+ When I follow "Bookmark External Work"
+ And I fill in "Creator" with "Sidra"
+ And I fill in "Title" with "Stuck with You"
+ And I fill in "Fandoms" with "Popslash"
+ And I press "Create"
+ Then I should see "does not appear to be a valid URL"
+ When I fill in "URL" with "http://example.org/200"
+ And I press "Create"
+ And all indexing jobs have been run
+ Then I should see "This work isn't hosted on the Archive"
+ When I go to first_bookmark_user's bookmarks page
+ Then I should see "Stuck with You"
+
+ # edit external bookmark
+ When I follow "Edit"
+ Then I should see "Editing bookmark for Stuck with You"
+ When I fill in "Notes" with "I wish this author would join AO3"
+ And I fill in "Your tags" with "WIP"
+ And I press "Update"
+ Then I should see "Bookmark was successfully updated"
+
+ # delete external bookmark
+ When I follow "Delete"
+ Then I should see "Are you sure you want to delete"
+ And I should see "Stuck with You"
+ When I press "Yes, Delete Bookmark"
+ Then I should see "Bookmark was successfully deleted."
+ And I should not see "Stuck with You"
+
+ Scenario Outline: A user can enter a valid non-ASCII URL to create a bookmark on an external work
+ Given I am logged in as "first_bookmark_user"
+ And the default ratings exist
+ And all pages on the host "" return status 200
+ When I am on the new external work page
+ And I fill in "URL" with ""
+ And I fill in "Creator" with "foo"
+ And I fill in "Title" with ""
+ And I fill in "Fandoms" with "Popslash"
+ And I press "Create"
+ Then I should see "Bookmark was successfully created."
+
+ Examples:
+ | url | title |
+ | https://example.com/ö | Ö |
+ | https://example.com/á | á |
+ | https://example.com/?utf8=✓ | check |
+ | https://example.com/a,b,c | comma |
+
+ Scenario: Bookmark External Work link should be available to logged in users, but not logged out users
+ Given a fandom exists with name: "Testing BEW Button", canonical: true
+ And I am logged in as "markie" with password "theunicorn"
+ And I create the collection "Testing BEW Collection"
+ When I go to markie's bookmarks page
+ Then I should see "Bookmark External Work"
+ When I go to the bookmarks page
+ Then I should see "Bookmark External Work"
+ When I go to the bookmarks in collection "Testing BEW Collection"
+ Then I should see "Bookmark External Work"
+ When I log out
+ And I go to markie's bookmarks page
+ Then I should not see "Bookmark External Work"
+ When I go to the bookmarks page
+ Then I should not see "Bookmark External Work"
+ When I go to the bookmarks tagged "Testing BEW Button"
+ Then I should not see "Bookmark External Work"
+ When I go to the bookmarks in collection "Testing BEW Collection"
+ Then I should not see "Bookmark External Work"
+
+ Scenario: Users can see external works, admins can also see only duplicates
+ Given basic tags
+ And I am logged in
+ # Bookmark the same URL twice
+ And I bookmark the external work "External Title"
+ And I bookmark the external work "Alternate Title"
+
+ When I go to the external works page
+ Then I should see "External Title"
+ And I should see "Alternate Title"
+ And I should not see "Show duplicates"
+
+ When I go to the external works page with only duplicates
+ Then I should see "Sorry, you don't have permission to access the page you were trying to reach."
+
+ When I am logged in as an admin
+ And I go to the external works page
+ Then I should see "External Title"
+ And I should see "Alternate Title"
+ And I should see "Show duplicates (1)"
+ When I follow "Show duplicates (1)"
+ Then I should see "External Title"
+ And I should not see "Alternate Title"
+
+ @javascript
+ Scenario: When using the bookmarklet on a URL that has already been bookmarked, the work information will be automatically filled in
+ Given "first_user" has bookmarked an external work
+ And I am logged in as "second_user"
+ When I use the bookmarklet on a previously bookmarked URL
+ And I press "Create"
+ Then I should see "Bookmark was successfully created."
+ And the work info for my new bookmark should match the original
+
+ @javascript
+ Scenario: When using the autocomplete to select a URL that has already been bookmarked, the work information will be automatically filled in
+ Given "first_user" has bookmarked an external work
+ And I am logged in as "second_user"
+ When I go to the new external work page
+ And I choose a previously bookmarked URL from the autocomplete
+ And I press "Create"
+ Then I should see "Bookmark was successfully created."
+ And the work info for my new bookmark should match the original
+
+ @javascript
+ Scenario: When getting an error (e.g. because the title is blank) after using the autocomplete to select a previously-bookmarked URL, any changes you made to the work information are not overridden on the edit page
+ Given "first_user" has bookmarked an external work
+ And I am logged in as "second_user"
+ When I go to the new external work page
+ And I choose a previously bookmarked URL from the autocomplete
+ And I fill in "Title" with ""
+ And I fill in "Creator" with "Different Author"
+ And I press "Create"
+ Then I should see "Sorry! We couldn't save this bookmark because:"
+ And I should see "Title can't be blank"
+ And the field labeled "Title" should contain ""
+ And the field labeled "Creator" should contain "Different Author"
+ When I fill in "Title" with "Different Title"
+ And I press "Create"
+ Then I should see "Bookmark was successfully created."
+ And the title and creator info for my new bookmark should vary from the original
+ And the summary and tag info for my new bookmark should match the original
+
+ @javascript
+ Scenario: When using the autocomplete to select a URL that has already been bookmarked, any information you have entered will be overwritten
+ Given "first_user" has bookmarked an external work
+ And I am logged in as "second_user"
+ When I go to the new external work page
+ And I fill in "Creator" with "ao3testing"
+ And I fill in "Title" with "Some External Work"
+ And I fill in "Fandoms" with "Test Fandom"
+ And I select "General Audiences" from "Rating"
+ And I check "M/M"
+ And I fill in "Relationships" with "Character 1/Character 2"
+ And I fill in "Characters" with "Character 3, Character 4"
+ And I choose a previously bookmarked URL from the autocomplete
+ And I press "Create"
+ Then I should see "Bookmark was successfully created."
+ And the work info for my new bookmark should match the original
+
+ Scenario: Bookmark of external work with HTTPS URL is saved as HTTPS
+ Given I am logged in as "bookmarker1"
+ And all pages on the host "https://example.com" return status 200
+ And I set up an external work
+ And I fill in "URL" with "https://example.com/external_work"
+ And I press "Create"
+ Then I should see "Bookmark was successfully created"
+ When I follow "A Work Not Posted To The AO3"
+ Then I should see "This work isn't hosted on the Archive"
+ And I should see a link to "https://example.com/external_work" within "h4"
diff --git a/features/bookmarks/bookmark_filter.feature b/features/bookmarks/bookmark_filter.feature
new file mode 100755
index 0000000..e4c3a64
--- /dev/null
+++ b/features/bookmarks/bookmark_filter.feature
@@ -0,0 +1,19 @@
+@bookmarks
+Feature: Filter bookmarks
+ In order to search an archive full of bookmarks
+ As a humble user
+ I want to filter some bookmarks
+
+ Scenario: Filter a user's bookmarks by work language
+ Given "recengine" has bookmarks of works in various languages
+ And I am logged in as "recengine"
+ When I go to recengine's bookmarks page
+ And I select "Deutsch" from "Work language"
+ And I press "Sort and Filter"
+ Then I should see "1 Bookmark by recengine"
+ And I should not see "english work"
+ And I should see "german work"
+ When I follow "Clear Filters"
+ Then I should see "2 Bookmarks by recengine"
+ And I should see "english work"
+ And I should see "german work"
diff --git a/features/bookmarks/bookmark_indexing.feature b/features/bookmarks/bookmark_indexing.feature
new file mode 100755
index 0000000..e32a131
--- /dev/null
+++ b/features/bookmarks/bookmark_indexing.feature
@@ -0,0 +1,167 @@
+@bookmarks @search
+Feature: Bookmark Indexing
+
+ Scenario: Adding a new, out-of-fandom work to a series
+ Given I am logged in as "author"
+ And a canonical fandom "Veronica Mars"
+ And a canonical fandom "X-Files"
+ And I post the work "The Story Telling: Beginnings" with fandom "Veronica Mars" as part of a series "Telling Stories"
+ And I post the work "Unrelated Story" with fandom "X-Files"
+ And I am logged in as "bookmarker"
+ And I bookmark the work "Unrelated Story"
+ And I bookmark the series "Telling Stories"
+ When I go to the bookmarks tagged "X-Files"
+ And I select "Date Updated" from "Sort by"
+ And I press "Sort and Filter"
+ Then the 1st bookmark result should contain "Unrelated Story"
+ When I am logged in as "author"
+ And I post the work "The Story Returns" with fandom "X-Files" as part of a series "Telling Stories"
+ And I go to the bookmarks tagged "X-Files"
+ Then I should see "Telling Stories"
+ And I should not see "This tag has not been marked common and can't be filtered on (yet)."
+ When I select "Date Updated" from "Sort by"
+ And I press "Sort and Filter"
+ Then the 1st bookmark result should contain "Telling Stories"
+ And the 2nd bookmark result should contain "Unrelated Story"
+ When I go to bookmarker's user page
+ And I follow "Bookmarks (2)"
+ And I select "Date Updated" from "Sort by"
+ And I press "Sort and Filter"
+ Then the 1st bookmark result should contain "Telling Stories"
+ And the 2nd bookmark result should contain "Unrelated Story"
+ When I go to the bookmarks tagged "X-Files"
+ Then I should see "Telling Stories"
+ And I should not see "This tag has not been marked common and can't be filtered on (yet)."
+ When I select "Date Updated" from "Sort by"
+ And I press "Sort and Filter"
+ Then the 1st bookmark result should contain "Telling Stories"
+ And the 2nd bookmark result should contain "Unrelated Story"
+
+ Scenario: When a work in a series is updated with a new tag, bookmarks of the
+ series should appear on the tag's bookmark listing; when a tag is removed, the
+ bookmarks should disappear from the tag listing
+ Given a canonical freeform "New Tag"
+ And I am logged in
+ And I post the work "Work" as part of a series "Series"
+ And I bookmark the series "Series"
+ When I edit the work "Work"
+ And I fill in "Additional Tags" with "New Tag"
+ And I press "Post"
+ And all indexing jobs have been run
+ And I go to the bookmarks tagged "New Tag"
+ Then the 1st bookmark result should contain "Series"
+ When I edit the work "Work"
+ And I fill in "Additional Tags" with ""
+ And I press "Post"
+ And all indexing jobs have been run
+ And I go to the bookmarks tagged "New Tag"
+ Then I should not see "Series"
+
+ Scenario: Synning a canonical tag used on bookmarked series and external works
+ should move the bookmarks to the new canonical's bookmark listings; de-synning
+ should remove them
+ Given a canonical fandom "Veronica Mars"
+ And a canonical fandom "Veronica Mars (TV)"
+ And bookmarks of external works and series tagged with the fandom tag "Veronica Mars"
+ And I am logged in as a "tag_wrangling" admin
+ When I syn the tag "Veronica Mars" to "Veronica Mars (TV)"
+ And I go to the bookmarks tagged "Veronica Mars (TV)"
+ Then I should see "BookmarkedExternalWork"
+ And I should see "BookmarkedSeries"
+ When I de-syn the tag "Veronica Mars" from "Veronica Mars (TV)"
+ And the tag "Veronica Mars" is canonized
+ And I go to the bookmarks tagged "Veronica Mars (TV)"
+ Then I should not see "BookmarkedExternalWork"
+ And I should not see "BookmarkedSeries"
+ When I go to the bookmarks tagged "Veronica Mars"
+ Then I should see "BookmarkedExternalWork"
+ And I should see "BookmarkedSeries"
+ When I syn the tag "Veronica Mars" to "Veronica Mars (TV)"
+ And I go to the bookmarks tagged "Veronica Mars (TV)"
+ Then I should see "BookmarkedSeries"
+ And I should see "BookmarkedExternalWork"
+
+ Scenario: Subtagging a tag used on bookmarked series and external works should
+ make the bookmarks appear in the metatag's bookmark listings; de-subbing
+ should remove them
+ Given a canonical character "Laura"
+ And a canonical character "Laura Roslin"
+ And bookmarks of external works and series tagged with the character tag "Laura Roslin"
+ And I am logged in as a tag wrangler
+ When I subtag the tag "Laura Roslin" to "Laura"
+ And I go to the bookmarks tagged "Laura"
+ Then I should see "BookmarkedExternalWork"
+ And I should see "BookmarkedSeries"
+ When I remove the metatag "Laura" from "Laura Roslin"
+ And I go to the bookmarks tagged "Laura"
+ Then I should not see "BookmarkedExternalWork"
+ And I should not see "BookmarkedSeries"
+ When I go to the bookmarks tagged "Laura Roslin"
+ Then I should see "BookmarkedExternalWork"
+ And I should see "BookmarkedSeries"
+
+ Scenario: A bookmark of an external work should show on a tag's bookmark
+ listing once the tag is made canonical
+ Given basic tags
+ And I am logged in as "bookmarker"
+ And I bookmark the external work "Outside Story" with character "Mikki Mendoza"
+ When the tag "Mikki Mendoza" is canonized
+ And I go to the bookmarks tagged "Mikki Mendoza"
+ Then I should see "Outside Story"
+
+ Scenario: New bookmarks of external works should appear in the bookmark listings for its tag's existing metatag, and removing the tag should remove the bookmark from both the tag's and metatag's bookmark listings
+ Given basic tags
+ And a canonical character "Ann"
+ And a canonical character "Ann Ewing"
+ And "Ann" is a metatag of the character "Ann Ewing"
+ When I am logged in
+ And I bookmark the external work "The Big D" with character "Ann Ewing"
+ And I go to the bookmarks tagged "Ann"
+ Then I should see "The Big D"
+ When the character "Ann Ewing" is removed from the external work "The Big D"
+ And I go to the bookmarks tagged "Ann Ewing"
+ Then I should not see "The Big D"
+ When I go to the bookmarks tagged "Ann"
+ Then I should not see "The Big D"
+
+ Scenario: Adding a chapter to a work in a series should update the series, as
+ should deleting a chapter from a work in a series
+ Given I have bookmarks of old series to search
+ When a chapter is added to "WIP in a Series"
+ And I go to the search bookmarks page
+ And I select "Series" from "Type"
+ And I select "Date Updated" from "Sort by"
+ And I press "Search Bookmarks"
+ Then the 1st bookmark result should contain "Older WIP Series"
+ And the 2nd bookmark result should contain "Newer Complete Series"
+ When I delete chapter 2 of "WIP in a Series"
+ And I go to the search bookmarks page
+ And I select "Series" from "Type"
+ And I select "Date Updated" from "Sort by"
+ And I press "Search Bookmarks"
+ Then the 1st bookmark result should contain "Newer Complete Series"
+ And the 2nd bookmark result should contain "Older WIP Series"
+
+ Scenario: When a wrangler edits a tag's merger using the "Synonym of" field,
+ the tag's bookmarks should be transfered to the new merger's bookmark listings
+ Given a canonical character "Ellie Ewing"
+ And a canonical character "Ellie Farlow"
+ And a synonym "Miss Ellie" of the tag "Ellie Ewing"
+ And bookmarks of all types tagged with the character tag "Miss Ellie"
+ And I am logged in as a tag wrangler
+ When I go to the bookmarks tagged "Ellie Ewing"
+ Then I should see "BookmarkedWork"
+ And I should see "BookmarkedSeries"
+ And I should see "BookmarkedExternalWork"
+ When I edit the tag "Miss Ellie"
+ And I fill in "Synonym of" with "Ellie Farlow"
+ And I press "Save changes"
+ And all indexing jobs have been run
+ And I go to the bookmarks tagged "Ellie Ewing"
+ Then I should not see "BookmarkedWork"
+ And I should not see "BookmarkedSeries"
+ And I should not see "BookmarkedExternalWork"
+ When I go to the bookmarks tagged "Ellie Farlow"
+ Then I should see "BookmarkedWork"
+ And I should see "BookmarkedSeries"
+ And I should see "BookmarkedExternalWork"
diff --git a/features/bookmarks/bookmark_privacy.feature b/features/bookmarks/bookmark_privacy.feature
new file mode 100755
index 0000000..7d5417b
--- /dev/null
+++ b/features/bookmarks/bookmark_privacy.feature
@@ -0,0 +1,200 @@
+@bookmarks
+Feature: Private bookmarks
+ In order to have an archive full of bookmarks
+ As a humble user
+ I want to bookmark some works privately
+
+ @disable_caching
+ Scenario: private bookmarks on public and restricted works
+
+ Given dashboard counts expire after 10 seconds
+ And a canonical fandom "Stargate SG-1"
+ And I am logged in as "workauthor"
+ And I post the locked work "Secret Masterpiece"
+ And I post the work "Public Masterpiece"
+ And I post the work "Another Masterpiece"
+ When I am logged in as "avid_bookmarker"
+ And "avid_bookmarker" creates the pseud "infrequent_bookmarker"
+ And I start a new bookmark for "Secret Masterpiece"
+ And I check "Rec"
+ And I check "Private bookmark"
+ And I press "Create"
+ Then I should see "Bookmark was successfully created"
+ And I should see the image "title" text "Restricted"
+ And I should not see "Rec"
+ And I should see "Private Bookmark"
+ And I should see "0" within ".count"
+ When I start a new bookmark for "Public Masterpiece"
+ And I check "Rec"
+ And I check "Private bookmark"
+ And I press "Create"
+ Then I should see "Bookmark was successfully created"
+ And I should not see the image "title" text "Restricted"
+ And I should not see "Rec"
+ And I should see "Private Bookmark"
+ And I should see "0" within ".count"
+ When I start a new bookmark for "Another Masterpiece"
+ And I select "infrequent_bookmarker" from "bookmark_pseud_id"
+ And I check "Private bookmark"
+ And I press "Create"
+ And all indexing jobs have been run
+ Then I should see "Bookmark was successfully created"
+ And I should not see the image "title" text "Restricted"
+ And I should not see "Rec"
+ And I should see "Private Bookmark"
+ And I should see "0" within ".count"
+
+ # Private bookmarks should not show on the main bookmark page, but should show on your own bookmark page
+
+ When I go to the bookmarks page
+ Then I should not see "Secret Masterpiece"
+ And I should not see "Public Masterpiece"
+ And I should not see "Another Masterpiece"
+ When I am on avid_bookmarker's bookmarks page
+ Then I should see "3 Bookmarks by avid_bookmarker"
+ And I should see "Bookmarks (0)"
+ And I should see "Public Masterpiece"
+ And I should see "Secret Masterpiece"
+ And I should see "Another Masterpiece"
+ When I wait 11 seconds
+ And I reload the page
+ Then I should see "Bookmarks (3)"
+ When I go to the bookmarks page for user "avid_bookmarker" with pseud "infrequent_bookmarker"
+ Then I should see "1 Bookmark by infrequent_bookmarker (avid_bookmarker)"
+ And I should see "Bookmarks (1)"
+ And I should see "Another Masterpiece"
+ But I should not see "Secret Masterpiece"
+ And I should not see "Public Masterpiece"
+
+ # Private bookmarks should not be visible when logged out
+
+ When I log out
+ And I go to the bookmarks page
+ Then I should not see "Secret Masterpiece"
+ And I should not see "Public Masterpiece"
+ And I should not see "Another Masterpiece"
+ And I should not see "avid_bookmarker"
+ When I go to avid_bookmarker's bookmarks page
+ Then I should see "Bookmarks (0)"
+ And I should not see "Secret Masterpiece"
+ And I should not see "Public Masterpiece"
+ And I should not see "Another Masterpiece"
+ When I go to the bookmarks page for user "avid_bookmarker" with pseud "infrequent_bookmarker"
+ Then I should see "Bookmarks (0)"
+ And I should not see "Secret Masterpiece"
+ And I should not see "Public Masterpiece"
+ And I should not see "Another Masterpiece"
+ When I go to the works page
+ Then I should not see "Secret Masterpiece"
+ And I should see "Public Masterpiece"
+ And I should not see "Bookmarks:"
+ And I should not see "Bookmarks: 1"
+ When I view the work "Public Masterpiece"
+ Then I should not see "Bookmarks:"
+ And I should not see "Bookmarks:1"
+
+ # Private bookmarks should not be visible to other users
+
+ When I am logged in as "otheruser"
+ And I go to the bookmarks page
+ Then I should not see "Secret Masterpiece"
+ And I should not see "Public Masterpiece"
+ And I should not see "Another Masterpiece"
+ When I go to avid_bookmarker's bookmarks page
+ Then I should see "Bookmarks (0)"
+ And I should not see "Secret Masterpiece"
+ And I should not see "Public Masterpiece"
+ And I should not see "Another Masterpiece"
+ When I go to the bookmarks page for user "avid_bookmarker" with pseud "infrequent_bookmarker"
+ Then I should see "Bookmarks (0)"
+ And I should not see "Secret Masterpiece"
+ And I should not see "Public Masterpiece"
+ And I should not see "Another Masterpiece"
+ When I go to the works page
+ Then I should see "Public Masterpiece"
+ And I should see "Another Masterpiece"
+ And I should not see "Secret Masterpiece"
+ And I should not see "Bookmarks:"
+ And I should not see "Bookmarks: 1"
+
+ # Private bookmarks should not be visible even to the author
+
+ When I am logged in as "workauthor"
+ And I go to the bookmarks page
+ Then I should not see "Secret Masterpiece"
+ And I should not see "Public Masterpiece"
+ And I should not see "Another Masterpiece"
+ When I go to avid_bookmarker's bookmarks page
+ Then I should see "Bookmarks (0)"
+ And I should not see "Secret Masterpiece"
+ And I should not see "Public Masterpiece"
+ And I should not see "Another Masterpiece"
+ When I go to the bookmarks page for user "avid_bookmarker" with pseud "infrequent_bookmarker"
+ Then I should see "Bookmarks (0)"
+ And I should not see "Secret Masterpiece"
+ And I should not see "Public Masterpiece"
+ And I should not see "Another Masterpiece"
+
+ # Private bookmarks should not be visible when logged out, even if there are other bookmarks on that work
+
+ When I am logged in as "otheruser"
+ And I view the work "Public Masterpiece"
+ And I rec the current work
+ And all indexing jobs have been run
+ When I log out
+ And I go to the bookmarks page
+ Then I should not see "Secret Masterpiece"
+ And I should not see "Another Masterpiece"
+ And I should see "Public Masterpiece"
+ And I should not see "avid_bookmarker"
+ And I should see "otheruser"
+ When I go to avid_bookmarker's bookmarks page
+ Then I should not see "Secret Masterpiece"
+ And I should not see "Public Masterpiece"
+ And I should not see "Another Masterpiece"
+ When I go to the bookmarks page for user "avid_bookmarker" with pseud "infrequent_bookmarker"
+ Then I should see "Bookmarks (0)"
+ And I should not see "Secret Masterpiece"
+ And I should not see "Public Masterpiece"
+ And I should not see "Another Masterpiece"
+ When I go to the works page
+ Then I should not see "Secret Masterpiece"
+ And I should see "Public Masterpiece"
+ And I should not see "Bookmarks: 2"
+ And I should see "Bookmarks: 1"
+ And I should see "Another Masterpiece"
+ When I view the work "Public Masterpiece"
+ Then I should not see "Bookmarks:2"
+ And I should see "Bookmarks:1"
+ When I follow "1"
+ Then I should see "List of Bookmarks"
+ And I should see "Public Masterpiece"
+ And I should see "otheruser"
+ And I should not see "avid_bookmarker"
+
+ # Private bookmarks should not show on tag's page
+
+ When I go to the bookmarks tagged "Stargate SG-1"
+ Then I should not see "Secret Masterpiece"
+ And I should see "Public Masterpiece"
+ And I should not see "Another Masterpiece"
+ And I should not see "avid_bookmarker"
+ And I should see "otheruser"
+ # This *should* be 1, because there's no way for a bookmark to appear on
+ # a tag bookmark page if the bookmarkable has a public_bookmark_count of
+ # 0. However, caching means that this is actually 0:
+ And I should see "0" within ".count"
+ And I should not see "2" within ".count"
+
+ # Private bookmarks should not be visible to admins, but the admin
+ # should be able to see how many private bookmarks the user has
+
+ When I am logged in as an admin
+ And I go to avid_bookmarker's bookmarks page
+ Then I should see "Bookmarks (3)"
+ But I should not see "Secret Masterpiece"
+ And I should not see "Public Masterpiece"
+ And I should not see "Another Masterpiece"
+ When I go to the bookmarks page for user "avid_bookmarker" with pseud "infrequent_bookmarker"
+ Then I should see "Bookmarks (1)"
+ But I should not see "Another Masterpiece"
diff --git a/features/bookmarks/bookmark_search.feature b/features/bookmarks/bookmark_search.feature
new file mode 100755
index 0000000..ec9ab52
--- /dev/null
+++ b/features/bookmarks/bookmark_search.feature
@@ -0,0 +1,257 @@
+@bookmarks @search
+Feature: Search Bookmarks
+ In order to test search
+ As a humble coder
+ I have to use cucumber with elasticsearch
+
+ Background:
+ Given I am on the search bookmarks page
+
+ Scenario: Search bookmarks by tag
+ Given I have bookmarks to search
+
+ # Only on bookmarks
+ When I fill in "Bookmarker's tags" with "rare"
+ And I press "Search Bookmarks"
+ Then I should see the page title "Search Bookmarks"
+ And I should see "You searched for: Tags: rare"
+ And I should see "1 Found"
+ And I should see "second work"
+ When I follow "Edit Your Search"
+ Then the field labeled "Bookmarker's tags" should contain "rare"
+
+ # Only on bookmarkables
+ When I am on the search bookmarks page
+ And I fill in "Work tags" with "rare"
+ And I press "Search Bookmarks"
+ Then I should see the page title "Search Bookmarks"
+ And I should see "You searched for: Tags: rare"
+ And I should see "2 Found"
+ And I should see "First work"
+ And I should see "First Series"
+ When I follow "Edit Your Search"
+ Then the field labeled "Work tags" should contain "rare"
+
+ # On bookmarks and bookmarkables, results should match both
+ When I am on the search Bookmarks page
+ And I fill in "Bookmarker's tags" with "rare"
+ And I fill in "Work tags" with "rare"
+ And I press "Search Bookmarks"
+ Then I should see the page title "Search Bookmarks"
+ And I should see "You searched for: Tags: rare"
+ And I should see "No results found."
+
+ Scenario: Search bookmarks by date bookmarked
+ Given I have bookmarks to search by dates
+ When I fill in "Date bookmarked" with "> 900 days ago"
+ And I press "Search Bookmarks"
+ Then I should see the page title "Search Bookmarks"
+ And I should see "You searched for: Date bookmarked: > 900 days ago"
+ And I should see "3 Found"
+ And I should see "Old bookmark of old work"
+ And I should see "Old bookmark of old series"
+ And I should see "Old bookmark of old external work"
+ When I follow "Edit Your Search"
+ Then the field labeled "Date bookmarked" should contain "> 900 days ago"
+
+ When I fill in "Date bookmarked" with "< 900 days ago"
+ And I press "Search Bookmarks"
+ Then I should see "You searched for: Date bookmarked: < 900 days ago"
+ And I should see "6 Found"
+ And I should see "New bookmark of old work"
+ And I should see "New bookmark of new work"
+ And I should see "New bookmark of old series"
+ And I should see "New bookmark of new series"
+ And I should see "New bookmark of old external work"
+ And I should see "New bookmark of new external work"
+
+ Scenario: Search bookmarks by date updated
+ Given I have bookmarks to search by dates
+ When I fill in "Date updated" with "> 900 days ago"
+ And I press "Search Bookmarks"
+ Then I should see the page title "Search Bookmarks"
+ And I should see "You searched for: Date updated: > 900 days ago"
+ And I should see "6 Found"
+ And I should see "Old bookmark of old work"
+ And I should see "New bookmark of old work"
+ And I should see "Old bookmark of old series"
+ And I should see "New bookmark of old series"
+ And I should see "Old bookmark of old external work"
+ And I should see "New bookmark of old external work"
+ When I follow "Edit Your Search"
+ Then the field labeled "Date updated" should contain "> 900 days ago"
+
+ When I fill in "Date updated" with "< 900 days ago"
+ And I press "Search Bookmarks"
+ Then I should see "You searched for: Date updated: < 900 days ago"
+ And I should see "3 Found"
+ And I should see "New bookmark of new work"
+ And I should see "New bookmark of new series"
+ And I should see "New bookmark of new external work"
+
+ Scenario: Search bookmarks for recs
+ Given I have bookmarks to search
+ When I check "Rec"
+ And I press "Search Bookmarks"
+ Then I should see the page title "Search Bookmarks"
+ And I should see "You searched for: Rec"
+ And I should see "2 Found"
+ And I should see "First work"
+ And I should see "Second Series"
+ When I follow "Edit Your Search"
+ Then the "Rec" checkbox should be checked
+
+ Scenario: Search bookmarks by any field
+ Given I have bookmarks to search by any field
+
+ # Only on bookmarks
+ When I fill in "Any field on bookmark" with "more please"
+ And I press "Search Bookmarks"
+ Then I should see the page title "Bookmarks Matching 'more please'"
+ And I should see "You searched for: more please"
+ And I should see "6 Found"
+ And I should see "Hurt and that's it"
+ And I should see "Fluff"
+ And I should see "H/C Series"
+ And I should see "Ouchless Series"
+ And I should see "External Fix-It"
+ And I should see "External Whump"
+ When I follow "Edit Your Search"
+ Then the field labeled "Any field on bookmark" should contain "more please"
+
+ # Only on bookmarkables
+ When I am on the search bookmarks page
+ And I fill in "Any field on work" with "hurt"
+ And I press "Search Bookmarks"
+ Then I should see the page title "Bookmarks Matching 'hurt'"
+ And I should see "You searched for: hurt"
+ And I should see "4 Found"
+ And I should see "Comfort"
+ And I should see "Hurt and that's it"
+ And I should see "H/C Series"
+ And I should see "External Whump"
+ When I follow "Edit Your Search"
+ Then the field labeled "Any field on work" should contain "hurt"
+
+ # On bookmarks and bookmarkables, results should match both
+ When I am on the search bookmarks page
+ And I fill in "Any field on bookmark" with "more please"
+ And I fill in "Any field on work" with "hurt"
+ And I press "Search Bookmarks"
+ Then I should see the page title "Bookmarks Matching 'hurt, more please'"
+ And I should see "You searched for: hurt, more please"
+ And I should see "3 Found"
+ And I should see "Hurt and that's it"
+ And I should see "H/C Series"
+ And I should see "External Whump"
+
+ Scenario: Search bookmarks by type
+ Given I have bookmarks to search
+ When I select "External Work" from "Type"
+ And I press "Search Bookmarks"
+ Then I should see the page title "Search Bookmarks"
+ And I should see "You searched for: Type: External Work"
+ And I should see "1 Found"
+ And I should see "Skies Grown Darker"
+ When I follow "Edit Your Search"
+ Then "External Work" should be selected within "Type"
+ When I select "Series" from "Type"
+ And I press "Search Bookmarks"
+ Then I should see "You searched for: Type: Series"
+ And I should see "2 Found"
+
+ Scenario: Search for bookmarks with notes, and then edit search to narrow
+ results by the note content
+ Given I have bookmarks to search
+ When I check "With notes"
+ And I press "Search Bookmarks"
+ Then I should see the page title "Search Bookmarks"
+ And I should see "You searched for: With Notes"
+ And I should see "3 Found"
+ And I should see "fifth"
+ And I should see "Skies Grown Darker"
+ And I should see "Second Series"
+ When I follow "Edit Your Search"
+ Then the "With notes" checkbox should be checked
+ When I fill in "Notes" with "broken heart"
+ And I press "Search Bookmarks"
+ Then I should see the page title "Search Bookmarks"
+ And I should see "You searched for: Notes: broken heart, With Notes"
+ And I should see "1 Found"
+ And I should see "fifth"
+ When I follow "Edit Your Search"
+ Then the field labeled "Notes" should contain "broken heart"
+ And the "With notes" checkbox should be checked
+
+ Scenario: If testuser has the pseud tester_pseud, searching for bookmarks by
+ the bookmarker testuser returns all of tester_pseud's bookmarks
+ Given I have bookmarks to search
+ When I fill in "Bookmarker" with "testuser"
+ And I press "Search Bookmarks"
+ Then I should see the page title "Search Bookmarks"
+ And I should see "You searched for: Bookmarker: testuser"
+ And I should see "8 Found"
+ And I should see "First work"
+ And I should see "second work"
+ And I should see "third work"
+ And I should see "tester_pseud"
+ And I should see "fifth"
+ And I should see "Skies Grown Darker"
+ And I should see "First Series"
+ And I should see "Second Series"
+ When I follow "Edit Your Search"
+ Then the field labeled "Bookmarker" should contain "testuser"
+
+ Scenario: Search for bookmarks by the bookmarkable item's completion status
+ Given I have bookmarks of various completion statuses to search
+ When I fill in "Any field on work" with "complete: true"
+ And I press "Search Bookmarks"
+ Then I should see "You searched for: complete: true"
+ And I should see "2 Found"
+ And I should see "Finished Work"
+ And I should see "Complete Series"
+ And I should not see "Incomplete Work"
+ And I should not see "Incomplete Series"
+ And I should not see "External Work"
+ When I follow "Edit Your Search"
+ Then the field labeled "Any field on work" should contain "complete: true"
+ When I fill in "Any field on work" with "complete: false"
+ And I press "Search Bookmarks"
+ Then I should see "You searched for: complete: false"
+ And I should see "2 Found"
+ And I should see "Incomplete Work"
+ And I should see "Incomplete Series"
+ And I should not see "Finished Work"
+ And I should not see "Complete Series"
+ And I should not see "External Work"
+
+ Scenario: Search bookmarks by work language
+ Given "someuser" has bookmarks of works in various languages
+ # reload search page to bring new language-id mappings for dropdown
+ When I reload the page
+ And I select "Deutsch" from "Work language"
+ And I press "Search Bookmarks"
+ Then I should see the page title "Search Bookmarks"
+ And I should see "You searched for: Work language: Deutsch"
+ And I should see "1 Found"
+ And I should see "german work"
+ And I should not see "english work"
+
+ Scenario: Search bookmarks by bookmarker
+ Given "testuser2" has a bookmark of a work titled "Test Title 2"
+ When I fill in "Bookmarker" with "testuser2"
+ And I press "Search Bookmarks"
+ Then I should see "You searched for: Bookmarker: testuser2"
+ And I should see "1 Found"
+ And I should see "Test Title 2"
+ When I follow "Edit Your Search"
+ And I fill in "Bookmarker" with "testuser"
+ And I press "Search Bookmarks"
+ Then I should see "You searched for: Bookmarker: testuser"
+ And I should see "No results found."
+
+ Scenario: Inputting bad queries
+ Given I have bookmarks to search
+ When I fill in "Any field on work" with "bad~query~~!!!"
+ And I press "Search Bookmarks"
+ Then I should see "Your search failed because of a syntax error. Please try again."
diff --git a/features/bookmarks/bookmark_share.feature b/features/bookmarks/bookmark_share.feature
new file mode 100755
index 0000000..40e0e05
--- /dev/null
+++ b/features/bookmarks/bookmark_share.feature
@@ -0,0 +1,48 @@
+@bookmarks
+Feature: Share Bookmarks
+ Testing the "Share" button on bookmarks, with JavaScript emulation
+
+ # We need to load the site skin to make the share modal work properly:
+ @javascript @load-default-skin
+ Scenario: Share a bookmark
+ Given I am logged in as "tess"
+ And the work "Damp Gravel" by "tess" with fandom "Stargate SG-1"
+ And I have a bookmark for "Damp Gravel"
+ When I go to the first bookmark for the work "Damp Gravel"
+ Then I should see "Damp Gravel"
+ And I should see "Share"
+ When I follow "Share"
+ Then I should see "Copy and paste the following code to link back to this work" within "#share"
+ And I should see "or use the Tweet or Tumblr links to share the work" within "#share"
+ And I should see 'Damp Gravel (8 words)' within "#share textarea"
+ And I should see 'by tess' within "#share textarea"
+ And I should see 'Fandom: Stargate SG-1' within "#share textarea"
+ And I should see "Rating: Not Rated" within "#share textarea"
+ And I should see "Warnings: No Archive Warnings Apply" within "#share textarea"
+ And the share modal should contain social share buttons
+ And I should not see "Series:" within "#share textarea"
+ And I should not see "Relationships:" within "#share textarea"
+ And I should not see "Characters:" within "#share textarea"
+ And I should not see "Summary:" within "#share textarea"
+
+ Scenario: Share option is unavailable if bookmarkable is unrevealed.
+ Given there is a work "Hidden Figures" in an unrevealed collection "Backlist"
+ And I am logged in as the author of "Hidden Figures"
+ When I view the work "Hidden Figures"
+ Then I should see "Bookmark"
+ When I follow "Bookmark"
+ And I press "Create"
+ And all indexing jobs have been run
+ Then I should see "Bookmark was successfully created"
+ When I go to the first bookmark for the work "Hidden Figures"
+ Then I should see "Hidden Figures"
+ And I should see "Add To Collection"
+ And I should not see "Share"
+
+ Scenario: Sharing a bookmark is not possible when logged out
+ Given I am logged in as "tess"
+ And I have a bookmark for "Damp Gravel"
+ When I am logged out
+ And I go to the first bookmark for the work "Damp Gravel"
+ Then I should see "Damp Gravel"
+ And I should not see "Share"
diff --git a/features/bookmarks/delete_bookmarkable.feature b/features/bookmarks/delete_bookmarkable.feature
new file mode 100755
index 0000000..2a5f98b
--- /dev/null
+++ b/features/bookmarks/delete_bookmarkable.feature
@@ -0,0 +1,84 @@
+@bookmarks @search
+Feature: Bookmarks of deleted items
+
+ Scenario: Deleting a work shouldn't make its bookmarks disappear completely.
+ Given I am logged in as "Alice"
+ And I post the work "Mayfly"
+ And I am logged in as "Beth"
+ And I bookmark the work "Mayfly" with the note "The best yet!"
+ And I am logged in as "Alice"
+ And I delete the work "Mayfly"
+ And all indexing jobs have been run
+ And I am logged in as "Charlotte"
+ When I go to the search people page
+ And I fill in "Name" with "Beth"
+ And I press "Search People"
+ Then I should see "Beth" within "ol.pseud.group"
+ And I should see "1 bookmark"
+ When I follow "1 bookmark"
+ Then I should see "Bookmarks (1)"
+ And I should see "1 Bookmark by Beth"
+ And I should see "This has been deleted, sorry!"
+ And I should see "The best yet!"
+ But I should not see "Mayfly"
+
+ Scenario: Deleting a series shouldn't make its bookmarks disappear completely.
+ Given I am logged in as "Alice"
+ And I post the work "Mayfly" as part of a series "Shorts"
+ And I am logged in as "Beth"
+ And I bookmark the series "Shorts"
+ And I am logged in as "Alice"
+ And I delete the series "Shorts"
+ And all indexing jobs have been run
+ And I am logged in as "Charlotte"
+ When I go to the search people page
+ And I fill in "Name" with "Beth"
+ And I press "Search People"
+ Then I should see "Beth" within "ol.pseud.group"
+ And I should see "1 bookmark"
+ When I follow "1 bookmark"
+ Then I should see "Bookmarks (1)"
+ And I should see "1 Bookmark by Beth"
+ And I should see "This has been deleted, sorry!"
+ But I should not see "Shorts"
+
+ Scenario: Deleting an external work shouldn't make its bookmarks disappear completely.
+ Given basic tags
+ And I am logged in as "Alice"
+ And I bookmark the external work "Extremely Objectionable Content"
+ And I am logged in as a "policy_and_abuse" admin
+ And I view the external work "Extremely Objectionable Content"
+ And I follow "Delete External Work"
+ And all indexing jobs have been run
+ And I am logged in as "Beth"
+ When I go to the search people page
+ And I fill in "Name" with "Alice"
+ And I press "Search People"
+ Then I should see "Alice" within "ol.pseud.group"
+ And I should see "1 bookmark"
+ When I follow "1 bookmark"
+ Then I should see "Bookmarks (1)"
+ And I should see "1 Bookmark by Alice"
+ And I should see "This has been deleted, sorry!"
+ And I should not see "Extremely Objectionable Content"
+
+ Scenario: Deleting a restricted work with bookmarks makes the public bookmark count on all bookmarker's pseuds increase.
+ Given I am logged in as "Alice"
+ And I post the locked work "Mayfly"
+ And I am logged in as "Beth"
+ And I bookmark the work "Mayfly"
+ When I am logged out
+ And I go to the search people page
+ And I fill in "Name" with "Beth"
+ And I press "Search People"
+ Then I should see "Beth" within "ol.pseud.group"
+ And I should not see "1 bookmark"
+ When I am logged in as "Alice"
+ And I delete the work "Mayfly"
+ And all indexing jobs have been run
+ And I am logged out
+ And I go to the search people page
+ And I fill in "Name" with "Beth"
+ And I press "Search People"
+ Then I should see "Beth" within "ol.pseud.group"
+ And I should see "1 bookmark"
diff --git a/features/collections/collectible_closed_collection.feature b/features/collections/collectible_closed_collection.feature
new file mode 100755
index 0000000..c43359b
--- /dev/null
+++ b/features/collections/collectible_closed_collection.feature
@@ -0,0 +1,25 @@
+@bookmarks @collections @works
+Feature: Collectible items in closed collections
+ As a moderator
+ I want users to be unable to add items to my closed collection
+
+ Background:
+ Given I have a closed collection "Various Penguins"
+ And I am logged in as a random user
+
+ Scenario: Add my work to a closed collection
+ When I post the work "Blabla" in the collection "Various Penguins"
+ Then I should see "is not currently open"
+ When the collection counts have expired
+ And I go to "Various Penguins" collection's page
+ Then I should see "Works (0)" within "#dashboard"
+ And I should not see "Blabla"
+
+ Scenario: Add my bookmark to a closed collection
+ Given I have a bookmark for "Tundra penguins"
+ When I add my bookmark to the collection "Various_Penguins"
+ Then I should see "is closed"
+ When the collection counts have expired
+ And I go to "Various Penguins" collection's page
+ Then I should see "Bookmarked Items (0)" within "#dashboard"
+ And I should not see "Tundra penguins"
diff --git a/features/collections/collectible_moderated_collection.feature b/features/collections/collectible_moderated_collection.feature
new file mode 100755
index 0000000..98f37ea
--- /dev/null
+++ b/features/collections/collectible_moderated_collection.feature
@@ -0,0 +1,47 @@
+@bookmarks @collections @works
+Feature: Collectible items in moderated collections
+ As a user
+ I want to add my items to moderated collections
+
+ Background:
+ Given I have a moderated collection "Various Penguins"
+ And I am logged in as a random user
+
+ Scenario: Add my work to a moderated collection by editing the work
+ Given I post the work "Blabla"
+ When I edit the work "Blabla"
+ And I fill in "Post to Collections / Challenges" with "various_penguins"
+ And I press "Preview"
+ Then I should see "the moderated collection 'Various Penguins'"
+ When I press "Update"
+ Then I should see "the moderated collection 'Various Penguins'"
+
+ Scenario: Add my bookmark to a moderated collection
+ Given I have a bookmark for "Tundra penguins"
+ When I add my bookmark to the collection "Various_Penguins"
+ Then I should see "until it has been approved by a moderator."
+ When the collection counts have expired
+ And I go to "Various Penguins" collection's page
+ Then I should see "Bookmarked Items (0)" within "#dashboard"
+ And I should not see "Tundra penguins"
+
+ Scenario: Bookmarks of deleted items are included on a moderated collection's
+ Awaiting Approval Manage Items page
+ Given I have a bookmark of a deleted work
+ And I add my bookmark to the collection "Various_Penguins"
+ When I am logged in as the owner of "Various Penguins"
+ And I view the awaiting collection approval collection items page for "Various Penguins"
+ Then I should see "Bookmark of deleted item"
+ And I should see "This has been deleted, sorry!"
+
+ Scenario: A work with too many tags can be approved
+ Given the user-defined tag limit is 2
+ And I post the work "Over the Limit" to the collection "Various Penguins"
+ And the work "Over the Limit" has 3 fandom tags
+ When I am logged in as the owner of "Various Penguins"
+ And I approve the work "Over the Limit" in the collection "Various Penguins"
+ And I submit
+ Then I should see "Collection status updated!"
+ And I should not see "Over the Limit"
+ When I view the work "Over the Limit"
+ Then I should see "Various Penguins"
diff --git a/features/collections/collectible_multiple_collections.feature b/features/collections/collectible_multiple_collections.feature
new file mode 100755
index 0000000..a4c5340
--- /dev/null
+++ b/features/collections/collectible_multiple_collections.feature
@@ -0,0 +1,59 @@
+@bookmarks @collections @works
+
+Feature: Collectible items in multiple collections
+ As a user
+ I want to be unable to add items to more than one collection
+
+ Scenario: Add a work that is already in a moderated collection to a second moderated collection
+ Given I have the moderated collection "ModeratedCollection"
+ And I have the moderated collection "ModeratedCollection2"
+ And I am logged in as a random user
+ And I set my preferences to allow collection invitations
+ And I post the work "Blabla" to the collection "ModeratedCollection"
+ When I edit the work "Blabla" to be in the collections "ModeratedCollection,ModeratedCollection2"
+ Then I should see "Work was successfully updated. You have submitted your work to moderated collections (ModeratedCollection, ModeratedCollection2). It will not become a part of those collections until it has been approved by a moderator."
+
+ Scenario: Add my work to both moderated and unmoderated collections by editing
+ the work
+ Given I have the moderated collection "ModeratedCollection"
+ And I have the collection "UnModeratedCollection"
+ And I am logged in as a random user
+ And I post the work "RandomWork" to the collection "ModeratedCollection"
+ When I go to "ModeratedCollection" collection's page
+ Then I should not see "RandomWork"
+ When I edit the work "RandomWork"
+ # Fill in both the existing and new collection names or else this will
+ # remove it from the original collection by replacing the text in the
+ # field
+ And I fill in "Post to Collections / Challenges" with "ModeratedCollection, UnModeratedCollection"
+ And I press "Post"
+ Then I should see "Work was successfully updated. You have submitted your work to the moderated collection 'ModeratedCollection'. It will not become a part of the collection until it has been approved by a moderator."
+ And I should see "UnModeratedCollection"
+ When I go to "UnModeratedCollection" collection's page
+ Then I should see "RandomWork"
+ When I go to "ModeratedCollection" collection's page
+ Then I should not see "RandomWork"
+
+ Scenario: Collection mod can't add an anonymous work to their collection using
+ the Add to Collections option on the work
+ Given I have the anonymous collection "AnonymousCollection"
+ And I have the collection "MyCollection"
+ And I am logged in as a random user
+ And I set my preferences to allow collection invitations
+ And I post the work "Some Work" to the collection "AnonymousCollection"
+ When I am logged in as the owner of "MyCollection"
+ And I view the work "Some Work"
+ And I fill in "Collection name(s):" with "MyCollection"
+ And I press "Invite"
+ Then I should see "We couldn't add your submission to the following collection(s):"
+ And I should see "MyCollection, because you don't own this item and the item is anonymous."
+
+ Scenario: Work creator can add their own anonymous work to another collection
+ Given I have the anonymous collection "AnonymousCollection"
+ And I have the collection "OtherCollection"
+ And I am logged in as a random user
+ And I set my preferences to allow collection invitations
+ And I post the work "Some Work" to the collection "AnonymousCollection"
+ When I edit the work "Some Work" to be in the collections "AnonymousCollection,OtherCollection"
+ Then I should see "Work was successfully updated."
+ And I should see "OtherCollection"
diff --git a/features/collections/collectible_open_collection.feature b/features/collections/collectible_open_collection.feature
new file mode 100755
index 0000000..2ab70cc
--- /dev/null
+++ b/features/collections/collectible_open_collection.feature
@@ -0,0 +1,89 @@
+@bookmarks @collections @works
+Feature: Collectible items
+ As a user
+ I want to add my items to collections
+
+ Background:
+ Given I have a collection "Various Penguins"
+ And I am logged in as "penguin_fan"
+
+ Scenario: Post my work to a collection
+ Given I go to "Various Penguins" collection's page
+ And I post the work "Blabla" in the collection "Various Penguins"
+ When I go to "Various Penguins" collection's page
+ Then I should see "Works (0)" within "#dashboard"
+ And I should see "Blabla"
+ When the collection counts have expired
+ And I reload the page
+ Then I should see "Works (1)" within "#dashboard"
+ And I should see "Blabla"
+
+ Scenario: Add my chaptered work to a collection
+ Given I go to "Various Penguins" collection's page
+ And I post the chaptered work "Blabla" in the collection "Various Penguins"
+ When I go to "Various Penguins" collection's page
+ Then I should see "Works (0)" within "#dashboard"
+ And I should see "Blabla"
+ When the collection counts have expired
+ And I reload the page
+ Then I should see "Works (1)" within "#dashboard"
+ And I should see "Blabla"
+
+ Scenario: Add my bookmark to a collection
+ Given I go to "Various Penguins" collection's page
+ And I have a bookmark for "Tundra penguins"
+ When I add my bookmark to the collection "Various_Penguins"
+ Then I should see "Added"
+ When I go to "Various Penguins" collection's page
+ Then I should see "Bookmarked Items (0)" within "#dashboard"
+ And I should see "Tundra penguins"
+ When the collection counts have expired
+ And I reload the page
+ Then I should see "Bookmarked Items (1)" within "#dashboard"
+ And I should see "Tundra penguins"
+
+ Scenario: Bookmarks of deleted items are included on the collection's Manage
+ Items page
+ Given I have a bookmark of a deleted work
+ And I add my bookmark to the collection "Various_Penguins"
+ When I am logged in as the owner of "Various Penguins"
+ And I view the approved collection items page for "Various Penguins"
+ Then I should see "Bookmark of deleted item"
+ And I should see "This has been deleted, sorry!"
+
+ Scenario: Bookmarks of deleted items are included on the user's Manage
+ Collected Works page
+ Given I have a bookmark of a deleted work
+ When I add my bookmark to the collection "Various_Penguins"
+ Then I should see "Added"
+ When I go to penguin_fan's collection items page
+ And I follow "Approved"
+ Then I should see "Bookmark of deleted item"
+ And I should see "This has been deleted, sorry!"
+
+ Scenario: Deleted works are not included on the user's Manage Collection Items
+ page
+ Given I post the work "Emperor Penguins" to the collection "Various Penguins"
+ And I delete the work "Emperor Penguins"
+ When I go to penguin_fan's collection items page
+ And I follow "Approved"
+ Then I should not see "Emperor Penguins"
+
+ Scenario: Deleted works are not included on the collection's Manage Items page
+ Given I post the work "Emperor Penguins" to the collection "Various Penguins"
+ And I delete the work "Emperor Penguins"
+ When I am logged in as the owner of "Various Penguins"
+ And I view the approved collection items page for "Various Penguins"
+ Then I should not see "Emperor Penguins"
+
+ Scenario: Drafts are included on the user's Manage Collection Items page
+ Given the draft "Sweater Penguins" in the collection "Various Penguins"
+ When I go to penguin_fan's collection items page
+ And I follow "Approved"
+ Then I should see "Sweater Penguins (Draft)"
+
+ Scenario: Drafts are included on a collection's Manage Items page
+ Given the draft "Sweater Penguins" in the collection "Various Penguins"
+ When I am logged in as the owner of "Various Penguins"
+ And I view the approved collection items page for "Various Penguins"
+ Then I should see "Sweater Penguins (Draft)"
diff --git a/features/collections/collection_anonymity.feature b/features/collections/collection_anonymity.feature
new file mode 100755
index 0000000..3068d45
--- /dev/null
+++ b/features/collections/collection_anonymity.feature
@@ -0,0 +1,625 @@
+@collections
+Feature: Collection
+ In order to run some fanfic festivals
+ As a humble user
+ I want to create a collection with anonymity and hidden-until-reveal works
+
+ Scenario: Works in a hidden collection should be visible to the mod and author but not other users
+ Given I have the hidden collection "Hidden Treasury"
+ When I am logged in as "first_user"
+ And I set up the draft "Old Snippet" in collection "Hidden Treasury"
+ And I press "Preview"
+ Then I should see "Collections: Hidden Treasury"
+ And I should see "Draft was successfully created."
+ When I press "Post"
+ Then the work "Old Snippet" should be visible to me
+ And I should see "part of an ongoing challenge"
+ When I am logged in as "moderator"
+ Then the work "Old Snippet" should be visible to me
+ And I should see "part of an ongoing challenge"
+ When I am logged in as "second_user"
+ Then the work "Old Snippet" should be hidden from me
+ When I am logged out
+ Then the work "Old Snippet" should be hidden from me
+
+ Scenario: The moderator can reveal all the works in a hidden collection
+ Given I have the hidden collection "Hidden Treasury"
+ And "second_user" subscribes to author "first_user"
+ And the user "third_user" exists and is activated
+ And the user "third_user" allows gifts
+ And all emails have been delivered
+ When I am logged in as "first_user"
+ And I post the work "New Snippet" to the collection "Hidden Treasury" as a gift for "third_user"
+ And subscription notifications are sent
+ Then 0 emails should be delivered
+ When I reveal works for "Hidden Treasury"
+ Then "third_user" should be emailed
+ # not anonymous
+ And the email to "third_user" should contain "first_user"
+ When subscription notifications are sent
+ Then "second_user" should be emailed
+ And the email to "second_user" should contain "first_user"
+ When I am logged out
+ Then the work "New Snippet" should be visible to me
+ When I view the collection "Hidden Treasury"
+ Then I should see "New Snippet"
+ And I should not see "Mystery Work"
+ When I am logged in as "second_user"
+ Then the work "New Snippet" should be visible to me
+ When I view the collection "Hidden Treasury"
+ Then I should see "New Snippet"
+ And I should not see "Mystery Work"
+
+ Scenario: The moderator can reveal a single work in a hidden collection
+ Given I have the hidden collection "Hidden Treasury"
+ And "second_user" subscribes to author "first_user"
+ And the user "third_user" exists and is activated
+ And the user "fourth_user" exists and is activated
+ And the user "third_user" allows gifts
+ And the user "fourth_user" allows gifts
+ And all emails have been delivered
+ When I am logged in as "first_user"
+ And I post the work "First Snippet" to the collection "Hidden Treasury" as a gift for "third_user"
+ # Delay before posting to make sure first work is clearly older
+ And it is currently 1 second from now
+ And I post the work "Second Snippet" to the collection "Hidden Treasury" as a gift for "fourth_user"
+ And subscription notifications are sent
+ Then 0 emails should be delivered
+ When I am logged in as "moderator"
+ And I view the approved collection items page for "Hidden Treasury"
+ # items listed in date order so checking the second will reveal the older work
+ And I uncheck the 2nd checkbox with id matching "collection_items_\d+_unrevealed"
+ And I submit
+ Then "fourth_user" should not be emailed
+ When "AO3-2240: gift notifications not sent for individual reveals" is fixed
+ # Then "third_user" should be emailed
+ # And the email to "third_user" should contain "first_user"
+ When subscription notifications are sent
+ Then "second_user" should be emailed
+ And 1 emails should be delivered
+ And the email to "second_user" should contain "first_user"
+ And the email to "second_user" should contain "First Snippet"
+ And the email to "second_user" should not contain "Second Snippet"
+ When I am logged out
+ Then the work "First Snippet" should be visible to me
+ And the work "Second Snippet" should be hidden from me
+ When I view the collection "Hidden Treasury"
+ Then I should see "First Snippet"
+
+ Scenario: Bookmarks for hidden works should not reveal the work to others
+ Given I have the hidden collection "Hidden Treasury"
+ And I am logged in as "first_user"
+ And I post the work "Hiding Work" to the collection "Hidden Treasury"
+ When I am logged in as "moderator"
+ And I bookmark the work "Hiding Work"
+ When I am logged out
+ And I go to the bookmarks page
+ Then I should see "List of Bookmarks"
+ And I should see "Mystery Work"
+ And I should not see "Hiding Work"
+ When I reveal works for "Hidden Treasury"
+ And I am logged out
+ And I go to the bookmarks page
+ Then I should not see "Mystery Work"
+ And I should see "Hiding Work"
+
+ Scenario: The authors in an anonymous collection should only be visible to themselves and admins
+ Given I have the anonymous collection "Anonymous Hugs"
+ And I am logged in as "first_user"
+ And I post the work "Old Snippet" to the collection "Anonymous Hugs"
+ When I view the work "Old Snippet"
+ Then the author of "Old Snippet" should be visible to me on the work page
+ When I am logged out
+ Then the author of "Old Snippet" should be hidden from me
+ When I am logged in as "second_user"
+ Then the author of "Old Snippet" should be hidden from me
+ When I am logged in as an admin
+ Then the author of "Old Snippet" should be visible to me on the work page
+ # special case for moderator: can't see name on the work (to avoid unwanted spoilers)
+ # but can see names + titles on in the collection items management area
+ When I am logged in as "moderator"
+ Then the author of "Old Snippet" should be hidden from me
+ When I view the approved collection items page for "Anonymous Hugs"
+ Then I should see "Old Snippet"
+ And I should see "first_user"
+
+ Scenario: Bookmarks should not reveal the authors of anonymous works
+ Given I have the anonymous collection "Anonymous Hugs"
+ And I am logged in as "first_user"
+ And I post the work "Old Snippet" to the collection "Anonymous Hugs"
+ When I am logged in as "second_user"
+ And I bookmark the work "Old Snippet"
+ And I go to the bookmarks page
+ Then I should see "Old Snippet by Anonymous"
+ And I should not see "first_user"
+
+ Scenario: The moderator can reveal all the authors in an anonymous collection
+ Given I have the anonymous collection "Anonymous Hugs"
+ And "second_user" subscribes to author "first_user"
+ And the user "third_user" exists and is activated
+ And the user "third_user" allows gifts
+ And all emails have been delivered
+ When I am logged in as "first_user"
+ And I post the work "Old Snippet" to the collection "Anonymous Hugs" as a gift for "third_user"
+ Then "third_user" should be emailed
+ And the email to "third_user" should not contain "first_user"
+ And the email to "third_user" should contain "Anonymous"
+ When subscription notifications are sent
+ Then "second_user" should not be emailed
+ When all emails have been delivered
+ And I am logged in as "moderator"
+ And I reveal authors for "Anonymous Hugs"
+ Then the author of "Old Snippet" should be publicly visible
+ When subscription notifications are sent
+ Then "second_user" should be emailed
+ And the email to "second_user" should contain "first_user"
+ And "third_user" should not be emailed
+
+ Scenario: The moderator can reveal a single author in an anonymous collection
+ Given I have the anonymous collection "Anonymous Hugs"
+ And "second_user" subscribes to author "first_user"
+ And the user "third_user" exists and is activated
+ And the user "third_user" allows gifts
+ And I am logged in as "first_user"
+ And I post the work "First Snippet" to the collection "Anonymous Hugs" as a gift for "third_user"
+ # Delay before posting to make sure first work is clearly older
+ And it is currently 1 second from now
+ And I post the work "Second Snippet" to the collection "Anonymous Hugs" as a gift for "not a user"
+ When subscription notifications are sent
+ Then "second_user" should not be emailed
+ When I am logged in as "moderator"
+ And I view the approved collection items page for "Anonymous Hugs"
+ # items listed in date order so checking the second will reveal the older work
+ And I uncheck the 2nd checkbox with id matching "collection_items_\d+_anonymous"
+ # Delay before submitting to make sure the cache is expired
+ And it is currently 1 second from now
+ And I submit
+ Then the author of "First Snippet" should be publicly visible
+ When subscription notifications are sent
+ Then "second_user" should be emailed
+ And the email to "second_user" should contain "first_user"
+ And the email to "second_user" should contain "First Snippet"
+ And the email to "second_user" should not contain "Second Snippet"
+ When I am logged out
+ Then the author of "First Snippet" should be publicly visible
+ And the author of "Second Snippet" should be hidden from me
+
+ Scenario: Works should not be visible in series if unrevealed
+ Given I have the hidden collection "Hidden Treasury"
+ And I am logged in as "first_user"
+ And I post the work "Before"
+ And I add the work "Before" to series "New series"
+ And I post the work "Hiding Work" to the collection "Hidden Treasury"
+ And I add the work "Hiding Work" to series "New series"
+ And I post the work "After"
+ And I add the work "After" to series "New series"
+ When "AO3-1250: series anonymity issues" is fixed
+ ### even the author should not see the work listed within the series
+ # Then the work "Hiding Work" should be part of the "New series" series in the database
+ # And the work "Hiding Work" should not be visible on the "New series" series page
+ # And the series "New series" should not be visible on the "Hiding Work" work page
+ # And the neighbors of "Hiding Work" in the "New series" series should link over it
+ When I am logged out
+ Then the work "Hiding Work" should be part of the "New series" series in the database
+ And the work "Hiding Work" should not be visible on the "New series" series page
+ And the series "New series" should not be visible on the "Hiding Work" work page
+ When "AO3-1250: series anonymity issues" is fixed
+ # And I should not see "Mystery Work"
+ # And the neighbors of "Hiding Work" in the "New series" series should link over it
+ When I reveal works for "Hidden Treasury"
+ And I am logged out
+ Then the work "Hiding Work" should be visible on the "New series" series page
+ And the series "New series" should be visible on the "Hiding Work" work page
+ And the neighbors of "Hiding Work" in the "New series" series should link to it
+
+ Scenario: Works should not be visible in series if anonymous
+ Given I have the anonymous collection "Anon Treasury"
+ And I am logged in as "first_user"
+ And I post the work "Before"
+ And I add the work "Before" to series "New series"
+ And I post the work "Anon Work" to the collection "Anon Treasury"
+ And I add the work "Anon Work" to series "New series"
+ And I post the work "After"
+ And I add the work "After" to series "New series"
+ Then the work "Anon Work" should be part of the "New series" series in the database
+ When "AO3-1250: series anonymity fixes" is fixed
+ # even the author should not see the work in the series
+ # And the work "Anon Work" should not be visible on the "New series" series page
+ # And the series "New series" should not be visible on the "Anon Work" work page
+ # And the neighbors of "Anon Work" in the "New series" series should link over it
+ When I am logged out
+ Then the work "Anon Work" should be part of the "New series" series in the database
+ When "AO3-1250: series anonymity fixes" is fixed
+ # And the work "Anon Work" should not be visible on the "New series" series page
+ # And the series "New series" should not be visible on the "Anon Work" work page
+ # And the neighbors of "Anon Work" in the "New series" series should link over it
+ When I reveal authors for "Anon Treasury"
+ And I am logged out
+ Then the work "Anon Work" should be visible on the "New series" series page
+ And the series "New series" should be visible on the "Anon Work" work page
+ And the neighbors of "Anon Work" in the "New series" series should link to it
+
+ Scenario: Adding a co-author to (one chapter of) an anonymous work should still keep it anonymous
+ Given I have the anonymous collection "Various Penguins"
+ And I am logged in as "Jessica"
+ And I post the chaptered work "Cone of Silence" in the collection "Various Penguins"
+ When I edit the work "Cone of Silence"
+ And I follow "2" within "div#main.works-edit.region"
+ And I invite the co-author "Amos"
+ And I press "Post"
+ And the user "Amos" accepts all co-creator requests
+ Then the author of "Cone of Silence" should be visible to me on the work page
+ When I am logged out
+ Then the author of "Cone of Silence" should be hidden from me
+
+ Scenario: A work is in two anonymous collections, and one is revealed
+ Given I have the anonymous collection "Permanent Mice"
+ And I have the anonymous collection "Temporary Mice"
+ And I am logged in as "a_nonny_mouse"
+ And I post the work "Cheesy Goodness"
+ And I edit the work "Cheesy Goodness" to be in the collections "Permanent_Mice,Temporary_Mice"
+ And "eager_fan" subscribes to author "a_nonny_mouse"
+
+ When I am logged in as "moderator"
+ And I reveal authors for "Temporary Mice"
+ And subscription notifications are sent
+
+ Then "eager_fan" should not be emailed
+
+ Scenario: A work is in two unrevealed collections, and one is revealed
+ Given I have the hidden collection "Super-Secret"
+ And I have the hidden collection "Secret for Now"
+ And I am logged in as "classified"
+ And I post the work "Top-Secret Goodness"
+ And I edit the work "Top-Secret Goodness" to be in the collections "Super-Secret,Secret_for_Now"
+ And "eager_fan" subscribes to author "classified"
+
+ When I am logged in as "moderator"
+ And I reveal works for "Secret for Now"
+ And subscription notifications are sent
+
+ Then "eager_fan" should not be emailed
+
+ Scenario: A work is in one anonymous and one hidden collection, and the anonymous collection is revealed
+ Given I have the hidden collection "Triple-Secret"
+ And I have the anonymous collection "Cheese Enthusiasts"
+ And I am logged in as "classified"
+ And I post the work "Half and Half"
+ And I edit the work "Half and Half" to be in the collections "Triple-Secret,Cheese_Enthusiasts"
+ And "eager_fan" subscribes to author "classified"
+
+ When I am logged in as "moderator"
+ And I reveal authors for "Cheese Enthusiasts"
+ And subscription notifications are sent
+
+ Then "eager_fan" should not be emailed
+
+ Scenario: A work is in one anonymous and one hidden collection, and the hidden collection is revealed
+ Given I have the hidden collection "Hidden Dreams"
+ And I have the anonymous collection "Anons Anonymous"
+ And I am logged in as "classified"
+ And I post the work "Half and Half"
+ And I edit the work "Half and Half" to be in the collections "Hidden_Dreams,Anons_Anonymous"
+ And "eager_fan" subscribes to author "classified"
+
+ When I am logged in as "moderator"
+ And I reveal works for "Hidden Dreams"
+ And subscription notifications are sent
+
+ Then "eager_fan" should not be emailed
+
+ Scenario: Creating a new work then immediately editing to add it to an
+ anonymous collection should not trigger a subscriber email.
+
+ Given I have the anonymous collection "Anon Forever"
+ And the following activated users exist
+ | login | password | email |
+ | mysterious | password | mysterious@foo.com |
+ | subscriber | password | subscriber@foo.com |
+ And "subscriber" subscribes to author "mysterious"
+ And all emails have been delivered
+
+ When I am logged in as "mysterious"
+ And I post the work "Anonymous Gift"
+ And I edit the work "Anonymous Gift" to be in the collection "Anon_Forever"
+ And subscription notifications are sent
+
+ Then 0 emails should be delivered
+
+ Scenario: When a creator views their own anonymous work, they should see a message explaining that their comment will be anonymous, and their comment should be anonymous.
+
+ Given I have the anonymous collection "Anon Forever"
+ And I am logged in as "shy_author"
+ And I post the work "Hidden Masterpiece" to the collection "Anon Forever"
+
+ When I view the work "Hidden Masterpiece"
+ Then I should see "While this work is anonymous, comments you post will also be listed anonymously."
+
+ When I post a comment "Reply from the author."
+ And I am logged out
+ And I view the work "Hidden Masterpiece" with comments
+ Then I should see "Reply from the author."
+ And I should see "Anonymous Creator"
+ And I should not see "shy_author"
+
+ Scenario: When a creator adds a work to an anonymous collection and previews the change, it should save correctly
+
+ Given I have the anonymous collection "Anonymous Collection"
+ And I am logged in as "creator"
+ And I post the work "My Work"
+
+ When I edit the work "My Work"
+ And I fill in "Post to Collections / Challenges" with "anonymous_collection"
+ And I press "Preview"
+
+ Then I should see "Anonymous Collection"
+ And I should see "Anonymous [creator]"
+
+ When I press "Update"
+
+ Then I should see "Anonymous Collection"
+ And I should see "Anonymous [creator]"
+
+ Scenario: When a creator adds a work to an anonymous collection and previews the change, it should cancel correctly
+
+ Given I have the anonymous collection "Anonymous Collection"
+ And I am logged in as "creator"
+ And I post the work "My Work"
+
+ When I edit the work "My Work"
+ And I fill in "Post to Collections / Challenges" with "anonymous_collection"
+ And I press "Preview"
+
+ Then I should see "Anonymous Collection"
+ And I should see "Anonymous [creator]"
+
+ When I press "Cancel"
+
+ Then I should see "The work was not updated."
+
+ When I view the work "My Work"
+
+ # This is not the desired behavior (AO3-5556), but we want to make sure it doesn't get broken worse
+ Then I should see "Anonymous Collection"
+ And I should see "Anonymous [creator]"
+
+ Scenario: When an anonymous collection is deleted, works in the collection stop being anonymous.
+ Given I have an anonymous collection "Anonymous Collection"
+ And I am logged in as "creator"
+ And I post the work "Secret Work" to the collection "Anonymous Collection"
+
+ When I go to creator's works page
+ Then I should not see "Secret Work"
+
+ When I am logged in as the owner of "Anonymous Collection"
+ And I go to "Anonymous Collection" collection edit page
+ And I follow "Delete Collection"
+ # Delay before deleting to make sure the cache is expired
+ And it is currently 1 second from now
+ And I press "Yes, Delete Collection"
+ And I go to creator's works page
+ Then I should see "Secret Work"
+
+ Scenario: When an unrevealed collection is deleted, works in the collection stop being unrevealed.
+ Given I have a hidden collection "Hidden Collection"
+ And I am logged in as "creator"
+ And I post the work "Secret Work" to the collection "Hidden Collection"
+
+ When I am logged out
+ Then the work "Secret Work" should be hidden from me
+
+ When I am logged in as the owner of "Hidden Collection"
+ And I go to "Hidden Collection" collection edit page
+ And I follow "Delete Collection"
+ And I press "Yes, Delete Collection"
+ And I am logged out
+ Then the work "Secret Work" should be visible to me
+
+ Scenario: When the moderator removes a work from an anonymous collection, the creator is revealed.
+ Given I have an anonymous collection "Anonymous Collection"
+ And I am logged in as "creator"
+ And I post the work "Secret Work" to the collection "Anonymous Collection"
+
+ When I go to creator's works page
+ Then I should not see "Secret Work"
+
+ When I am logged in as the owner of "Anonymous Collection"
+ And I view the approved collection items page for "Anonymous Collection"
+ And I check "Remove"
+ # Delay before submitting to make sure the cache is expired
+ And it is currently 1 second from now
+ And I submit
+ And I go to creator's works page
+ Then I should see "Secret Work"
+
+ Scenario: When the moderator removes a work from an unrevealed collection, the work is revealed.
+ Given I have a hidden collection "Hidden Collection"
+ And I am logged in as "creator"
+ And I post the work "Secret Work" to the collection "Hidden Collection"
+
+ When I am logged out
+ Then the work "Secret Work" should be hidden from me
+
+ When I am logged in as the owner of "Hidden Collection"
+ And I view the approved collection items page for "Hidden Collection"
+ And I check "Remove"
+ And I submit
+ And I am logged out
+ Then the work "Secret Work" should be visible to me
+
+ Scenario: Moving a work with two collections from an anonymous collection to a non-anonymous collection should reveal the creator.
+ Given an anonymous collection "Anonymizing"
+ And a collection "Fluffy"
+ And a collection "Holidays"
+
+ When I am logged in as "creator"
+ And I set up the draft "Secret Work"
+ And I fill in "Collections" with "Anonymizing,Fluffy"
+ And I press "Post"
+ And I go to creator's works page
+ Then I should not see "Secret Work"
+
+ When I edit the work "Secret Work"
+ And I fill in "Collections" with "Holidays,Fluffy"
+ # Delay before posting to make sure the cache is expired
+ And it is currently 1 second from now
+ And I press "Post"
+ And I go to creator's works page
+ Then I should see "Secret Work"
+
+ Scenario: Changing a collection item to anonymous triggers a notification
+ Given a collection "Changeable"
+ And I am logged in as "creator"
+ And I post the work "Lovely" in the collection "Changeable"
+ And all emails have been delivered
+
+ When I am logged in as the owner of "Changeable"
+ And I go to "Changeable" collection edit page
+ And I check "This collection is anonymous"
+ And I press "Update"
+ And I view the approved collection items page for "Changeable"
+ And I check "Anonymous"
+ And I submit
+ Then "creator" should be emailed
+ And the email should have "Your work was made anonymous" in the subject
+ And the email should contain "Anonymous works are included in tag listings, but not on your works page."
+ And the email should not contain "translation missing"
+
+ Scenario: Changing a collection item to unrevealed triggers a notification
+ Given a collection "Changeable"
+ And I am logged in as "creator"
+ And I post the work "Lovely" in the collection "Changeable"
+ And all emails have been delivered
+
+ When I am logged in as the owner of "Changeable"
+ And I go to "Changeable" collection edit page
+ And I check "This collection is unrevealed"
+ And I press "Update"
+ And I view the approved collection items page for "Changeable"
+ And I check "Unrevealed"
+ And I submit
+ Then "creator" should be emailed
+ And the email should have "Your work was made unrevealed" in the subject
+ And the email should contain "Unrevealed works are not included in tag listings or on your works page."
+ And the email should not contain "translation missing"
+
+ Scenario: Changing a collection item to anonymous and unrevealed triggers a notification
+ Given a collection "Changeable"
+ And I am logged in as "creator"
+ And I post the work "Lovely" in the collection "Changeable"
+ And all emails have been delivered
+
+ When I am logged in as the owner of "Changeable"
+ And I go to "Changeable" collection edit page
+ And I check "This collection is anonymous"
+ And I check "This collection is unrevealed"
+ And I press "Update"
+ And I view the approved collection items page for "Changeable"
+ And I check "Anonymous"
+ And I check "Unrevealed"
+ And I submit
+ Then "creator" should be emailed
+ And the email should have "Your work was made anonymous and unrevealed" in the subject
+ And the email should contain "Unrevealed works are not included in tag listings or on your works page."
+ And the email should contain "The collection maintainers may later reveal your work but leave it anonymous."
+ And the email should not contain "translation missing"
+
+ # We need to load the site skin to make the share modal work:
+ @javascript @load-default-skin
+ Scenario: Work share modal should not reveal anonymous authors
+ Given I have the anonymous collection "Anonymous Hugs"
+ When I am logged in as "first_user"
+ And I post the work "Old Snippet" to the collection "Anonymous Hugs"
+ When I am logged out
+ And I view the work "Old Snippet"
+ Then I should see "Share"
+ When I follow "Share"
+ Then I should see "by Anonymous" within "#modal textarea"
+
+ Scenario: Work share button should not display for unrevealed works
+ Given I have the hidden collection "Hidden Treasury"
+ When I am logged in as "first_user"
+ And I post the work "Old Snippet" to the collection "Hidden Treasury"
+ Then the work "Old Snippet" should be visible to me
+ And I should not see "Share"
+ When I am logged in as "moderator"
+ Then the work "Old Snippet" should be visible to me
+ And I should not see "Share"
+
+ Scenario: Works that are over the tag limit can be revealed
+ Given the user-defined tag limit is 2
+ And I have the hidden collection "Hidden Gems"
+ And I am logged in as "creator"
+ And I post the work "Over the Limit" in the collection "Hidden Gems"
+ And the work "Over the Limit" has 3 fandom tags
+ When I reveal works for "Hidden Gems"
+ And I log out
+ And I view the work "Over the Limit"
+ Then I should see "Over the Limit"
+
+ Scenario: Mystery blurb collections contain only unrevealed (approved or unmoderated) collections
+ Given I have the hidden moderated collection "Hidden Moderated Approved"
+ And I have the hidden moderated collection "Hidden Moderated Not Approved"
+ And I have the hidden collection "Just Hidden"
+ And I have the anonymous collection "Just Anonymous"
+ And I have the hidden anonymous collection "Hidden and Anonymous"
+ And I have the collection "Welcome"
+ And I am logged in as "author"
+ And I post the work "Work"
+ And I edit the work "Work" to be in the collections "Welcome,Hidden_Moderated_Not_Approved"
+
+ When I am logged out
+ And I go to "Welcome" collection's page
+ Then I should see "Mystery Work"
+ And I should not see "Part of"
+
+ When I am logged in as "author"
+ And I edit the work "Work" to be in the collections "Welcome,Hidden_Moderated_Not_Approved,Hidden_Moderated_Approved,Just_Hidden,Just_Anonymous,Hidden_And_Anonymous"
+ And I am logged in as "moderator"
+ And I approve the work "Work" in the collection "Hidden Moderated Approved"
+ And I submit
+
+ When I am logged out
+ And I go to "Welcome" collection's page
+ Then I should see "Mystery Work"
+ And I should see "Part of Hidden Moderated Approved, Just Hidden, Hidden and Anonymous"
+ And I should not see "Hidden Moderated Not Approved"
+ And I should not see "Just Anonymous"
+ And I should not see "Welcome" within ".mystery"
+
+ Scenario: Unrevealed work collection message mentions collections relevant to user
+ Given I have the hidden moderated collection "Hidden Moderated 1"
+ And I have the hidden moderated collection "Hidden Moderated 2"
+ And I am logged in as "author"
+ And I post the chaptered work "Work"
+ And I edit the work "Work" to be in the collections "Hidden_Moderated_1,Hidden_Moderated_2"
+
+ When I view the work "Work"
+ Then I should see "You can find details here: Hidden Moderated 1, Hidden Moderated 2"
+ When I view the work "Work" in full mode
+ Then I should see "You can find details here: Hidden Moderated 1, Hidden Moderated 2"
+
+ When I am logged out
+ And I view the work "Work"
+ Then I should not see "You can find details here"
+ When I go to the work "Work" in full mode
+ And I should not see "You can find details here"
+
+ When I am logged in as "moderator"
+ And I approve the work "Work" in the collection "Hidden Moderated 1"
+ And I submit
+
+ When I view the work "Work"
+ Then I should see "You can find details here: Hidden Moderated 1"
+ And I should not see "Hidden Moderated 2"
+ When I go to the work "Work" in full mode
+ Then I should see "You can find details here: Hidden Moderated 1"
+ And I should not see "Hidden Moderated 2"
+
+ When I am logged out
+ And I view the work "Work"
+ Then I should see "You can find details here: Hidden Moderated 1"
+ And I should not see "Hidden Moderated 2"
+ When I go to the work "Work" in full mode
+ Then I should see "You can find details here: Hidden Moderated 1"
+ And I should not see "Hidden Moderated 2"
diff --git a/features/collections/collection_browse.feature b/features/collections/collection_browse.feature
new file mode 100755
index 0000000..7881dab
--- /dev/null
+++ b/features/collections/collection_browse.feature
@@ -0,0 +1,181 @@
+@collections
+Feature: Collection
+ In order to have an archive full of collections
+ As a humble user
+ I want to locate and browse an existing collection
+
+ Scenario: Collections index should have different links for logged in and logged out users
+
+ Given I am logged in as "onlooker"
+ When I go to the collections page
+ Then I should see "Open Challenges"
+ And I should see "New Collection"
+ When I log out
+ And I go to the collections page
+ Then I should see "Open Challenges"
+ And I should not see "New Collection"
+
+ Scenario: Filter collections index to only show prompt memes
+
+ Given a set of collections for searching
+ When I go to the collections page
+ And I choose "Prompt Meme Challenge"
+ And I press "Sort and Filter"
+ Then I should see "On Demand"
+ And I should not see "Some Test Collection"
+ And I should not see "Some Other Collection"
+ And I should not see "Another Plain Collection"
+ And I should not see "Surprise Presents"
+ And I should not see "Another Gift Swap"
+
+ Scenario: Filter collections index to only show gift exchanges
+
+ Given a set of collections for searching
+ When I go to the collections page
+ And I choose "Gift Exchange Challenge"
+ And I press "Sort and Filter"
+ Then I should see "Surprise Presents"
+ And I should see "Another Gift Swap"
+ And I should not see "On Demand"
+ And I should not see "Some Test Collection"
+ And I should not see "Some Other Collection"
+ And I should not see "Another Plain Collection"
+
+ Scenario: Filter collections index to only show non-challenge collections
+
+ Given a set of collections for searching
+ When I go to the collections page
+ And I choose "No Challenge"
+ And I press "Sort and Filter"
+ Then I should see "Some Test Collection"
+ And I should see "Some Other Collection"
+ And I should see "Another Plain Collection"
+ And I should not see "Surprise Presents"
+ And I should not see "Another Gift Swap"
+ And I should not see "On Demand"
+
+ Scenario: Filter collections index to only show closed collections
+
+ Given a set of collections for searching
+ When I go to the collections page
+ And I choose "collection_filters_closed_true"
+ And I press "Sort and Filter"
+ Then I should see "Another Plain Collection"
+ And I should see "On Demand"
+ And I should not see "Some Test Collection"
+ And I should not see "Some Other Collection"
+ And I should not see "Surprise Presents"
+ And I should not see "Another Gift Swap"
+
+ Scenario: Filter collections index to only show open collections
+
+ Given a set of collections for searching
+ When I go to the collections page
+ And I choose "collection_filters_closed_false"
+ And I press "Sort and Filter"
+ Then I should see "Some Test Collection"
+ And I should see "Some Other Collection"
+ And I should see "Surprise Presents"
+ And I should see "Another Gift Swap"
+ And I should not see "Another Plain Collection"
+ And I should not see "On Demand"
+
+ Scenario: Filter collections index to only show moderated collections
+
+ Given a set of collections for searching
+ When I go to the collections page
+ And I choose "collection_filters_moderated_true"
+ And I press "Sort and Filter"
+ Then I should see "Surprise Presents"
+ And I should not see "Some Test Collection"
+ And I should not see "Some Other Collection"
+ And I should not see "Another Plain Collection"
+ And I should not see "Another Gift Swap"
+ And I should not see "On Demand"
+
+ Scenario: Filter collections index to only show unmoderated collections
+
+ Given a set of collections for searching
+ When I go to the collections page
+ And I choose "collection_filters_moderated_false"
+ And I press "Sort and Filter"
+ Then I should see "Some Test Collection"
+ And I should see "Some Other Collection"
+ And I should see "Another Plain Collection"
+ And I should see "Another Gift Swap"
+ And I should see "On Demand"
+ And I should not see "Surprise Presents"
+
+ Scenario: Filter collections index to show open, moderated gift exchanges
+
+ Given a set of collections for searching
+ When I go to the collections page
+ And I choose "collection_filters_closed_false"
+ And I choose "collection_filters_moderated_true"
+ And I choose "Gift Exchange Challenge"
+ And I press "Sort and Filter"
+ Then I should see "Surprise Presents"
+ And I should not see "Some Test Collection"
+ And I should not see "Some Other Collection"
+ And I should not see "Another Plain Collection"
+ And I should not see "Another Gift Swap"
+ And I should not see "On Demand"
+
+ Scenario: Filter collections index by fandom
+
+ Given I have the collection "Collection1"
+ And I have the collection "Collection2"
+ And a fandom exists with name: "Steven's Universe", canonical: true
+ And I am logged in as a random user
+ And I post the work "Stronger than you" with fandom "Steven's Universe" in the collection "Collection1"
+ When I go to the collections page
+ And I fill in "collection_filters_fandom" with "Steven's Universe"
+ And I press "Sort and Filter"
+ Then I should see "Collection1"
+ And I should not see "Collection2"
+
+ Scenario: Clear filters applied on collections
+
+ Given a set of collections for searching
+ And I am logged in as a random user
+ When I go to the collections page
+ And I choose "Gift Exchange Challenge"
+ And I choose "collection_filters_closed_false"
+ And I choose "collection_filters_moderated_true"
+ And I press "Sort and Filter"
+ Then I should see "1 Collection"
+ And I should see "Surprise Presents"
+ When I follow "Clear Filters"
+ Then I should see "6 Collections"
+ And the "Gift Exchange Challenge" checkbox within "#collection-filters" should not be checked
+ And the "No" checkbox within "#collection-filters" should not be checked
+ And the "Yes" checkbox within "#collection-filters" should not be checked
+
+ Scenario: Look at a collection, see the rules and intro and FAQ
+
+ Given a set of collections for searching
+ And I am logged in as "testuser" with password "testuser"
+ When I go to the collections page
+ Then I should see "Collections in the "
+ And I should see "Some Test Collection"
+ When I follow "Some Test Collection"
+ Then I should see "Some Test Collection"
+ And I should see "There are no works or bookmarks in this collection yet."
+ When I follow "Profile"
+ Then I should see "Welcome to the test collection" within "#intro"
+ And I should see "What is this test thing?" within "#faq"
+ And I should see "It's a test collection" within "#faq"
+ And I should see "Be nice to testers" within "#rules"
+ And I should see "About Some Test Collection (sometest)"
+
+
+ Scenario: Work blurb includes an HTML comment containing the unix epoch of the updated time
+
+ Given time is frozen at 2025-04-12 17:00 UTC
+ And I have the collection "Collection1"
+ And a fandom exists with name: "Steven's Universe", canonical: true
+ And I am logged in as a random user
+ And I post the work "Stronger than you" with fandom "Steven's Universe" in the collection "Collection1"
+ When I go to the collections page
+ And I follow "Collection1"
+ Then I should see an HTML comment containing the number 1744477200 within "li.work.blurb"
diff --git a/features/collections/collection_create.feature b/features/collections/collection_create.feature
new file mode 100755
index 0000000..4cc5ded
--- /dev/null
+++ b/features/collections/collection_create.feature
@@ -0,0 +1,226 @@
+@collections
+Feature: Collection
+ In order to have an archive full of collections
+ As a humble user
+ I want to create a collection and post to it
+
+Scenario: Create a collection
+
+ Given I am logged in as "first_user"
+ When I go to the collections page
+ Then I should see "Collections in the "
+ And I should not see "My Collection Thing"
+ When I follow "New Collection"
+ And I fill in "Display title" with "My Collection Thing"
+ And I fill in "Collection name" with "collection_thing"
+ And I fill in "Introduction" with "Welcome to the collection"
+ And I fill in "FAQ" with "
What is this thing?
It's a collection
"
+ And I fill in "Rules" with "Be nice to people"
+ And I check all the collection settings checkboxes
+ And I submit
+ Then I should see "Collection was successfully created"
+ When I follow "Profile"
+ Then I should see "Welcome to the collection" within "#intro"
+ And I should see "What is this thing?" within "#faq"
+ And I should see "It's a collection" within "#faq"
+ And I should see "Be nice to people" within "#rules"
+ When I follow "Collection Settings"
+ And I fill in "Collection name" with " "
+ And I submit
+ And I should see "Please enter a name for your collection"
+ When I fill in "Collection name" with "collection_thing2"
+ And I submit
+ And I should see "Collection was successfully updated"
+ And the name of the collection "My Collection Thing" should be "collection_thing2"
+
+Scenario: Post to collection from the work edit page
+ Given the collection "My Collection Thing" with name "collection_thing"
+ And I am logged in as "first_user"
+ When I post the work "collect-y work"
+ And I go to first_user's user page
+ Then I should see "collect-y work"
+ When I edit the work "collect-y work"
+ And I fill in "work_collection_names" with "collection_thing"
+ And I press "Preview"
+ And I press "Update"
+ Then I should see "collect-y work"
+ And I should see "Collections: My Collection Thing"
+
+Scenario: Post to collection from the collection home page
+
+ Given I have the collection "My Collection Thing" with name "collection_thing"
+ And basic tags
+ And I am logged in as "first_user"
+ When I go to the collections page
+ And I follow "My Collection Thing"
+ And I follow "Post to Collection"
+ Then I should see "Post New Work"
+ And I should see "collection_thing" in the "Post to Collections / Challenges" input
+ When I fill in the basic work information for "My Collected Work"
+ And I press "Preview"
+ Then I should see "My Collection Thing" within "dd.collections"
+ When I press "Post"
+ Then I should see "My Collected Work"
+ And I should see "Collections: My Collection Thing"
+
+Scenario: Create a subcollection
+
+ Given I am logged in as "first_user"
+ And I create the collection "My Collection Thing" with name "collection_thing"
+ When I go to the collections page
+ And I follow "New Collection"
+ And I fill in "collection_parent_name" with "collection_thing"
+ And I fill in "Display title" with "My SubCollection"
+ And I fill in "Collection name" with "subcollection_thing"
+ And I submit
+ Then I should see "Collection was successfully created"
+
+Scenario: Fill out new collection form with faulty data
+
+ Given I am logged in as a random user
+ And I am on the collections page
+
+ When I follow "New Collection"
+ And I fill in the following:
+ | Collection name | faulty name |
+ | Display title | Awesome Collection |
+ | Collection email | fangirl@example.org |
+ | Brief description | My Description |
+ | Introduction | My Introduction |
+ | FAQ | My FAQ |
+ | Rules | My Rules |
+ | Assignment notification message | My Message |
+ | Gift notification message | My Other Message |
+
+ And I check "This collection is closed"
+ And I select "Gift Exchange" from "Type of challenge, if any"
+ And I submit
+
+ Then I should see a save error message
+ And I should see "faulty name" in the "Collection name" input
+ And I should see "Awesome Collection" in the "Display title" input
+ And I should see "fangirl@example.org" in the "Collection email" input
+ And I should see "My Description" in the "Brief description" input
+ And I should see "My Introduction" in the "Introduction" input
+ And I should see "My FAQ" in the "FAQ" input
+ And I should see "My Rules" in the "Rules" input
+ And I should see "My Message" in the "Assignment notification message" input
+ And I should see "My Other Message" in the "Gift notification message" input
+ And the "This collection is closed" checkbox should not be disabled
+ And "Gift Exchange" should be selected within "Type of challenge, if any"
+
+Scenario: Create a collection with a malformed header URL
+
+Given I have the collection "Scotts Collection" with name "scotts_collection"
+ And I am logged in as "moderator"
+ And I am on "Scotts Collection" collection's page
+ And I follow "Collection Settings"
+ And I fill in "collection_header_image_url" with "fc00.deviantart.net/fs13/f/2007/004/a/7/Flooded_by_bingeling.jpg"
+ And I press "Update"
+ And I should see "Collection was successfully updated"
+
+ Scenario: Update a collection with a HTTPS header URL
+
+ Given I have the collection "Scotts Collection" with name "scotts_collection"
+ When I am logged in as "moderator"
+ And I am on "Scotts Collection" collection's page
+ And I follow "Collection Settings"
+ And I fill in "Custom header URL" with "https://example.com/image.png"
+ And I press "Update"
+ Then I should see "Collection was successfully updated"
+ When I follow "Collection Settings"
+ Then I should see "https://example.com/image.png" in the "Custom header URL" input
+
+ Scenario: Delete a subcollection and then its parent collection
+
+ Given I am logged in as "collector"
+ And I create the collection "Temporary Top" with name "temporary_top_collection"
+ When I go to the collections page
+ And I follow "New Collection"
+ And I fill in "collection_parent_name" with "temporary_top_collection"
+ And I fill in "Display title" with "Temporary Subcollection"
+ And I fill in "Collection name" with "temporary_subcollection"
+ And I press "Submit"
+ Then I should see "Collection was successfully created"
+ When I follow "Collection Settings"
+ And I follow "Delete Collection"
+ And I press "Yes, Delete Collection"
+ Then I should see "Collection was successfully deleted."
+ And I should see "Temporary Top"
+ When I follow "Temporary Top"
+ And I follow "Collection Settings"
+ When I follow "Delete Collection"
+ And I press "Yes, Delete Collection"
+ Then I should see "Collection was successfully deleted."
+ And I should not see "Temporary Top"
+
+ Scenario: Delete a collection that has subcollections
+
+ Given I am logged in as "collector"
+ And I create the collection "Parent" with name "parent_collection"
+ When I go to the collections page
+ And I follow "New Collection"
+ And I fill in "collection_parent_name" with "parent_collection"
+ And I fill in "Display title" with "Child"
+ And I fill in "Collection name" with "child_collection"
+ And I press "Submit"
+ Then I should see "Collection was successfully created"
+ When I go to the collections page
+ And I follow "Parent"
+ And I follow "Collection Settings"
+ When I follow "Delete Collection"
+ And I press "Yes, Delete Collection"
+ Then I should see "Collection was successfully deleted."
+ And I should not see "Parent"
+
+ Scenario: Moderator cannot list one collection's subcollection as another
+ collection's parent
+
+ Given I am logged in as "collector"
+ And I add the subcollection "Subcollection" to the parent collection named "parent_collection"
+ When I go to the new collection page
+ And I fill in "Parent collection (that you maintain)" with "subcollection"
+ And I fill in "Display title" with "Sub-Subcollection"
+ And I fill in "Collection name" with "sub_subcollection"
+ And I press "Submit"
+ Then I should see "Sorry, but Subcollection is a subcollection, so it can't also be a parent collection."
+
+ Scenario: Moderator cannot specify a parent collection that does not exist
+
+ Given I am logged in
+ When I go to the new collection page
+ And I fill in "Parent collection (that you maintain)" with "nonexistent_collection"
+ And I fill in "Display title" with "Collection"
+ And I fill in "Collection name" with "Collection"
+ And I press "Submit"
+ Then I should see "We couldn't find a collection with name nonexistent_collection."
+
+ Scenario: Moderator cannot make a collection its own parent
+
+ Given I am logged in
+ And I create the collection "Collection" with name "collection"
+ When I go to "Collection" collection edit page
+ And I fill in "Parent collection (that you maintain)" with "collection"
+ And I press "Update"
+ Then I should see "You can't make a collection its own parent."
+
+ Scenario: Moderator cannot list a parent collection they do not own
+
+ Given I am logged in as "collector"
+ And I create the collection "Collection" with name "collection"
+ When I am logged in as "other_collector"
+ And I go to the new collection page
+ And I fill in "Display title" with "Other Collection"
+ And I fill in "Collection name" with "other_collection"
+ And I fill in "Parent collection (that you maintain)" with "collection"
+ And I press "Submit"
+ Then I should see "You have to be a maintainer of collection to make a subcollection."
+
+ Scenario: Collection display title can't contain commas
+
+ Given I am logged in
+ And I am on the new collection page
+ When I fill in "Display title" with "Hey, You"
+ And I fill in "Collection name" with "hey_you"
+ And I press "Submit"
+ Then I should see "Sorry, the ',' character cannot be in a collection Display Title."
diff --git a/features/collections/collection_dashboard.feature b/features/collections/collection_dashboard.feature
new file mode 100755
index 0000000..e2a844b
--- /dev/null
+++ b/features/collections/collection_dashboard.feature
@@ -0,0 +1,81 @@
+@collections
+Feature: Collection
+ In order to browse a collection
+ As a humble user
+ I want to see a collection dashboard
+
+ Scenario: When a collection has more works or bookmarks than the maximum displayed on dashboards (5), the listbox for that type of item should contain a link to the collection's page for that type of item (e.g. Works (6) or Bookmarks (10)).
+
+ Given I have a collection "Dashboard Light" with name "dashboard_light"
+ And I am logged in as "user"
+ When I post the work "Work 1" in the collection "Dashboard Light"
+ And I post the work "Work 2" in the collection "Dashboard Light"
+ And I post the work "Work 3" in the collection "Dashboard Light"
+ And I post the work "Work 4" in the collection "Dashboard Light"
+ And I post the work "Work 5" in the collection "Dashboard Light"
+ And I post the work "Work 6" in the collection "Dashboard Light"
+ When I go to "Dashboard Light" collection's page
+ Then I should see "Works (6)" within "#collection-works"
+ When I follow "Works (6)" within "#collection-works"
+ And I follow "Work 1"
+ And I follow "Bookmark"
+ And I fill in "bookmark_collection_names" with "dashboard_light"
+ And I press "Create"
+ Then I should see "Bookmark was successfully created."
+ And I should see "Dashboard Light"
+ When I follow "Dashboard Light"
+ And I follow "Works (6)"
+ And I follow "Work 2"
+ And I follow "Bookmark"
+ And I fill in "bookmark_collection_names" with "dashboard_light"
+ And I press "Create"
+ Then I should see "Bookmark was successfully created."
+ When I go to "Dashboard Light" collection's page
+ And I follow "Works (6)"
+ And I follow "Work 3"
+ And I follow "Bookmark"
+ And I fill in "bookmark_collection_names" with "dashboard_light"
+ And I press "Create"
+ Then I should see "Bookmark was successfully created."
+ When I go to "Dashboard Light" collection's page
+ And I follow "Works (6)"
+ And I follow "Work 4"
+ And I follow "Bookmark"
+ And I fill in "bookmark_collection_names" with "dashboard_light"
+ And I press "Create"
+ Then I should see "Bookmark was successfully created."
+ When I go to "Dashboard Light" collection's page
+ And I follow "Works (6)"
+ And I follow "Work 5"
+ And I follow "Bookmark"
+ And I fill in "bookmark_collection_names" with "dashboard_light"
+ And I press "Create"
+ Then I should see "Bookmark was successfully created."
+ When I go to "Dashboard Light" collection's page
+ And I follow "Works (6)"
+ And I follow "Work 6"
+ And I follow "Bookmark"
+ And I fill in "bookmark_collection_names" with "dashboard_light"
+ And I press "Create"
+ Then I should see "Bookmark was successfully created."
+ When I go to "Dashboard Light" collection's page
+ Then I should see "Dashboard Light"
+ And I should see "Recent bookmarks"
+ And I should see "Bookmarks (6)" within "#collection-bookmarks"
+
+ Scenario: Given that I am on a collection's homepage the "Random Items" button should work
+
+ Given I have a collection "Dashboard Light" with name "dashboard_light"
+ And I am logged in as "user"
+ When I post the work "Work 1" in the collection "Dashboard Light"
+ And I post the work "Work 2" in the collection "Dashboard Light"
+ And I post the work "Work 3" in the collection "Dashboard Light"
+ And I post the work "Work 4" in the collection "Dashboard Light"
+ And I post the work "Work 5" in the collection "Dashboard Light"
+ And I post the work "Work 6" in the collection "Dashboard Light"
+ When I go to "Dashboard Light" collection's page
+ Then I should see "Random Items"
+
+ When I go to "Dashboard Light" collection's page
+ And I follow "Random Items"
+ Then I should see "Random works"
\ No newline at end of file
diff --git a/features/collections/collection_invite.feature b/features/collections/collection_invite.feature
new file mode 100755
index 0000000..6ab377f
--- /dev/null
+++ b/features/collections/collection_invite.feature
@@ -0,0 +1,107 @@
+@collection @works
+
+Feature: Collection
+ In order to have a collection full of curated works
+ As a collection maintainer
+ I want to add and invite works to my collection
+
+ Scenario: Invite a work to a collection where a user approves inclusion
+ Given I am logged in as "Scott" with password "password"
+ And I set my preferences to allow collection invitations
+ And I post the work "Murder in Milan" with fandom "Murder She Wrote"
+ When I have the collection "scotts collection" with name "scotts_collection"
+ And I am logged in as "moderator" with password "password"
+ And I invite the work "Murder in Milan" to the collection "scotts collection"
+ Then I should see "This work has been invited to your collection (scotts collection)."
+ And 1 email should be delivered to "Scott"
+ When I go to "scotts collection" collection's page
+ Then I should see "Works (0)"
+ When I follow "Manage Items"
+ And I follow "Awaiting User Approval"
+ Then I should see "Murder in Milan"
+ And I should see /Works and bookmarks listed here have been invited to this collection. Once a work's creator has approved inclusion in this collection, the work will be moved to "Approved\."/
+ When I am logged in as "Scott" with password "password"
+ And "Scott" accepts the invitation for their work in the collection "scotts collection"
+ And I press "Submit"
+ Then I should not see "Murder in Milan"
+ When I follow "Approved"
+ Then I should see "Murder in Milan"
+ When I am logged in as "moderator"
+ And I am on "scotts collection" collection's page
+ And I follow "Manage Items"
+ Then I should not see "Murder in Milan"
+ When I follow "Approved"
+ Then I should see "Murder in Milan"
+
+ Scenario: Collection invitation emails are translated
+ Given I am logged in as "Scott"
+ And I set my preferences to allow collection invitations
+ And a locale with translated emails
+ And the user "Scott" enables translated emails
+ And the user "Friend" allows co-creators
+ When I coauthored the work "Murder in Milan" as "Scott" with "Friend"
+ And the user "Friend" accepts all co-creator requests
+ And all emails have been delivered
+ When I have the collection "scotts collection" with name "scotts_collection"
+ And I am logged in as "moderator"
+ And I invite the work "Murder in Milan" to the collection "scotts collection"
+ Then 1 email should be delivered to "Scott"
+ And the email to "Scott" should be translated
+ And the email should have "Request to include work in a collection" in the subject
+ And 1 email should be delivered to "Friend"
+ And the email to "Friend" should be non-translated
+ And the email should have "Request to include work in a collection" in the subject
+
+ Scenario: Invite another's work to a anonymous collection should not be allowed.
+ Given I am logged in
+ And I set my preferences to allow collection invitations
+ And I post the work "A Death in Hong Kong"
+ When I have the hidden collection "anon collection" with name "anon_collection"
+ And I am logged in as "moderator"
+ And I invite the work "A Death in Hong Kong" to the collection "anon collection"
+ Then I should see "because you don't own this item and the collection is anonymous or unrevealed"
+ And 0 emails should be delivered
+ When I view the approved collection items page for "anon collection"
+ Then I should not see "A Death in Hong Kong"
+
+ Scenario: Invite another's work to a hidden collection should not be allowed.
+ Given I am logged in
+ And I set my preferences to allow collection invitations
+ And I post the work "A Death in Hong Kong"
+ When I have the hidden collection "hidden collection" with name "hidden_collection"
+ And I am logged in as "moderator"
+ And I invite the work "A Death in Hong Kong" to the collection "hidden collection"
+ Then I should see "because you don't own this item and the collection is anonymous or unrevealed"
+ And 0 emails should be delivered
+ When I view the approved collection items page for "hidden collection"
+ Then I should not see "A Death in Hong Kong"
+
+ Scenario: Invite another's work to a hidden anonymous collection should not be allowed.
+ Given I am logged in
+ And I set my preferences to allow collection invitations
+ And I post the work "A Death in Hong Kong"
+ When I have the hidden anonymous collection "anon hidden collection" with name "anon_hidden_collection"
+ And I am logged in as "moderator"
+ And I invite the work "A Death in Hong Kong" to the collection "anon hidden collection"
+ Then I should see "because you don't own this item and the collection is anonymous or unrevealed"
+ And 0 emails should be delivered
+ When I view the approved collection items page for "anon hidden collection"
+ Then I should not see "A Death in Hong Kong"
+
+ Scenario: A work with too many tags can be invited to a collection, and the user can accept the invitation
+ Given the user-defined tag limit is 2
+ And the collection "Favorites"
+ And the work "Over the Limit" by "sky"
+ And the work "Over the Limit" has 3 fandom tags
+ And I am logged in as "sky"
+ And I set my preferences to allow collection invitations
+ When I am logged in as "moderator"
+ And I invite the work "Over the Limit" to the collection "Favorites"
+ Then I should see "This work has been invited to your collection (Favorites)."
+ When I am logged in as "sky"
+ And "sky" accepts the invitation for their work in the collection "Favorites"
+ And I submit
+ Then I should see "Collection status updated!"
+ And I should not see "Over the Limit"
+ When I view the work "Over the Limit"
+ Then I should see "Favorites"
diff --git a/features/collections/collection_navigation.feature b/features/collections/collection_navigation.feature
new file mode 100755
index 0000000..9b54cd6
--- /dev/null
+++ b/features/collections/collection_navigation.feature
@@ -0,0 +1,138 @@
+@collections
+Feature: Basic collection navigation
+
+ @disable_caching
+ Scenario: Create a collection and check the links
+ When I am logged in as "mod" with password "password"
+ And I go to the collections page
+ And I follow "New Collection"
+ And I fill in "Collection name" with "my_collection"
+ And I fill in "Display title" with "My Collection"
+ And I submit
+ Then I should see "Collection was successfully created."
+ And I should see "Works (0)"
+ And I should see "Fandoms (0)"
+ Given basic tags
+ And I have a canonical "TV Shows" fandom tag named "New Fandom"
+ And a freeform exists with name: "Free", canonical: true
+ When I follow "New Work" within "ul.user.navigation.actions"
+ And I select "Not Rated" from "Rating"
+ And I check "No Archive Warnings Apply"
+ And I fill in "Fandoms" with "New Fandom"
+ And I fill in "Additional Tags" with "Free"
+ And I fill in "Work Title" with "Work for my collection"
+ And I select "English" from "Choose a language"
+ And I fill in "content" with "First because I'm the mod"
+ And I fill in "Post to Collections / Challenges" with "my_collection"
+ And I press "Preview"
+ And I press "Post"
+ And the collection counts have expired
+ And I follow "My Collection"
+ When I follow "Profile"
+ Then I should see "About My Collection (my_collection)"
+ And I should see "Maintainers: mod"
+ When I follow "Subcollections (0)"
+ Then I should see "Challenges/Subcollections in My Collection"
+ And I should see "Sorry, there were no collections found."
+ When I follow "Fandoms (1)"
+ Then I should see "New Fandom (1)"
+ When I follow "Works (1)"
+ Then I should see "Work for my collection by mod"
+ And I should see "1 Work in My Collection"
+ When I follow "Bookmarked Items" within "#dashboard"
+ Then I should see "0 Bookmarked Items"
+ When I follow "Random Items"
+ Then I should see "Work for my collection by mod"
+ When I follow "People" within "div#dashboard"
+ Then I should see "Participants in My Collection"
+ And I should see "mod"
+ When I follow "Tags" within "div#dashboard"
+ Then I should see "Free"
+ When I follow "Collection Settings"
+ Then I should see "Edit Collection"
+ When I am logged out
+ And I am on the collections page
+ And I follow "My Collection"
+ Then I should not see "Settings"
+
+ Scenario: A Collection's Fandoms should be in alphabetical order
+ Given I have the collection "My ABCs" with name "my_abcs"
+ And a canonical fandom "A League of Their Own"
+ And a canonical fandom "Merlin"
+ And a canonical fandom "Teen Wolf"
+ And a canonical fandom "The Borgias"
+ When I am logged in as "Scott" with password "password"
+ And I post the work "Sesame Street" in the collection "My ABCs"
+ And I edit the work "Sesame Street"
+ And I fill in "Fandoms" with "A League of Their Own, Merlin, Teen Wolf, The Borgias"
+ And I press "Post"
+ And the collection counts have expired
+ And I go to "My ABCs" collection's page
+ And I follow "Fandoms ("
+ Then "The Borgias" should appear before "A League of Their Own"
+ And "A League of Their Own" should appear before "Merlin"
+ And "Merlin" should appear before "Teen Wolf"
+
+ Scenario: Collections can be filtered by media type
+ Given I have the collection "We all sing together"
+ And I have a canonical "TV Shows" fandom tag named "Steven's Universe"
+ And I have a canonical "Movies" fandom tag named "High School Musical"
+ When I am logged in as "Brian" with password "They called him Brian"
+ And I post the work "Stronger than you" with fandom "Steven's Universe" in the collection "We all sing together"
+ And I post the work "Breaking Free" with fandom "High School Musical" in the collection "We all sing together"
+ And I go to "We all sing together" collection's page
+ And I follow "Fandoms ("
+ And I select "Movies" from "media_id"
+ And I press "Show"
+ Then I should see "High School Musical"
+ And I should not see "Steven's Universe"
+ When I select "TV Shows" from "media_id"
+ And I press "Show"
+ Then I should not see "High School Musical"
+ And I should see "Steven's Universe"
+
+ Scenario: A collection's fandom count shouldn't include inherited metatags.
+ Given I have the collection "MCU Party"
+ And a canonical fandom "The Avengers"
+ And a canonical fandom "MCU"
+ And "MCU" is a metatag of the fandom "The Avengers"
+ And I am logged in as "mcu_fan"
+ And I post the work "Ensemble Piece" with fandom "The Avengers" in the collection "MCU Party"
+ And the collection counts have expired
+
+ When I go to the collections page
+ Then I should see "Fandoms: 1"
+
+ When I go to "MCU Party" collection's page
+ Then I should see "Fandoms (1)"
+
+ Scenario: Browse tags within a collection (or not)
+ Given I have a collection "Randomness"
+ And a canonical fandom "Naruto"
+ And a canonical freeform "Crack"
+ And I am logged in
+ And I post the work "Has some tags" with fandom "Naruto" with freeform "Crack" in the collection "Randomness"
+ And the collection counts have expired
+
+ # Tag links from the work blurb in a collection should not be collection-scoped
+ When I go to "Randomness" collection's page
+ And I follow "Naruto" within "#collection-works"
+ Then I should be on the works tagged "Naruto"
+
+ # Tag links from the work meta in a collection should not be collection-scoped
+ When I go to "Randomness" collection's page
+ And I follow "Has some tags"
+ And I follow "Naruto"
+ Then I should be on the works tagged "Naruto"
+
+ # Tag links from a collection's fandoms page should be collection-scoped
+ When I go to "Randomness" collection's page
+ And I follow "Fandoms (1)"
+ And I follow "Naruto"
+ Then I should be on the works tagged "Naruto" in collection "Randomness"
+
+ # Tag links from a collection's tags page should be collection-scoped
+ When I go to "Randomness" collection's page
+ And I follow "Tags" within "#dashboard"
+ And I follow "Crack"
+ Then I should be on the works tagged "Crack" in collection "Randomness"
diff --git a/features/collections/collection_notification.feature b/features/collections/collection_notification.feature
new file mode 100755
index 0000000..b5863d4
--- /dev/null
+++ b/features/collections/collection_notification.feature
@@ -0,0 +1,144 @@
+@collections @works
+
+Feature: Collectible items email
+ As a moderator
+ I want to get notifications when items are added to my collection
+
+ @disable_caching
+ Scenario: Work added to collection sends notification email
+ Given I am logged in as "first_user"
+ And all emails have been delivered
+ When I go to the collections page
+ When I follow "New Collection"
+ And I fill in "Display title" with "Antarctic Penguins"
+ And I fill in "Collection name" with "AntarcticPenguins"
+ And I fill in "Collection email" with "test@archiveofourown.org"
+ And I check the 1st checkbox with id matching "collection_collection_preference_attributes_email_notify"
+ And I submit
+ Then I should see "Collection was successfully created"
+ When I go to the collections page
+ When I follow "New Collection"
+ And I fill in "Display title" with "Polar Bears"
+ And I fill in "Collection name" with "PolarBears"
+ And I fill in "Collection email" with "test2@archiveofourown.org"
+ And I check the 1st checkbox with id matching "collection_collection_preference_attributes_email_notify"
+ And I submit
+ Then I should see "Collection was successfully created"
+ When I post the work "collect-y work"
+ And I go to first_user's user page
+ When I edit the work "collect-y work"
+ And I fill in "work_collection_names" with "AntarcticPenguins"
+ And I press "Preview"
+ Then I should see "Preview"
+ And I press "Update"
+ Then I should see "Work was successfully updated."
+ And I should see "collect-y work"
+ And I should see "Antarctic Penguins"
+ And 1 email should be delivered to test@archiveofourown.org
+ And all emails have been delivered
+ When I edit the work "collect-y work"
+ And I fill in "work_collection_names" with "AntarcticPenguins, PolarBears"
+ And I press "Preview"
+ Then I should see "Preview"
+ And I press "Update"
+ Then I should see "Work was successfully updated."
+ And I should see "collect-y work"
+ And I should see "Polar Bears"
+ And I should see "Antarctic Penguins"
+ And 1 email should be delivered to test2@archiveofourown.org
+
+ Scenario: Bookmark added to collection sends notification email
+ Given all email have been delivered
+ When I have the collection "Dont Bookmark Me Bro" with name "dont_bookmark_me_bro"
+ And I am logged in as "moderator"
+ And I go to "Dont Bookmark Me Bro" collection's page
+ And I follow "Collection Settings"
+ And I fill in "Collection email" with "test@archiveofourown.org"
+ And I check "Send a message to the collection email when a work is added"
+ And I press "Update"
+ When I post the work "Excessive Force"
+ And I am logged in as "bookmarker"
+ And I view the work "Excessive Force"
+ And I follow "Bookmark"
+ And I fill in "bookmark_collection_names" with "dont_bookmark_me_bro"
+ And I press "Create"
+ Then 1 email should be delivered
+
+ Scenario: Archivist adds work to collection
+ Given I am logged in as "regular_user"
+ And I post the work "Collection Work"
+ And a locale with translated emails
+ And the user "regular_user" enables translated emails
+ And I have an archivist "archivist"
+ When all emails have been delivered
+ And I am logged in as "archivist"
+ And I create the collection "Open Doors Collection" with name "open_doors_collection"
+ And I view the work "Collection Work"
+ And I follow "Add to Collections"
+ And I fill in "collection_names" with "open_doors_collection"
+ And I press "Add"
+ Then I should see "Added to collection(s): Open Doors Collection"
+ And 1 email should be delivered
+ And the email to "regular_user" should be translated
+
+ Scenario: Translated email is sent when the status of a Collection item is changed to anonymous
+ Given a locale with translated emails
+ And the user "user1" exists and is activated
+ And the user "user1" enables translated emails
+ And all emails have been delivered
+ When I have the collection "Collection1"
+ And I am logged in as "user1"
+ And I post the work "Test work" in the collection "Collection1"
+ When I am logged in as the owner of "Collection1"
+ And I go to "Collection1" collection's page
+ And I follow "Collection Settings"
+ And I check the 1st checkbox with id matching "collection_collection_preference_attributes_anonymous"
+ And I press "Update"
+ When I view the approved collection items page for "Collection1"
+ And I check the 1st checkbox with id matching "collection_items_\d+_anonymous"
+ And I submit
+ Then "user1" should be emailed
+ And the email should have "Your work was made anonymous" in the subject
+ And the email to "user1" should be translated
+
+ Scenario: Translated email is sent when the status of a Collection item is changed to unrevealed
+ Given a locale with translated emails
+ And the user "user1" exists and is activated
+ And the user "user1" enables translated emails
+ And all emails have been delivered
+ When I have the collection "Collection1"
+ And I am logged in as "user1"
+ And I post the work "Test work" in the collection "Collection1"
+ When I am logged in as the owner of "Collection1"
+ And I go to "Collection1" collection's page
+ And I follow "Collection Settings"
+ And I check the 1st checkbox with id matching "collection_collection_preference_attributes_unrevealed"
+ And I press "Update"
+ When I view the approved collection items page for "Collection1"
+ And I check the 1st checkbox with id matching "collection_items_\d+_unrevealed"
+ And I submit
+ Then "user1" should be emailed
+ And the email should have "Your work was made unrevealed" in the subject
+ And the email to "user1" should be translated
+
+ Scenario: Translated email is sent when the status of a Collection item is changed to anonymous and unrevealed
+ Given a locale with translated emails
+ And the user "user1" exists and is activated
+ And the user "user1" enables translated emails
+ And all emails have been delivered
+ When I have the collection "Collection1"
+ And I am logged in as "user1"
+ And I post the work "Test work" in the collection "Collection1"
+ When I am logged in as the owner of "Collection1"
+ And I go to "Collection1" collection's page
+ And I follow "Collection Settings"
+ And I check the 1st checkbox with id matching "collection_collection_preference_attributes_unrevealed"
+ And I check the 1st checkbox with id matching "collection_collection_preference_attributes_anonymous"
+ And I press "Update"
+ When I view the approved collection items page for "Collection1"
+ And I check the 1st checkbox with id matching "collection_items_\d+_unrevealed"
+ And I check the 1st checkbox with id matching "collection_items_\d+_anonymous"
+ And I submit
+ Then "user1" should be emailed
+ And the email should have "Your work was made anonymous and unrevealed" in the subject
+ And the email to "user1" should be translated
diff --git a/features/collections/collection_participants.feature b/features/collections/collection_participants.feature
new file mode 100755
index 0000000..2af84d4
--- /dev/null
+++ b/features/collections/collection_participants.feature
@@ -0,0 +1,77 @@
+@collection
+
+ Feature: Collection
+
+ Scenario: A collection owner can't remove the owner from a collection
+ Given I have the collection "Such a nice collection"
+ And I am logged in as the owner of "Such a nice collection"
+ When I am on the "Such a nice collection" participants page
+ And I press "Remove"
+ Then I should see "You can't remove the only owner!"
+
+ Scenario: A collection owner can invite, update, and remove a collection member
+ Given a user exists with login: "sam"
+ And I have the collection "Such a nice collection"
+ And I am logged in as the owner of "Such a nice collection"
+ When I am on the "Such a nice collection" participants page
+ And I fill in "participants_to_invite" with "sam"
+ And I press "Submit"
+ Then I should see "New members invited: sam"
+ When I select "Owner" from "sam_role"
+ And I submit with the 4th button
+ Then I should see "Updated sam."
+ When I click the 2nd button
+ Then I should see "Removed sam from collection."
+
+ Scenario: Owner can't invite a nonexistent user to the collection
+ Given I have the collection "Such a nice collection"
+ And I am logged in as the owner of "Such a nice collection"
+ When I am on the "Such a nice collection" participants page
+ And I fill in "participants_to_invite" with "sam"
+ And I press "Submit"
+ Then I should see "We couldn't find anyone new by that name to add."
+
+ Scenario: Collection owner can't invite a banned user to a collection
+ Given a user exists with login: "sam"
+ And user "sam" is banned
+ And I have the collection "Such a nice collection"
+ And I am logged in as the owner of "Such a nice collection"
+ When I am on the "Such a nice collection" participants page
+ And I fill in "participants_to_invite" with "sam"
+ And I press "Submit"
+ Then I should see "sam cannot participate in challenges."
+
+ Scenario: A user can ask to join a closed collection
+ Given I have a moderated closed collection "Such a nice collection"
+ And I am logged in as "sam"
+ When I go to "Such a nice collection" collection's page
+ And I press "Join"
+ Then I should see "You have applied to join Such a nice collection"
+
+ Scenario: A collection owner can preapprove a user to join a closed collection
+ Given I have a moderated closed collection "Such a nice collection"
+ And I am in sam's browser
+ And I am logged in as "sam"
+ When I go to "Such a nice collection" collection's page
+ When I am in the moderator's browser
+ And I am logged in as the owner of "Such a nice collection"
+ And I am on the "Such a nice collection" participants page
+ And I fill in "participants_to_invite" with "sam"
+ And I press "Submit"
+ Then I should see "New members invited: sam"
+ When I select "Invited" from "sam_role"
+ And I submit with the 4th button
+ Then I should see "Updated sam."
+ When I am in sam's browser
+ And I press "Join"
+ Then I should see "You are now a member of Such a nice collection"
+ When I am in the default browser
+
+Scenario: Collection member should see correct button text
+ Given I have the moderated collection "ModeratedCollection"
+ And I have the moderated collection "ModeratedCollectionTheSequel"
+ And I am logged in as "sam"
+ And I have joined the collection "ModeratedCollection" as "sam"
+ When I am on the collections page
+ Then I should see "Leave" exactly 1 time
+ And I should see "Join" exactly 1 time
diff --git a/features/comments_and_kudos/add_comment.feature b/features/comments_and_kudos/add_comment.feature
new file mode 100755
index 0000000..855270a
--- /dev/null
+++ b/features/comments_and_kudos/add_comment.feature
@@ -0,0 +1,316 @@
+@comments
+Feature: Comment on work
+ In order to give feedback
+ As a reader
+ I'd like to comment on a work
+
+Scenario: Comment links from downloads and static pages
+
+ Given the work "Generic Work"
+ When I am logged in as "commenter"
+ And I visit the new comment page for the work "Generic Work"
+ Then I should see the comment form
+
+Scenario: When logged in I can comment on a work
+
+ Given the work "The One Where Neal is Awesome"
+ When I am logged in as "commenter"
+ And I view the work "The One Where Neal is Awesome"
+ And I fill in "Comment" with "I loved this! 😍🤩"
+ And I press "Comment"
+ Then I should see "Comment created!"
+ And I should see "I loved this! 😍🤩" within ".odd"
+
+ Scenario: When a one-shot work becomes multi-chapter, all previous comments say "on Chapter 1"
+ Given the work "The One Where Neal is Awesome"
+ And I am logged in as "commenter"
+ And I post the comment "I loved this! 😍🤩" on the work "The One Where Neal is Awesome"
+ When I view the work "The One Where Neal is Awesome" with comments
+ Then I should not see "commenter on Chapter 1" within "h4.heading.byline"
+ When a chapter is added to "The One Where Neal is Awesome"
+ And I view the work "The One Where Neal is Awesome" in full mode
+ And I follow "Comments (1)"
+ When "AO3-4214" is fixed
+ # Then I should see "commenter on Chapter 1" within "h4.heading.byline"
+
+Scenario: When commenting on a multi-chapter work, there should be a link to the chapter on the comment
+
+ Given the work "The One Where Neal is Awesome"
+ And a chapter is added to "The One Where Neal is Awesome"
+ And I am logged in as "commenter"
+ And I post the comment "I loved this! 😍🤩" on the work "The One Where Neal is Awesome"
+ Then I should see "commenter on Chapter 1"
+ And I should see a link "Chapter 1" within ".comment h4.heading.byline"
+ And I should see a page link to the 1st chapter of the work "The One Where Neal is Awesome" within ".comment h4.heading.byline"
+ When I follow "Thread"
+ Then I should see a link "Chapter 1" within ".comment h4.heading.byline"
+ And I should see a page link to the 1st chapter of the work "The One Where Neal is Awesome" within ".comment h4.heading.byline"
+
+Scenario: When commenting on a single-chapter work, there should not be a link to the chapter on the comment
+
+ Given the work "The One Where Neal is Awesome"
+ And I am logged in as "commenter"
+ And I post the comment "I loved this! 😍🤩" on the work "The One Where Neal is Awesome"
+ Then I should see "commenter"
+ And I should not see "commenter on Chapter 1"
+ And I should not see a link "Chapter 1" within ".comment h4.heading.byline"
+ And I should not see a page link to the 1st chapter of the work "The One Where Neal is Awesome" within ".comment h4.heading.byline"
+ When I follow "Thread"
+ Then I should not see a link "Chapter 1" within ".comment h4.heading.byline"
+ And I should not see a page link to the 1st chapter of the work "The One Where Neal is Awesome" within ".comment h4.heading.byline"
+
+Scenario: I cannot comment with a pseud that I don't own
+
+ Given the work "Random Work"
+ When I attempt to comment on "Random Work" with a pseud that is not mine
+ Then I should not see "Comment created!"
+ And I should not see "on Chapter 1"
+ And I should see "You can't comment with that pseud"
+
+Scenario: I cannot edit in a pseud that I don't own
+
+ Given the work "Random Work"
+ When I attempt to update a comment on "Random Work" with a pseud that is not mine
+ Then I should not see "Comment was successfully updated"
+ And I should see "You can't comment with that pseud"
+
+Scenario: Comment editing
+
+ When I am logged in as "author"
+ And I post the work "The One Where Neal is Awesome"
+ When I am logged in as "commenter"
+ And I post the comment "Mistaken comment" on the work "The One Where Neal is Awesome"
+ And it is currently 1 second from now
+ And I follow "Edit"
+ And I fill in "Comment" with "Actually, I meant something different"
+ And I press "Update"
+ Then I should see "Comment was successfully updated"
+ And I should see "Actually, I meant something different"
+ And I should not see "Mistaken comment"
+ And I should see Last Edited in the right timezone
+
+Scenario: Comment threading, comment editing
+
+ When I am logged in as "author"
+ And I post the work "The One Where Neal is Awesome"
+ When I am logged in as "commenter"
+ And I post the comment "I loved this!" on the work "The One Where Neal is Awesome"
+ When I follow "Reply"
+ And I fill in "Comment" with "I wanted to say more." within ".odd"
+ And I press "Comment" within ".odd"
+ Then I should see "Comment created!"
+ And I should see "I wanted to say more." within ".even"
+ When I am logged in as "commenter2"
+ And I view the work "The One Where Neal is Awesome"
+ And I fill in "Comment" with "I loved it, too."
+ And I press "Comment"
+ Then I should see "Comment created!"
+ And I should see "I loved it, too."
+ When I am logged in as "author"
+ And I view the work "The One Where Neal is Awesome"
+ And I follow "Comments (3)"
+ And I follow "Reply" within ".even"
+ And I fill in "Comment" with "Thank you." within ".even"
+ And I press "Comment" within ".even"
+ Then I should see "Comment created!"
+ And I should see "Thank you." within "ol.thread li ol.thread li ol.thread li"
+ When I am logged in as "commenter"
+ And I view the work "The One Where Neal is Awesome"
+ And I follow "Comments (4)"
+ And I follow "Reply" within ".thread .thread .odd"
+ And I fill in "Comment" with "Mistaken comment" within ".thread .thread .odd"
+ And I press "Comment" within ".thread .thread .odd"
+ And it is currently 1 second from now
+ And I follow "Edit" within "ol.thread li ol.thread li ol.thread li ol.thread ul.actions"
+ And I fill in "Comment" with "Actually, I meant something different"
+ And I press "Update"
+ Then I should see "Comment was successfully updated"
+ And I should see "Actually, I meant something different"
+ And I should not see "Mistaken comment"
+ And I should see Last Edited in the right timezone
+ When I am logged in as "commenter3"
+ And I view the work "The One Where Neal is Awesome"
+ And I follow "Comments (5)"
+ And I follow "Reply" within ".thread .even"
+ And I fill in "Comment" with "This should be nested" within ".thread .even"
+ And I press "Comment" within ".thread .even"
+ Then I should see "Comment created!"
+ And I should not see "Mistaken comment"
+ And I should see "Actually, I meant something different" within "ol.thread li ol.thread li ol.thread li ol.thread"
+ And I should see "I loved it, too." within "ol.thread"
+ And I should see "Thank you." within "ol.thread li ol.thread li ol.thread"
+ And I should see "This should be nested" within "ol.thread li ol.thread li ol.thread"
+ And I should not see "This should be nested" within ".thread .thread .thread .thread"
+ And I should see "I loved this" within "ol.thread"
+
+ Scenario: A leaves a comment, B replies to it, A deletes their comment, B edits the comment, A should not receive a comment edit notification email
+
+ Given the work "Generic Work" by "creator"
+ And a comment "A's comment (to be deleted)" by "User_A" on the work "Generic Work"
+ And a reply "B's comment (to be edited)" by "User_B" on the work "Generic Work"
+ And 1 email should be delivered to "User_A"
+ And all emails have been delivered
+ When I am logged in as "User_A"
+ And I view the work "Generic Work" with comments
+ And I delete the comment
+ When I am logged in as "User_B"
+ And I view the work "Generic Work" with comments
+ And I follow "Edit"
+ And I fill in "Comment" with "B's improved comment (edited)"
+ And I press "Update"
+ Then 0 emails should be delivered to "User_A"
+
+ Scenario: Try to post an invalid comment
+
+ When I am logged in as "author"
+ And I post the work "Generic Work"
+ When I am logged in as "commenter"
+ And I view the work "Generic Work"
+ And I compose an invalid comment
+ And I press "Comment"
+ Then I should see "must be less than"
+ And I should see "Now, we can devour the gods, together!"
+ When I fill in "Comment" with "This is a valid comment"
+ And I press "Comment"
+ And I follow "Reply" within ".thread .odd"
+ And I compose an invalid comment within ".thread .odd"
+ And I press "Comment" within ".thread .odd"
+ Then I should see "must be less than"
+ And I should see "Now, we can devour the gods, together!"
+ When I fill in "Comment" with "This is a valid reply comment"
+ And I press "Comment"
+ And it is currently 1 second from now
+ And I follow "Edit"
+ And I compose an invalid comment
+ And I press "Update"
+ Then I should see "must be less than"
+ And I should see "Now, we can devour the gods, together!"
+
+Scenario: Don't receive comment notifications of your own comments by default
+
+ When I am logged in as "author"
+ And I post the work "Generic Work"
+ When I am logged in as "commenter"
+ And I post the comment "Something" on the work "Generic Work"
+ Then "author" should be emailed
+ And "commenter" should not be emailed
+
+Scenario: Set preference and receive comment notifications of your own comments
+
+ When I am logged in as "author"
+ And I post the work "Generic Work"
+ When I am logged in as "commenter"
+ And I set my preferences to turn on copies of my own comments
+ And I post the comment "Something" on the work "Generic Work"
+ Then "author" should be emailed
+ And "commenter" should be emailed
+ And 1 email should be delivered to "commenter"
+
+Scenario: Try to post a comment with a < angle bracket before a linebreak, without a space before the bracket
+
+ Given the work "Generic Work"
+ And I am logged in as "commenter"
+ And I view the work "Generic Work"
+ When I fill in "Comment" with
+ """
+ Here is a comment with a bracket
+ abc<
+ xyz
+ """
+ And I press "Comment"
+ Then I should see "Comment created!"
+
+Scenario: Try to post a comment with a < angle bracket before a linebreak, with a space before the bracket
+
+ Given the work "Generic Work"
+ And I am logged in as "commenter"
+ And I view the work "Generic Work"
+ When I fill in "Comment" with
+ """
+ Here is a comment with a bracket
+ abc <
+ xyz
+ """
+ And I press "Comment"
+ Then I should see "Comment created!"
+
+Scenario: Users with different time zone preferences should see the time in their own timezone
+ Given the work "Generic Work"
+ And I am logged in as "commenter"
+ And the user "commenter" sets the time zone to "UTC"
+ And I post the comment "Something" on the work "Generic Work"
+ And it is currently 1 second from now
+ And I follow "Edit"
+ And I fill in "Comment" with "Something else"
+ And I press "Update"
+ Then I should see "UTC" within ".posted.datetime"
+ And I should see "UTC" within ".edited.datetime"
+ When I am logged in as "reader"
+ And the user "reader" sets the time zone to "Brisbane"
+ And I view the work "Generic Work" with comments
+ Then I should see "AEST" within ".posted.datetime"
+ And I should see "AEST" within ".edited.datetime"
+
+Scenario: It hides comment actions when a reply form is open
+ Given the work "The One Where Neal is Awesome"
+ And I am logged in as "commenter"
+ And I post the comment "I loved this!" on the work "The One Where Neal is Awesome"
+ When I follow "Reply"
+ Then I should see "Comment as commenter"
+ And I should not see "Thread"
+
+@javascript
+Scenario: It shows and hides cancel buttons properly
+ Given the work "Aftermath" by "creator" with guest comments enabled
+ And a comment "Ugh." by "pest" on the work "Aftermath"
+ When I view the work "Aftermath"
+ And I display comments
+ Then I should see "Ugh."
+ When I open the reply box
+ Then I should see "Cancel"
+ But I should not see "Reply"
+ When I cancel the reply box
+ Then I should not see "Cancel"
+ But I should see "Reply"
+
+@javascript
+Scenario: It shows and hides cancel buttons properly even on a new page
+Given the work "Aftermath" by "creator" with guest comments enabled
+ And a comment "Ugh." by "pest" on the work "Aftermath"
+ When I view the work "Aftermath"
+ And I display comments
+ Then I should see "Ugh."
+ # Go to /chapters/XX?add_comment_reply_id=YY&show_comments=true#comment_YY"; akin to a Ctrl+Click on "Reply"
+ When I reply on a new page
+ Then I should see "Cancel"
+ But I should not see "Reply"
+ When I cancel the reply box
+ Then I should not see "Cancel"
+ But I should see "Reply"
+
+Scenario: Cannot comment (no form) while logged as admin
+
+ Given the work "Generic Work" by "creator" with guest comments enabled
+ And I am logged in as an admin
+ And I view the work "Generic Work"
+ Then I should see "Generic Work"
+ And I should not see "Post Comment"
+ And I should not see a "Comment" button
+ And I should see "Please log out of your admin account to comment."
+
+Scenario: Cannot reply to comments (no button) while logged as admin
+
+ Given the work "Generic Work" by "creator" with guest comments enabled
+ When I am logged in as "commenter"
+ And I view the work "Generic Work"
+ And I post a comment "Woohoo"
+ When I am logged in as an admin
+ And I view the work "Generic Work"
+ And I follow "Comments (1)"
+ Then I should see "Woohoo"
+ And I should not see "Reply"
+ When I am logged out
+ And I view the work "Generic Work"
+ And I follow "Comments (1)"
+ Then I should see "Woohoo"
+ And I should see "Reply"
diff --git a/features/comments_and_kudos/admin_info.feature b/features/comments_and_kudos/admin_info.feature
new file mode 100755
index 0000000..5caed5e
--- /dev/null
+++ b/features/comments_and_kudos/admin_info.feature
@@ -0,0 +1,51 @@
+Feature: Some admins can see IP addresses and emails for comments
+ Scenario: Admin info for comments isn't visible to logged-out users
+ Given the work "Random Work"
+ And a guest comment on the work "Random Work"
+ When I am a visitor
+ And I view the work "Random Work" with comments
+ Then I should not see "IP Address:" within ".work.meta"
+ And I should not see "IP Address:" within ".comment.group"
+ And I should not see "Email:" within ".comment.group"
+
+ Scenario: Admin info for comments isn't visible to the work's owner
+ Given the work "Random Work"
+ And a guest comment on the work "Random Work"
+ When I am logged in as the author of "Random Work"
+ And I view the work "Random Work" with comments
+ Then I should not see "IP Address:" within ".work.meta"
+ And I should not see "IP Address:" within ".comment.group"
+ And I should not see "Email:" within ".comment.group"
+
+ Scenario Outline: Only certain admins can see IP addresses and email addresses for comments
+ Given the work "Random Work"
+ And a guest comment on the work "Random Work"
+ When I am logged in as a "" admin
+ And I view the work "Random Work" with comments
+ Then I see "IP Address:" within ".work.meta"
+ And I see "IP Address:" within ".comment.group"
+ And I see "Email:" within ".comment.group"
+
+ Examples:
+ | role | should_ip | should_email |
+ | superadmin | should | should |
+ | legal | should | should |
+ | policy_and_abuse | should | should |
+ | support | should not | should |
+ | board | should not | should not |
+ | communications | should not | should not |
+ | docs | should not | should not |
+ | elections | should not | should not |
+ | open_doors | should not | should not |
+ | tag_wrangling | should not | should not |
+ | translation | should not | should not |
+
+ Scenario: No one can see email addresses for comments by registered users
+ Given the work "Random Work"
+ And I am logged in as "commenter"
+ And I post the comment "Hello" on the work "Random Work"
+ When I am logged in as a "superadmin" admin
+ And I view the work "Random Work" with comments
+ Then I should see "IP Address:" within ".work.meta"
+ And I should see "IP Address:" within ".comment.group"
+ But I should not see "Email:" within ".comment.group"
diff --git a/features/comments_and_kudos/comment_blocking.feature b/features/comments_and_kudos/comment_blocking.feature
new file mode 100755
index 0000000..25bc30d
--- /dev/null
+++ b/features/comments_and_kudos/comment_blocking.feature
@@ -0,0 +1,113 @@
+Feature: Comment Blocking
+ Scenario: Blocked users cannot comment on their blocker's works, or edit existing comments
+ Given the work "Aftermath" by "creator"
+ And a comment "Ugh." by "pest" on the work "Aftermath"
+ And the user "creator" has blocked the user "pest"
+ When I am logged in as "pest"
+ And I view the work "Aftermath" with comments
+ Then I should see "Sorry, you have been blocked by one or more of this work's creators."
+ And I should not see a "Comment" button
+ And I should not see a link "Edit"
+
+ Scenario: Blocked users cannot reply to others on their blocker's works
+ Given the work "Aftermath" by "creator"
+ And a comment "OMG!" by "commenter" on the work "Aftermath"
+ And the user "creator" has blocked the user "pest"
+ When I am logged in as "pest"
+ And I view the work "Aftermath" with comments
+ Then I should not see a link "Reply"
+
+ Scenario Outline: Blocked users cannot reply to their blocker
+ Given
+ And a comment "OMG!" by "commenter" on
+ And the user "commenter" has blocked the user "pest"
+ When I am logged in as "pest"
+ And I view with comments
+ Then I should see a "Comment" button
+ But I should not see a link "Reply"
+
+ Examples:
+ | commentable |
+ | the work "Aftermath" |
+ | the admin post "Change Log" |
+
+ Scenario Outline: Blocked users cannot edit existing replies to their blocker
+ Given
+ And a comment "OMG!" by "commenter" on
+ And a reply "Ugh." by "pest" on
+ And the user "commenter" has blocked the user "pest"
+ When I am logged in as "pest"
+ And I view with comments
+ Then I should not see a link "Edit"
+
+ Examples:
+ | commentable |
+ | the work "Aftermath" |
+ | the admin post "Change Log" |
+
+ Scenario: Blocked users can reply to their blocker on tags
+ Given the following activated tag wranglers exist
+ | login |
+ | commenter |
+ | pest |
+ And a canonical fandom "Controversial"
+ And a comment "OMG!" by "commenter" on the tag "Controversial"
+ And the user "commenter" has blocked the user "pest"
+ When I am logged in as "pest"
+ And I view the tag "Controversial" with comments
+ And I follow "Reply"
+ And I fill in "Comment" with "Ugh." within ".odd"
+ And I press "Comment" within ".odd"
+ Then I should see "Comment created!"
+
+ Scenario: Blocked users can delete their existing comments on their blocker's works
+ Given the work "Aftermath" by "creator"
+ And a comment "Ugh." by "pest" on the work "Aftermath"
+ And the user "creator" has blocked the user "pest"
+ When I am logged in as "pest"
+ And I view the work "Aftermath" with comments
+ And I follow "Delete"
+ And I follow "Yes, delete!"
+ Then I should see "Comment deleted."
+
+ Scenario: Blocked users can comment on works shared with their blocker
+ Given the user "creator" has blocked the user "nemesis"
+ And the work "Aftermath" by "creator" and "nemesis"
+ When I am logged in as "nemesis"
+ And I view the work "Aftermath"
+ And I fill in "Comment" with "I can still do this."
+ And I press "Comment"
+ Then I should see "Comment created!"
+
+ Scenario: Blocked users can't reply to their blocker on works shared with their blocker
+ Given the user "creator" has blocked the user "nemesis"
+ And the work "Aftermath" by "creator" and "nemesis"
+ And a comment "OMG!" by "creator" on the work "Aftermath"
+ When I am logged in as "nemesis"
+ And I view the work "Aftermath" with comments
+ Then I should not see a link "Reply"
+
+ Scenario: When a user is blocked, they cannot reply to their blocker on the homepage or the inbox
+ Given the work "Aftermath" by "pest"
+ And a comment "OMG!" by "commenter" on the work "Aftermath"
+ And the user "commenter" has blocked the user "pest"
+ When I am logged in as "pest"
+ And I go to the homepage
+ Then I should see "OMG!"
+ But I should not see a link "Reply"
+ When I go to pest's inbox page
+ Then I should see "OMG!"
+ But I should not see a link "Reply"
+
+ Scenario: When a user is blocked by the work creator, they cannot reply on the homepage or the inbox
+ Given the work "Aftermath" by "creator"
+ And a comment "Ugh." by "pest" on the work "Aftermath"
+ And a reply "OMG!" by "commenter" on the work "Aftermath"
+ And the user "creator" has blocked the user "pest"
+ When I am logged in as "pest"
+ And I go to the homepage
+ Then I should see "OMG!"
+ But I should not see a link "Reply"
+ When I go to pest's inbox page
+ Then I should see "OMG!"
+ But I should not see a link "Reply"
diff --git a/features/comments_and_kudos/comment_moderation.feature b/features/comments_and_kudos/comment_moderation.feature
new file mode 100755
index 0000000..969b0ea
--- /dev/null
+++ b/features/comments_and_kudos/comment_moderation.feature
@@ -0,0 +1,331 @@
+@comments
+Feature: Comment Moderation
+ In order to avoid spam and troll comments
+ As an author
+ I'd like to be able to moderate comments
+
+
+ Scenario: Turn off comments from anonymous users who can still leave kudos
+ Given I am logged in as "author"
+ And I set up the draft "No Anons"
+ And I choose "Only registered users can comment"
+ And I post the work without preview
+ And I am logged out
+ When I view the work "No Anons"
+ Then I should see "Sorry, this work doesn't allow non-Archive users to comment."
+ When I press "Kudos ♥"
+ Then I should see "Thank you for leaving kudos"
+
+ Scenario: Turn off comments from everyone, but everyone can still leave kudos
+ Given I am logged in as "author"
+ And I set up the draft "No Comments"
+ And I choose "No one can comment"
+ And I post the work without preview
+ When I am logged out
+ And I view the work "No Comments"
+ Then I should see "Sorry, this work doesn't allow comments."
+ When I press "Kudos ♥"
+ Then I should see "Thank you for leaving kudos"
+ When I am logged in as "fan"
+ And I view the work "No Comments"
+ Then I should see "Sorry, this work doesn't allow comments."
+ When I press "Kudos ♥"
+ Then I should see "Thank you for leaving kudos"
+
+ Scenario: Turn on moderation
+ Given I am logged in as "author"
+ And I set up the draft "Moderation"
+ And I check "Enable comment moderation"
+ And I post the work without preview
+ And I post a chapter for the work "Moderation"
+ Then comment moderation should be enabled on "Moderation"
+ When I am logged in as "commenter"
+ And I go to the work "Moderation" in full mode
+ Then I should see "This work's creator has chosen to moderate comments on the work. Your comment will not appear until it has been approved by the creator."
+ When I go to the 2nd chapter of the work "Moderation"
+ Then I should see "This work's creator has chosen to moderate comments on the work. Your comment will not appear until it has been approved by the creator."
+
+ Scenario: Post a moderated comment
+ Given the moderated work "Moderation" by "author"
+ When I am logged in as "commenter"
+ And I post the comment "Fail comment" on the work "Moderation"
+ Then I should see "Your comment was received! It will appear publicly after the work creator has approved it."
+ And I should see "Edit"
+ And I should see "Delete"
+ And I should see "Fail comment"
+ And I should not see "by author"
+ And the comment on "Moderation" should be marked as unreviewed
+ And I should not see "Unreviewed Comments (1)"
+ And I should not see "Comments:1"
+ And "author" should be emailed
+ And the email to "author" should contain "will not appear until you approve"
+ And the email to "author" should contain "Review comments on"
+ And the email to "author" should not contain "Reply"
+ When I post the comment "another comment" on the work "Moderation" as a guest
+ Then I should see "will appear publicly after the work creator has approved"
+ And I should be on the "Moderation" work page
+ And I should not see "Comments:1"
+ And I should not see "Comments:2"
+ And I should not see "another comment"
+ And I should not see "Edit"
+ And I should not see "Delete"
+
+ @disable_caching
+ Scenario: Edit a moderated comment
+ Given the moderated work "Moderation" by "author"
+ And I am logged in as "commenter"
+ And I post the comment "Fail comment" on the work "Moderation"
+ And it is currently 1 second from now
+ When I follow "Edit"
+ And I fill in "Comment" with "Edited unfail comment"
+ And I press "Update"
+ Then I should see "Comment was successfully updated"
+ When I reload the comments on "Moderation"
+ And I am logged in as "author"
+ And I view the work "Moderation"
+ And I follow "Unreviewed Comments (1)"
+ Then I should see "Edited unfail comment"
+
+ Scenario: Author comments do not need to be approved
+ Given the moderated work "Moderation" by "author"
+ When I am logged in as "author"
+ And I post the comment "Fail comment" on the work "Moderation"
+ Then I should not see "It will appear publicly after the work creator has approved it."
+ And the comment on "Moderation" should not be marked as unreviewed
+ And I should see "Comment created"
+ And I should not see "Unreviewed Comments (1)"
+ And I should see "Comments:1"
+
+ Scenario: Moderated comments can be approved by the author
+ Given the moderated work "Moderation" by "author"
+ And I am logged in as "commenter"
+ And I post the comment "Test comment" on the work "Moderation"
+ When I am logged in as "author"
+ And I view the work "Moderation"
+ Then I should see "Unreviewed Comments (1)"
+ And the comment on "Moderation" should be marked as unreviewed
+ When I follow "Unreviewed Comments (1)"
+ Then I should see "Test comment"
+ When I press "Approve"
+ Then I should see "Comment approved"
+ When I am logged out
+ And I view the work "Moderation"
+ Then I should see "Comments:1"
+ And I should see "Comments (1)"
+ When I follow "Comments (1)"
+ Then I should see "Test comment"
+ And the comment on "Moderation" should not be marked as unreviewed
+
+ Scenario: Moderated comments can be approved from the inbox
+ Given the moderated work "Moderation" by "author"
+ And I am logged in as "commenter"
+ And I post the comment "Test comment" on the work "Moderation"
+ When I am logged in as "author"
+ And I go to author's inbox page
+ Then I should see "Test comment"
+ And I should not see "Reply"
+ And I should see "Unreviewed"
+ # we can only test the non-javascript version here
+ When I follow "Unreviewed Comments"
+ And I press "Approve"
+ And I go to author's inbox page
+ Then I should see "Reply"
+ And I should not see "Unreviewed"
+ And I should not see "Unread"
+ When I view the work "Moderation"
+ Then I should see "Comments:1"
+ And I should see "Comments (1)"
+ And I should not see "Unreviewed Comments (1)"
+
+ Scenario: Comments can be approved from the home page inbox
+ Given the moderated work "Moderation" by "author"
+ And I am logged in as "commenter"
+ And I post the comment "Test comment" on the work "Moderation"
+ When I am logged in as "author"
+ And I go to the home page
+ Then I should see "Test comment"
+ And I should see "Unreviewed"
+ And I should not see "Reply"
+ # we can only test the non-javascript version here
+ When I follow "Unreviewed Comments"
+ And I press "Approve"
+ And I view the work "Moderation"
+ Then I should see "Comments:1"
+ And I should see "Comments (1)"
+ And I should not see "Unreviewed Comments (1)"
+
+ Scenario: Moderated comments can be deleted by the author
+ Given the moderated work "Moderation" by "author"
+ And I am logged in as "commenter"
+ And I post the comment "Test comment" on the work "Moderation"
+ When I am logged in as "author"
+ And I view the unreviewed comments page for "Moderation"
+ # The following won't work until deleting comments without javascript is fixed
+ # And I delete the comment
+ # Then I should see "Comment deleted"
+ # And I should not see "Test comment"
+ # And I should see "No unreviewed comments"
+
+ Scenario: Moderation should work on threaded comments
+ Given the moderated work "Moderation" by "author"
+ And I am logged in as "author"
+ And I post the comment "Test comment" on the work "Moderation"
+ When I am logged in as "commenter"
+ And I view the work "Moderation"
+ And I follow "Comments (1)"
+ And I follow "Reply" within ".odd"
+ And I fill in "Comment" with "A moderated reply" within ".odd"
+ And I press "Comment" within ".odd"
+ Then I should see "It will appear publicly"
+ And I should see "A moderated reply"
+ And I should not see "Test comment"
+ When I am logged in as "author"
+ And I view the unreviewed comments page for "Moderation"
+ Then I should see "A moderated reply"
+ When I press "Approve"
+ Then I should see "Comment approved"
+ When I view the work "Moderation"
+ And I follow "Comments (2)"
+ Then I should see "A moderated reply" within ".even"
+
+ Scenario: The author cannot reply to unapproved comments
+ Given the moderated work "Moderation" by "author"
+ And I am logged in as "commenter"
+ And I post the comment "Test comment" on the work "Moderation"
+ When I am logged in as "author"
+ And I view the unreviewed comments page for "Moderation"
+ Then I should not see "Reply"
+
+ Scenario: The commenter can edit their unapproved comment
+ Given the moderated work "Moderation" by "author"
+ And I am logged in as "commenter"
+ And I set my preferences to turn on copies of my own comments
+ And I post the comment "Test comment" on the work "Moderation"
+ Then "commenter" should be emailed
+ And the email to "commenter" should contain "will not appear until approved"
+ When I visit the thread for the comment on "Moderation"
+ Then I should see "Test comment"
+ And I should see "Delete"
+ When I edit a comment
+ Then I should see "Comment was successfully updated"
+
+ Scenario: Users should not see unapproved replies to their own comments
+ Given the moderated work "Moderation" by "author" with the approved comment "Test comment" by "commenter"
+ And I am logged in as "new_commenter"
+ And I set my preferences to turn on copies of my own comments
+ When I view the work "Moderation"
+ And I follow "Comments (1)"
+ And I follow "Reply" within ".odd"
+ And I fill in "Comment" with "A moderated reply" within ".odd"
+ And I press "Comment" within ".odd"
+ # emails should only be delivered to author and new_commenter
+ Then "author" should be emailed
+ And "new_commenter" should be emailed
+ And "commenter" should not be emailed
+ When all emails have been delivered
+ And I am logged in as "commenter"
+ And I set my preferences to turn on copies of my own comments
+ And I go to commenter's inbox page
+ Then I should not see "A moderated reply"
+ When I view the work "Moderation"
+ And I follow "Comments (1)"
+ Then I should see "Test comment"
+ And I should not see "A moderated reply"
+ When I am logged in as "author"
+ And I view the unreviewed comments page for "Moderation"
+ And I press "Approve"
+ Then "commenter" should be emailed
+ And "author" should not be emailed
+ And "new_commenter" should not be emailed
+ When I am logged in as "commenter"
+ And I go to commenter's inbox page
+ Then I should see "A moderated reply"
+
+ Scenario: When I turn off moderation, comments stay unreviewed
+ Given the moderated work "Moderation" by "author"
+ And I am logged in as "commenter"
+ And I post the comment "Interesting Comment" on the work "Moderation"
+ When I am logged in as "author"
+ And I edit the work "Moderation"
+ And I uncheck "Enable comment moderation"
+ And I post the work without preview
+ Then comment moderation should not be enabled on "Moderation"
+ When I view the work "Moderation"
+ Then I should see "Unreviewed Comments"
+ And I should not see "Comments:1"
+ When I go to author's inbox page
+ Then I should not see "Reply"
+ When I am logged in as "commenter"
+ And I view the work "Moderation"
+ Then I should not see "has chosen to moderate comments"
+ And I should not see "Interesting Comment"
+ When I post the comment "New Comment" on the work "Moderation"
+ And I view the work "Moderation"
+ Then I should see "Comments:1"
+ When I follow "Comments (1)"
+ Then I should see "New Comment"
+ And I should not see "Interesting Comment"
+
+ Scenario: When an approved comment is edited significantly it gets moderated again
+ Given the moderated work "Moderation" by "author"
+ And I am logged in as "commenter"
+ And I post the comment "Interesting Comment" on the work "Moderation"
+ And I am logged in as "author"
+ And I view the unreviewed comments page for "Moderation"
+ And I press "Approve"
+ When it is currently 1 second from now
+ And I am logged in as "commenter"
+ And I view the work "Moderation"
+ And I follow "Comments (1)"
+ And I follow "Edit"
+ And I fill in "Comment" with "Interesting Commentary"
+ And I press "Update"
+ When I reload the comments on "Moderation"
+ And I view the work "Moderation"
+ Then I should see "Comments (1)"
+ When I follow "Comments (1)"
+ Then I should see "Interesting Commentary"
+ When it is currently 1 second from now
+ And I follow "Edit"
+ And I fill in "Comment" with "AHAHAHA LOOK I HAVE TOTALLY CHANGED IT"
+ And it is currently 1 second from now
+ And I press "Update"
+ Then the comment on "Moderation" should be marked as unreviewed
+ And I should not see "Comments:"
+ And I should not see "Comments (1)"
+
+ Scenario: I can approve multiple comments at once
+ Given the moderated work "Moderation" by "author"
+ And I am logged in as "commenter"
+ And I post the comment "One Comment" on the work "Moderation"
+ And I post the comment "Two Comment" on the work "Moderation"
+ And I post the comment "Three Comment" on the work "Moderation"
+ And I post the comment "Four Comment" on the work "Moderation"
+ When I am logged in as "author"
+ And I view the unreviewed comments page for "Moderation"
+ And I press "Approve All"
+ Then I should see "All moderated comments approved."
+ When I view the work "Moderation"
+ Then I should see "Comments (4)"
+
+ Scenario: I can view the parent thread of an unreviewed comment
+ Given the moderated work "Moderation" by "author" with the approved comment "Test comment" by "commenter"
+ And I am logged in as "new_commenter"
+ When I view the work "Moderation"
+ And I follow "Comments (1)"
+ And I follow "Reply" within ".odd"
+ And I fill in "Comment" with "A moderated reply" within ".odd"
+ And I press "Comment" within ".odd"
+ When I am logged in as "author"
+ And I view the work "Moderation"
+ And I follow "Unreviewed Comments (1)"
+ And I follow "Parent Thread"
+ Then I should see "Test comment"
+ When I view the unreviewed comments page for "Moderation"
+ And I press "Approve"
+ When I am logged in as "new_commenter"
+ And I post the comment "Zero-depth comment" on the work "Moderation"
+ When I am logged in as "author"
+ And I view the work "Moderation"
+ And I follow "Unreviewed Comments (1)"
+ Then I should not see "Parent Thread"
diff --git a/features/comments_and_kudos/comments_adminposts.feature b/features/comments_and_kudos/comments_adminposts.feature
new file mode 100755
index 0000000..1cf460f
--- /dev/null
+++ b/features/comments_and_kudos/comments_adminposts.feature
@@ -0,0 +1,142 @@
+@comments
+Feature: Commenting on admin posts
+ As a user
+ I want to comment on admin posts
+ In order to communicate with admins and other users
+
+ Scenario: Random user comments on an admin post
+ Given I have posted an admin post
+ And I am logged in as "regular"
+ And all emails have been delivered
+ When I comment on an admin post
+ Then "regular" should not be emailed
+
+ Scenario: A user who receives copies of their own comments comments on an admin post
+ Given I have posted an admin post
+ And I am logged in as "narcis"
+ And I set my preferences to turn on copies of my own comments
+ And all emails have been delivered
+ When I comment on an admin post
+ Then 1 email should be delivered to "narcis"
+
+ Scenario: Random user edits a comment on an admin post
+ Given I have posted an admin post
+ And I am logged in as "regular"
+ And I comment on an admin post
+ And all emails have been delivered
+ When I edit a comment
+ Then "regular" should not be emailed
+
+ Scenario: A user who receives copies of their own comments edits a comment on an admin post
+ Given I have posted an admin post
+ And I am logged in as "narcis"
+ And I set my preferences to turn on copies of my own comments
+ And I comment on an admin post
+ And all emails have been delivered
+ When I edit a comment
+ Then 1 email should be delivered to "narcis"
+
+ Scenario: Admin post with comments disabled
+ Given I have posted an admin post
+ And I am logged in as a "communications" admin
+ When I go to the admin-posts page
+ And I follow "Edit"
+ Then I should see "Who can comment on this"
+ And I should see "No one can comment"
+ When I choose "No one can comment"
+ And I press "Post"
+ Then I should see "successfully updated"
+ When I follow "Edit Post"
+ Then the "No one can comment" radio button should be checked
+ When I am logged out
+ And I go to the admin-posts page
+ And I follow "Default Admin Post"
+ Then I should see "Sorry, this news post doesn't allow comments."
+ When I am logged in as "regular"
+ And I go to the admin-posts page
+ And I follow "Default Admin Post"
+ Then I should see "Sorry, this news post doesn't allow comments."
+
+ Scenario: Admin post with comments restricted to Archive users
+ Given I have posted an admin post
+ And I am logged in as a "communications" admin
+ When I go to the admin-posts page
+ And I follow "Edit"
+ Then I should see "Who can comment on this"
+ And I should see "Only registered users can comment"
+ When I choose "Only registered users can comment"
+ And I press "Post"
+ Then I should see "successfully updated"
+ When I follow "Edit Post"
+ Then the "Only registered users can comment" radio button should be checked
+ When I am logged out
+ And I go to the admin-posts page
+ And I follow "Default Admin Post"
+ Then I should see "Sorry, this news post doesn't allow non-Archive users to comment."
+ And I should see "You can however contact Support with any feedback or questions."
+ When I follow "contact Support"
+ Then I should be on the support page
+ When I am logged in as "regular"
+ And I go to the admin-posts page
+ And I follow "Default Admin Post"
+ And I fill in "comment[comment_content]" with "zug zug"
+ And I press "Comment"
+ Then I should see "Comment created!"
+ And I should see "zug zug"
+
+ Scenario: Admin post with all comments enabled
+ Given I have posted an admin post
+ And I am logged in as a "communications" admin
+ When I go to the admin-posts page
+ And I follow "Edit"
+ Then I should see "Who can comment on this"
+ And I should see "Registered users and guests can comment"
+ When I choose "Registered users and guests can comment"
+ And I press "Post"
+ Then I should see "successfully updated"
+ When I follow "Edit Post"
+ Then the "Registered users and guests can comment" radio button should be checked
+ When I am logged out
+ And I go to the admin-posts page
+ And I follow "Default Admin Post"
+ And I fill in "comment[name]" with "tester"
+ And I fill in "comment[email]" with "tester@example.com"
+ And I fill in "comment[comment_content]" with "guz guz"
+ And I press "Comment"
+ Then I should see "Comment created!"
+ And I should see "guz guz"
+ When I am logged in as "regular"
+ And I go to the admin-posts page
+ And I follow "Default Admin Post"
+ And I fill in "comment[comment_content]" with "zug zug"
+ And I press "Comment"
+ Then I should see "Comment created!"
+ And I should see "zug zug"
+
+ Scenario: Modifying the comment permissions of an admin post with translations
+ Given I have posted an admin post
+ And basic languages
+ And I am logged in as a "translation" admin
+ And I make a translation of an admin post
+ When I follow "Back to AO3 News Index"
+ And I follow "Show"
+ Then I should see "Sorry, this news post doesn't allow non-Archive users to comment."
+ When I follow "Deutsch"
+ Then I should see "Sorry, this news post doesn't allow non-Archive users to comment."
+ When I follow "Back to AO3 News Index"
+ And I follow "Edit"
+ And I choose "Registered users and guests can comment"
+ And I press "Post"
+ Then I should see "Please log out of your admin account to comment."
+ When I follow "Deutsch"
+ Then I should see "Please log out of your admin account to comment."
+
+ Scenario: Translation of admin post with comments disabled
+ Given I have posted an admin post with comments disabled
+ And basic languages
+ And I am logged in as a "translation" admin
+ When I make a translation of an admin post
+ Then I should see "Sorry, this news post doesn't allow comments."
+ When I follow "Edit Post"
+ Then I should see "No one can comment"
+ # TODO: Test that the other options aren't available/selected in a non-brittle way
diff --git a/features/comments_and_kudos/comments_delete.feature b/features/comments_and_kudos/comments_delete.feature
new file mode 100755
index 0000000..bfd1a22
--- /dev/null
+++ b/features/comments_and_kudos/comments_delete.feature
@@ -0,0 +1,96 @@
+@comments
+Feature: Delete a comment
+ In order to remove a comment from public view
+ As a user
+ I want to be able to delete a comment I added
+ As an author
+ I want to be able to delete a comment a reader added to my work
+
+ Scenario: User deletes a comment they added to a work
+ When I am logged in as "author"
+ And I post the work "Awesome story"
+ When I am logged in as "commenter"
+ And I post the comment "Fail comment" on the work "Awesome story"
+ And I delete the comment
+ Then I should see "Comment deleted."
+ And I should not see "Comments:"
+ And I should not see a link "Hide Comments (1)"
+
+ Scenario: User deletes a comment they added to a work and which is the parent of another comment
+ When I am logged in as "author"
+ And I post the work "Awesome story"
+ When I am logged in as "commenter1"
+ And I post the comment "Fail comment" on the work "Awesome story"
+ And I reply to a comment with "I didn't mean that"
+ And I delete the comment
+ Then I should see "Comment deleted."
+ And I should see "(Previous comment deleted.)"
+ And I should see "I didn't mean that"
+ And I should see "Comments:1"
+ And I should see a link "Hide Comments (1)"
+
+ Scenario: Author deletes a comment another user added to their work
+ When I am logged in as "author"
+ And I post the work "Awesome story"
+ When I am logged in as "commenter"
+ And I post the comment "Fail comment" on the work "Awesome story"
+ When I am logged in as "author"
+ And I view the work "Awesome story" with comments
+ And I delete the comment
+ Then I should see "Comment deleted."
+ And I should not see "Comments:"
+ And I should not see a link "Hide Comments (1)"
+
+ Scenario: Author deletes a parent comment that another user added to their work
+ When I am logged in as "author"
+ And I post the work "Awesome story"
+ When I am logged in as "commenter"
+ And I post the comment "Fail comment" on the work "Awesome story"
+ And I reply to a comment with "I didn't mean that"
+ When I am logged in as "author"
+ And I view the work "Awesome story" with comments
+ And I delete the comment
+ Then I should see "Comment deleted."
+ And I should see "(Previous comment deleted.)"
+ And I should see "I didn't mean that"
+ And I should see "Comments:1"
+ And I should see a link "Hide Comments (1)"
+
+ Scenario: Deleting higher-level comments in a deep comment thread should still allow readers to access the deeper comments.
+
+ Given the work "Testing"
+ And I am logged in as "commenter"
+
+ When "commenter" posts a deeply nested comment thread on "Testing"
+ And I view the work "Testing" with comments
+ Then I should see "(2 more comments in this thread)"
+ And I should not see the deeply nested comments
+
+ When I delete all visible comments on "Testing"
+ And I view the work "Testing" with comments
+ Then I should see "(Previous comment deleted.)"
+ And I should see "(2 more comments in this thread)"
+ And I should not see the deeply nested comments
+
+ When I follow "2 more comments in this thread"
+ Then I should see the deeply nested comments
+
+ Scenario: Deleting a comment followed by its reply should hide the deleted comment placeholder.
+
+ Given the work "Amazing Story"
+ And I am logged in as "commenter"
+ And I post the comment "I love it!" on the work "Amazing Story"
+ And I reply to a comment with "Is there going to be a sequel?"
+ When I delete the comment
+ And I delete the reply comment
+ Then I should not see "(Previous comment deleted.)"
+
+ Scenario: Deleting a reply comment followed by its parent should hide the deleted comment placeholder.
+
+ Given the work "Amazing Story"
+ And I am logged in as "commenter"
+ And I post the comment "I love it!" on the work "Amazing Story"
+ And I reply to a comment with "Is there going to be a sequel?"
+ When I delete the reply comment
+ And I delete the comment
+ Then I should not see "(Previous comment deleted.)"
diff --git a/features/comments_and_kudos/comments_pagination.feature b/features/comments_and_kudos/comments_pagination.feature
new file mode 100755
index 0000000..25879a6
--- /dev/null
+++ b/features/comments_and_kudos/comments_pagination.feature
@@ -0,0 +1,36 @@
+@comments
+Feature: Comments should be paginated
+
+Scenario: One-chapter work with many comments
+ Given the work with 34 comments setup
+ When I view the work "Blabla"
+ And I follow "Comments"
+ Then I should see "2" within ".pagination"
+ When I follow "Next" within ".pagination"
+ Then I should see "1" within ".pagination"
+
+Scenario: Multi-chapter work with many comments per chapter
+
+ Given the chaptered work with 6 chapters with 50 comments "Epic WIP"
+ When I view the work "Epic WIP"
+ Then I should see "Comments (50)"
+ When I follow "Comments"
+ Then I should see "2" within ".pagination"
+ And I should not see "3" within ".pagination"
+ When I follow "Next" within ".pagination"
+ Then I should see "1" within ".pagination"
+ # All those comments were on the first chapter. Now put some more on
+ When I am logged in
+ And I view the work "Epic WIP"
+ And I view the 3rd chapter
+ And I post a comment "The third chapter is especially good."
+ And I post a comment "I loved the cliffhanger in chapter 3"
+ Then I should see "Comments (2)"
+ # Going to the work shows first chapter and only those comments
+ When I view the work "Epic WIP"
+ Then I should see "Comments (50)"
+ # Entire work shows all comments
+ When I follow "Entire Work"
+ Then I should see "Comments (52)"
+ When I follow "Comments (52)"
+ Then I should see "3" within ".pagination"
diff --git a/features/comments_and_kudos/comments_redirect.feature b/features/comments_and_kudos/comments_redirect.feature
new file mode 100755
index 0000000..ec20253
--- /dev/null
+++ b/features/comments_and_kudos/comments_redirect.feature
@@ -0,0 +1,119 @@
+@comments
+Feature: Posting a comment should result in a friendly redirect
+
+# TOP LEVEL COMMENTS
+
+Scenario: Posting a top level comment on a one-chapter work
+ Given I have a work "Blabla"
+ And I am logged in as a random user
+ When I view the work "Blabla"
+ And I post a comment "blaaaaaa"
+ Then I should see "Blabla"
+ And I should see "blaaaaaa"
+
+Scenario: Posting top level comment on middle chapter of chaptered work, with default preferences
+ Given the chaptered work setup
+ And I am logged in as a random user
+ When I view the work "BigBang"
+ And I view the 2nd chapter
+ And I post a comment "Woohoo"
+ Then I should see "Woohoo"
+ And I should see "Chapter 2" within "div#chapters"
+ And I should not see "Chapter 1" within "div#chapters"
+
+Scenario: Posting top level comment on a chaptered work, while in temporary view full mode, with default preferences
+ Given the chaptered work setup
+ And I am logged in as a random user
+ When I view the work "BigBang" in full mode
+ And I post a comment "Woohoo"
+ Then I should see "Woohoo"
+ And I should see "Chapter 2" within "div#chapters"
+ And I should see "Chapter 3" within "div#chapters"
+
+Scenario: Posting top level comment on a chaptered work, with view full work in the preferences
+ Given the chaptered work setup
+ And I am logged in as a random user
+ And I set my preferences to View Full Work mode by default
+ When I view the work "BigBang"
+ And I post a comment "Woohoo"
+ Then I should see "Woohoo"
+ And I should see "Chapter 2" within "div#chapters"
+ And I should see "Chapter 3" within "div#chapters"
+
+ Scenario: Posting top level comment on a middle chapter, while in temporary view by chapter mode, with view full work in the preferences
+ Given the chaptered work setup
+ And I am logged in as a random user
+ And I set my preferences to View Full Work mode by default
+ When I view the work "BigBang" in chapter-by-chapter mode
+ And I view the 2nd chapter
+ And I post a comment "Woohoo"
+ Then I should see "Woohoo"
+ And I should see "Chapter 2" within "div#chapters"
+ # Once you've commented, it defaults back to your preference
+ And I should see "Chapter 1" within "div#chapters"
+
+# REPLY COMMENTS
+
+Scenario: Posting a reply comment to a comment on a one-chapter work
+ Given the work with comments setup
+ And I am logged in as a random user
+ And I view the work "Blabla"
+ And I follow "Comments"
+ When I reply to a comment with "Supercalifragelistic"
+ Then I should see "Supercalifragelistic"
+
+Scenario: Posting a reply comment to a comment on the first chapter, with default preferences
+ Given the chaptered work with comments setup
+ And I am logged in as a random user
+ And I view the work "BigBang"
+ And I view the 1st chapter
+ And I follow "Comments"
+ When I reply to a comment with "Supercalifragelistic"
+ Then I should see "Supercalifragelistic"
+
+Scenario: Posting a reply comment to a comment on a middle chapter, with default preferences
+ Given the chaptered work with comments setup
+ And I am logged in as a random user
+ And I view the work "BigBang"
+ And I view the 2nd chapter
+ And I follow "Comments"
+ When I reply to a comment with "Supercalifragelistic"
+ Then I should see "Supercalifragelistic"
+ And I should see "Chapter 2" within "div#chapters"
+ And I should not see "Chapter 1" within "div#chapters"
+
+Scenario: Posting reply comment on a chaptered work, while in temporary view full mode, with default preferences
+ Given the chaptered work with comments setup
+ And I am logged in as a random user
+ And I view the work "BigBang" in full mode
+ And I follow "Comments"
+ Then I should see "Chapter 2" within "div#chapters"
+ When I reply to a comment with "Supercalifragelistic"
+ Then I should see "Supercalifragelistic"
+ And I should see "Chapter 2" within "div#chapters"
+ And I should see "Chapter 3" within "div#chapters"
+
+Scenario: Posting reply comment on a chaptered work, with view full work in the preferences
+ Given the chaptered work with comments setup
+ And I am logged in as a random user
+ And I set my preferences to View Full Work mode by default
+ And I view the work "BigBang"
+ And I follow "Comments"
+ When I reply to a comment with "Supercalifragelistic"
+ Then I should see "Supercalifragelistic"
+ And I should see "Chapter 2" within "div#chapters"
+ And I should see "Chapter 3" within "div#chapters"
+
+Scenario: Posting top level comment on a middle chapter, while in temporary view by chapter mode, with view full work in the preferences
+ Given the chaptered work with comments setup
+ And I am logged in as a random user
+ And I set my preferences to View Full Work mode by default
+ And I view the work "BigBang" in chapter-by-chapter mode
+ And I view the 2nd chapter
+ # this opens comments on that chapter only
+ And I follow "Comments"
+ When I reply to a comment with "Supercalifragelistic"
+ Then I should see "Supercalifragelistic"
+ And I should see "Chapter 2" within "div#chapters"
+ # Once you've commented, it defaults back to your preference
+ And I should see "Chapter 1" within "div#chapters"
diff --git a/features/comments_and_kudos/frozen_comments.feature b/features/comments_and_kudos/frozen_comments.feature
new file mode 100755
index 0000000..bf4fe9e
--- /dev/null
+++ b/features/comments_and_kudos/frozen_comments.feature
@@ -0,0 +1,47 @@
+@comments
+Feature: Comment freezing
+
+ Scenario: Freezing a comment removes the reply option and adds an indicator
+ the comment is frozen. Unfreezing returns the reply option and removes the
+ indicator. This behavior is present on the comment itself, the inbox, and the
+ inbox module on the homepage.
+
+ Given I am logged in as "author"
+ And I post the work "Popular Fic"
+ And I am logged out
+ And I am logged in as "commenter"
+ And I post the comment "My test comment!" on the work "Popular Fic"
+ And I am logged out
+
+ When I am logged in as "author"
+ And I view the work "Popular Fic" with comments
+ And I press "Freeze Thread"
+ Then I should see "Comment thread successfully frozen!"
+ And I should see "Frozen" within "#comments_placeholder .comment ul.actions"
+ And I should not see "Reply" within "#comments_placeholder .comment ul.actions"
+
+ When I go to the homepage
+ Then I should see "My test comment!" within "div.messages .comment"
+ And I should see "Frozen" within "div.messages .comment ul.actions"
+ And I should not see "Reply" within "div.messages .comment ul.actions"
+
+ When I go to author's inbox page
+ Then I should see "My test comment!" within "#inbox-form .comment"
+ And I should see "Frozen" within "#inbox-form .comment ul.actions"
+ And I should not see "Reply" within "#inbox-form .comment ul.actions"
+
+ When I view the work "Popular Fic" with comments
+ And I press "Unfreeze Thread"
+ Then I should see "Comment thread successfully unfrozen!"
+ And I should see "Reply" within "#comments_placeholder .comment ul.actions"
+ And I should not see "Frozen" within "#comments_placeholder .comment ul.actions"
+
+ When I go to the homepage
+ Then I should see "My test comment!" within "div.messages .comment"
+ And I should see "Reply" within "div.messages .comment ul.actions"
+ And I should not see "Frozen" within "div.messages .comment ul.actions"
+
+ When I go to author's inbox page
+ Then I should see "My test comment!" within "#inbox-form .comment"
+ And I should see "Reply" within "#inbox-form .comment ul.actions"
+ And I should not see "Frozen" within "#inbox-form .comment ul.actions"
diff --git a/features/comments_and_kudos/guest_comment_replies.feature b/features/comments_and_kudos/guest_comment_replies.feature
new file mode 100755
index 0000000..48fae1d
--- /dev/null
+++ b/features/comments_and_kudos/guest_comment_replies.feature
@@ -0,0 +1,70 @@
+Feature: Disallowing guest comment replies
+
+ Scenario Outline: Guests cannot reply to a user who has guest comments off on news posts and other users' works
+ Given
+ And with guest comments enabled
+ And the user "commenter" turns off guest comment replies
+ And a comment "OMG!" by "commenter" on
+ When I view with comments
+ Then I should see a "Comment" button
+ But I should not see a link "Reply"
+ When I am logged in as "reader"
+ And I view with comments
+ Then I should see a "Comment" button
+ And I should see a link "Reply"
+
+ Examples:
+ | commentable |
+ | the work "Aftermath" |
+ | the admin post "Change Log" |
+
+ Scenario: Guests can reply to a user who has guest comments off on their own work
+ Given the work "Aftermath" by "creator" with guest comments enabled
+ And the user "creator" turns off guest comment replies
+ And a comment "OMG!" by "creator" on the work "Aftermath"
+ When I view the work "Aftermath" with comments
+ Then I should see a "Comment" button
+ And I should see a link "Reply"
+ When I am logged in as "reader"
+ And I view the work "Aftermath" with comments
+ Then I should see a "Comment" button
+ And I should see a link "Reply"
+
+ Scenario: Guests can reply to a user who has guest comments off on works co-created by the user
+ Given the user "nemesis" turns off guest comment replies
+ And the work "Aftermath" by "creator" and "nemesis" with guest comments enabled
+ And a comment "OMG!" by "nemesis" on the work "Aftermath"
+ When I view the work "Aftermath" with comments
+ Then I should see a "Comment" button
+ And I should see a link "Reply"
+ When I am logged in as "reader"
+ And I view the work "Aftermath" with comments
+ Then I should see a "Comment" button
+ And I should see a link "Reply"
+
+ Scenario: Users can reply to a user who has guest comments off on tags
+ Given the following activated tag wranglers exist
+ | login |
+ | commenter |
+ | wrangler |
+ And a canonical fandom "Controversial"
+ And the user "commenter" turns off guest comment replies
+ And a comment "OMG!" by "commenter" on the tag "Controversial"
+ When I am logged in as "wrangler"
+ And I view the tag "Controversial" with comments
+ And I follow "Reply"
+ And I fill in "Comment" with "Ugh." within ".odd"
+ And I press "Comment" within ".odd"
+ Then I should see "Comment created!"
+
+ Scenario: Guests can reply to guests
+ Given the work "Aftermath"
+ And the work "Aftermath" with guest comments enabled
+ And a guest comment on the work "Aftermath"
+ When I view the work "Aftermath" with comments
+ Then I should see a "Comment" button
+ And I should see a link "Reply"
+ When I am logged in as "reader"
+ And I view the work "Aftermath" with comments
+ Then I should see a "Comment" button
+ And I should see a link "Reply"
diff --git a/features/comments_and_kudos/guest_comments.feature b/features/comments_and_kudos/guest_comments.feature
new file mode 100755
index 0000000..0a4cd50
--- /dev/null
+++ b/features/comments_and_kudos/guest_comments.feature
@@ -0,0 +1,45 @@
+@comments
+Feature: Read guest comments
+ In order to tell guest comments from logged-in users' comments
+ As a user
+ I'd like to see the "guest" sign
+
+Scenario: View guest comments in homepage, inbox and works
+ Given I am logged in as "normal_user"
+ And I post the work "My very meta work about AO3" with guest comments enabled
+ And I am logged out
+ When I post a guest comment
+ Then I should see "(Guest)"
+ When I am logged in as "normal_user"
+ And I go to the home page
+ Then I should see "(Guest)"
+ When I follow "My Inbox"
+ Then I should see "(Guest)"
+ When I view the work "My very meta work about AO3" with comments
+ Then I should see "(Guest)"
+
+Scenario: View logged-in comments in homepage, inbox and works
+ Given I am logged in as "normal_user"
+ And I post the work "My very meta work about AO3"
+ And I am logged in as "logged_in_user"
+ When I post the comment ":)))))))" on the work "My very meta work about AO3"
+ Then I should not see "(Guest)"
+ When I am logged in as "normal_user"
+ And I go to the home page
+ Then I should not see "(Guest)"
+ When I follow "My Inbox"
+ Then I should not see "(Guest)"
+ When I view the work "My very meta work about AO3" with comments
+ Then I should not see "(Guest)"
+
+Scenario: Guest comments with embedded images are rendered as plain text
+ Given I am logged in as "normal_user"
+ And I post the work "foobar" with guest comments enabled
+ And I am logged out
+ When I view the work "foobar"
+ And I post a guest comment "Hello "
+ Then I should see "Hello img src="
+ And I should see "https://example.com/image.jpg"
+ And I should see "alt="
+ And I should see "baz"
+ But I should not see the image "src" text "https://example.com/image.jpg"
diff --git a/features/comments_and_kudos/hidden_comments.feature b/features/comments_and_kudos/hidden_comments.feature
new file mode 100755
index 0000000..819f0b6
--- /dev/null
+++ b/features/comments_and_kudos/hidden_comments.feature
@@ -0,0 +1,88 @@
+@comments
+Feature: Comment hiding
+
+ Scenario: Hiding a comment replaces it with a placeholder message.
+ Given I am logged in as "author"
+ And I post the work "Popular Fic"
+ And I am logged out
+ And I am logged in as "commenter"
+ And I post the comment "A suspicious comment" on the work "Popular Fic"
+ And I am logged out
+
+ # Delay to make sure the cache is expired when the comment is hidden:
+ When it is currently 1 second from now
+ And I am logged in as a super admin
+ And I view the work "Popular Fic" with comments
+ And I press "Hide Comment"
+ Then I should see "Comment successfully hidden!"
+ And I should see "This comment has been hidden by an admin."
+ And I should see "A suspicious comment"
+ And I should not see "This comment is under review by an admin and is currently unavailable."
+ When I go to the admin-activities page
+ Then I should see 1 admin activity log entry
+ And I should see "hide comment"
+
+ When I am logged in as "author"
+ And I go to the home page
+ Then I should see "Find your favorites"
+ And I should not see "Unread messages"
+ And I should not see "This comment is under review by an admin and is currently unavailable."
+ And I should not see "A suspicious comment"
+ And I should not see "This comment has been hidden by an admin."
+ When I go to author's inbox page
+ Then I should not see "This comment is under review by an admin and is currently unavailable."
+ And I should not see "A suspicious comment"
+ And I should not see "This comment has been hidden by an admin."
+ When I view the work "Popular Fic" with comments
+ Then I should see "This comment is under review by an admin and is currently unavailable."
+ And I should not see "A suspicious comment"
+ And I should not see "This comment has been hidden by an admin."
+ And I should not see a "Make Comment Visible" button
+
+ When I am logged in as "commenter"
+ And I view the work "Popular Fic" with comments
+ Then I should see "A suspicious comment"
+ And I should see "This comment has been hidden by an admin."
+
+ When I am logged in as a super admin
+ And I view the work "Popular Fic" with comments
+ And I press "Make Comment Visible"
+ Then I should see "Comment successfully unhidden!"
+ When I go to the admin-activities page
+ Then I should see 2 admin activity log entry
+ And I should see "unhide comment"
+
+ When I am logged in as "author"
+ And I go to the home page
+ Then I should see "A suspicious comment"
+ And I follow "My Inbox"
+ Then I should see "A suspicious comment"
+ And I view the work "Popular Fic" with comments
+ Then I should see "A suspicious comment"
+ And I should not see a "Hide Comment" button
+
+ When I am logged in as "commenter"
+ And I view the work "Popular Fic" with comments
+ Then I should see "A suspicious comment"
+ And I should not see "This comment has been hidden by an admin."
+
+ Scenario: Embedded images in hidden comments are replaced with their URLs.
+ Given the work "Popular Fic"
+ And I am logged in as "commenter"
+ And I post the comment "OMG! " on the work "Popular Fic"
+
+ # Delay to make sure the cache is expired when the comment is hidden:
+ When it is currently 1 second from now
+ And I am logged in as a super admin
+ And I view the work "Popular Fic" with comments
+ Then I should see the image "src" text "https://example.com/image.jpg"
+ And I should not see "OMG! img src="
+ And I press "Hide Comment"
+ Then I should see "Comment successfully hidden!"
+ Then I should not see the image "src" text "https://example.com/image.jpg"
+ And I should see "OMG! img src="
+ And I should see "https://example.com/image.jpg"
+ Then I press "Make Comment Visible"
+ And I should see "Comment successfully unhidden!"
+ Then I should see the image "src" text "https://example.com/image.jpg"
+ And I should not see "OMG! img src="
diff --git a/features/comments_and_kudos/hidden_works.feature b/features/comments_and_kudos/hidden_works.feature
new file mode 100755
index 0000000..e5e2ea5
--- /dev/null
+++ b/features/comments_and_kudos/hidden_works.feature
@@ -0,0 +1,78 @@
+Feature: Comments on Hidden Works
+
+ Scenario: When a work is hidden, admins and the creator can see (but not edit or add) comments, while everyone else is redirected.
+ Given I am logged in as "creator"
+ And I post the work "To Be Hidden" with guest comments enabled
+ And I post the comment "Can I change this?" on the work "To Be Hidden"
+ And I am logged in as "commenter"
+ And I post the comment "Do you see?" on the work "To Be Hidden"
+ And I am logged in as a "policy_and_abuse" admin
+ And I view the work "To Be Hidden"
+ And I follow "Hide Work"
+
+ # As an admin
+ When I go to the work comments page for "To Be Hidden"
+ Then I should see "Do you see?"
+ But I should not see "Reply"
+ And I should not see "Post Comment"
+ And I should see "Sorry, you can't add or edit comments on a hidden work."
+
+ When I am logged in as "creator"
+ And I go to the work comments page for "To Be Hidden"
+ Then I should see "Do you see?"
+ And I should see "Can I change this?"
+ But I should not see "Reply"
+ And I should not see "Post Comment"
+ And I should not see "Edit"
+ And I should see "Sorry, you can't add or edit comments on a hidden work."
+
+ When I am logged in as "commenter"
+ And I go to the work comments page for "To Be Hidden"
+ Then I should not see "Do you see?"
+ And I should not see "Sorry, you can't add or edit comments on a hidden work."
+ But I should see "Sorry, you don't have permission to access the page you were trying to reach."
+
+ When I am logged out
+ And I go to the work comments page for "To Be Hidden"
+ Then I should not see "Do you see?"
+ And I should not see "Sorry, you can't add or edit comments on a hidden work."
+ But I should see "Sorry, you don't have permission to access the page you were trying to reach."
+
+ Scenario: When a work is unrevealed, admins and the creator can see (but not edit or add) comments, while everyone else is redirected.
+ Given I am logged in as "creator"
+ And I post the work "Murder, She Wrote" with guest comments enabled
+ And I post the comment "Can I change this?" on the work "Murder, She Wrote"
+ And I am logged in as "commenter"
+ And I post the comment "Do you see?" on the work "Murder, She Wrote"
+ And I am logged out
+ And I have the hidden collection "Dreamboat"
+ And I am logged in as "creator"
+ And I edit the work "Murder, She Wrote" to be in the collection "Dreamboat"
+
+ # As the work's creator
+ When I go to the work comments page for "Murder, She Wrote"
+ Then I should see "Do you see?"
+ And I should see "Can I change this?"
+ But I should not see "Reply"
+ And I should not see "Post Comment"
+ And I should not see "Edit"
+ And I should see "Sorry, you can't add or edit comments on an unrevealed work."
+
+ When I am logged in as an admin
+ And I go to the work comments page for "Murder, She Wrote"
+ Then I should see "Do you see?"
+ But I should not see "Reply"
+ And I should not see "Post Comment"
+ And I should see "Sorry, you can't add or edit comments on an unrevealed work."
+
+ When I am logged in as "commenter"
+ And I go to the work comments page for "Murder, She Wrote"
+ Then I should not see "Do you see?"
+ And I should not see "Sorry, you can't add or edit comments on an unrevealed work."
+ But I should see "Sorry, you don't have permission to access the page you were trying to reach."
+
+ When I am logged out
+ And I go to the work comments page for "Murder, She Wrote"
+ Then I should not see "Do you see?"
+ And I should not see "Sorry, you can't add or edit comments on an unrevealed work."
+ But I should see "Sorry, you don't have permission to access the page you were trying to reach."
diff --git a/features/comments_and_kudos/image_safety_mode.feature b/features/comments_and_kudos/image_safety_mode.feature
new file mode 100755
index 0000000..9df878e
--- /dev/null
+++ b/features/comments_and_kudos/image_safety_mode.feature
@@ -0,0 +1,68 @@
+Feature: Image safety mode
+ In order to protect users
+ As a site owner
+ I'd like to control which comments can include images
+
+ Scenario Outline: Images are embedded in comments when image safety mode is off.
+ Given the setup for testing image safety mode on
+ And image safety mode is disabled for comments
+ When I view with comments
+ Then I should see the image "src" text "https://example.com/image.jpg"
+ And I should not see "OMG! img src="
+ When I go to the homepage
+ Then I should see the image "src" text "https://example.com/image.jpg"
+ And I should not see "OMG! img src="
+ When I go to commentrecip's inbox page
+ Then I should see the image "src" text "https://example.com/image.jpg"
+ When image safety mode is enabled for comments on a ""
+ And I view with comments
+ Then I should not see the image "src" text "https://example.com/image.jpg"
+ But I should see "OMG! img src="
+ And I should see "https://example.com/image.jpg"
+ When I go to the homepage
+ Then I should not see the image "src" text "https://example.com/image.jpg"
+ But I should see "OMG! img src="
+ And I should see "https://example.com/image.jpg"
+ When I go to commentrecip's inbox page
+ Then I should not see the image "src" text "https://example.com/image.jpg"
+ But I should see "OMG! img src="
+ And I should see "https://example.com/image.jpg"
+
+ Examples:
+ | commentable | parent_type |
+ | the admin post "Change Log" | AdminPost |
+ | the work "My Opus" | Chapter |
+ | the tag "No Fandom" | Tag |
+
+ Scenario Outline: Embedded images in comments are replaced with their URLs when image safety mode is enabled.
+ Given the setup for testing image safety mode on
+ And image safety mode is enabled for comments on a ""
+ When I view with comments
+ Then I should not see the image "src" text "https://example.com/image.jpg"
+ But I should see "OMG! img src="
+ And I should see "https://example.com/image.jpg"
+ When I go to the homepage
+ Then I should not see the image "src" text "https://example.com/image.jpg"
+ But I should see "OMG! img src="
+ And I should see "https://example.com/image.jpg"
+ When I go to commentrecip's inbox page
+ Then I should not see the image "src" text "https://example.com/image.jpg"
+ But I should see "OMG! img src="
+ And I should see "https://example.com/image.jpg"
+ When image safety mode is disabled for comments
+ And I view with comments
+ Then I should see the image "src" text "https://example.com/image.jpg"
+ And I should not see "OMG! img src="
+ When I go to the homepage
+ Then I should see the image "src" text "https://example.com/image.jpg"
+ And I should not see "OMG! img src="
+ When I go to commentrecip's inbox page
+ Then I should see the image "src" text "https://example.com/image.jpg"
+ And I should not see "OMG! img src="
+
+ Examples:
+ | parent_type | commentable |
+ | AdminPost | the admin post "Change Log" |
+ | Chapter | the work "My Opus" |
+ | Tag | the tag "No Fandom" |
+
diff --git a/features/comments_and_kudos/inbox.feature b/features/comments_and_kudos/inbox.feature
new file mode 100755
index 0000000..f94ade7
--- /dev/null
+++ b/features/comments_and_kudos/inbox.feature
@@ -0,0 +1,167 @@
+Feature: Get messages in the inbox
+ In order to stay informed about activity concerning my works and comments
+ As a user
+ I'd like to get messages in my inbox
+
+ Scenario: I should not receive comments in my inbox if I have set my preferences to "Turn off messages to your inbox about comments."
+ Given I am logged in as "boxer" with password "10987tko"
+ And the work "Another Round" by "boxer"
+ And I set my preferences to turn off messages to my inbox about comments
+ When I am logged in as "cutman"
+ And I post the comment "You should not receive this in your inbox." on the work "Another Round"
+ When I am logged in as "boxer" with password "10987tko"
+ And I go to boxer's inbox page
+ Then I should not see "cutman on Another Round"
+ And I should not see "You should not receive this in your inbox."
+
+ Scenario: I should receive comments in my inbox if I haven't set my preferences to "Turn off messages to your inbox about comments."
+ Given I am logged in as "boxer" with password "10987tko"
+ And the work "The Fight" by "boxer"
+ And I set my preferences to turn on messages to my inbox about comments
+ When I am logged in as "cutman"
+ And I post the comment "You should receive this in your inbox." on the work "The Fight"
+ When I am logged in as "boxer" with password "10987tko"
+ And I go to boxer's inbox page
+ Then I should see "cutman on The Fight"
+ And I should see "You should receive this in your inbox."
+
+ Scenario: Logged in comments in my inbox should have timestamps
+ Given the work "Down for the Count" by "boxer"
+ When I am logged in as "cutman"
+ And I post the comment "It was a right hook... with a bit of a jab. (And he did it with his left hand.)" on the work "Down for the Count"
+ When I am logged in as "boxer" with password "10987tko"
+ And I go to boxer's inbox page
+ Then I should see "cutman on Down for the Count"
+ And I should see "less than 1 minute ago"
+
+ Scenario: Inbox comments should display which chapter it's on, if and only if the work is multi-chapter
+ Given I am logged in as "author"
+ And I post the work "Single-chapter Work"
+ And I post the chaptered work "Multi-chapter Work"
+ And I set my preferences to turn on messages to my inbox about comments
+ When I am logged in as "commenter"
+ And I post the comment "You should receive this in your inbox." on the work "Single-chapter Work"
+ And I post the comment "And this one too." on the work "Multi-chapter Work"
+ When I am logged in as "author"
+ And I go to author's inbox page
+ Then I should see "on Single-chapter Work"
+ And I should not see "on Chapter 1 of Single-chapter Work"
+ And I should see "on Chapter 1 of Multi-chapter Work"
+
+ Scenario: Comments in my inbox should be filterable
+ Given the work "Down for the Count" by "boxer"
+ When I post the comment "The fight game's complex." on the work "Down for the Count" as a guest
+ When I am logged in as "boxer" with password "10987tko"
+ And I go to boxer's inbox page
+ And I choose "Show unread"
+ And I press "Filter"
+ Then I should see "guest (Guest) on Down for the Count"
+ And I should see "less than 1 minute ago"
+ When I choose "Show read"
+ And I press "Filter"
+ Then I should not see "guest (Guest) on Down for the Count"
+
+ Scenario: I can bulk edit comments in my inbox by clicking 'Select'
+ Given the work "The Fight" by "boxer"
+ When I am logged in as "cutman"
+ And I post the comment "You should receive this in your inbox." on the work "The Fight"
+ And I post the comment "A second message for your inbox!" on the work "The Fight"
+ When I am logged in as "boxer"
+ And I go to boxer's inbox page
+ Then I should see "cutman on The Fight"
+ And I should see "You should receive this in your inbox."
+ And I should see "A second message for your inbox!"
+ And I should see "Unread" within "li.comment:first-child"
+ And I should see "Unread" within "li.comment:last-child"
+ When I check "Select" within "li.comment:first-child"
+ And I check "Select" within "li.comment:last-child"
+ And I press "Mark Read"
+ Then I should not see "Unread"
+
+ Scenario: A user can see some of their unread comments on the homepage
+ Given the work "Pre-Fight Coverage" by "boxer"
+ When I am logged in as "cutman"
+ And I post the comment "That's a haymaker? I actually never knew that." on the work "Pre-Fight Coverage"
+ When I am logged in as "boxer" with password "10987tko"
+ And I go to the homepage
+ Then I should see "Unread messages"
+ And I should see "My Inbox"
+ And I should see "The latest unread items from your inbox."
+ And I should see "cutman on Pre-Fight Coverage"
+ And I should see "That's a haymaker? I actually never knew that."
+
+ Scenario: A user can delete an unread comment on the homepage
+ Given the work "The Gladiators of Old" by "boxer"
+ When I am logged in as "cutman"
+ And I post the comment "I can still make you cry, you know." on the work "The Gladiators of Old"
+ When I am logged in as "boxer" with password "10987tko"
+ And I go to the homepage
+ Then I should see "cutman on The Gladiators of Old"
+ And I should see "I can still make you cry, you know."
+ And I should see a "Delete" button
+ When I press "Delete"
+ Then I should see "Inbox successfully updated."
+ And I should be on the homepage
+ And I should not see "Unread messages"
+ And I should not see "My Inbox"
+ And I should not see "The latest unread items from your inbox."
+ And I should not see "cutman on the Gladiators of Old"
+ And I should not see "I can still make you cry, you know."
+
+ Scenario: A user can mark an unread comment read on the homepage
+ Given the work "Special Coverage" by "boxer"
+ When I am logged in as "cutman"
+ And I post the comment "Is there anything we can do to make the fight go longer?" on the work "Special Coverage"
+ When I am logged in as "boxer" with password "10987tko"
+ And I go to the homepage
+ Then I should see "cutman on Special Coverage"
+ And I should see "Is there anything we can do to make the fight go longer?"
+ And I should see a "Mark Read" button
+ When I press "Mark Read"
+ Then I should see "Inbox successfully updated."
+ And I should be on the homepage
+ And I should not see "Unread messages"
+ And I should not see "My Inbox"
+ And I should not see "The latest unread items from your inbox."
+ And I should not see "cutman on Special Coverage"
+ And I should not see "Is there anything we can do to make the fight go longer?"
+
+ Scenario: A user can reply to a comment from the home page without JavaScript
+ Given the work "Cat Thor's Bizarre Adventure" by "sewwiththeflo"
+ And I am logged in as "unbeatablesg"
+ And I post the comment "dude this is super great!!" on the work "Cat Thor's Bizarre Adventure"
+ When I am logged in as "sewwiththeflo"
+ And I go to the homepage
+ Then I should see "unbeatablesg on Cat Thor's Bizarre Adventure"
+ And I should see "dude this is super great!!"
+ And I should see a link "Reply"
+ When I reply to a comment with "Thank you! Please go to bed."
+ And I go to the homepage
+ Then I should not see "Unread messages"
+ And I should not see "dude this is super great!!"
+ When I am logged in as "unbeatablesg"
+ And I go to the homepage
+ Then I should see "sewwiththeflo on Cat Thor's Bizarre Adventure"
+ And I should see "Thank you! Please go to bed."
+
+ @javascript
+ Scenario: A user can reply to a comment from the home page
+ Given the work "Cat Thor's Bizarre Adventure" by "sewwiththeflo"
+ And I am logged in as "unbeatablesg"
+ And I post the comment "dude this is super great!!" on the work "Cat Thor's Bizarre Adventure"
+ When I am logged in as "sewwiththeflo"
+ And I go to the homepage
+ Then I should see "unbeatablesg on Cat Thor's Bizarre Adventure"
+ And I should see "dude this is super great!!"
+ And I should see a link "Reply"
+ When I follow "Reply" within ".latest.messages.module"
+ And I fill in "Comment" with "Thank you! Please go to bed." within "#reply-to-comment"
+ And I press "Comment" within "#reply-to-comment"
+ And "AO3-5877" is fixed
+ # Then I should be on the homepage
+ # And I should not see "Unread messages"
+ # And I should not see "dude this is super great!!"
+ When I am logged in as "unbeatablesg"
+ And I go to the homepage
+ Then I should see "sewwiththeflo on Cat Thor's Bizarre Adventure"
+ And I should see "Thank you! Please go to bed."
diff --git a/features/comments_and_kudos/kudos.feature b/features/comments_and_kudos/kudos.feature
new file mode 100755
index 0000000..8d8590e
--- /dev/null
+++ b/features/comments_and_kudos/kudos.feature
@@ -0,0 +1,261 @@
+Feature: Kudos
+ In order to show appreciation
+ As a reader
+ I want to leave kudos
+
+ Background:
+
+ Given the following activated users exist
+ | login | email |
+ | myname1 | myname1@foo.com |
+ | myname2 | myname2@foo.com |
+ | myname3 | myname3@foo.com |
+ And the work "Awesome Story" by "myname1"
+
+ Scenario: Leaving kudos
+
+ Given I am logged in as "myname2"
+ And all emails have been delivered
+ And I view the work "Awesome Story"
+ Then I should not see "left kudos on this work"
+
+ # Note: this step cannot be put into the steps file because of the heart character
+ When I press "Kudos ♥"
+ Then I should see "myname2 left kudos on this work!"
+ # make sure no emails go out until notifications are sent
+ And 0 emails should be delivered
+ When kudos are sent
+ Then 1 email should be delivered to "myname1@foo.com"
+ And the email should contain "myname2"
+ And the email should contain "left kudos"
+ And the email should contain "."
+ And all emails have been delivered
+ When I press "Kudos ♥"
+ Then I should see "You have already left kudos here. :)"
+ And I should not see "myname2 and myname2 left kudos on this work!"
+
+ When I am logged out
+ And I view the work "Awesome Story"
+ And I press "Kudos ♥"
+ Then I should see "Thank you for leaving kudos!"
+ And I should see "myname2 as well as 1 guest left kudos on this work!"
+ When I press "Kudos ♥"
+ Then I should see "You have already left kudos here. :)"
+ When kudos are sent
+ Then 1 email should be delivered to "myname1@foo.com"
+ And the email should contain "A guest"
+ And the email should contain "left kudos"
+ And the email should contain "."
+
+ When I am logged in as "myname3"
+ And I view the work "Awesome Story"
+ And I press "Kudos ♥"
+ Then I should see "myname3 and myname2 as well as 1 guest left kudos on this work!"
+ When I am logged in as "myname1"
+ And I view the work "Awesome Story"
+ Then I should not see "Kudos ♥"
+
+ When I go to the work kudos page for "Awesome Story"
+ Then I should see "2 Users Who Left Kudos on Awesome Story"
+ And I should see "1 guest has also left kudos"
+ And I should see "myname3 and myname2 left kudos on this work!"
+
+ @javascript
+ Scenario: Leaving kudos with JavaScript enabled
+
+ # As a registered user
+ When I am logged in as "myname2"
+ And I view the work "Awesome Story"
+ And I press "Kudos ♥"
+ Then I should see "Thank you for leaving kudos!"
+ And I should see "myname2 left kudos on this work!"
+ When I press "Kudos ♥"
+ Then I should see "You have already left kudos here. :)"
+ And I should not see "myname2 and myname2 left kudos on this work!"
+
+ # As another registered user
+ When I am logged in as "myname3"
+ And I view the work "Awesome Story"
+ And I press "Kudos ♥"
+ Then I should see "Thank you for leaving kudos!"
+ And I should see "myname3 and myname2 left kudos on this work!"
+ When I press "Kudos ♥"
+ Then I should see "You have already left kudos here. :)"
+
+ # As a guest user
+ When I am logged out
+ And I view the work "Awesome Story"
+ And I press "Kudos ♥"
+ Then I should see "Thank you for leaving kudos!"
+ And I should see "myname3 and myname2 as well as 1 guest left kudos on this work!"
+ When I press "Kudos ♥"
+ Then I should see "You have already left kudos here. :)"
+
+ Scenario: Leaving kudos on a multi-chapter work
+
+ Given I am logged in as "myname1"
+ And I post the chaptered work "Epic Saga"
+ And a draft chapter is added to "Epic Saga"
+ When I am logged in as "myname3"
+ And I view the work "Epic Saga"
+ And I press "Kudos ♥"
+ Then I should see kudos on every chapter
+ When I am logged in as "myname1"
+ And I view the work "Epic Saga"
+ Then I should see kudos on every chapter but the draft
+
+ Scenario: Deleting user after leaving kudos should orphan them
+
+ Given I am logged in as "myname3"
+ When "myname3" creates the default pseud "foobar"
+ And I view the work "Awesome Story"
+ And I press "Kudos ♥"
+ Then I should see "myname3 left kudos on this work!"
+ When "myname3" deletes their account
+ And I view the work "Awesome Story"
+ And "AO3-2195" is fixed
+ # Then I should see "1 guest left kudos on this work!"
+
+ Scenario: Redirection when leaving kudos on a middle chapter, with default preferences
+
+ Given the chaptered work setup
+ And I am logged in as a random user
+ When I view the work "BigBang"
+ And I view the 2nd chapter
+ And I press "Kudos ♥"
+ Then I should see "Chapter 2" within "div#chapters"
+ And I should not see "Chapter 1" within "div#chapters"
+
+ Scenario: Redirection when leaving kudos in temporary "Entire Work" mode, with default preferences
+
+ Given the chaptered work setup
+ And I am logged in as a random user
+ When I view the work "BigBang" in full mode
+ And I press "Kudos ♥"
+ Then I should see "Chapter 2" within "div#chapters"
+ And I should see "Chapter 3" within "div#chapters"
+
+ Scenario: Batched kudos email
+
+ Given the work "Another Awesome Story" by "myname1"
+ And the work "Meh Story" by "myname1"
+ And the work "Okay Story" by "myname1"
+ And all emails have been delivered
+ And the kudos queue is cleared
+ And I am logged in as "myname2"
+ And I leave kudos on "Awesome Story"
+ And I leave kudos on "Another Awesome Story"
+ And I leave kudos on "Okay Story"
+ And I am logged in as "someone_else"
+ And I leave kudos on "Awesome Story"
+ And I am logged out
+ And I leave kudos on "Awesome Story"
+ And I leave kudos on "Another Awesome Story"
+ And I leave kudos on "Meh Story"
+ When kudos are sent
+ Then 1 email should be delivered to "myname1@foo.com"
+ And the email should have "You've got kudos!" in the subject
+ And the email should contain "myname2"
+ And the email should contain "someone_else"
+ And the email should contain "a guest"
+ And the email should contain "A guest"
+ And the email should contain "Awesome Story"
+ And the email should contain "Another Awesome Story"
+ And the email should contain "Meh Story"
+ And the email should not contain "0 guests"
+ And the email should not contain "translation missing"
+
+ Scenario: Translated kudos email
+
+ Given a locale with translated emails
+ And the user "myname1" enables translated emails
+ And all emails have been delivered
+ And the kudos queue is cleared
+ And I am logged in as "myname2"
+ And I leave kudos on "Awesome Story"
+ When kudos are sent
+ Then 1 email should be delivered to "myname1@foo.com"
+ And the email should have "Translated subject" in the subject
+ And the email to "myname1" should contain "myname2"
+ And the email to "myname1" should contain "Awesome Story"
+ And the email to "myname1" should be translated
+ # AO3-6042: Emails should not obey user's locale preference when locales are deactivated
+ When the locale preference feature flag is disabled for user "myname1"
+ And all emails have been delivered
+ And I am logged in as "myname3"
+ And I leave kudos on "Awesome Story"
+ And kudos are sent
+ Then 1 email should be delivered to "myname1@foo.com"
+ And the email should have "You've got kudos!" in the subject
+ And the email to "myname1" should contain "myname3"
+ And the email to "myname1" should contain "Awesome Story"
+ And the email to "myname1" should be non-translated
+
+ Scenario: Emails should not obey user's locale preference when localised emails for that locale are deactivated
+
+ Given a locale with translated emails
+ And the user "myname1" enables translated emails
+ And all emails have been delivered
+ And the kudos queue is cleared
+ And I am logged in as "myname2"
+ And I leave kudos on "Awesome Story"
+ When kudos are sent
+ Then 1 email should be delivered to "myname1@foo.com"
+ And the email to "myname1" should be translated
+ When translated emails are disabled for the locale
+ And all emails have been delivered
+ And I am logged in as "myname3"
+ And I leave kudos on "Awesome Story"
+ And kudos are sent
+ Then 1 email should be delivered to "myname1@foo.com"
+ And the email to "myname1" should be non-translated
+
+ Scenario: Blocked users should not see a kudos button on their blocker's works
+ Given the work "Aftermath" by "creator"
+ And the user "creator" has blocked the user "pest"
+ When I am logged in as "pest"
+ And I view the work "Aftermath"
+ Then I should not see a "Kudos ♥" button
+
+ Scenario: Kudos cache expires periodically to ensure deleted users are removed and renamed users are updated
+ Given the work "Interesting beans"
+ And I am logged in as "oldusername1" with password "password"
+ When I view the work "Interesting beans"
+ And I press "Kudos ♥"
+ Then I should see "oldusername1 left kudos on this work!"
+ When I visit the change username page for oldusername1
+ And I fill in "New username" with "newusername1"
+ And I fill in "Password" with "password"
+ And I press "Change Username"
+ Then I should get confirmation that I changed my username
+ And I should see "Hi, newusername1"
+ When the kudos cache has expired
+ And I view the work "Interesting beans"
+ Then I should see "newusername1 left kudos on this work!"
+ And I should not see "oldusername1"
+ When I try to delete my account as newusername1
+ And the kudos cache has expired
+ And I view the work "Interesting beans"
+ Then I should not see "newusername1 left kudos on this work!"
+
+ Scenario: Cannot leave kudos (no button) while logged in as admin
+ Given I am logged in as an admin
+ And I view the work "Awesome Story"
+ Then I should see "Awesome Story"
+ And I should not see a "Kudos ♥" button
+
+ @javascript
+ Scenario: Cannot leave kudos while logged in as a user with the official role
+ Given the user "clicker" exists and has the role "official"
+ And I am logged in as "clicker"
+ When I view the work "Awesome Story"
+ And I press "Kudos ♥"
+ Then I should see "Please log out of your official account!"
+
+ @javascript
+ Scenario: Cannot leave kudos while logged in as a user with the archivist role
+ Given the user "clicker" exists and has the role "archivist"
+ And I am logged in as "clicker"
+ When I view the work "Awesome Story"
+ And I press "Kudos ♥"
+ Then I should see "Please log out of your archivist account!"
diff --git a/features/comments_and_kudos/kudos_pagination.feature b/features/comments_and_kudos/kudos_pagination.feature
new file mode 100755
index 0000000..51f04c8
--- /dev/null
+++ b/features/comments_and_kudos/kudos_pagination.feature
@@ -0,0 +1,67 @@
+Feature: Kudos Pagination
+
+ Background:
+ Given the maximum number of kudos to show is 3
+
+ Scenario: Viewing one kudo
+ Given a work "Popular (1)" with 1 kudo
+ When I view the work "Popular (1)"
+ Then I should see "fan1 left kudos on this work!"
+
+ Scenario: Viewing two kudos
+ Given a work "Popular (2)" with 2 kudos
+ When I view the work "Popular (2)"
+ Then I should see "fan2 and fan1 left kudos on this work!"
+
+ Scenario: Viewing three kudos
+ Given a work "Popular (3)" with 3 kudos
+ When I view the work "Popular (3)"
+ Then I should see "fan3, fan2, and fan1 left kudos on this work!"
+
+ @javascript
+ Scenario: Multiple pages of kudos with 1 kudo on the last page
+ Given a work "Popular (4)" with 4 kudos
+ When I view the work "Popular (4)"
+ Then I should see "fan4, fan3, fan2, and 1 more user left kudos on this work!"
+ When I follow "1 more user"
+ Then I should see "fan4, fan3, fan2, and fan1 left kudos on this work!"
+
+ @javascript
+ Scenario: Multiple pages of kudos with 2 kudos on the last page
+ Given a work "Popular (5)" with 5 kudos
+ When I view the work "Popular (5)"
+ Then I should see "fan5, fan4, fan3, and 2 more users left kudos on this work!"
+ When I follow "2 more users"
+ Then I should see "fan5, fan4, fan3, fan2, and fan1 left kudos on this work!"
+
+ @javascript
+ Scenario: Multiple pages of kudos with 3 kudos on the last page
+ Given a work "Popular (6)" with 6 kudos
+ When I view the work "Popular (6)"
+ Then I should see "fan6, fan5, fan4, and 3 more users left kudos on this work!"
+ When I follow "3 more users"
+ Then I should see "fan6, fan5, fan4, fan3, fan2, and fan1 left kudos on this work!"
+
+ @javascript
+ Scenario: Three pages of kudos
+ Given a work "Popular (9)" with 9 kudos
+ When I view the work "Popular (9)"
+ Then I should see "fan9, fan8, fan7, and 6 more users left kudos on this work!"
+ When I follow "6 more users"
+ Then I should see "fan9, fan8, fan7, fan6, fan5, fan4, and 3 more users left kudos on this work!"
+ When I follow "3 more users"
+ Then I should see "fan9, fan8, fan7, fan6, fan5, fan4, fan3, fan2, and fan1 left kudos on this work!"
+
+ Scenario: Three pages of kudos without javascript
+ Given a work "Popular (9)" with 9 kudos
+ When I view the work "Popular (9)"
+ Then I should see "fan9, fan8, fan7, and 6 more users left kudos on this work!"
+ When I follow "6 more users"
+ Then I should see "1 - 3 of 9 Users Who Left Kudos on Popular (9)"
+ And I should see "fan9, fan8, and fan7 left kudos on this work!"
+ When I follow "Next"
+ Then I should see "4 - 6 of 9 Users Who Left Kudos on Popular (9)"
+ And I should see "fan6, fan5, and fan4 left kudos on this work!"
+ When I follow "Next"
+ Then I should see "7 - 9 of 9 Users Who Left Kudos on Popular (9)"
+ And I should see "fan3, fan2, and fan1 left kudos on this work!"
diff --git a/features/comments_and_kudos/official_comments.feature b/features/comments_and_kudos/official_comments.feature
new file mode 100755
index 0000000..7173da7
--- /dev/null
+++ b/features/comments_and_kudos/official_comments.feature
@@ -0,0 +1,34 @@
+@comments
+Feature: Read official comments
+ In order to tell genuine official accounts from impersonators
+ As a user
+ I'd like to see the "official" sign
+
+Scenario: View official comments in homepage, inbox and works
+ Given I am logged in as "normal_user"
+ And I post the work "My very meta work about AO3"
+ And the user "official_account" exists and has the role "official"
+ And I am logged in as "official_account"
+ When I post the comment ":)))))))" on the work "My very meta work about AO3"
+ Then I should see "(Official)"
+ When I am logged in as "normal_user"
+ And I go to the home page
+ Then I should see "(Official)"
+ When I follow "My Inbox"
+ Then I should see "(Official)"
+ When I view the work "My very meta work about AO3" with comments
+ Then I should see "(Official)"
+
+Scenario: View fake official comments in homepage, inbox and works
+ Given I am logged in as "normal_user"
+ And I post the work "My very meta work about AO3"
+ And I am logged in as "fake_official_account"
+ When I post the comment ":)))))))" on the work "My very meta work about AO3"
+ Then I should not see "(Official)"
+ When I am logged in as "normal_user"
+ And I go to the home page
+ Then I should not see "(Official)"
+ When I follow "My Inbox"
+ Then I should not see "(Official)"
+ When I view the work "My very meta work about AO3" with comments
+ Then I should not see "(Official)"
diff --git a/features/comments_and_kudos/spam_comments.feature b/features/comments_and_kudos/spam_comments.feature
new file mode 100755
index 0000000..9b01f1b
--- /dev/null
+++ b/features/comments_and_kudos/spam_comments.feature
@@ -0,0 +1,196 @@
+@comments
+Feature: Marking comments as spam
+
+ Scenario: Spam comments are not included in a work's comment count
+ Given the work "Popular Fic" by "author" with guest comments enabled
+ And I view the work "Popular Fic" with comments
+ And I post a guest comment
+ And I post a spam comment
+ And all comments by "spammer" are marked as spam
+
+ When I am logged in as "author"
+ And I go to the home page
+ Then I should see "This was really lovely!"
+ And I should not see "Buy my product!"
+ When I follow "My Inbox"
+ Then I should see "(1 comments, 1 unread)"
+ And I should see "This was really lovely!"
+ And I should not see "Buy my product!"
+
+ When I go to author's user page
+ Then I should see "Popular Fic"
+ And I should see "Comments: 1"
+
+ When I follow "Popular Fic"
+ Then I should see "Comments:1"
+ And I should see "Comments (1)"
+
+ When I am logged in as "author"
+ And I go to author's stats page
+ Then I should see "Comment Threads: 1"
+
+ Scenario: Spam comments are not included in an admin post's comment count
+ Given the admin post "Default Admin Post"
+ And I go to the admin-posts page
+ And I follow "Default Admin Post"
+ And I post a guest comment
+ And I post a spam comment
+ And all comments by "spammer" are marked as spam
+
+ When I go to the admin-posts page
+ Then I should see "Default Admin Post (1)"
+
+ When I follow "Default Admin Post"
+ Then I should see "Comments (1)"
+
+ Scenario: Author can mark comments as spam
+ Given the work "Popular Fic" by "author" with guest comments enabled
+ When I view the work "Popular Fic" with comments
+ And I post a spam comment
+ And I post a guest comment
+ And I am logged in as "author"
+ And I view the work "Popular Fic" with comments
+ Then I should see "Comments (2)"
+ And I should see "Buy my product"
+ When I mark the comment as spam
+ Then I should see "Comments (1)"
+ And I should not see "Buy my product"
+
+ @javascript
+ Scenario: If Javascript is enabled, there's a confirmation popup before marking a comment as spam
+ Given the work "Popular Fic" by "author"
+ And a guest comment on the work "Popular Fic"
+ And a guest comment on the work "Popular Fic"
+ When I am logged in as "author"
+ And I view the work "Popular Fic" with comments
+ Then I should see "Comments (2)"
+ When I mark the comment as spam
+ And I confirm I want to mark the comment as spam
+ And I view the work "Popular Fic" with comments
+ Then I should see "Comments (1)"
+
+ Scenario Outline: Guest comments should be spam-checked
+ Given
+ And with guest comments enabled
+ When I view with comments
+ And Akismet will flag any comment by "spammer"
+ And I try to post a spam comment
+ Then I should see "This comment looks like spam to our system, sorry!"
+
+ Examples:
+ | commentable |
+ | the work "Generic Work" |
+ | the admin post "Generic Post" |
+
+ Scenario Outline: New users' comments should be spam-checked on posting when the admin setting is enabled
+ Given
+ And account age threshold for comment spam check is set to 5 days
+ And Akismet will flag any comment by "spammer"
+ When I am logged in as a new user "good_user"
+ And I view with comments
+ And I post the comment "I don't like spam" on
+ Then I should see "Comment created!"
+ When I am logged in as a new user "spammer"
+ And I post the comment "I like spam" on
+ Then I should see "This comment looks like spam to our system, sorry!"
+
+ Examples:
+ | commentable |
+ | the work "Generic Work" |
+ | the admin post "Generic Post" |
+
+ Scenario Outline: New user's comments should be spam-checked on editing when the admin setting is enabled
+ Given
+ And account age threshold for comment spam check is set to 5 days
+ And Akismet will flag any comment containing "spam"
+ When I am logged in as a new user "spammer"
+ And I view with comments
+ And I post the comment "abcdefghijk" on
+ Then I should see "Comment created!"
+ When I follow "Thread"
+ And I follow "Edit"
+ And I fill in "Comment" with "abcspamcccc"
+ And it is currently 1 second from now
+ And I press "Update"
+ Then I should see "Comment was successfully updated."
+ And I should see "abcspamcccc"
+ When I follow "Thread"
+ And I follow "Edit"
+ And I fill in "Comment" with "I like spam"
+ And it is currently 1 second from now
+ And I press "Update"
+ Then I should see "This comment looks like spam to our system, sorry!"
+
+ Examples:
+ | commentable |
+ | the work "Generic Work" |
+ | the admin post "Generic Post" |
+
+ Scenario Outline: Old users' comments should not be spam-checked when the admin setting is enabled
+ Given
+ And account age threshold for comment spam check is set to 5 days
+ And Akismet will flag any comment by "spammer"
+ When I am logged in as a new user "good_user"
+ And it is currently 10 days from now
+ And I post the comment "I don't like spam" on
+ Then I should see "Comment created!"
+ When I am logged in as a new user "spammer"
+ And it is currently 10 days from now
+ And I post the comment "I like spam" on
+ Then I should see "Comment created!"
+
+ Examples:
+ | commentable |
+ | the work "Generic Work" |
+ | the admin post "Generic Post" |
+
+ Scenario Outline: New users' comments should not be spam-checked if the admin setting is disabled
+ Given
+ And account age threshold for comment spam check is set to 0 days
+ And Akismet will flag any comment by "spammer"
+ When I am logged in as a new user "good_user"
+ And I post the comment "I don't like spam" on
+ Then I should see "Comment created!"
+ When I am logged in as a new user "spammer"
+ And I post the comment "I like spam" on
+ Then I should see "Comment created!"
+
+ Examples:
+ | commentable |
+ | the work "Generic Work" |
+ | the admin post "Generic Post" |
+
+ Scenario: New users' comments should not be spam-checked on tags
+ Given a canonical fandom "Stargate SG-1"
+ And the tag wrangler "spammer" with password "password" is wrangler of "Stargate SG-1"
+ And account age threshold for comment spam check is set to 5 days
+ And Akismet will flag any comment by "spammer"
+ When I am logged in as a new user "spammer"
+ And I view the tag "Stargate SG-1" with comments
+ And I post the comment "Sent you a syn" on the tag "Stargate SG-1"
+ Then I should see "Comment created!"
+
+ Scenario: New users' comments should not be spam-checked on their own work
+ Given the work "Generic Work" by "spammer"
+ And account age threshold for comment spam check is set to 5 days
+ And Akismet will flag any comment by "spammer"
+ When I am logged in as a new user "spammer"
+ And I post the comment "I like spam" on the work "Generic Work"
+ Then I should see "Comment created!"
+ When I reply to a comment with "I still like spam"
+ Then I should see "Comment created!"
+
+ Scenario: Old users' comments should not be spam-checked after they change their email
+ Given
+ And account age threshold for comment spam check is set to 5 days
+ And Akismet will flag any comment by "spammer"
+ When I am logged in as a new user "spammer"
+ And it is currently 10 days from now
+ And I change my email to "newemail@example.com"
+ And I post the comment "I like spam" on
+ Then I should see "Comment created!"
+
+ Examples:
+ | commentable |
+ | the work "Generic Work" |
+ | the admin post "Generic Post" |
diff --git a/features/fixtures/icon.bmp b/features/fixtures/icon.bmp
new file mode 100755
index 0000000000000000000000000000000000000000..742d271d8007528250cd8968cfe667d00b15b9b9
GIT binary patch
literal 10650
zcmeHLd6*Q%6@To)Ko5+74J^(s?1F#`EUdr+i}du&⁡CEXQ&mE{GsE!4Q=Isn*Zr
z^D~J#zI?`j#E5c;@qiCeiARh`4DkquA_zoILx6-+B68(bb$1;-=kl5SMeok^tM`8I
z{iIF=w#TU{k
z*Ve!M1Kxh?5L~)+1*YA-33h#Y5(-)^gN66M3-7#h7#@D;edyBZX?WyETVegWeXxK3
zX)yY%fvSNo!SiePz^fbo4rIv|cS60)$e@>D{Mc7u^Sejj*N^`V4jw!Mr%!(k
zt5)uU9otX9D;xI1?%n@_%a^ahl`B`_@S(GipSKj&y}TE$UcCl~4xWYLq7~4y`?GNB
z)YmY7-rMjX*#f;wRzlnSC*kCY3-H)aKY-;=?tq;;PQZ!d7hu+mH{kg3ub@|tm9X;J
zU2x#Q8F>A*FW|!uj=|C=wnNx>4)%V23ij;zH%u6}5q|NrkKno0pTVZrzkrHJ?y4GT0a8-D(FSIG7t|>Wg8SDm3%yDOm$ikGGqf?r3WX3V2p4(G)_vETkj%!Uk
zj!Mzl6w>GcZWMMOrb4IiOm-Fq$)UN>rjS}n9w?4fp*7svN*K6~kf}nOLW)TqDCVos
z_T1V*7`Tp*HbR?18YOw4*ieNw=T<3+Tt~>=Dm+Q6spNrTjS8E~t&@a->j;^gCDh3!
zL-HUbXQGPC=GK@na2?_Y33XDbMTz7=NJ$F?nbnb7+X(~Lq0nBalS*~f?NOuxEBN*(
z93d<+M+2cwDv3#Do*oymtcv@K5HiR2%7s3e^nmO@h?I5eA*Ot&6hL;Vw@5N17MpdG
z9Y{`v0vmV}w|0>XTuBALWJoM_QShYiKm``&(?O&uw-leMTNRi1M%Hu%@2w;gxK&wi
zT+0M`go0sW^SVx>p$e}T6(B0L;^wikfoqvi6_IU@Jfs)N88(hKS{JBrU0OV7mQuCY
z@ntvFi8PgMj$D^w#&^LgFx7+h3_FjTyLFZ0aJ4Pet0Nn2xu3o5ZfXf)N*P+aMFD5!
z@?dJ|<7!9f(@6274TpmaP}eOXb5-a)BGy91BJ_?_&`hyQ9A0Q)Cly)50h&}u5X1Fc
z$ep6P@^Ns4Bg{~7AP!-9lQRxcDjna!o`c=Zm4{=vXAO#lOF(7EYU}aEW_DW{l!U``
z0)|AGzUq?3TvXBhXay?~dzVMvfTBWBP7Vw860Tfc8zL15at7T+NA7P4nhN2u+nE=E~1qnGwwNKv5
zQobjv$1jFw99EC`&|H&*gOHxL^Q6cRT{n9&<#Je_CmK$GLQ)Pwbmyt0qNi}^i*?O&
z+2gQMkfh*nN@!MRK{24EhX9){hb4-pa9~m@jxrW-iR9tg;>^xDtQdbikz7zr&QVgv
zj@kthzE51WlgD9McXgM*LQKYyGuJNdiv=`(`+3HJdv%nw%DHC}j%?@rt&}%cZx+-0
zC2~bAotpA@t<)UF_c(zT%S9kC#2cQqRSwIV`CW0E*)$2p(PxyqGbZt_piu3pw=SWV
z10M|P&nu9}A6HANzwb_Lmb$ItiikrJ&w+;WS+SA5?inA`uc4CxuYUQd<#9%bV?5#(cRWinhN#B}T7Zm+B+>T03^nx=j^
z;vD#1JF=hKb>&{(psK^Yj;2wb=dl|7tMe6jd_&?uboczb;vtshjBsLEsppB8dIu7m
z7-|rO0~^(PgFD0VL+@WvXGY%y1cZ!rH@zBHZh1LqJ0~~WKcRlRS)E3V8PTj!wEnan
z!2<8|wBy%vnj9>NoQT8Ghd612+69H9#s=YOQbAlewzrAMxwR7fBRDTG)olHqw9RLdq|}T&&ye!wlA-;rdfP_ap7t2i8#_a
z)e>-nBlCo%&sEs8-!d|f=|!gJh#DFE2Ivi|w9|7~mXAafpV~GAhe51}Un7AS3qcGk
zL`=&hAz
zz9_T!l*30%6Psa85Bab=l9%nlz7-BD6g73)bA_Xfm^LpBfgF}0I4HYLaJXew7%dra
zM06~Few$n5DyT=W#||;(ifDoZgHlSCQj{~`FfkD!dwz+I`S4NaFtX|a&JhWR0+hqY
z@)bY>HgTvpLZ2nP7~c6(a#coxe%dp{XD4&^1%n!jcyY;(Q5?GR#MrVfgKFm?6_JBR(T$QGM8?lT=0mrWN)X!6+)CK7%F@
zQJ5=Sk%kYuA-ocE`0WA6Mz5@CjLV~@94Rj`jtn?rhQSt{8ZoV-;7}dE&DIT$%(MBe
z^mB;
z-ssoCfkls9Va9=#igVD@i;WLS@a&-+x)vmOaAlq=+JCGtesqsvNu?Z-05JuJNla(f
zuu5WSClL}fNvt*;tow8-0?v7h6M$SPIAX+%5`S$t+$w31n5NNsOi5YV84o+@)m-`U
z5YY7N;lQGf2awHycRmhWQgqJgvcnuway)lY;hfjhgI*M$ODlN%{
zhupBS@ZH^~IXtdPP1&ic-nF*He16)kuB}N+_2}r;p`!ct_M9Rm<;BML?CiT|X{kU$
z&XJR~T3r13`O}=8szF4-b9L#>&iV55>d?^Oxw^b+Z2I){pC~J-K0x&5=CM*#vsG8r
zo}lyP<*Y_X(U_W^CMmO2R?3Nsr8PLAE-|J!I=yRcwp(7JFfy@FQm;!+v{+i?!o$y%
zmhs`?`0()7q^0Z8(%rMP?bz7<{QUm?{qo}DnH(S6udx6A{_^4Cp)4<>GBx<`@4jwu
z#eRUKG&h?cBKGU-%Z!cEo1E*?)As7?yJ%|l>guF3HrAu0@!;U!x47ub%+Z&bs60RK
z+S~5g*|k|)&5n@Qrl{YzxvNA+$%l#aOUA^8LV00000EC2ui
z0Av7U000O7fPR95goTEOh>41ejE#zakdcy;l$Dm3n3XFk)?$b!I44@g9*)lgU!E!yP3VCzX?3w
zJjj1Q3D}Z?2|$p_e%}*<%|hioJ__Xq>w-LxK7-kq+eH1^M=1y~KlqXfO32QcpdWvD
z_?ZR-+AcxwAW3}p?^3`}11m`!k|3Qza}9<03G%~Ff=T@pQ1hd}kB~nJCP?gP=_9CX
zgdnvnHcZ^ieiLTqlhn^gNJ$|D4Vfr}=1xsMKXn5~u%^tZN%K{ln3CVbr;(!i0pdqP
zzadxuwfSj)m%zQKc3wOze(8kSBKjM$@TRCU>|mGo}NHV#M
z_Oq9CDoWyg^M3@f+>~P^pzXthGzk>bjRM6X*H&ea5at8_
zVGy~bL5(-&m6#>|^`pTMJsg-!QorESPY*50G(j=v5ClXn(EQP*niKue0GVkT6iik4
z_`xMYM$&gqi*o@1onU3M8O)cIO~BSP>vV{C^4{yq9=Xn$nX4>B#~){dow%9W{+3iKe6l$C}$DLJeLdg^YLK01sK
z$+ebK55rspYMZrADN>&|Hn8HSw0c?;CA0ofQv)^W(&nQ;NYsE~w7t`SIJ5eyr$XQj
zqa}N!0LY3En$dfTdZTREoW7y(3oyL`54>K%2A_v;!s9J`1qlm$;l=Axwk-K*FUMQ34JHTrKt?D@fo0z=8;n#13P(
zjR*)8oItQ24!CVMA{#s)z`=L-jmQoGpy&4mD3p`{3K#h8#|=X_eDwtz44}Xk2auqK
z3R7G$1qBQUaX=X}XaGVCZP!qS5QZrh
zP{KtJIN0uU0~qM82pCilaY_*gG+{yEifbYsO5Re5C!d8eCY|RoqSb@uUFaq}pf(akk
zn*a$|2qc7H36@a89;(&|7BsLGg3tg4Z6N|hB!B}ot3(z;;0PZ0FFdUQ@vPC8b+nrnB_RL>
zCISh`3mA5eiOjoAfD4p3LJbtL0|~0)H#6|tFRfMs803)>yGeuuq!|f=h%=d1lY#+U
zkclTO?}eE90{}MJw=s%s0w&7D6(*U8Hh%LDPUzXvE^r17Ab5qk%F2iW#19l|bpQqcV7x^n;FOvWfE*Dq2D)Z(6a*lI
z7Fr-dGPtY-oaiMYVnENOE}{uW<%AP#;D-x9Aq$f50QMR&ft_aJ2YoHX9$3T($eMx+
z6~H7V7T|~i1T+&!MFbXDtB59KR2Bj_gCnTGi7h082Kc<$A~s`-7C2%DwLEQV2|)tO
zBBB6#_znfyx`+!jE)zialxgy{+b1!RryAud4D>{x&XjX>_OCIsM9L{wM7h5&;%zk@|ZTmYM!
z7(gP}t6ajKhnkD<0w>&yh%Y225g|BF6&ztxP1F|=PcI
zH~PHabG<*lKi@v*I@dX8oxQHT_qy-3?z3;EZx#WllANL(fQ5wxC}0lYW)_eJu)z=r
z1dNTjU}IzB;1c5DVj9Viqa2@D3OpreG7(U8MnR0t{>dU{4iMmQxiD>DNt
z9Rnl7A17FtuDCe3#CUkb3}i4ehW~fF`3k`B01LnZj0FQgFf1?(>!uT+0{|@SKcoG(
z;R0X?HVzgD4`Zdl0zlyZHUao)1d3!
zhI%qs$|-p2HLZw;w9-2?^Wf1|pTbcC+wK~9oCb#%Ph^vkphk2zU+zg=BgOA|Y~Q;{
zGfC*I=#-fxF|KkbSBr`{ExYEarh%wItaE0c_H!<;j_OLqWpSeI-)7hzs1N4)eTnsR
zIKsche1+(Dp-hr~fS=K6FxqycG`yJ@oF`P9u4vA1Vv{cUR~z7HCjR2xt{@`c-ck?SdpPl}#UFdg&-^Gdg}t=Zyh&$ID;kTXYM++NX*
zlaN
z(kfO_Ck}!ziXwhq(Vg?$04&NllAK;?S!oBLoI2j|%`H2nuUhGn?`YM+tlXz8D^M#o
zBQpjm(m1V{c(FnO5T^ZCM8OacHh_gA1c#E6ky9|i5SYl}VIqo!4Jwx9)VcDaKHe}5
zP@&%T3JxyR4%5&n&>|9>q3DwLJI+t?)ji-OobxIH%;P>VmCq1^8IE1gqTrfg1n<{|M#`(3WP#r-UAjZHx9{z?z#VeS40_=E;=|fc-Eiu`*Gj2nM9+e&cDD8?$gWL4
zT1*`ezE(Zp#HL7nS#tg%nl1{h_KxeNmhbPi=1e@nqtO+nKu|N(s`f$uPelIN$Lt#V
z7;xxXU438R4KUYgY&)Z6`?Rp4QX8)ZH645E`=WW4uDI7Feq9%Jo35%zW1Nldt1*{t
z!}+1{w<06mQL<=Gegt>*PF=ob>(1GnG-vc@ofkPvoKkMYFVF&sC+}HNW<
zf@-tQoF55)i>vt29MqBqk84)5oq6uPVqJ9Zad@=5zPT_w)*8e%V68kWG1mIQnO%*L
zgvAqC{iYTd6JkCXfCUDDaUeJ#@IRdW!%+wfo0JaqF5?HCPWJ?8^9eCxuuA%UuIiym_?
zFZ;fTDD-SpyvjvsY~x>*Y$NS$lf0)oRgW3_I*ivlmBY^pQ(YQUD#S~2smsbLa}K~f
z#}Dsj+An)V<&=ieP<{F9L7n
zC^Bl0cu{NGPW@<4b5xP|%bR+PSyM|M?~wd1$KeKrdVb7#gJIzVbM9B~iZu)G6ojXB
zA%o@F!EGhlmW&P$O(LTS(5YM5^Hv^@H5K^|^7nE{^o<$SziG-%9||>VCem?h%TbX~
zeI|F-6k|93?*Pn)G{KEsnEnw6ywAXNeic
ziJ9f$Wd+>;w#q$fT8_aydz5Ud6lM)uQ28tgzkew0<$J{}JwE38nz5xDAU&Qd!X+)m
zyxfm@)a0FxUtCn{F^v-Kn!1ykQZB-!i-9o<2F4I9Oey)d^8A4@3`&aNhcodZpD_FV
z1up0haBXFIh|a1-mJxoM6tRUiSOL(sg3v_Fi})uh%lycD>I)XWssw{cXSErMHO+-z
zt}`0_I)6mp5AY6B(1KbM_}z&iR7D8{%aFIce_A)8*l|R$6>(HFc!%Tf>vqpu`Qr>-
z-iOXcHe@iTN3RVy@ym#}lRnuhHzDv&-T|GFSz6T<*YHdhSpUY!A5UtY4xcR_#c89~
z_P)2|@k%%b=fJP%22qro)-v;)bUoDet4jTpfntxFRQtiMy=|R8qFzviO!Bc<=NmPf
z+3UT3JqS6O?vzUj37kx{Ubfc@ihOa9v`(_(x1`!n^toE&r>I-?oejEuOGBhT()hc3a;Q2Hx2@T+Xn
zY-F~#^1$+(@2Sc*;qwI7im^^No&1*>;a?_zTz%$HeY0-2dxU%0_Nx4;emz;=6*KYT
zE26Z}%{qk*ZHPUuuJ%YsIDSU}o!>EfoaSYqQpe6|5V+6*
z_PpHJ;Xklzmqtf`1MjRh;Ex$q9ZHGhrTw_H+2r|TS~^Ffb}}rs9@-*Yd&<}OBZADI
zDt{=QgWJiFr^uM?^E{pryY90hEA%>5eUzF@!-Gc#EIaZcMQW=Lh0T=fky{hSlaHcR
zxjmpEjHBj_Lh*BkzKX*ukqr!N>2)<>$x(%IK7EIWjh6zAdp6dJG(dwgKN~`Rwtl
zGd$~zaO8?v@qL(-*nYlme3Oscs*YN6SyK~jZ6YUGQRy<#E!x0!60M%RrP@Eksg`N4
z_oP(Vn(O}Xslvma^;cgf`VB>i-`rX&dX~@l$-_ivJ`~AmmJ8|cvz~E|2Xy?&?-V5t
z9i7JA<$pxNB^!pwIDU~;$bCtP0MbVb`z4eaz*T0a(w(Eqg506k5$?{UbSUDTiH+%Y
zIhN?u>XYol=hXCB7MPic-EK79aM7)^8Q9BGXrc;V&IzOYWn(-S6%isDWHvko_vyI-
z2m&3P+P^b8KU5pI7=@o%s=M`GZQ*W3sqa>9_~xbcTily`dp+^T-tIu=7{jn9x?iUK
z@+hs@tH>ey>gR-kH~X$};q0b_*DC{RcWx(S5OVOZX>-ZC
zt9e~EfjRYiRL(_(k`+2uMIVPK<`Cfv^wqyd+>`cKXNA@a#RhZx5&Be
zv}%2;gy=q+*s5J7Y0T_;nDsPC%Fu$zny~o=HBocO6k8Iqv7IGf={hx*u&Ddc*jDp)
zbY
+ """
+ When I press "Post"
+ And I follow "Edit"
+ # testing the textarea content here
+ Then I should see in the "content" input
+ """
+
This is paragraph 1.
+
+
This is paragraph 2.
+
+
+
+
This is paragraph 3.
+ """
+ When I press "Post"
+ And I follow "Edit"
+ Then I should see in the "content" input
+ """
+
This is paragraph 1.
+
+
This is paragraph 2.
+
+
+
+
This is paragraph 3.
+ """
+
+ Scenario: HTML Parser should kick in
+ When I am logged in as "newbie"
+ And I set up the draft "My Awesome Story"
+ And I fill in "content" with
+ """
+ A paragraph
+
+ Another paragraph.
+ """
+ And I press "Preview"
+ Then I should see "Preview"
+ And I should see the text with tags
+ """
+
A paragraph
+
Another paragraph.
+ """
+
+ Scenario: Work notes and content HTML can have classes and they are kept when editing after previewing
+ Given I am logged in as a random user
+ And I set up the draft "Classy Work"
+ When I fill in "Summary" with "
Text
"
+ And I fill in "Notes" with "
Text
"
+ And I fill in "End Notes" with "Text"
+ And I fill in "content" with "
You better work
"
+ And I press "Preview"
+ Then I should see "Draft was successfully created."
+ And I should see the image "src" text "http://www.example.org/britney.gif"
+ And I should see the image "alt" text "Britney Spears"
+ When I press "Edit"
+ Then the "Summary" field should not contain "myclass"
+ And the "Notes" field should contain "note"
+ And the "End Notes" field should contain "keep-me"
+ And the "content" field should contain "size-10"
+ When I press "Post"
+ Then I should see "Work was successfully posted."
+ And I should see the image "src" text "http://www.example.org/britney.gif"
+
+ Scenario: Chapter notes and content HTML keep classes when previewing before posting
+ Given I am logged in as a random user
+ And I post the work "Classy Multichapter Work"
+ And a chapter is set up for "Classy Multichapter Work"
+ When I fill in "Chapter Summary" with "
Text
"
+ And I fill in "Notes" with "
Text
"
+ And I fill in "End Notes" with "Text"
+ And I fill in "content" with "
"
+ And I press "Preview"
+ Then I should see "This is a draft chapter in a posted work."
+ When I press "Post"
+ Then I should see "Chapter was successfully posted."
+ And I should see the text with tags '
'
+ When I follow "Edit Chapter"
+ Then the "Chapter Summary" field should not contain "summary classes"
+ And the "Notes" field should contain "note"
+ And the "End Notes" field should contain "keep-me"
+ And the "content" field should contain "elaborate formatting"
+
+ Scenario: Can't use classes in comment content
+ Given the work "Generic Work"
+ And I am logged in as "commenter"
+ And I view the work "Generic Work"
+ When I fill in "Comment" with "
Hi there!
"
+ And I press "Comment"
+ Then I should see "Comment created!"
+ When I follow "Edit"
+ Then the "Comment" field should not contain "strip me"
+
+Scenario: Can't use classes in a bookmark note
+ Given the work "Really Good Thing"
+ And I am logged in as "bookmarker"
+ When I bookmark the work "Really Good Thing" with the note "My best yet"
+ And I edit the bookmark for "Really Good Thing"
+ Then the "Notes" field should not contain "remove-me"
diff --git a/features/other_a/preferences_edit.feature b/features/other_a/preferences_edit.feature
new file mode 100755
index 0000000..5624444
--- /dev/null
+++ b/features/other_a/preferences_edit.feature
@@ -0,0 +1,337 @@
+@users
+Feature: Edit preferences
+ In order to have an archive full of users
+ As a humble user
+ I want to fill out my preferences
+
+
+ Scenario: Ensure all Preference options are available
+
+ Given the following activated user exists
+ | login | password |
+ | scott | password |
+
+ When I am logged in as "scott" with password "password"
+ And I go to scott's user page
+ And I follow "Preferences"
+ Then I should see "Set My Preferences"
+ And I should see "Hide my work from search engines when possible."
+ And I should see "Hide the share buttons on my work."
+ And I should see "Show me adult content without checking."
+ And I should see "Show the whole work by default."
+ And I should see "Hide warnings (you can still choose to show them)."
+ And I should see "Hide additional tags (you can still choose to show them)."
+ And I should see "Hide work skins (you can still choose to show them)."
+ And I should see "Your site skin"
+ And I should see "Your time zone"
+ And I should see "Browser page title format"
+ And I should see "Turn off emails about comments."
+ And I should see "Turn off messages to your inbox about comments."
+ And I should see "Turn off copies of your own comments."
+ And I should see "Turn off emails about kudos."
+ And I should see "Do not allow guests to reply to my comments on news posts or other users' works (you can still control the comment settings for your works separately)."
+ And I should see "Allow others to invite my works to collections."
+ And I should see "Turn off emails from collections."
+ And I should see "Turn off inbox messages from collections."
+ And I should see "Turn off emails about gift works."
+ And I should see "Turn on History."
+ And I should see "Turn the new user help banner back on."
+ And I should see "Turn off the banner showing on every page."
+
+
+ Scenario: View and edit preferences for history, view entire work
+
+ Given the following activated user exists
+ | login | password |
+ | editname | password |
+ When I go to editname's user page
+ And I follow "Profile"
+ Then I should not see "My email address"
+ And I should not see "My birthday"
+ When I am logged in as "editname" with password "password"
+ And I post the 2 chapter work "This has two chapters"
+ Then I should be on the 2nd chapter of the work "This has two chapters"
+ And I follow "Previous Chapter"
+ Then I should be on the 1st chapter of the work "This has two chapters"
+ When I follow "editname"
+ Then I should see "Dashboard" within "div#dashboard"
+ And I should see "History" within "div#dashboard"
+ And I should see "Preferences" within "div#dashboard"
+ And I should see "Profile" within "div#dashboard"
+ When I follow "Preferences" within "div#dashboard"
+ Then I should see "Set My Preferences"
+ When I follow "Edit My Profile"
+ Then I should see "Password"
+ # TODO: figure out why pseud switcher doesn't show up in cukes
+ # When I follow "editname" within "#pseud_switcher"
+ When I follow "Dashboard"
+ And I follow "Profile"
+ Then I should see "Set My Preferences"
+ When I follow "Set My Preferences"
+ Then I should see "Edit My Profile"
+ When I uncheck "Turn on History"
+ And I check "Show the whole work by default."
+ And I press "Update"
+ Then I should see "Your preferences were successfully updated"
+ And I should not see "History" within "div#dashboard"
+ When I go to the works page
+ And I follow "This has two chapters"
+ Then I should not see "Next Chapter"
+
+ @javascript
+ Scenario: User can hide warning and freeform tags and reveal them on a case-
+ by-case basis.
+
+ Given a canonical freeform "Scary tag"
+ And I am logged in as "someone_else"
+ And I post the work "Someone Else's Work" as part of a series "A Series"
+ And I am logged in as "tester"
+ And I post the work "My Work"
+ And I bookmark the work "Someone Else's Work"
+
+ # Change tester's preferences to hide warnings.
+ When I set my preferences to hide warnings
+ Then I should see "Your preferences were successfully updated"
+
+ # Warnings are hidden on work meta, except on user's own works.
+ # We use a selector so it doesn't pick up the info in the Share box.
+ When I view the work "Someone Else's Work"
+ Then I should not see "No Archive Warnings Apply" within "dl.work.meta"
+ And I should see "Show warnings"
+ And I should see "Scary tag" within "dl.work.meta"
+ And I should not see "Show additional tags"
+ When I follow "Show warnings"
+ Then I should see "No Archive Warnings Apply" within "dl.work.meta"
+ When I follow "No Archive Warnings Apply" within "dl.work.meta"
+ Then I should be on the works tagged "No Archive Warnings Apply"
+ When I view the work "My Work"
+ Then I should see "No Archive Warnings Apply" within "dl.work.meta"
+ And I should not see "Show warnings"
+ And I should see "Scary tag" within "dl.work.meta"
+ And I should not see "Show additional tags"
+
+ # Warnings are hidden in work blurbs, except on user's own works.
+ When I go to someone_else's works page
+ Then I should see "Someone Else's Work"
+ And I should not see "No Archive Warnings Apply" within "li.warnings"
+ And I should see "Show warnings"
+ And I should see "Scary tag" within "li.freeforms"
+ And I should not see "Show additional tags"
+ When I follow "Show warnings"
+ Then I should see "No Archive Warnings Apply" within "li.warnings"
+ When I follow "No Archive Warnings Apply" within "li.warnings"
+ Then I should be on the works tagged "No Archive Warnings Apply"
+ When I go to tester's works page
+ Then I should see "My Work"
+ And I should see "No Archive Warnings Apply" within "li.warnings"
+ And I should not see "Show warnings"
+ And I should see "Scary tag" within "li.freeforms"
+ And I should not see "Show additional tags"
+
+ # Warnings are hidden in series blurbs.
+ When I go to someone_else's series page
+ Then I should see "A Series"
+ And I should not see "No Archive Warnings Apply" within "li.warnings"
+ And I should see "Show warnings"
+ And I should see "Scary tag" within "li.freeforms"
+ And I should not see "Show additional tags"
+ When I follow "Show warnings"
+ Then I should see "No Archive Warnings Apply" within "li.warnings"
+ When I follow "No Archive Warnings Apply" within "li.warnings"
+ Then I should be on the works tagged "No Archive Warnings Apply"
+
+ # Warnings are hidden in bookmark blurbs.
+ # This is slightly excessive -- bookmarks use the work blurb -- but we'll
+ # check in case that ever changes.
+ When I go to tester's bookmarks page
+ Then I should see "Someone Else's Work"
+ And I should not see "No Archive Warnings Apply" within "li.warnings"
+ And I should see "Show warnings"
+ And I should see "Scary tag" within "li.freeforms"
+ And I should not see "Show additional tags"
+ When I follow "Show warnings"
+ Then I should see "No Archive Warnings Apply" within "li.warnings"
+ When I follow "No Archive Warnings Apply" within "li.warnings"
+ Then I should be on the works tagged "No Archive Warnings Apply"
+
+ # Change tester's preferences to hide freeforms as well as warnings.
+ When I follow "My Preferences"
+ And I check "Hide additional tags"
+ And I press "Update"
+ Then I should see "Your preferences were successfully updated"
+
+ # Freeforms and warnings are hidden on work meta, except for user's own works.
+ When I view the work "Someone Else's Work"
+ Then I should not see "No Archive Warnings Apply" within "dl.work.meta"
+ And I should see "Show warnings"
+ And I should not see "Scary tag" within "dl.work.meta"
+ And I should see "Show additional tags"
+ When I follow "Show warnings"
+ Then I should see "No Archive Warnings Apply" within "dl.work.meta"
+ And I should not see "Scary tag" within "dl.work.meta"
+ When I follow "Show additional tags"
+ Then I should see "Scary tag" within "dl.work.meta"
+ When I view the work "My Work"
+ Then I should see "No Archive Warnings Apply" within "dl.work.meta"
+ And I should not see "Show warnings"
+ And I should see "Scary tag" within "dl.work.meta"
+ And I should not see "Show additional tags"
+
+ # Freeforms and warnings are hidden in work blurbs, except on user's own
+ # works.
+ When I go to someone_else's works page
+ Then I should see "Someone Else's Work"
+ And I should not see "No Archive Warnings Apply" within "li.warnings"
+ And I should see "Show warnings"
+ And I should not see "Scary tag" within "li.freeforms"
+ And I should see "Show additional tags"
+ When I follow "Show warnings"
+ Then I should see "No Archive Warnings Apply" within "li.warnings"
+ And I should not see "Scary tag" within "li.freeforms"
+ When I follow "Show additional tags"
+ Then I should see "Scary tag" within "li.freeforms"
+ When I go to tester's works page
+ Then I should see "My Work"
+ And I should see "No Archive Warnings Apply" within "li.warnings"
+ And I should not see "Show warnings"
+ And I should see "Scary tag" within "li.freeforms"
+ And I should not see "Show additional tags"
+
+ # Freeforms and warnings are hidden in series blurbs.
+ When I go to someone_else's series page
+ Then I should see "A Series"
+ And I should not see "No Archive Warnings Apply" within "li.warnings"
+ And I should see "Show warnings"
+ And I should not see "Scary tag" within "li.freeforms"
+ And I should see "Show additional tags"
+ When I follow "Show warnings"
+ Then I should see "No Archive Warnings Apply" within "li.warnings"
+ And I should not see "Scary tag" within "li.freeforms"
+ When I follow "Show additional tags"
+ Then I should see "Scary tag" within "li.freeforms"
+
+ # Freeforms and warnings are hidden in bookmark blurbs.
+ When I go to tester's bookmarks page
+ Then I should see "Someone Else's Work"
+ And I should not see "No Archive Warnings Apply" within "li.warnings"
+ And I should see "Show warnings"
+ And I should not see "Scary tag" within "li.freeforms"
+ And I should see "Show additional tags"
+ When I follow "Show warnings"
+ Then I should see "No Archive Warnings Apply" within "li.warnings"
+ And I should not see "Scary tag" within "li.freeforms"
+ When I follow "Show additional tags"
+ Then I should see "Scary tag" within "li.freeforms"
+
+ # Change tester's preferences to show warnings but keep freeforms hidden.
+ When I follow "My Preferences"
+ And I uncheck "Hide warnings"
+ And I press "Update"
+ Then I should see "Your preferences were successfully updated"
+
+ # Freeforms are hidden on work meta, except on user's own works.
+ When I view the work "Someone Else's Work"
+ Then I should see "No Archive Warnings Apply" within "dl.work.meta"
+ And I should not see "Show warnings"
+ And I should see "Show additional tags"
+ And I should not see "Scary tag" within "dl.work.meta"
+ When I follow "Show additional tags"
+ Then I should see "Scary tag" within "dl.work.meta"
+ When I follow "Scary tag" within "dl.work.meta"
+ Then I should be on the works tagged "Scary tag"
+ When I view the work "My Work"
+ Then I should see "No Archive Warnings Apply" within "dl.work.meta"
+ And I should not see "Show warnings"
+ And I should see "Scary tag" within "dl.work.meta"
+ And I should not see "Show additional tags"
+
+ # Freeforms are hidden in work blurbs, except on user's own works.
+ When I go to someone_else's works page
+ Then I should see "Someone Else's Work"
+ And I should see "No Archive Warnings Apply" within "li.warnings"
+ And I should not see "Show warnings"
+ And I should not see "Scary tag" within "li.freeforms"
+ And I should see "Show additional tags"
+ When I follow "Show additional tags"
+ Then I should see "Scary tag" within "li.freeforms"
+ When I follow "Scary tag" within "li.freeforms"
+ Then I should be on the works tagged "Scary tag"
+ When I go to tester's works page
+ Then I should see "My Work"
+ And I should see "No Archive Warnings Apply" within "li.warnings"
+ And I should not see "Show warnings"
+ And I should see "Scary tag" within "li.freeforms"
+ And I should not see "Show additional tags"
+
+ # Freeforms are hidden in series blurbs.
+ When I go to someone_else's series page
+ Then I should see "A Series"
+ And I should see "No Archive Warnings Apply" within "li.warnings"
+ And I should not see "Show warnings"
+ And I should not see "Scary tag" within "li.freeforms"
+ And I should see "Show additional tags"
+ When I follow "Show additional tags"
+ Then I should see "Scary tag" within "li.freeforms"
+ When I follow "Scary tag" within "li.freeforms"
+ Then I should be on the works tagged "Scary tag"
+
+ # Freeforms are hidden in bookmark blurbs.
+ When I go to tester's bookmarks page
+ Then I should see "Someone Else's Work"
+ And I should see "No Archive Warnings Apply" within "li.warnings"
+ And I should not see "Show warnings"
+ And I should not see "Scary tag" within "li.freeforms"
+ And I should see "Show additional tags"
+ When I follow "Show additional tags"
+ Then I should see "Scary tag" within "li.freeforms"
+ When I follow "Scary tag" within "li.freeforms"
+ Then I should be on the works tagged "Scary tag"
+
+ Scenario: User can hide warning and freeform tags on work blurbs and meta with
+ JavaScript disabled, but gets an error if they attempt to reveal them.
+
+ Given I am logged in as "first_user"
+ And I post the work "Asteroid Blues" with fandom "Cowboy Bebop" with freeform "Ed is a sweetie"
+ When I am logged in
+ And I set my preferences to hide both warnings and freeforms
+ And I go to first_user's works page
+
+ # Check hidden tags on the blurb
+ Then I should see "Asteroid Blues"
+ And I should not see "No Archive Warnings Apply" within "li.warnings"
+ And I should not see "Ed is a sweetie"
+ When I follow "Show additional tags"
+ Then I should see "Sorry, you need to have JavaScript enabled for this."
+ And I should see "Show additional tags"
+ When I follow "Show warnings"
+ Then I should see "Sorry, you need to have JavaScript enabled for this."
+ And I should see "Show warnings"
+
+ # Check hidden tags in the meta
+ When I view the work "Asteroid Blues"
+ And I follow "Show additional tags"
+ Then I should see "Sorry, you need to have JavaScript enabled for this."
+ And I should see "Show additional tags"
+ When I follow "Show warnings"
+ Then I should see "Sorry, you need to have JavaScript enabled for this."
+ And I should see "Show warnings"
+
+ Scenario: User can hide warning and freeform tags on series blurbs with
+ JavaScript disabled, but gets an error if they attempt to reveal them.
+
+ Given I am logged in as "first_user"
+ And I post the work "Asteroid Blues" with fandom "Cowboy Bebop" with freeform "Ed is a sweetie" as part of a series "Cowboy Bebop Blues"
+ And I post the work "Wild Horses" with fandom "Cowboy Bebop" with freeform "Faye Valentine is a sweetie" as part of a series "Cowboy Bebop Blues"
+ When I am logged in
+ And I set my preferences to hide both warnings and freeforms
+ And I go to first_user's series page
+ Then I should see "Cowboy Bebop Blues"
+ And I should not see "No Archive Warnings Apply" within "li.warnings"
+ And I should not see "Ed is a sweetie"
+ And I should not see "Faye Valentine is a sweetie"
+ When I follow "Show additional tags"
+ Then I should see "Sorry, you need to have JavaScript enabled for this."
+ And I should see "Show additional tags"
+ When I follow "Show warnings"
+ Then I should see "Sorry, you need to have JavaScript enabled for this."
+ And I should see "Show warnings"
diff --git a/features/other_a/preferences_edit_more.feature b/features/other_a/preferences_edit_more.feature
new file mode 100755
index 0000000..31e95ee
--- /dev/null
+++ b/features/other_a/preferences_edit_more.feature
@@ -0,0 +1,73 @@
+@users
+Feature: Preferences
+
+ Scenario: View and edit preferences - show/hide mature content warning
+
+ Given I am logged in as "mywarning1"
+ And I post the work "Adult Work by mywarning1" with rating "Mature"
+ When I am logged in as "mywarning2"
+ And I follow "My Preferences"
+ And I check "Show me adult content without checking."
+ And I press "Update"
+ Then I should see "Your preferences were successfully updated"
+ When I go to the works page
+ And I follow "Adult Work by mywarning1"
+ Then I should not see "adult content"
+ And I should see "Rating: Mature"
+ When I follow "My Preferences"
+ And I uncheck "Show me adult content without checking."
+ And I press "Update"
+ Then I should see "Your preferences were successfully updated"
+ When I go to the works page
+ And I follow "Adult Work by mywarning1"
+ Then I should see "adult content"
+ And I should not see "Rating: Mature"
+
+ Scenario: set preference to hide custom css on stories
+ Given basic tags
+ And basic skins
+ And I am logged in as "tasteless"
+ When I set up the draft "Big and Loud"
+ And I select "Basic Formatting" from "work_work_skin_id"
+ And I press "Preview"
+ And I press "Post"
+ And I go to the "Big and Loud" work page
+ Then I should see "#workskin .font-murkyyellow" in the page style
+ And I should see "Hide Creator's Style"
+ When I follow "My Preferences"
+ Then the "Hide work skins (you can still choose to show them)." checkbox should not be checked
+ When I check "Hide work skins (you can still choose to show them)."
+ And I press "Update"
+ When I go to the "Big and Loud" work page
+ Then I should not see "#workskin .font-murkyyellow"
+ And I should not see "Hide Creator's Style"
+ And I should see "Show Creator's Style"
+ When I follow "Creator's Style"
+ Then I should see "#workskin .font-murkyyellow" in the page style
+ And I should see "Hide Creator's Style"
+ Given I am logged out
+ And I am logged in as "tasteful"
+ And I go to the "Big and Loud" work page
+
+ Scenario: Hidden users' user, works, and series pages are disallowed for search engine indexing
+ Given I am logged in as "hidden"
+ And the user "hidden" is hidden from search engines
+ And I post the work "Hidden Work" as part of a series "Hidden Series"
+ And I am logged out
+ When I go to hidden's user page
+ Then the page should be hidden from search engines
+ When I view the work "Hidden Work"
+ Then the page should be hidden from search engines
+ When I view the series "Hidden Series"
+ Then the page should be hidden from search engines
+
+ Scenario: Unhidden users' user, works, and series pages are allowed for search engine indexing
+ Given I am logged in as "unhidden"
+ And I post the work "Unhidden Work" as part of a series "Unhidden Series"
+ And I am logged out
+ When I go to unhidden's user page
+ Then the page should not be hidden from search engines
+ When I view the work "Unhidden Work"
+ Then the page should not be hidden from search engines
+ When I view the series "Unhidden Series"
+ Then the page should not be hidden from search engines
\ No newline at end of file
diff --git a/features/other_a/profile_edit.feature b/features/other_a/profile_edit.feature
new file mode 100755
index 0000000..a5930a8
--- /dev/null
+++ b/features/other_a/profile_edit.feature
@@ -0,0 +1,229 @@
+@users
+Feature: Edit profile
+ In order to have a presence on the archive
+ As a humble user
+ I want to fill out and edit my profile
+
+Background:
+ Given the following activated user exists
+ | login | password | email |
+ | editname | password | bar@ao3.org |
+ And I am logged in as "editname"
+ And I want to edit my profile
+
+Scenario: Add details
+ Then I should see the page title "Edit Profile"
+ When I fill in the details of my profile
+ Then I should see "Your profile has been successfully updated"
+ And 0 emails should be delivered
+ And I should see "Test title thingy"
+ And I should see "This is some text about me."
+
+Scenario: Change details
+ When I change the details in my profile
+ Then I should see "Your profile has been successfully updated"
+ And 0 emails should be delivered
+ And I should see "Alternative title thingy"
+ And I should see "This is some different text about me."
+
+Scenario: Remove details
+ When I remove details from my profile
+ Then I should see "Your profile has been successfully updated"
+ And 0 emails should be delivered
+ And I should not see "Bio"
+
+Scenario: Change details as an admin
+
+ Given I am logged in as a "policy_and_abuse" admin
+ And an abuse ticket ID exists
+ When I go to editname profile page
+ And I follow "Edit Profile"
+ And I fill in "About Me" with "is it merely thy habit, to talk to dolls?"
+ And I fill in "Ticket ID" with "fine"
+ And I press "Update"
+ Then I should see "may begin with an # and otherwise contain only numbers."
+ And the field labeled "Ticket ID" should contain "fine"
+ When I fill in "Ticket ID" with "480000"
+ And I press "Update"
+ Then I should see "Your profile has been successfully updated"
+ And I should see "is it merely thy habit, to talk to dolls?"
+ When I visit the last activities item
+ Then I should see "edit profile"
+ And I should see a link "Ticket #480000"
+
+ # Skip logging admin activity if no change was actually made.
+ When I go to editname profile page
+ And I follow "Edit Profile"
+ And I fill in "Ticket ID" with "480000"
+ And I press "Update"
+ Then I should see "Your profile has been successfully updated"
+ When I go to the admin-activities page
+ Then I should see 1 admin activity log entry
+
+Scenario: Changing email address shows a confirmation page and sends a confirmation mail
+ When it is currently 2020-04-10 13:37
+ And the email address change confirmation period is set to 4 days
+ And I follow "Profile"
+ And I follow "Edit My Profile"
+ And I follow "Change Email"
+ And I fill in "New email" with "valid2@archiveofourown.org"
+ And I fill in "Enter new email again" with "valid2@archiveofourown.org"
+ And I fill in "Password" with "password"
+ And I press "Confirm New Email"
+ Then I should see "Are you sure you want to change your email address to valid2@archiveofourown.org?"
+ And I should see "If you don't confirm your request within 4 days"
+ And 0 emails should be delivered
+ When I press "Yes, Change Email"
+ Then I should see "You have requested to change your email address to valid2@archiveofourown.org."
+ And I should see "If you don't confirm your request by Tue, 14 Apr 2020"
+ And I should see "bar@ao3.org"
+ And 1 email should be delivered to "bar@ao3.org"
+ And the email should contain "Someone has made a request to change the email address associated with your AO3 account."
+ And the email should contain "valid2@archiveofourown.org"
+ And 1 email should be delivered to "valid2@archiveofourown.org"
+ And the email should contain "request to change the email address associated with the AO3 account"
+ And the email should contain "editname"
+
+ When I am a visitor
+ And I follow "confirm your email change" in the email
+ Then I should see "Sorry, you don't have permission to access the page you were trying to reach. Please log in."
+ When I am logged in as "editname"
+ And I go to editname's profile page
+ And I follow "Edit My Profile"
+ And I follow "Change Email"
+ Then I should see "bar@ao3.org"
+
+ When I am logged in as "editname"
+ And I follow "confirm your email change" in the email
+ Then I should see "Your email has been successfully updated."
+ And I should see "valid2@archiveofourown.org"
+ But I should not see "bar@ao3.org"
+ But I should not see "You have requested to change your email address"
+ When I go to editname's profile page
+ And I follow "Edit My Profile"
+ And I follow "Change Email"
+ Then I should see "valid2@archiveofourown.org"
+
+Scenario: Changing email address -- canceling in confirmation step
+
+ When I follow "Change Email"
+ And I start to change my email to "valid2@archiveofourown.org"
+ Then I should see "Are you sure you want to change your email address"
+ And 0 emails should be delivered
+ When I follow "Cancel"
+ Then I should see "Change Email" within "h2.heading"
+ And 0 emails should be delivered
+ And I should not see "You have requested to change your email address"
+
+Scenario: Changing email address -- request expires
+
+ When it is currently 2020-04-10 13:37
+ And the email address change confirmation period is set to 4 days
+ And I follow "Change Email"
+ And I request to change my email to "valid2@archiveofourown.org"
+ Then I should see "If you don't confirm your request by Tue, 14 Apr 2020"
+ And 1 email should be delivered to "valid2@archiveofourown.org"
+ And the email should contain "request to change the email address"
+ And I should see "You have requested to change your email address"
+
+ When it is currently 2020-04-15 14:00
+ And I follow "My Preferences"
+ And I follow "Change Email"
+ Then I should not see "You have requested to change your email address"
+ And I should see "bar@ao3.org"
+ But I should not see "valid2@archiveofourown.org"
+ When I follow "confirm your email change" in the email
+ Then I should see "This email confirmation link is invalid or expired. Please check your email for the correct link or submit the email change form again."
+ And I should see "bar@ao3.org"
+ But I should not see "valid2@archiveofourown.org"
+
+Scenario: Changing email address -- resubmitting form changes target email and expiration date
+
+ When it is currently 2020-04-10 13:37
+ And the email address change confirmation period is set to 4 days
+ And I follow "Change Email"
+ And I request to change my email to "valid2@archiveofourown.org"
+ Then I should see "If you don't confirm your request by Tue, 14 Apr 2020"
+ And 1 email should be delivered to "bar@ao3.org"
+ And 1 email should be delivered to "valid2@archiveofourown.org"
+ And the email should contain "request to change the email address"
+
+ When it is currently 2020-04-12 14:00
+ And I request to change my email to "another@archiveofourown.org"
+ Then I should see "You have requested to change your email address to another@archiveofourown.org."
+ And I should see "If you don't confirm your request by Thu, 16 Apr 2020"
+ # The original email gets another notification
+ And 2 emails should be delivered to "bar@ao3.org"
+ # Old link should be invalid
+ And 1 email should be delivered to "valid2@archiveofourown.org"
+ When I follow "confirm your email change" in the email
+ Then I should see "This email confirmation link is invalid or expired. Please check your email for the correct link or submit the email change form again."
+ And I should see "bar@ao3.org"
+ And I should see "You have requested to change your email address to another@archiveofourown.org"
+ But I should not see "valid2@archiveofourown.org"
+ # Newest email gets new link that should work
+ And 1 email should be delivered to "another@archiveofourown.org"
+ And the email should contain "request to change the email address"
+ When I follow "confirm your email change" in the email
+ Then I should see "Your email has been successfully updated."
+ And I should see "another@archiveofourown.org"
+ But I should not see "valid2@archiveofourown.org"
+ But I should not see "bar@ao3.org"
+
+Scenario: Changing email address -- after requesting password reset
+
+ When I am logged out
+ And I follow "Forgot password?"
+ And I fill in "Email address or username" with "editname"
+ And I press "Reset Password"
+ Then 1 email should be delivered to "bar@ao3.org"
+ When all emails have been delivered
+ And I am logged in as "editname"
+ And I follow "My Preferences"
+ And I follow "Change Email"
+ And I request to change my email to "valid2@archiveofourown.org"
+ Then I should see "You have requested to change your email address to valid2@archiveofourown.org."
+ And 1 email should be delivered to "bar@ao3.org"
+ And 1 email should be delivered to "valid2@archiveofourown.org"
+ When I follow "confirm your email change" in the email
+ Then I should see "Your email has been successfully updated."
+ And I should see "valid2@archiveofourown.org"
+ But I should not see "bar@ao3.org"
+
+Scenario: Changing email address -- translated emails are sent when user enables locale settings
+ Given a locale with translated emails
+ And the user "editname" enables translated emails
+ And all emails have been delivered
+ When I am logged in as "editname"
+ And I follow "My Preferences"
+ And I follow "Change Email"
+ And I request to change my email to "valid2@archiveofourown.org"
+ Then the email address "bar@ao3.org" should be emailed
+ And the email should have "Email change request" in the subject
+ And the email to email address "bar@ao3.org" should be translated
+ And 1 email should be delivered to "valid2@archiveofourown.org"
+ And the email should have "Confirm your email change" in the subject
+ And the email to email address "valid2@archiveofourown.org" should be translated
+
+Scenario: Change password - mistake in typing old password
+
+ When I make a mistake typing my old password
+ Then I should see "Your old password was incorrect"
+
+Scenario: Change password - mistake in typing new password confirmation
+
+ When I make a typing mistake confirming my new password
+ Then I should see "Password confirmation doesn't match new password."
+
+Scenario: Change password
+
+ When it is currently 2025-04-12 17:00 UTC
+ And I change my password
+ Then I should see "Your password has been changed. To protect your account, you have been logged out of all active sessions. Please log in with your new password."
+ And 1 email should be delivered to "editname"
+ And the email should have "Your password has been changed" in the subject
+ And the email should contain "The password for your AO3 account was changed on Sat, 12 Apr 2025 17:00:\d+ \+0000"
+ When I am logged in as a super admin
+ And I go to the user administration page for "editname"
+ Then I should see "Password Changed" within "#user_history"
+ But I should not see "Password Reset" within "#user_history"
diff --git a/features/other_a/pseud_dashboard.feature b/features/other_a/pseud_dashboard.feature
new file mode 100755
index 0000000..029df57
--- /dev/null
+++ b/features/other_a/pseud_dashboard.feature
@@ -0,0 +1,107 @@
+@users
+Feature: Pseud dashboard
+ In order to have an archive full of users
+ As a humble user
+ I want to write some works and see my dashboard
+
+ Scenario: Fandoms on pseud dashboard
+
+ Given the following activated user exists
+ | login | password |
+ | myself | password |
+ Given the following activated tag wrangler exists
+ | login | password |
+ | Enigel | wrangulate! |
+
+ # set up metatag and synonym
+
+ When I am logged in as "Enigel" with password "wrangulate!"
+ And a fandom exists with name: "Stargate SG-1", canonical: true
+ And a fandom exists with name: "Stargatte SG-oops", canonical: false
+ And a fandom exists with name: "Stargate Franchise", canonical: true
+ And I edit the tag "Stargate SG-1"
+ Then I should see "Edit Stargate SG-1 Tag"
+ And I should see "MetaTags"
+ When I fill in "MetaTags" with "Stargate Franchise"
+ And I press "Save changes"
+ Then I should see "Tag was updated"
+ When I edit the tag "Stargatte SG-oops"
+ And I fill in "Synonym" with "Stargate SG-1"
+ And I press "Save changes"
+ Then I should see "Tag was updated"
+
+ When I log out
+ Then I should see "Sorry, you don't have permission to access the page you were trying to reach. Please log in."
+
+ # set up pseuds
+
+ When I am logged in as "myself" with password "password"
+ And I go to myself's pseuds page
+ Then I should see "Default Pseud" within "div#main.pseuds-index"
+ When I follow "New Pseud"
+ And I fill in "Name" with "Me"
+ And I check "pseud_is_default"
+ And I fill in "Description" with "Something's cute"
+ And I press "Create"
+ Then I should see "Pseud was successfully created."
+
+ # view main dashboard - when posting a work with the canonical, metatag and synonym should not be seen
+
+ When I follow "myself"
+ Then I should see "Dashboard"
+ And I should see "You don't have anything posted under this name yet"
+ And I should not see "Revenge of the Sith"
+ And I should not see "Stargate"
+ When I post the work "Revenge of the Sith"
+ And I follow "myself"
+ Then I should see "Stargate"
+ And I should see "SG-1" within "#user-fandoms"
+ And I should not see "Stargate Franchise"
+ And I should not see "Stargatte SG-oops"
+
+ # check on pseud that posted the work
+
+ When I follow "Me" within ".pseud .expandable li"
+ Then I should see "Stargate"
+ And I should see "SG-1" within "#user-fandoms"
+ And I should not see "Stargate Franchise"
+ And I should not see "Stargatte SG-oops"
+
+ # check on pseud that didn't post the work
+ When I follow "myself" within "div#dashboard ul.expandable.secondary"
+ Then I should not see "Stargate"
+ And I should not see "SG-1"
+ And I should not see "Stargate Franchise"
+ And I should not see "Stargatte SG-oops"
+
+ # now using the synonym - canonical should be seen, but metatag still not seen
+
+ When I edit the work "Revenge of the Sith"
+ And I fill in "Fandoms" with "Stargatte SG-oops"
+ And I press "Preview"
+ And I press "Update"
+ Then I should see "Work was successfully updated"
+ When I follow "myself"
+ Then I should see "Stargate"
+ And I should see "SG-1" within "#user-fandoms"
+ And I should not see "Stargate Franchise"
+ And I should not see "Stargatte SG-oops" within "#user-fandoms"
+ And I should see "Stargatte SG-oops"
+
+ # check on pseud that posted the work
+
+ When I follow "Me" within ".pseud .expandable li"
+ Then I should see "Stargate"
+ And I should see "SG-1" within "#user-fandoms"
+ And I should not see "Stargate Franchise"
+ And I should not see "Stargatte SG-oops" within "#user-fandoms"
+ And I should see "Stargatte SG-oops"
+
+ # check on pseud that didn't post the work
+
+ When I follow "myself" within "div#dashboard ul.expandable.secondary"
+ Then I should not see "Stargate"
+ And I should not see "SG-1"
+ And I should not see "Stargate Franchise"
+ And I should not see "Stargatte SG-oops"
+
diff --git a/features/other_a/pseud_delete.feature b/features/other_a/pseud_delete.feature
new file mode 100755
index 0000000..225ad6f
--- /dev/null
+++ b/features/other_a/pseud_delete.feature
@@ -0,0 +1,209 @@
+@users
+Feature: Delete pseud.
+ In order to tidy some mess
+ As a humble user
+ I want to delete a pseud
+
+ Scenario: Delete pseud, have option to move works, delete works, or orphan works. Test if those choices work.
+ Given the user "testuser" exists and is activated
+ And "testuser" has the pseud "tester_pseud"
+ And "testuser" has the pseud "testy"
+ And "testuser" has the pseud "testymctesty"
+ And pseud "tester_pseud" has a bookmark of a work titled "one" by "testuser2"
+ And pseud "testy" has a bookmark of a work titled "two" by "testuser2"
+ And pseud "testymctesty" has a bookmark of a work titled "three" by "testuser2"
+ When I am logged in as "sad_user_with_no_pseuds"
+ And I am on sad_user_with_no_pseuds's pseuds page
+ Then I should not see "Delete"
+
+ When I am logged in as "testuser"
+ And I am on testuser's pseuds page
+ And I follow "delete_tester_pseud"
+ And I press "Cancel"
+ Then I should see "The pseud was not deleted."
+ When I am on testuser's pseuds page
+ And I follow "tester_pseud"
+ Then I should see "one by testuser2"
+
+ When I am on testuser's pseuds page
+ And I follow "delete_tester_pseud"
+ And I choose "Delete this bookmark"
+ And I press "Submit"
+ Then I should see "The pseud was successfully deleted."
+ When I am on testuser's pseuds page
+ Then I should not see "tester_pseud"
+
+ When I follow "delete_testy"
+ And I choose "Transfer this bookmark to the default pseud"
+ And I press "Submit"
+ Then I should see "The pseud was successfully deleted."
+ When I am on testuser's pseuds page
+ And I follow "testymctesty"
+ Then I should see "three by testuser2"
+
+ Scenario: Deleting a pseud shouldn't break gift exchange signups.
+ Given I am logged in as "moderator"
+ And I set up the collection "Exchange1"
+ And I select "Gift Exchange" from "Type of challenge"
+ And I press "Submit"
+ And I check "Sign-up open?"
+ And I press "Submit"
+ And I am logged in as "test"
+ And "test" creates the pseud "testpseud"
+
+ When I start signing up for "Exchange1"
+ And I select "testpseud" from "challenge_signup_pseud_id"
+ And I fill in "Description:" with "Antidisestablishmentarianism."
+ And I press "Submit"
+ Then I should see "Sign-up was successfully created."
+
+ When I view the collection "Exchange1"
+ And I follow "My Sign-up"
+ Then I should see "Antidisestablishmentarianism."
+
+ When I am on test's pseuds page
+ And I follow "Delete"
+ Then I should see "The pseud was successfully deleted."
+
+ When I view the collection "Exchange1"
+ And I follow "My Sign-up"
+ Then I should see "Antidisestablishmentarianism."
+
+ Scenario: Deleting a pseud shouldn't break prompt meme signups.
+ Given I am logged in as "moderator"
+ And I set up the collection "PromptsGalore"
+ And I select "Prompt Meme" from "challenge_type"
+ And I press "Submit"
+ And I press "Submit"
+ And I am logged in as "test"
+ And "test" creates the pseud "testpseud"
+
+ When I start signing up for "PromptsGalore"
+ And I select "testpseud" from "challenge_signup_pseud_id"
+ And I fill in "Description:" with "Antidisestablishmentarianism."
+ And I press "Submit"
+ Then I should see "Sign-up was successfully created."
+
+ When I view the collection "PromptsGalore"
+ And I follow "My Prompts"
+ Then I should see "Antidisestablishmentarianism."
+
+ When I am on test's pseuds page
+ And I follow "Delete"
+ Then I should see "The pseud was successfully deleted."
+
+ When I view the collection "PromptsGalore"
+ And I follow "My Prompts"
+ Then I should see "Antidisestablishmentarianism."
+
+ Scenario: Deleting a pseud should preserve approved creatorships even if the default pseud has a request for the same work.
+ Given I am logged in as "original_pseud"
+ And "original_pseud" creates the pseud "other_pseud"
+ And I am logged in as "coauthor"
+ And the user "original_pseud" allows co-creators
+
+ When I set up the draft "Original Invited"
+ And I try to invite the co-authors "original_pseud, other_pseud"
+ And I press "Post"
+ Then I should see "Work was successfully posted."
+
+ When I am logged in as "original_pseud"
+ And I go to original_pseud's co-creator requests page
+ Then I should see "Co-Creator Requests (2)"
+
+ When I check the 1st checkbox with id matching "selected"
+ And I press "Accept"
+ Then I should see "You are now listed as a co-creator on Original Invited."
+ And I should see "original_pseud" within ".creatorships"
+ And I should see "Original Invited" within ".creatorships"
+ And I should see "Co-Creator Requests (1)"
+
+ When I view the work "Original Invited"
+ Then I should see "Edit"
+ And I should see "You've been invited to become a co-creator of this work."
+
+ When I go to original_pseud's pseuds page
+ And I follow "Delete"
+ Then I should see "The pseud was successfully deleted."
+
+ When I view the work "Original Invited"
+ Then I should see "Edit"
+ But I should not see "You've been invited to become a co-creator of this work."
+
+ Scenario: Deleting a pseud should preserve co-creator requests.
+ Given I am logged in as "original_pseud"
+ And "original_pseud" creates the pseud "other_pseud"
+ And I am logged in as "coauthor"
+ And the user "original_pseud" allows co-creators
+
+ When I set up the draft "Other Invited"
+ And I try to invite the co-author "other_pseud"
+ And I press "Post"
+ Then I should see "Work was successfully posted."
+
+ When I am logged in as "original_pseud"
+ And I go to original_pseud's co-creator requests page
+ Then I should see "other_pseud" within ".creatorships"
+ And I should see "Other Invited" within ".creatorships"
+ And I should see "Co-Creator Requests (1)"
+
+ When I go to original_pseud's pseuds page
+ And I follow "Delete"
+ Then I should see "The pseud was successfully deleted."
+
+ # We should still have a request for Other Invited:
+ When I go to original_pseud's co-creator requests page
+ Then I should see "Other Invited" within ".creatorships"
+ And I should see "original_pseud" within ".creatorships"
+ And I should see "Co-Creator Requests (1)"
+ And I should not see "other_pseud" within ".creatorships"
+
+ Scenario: Collections reflect pseud deletion of the owner after the cache expires
+
+ When I am logged in as "original_pseud"
+ And "original_pseud" creates the pseud "other_pseud"
+ And I set up the collection "My Collection Thing"
+ And I select "other_pseud" from "Owner pseud(s)"
+ And I unselect "original_pseud" from "Owner pseud(s)"
+ And I press "Submit"
+ And I go to the collections page
+ Then I should see "My Collection Thing"
+ And I should see "other_pseud (original_pseud)" within "#main"
+
+ When "original_pseud" deletes the pseud "other_pseud"
+ And I go to the collections page
+ Then I should see "My Collection Thing"
+ And I should see "other_pseud (original_pseud)" within "#main"
+ When the collection blurb cache has expired
+ And I go to the collections page
+ Then I should see "My Collection Thing"
+ And I should see "original_pseud" within "#main"
+ And I should not see "other_pseud" within "#main"
+
+
+ Scenario: Collections reflect pseud deletion of moderators after the cache expires
+
+ Given "myself" has the pseud "other_pseud"
+ When I have the collection "My Collection Thing"
+ And I am logged in as the owner of "My Collection Thing"
+ And I am on the "My Collection Thing" participants page
+ And I fill in "participants_to_invite" with "other_pseud (myself)"
+ And I press "Submit"
+ Then I should see "New members invited: other_pseud (myself)"
+ When I select "Moderator" from "myself_role"
+ And I submit with the 4th button
+ Then I should see "Updated other_pseud."
+ When I go to the collections page
+ Then I should see "My Collection Thing"
+ And I should see "other_pseud (myself)" within "#main"
+
+ When I am logged in as "myself"
+ And "myself" deletes the pseud "other_pseud"
+ And I go to the collections page
+ Then I should see "My Collection Thing"
+ And I should see "other_pseud (myself)" within "#main"
+ When the collection blurb cache has expired
+ And I go to the collections page
+ Then I should see "My Collection Thing"
+ And I should not see "other_pseud (myself)" within "#main"
+ And I should see "myself" within "#main"
diff --git a/features/other_a/pseuds.feature b/features/other_a/pseuds.feature
new file mode 100755
index 0000000..1dba626
--- /dev/null
+++ b/features/other_a/pseuds.feature
@@ -0,0 +1,330 @@
+@users
+Feature: Pseuds
+
+Scenario: pseud creation and playing with the default pseud
+
+ Given I am logged in as "myself"
+ And I go to myself's pseuds page
+
+ # Check that you can't edit your default pseud.
+ Then I should see "Default Pseud"
+ When I follow "Edit"
+ Then I should see "You cannot change the pseud that matches your username."
+ And the "Make this name default" checkbox should be checked and disabled
+
+ # Make a new default pseud called "Me."
+ When I follow "Back To Pseuds"
+ And I follow "New Pseud"
+ And I fill in "Name" with "Me"
+ And I check "Make this name default"
+ And I fill in "Description" with "Something's cute"
+ And I press "Create"
+ Then I should see "Pseud was successfully created."
+ And I should be on the dashboard page for user "myself" with pseud "Me"
+
+ # Make sure the new "Me" pseud is the default.
+ When I follow "Edit Pseud"
+ Then I should see "Me"
+ And the "Make this name default" checkbox should not be disabled
+ And the "Make this name default" checkbox should be checked
+
+ # Make sure the old "myself" pseud is no longer the default.
+ When I follow "Back To Pseuds"
+ And I follow "edit_myself"
+ Then the "Make this name default" checkbox should not be checked
+ And the "Make this name default" checkbox should not be disabled
+
+ # Edit "Me" to remove it as your default pseud.
+ When I follow "Back To Pseuds"
+ And I follow "Me"
+ Then I should be on the dashboard page for user "myself" with pseud "Me"
+ When I follow "Edit Pseud"
+ And I uncheck "Make this name default"
+ And I press "Update"
+ Then I should see "Pseud was successfully updated."
+ And I should be on the dashboard page for user "myself" with pseud "Me"
+
+ # Make sure "Me" is no longer the default pseud, but "myself" is.
+ When I follow "Edit Pseud"
+ Then the "Make this name default" checkbox should not be checked
+ When I follow "Back To Pseuds"
+ And I follow "edit_myself"
+ Then the "Make this name default" checkbox should be checked and disabled
+
+ # Test the pseud update path by making Me the default pseud once again.
+ When I follow "Back To Pseuds"
+ And I follow "Me"
+ And I follow "Edit Pseud"
+ And I check "Make this name default"
+ And I press "Update"
+ Then I should see "Pseud was successfully updated."
+ And I should be on the dashboard page for user "myself" with pseud "Me"
+ When I follow "Edit Pseud"
+ Then the "Make this name default" checkbox should be checked
+
+Scenario: Manage pseuds - add, edit
+
+ Given I am logged in as "editpseuds"
+
+ # Check the Manage My Pseuds link in the profile works.
+ When I go to editpseuds's user page
+ And I follow "Profile"
+ And I follow "Manage My Pseuds"
+ Then I should see "Pseuds for editpseuds"
+
+ # Make a new pseud.
+ When I follow "New Pseud"
+ And I fill in "Name" with "My new name"
+ And I fill in "Description" with "I wanted to add another name"
+ And I press "Create"
+ Then I should be on the dashboard page for user "editpseuds" with pseud "My new name"
+ And I should see "Pseud was successfully created."
+ And I should see "My new name"
+ And I should see "You don't have anything posted under this name yet."
+
+ # Check that all pseuds are listed on user's pseuds page.
+ When I follow "Back To Pseuds"
+ Then I should see "editpseuds (editpseuds)"
+ And I should see "My new name (editpseuds)"
+ And I should see "I wanted to add another name"
+ And I should see "Default Pseud"
+
+ # Try to create another pseud with the same name you already used.
+ When I follow "New Pseud"
+ Then I should see "New pseud"
+ When I fill in "Name" with "My new name"
+ And I press "Create"
+ Then I should see "You already have a pseud with that name."
+
+ # Recheck various links.
+ When I follow "Back To Pseuds"
+ And I follow "editpseuds"
+ And I follow "Profile"
+ And I follow "Manage My Pseuds"
+ Then I should see "Edit My new name"
+
+ # Edit your new pseud's name and description.
+ When I follow "edit_my_new_name"
+ And I fill in "Description" with "I wanted to add another fancy name"
+ And I fill in "Name" with "My new fancy name"
+ And I press "Update"
+ Then I should see "Pseud was successfully updated."
+ And I should be on the dashboard page for user "editpseuds" with pseud "My new fancy name"
+
+ # Check that the changes to your pseud show up on your pseuds page.
+ When I follow "Back To Pseuds"
+ Then I should see "editpseuds (editpseuds)"
+ And I should see "My new fancy name (editpseuds)"
+ And I should see "I wanted to add another fancy name"
+ And I should not see "My new name (editpseuds)"
+
+Scenario: Pseud descriptions do not display images
+
+ Given I am logged in as "myself"
+ And I go to myself's pseuds page
+ When I follow "Edit"
+ And I fill in "Description" with "Fantastic!"
+ And I press "Update"
+ Then I should see "Pseud was successfully updated."
+ When I follow "Back To Pseuds"
+ Then I should not see the image "src" text "http://example.com/icon.svg"
+ And I should see "Fantastic!"
+
+Scenario: Comments reflect pseud changes immediately
+
+ Given the work "Interesting"
+ And I am logged in as "myself"
+ And "myself" creates the pseud "before"
+ When I set up the comment "Wow!" on the work "Interesting"
+ And I select "before" from "comment[pseud_id]"
+ And I press "Comment"
+ And I view the work "Interesting" with comments
+ Then I should see "before (myself)" within ".comment h4.byline"
+
+ When it is currently 1 second from now
+ And "myself" changes the pseud "before" to "after"
+ And I view the work "Interesting" with comments
+ Then I should see "after (myself)" within ".comment h4.byline"
+ And I should not see "before (myself)"
+
+Scenario: Collections reflect pseud changes of the owner after the cache expires
+
+ When I am logged in as "myself"
+ And "myself" creates the pseud "before"
+ And I set up the collection "My Collection Thing"
+ And I select "before" from "Owner pseud(s)"
+ And I unselect "myself" from "Owner pseud(s)"
+ And I press "Submit"
+ And I go to the collections page
+ Then I should see "My Collection Thing"
+ And I should see "before (myself)" within "#main"
+
+ When "myself" changes the pseud "before" to "after"
+ And I go to the collections page
+ Then I should see "My Collection Thing"
+ And I should see "before (myself)" within "#main"
+ When the collection blurb cache has expired
+ And I go to the collections page
+ Then I should see "My Collection Thing"
+ And I should see "after (myself)" within "#main"
+ And I should not see "before (myself)" within "#main"
+
+Scenario: Collections reflect pseud changes of moderators after the cache expires
+
+ Given "myself" has the pseud "before"
+ When I have the collection "My Collection Thing"
+ And I am logged in as the owner of "My Collection Thing"
+ And I am on the "My Collection Thing" participants page
+ And I fill in "participants_to_invite" with "before (myself)"
+ And I press "Submit"
+ Then I should see "New members invited: before (myself)"
+ When I select "Moderator" from "myself_role"
+ And I submit with the 3rd button
+ Then I should see "Updated before."
+ When I go to the collections page
+ Then I should see "My Collection Thing"
+ And I should see "before (myself)" within "#main"
+
+ When I am logged in as "myself"
+ And "myself" changes the pseud "before" to "after"
+ And I go to the collections page
+ Then I should see "My Collection Thing"
+ And I should see "before (myself)" within "#main"
+ When the collection blurb cache has expired
+ And I go to the collections page
+ Then I should see "My Collection Thing"
+ And I should see "after (myself)" within "#main"
+ And I should not see "before (myself)" within "#main"
+
+Scenario: Many pseuds
+
+ Given there are 3 pseuds per page
+ And "Zaphod" has the pseud "Slartibartfast"
+ And "Zaphod" has the pseud "Agrajag"
+ And "Zaphod" has the pseud "Betelgeuse"
+ And I am logged in as "Zaphod"
+
+ When I view my profile
+ Then I should see "Zaphod" within "dl.meta"
+ And I should see "Agrajag" within "dl.meta"
+ And I should see "Betelgeuse" within "dl.meta"
+ And I should not see "Slartibartfast" within "dl.meta"
+ And I should see "1 more pseud" within "dl.meta"
+
+ When I go to Zaphod's user page
+ Then I should see "Zaphod" within "ul.expandable"
+ And I should see "Agrajag" within "ul.expandable"
+ And I should see "Betelgeuse" within "ul.expandable"
+ And I should not see "Slartibartfast" within "ul.expandable"
+ And I should see "All Pseuds (4)" within "ul.expandable"
+
+ When I go to the dashboard page for user "Zaphod" with pseud "Slartibartfast"
+ Then I should see "Pseuds" within "li.pseud > a"
+ And I should see "Slartibartfast" within "ul.expandable"
+
+ When I go to Zaphod's pseuds page
+ Then I should not see "Zaphod (Zaphod)" within "ul.pseud.index"
+ But I should see "Agrajag (Zaphod)" within "ul.pseud.index"
+ And I should see "Betelgeuse (Zaphod)" within "ul.pseud.index"
+ And I should see "Slartibartfast (Zaphod)" within "ul.pseud.index"
+ And I should see "Next" within ".pagination"
+ When I follow "Next" within ".pagination"
+ Then I should see "Zaphod (Zaphod)" within "ul.pseud.index"
+
+ When there are 10 pseuds per page
+ And I view my profile
+ Then I should see "Zaphod, Agrajag, Betelgeuse, and Slartibartfast" within "dl.meta"
+
+Scenario: Edit pseud updates series blurbs
+
+ Given I am logged in as "Myself"
+ And "Myself" creates the pseud "Me2"
+ And I add the work "Great Work" to series "Best Series" as "Me2"
+ When I go to the dashboard page for user "Myself" with pseud "Me2"
+ And I follow "Series"
+ Then I should see "Best Series by Me2 (Myself)"
+
+ When I view my profile
+ And I follow "Manage My Pseuds"
+ And I follow "Edit Me2"
+ And I fill in "Name" with "Me3"
+ And I press "Update"
+ Then I should see "Pseud was successfully updated."
+
+ When I follow "Series"
+ Then I should see "Best Series by Me3 (Myself)"
+
+Scenario: Change details as an admin
+
+ Given "someone" has the pseud "alt"
+ And I am logged in as a "policy_and_abuse" admin
+ And an abuse ticket ID exists
+ When I go to someone's pseuds page
+ And I follow "Edit alt"
+ And I fill in "Description" with "I'd probably be removing text."
+ And I fill in "Ticket ID" with "no 💜"
+ And I press "Update"
+ Then I should see "may begin with an # and otherwise contain only numbers"
+ And the field labeled "Ticket ID" should contain "no 💜"
+ When I fill in "Ticket ID" with "#4798454#331"
+ And I press "Update"
+ Then I should see "may begin with an # and otherwise contain only numbers"
+ And the field labeled "Ticket ID" should contain "4798454#331"
+ When I fill in "Ticket ID" with "47"
+ And I press "Update"
+ Then I should see "Pseud was successfully updated."
+ When I go to someone's pseuds page
+ And I follow "Edit alt"
+ When I fill in "Ticket ID" with "#47"
+ And I press "Update"
+ Then I should see "Pseud was successfully updated."
+ When I go to someone's pseuds page
+ Then I should see "I'd probably be removing text."
+ When I follow "Activities" within ".admin.primary.navigation"
+ Then I should see "Pseud alt (someone)"
+ When I follow "Pseud alt (someone)"
+ Then I should be on someone's pseuds page
+ When I visit the last activities item
+ Then I should see "Pseud alt (someone)"
+ And I should see "edit pseud"
+ And I should see a link "Ticket #47"
+
+ # Skip logging admin activity if no change was actually made.
+ When I go to someone's pseuds page
+ And I follow "Edit alt"
+ And I fill in "Ticket ID" with "47"
+ And I press "Update"
+ Then I should see "Pseud was successfully updated."
+ When I go to the admin-activities page
+ Then I should see 1 admin activity log entry
+
+Scenario: Bookmarks reflect pseud changes immediately
+
+ Given the work "Interesting"
+ And I am logged in as "myself"
+ And "myself" has the pseud "before"
+ And I bookmark the work "Interesting" as "before"
+ And I go to myself's bookmarks page
+ Then I should see "Bookmarked by before (myself)"
+
+ When it is currently 1 second from now
+ And "myself" changes the pseud "before" to "after"
+ And I go to myself's bookmarks page
+ Then I should see "Bookmarked by after (myself)"
+ And I should not see "Bookmarked by before (myself)"
+
+Scenario: Chapter byline reflects pseud changes immediately
+
+ Given I am logged in as "myself"
+ And "myself" creates the pseud "before"
+ And I post the work "Title" using the pseud "before"
+ And I add the co-author "pikachu" to the work "Title"
+ And I post a chapter for the work "Title" as "before"
+ When I view the work "Title"
+ And I view the 2nd chapter
+ Then I should see "Chapter by before (myself)"
+ When it is currently 1 second from now
+ And "myself" changes the pseud "before" to "after"
+ And I view the work "Title"
+ And I view the 2nd chapter
+ Then I should see "Chapter by after (myself)"
diff --git a/features/other_a/pseuds_special_characters.feature b/features/other_a/pseuds_special_characters.feature
new file mode 100755
index 0000000..a2a3f25
--- /dev/null
+++ b/features/other_a/pseuds_special_characters.feature
@@ -0,0 +1,162 @@
+@users
+Feature: Pseuds
+
+Scenario: creating pseud with unicode characters
+
+ Given I am logged in as "myself"
+ And I go to myself's user page
+ And I follow "Profile" within "div#dashboard ul.navigation.actions"
+ And I follow "Manage My Pseuds" within "div.user"
+ And I follow "New Pseud" within "div#main.pseuds-index"
+ And I fill in "Name" with "Àlice and Bôb"
+ And I fill in "Description" with "special character name"
+ And I fill in "Icon alt text" with "special Alice"
+ And I press "Create"
+ Then I should see "Pseud was successfully created."
+ When I follow "Edit Pseud"
+ Then I should see "Àlice and Bôb"
+ And I should not see "Alice"
+
+Scenario: creating pseud with chinese characters
+
+ Given I am logged in as "myself" with password "password"
+ And I go to myself's user page
+ And I follow "Profile" within "div#dashboard ul.navigation.actions"
+ And I follow "Manage My Pseuds" within "div.user"
+ And I follow "New Pseud" within "div#main.pseuds-index"
+ And I fill in "Name" with "爱丽丝"
+ And I press "Create"
+ Then I should see "Pseud was successfully created."
+ When I follow "Edit Pseud"
+ Then I should see "爱丽丝"
+
+Scenario: creating pseud with pinyin characters
+
+ Given I am logged in as "myself" with password "password"
+ And I go to myself's user page
+ And I follow "Profile" within "div#dashboard ul.navigation.actions"
+ And I follow "Manage My Pseuds" within "div.user"
+ And I follow "New Pseud" within "div#main.pseuds-index"
+ And I fill in "Name" with "Aì lì sī"
+ And I press "Create"
+ Then I should see "Pseud was successfully created."
+ When I follow "Edit Pseud"
+ Then I should see "Aì lì sī"
+
+Scenario: creating pseud with japanese characters
+
+ Given I am logged in as "myself" with password "password"
+ And I go to myself's user page
+ And I follow "Profile" within "div#dashboard ul.navigation.actions"
+ And I follow "Manage My Pseuds" within "div.user"
+ And I follow "New Pseud" within "div#main.pseuds-index"
+ And I fill in "Name" with "アリス"
+ And I press "Create"
+ Then I should see "Pseud was successfully created."
+ When I follow "Edit Pseud"
+ Then I should see "アリス"
+
+Scenario: creating pseud with russian characters
+
+ Given I am logged in as "myself" with password "password"
+ And I go to myself's user page
+ And I follow "Profile" within "div#dashboard ul.navigation.actions"
+ And I follow "Manage My Pseuds" within "div.user"
+ And I follow "New Pseud" within "div#main.pseuds-index"
+ And I fill in "Name" with "Алиса"
+ And I press "Create"
+ Then I should see "Pseud was successfully created."
+ When I follow "Edit Pseud"
+ Then I should see "Алиса"
+
+
+Scenario: not creating pseuds with characters which break urls
+
+ Given I am logged in as "myself" with password "password"
+ And I go to myself's user page
+ And I follow "Profile" within "div#dashboard ul.navigation.actions"
+ And I follow "Manage My Pseuds" within "div.user"
+ And I follow "New Pseud" within "div#main.pseuds-index"
+ And I fill in "Name" with "Alice/Bob"
+ And I press "Create"
+ Then I should not see "Pseud was successfully created."
+ And I should see "can contain letters, numbers, spaces, underscores, and dashes"
+
+ Given I am logged in as "myself" with password "password"
+ And I go to myself's user page
+ And I follow "Profile" within "div#dashboard ul.navigation.actions"
+ And I follow "Manage My Pseuds" within "div.user"
+ And I follow "New Pseud" within "div#main.pseuds-index"
+ And I fill in "Name" with "Alice & Bob"
+ And I press "Create"
+ Then I should not see "Pseud was successfully created."
+ And I should see "can contain letters, numbers, spaces, underscores, and dashes"
+
+ Given I am logged in as "myself" with password "password"
+ And I go to myself's user page
+ And I follow "Profile" within "div#dashboard ul.navigation.actions"
+ And I follow "Manage My Pseuds" within "div.user"
+ And I follow "New Pseud" within "div#main.pseuds-index"
+ And I fill in "Name" with "Alice."
+ And I press "Create"
+ Then I should not see "Pseud was successfully created."
+ And I should see "can contain letters, numbers, spaces, underscores, and dashes"
+
+ Given I am logged in as "myself" with password "password"
+ And I go to myself's user page
+ And I follow "Profile" within "div#dashboard ul.navigation.actions"
+ And I follow "Manage My Pseuds" within "div.user"
+ And I follow "New Pseud" within "div#main.pseuds-index"
+ And I fill in "Name" with "Alice?"
+ And I press "Create"
+ Then I should not see "Pseud was successfully created."
+ And I should see "can contain letters, numbers, spaces, underscores, and dashes"
+
+ Given I am logged in as "myself" with password "password"
+ And I go to myself's user page
+ And I follow "Profile" within "div#dashboard ul.navigation.actions"
+ And I follow "Manage My Pseuds" within "div.user"
+ And I follow "New Pseud" within "div#main.pseuds-index"
+ And I fill in "Name" with "Alice#"
+ And I press "Create"
+ Then I should not see "Pseud was successfully created."
+ And I should see "can contain letters, numbers, spaces, underscores, and dashes"
+
+Scenario: not creating pseuds with other characters we don't allow
+
+ Given I am logged in as "myself" with password "password"
+ And I go to myself's user page
+ And I follow "Profile" within "div#dashboard ul.navigation.actions"
+ And I follow "Manage My Pseuds" within "div.user"
+ And I follow "New Pseud" within "div#main.pseuds-index"
+ And I fill in "Name" with "Alice + Bob"
+ And I press "Create"
+ Then I should not see "Pseud was successfully created."
+ And I should see "can contain letters, numbers, spaces, underscores, and dashes"
+
+Scenario: not creating pseuds with more characters we don't allow
+
+ Given I am logged in as "myself" with password "password"
+ And I go to myself's user page
+ And I follow "Profile" within "div#dashboard ul.navigation.actions"
+ And I follow "Manage My Pseuds" within "div.user"
+ And I follow "New Pseud" within "div#main.pseuds-index"
+ And I fill in "Name" with "Alice 'Bob'"
+ And I press "Create"
+ Then I should not see "Pseud was successfully created."
+ And I should see "can contain letters, numbers, spaces, underscores, and dashes"
+
+Scenario: not creating pseuds with even more characters we don't allow
+
+ Given I am logged in as "myself" with password "password"
+ And I go to myself's user page
+ And I follow "Profile" within "div#dashboard ul.navigation.actions"
+ And I follow "Manage My Pseuds" within "div.user"
+ And I follow "New Pseud" within "div#main.pseuds-index"
+ And I fill in "Name" with "Alice (Bob)"
+ And I press "Create"
+ Then I should not see "Pseud was successfully created."
+ And I should see "can contain letters, numbers, spaces, underscores, and dashes"
+
+
+
diff --git a/features/other_a/reading.feature b/features/other_a/reading.feature
new file mode 100755
index 0000000..18db0d3
--- /dev/null
+++ b/features/other_a/reading.feature
@@ -0,0 +1,303 @@
+@users
+Feature: Reading count
+
+ Scenario: A user can only see their own reading history
+
+ Given the following activated user exists
+ | login |
+ | first_reader |
+ When I am logged in as "second_reader"
+ And I go to first_reader's reading page
+ Then I should see "Sorry, you don't have permission"
+ And I should not see "History" within "div#dashboard"
+ When I go to second_reader's reading page
+ Then I should see the page title "History"
+ And I should see "History" within "h2.heading"
+ And I should see "History" within "div#dashboard"
+
+ Scenario: A user can read a work several times, updating the count and date in their history
+
+ Given I am logged in as "writer"
+ And I post the work "some work"
+ And all indexing jobs have been run
+ And I am logged out
+ When I am logged in as "fandomer"
+ And fandomer first read "some work" on "2010-05-25"
+ And I go to fandomer's reading page
+ Then I should see "some work"
+ And I should see "Visited once"
+ And I should see "Last visited: 25 May 2010"
+
+ When time is frozen at 20/4/2020
+ And I go to the work "some work"
+ And the readings are saved to the database
+ And I go to fandomer's reading page
+ Then I should see "Visited 2 times"
+ And I should see "Last visited: 20 Apr 2020"
+
+ Scenario: A user's reading history is updated only when enabled
+
+ Given I am logged in as "writer"
+ And I post the work "some work"
+ And I am logged out
+ When I am logged in as "fandomer"
+ And fandomer first read "some work" on "2010-05-25"
+ And I go to fandomer's reading page
+ Then I should see "some work"
+ And I should see "Visited once"
+ And I should see "Last visited: 25 May 2010"
+
+ When I follow "Preferences"
+ And I uncheck "Turn on History"
+ And I press "Update"
+ And all indexing jobs have been run
+ Then I should not see "My History"
+
+ When I go to the work "some work"
+ And the readings are saved to the database
+ And I go to fandomer's reading page
+ Then I should see "You have reading history disabled"
+ And I should not see "some work"
+
+ When I check "Turn on History"
+ And I press "Update"
+ Then I should see "Your preferences were successfully updated."
+
+ When I go to fandomer's reading page
+ Then I should see "Visited once"
+ And I should see "Last visited: 25 May 2010"
+ When time is frozen at 20/4/2020
+ And I go to the work "some work"
+ And the readings are saved to the database
+ And I go to fandomer's reading page
+ Then I should see "Visited 2 times"
+ And I should see "Last visited: 20 Apr 2020"
+
+ Scenario: Clear entire reading history
+
+ Given the work "First work" by "testuser"
+ And the work "second work" by "testuser"
+ And the work "fourth" by "testuser2"
+ And I am logged in as "testuser2"
+ And I post the work "fifth" with rating "Mature"
+ When I am logged in as "fandomer"
+ And I am on testuser's works page
+ And I follow "First work"
+ And I am on testuser's works page
+ And I follow "second work"
+ And I am on testuser2 works page
+ And I follow "fifth"
+ And I should see "fifth by testuser2"
+ And I follow "Yes, Continue"
+ And the readings are saved to the database
+ When I go to fandomer's reading page
+ Then I should see "History" within "div#dashboard"
+ And I should see "First work"
+ And I should see "second work"
+ And I should see "fifth"
+ But I should not see "fourth"
+ When I follow "Clear History"
+ Then I should see "Your history is now cleared"
+ And I should see "History" within "div#dashboard"
+ But I should not see "First work"
+ And I should not see "second work"
+ And I should not see "fifth"
+
+ Scenario: Mark a story to read later
+
+ Given I am logged in as "writer"
+ When I post the work "Testy"
+ Then I should see "Work was successfully posted"
+ When I am logged out
+ And I am logged in as "reader"
+ And I view the work "Testy"
+ Then I should see "Mark for Later"
+ When I press "Mark for Later"
+ Then I should see "This work was added to your Marked for Later list."
+ When I go to reader's reading page
+ And I follow "Marked for Later"
+ Then I should see the page title "Marked for Later"
+ And I should see "Marked for Later" within "h2.heading"
+ And I should see "Testy"
+ And I should see "(Marked for Later.)"
+ When I view the work "Testy"
+ Then I should see "Mark as Read"
+ When I press "Mark as Read"
+ Then I should see "This work was removed from your Marked for Later list."
+ And I go to reader's reading page
+ Then I should see "Testy"
+ And I should not see "(Marked for Later.)"
+
+ Scenario: You can't mark a story to read later if you're not logged in or the author
+
+ Given I am logged in as "writer"
+ When I post the work "Testy"
+ Then I should see "Work was successfully posted"
+ When I view the work "Testy"
+ Then I should not see "Mark for Later"
+ And I should not see "Mark as Read"
+ When I am logged out
+ And I view the work "Testy"
+ Then I should not see "Mark for Later"
+ And I should not see "Mark as Read"
+
+ Scenario: Multi-chapter works are added to history, can be deleted from history, are updated every time the user accesses a chapter, and can be marked for later
+
+ Given I am logged in as "writer"
+ And I post the work "multichapter work"
+ And a chapter is added to "multichapter work"
+ Then I should see "multichapter work"
+ When I am logged out
+ And I am logged in as "fandomer"
+ And I view the work "multichapter work"
+ When the readings are saved to the database
+ And I go to fandomer's reading page
+ Then I should see "multichapter work"
+ And I should see "Visited once"
+ When I press "Delete from History"
+ Then I should see "Work successfully deleted from your history."
+ When I view the work "multichapter work"
+ And the readings are saved to the database
+ When I go to fandomer's reading page
+ Then I should see "multichapter work"
+ And I should see "Visited once"
+ When I view the work "multichapter work"
+ And I follow "Next Chapter"
+ And the readings are saved to the database
+ When I go to fandomer's reading page
+ Then I should see "multichapter work"
+ And I should see "Visited 3 times"
+ When I view the work "multichapter work"
+ And I follow "Next Chapter"
+ When I press "Mark for Later"
+ Then I should see "This work was added to your Marked for Later list."
+ And the readings are saved to the database
+ And I go to fandomer's reading page
+ Then I should see "multichapter work"
+ And I should see "Visited 6 times"
+ And I should see "(Marked for Later.)"
+
+ Scenario: A user can see some of their works marked for later on the homepage
+
+ Given the work "Maybe Tomorrow"
+ And I am logged in as "testy"
+ When I mark the work "Maybe Tomorrow" for later
+ And I go to the homepage
+ Then I should see "Is it later already?"
+ And I should see "Some works you've marked for later."
+ And I should see "Maybe Tomorrow"
+
+ Scenario: A user cannot see works marked for later on the homepage if they have their reading history disabled
+
+ Given the work "Maybe Tomorrow"
+ And I am logged in as "testy"
+ When I mark the work "Maybe Tomorrow" for later
+ And I set my preferences to turn off history
+ When I go to the homepage
+ Then I should not see "Is it later already?"
+ And I should not see "Some works you've marked for later."
+ And I should not see "Maybe Tomorrow"
+
+ Scenario: A user can delete a work marked for later from their history on the homepage
+
+ Given the work "Not Ever"
+ And I am logged in as "testy"
+ When I mark the work "Not Ever" for later
+ And I go to the homepage
+ Then I should see "Not Ever"
+ And I should see a "Delete from History" button
+ When I press "Delete from History"
+ Then I should see "Work successfully deleted from your history."
+ And I should be on the homepage
+ And I should not see "Is it later already?"
+ And I should not see "Some works you've marked for later."
+ And I should not see "Not Ever"
+
+ Scenario: When a user marks a work for later and the creator deletes that work, the marked for later blurb on their homepage should be replaced with a "Deleted work" placeholder
+
+ Given I am logged in as "golucky" with password "password"
+ And I post the work "Gone Gone Gone"
+ And I am logged out
+ When I am logged in as "reader" with password "password"
+ And I mark the work "Gone Gone Gone" for later
+ And the readings are saved to the database
+ And I am logged out
+ When I am logged in as "golucky" with password "password"
+ And I delete the work "Gone Gone Gone"
+ And I am logged out
+ When I am logged in as "reader" with password "password"
+ And I go to the homepage
+ Then I should see "Deleted work"
+ And I should not see "Gone Gone Gone"
+ When I go to reader's reading page
+ Then I should see "Deleted work"
+ And I should not see "Gone Gone Gone"
+ When I follow "Marked for Later"
+ Then I should see "Deleted work"
+ And I should not see "Gone Gone Gone"
+
+ Scenario: When a user marks a work for later and the creator updates that work, the marked for later blurb on their homepage should update
+
+ Given I am logged in as "editor" with password "password"
+ And I post the work "Some Work V1"
+ And I am logged out
+ When I am logged in as "reader" with password "password"
+ And I mark the work "Some Work V1" for later
+ And the readings are saved to the database
+ And I am logged out
+ When I am logged in as "editor" with password "password"
+ And I edit the work "Some Work V1"
+ And I fill in "Work Title" with "Some Work V2"
+ And I press "Post"
+ And I am logged out
+ When I am logged in as "reader" with password "password"
+ And I go to the homepage
+ Then I should see "Some Work V2"
+ And I should not see "Some Work V1"
+
+ Scenario: A user cannot see hidden by admin works in their reading history
+
+ Given I am logged in as "writer"
+ When I post the work "Testy"
+ Then I should see "Work was successfully posted"
+ When I am logged in as "reader"
+ And I view the work "Testy"
+ Then I should see "Mark for Later"
+ When I press "Mark for Later"
+ Then I should see "This work was added to your Marked for Later list."
+ When I am logged in as a "policy_and_abuse" admin
+ And I view the work "Testy"
+ And I follow "Hide Work"
+ Then I should see "Item has been hidden."
+ When I am logged in as "reader"
+ And I go to reader's reading page
+ Then I should not see "Testy"
+
+ Scenario: When a chapter is added to a work, "update available" should not appear until it is posted
+
+ Given the work "Some Work" by "writer"
+ When I am logged in as "reader"
+ And I mark the work "Some Work" for later
+ And the readings are saved to the database
+ And I am logged out
+ When a draft chapter is added to "Some Work"
+ And I am logged in as "reader"
+ And I go to reader's reading page
+ Then I should not see "(Update available.)"
+ When I am logged in as "writer"
+ And I view the work "Some Work"
+ And I view the 2nd chapter
+ And I post the draft chapter
+ When I am logged in as "reader"
+ And I go to reader's reading page
+ Then I should see "(Update available.)"
+
+ Scenario: Reading history blurb includes an HTML comment containing the unix epoch of the updated time
+
+ Given time is frozen at 2025-04-12 17:00 UTC
+ And I am logged in as "ethel"
+ And the work "Test"
+ And I view the work "Test"
+ And the readings are saved to the database
+ When I go to ethel's reading page
+ Then I should see an HTML comment containing the number 1744477200 within "li.work.blurb"
diff --git a/features/other_b/errors.feature b/features/other_b/errors.feature
new file mode 100755
index 0000000..1bc91f4
--- /dev/null
+++ b/features/other_b/errors.feature
@@ -0,0 +1,11 @@
+@errors
+Feature: Error messages
+
+ Scenario: Error messages should be able to display '^'
+ Given I am logged in as a random user
+ And I post the work "Work 1"
+ And I view the work "Work 1"
+ And I follow "Edit Tags"
+ When I fill in "Fandoms" with "^"
+ And I press "Post"
+ Then I should see "Sorry! We couldn't save this work because: Tag name '^' cannot include the following restricted characters: , ^ * < > { } = ` , 、 \ %"
diff --git a/features/other_b/fandoms.feature b/features/other_b/fandoms.feature
new file mode 100755
index 0000000..8c4a95f
--- /dev/null
+++ b/features/other_b/fandoms.feature
@@ -0,0 +1,47 @@
+@fandoms
+Feature: There is a list of unassigned Fandoms
+
+ Scenario: A user can see the list of fandoms and filter it
+
+ Given I have a canonical "TV Shows" fandom tag named "Steven Universe"
+ And I have a canonical "Movies" fandom tag named "High School Musical"
+ And I am logged in as "author"
+ And I post the work "Stronger than you" with fandom "Steven Universe"
+ And I post the work "Breaking free" with fandom "High School Musical"
+ And I am logged in as a tag wrangler
+ When I go to the unassigned fandoms page
+ Then I should see "Steven Universe"
+ And I should see "High School Musical"
+ When I select "TV Shows" from "media_id"
+ And I press "Sort and Filter"
+ Then I should see "Steven Universe"
+ And I should not see "High School Musical"
+ When I select "Movies" from "media_id"
+ And I press "Sort and Filter"
+ Then I should see "High School Musical"
+ When I follow "High School Musical"
+ Then I should see "This tag belongs to the Fandom Category."
+
+ Scenario: A user can see the list of relationships in a fandom
+
+ Given the following typed tags exists
+ | name | type | canonical |
+ | Steven Universe | Fandom | true |
+ | Ruby/Sapphire (Steven Universe) | Relationship | true |
+ | Ruby (Steven Universe) | Character | true |
+ | Sapphire (Steven Universe) | Character | true |
+ And I add the fandom "Steven Universe" to the character "Ruby (Steven Universe)"
+ And I add the fandom "Steven Universe" to the character "Sapphire (Steven Universe)"
+ And I am logged in as "author"
+ And I post the work "Stronger than you" with fandom "Steven Universe" with character "Ruby (Steven Universe)" with second character "Sapphire (Steven Universe)" with relationship "Ruby/Sapphire (Steven Universe)"
+ When I go to the "Steven Universe" tag page
+ And I follow "Relationship tags in this fandom"
+ Then I should see "Ruby (Steven Universe)"
+ And I should see "Sapphire (Steven Universe)"
+
+ Scenario: Click on "Relationships by Character" on a fandom tag containing periods
+ Given a canonical fandom "Harry Potter - J. K. Rowling"
+ When I view the tag "Harry Potter - J. K. Rowling"
+ And I follow "Relationship tags in this fandom"
+ Then I should not see "The page you were looking for doesn't exist."
+ And I should see "Relationships by Character"
diff --git a/features/other_b/icon.feature b/features/other_b/icon.feature
new file mode 100755
index 0000000..e7e5ab8
--- /dev/null
+++ b/features/other_b/icon.feature
@@ -0,0 +1,54 @@
+@users
+Feature: User icons
+
+ Scenario Outline: Users should be able to upload icons using an allowed image format
+
+ Given I am editing a pseud
+ When I attach an icon with the extension ""
+ And I press "Update"
+ Then I should see "Pseud was successfully updated"
+ And I should see the image "alt" text ""
+
+ Examples:
+ | extension |
+ | gif |
+ | jpg |
+ | png |
+
+ Scenario: Users should not be able to upload icons using the .bmp format
+
+ Given I am editing a pseud
+ When I attach an icon with the extension "bmp"
+ And I press "Update"
+ Then I should see "Sorry! We couldn't save this pseud because:"
+ And I should see "Icon content type is invalid"
+
+ Scenario: Users can change alt text
+
+ Given I have an icon uploaded
+ When I follow "Edit Pseud"
+ And I fill in "pseud_icon_alt_text" with "Some test description"
+ And I press "Update"
+ Then I should see the image "alt" text "Some test description"
+
+ Scenario: Add and remove a collection icon
+
+ Given I have a collection "Pretty"
+ When I add an icon to the collection "Pretty"
+ Then I should see "Collection was successfully updated"
+ And the "Pretty" collection should have an icon
+ When I delete the icon from the collection "Pretty"
+ Then I should see "Collection was successfully updated."
+ And the "Pretty" collection should not have an icon
+
+ Scenario: Users can delete icon and alt text
+
+ Given I have an icon uploaded
+ When I follow "Edit Pseud"
+ And I fill in "pseud_icon_alt_text" with "Some test description"
+ And I press "Update"
+ Then I should see the image "alt" text "Some test description"
+ When I delete the icon from my pseud
+ Then I should see "Pseud was successfully updated."
+ When I follow "Edit Pseud"
+ Then I should see the icon and alt text boxes are blank
diff --git a/features/other_b/sanitizer.feature b/features/other_b/sanitizer.feature
new file mode 100755
index 0000000..6ce67b1
--- /dev/null
+++ b/features/other_b/sanitizer.feature
@@ -0,0 +1,66 @@
+@works
+Feature: Sanitizing HTML
+
+ Scenario: Sanitizer should kick in
+
+ When I am logged in as "newbie" with password "password"
+ And I set up the draft "My Awesome Story"
+ And I fill in "content" with
+ """
+ The quick brown fox jumps over the lazy dog.
+
+
+ """
+ And I press "Preview"
+ Then I should see "Preview"
+ And I should not see the text with tags '
+
+
+
<%= search_header(@comments, nil, "Comment") %>
+ <%= render "comments/comment_abbreviated_list", comments: comments %> + <%= will_paginate(comments, param_name: "comments_page", params: { anchor: "comments-summary" }) %> +