commit 5fba9fe725b6d2b9e59c424b7f68fa54427a91d2 Author: aggie Date: Wed Mar 11 22:22:11 2026 +0000 first diff --git a/.codeclimate.yml b/.codeclimate.yml new file mode 100644 index 0000000..15bd1c2 --- /dev/null +++ b/.codeclimate.yml @@ -0,0 +1,12 @@ +exclude_paths: +- "bin/" +- "config/" +- "db/" +- "factories/" +- "features/" +- "public/**/*.min.js" +- "public/javascripts/bootstrap/" +- "public/javascripts/tinymce/" +- "script/" +- "spec/" +- "test/" diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 0000000..69cb760 --- /dev/null +++ b/.codecov.yml @@ -0,0 +1 @@ +comment: false diff --git a/.erb-lint.yml b/.erb-lint.yml new file mode 100644 index 0000000..24920f5 --- /dev/null +++ b/.erb-lint.yml @@ -0,0 +1,45 @@ +--- +EnableDefaultLinters: true +linters: + AllowedScriptType: + allowed_types: + - "text/javascript" + - "speculationrules" + DeprecatedClasses: + enabled: true + rule_set: + - deprecated: ["bookmarks", "collections", "readings", "works"] + suggestion: "Avoid the plural form of these classes." + RequireInputAutocomplete: + enabled: false + Rubocop: + enabled: true + rubocop_config: + inherit_from: + - .rubocop.yml + Layout/ArgumentAlignment: + EnforcedStyle: with_fixed_indentation + Layout/InitialIndentation: + Enabled: false + Layout/LineLength: + Enabled: false + Layout/TrailingEmptyLines: + Enabled: false + Layout/TrailingWhitespace: + Enabled: false + Naming/FileName: + Enabled: false + Style/FrozenStringLiteralComment: + Enabled: false + Lint/UselessAssignment: + Enabled: false + # Workaround for RuboCop 0.72 and later + # https://github.com/Shopify/erb-lint/issues/130 + Rails: + Enabled: true + Rails/OutputSafety: + Enabled: false + SelfClosingTag: + enabled: true + # XHTML style + enforced_style: always diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e838c59 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Set the default behavior, in case people don't have core.autocrlf set. +* text=auto +# Bash scripts needs to have LF line endings, even on Windows +*.sh text eol=lf +# /usr/local/bin/ruby: warning: shebang line ending with \r may cause problems +/bin/* text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9db6780 --- /dev/null +++ b/.gitignore @@ -0,0 +1,108 @@ +*.swp +.DS_Store +.bundle +.idea +stdout +*~ +aria_log_control +.vscode/ +docker-compose.yml +# / +/*.tmproj +/sphinx +/.DAV +/rerun.txt +/capybara-*.html +/.dropbox +/.dropbox.attr +/.rspec +REVISION +aria_log.00000001 +# /config/ +/config/brakeman.ignore +/config/docker/database.yml +/config/database.yml +/config/local.yml +/config/backup.yml +/config/*.sphinx.conf +/config/s3.yml +/config/sphinx.yml +/config/unicorn.rb +/config/newrelic.yml +/config/redis.yml +/config/redis-cucumber.conf +/config/skins.dump +config/local.yml.save +ibdata1 +ib_buffer_pool +ib_logfile0 +bunder_gems/ +bundler_gems/ +otwarchive_production/ +performance_schema/ +/bunder_gems +/bunder_gems/ +/bunder_gems/* +/bundler_gems +/bundler_gems/ +/bundler_gems/* +ibtmp1 +/performance_schema +/performance_schema/* +/performance-schema +/performance_schema/ +/mysql/* +/otwarchive_production/* +/db/* +/elastic-data/* +/elastic-data +/elastic-data/ +/elastic/* +# /config/environments/ +/config/environments/development.rb + +# /db/ +/db/*.sqlite3 +/db/backup +/db/sphinx +/db/seed + +# /log/ +/log/* + +# /public/ +/public/downloads +/public/stylesheets/cached_for_screen.css +/public/stylesheets/skins +public/system/development +/public/system/skins +public/system/test +/public/system/work_skins +/public/tags + +# /public/images/ +/public/images/Thumbs.db + +# /tmp/ +/tmp/* + +# ActiveRecord storage path +storage/ + +# /vendor/ +/vendor/gems + +# /vendor/gems/ +/vendor/gems/* + +# /vendor/plugins/ + +features/cassette_library/ + +spec/results.html +spec/results +features_report.html +coverage +redis-cucumber-dump.rdb + +.rspec diff --git a/.gitpod.yml b/.gitpod.yml new file mode 100644 index 0000000..75267cd --- /dev/null +++ b/.gitpod.yml @@ -0,0 +1,17 @@ +tasks: + - init: ./script/docker/init.sh + command: docker compose up -d web + +ports: + - port: 3000 + onOpen: open-browser + - port: 3306 + onOpen: ignore + - port: 6379 + onOpen: ignore + - port: 9200 + onOpen: ignore + - port: 9300 + onOpen: ignore + - port: 9400 + onOpen: ignore diff --git a/.hound.yml b/.hound.yml new file mode 100644 index 0000000..236f7f4 --- /dev/null +++ b/.hound.yml @@ -0,0 +1,9 @@ +# Available linter versions: +# http://help.houndci.com/en/articles/2461415-supported-linters + +jshint: + config_file: .jshintrc + ignore_file: .jshintignore + +rubocop: + enabled: false diff --git a/.jshintignore b/.jshintignore new file mode 100644 index 0000000..d86ef25 --- /dev/null +++ b/.jshintignore @@ -0,0 +1,4 @@ +coverage +public/**/*.min.js +public/javascripts/bootstrap +public/javascripts/tinymce diff --git a/.jshintrc b/.jshintrc new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/.jshintrc @@ -0,0 +1 @@ +{} diff --git a/.phrase.yml b/.phrase.yml new file mode 100644 index 0000000..cc7e934 --- /dev/null +++ b/.phrase.yml @@ -0,0 +1,164 @@ +phrase: + project_id: a3667b8095533c2b3f8d3ac946bb642f + file_format: yml + push: + sources: + # controllers + - file: ./config/locales/controllers/en.yml + params: + update_translations: true + # devise + - file: ./config/locales/devise/en.yml + params: + update_translations: true + # mailers + - file: ./config/locales/mailers/en.yml + params: + update_translations: true + # models + - file: ./config/locales/models/en.yml + params: + update_translations: true + # validators + - file: ./config/locales/validators/en.yml + params: + update_translations: true + # views + - file: ./config/locales/views/en.yml + params: + update_translations: true + pull: + targets: + - file: ./config/locales/phrase-exports/af.yml + params: + locale_id: af + - file: ./config/locales/phrase-exports/ar.yml + params: + locale_id: ar + - file: ./config/locales/phrase-exports/bg.yml + params: + locale_id: bg + - file: ./config/locales/phrase-exports/bn.yml + params: + locale_id: bn + - file: ./config/locales/phrase-exports/ca.yml + params: + locale_id: ca + - file: ./config/locales/phrase-exports/cs.yml + params: + locale_id: cs + - file: ./config/locales/phrase-exports/cy.yml + params: + locale_id: cy + - file: ./config/locales/phrase-exports/da.yml + params: + locale_id: da + - file: ./config/locales/phrase-exports/de.yml + params: + locale_id: de + - file: ./config/locales/phrase-exports/el.yml + params: + locale_id: el + - file: ./config/locales/phrase-exports/es.yml + params: + locale_id: es + - file: ./config/locales/phrase-exports/fa.yml + params: + locale_id: fa + - file: ./config/locales/phrase-exports/fi.yml + params: + locale_id: fi + - file: ./config/locales/phrase-exports/fil.yml + params: + locale_id: fil + - file: ./config/locales/phrase-exports/fr.yml + params: + locale_id: fr + - file: ./config/locales/phrase-exports/he.yml + params: + locale_id: he + - file: ./config/locales/phrase-exports/hi.yml + params: + locale_id: hi + - file: ./config/locales/phrase-exports/hr.yml + params: + locale_id: hr + - file: ./config/locales/phrase-exports/hu.yml + params: + locale_id: hu + - file: ./config/locales/phrase-exports/id.yml + params: + locale_id: id + - file: ./config/locales/phrase-exports/it.yml + params: + locale_id: it + - file: ./config/locales/phrase-exports/ja.yml + params: + locale_id: ja + - file: ./config/locales/phrase-exports/ko.yml + params: + locale_id: ko + - file: ./config/locales/phrase-exports/lt.yml + params: + locale_id: lt + - file: ./config/locales/phrase-exports/lv.yml + params: + locale_id: lv + - file: ./config/locales/phrase-exports/mk.yml + params: + locale_id: mk + - file: ./config/locales/phrase-exports/mr.yml + params: + locale_id: mr + - file: ./config/locales/phrase-exports/ms.yml + params: + locale_id: ms + - file: ./config/locales/phrase-exports/nb.yml + params: + locale_id: nb + - file: ./config/locales/phrase-exports/nl.yml + params: + locale_id: nl + - file: ./config/locales/phrase-exports/pl.yml + params: + locale_id: pl + - file: ./config/locales/phrase-exports/pt-BR.yml + params: + locale_id: pt-BR + - file: ./config/locales/phrase-exports/pt-PT.yml + params: + locale_id: pt-PT + - file: ./config/locales/phrase-exports/ro.yml + params: + locale_id: ro + - file: ./config/locales/phrase-exports/ru.yml + params: + locale_id: ru + - file: ./config/locales/phrase-exports/scr.yml + params: + locale_id: scr + - file: ./config/locales/phrase-exports/sk.yml + params: + locale_id: sk + - file: ./config/locales/phrase-exports/sl.yml + params: + locale_id: sl + - file: ./config/locales/phrase-exports/sv.yml + params: + locale_id: sv + - file: ./config/locales/phrase-exports/th.yml + params: + locale_id: th + - file: ./config/locales/phrase-exports/tr.yml + params: + locale_id: tr + - file: ./config/locales/phrase-exports/uk.yml + params: + locale_id: uk + - file: ./config/locales/phrase-exports/vi.yml + params: + locale_id: vi + - file: ./config/locales/phrase-exports/zh-CN.yml + params: + locale_id: zh-CN + diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..7fec430 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,310 @@ +# Options available at https://github.com/bbatsov/rubocop/blob/master/config/default.yml + +require: + - rubocop-rails + - rubocop-rspec + - ./rubocop/rubocop + +inherit_mode: + merge: + - Exclude + +AllCops: + NewCops: enable + TargetRubyVersion: 3.1 + +Bundler/OrderedGems: + Enabled: false + +I18n/DeprecatedTranslationKey: + Rules: + name_with_colon: "Prefer `name` with `mailer.general.metadata_label_indicator` over `name_with_colon`" + +Layout/DotPosition: + EnforcedStyle: leading + +Layout/EmptyLinesAroundAttributeAccessor: + Enabled: false + +Layout/FirstArrayElementIndentation: + EnforcedStyle: consistent + +Layout/LineLength: + Enabled: false + +Layout/MultilineMethodCallIndentation: + EnforcedStyle: indented + +Layout/SingleLineBlockChain: + Enabled: true + +Layout/TrailingWhitespace: + Enabled: false + +Lint/AmbiguousBlockAssociation: + Exclude: + # Exception for specs where we use change matchers: + # https://github.com/rubocop-hq/rubocop/issues/4222 + - 'features/step_definitions/**/*.rb' + - 'spec/**/*.rb' + +Lint/AmbiguousRegexpLiteral: + Enabled: false + +Lint/RedundantSafeNavigation: + Exclude: + # Take a better safe than sorry approach to safe navigation in admin + # policies. + - 'app/policies/*.rb' + +Metrics/AbcSize: + Enabled: false + +Metrics/BlockLength: + Enabled: false + +Metrics/ClassLength: + Enabled: false + +Metrics/CyclomaticComplexity: + Enabled: false + +Metrics/MethodLength: + Enabled: false + +Metrics/ModuleLength: + Enabled: false + +Metrics/ParameterLists: + CountKeywordArgs: false + +Metrics/PerceivedComplexity: + Enabled: false + +Migration/LargeTableSchemaUpdate: + Tables: + - abuse_reports + - active_storage_attachments + - active_storage_blobs + - active_storage_variant_records + - admin_activities + - audits + - blocks + - bookmarks + - chapters + - comments + - common_taggings + - collection_items + - collection_participants + - collection_preferences + - collection_profiles + - collections + - creatorships + - external_works + - favorite_tags + - feedbacks + - filter_counts + - filter_taggings + - gifts + - inbox_comments + - invitations + - kudos + - log_items + - meta_taggings + - mutes + - preferences + - profiles + - prompts + - pseuds + - readings + - related_works + - set_taggings + - serial_works + - series + - skins + - stat_counters + - subscriptions + - tag_nominations + - tag_set_associations + - tag_sets + - taggings + - tags + - users + - works + +Naming/VariableNumber: + AllowedIdentifiers: + - age_over_13 + - no_age_over_13 + +Rails/DefaultScope: + Enabled: true + +Rails/DynamicFindBy: + AllowedMethods: + # Exceptions for Tag.find_by_name and Tag.find_by_name! + - find_by_name + - find_by_name! + # Exception for Tagging.find_by_tag + - find_by_tag + # Exceptions for Work.find_by_* + - find_by_url + - find_by_url_cache_key + - find_by_url_generation + - find_by_url_generation_key + - find_by_url_uncached + # Exceptions for InboxComment.find_by_filters + - find_by_filters + +Rails/EnvironmentVariableAccess: + Enabled: true + +# Allow all uses of html_safe, they're everywhere... +Rails/OutputSafety: + Enabled: false + +Rails/Output: + Exclude: + # Allow patches to print warnings to console: + - 'config/initializers/monkeypatches/*.rb' + # Allow migrations to print pt-osc comments to console: + - 'db/migrate/*.rb' + +Rails/RakeEnvironment: + Enabled: false + +Rails/ReversibleMigrationMethodDefinition: + Enabled: true + +# Allow update_attribute, update_all, touch, etc. +Rails/SkipsModelValidations: + Enabled: false + +Rails/UnknownEnv: + Environments: + - development + - test + - staging + - production + +RSpec: + Include: + - "(?:^|/)factories/" + - "(?:^|/)features/" + - "(?:^|/)spec/" + +# Allow "allow_any_instance_of" +RSpec/AnyInstance: + Enabled: false + +# By default allow only prefixes "when", "with", "without". +# We have too many, so let's allow everything. +RSpec/ContextWording: + Enabled: false + +RSpec/DescribeClass: + Exclude: + # Exception for specs about I18n configurations + - 'spec/lib/i18n/**/*.rb' + # Exception for rake specs, where the top level describe uses a task name + - 'spec/lib/tasks/*.rake_spec.rb' + # Exception for integration specs, which may not test a specific class + - 'spec/requests/**/*.rb' + +RSpec/DescribedClass: + Enabled: false + +RSpec/ExampleLength: + Enabled: false + +# Prefer: expect { run }.to change { Foo.bar } +# over: expect { run }.to change(Foo, :bar) +RSpec/ExpectChange: + EnforcedStyle: block + +RSpec/FilePath: + Exclude: + # Exception for WorksController, whose many specs need multiple files + - 'spec/controllers/works/*.rb' + # Exception for concern specs, which may test multiple classes + - 'spec/models/concerns/**/*.rb' + +# Avoid instance variables, except for those not assigned within the spec, +# e.g. @request. +RSpec/InstanceVariable: + AssignmentOnly: true + +# Allow unreferenced let! calls for test setup +RSpec/LetSetup: + Enabled: false + +# Allow both "have_received" and "receive" for expectations +RSpec/MessageSpies: + Enabled: false + +# Allow multiple top level describes (rake specs) +RSpec/MultipleDescribes: + Enabled: false + +# Allow unlimited expectations per test +RSpec/MultipleExpectations: + Enabled: false + +# Allow unnamed subjects +RSpec/NamedSubject: + Enabled: false + +# Allow unlimited nested groups +RSpec/NestedGroups: + Enabled: false + +RSpec/PredicateMatcher: + Enabled: false + +Style/AndOr: + EnforcedStyle: conditionals + +Style/ClassAndModuleChildren: + Enabled: false + +Style/Documentation: + Enabled: false + +Style/EmptyMethod: + EnforcedStyle: expanded + +# Prefer template tokens (like %{foo}) over annotated tokens (like %s) +Style/FormatStringToken: + EnforcedStyle: template + +Style/FrozenStringLiteralComment: + Enabled: false + +Style/GlobalVars: + AllowedVariables: + - $elasticsearch + - $rollout + +Style/PercentLiteralDelimiters: + Exclude: + # Exception for Cucumber step definitions, where we heavily use %{} for strings + - 'features/**/*.rb' + +# Stop checking if uses of "self" are redundant +Style/RedundantSelf: + Enabled: false + +Style/SelectByRegexp: + Enabled: true + +Style/SingleLineMethods: + AllowIfMethodIsEmpty: false + +Style/StringLiterals: + EnforcedStyle: double_quotes + +Style/SymbolArray: + Enabled: false + +Style/TernaryParentheses: + EnforcedStyle: require_parentheses_when_complex diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..fb72f06 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +ruby-3.2.7 diff --git a/.simplecov b/.simplecov new file mode 100644 index 0000000..188e319 --- /dev/null +++ b/.simplecov @@ -0,0 +1,10 @@ +if ENV["CI"] == "true" + require "simplecov-cobertura" + SimpleCov.formatter = SimpleCov::Formatter::CoberturaFormatter +end + +SimpleCov.start "rails" do + add_filter "/factories/" + merge_timeout 7200 + command_name ENV["TEST_GROUP"].gsub(/[^\w]/, "_") if ENV["TEST_GROUP"] +end diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md new file mode 100644 index 0000000..128480d --- /dev/null +++ b/ACKNOWLEDGMENTS.md @@ -0,0 +1,27 @@ +Acknowledgments +========= + +

BrowserStack for our cross-browser testing tool.

+ +

Capybara for integration testing.

+

Codeship for continuous integration.

+

Cucumber for integration testing.

+

Debian for our Linux distribution.

+

Elasticsearch for our tag searching.

+

Exim for mail sending.

+

Galera Cluster for clustered MySQL.

+

GitHub for collaborative programming.

+

HAProxy for load balancing.

+

Hound and reviewdog logo reviewdog for style guidance.

+

Jira for our issue tracking.

+

NGINX for our front end.

+

Memcached for caching.

+

MaxScale for MySQL load balancing.

+

Percona XtraDB Cluster for our relational database.

+

Rails for our framework.

+

Redis for NoSQL.

+

RSpec for unit tests.

+

Ruby as our language.

+

RubyMine for our integrated development environment.

+

Sentry for APM/application monitoring.

+

Slack for communications.

diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b396910 --- /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, find a task on the [issues reserved for first timers](https://otwarchive.atlassian.net/issues/?filter=13119). Otherwise, or if you're up for a challenge, pick a task from the general [open and unassigned issues](https://otwarchive.atlassian.net/issues/?filter=10800). (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. +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) 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 100644 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 100644 index 0000000..62c33bb --- /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 100644 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 100644 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 100644 index 0000000..df30cc9 --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +OTW-Archive +========= + +this is the fork of the OTW's OTW-Archive software that powers symphony and sunset archives. the views and such are for symphony; the sunset version is coming soon. + +features: + +-status updates +-random user button +-estimated reading time for each work + +more always in progress! + +current features in progress: + +-forums + + +Credits for features not made by me +====== + +Reading time estimation: [paintedflowers](https://bytes.4-walls.net/paintedflowers/Futurethingstoteswiitotwcode) on Bytes +--------- + + +[![Build Status](https://img.shields.io/github/actions/workflow/status/otwcode/otwarchive/automated-tests.yml?branch=master)](https://github.com/otwcode/otwarchive/actions/workflows/automated-tests.yml?query=branch%3Amaster) [![Codeship Status](https://img.shields.io/codeship/1f7468f0-7e15-0131-c059-7a8d26daf885/master.svg?label=codeship)](https://www.codeship.io/projects/14476) [![Coverage Status](https://img.shields.io/codecov/c/github/otwcode/otwarchive/master.svg)](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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 index 0000000..c2e59c8 --- /dev/null +++ b/app/controllers/challenge_signups_controller.rb @@ -0,0 +1,396 @@ +# 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]) + no_signup and return unless @challenge_signup + end + + def no_signup + flash[:error] = ts("What sign-up did you want to work on?") + redirect_to collection_path(@collection) rescue redirect_to '/' + false + 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 100644 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 100644 index 0000000..36d3b18 --- /dev/null +++ b/app/controllers/chapters_controller.rb @@ -0,0 +1,285 @@ +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, :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, :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 + return unless params["remove"] == "me" + + @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 + else # remove from work if no longer co-creator on any chapter + redirect_to edit_work_path(@work, remove: "me") + 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 100644 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): ") + "
" + 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 index 0000000..f656cec --- /dev/null +++ b/app/controllers/home_controller.rb @@ -0,0 +1,102 @@ +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) + #unless @homepage.logged_in? + #@user_count, @work_count, @fandom_count = @homepage.rounded_counts + #end + + #@hide_dashboard = true + #render action: 'index', layout: 'application' + #end +#end +#commenting out old index controller so i can add in random user lol + +# replace index at the bottom with this code + +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 100644 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 100644 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 100644 index 0000000..a4bb7b7 --- /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, :query + ) + end +end diff --git a/app/controllers/known_issues_controller.rb b/app/controllers/known_issues_controller.rb new file mode 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 index 0000000..e591c16 --- /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 = ts("History") + if params[:show] == 'to-read' + @readings = @readings.where(toread: true) + @page_subtitle = ts("Marked For Later") + 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 100644 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 100644 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 100644 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 100644 index 0000000..166ba46 --- /dev/null +++ b/app/controllers/series_controller.rb @@ -0,0 +1,156 @@ +class SeriesController < ApplicationController + before_action :check_user_status, only: [:new, :create, :edit, :update] + before_action :load_series, only: [ :show, :edit, :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(", "), @series.anonymous? ? t(".anonymous") : @series.allpseuds.collect(&:byline).join(", "), @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 + if params["remove"] == "me" + 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 Exception => error + flash[:error] = error.message + redirect_to @series + end + 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 100644 index 0000000..e11050a --- /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_back_or_default @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_back_or_default "/" + 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 100644 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..fa0214e --- /dev/null +++ b/app/controllers/statuses_controller.rb @@ -0,0 +1,111 @@ +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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 index 0000000..9ce2f83 --- /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(".browser_title") + end + + def change_password + @page_subtitle = t(".browser_title") + end + + def change_username + authorize @user if logged_in_as_admin? + @page_subtitle = t(".browser_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..cdf60d4 --- /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 + + return unless params['remove'] == 'me' + + 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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={}) + " + \"email + ".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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 << %() + + 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 << %() + + html << %() + end +end diff --git a/app/helpers/prompt_restrictions_helper.rb b/app/helpers/prompt_restrictions_helper.rb new file mode 100644 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 100644 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| "
  • #{span_if_current(pseud.name, [pseud.user, pseud])}
  • " }.join.html_safe + end +end diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb new file mode 100644 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 100644 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 + # + # 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 + # + # 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 100644 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 100644 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 100644 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..62fedd9 --- /dev/null +++ b/app/helpers/statuses_helper.rb @@ -0,0 +1,2 @@ +module StatusesHelper +end diff --git a/app/helpers/tag_sets_helper.rb b/app/helpers/tag_sets_helper.rb new file mode 100644 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 100644 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 100644 index 0000000..09cb393 --- /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 << "
    • " + link_to_tag(sub) + unless sub.direct_sub_tags.empty? + sub_ul << sub_tag_tree(sub) + end + sub_ul << "
    • " + end + sub_ul << "
    " + end + sub_ul.html_safe + end + + def blurb_tag_block(item, tag_groups=nil) + tag_groups ||= item.tag_groups + categories = ['ArchiveWarning', 'Relationship', 'Character', 'Freeform'] + tag_block = "" + + categories.each do |category| + if tags = tag_groups[category] + unless tags.empty? + class_name = tag_block_class_name(category) + if (class_name == "warnings" && hide_warnings?(item)) || (class_name == "freeforms" && hide_freeform?(item)) + tag_block << show_hidden_tag_link_list_item(item, category) + elsif class_name == "warnings" + open_tags = "
  • " + close_tags = "
  • " + link_array = tags.collect{|tag| link_to_tag_works(tag)} + tag_block << open_tags + link_array.join("
  • ") + close_tags + else + link_array = tags.collect{|tag| link_to_tag_works(tag)} + tag_block << "
  • " + link_array.join("
  • ") + '
  • ' + 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.pluck(:name).join(t("support.array.words_connector")) + elsif tags.blank? && category_name.blank? + "Choose Not To Use Archive Warnings" + 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 100644 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 100644 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 100644 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 100644 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 100644 index 0000000..bf0cbcc --- /dev/null +++ b/app/helpers/works_helper.rb @@ -0,0 +1,282 @@ +module WorksHelper + # Average reading speed in words per minute + READING_WPM = 265 + + def estimated_reading_time(word_count) + return "" if word_count.blank? || word_count <= 0 + + minutes = (word_count.to_f / READING_WPM).round + + if minutes.zero? + "< 1 min" + elsif minutes < 60 + "#{minutes} min" + else + hours = minutes / 60 + remaining = minutes % 60 + + if remaining.zero? + "#{hours} hr" + elsif remaining == 1 + "#{hours} hr 1 min" + else + "#{hours} hr #{remaining} min" + end + end + end + + # Optional: nicer inline version with title attribute for exact value + def reading_time_with_title(word_count) + time_str = estimated_reading_time(word_count) + return "" if time_str.blank? + + exact_min = (word_count.to_f / READING_WPM).round(1) + title_text = "#{exact_min} minutes @ #{READING_WPM} wpm" + + content_tag(:span, time_str, title: title_text, class: "reading-time") + end + + # 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("Applause:"), "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("Plays:"), "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_link(work) + link_to ts("Mark as Read"), mark_as_read_work_path(work) + end + + def mark_for_later_link(work) + link_to ts("Mark for Later"), mark_for_later_work_path(work) + 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 << "

    Words: #{work.word_count}, Chapters: #{chapter_total_display(work)}, Language: #{work.language ? work.language.name : 'English'}

    " + + unless work.series.count == 0 + text << "

    Series: #{series_list_for_feeds(work)}

    " + end + + # Create list of tags + text << "
      " + %w(Fandom Rating ArchiveWarning Category Character Relationship Freeform).each do |type| + if tags[type] + text << "
    • #{type.constantize.label_name}: #{tags[type].map { |t| link_to_tag_works(t, full_path: true) }.join(', ')}
    • " + end + end + 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... + 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/works_helper.rb.example b/app/helpers/works_helper.rb.example new file mode 100644 index 0000000..44d6cd6 --- /dev/null +++ b/app/helpers/works_helper.rb.example @@ -0,0 +1,271 @@ +module WorksHelper + # Average reading speed in words per minute + + READING_WPM = 265 + + def estimated_reading_time(word_count) + return "" if word_count.blank? || word_count <= 0 + + minutes = (word_count.to_f / READING_WPM).round + + if minutes.zero? + "< 1 min" + elsif minutes < 60 + "#{minutes} min" + else + hours = minutes / 60 + remaining = minutes % 60 + + if remaining.zero? + "#{hours} hr" + elsif remaining == 1 + "#{hours} hr 1 min" + else + "#{hours} hr #{remaining} min" + end + end + end + + # Optional: nicer inline version with title attribute for exact value + def reading_time_with_title(word_count) + time_str = estimated_reading_time(word_count) + return "" if time_str.blank? + + exact_min = (word_count.to_f / READING_WPM).round(1) + title_text = "#{exact_min} minutes @ #{READING_WPM} wpm" + + content_tag(:span, time_str, title: title_text, class: "reading-time") + end + end + # 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("Applause:"), "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("Plays:"), "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_link(work) + link_to ts("Mark as Read"), mark_as_read_work_path(work) + end + + def mark_for_later_link(work) + link_to ts("Mark for Later"), mark_for_later_work_path(work) + 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 << "

    Words: #{work.word_count}, Chapters: #{chapter_total_display(work)}, Language: #{work.language ? work.language.name : 'English'}

    " + unless work.series.count == 0 + text << "

    Series: #{series_list_for_feeds(work)}

    " + end + # Create list of tags + text << "
      " + %w(Fandom Rating ArchiveWarning Category Character Relationship Freeform).each do |type| + if tags[type] + text << "
    • #{type.constantize.label_name}: #{tags[type].map { |t| link_to_tag_works(t, full_path: true) }.join(', ')}
    • " + end + end + 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 +end diff --git a/app/helpers/wrangling_helper.rb b/app/helpers/wrangling_helper.rb new file mode 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 index 0000000..44cfc68 --- /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: "Symphony Archive" + "<#{ArchiveConfig.RETURN_ADDRESS}>" +end diff --git a/app/mailers/archive_devise_mailer.rb b/app/mailers/archive_devise_mailer.rb new file mode 100644 index 0000000..29556c8 --- /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: "Archive of Our Own <#{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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 index 0000000..dc98628 --- /dev/null +++ b/app/models/freeform.rb @@ -0,0 +1,37 @@ +class Freeform < Tag + + NAME = ArchiveConfig.FREEFORM_CATEGORY_NAME + + def self.label_name + "Music Notes" + 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 index 0000000..8b9ab83 --- /dev/null +++ b/app/models/series.rb @@ -0,0 +1,310 @@ +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) + raise Exception.new("Sorry, we can't remove all authors of a series.") if pseuds_with_author_removed.empty? + 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 100644 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 100644 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 100644 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..e955fca --- /dev/null +++ b/app/models/status.rb @@ -0,0 +1,4 @@ +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 100644 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*(.*?)(?:)?$", 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 index 0000000..1df8c6d --- /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 100644 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 100644 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..6e58898 --- /dev/null +++ b/app/models/work.rb @@ -0,0 +1,1320 @@ +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: 0 + + ######################################################################## + # 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) + raise Exception.new("Sorry, we can't remove all creators of a work.") if pseuds_with_author_removed.empty? + + 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 index 0000000..2729d56 --- /dev/null +++ b/app/views/abuse_reports/new.html.erb @@ -0,0 +1,147 @@ + +

    <%= t(".heading.page_title") %>

    +<%= error_messages_for :abuse_report %> + + + +

    <%= t("a11y.navigation") %>

    +<%= render "home/tos_navigation" %> + + + +

    <%= t(".page_content_landmark") %>

    +
    +

    + <%= t(".purview.about_html", + tos_link: link_to(t(".purview.tos"), tos_path), + tos_faq_link: link_to(t(".purview.tos_faq"), tos_faq_path(anchor: "how_to_report"))) %> +

    + +

    + <%= t(".purview.contact_support_html", + fnok_link: link_to(t(".purview.fnok"), archive_faq_path("fannish-next-of-kin")), + support_link: link_to(t(".purview.support"), new_feedback_report_path)) %> +

    + +

    + <%= t(".purview.dmca_takedown_html", + dmca_abbreviation: tag.abbr(t(".purview.dmca.abbreviated"), title: t(".purview.dmca.full")), + legal_link: link_to(t(".purview.legal"), dmca_path)) %> +

    + +

    + + <%= t(".reportable.intro_html", + pac_link: link_to(t(".reportable.pac"), "https://www.transformativeworks.org/committees/policy-abuse-committee/")) %> + +

    + +
      +
    • <%= t(".reportable.violation_html", + content_policy_link: link_to(t(".reportable.content_policy"), content_path), + tos_link: link_to(t(".reportable.tos"), tos_path)) %>
    • +
    • <%= t(".reportable.allowed") %>
    • +
    • <%= t(".reportable.harassment") %>
    • +
    • <%= t(".reportable.hack") %>
    • +
    • <%= t(".reportable.suspended_html", + email_link: link_to(t(".reportable.email"), tos_faq_path(anchor: "complaint_notification"))) %> +
    • +
    + +

    + + <%= t(".include.intro") %> + +

    + +
      +
    • <%= t(".include.username_html", + reported_username_link: link_to(t(".include.reported_username"), tos_faq_path(anchor: "user_unknown"))) %>
    • +
    • <%= t(".include.other_content") %>
    • +
    • <%= t(".include.quote") %>
    • +
    • <%= t(".include.evidence_html", + sources_link: link_to(t(".include.sources"), tos_faq_path(anchor: "report_infringement"))) %>
    • +
    + +

    + <%= t(".do_not_spam.paragraph_html", + split_bold: tag.strong(t(".do_not_spam.split")), + delay_link: link_to(t(".do_not_spam.delay"), tos_faq_path(anchor: "complaint_resolution"))) %> +

    + +

    + <%= t(".languages.intro_html", + list_html: to_sentence(@abuse_languages.map { |language| tag.span(language.name, lang: language.short) })) %> + <%= t(".languages.delay") %> +

    +
    + +<%= form_for @abuse_report, class: "post" do |f| %> +
    + <%= t(".form.legend.abuse") %> + +
    +
    <%= f.label :username, t(".form.name.label") %>
    +
    <%= f.text_field :username %>
    + +
    <%= f.label :email, t(".form.email.label") %>
    +
    + <%= f.text_field :email, "aria-describedby" => "email-field-description" %> +

    + <%= t(".form.email.description") %> +

    +
    + +
    + <%= f.label :language, t(".form.language.label") %> +
    +
    + <%= f.select(:language, language_options_for_select(@abuse_languages, "name"), + { selected: @abuse_report.language || Language.default.name }) %> +
    + +
    <%= f.label :url, t(".form.link.label") %>
    +
    + <%= f.text_field :url, size: 60, "aria-describedby" => "url-field-description" %> + <%= live_validation_for_field("abuse_report_url", + failureMessage: t(".form.link.error")) %> +

    + <%= t(".form.link.description") %> +

    +
    + +
    + <%= f.label :summary, t(".form.summary.label") %> +
    +
    + <%= f.text_field :summary, class: "observe_textlength", "aria-describedby" => "summary-field-description" %> +

    + <%= t(".form.summary.description") %> +

    + <%= generate_countdown_html("abuse_report_summary", ArchiveConfig.FEEDBACK_SUMMARY_MAX_DISPLAYED) %> + <%= live_validation_for_field("abuse_report_summary", + failureMessage: t(".form.summary.error")) %> +
    + +
    + <%= f.label :comment, t(".form.comment.label") %> +
    +
    +

    + <%= t(".form.comment.description_html", + content_policy_link: link_to(t(".form.comment.content_policy"), content_path), + tos_link: link_to(t(".form.comment.tos"), tos_path), + include_link: link_to(t(".form.comment.include"), anchor: "reporthow")) %> +

    + <%= f.text_area :comment, "aria-describedby" => "comment-field-description" %> + <%= live_validation_for_field("abuse_report_comment", + failureMessage: t(".form.comment.error")) %> +
    + +
    <%= t(".form.landmark.send") %>
    +
    <%= f.submit t(".form.submit.active") %>
    +
    +
    + +<% end %> + diff --git a/app/views/admin/_admin_nav.html.erb b/app/views/admin/_admin_nav.html.erb new file mode 100644 index 0000000..e705ea4 --- /dev/null +++ b/app/views/admin/_admin_nav.html.erb @@ -0,0 +1,41 @@ +

    <%= t(".landmark") %>

    + diff --git a/app/views/admin/_admin_options.html.erb b/app/views/admin/_admin_options.html.erb new file mode 100644 index 0000000..4b6800c --- /dev/null +++ b/app/views/admin/_admin_options.html.erb @@ -0,0 +1,74 @@ + +
    + + <% item_type = item.class.to_s.tableize.singularize %> + +

    <%= t(".landmark") %>

    +
      + <% if policy(item).hide? %> +
    • + <% if item.hidden_by_admin? %> + <%= link_to t(".unhide.#{item_type}"), + hide_admin_user_creation_path(item, creation_type: item.class, hidden: "false"), + method: :put %> + <% else %> + <%= link_to t(".hide.#{item_type}"), + hide_admin_user_creation_path(item, creation_type: item.class, hidden: "true"), + method: :put %> + <% end %> +
    • + <% end %> + <% if @work.present? %> + <% if policy(@work).edit_tags? %> +
    • + <%= link_to t(".edit_tags"), edit_tags_work_path(@work) %> +
    • + <% end %> + <% if policy(@work).set_spam? %> +
    • + <% if @work.spam? %> + <%= link_to t(".not_spam"), + set_spam_admin_user_creation_path(@work.id, creation_type: @work.class, spam: "false"), + method: :put %> + <% else %> + <%= link_to t(".spam"), + set_spam_admin_user_creation_path(@work.id, creation_type: @work.class, spam: "true"), + method: :put %> + <% end %> +
    • + <% end %> + <% if policy(@work).remove_pseud? %> + <% orphan_pseuds = @work.orphan_pseuds %> + <% if orphan_pseuds.length == 1 %> +
    • + <%= link_to t(".remove_pseud"), + confirm_remove_pseud_admin_user_creation_path(@work), + data: { confirm: t(".remove_pseud_confirmation") } %> +
    • + <% elsif orphan_pseuds.length > 1 %> +
    • + <%= link_to t(".remove_pseud"), confirm_remove_pseud_admin_user_creation_path(@work) %> +
    • + <% end %> + <% end %> + <% end %> + <% if item.class == ExternalWork %> + <% if policy(item).edit? %> +
    • + <%= link_to t(".edit.#{item_type}"), edit_external_work_path(item) %> +
    • + <% end %> + <% end %> + <% if policy(item).destroy? %> +
    • + <%= link_to t(".delete.#{item_type}"), + admin_user_creation_path(item, creation_type: item.class), + data: { + confirm: t(".delete.confirmation") + }, + method: :delete %> +
    • + <% end %> +
    +
    + diff --git a/app/views/admin/_header.html.erb b/app/views/admin/_header.html.erb new file mode 100644 index 0000000..9c7ae29 --- /dev/null +++ b/app/views/admin/_header.html.erb @@ -0,0 +1,82 @@ + diff --git a/app/views/admin/activities/index.html.erb b/app/views/admin/activities/index.html.erb new file mode 100644 index 0000000..6a5948c --- /dev/null +++ b/app/views/admin/activities/index.html.erb @@ -0,0 +1,41 @@ +
    + +

    <%= t(".page_heading") %>

    + + + + + + +
    + "> + + + + + + + + + + + + + + + <% for admin_activity in @activities %> + + + + + + + <% end %> + +
    <%= t(".activities_table.caption") %>
    <%= t(".activities_table.date") %><%= t(".activities_table.admin") %><%= t(".activities_table.action") %><%= t(".activities_table.target") %>
    <%= link_to admin_activity.created_at, admin_activity %><%= admin_activity_login_string(admin_activity) %><%= admin_activity.action %><%= admin_activity_target_link(admin_activity) %>
    +
    + + + <%= will_paginate @activities %> + +
    diff --git a/app/views/admin/activities/show.html.erb b/app/views/admin/activities/show.html.erb new file mode 100644 index 0000000..98264a5 --- /dev/null +++ b/app/views/admin/activities/show.html.erb @@ -0,0 +1,33 @@ +
    + +

    <%= t(".page_heading") %>

    + + + + + + + +

    <%= t(".landmark.details") %>

    +
    +
    +
    <%= t(".date") %>
    +
    <%= @activity.created_at %>
    + +
    <%= t(".admin") %>
    +
    <%= admin_activity_login_string(@activity) %>
    + +
    <%= t(".action") %>
    +
    <%= @activity.action %>
    + +
    <%= t(".target") %>
    +
    <%= admin_activity_target_link(@activity) %>
    + +
    <%= t(".summary") %>
    +
    <%= admin_activity_summary(@activity) %>
    +
    +
    + +
    diff --git a/app/views/admin/admin_invitations/find.html.erb b/app/views/admin/admin_invitations/find.html.erb new file mode 100644 index 0000000..1921b28 --- /dev/null +++ b/app/views/admin/admin_invitations/find.html.erb @@ -0,0 +1,30 @@ + +

    <%= t(".page_heading") %>

    + + + + + + +<%= form_tag url_for(controller: "admin/admin_invitations", action: "find"), autocomplete: "off", class: "invitation simple search", method: :get do %> +
    +
    <%= label_tag "invitation[user_name]", t(".username") %>
    +
    <%= text_field_tag "invitation[user_name]", params[:invitation][:user_name] %>
    +
    <%= label_tag "invitation[token]", t(".token") %>
    +
    <%= text_field_tag "invitation[token]", params[:invitation][:token] %>
    +
    <%= label_tag "invitee_email", t(".email") %>
    +
    <%= text_field_tag "invitation[invitee_email]", params[:invitation][:invitee_email], id: "invitee_email" %>
    +
    +

    <%= submit_tag t(".search") %>

    +<% end %> + +<% if @user %> +

    <%= t(".invitations_for_html", user_invitations_link: link_to(@user.login, user_invitations_path(@user))) %>

    +<% end %> +<% if @invitations %> +<%= render "invitations/user_invitations", invitations: @invitations %> +<% end %> + + + + diff --git a/app/views/admin/admin_invitations/index.html.erb b/app/views/admin/admin_invitations/index.html.erb new file mode 100644 index 0000000..e65a803 --- /dev/null +++ b/app/views/admin/admin_invitations/index.html.erb @@ -0,0 +1,76 @@ + +

    <%= t(".page_heading") %>

    +<%= error_messages_for @invitation %> + + + + + +<%= form_tag url_for(controller: "admin/admin_invitations", action: :create), class: "invitation simple post", autocomplete: "off" do %> +
    +

    <%= t(".send_to_email.heading") %>

    +

    + <%= t(".send_to_email.description") %> + <%= text_field_tag "invitation[invitee_email]", + (@invitation.try(:invitee_email) || ""), + title: t(".send_to_email.invite_by_email_title") %> + <%= submit_tag t(".send_to_email.invite_user") %> +

    +
    +<% end %> + +<%= form_tag url_for(controller: "admin/admin_invitations", action: :invite_from_queue), class: "queue invitation simple post", autocomplete: "off" do %> +
    +

    + <%= t(".invite_from_queue.heading_html", + invitations_queue_link: link_to(t(".invite_from_queue.invitations_queue"), invite_requests_path)) %> +

    +

    <%= t(".invite_from_queue.requests_in_queue", count: InviteRequest.count) %>

    +

    + <%= label_tag "invitation[invite_from_queue]", t(".invite_from_queue.number_to_invite") %> + <%= text_field_tag "invitation[invite_from_queue]" %> + <%= submit_tag t(".invite_from_queue.invite_from_queue") %> +

    +
    +<% end %> + +<%= form_tag url_for(controller: "admin/admin_invitations", action: :grant_invites_to_users), class: "bulk invitation simple post", autocomplete: "off" do %> +
    +

    <%= t(".grant_invites.heading") %>

    +
    +
    <%= label_tag "invitation[number_of_invites]", t(".grant_invites.number_of_invitations") %>
    +
    <%= text_field_tag "invitation[number_of_invites]" %>
    +
    <%= label_tag "invitation[user_group]", t(".grant_invites.users") %>
    +
    + <%= select_tag "invitation[user_group]", + options_for_select([[t(".grant_invites.all"), "All"], + [t(".grant_invites.with_no_unused"), "With no unused invitations"]], + "All") %> +
    +
    <%= t(".grant_invites.landmark_submit") %>
    +
    <%= submit_tag t(".grant_invites.generate_invitations") %>
    +
    +
    +<% end %> + +<%= form_tag url_for(controller: "admin/admin_invitations", action: :find), class: "invitation simple search", autocomplete: "off", method: :get do %> +
    +

    <%= t(".find.heading") %>

    +
    +
    <%= label_tag "invitation[user_name]", t(".find.username") %>
    +
    <%= text_field_tag "invitation[user_name]" %>
    +
    <%= label_tag "invitation[token]", t(".find.invite_token") %>
    +
    <%= text_field_tag "invitation[token]" %>
    +
    <%= label_tag "track_invitation_invitee_email", t(".find.email") %>
    +
    <%= text_field_tag "invitation[invitee_email]", nil, id: "track_invitation_invitee_email" %>
    +
    <%= t(".find.landmark_submit") %>
    +
    <%= submit_tag t(".find.search") %>
    +
    +
    +<% end %> + diff --git a/app/views/admin/admin_invitations/new.html.erb b/app/views/admin/admin_invitations/new.html.erb new file mode 100644 index 0000000..74d6a72 --- /dev/null +++ b/app/views/admin/admin_invitations/new.html.erb @@ -0,0 +1,16 @@ + +

    <%= t('.invite_user', :default => "Invite a User") %>

    + + + + + + +<%= form_for :invitation, @invitation, :url => { :controller => 'admin/admin_invitations', :action => :create } do |f| %> +

    <%= f.label :recipient_email, t('.email_address', :default => "Email address") %> <%= f.text_field :recipient_email %>

    +

    <%= f.submit t('.submit', :default => "Invite!") %>

    +<% 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 100644 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. %> +
    +

    <%= search_header(@works, nil, "Work") %>

    + <%= render "works/work_abbreviated_list", works: works %> + <%= will_paginate(works, param_name: "works_page", params: { anchor: "works-summary" }) %> +
    +<% 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. %> +
    +

    <%= search_header(@comments, nil, "Comment") %>

    + <%= render "comments/comment_abbreviated_list", comments: comments %> + <%= will_paginate(comments, param_name: "comments_page", params: { anchor: "comments-summary" }) %> +
    +<% 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 100644 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? %> + <%= link_to user.login, user_path(user) %> + <%= text_field_tag "user[email]", user.email, title: ts("Email"), disabled: !admin_can_update_user_email? %> + <% for role in @roles %> + + <%= check_box_tag "user[roles][]", role.id, user.roles.include?(role), title: role.name, id: "user_roles_#{role.id}", disabled: !policy(User).can_edit_user_role?(role) %> + + <% end %> + + <% unless user.fannish_next_of_kin.blank? %> + <%= link_to user.fannish_next_of_kin.kin_name, user_path(user.fannish_next_of_kin.kin_name) %> + <% end %> + + <%= submit_tag ts("Update") %> + <%= link_to ts("Details"), admin_user_path(user) %> +<% end %> diff --git a/app/views/admin/admin_users/_user_history.html.erb b/app/views/admin/admin_users/_user_history.html.erb new file mode 100644 index 0000000..74724d1 --- /dev/null +++ b/app/views/admin/admin_users/_user_history.html.erb @@ -0,0 +1,51 @@ +

    <%= t("admin.admin_users.history.heading") %>

    +
    + "> + + + + + + + + + + + + + + + + + + + + <% unless @log_items.blank? + @log_items.each do |item| %> + + + + + + <% end %> + <% end %> + + + + + + +
    <%= t("admin.admin_users.history.table.caption") %>
    <%= t("admin.admin_users.history.table.head.date") %><%= t("admin.admin_users.history.table.head.action") %><%= t("admin.admin_users.history.table.head.details") %>
    <%= @user.current_sign_in_at.nil? ? "" : @user.current_sign_in_at %><%= t("admin.admin_users.history.table.sign_in.current.action") %> + <% if @user.current_sign_in_at.nil? %> + <%= t("admin.admin_users.history.table.sign_in.current.no_details") %> + <% else %> + <%= t("admin.admin_users.history.table.sign_in.details", ip: @user.current_sign_in_ip) %> + <% end %> +
    <%= @user.last_sign_in_at.nil? ? "" : @user.last_sign_in_at %><%= t("admin.admin_users.history.table.sign_in.last.action") %> + <% if @user.last_sign_in_at.nil? %> + <%= t("admin.admin_users.history.table.sign_in.last.no_details") %> + <% else %> + <%= t("admin.admin_users.history.table.sign_in.details", ip: @user.last_sign_in_ip) %> + <% end %> +
    <%= item.created_at %><%= log_item_action_name(item) %><%= item.role&.name %><%= item.enddate %><%= item.note %>
    <%= @user.created_at %><%= t("admin.admin_users.history.table.creation.action") %><%= t("admin.admin_users.history.table.creation.details") %>
    +
    diff --git a/app/views/admin/admin_users/_user_listing.html.erb b/app/views/admin/admin_users/_user_listing.html.erb new file mode 100644 index 0000000..7570407 --- /dev/null +++ b/app/views/admin/admin_users/_user_listing.html.erb @@ -0,0 +1,8 @@ +<%= user.login.html_safe %> +"><%= user.email.html_safe %> +<%= ts("Tag Wrangler") if user.tag_wrangler %> +<%= ts("Archivist") if user.archivist %> +<%= ts("Suspended") if user.suspended %> +<%= ts("Banned") if user.banned %> +<%= link_to ts("Edit"), { url: edit_admin_user_path(user), method: :get }, remote: true, href: edit_admin_user_path(user) %> +<%= ts("Delete") %> diff --git a/app/views/admin/admin_users/_user_table.html.erb b/app/views/admin/admin_users/_user_table.html.erb new file mode 100644 index 0000000..fb4f9be --- /dev/null +++ b/app/views/admin/admin_users/_user_table.html.erb @@ -0,0 +1,33 @@ +

    <%= ts("#{pluralize(@users.total_entries, 'user')} found") %>

    +<% if @users.size > 0 %> +
    + "> + + + + + + + + + + <% for role in @roles %> + + <% end %> + + + + + + + <% @users.each do |user| %> + + <%= render "user_form", user: user %> + + <% end %> + +
    <%= ts("List of Users") %>
    <%= ts("Username") %><%= ts("Email") %><%= role.name.try(:titleize) %><%= ts("Fannish Next of Kin") %><%= ts("Update") %><%= ts("Details") %>
    +
    + <%= will_paginate @users %> +<% end %> + diff --git a/app/views/admin/admin_users/bulk_search.html.erb b/app/views/admin/admin_users/bulk_search.html.erb new file mode 100644 index 0000000..e1b16e0 --- /dev/null +++ b/app/views/admin/admin_users/bulk_search.html.erb @@ -0,0 +1,46 @@ +
    + +

    <%= ts("Bulk Email Search") %>

    + + + +
    +

    <%= ts("Please enter a list of email addresses to search below. This form will search for exact matches.").html_safe %>

    +
    + + <%= form_tag url_for(controller: "admin/admin_users", action: :bulk_search, method: :post), class: "search", role: "search" do %> +
    + <%= ts("Email addresses") %> +
    +
    <%= label_tag "emails", ts("Email addresses *") %>
    +
    <%= text_area_tag "emails", @emails ? @emails.join("\n") : "", rows: 10, cols: 70, "aria-describedby" => "email-field-description" %> +

    + <%= ts("Email addresses to find; one per line.").html_safe %> +

    +
    +
    +
    + +
    + <%= ts("Find") %> +

    + <%= submit_tag ts("Download CSV"), name: "download_button" %> + <%= submit_tag ts("Find") %> +

    +
    + <% end %> + + <% if @results[:not_found_emails] || @results[:users] %> +

    <%= ts("#{pluralize(@results[:total], "email")} entered. #{@results[:searched]} searched. " + + "#{@results[:found_users].size} found. #{@results[:not_found_emails].size} not found. #{pluralize(@results[:duplicates], "duplicate")}.") %>

    + + <% if @results[:not_found_emails]&.any? %> +

    <%= ts("Not found") %>

    +

    <%= @results[:not_found_emails].join(", ") %>

    + <% end %> + + <% if @users %> + <%= render "user_table", users: @users %> + <% end %> + <% end %> +
    diff --git a/app/views/admin/admin_users/confirm_delete_user_creations.html.erb b/app/views/admin/admin_users/confirm_delete_user_creations.html.erb new file mode 100644 index 0000000..9a40a81 --- /dev/null +++ b/app/views/admin/admin_users/confirm_delete_user_creations.html.erb @@ -0,0 +1,17 @@ + +

    <%= t(".page_heading") %>

    + + +<%= form_tag destroy_user_creations_admin_user_path(@user), method: :post, class: "simple destroy" do %> + +

    + <%= t(".caution_html", bookmarks: @bookmarks.size, series: @series.size, collections: @collections.size) %> +

    + + <%= render "user_creations_summary", works: @works, comments: @comments %> + +

    + <%= submit_tag t(".submit"), data: { confirm: t(".confirm") } %> +

    +<% end %> + diff --git a/app/views/admin/admin_users/creations.html.erb b/app/views/admin/admin_users/creations.html.erb new file mode 100644 index 0000000..9ac8030 --- /dev/null +++ b/app/views/admin/admin_users/creations.html.erb @@ -0,0 +1,24 @@ +
    + +

    <%= t(".page_heading", user: @user.login) %>

    + + + + + + + + <% if @user.works.empty? && @user.comments.empty? %> +

    <%= t(".no_creations") %>

    + <% else %> + <%= render "user_creations_summary", works: @works, comments: @comments %> + <% end %> + +
    diff --git a/app/views/admin/admin_users/index.html.erb b/app/views/admin/admin_users/index.html.erb new file mode 100644 index 0000000..258dae7 --- /dev/null +++ b/app/views/admin/admin_users/index.html.erb @@ -0,0 +1,78 @@ +
    + +

    <%= ts("Find Users") %>

    + + + + <%= form_tag url_for(controller: "admin/admin_users", action: "index"), method: :get, class: "search", role: "search" do %> +
    +
    <%= label_tag "name", ts("Name") %>
    +
    + <%= text_field_tag "name", params[:name], "aria-describedby": "name-field-description" %> +

    + <%= ts("Search for users with matching usernames or pseuds.") %> +

    +
    +
    <%= label_tag "email", ts("Email") %>
    +
    <%= text_field_tag "email", params[:email] %>
    +
    <%= label_tag "user_id", ts("User ID") %>
    +
    + <%= text_field_tag "user_id", params[:user_id], "aria-describedby": "user-id-field-description" %> +

    + <%= ts("Search for user with exact ID.") %> +

    +
    +
    <%= label_tag "role_id", t(".role") %>
    +
    <%= select_tag "role_id", options_for_select(@role_values, params[:role_id].to_i), include_blank: true %>
    +
    <%= label_tag "status", ts("Status") %>
    +
    + <%= check_box_tag "inactive", "1", params[:inactive].present? %> + <%= label_tag "inactive", ts("Not yet activated") %> +
    + <% if policy(User).can_view_past? %> +
    <%= label_tag "settings", t(".settings.label") %>
    +
    + <%= check_box_tag "search_past", "1", params[:search_past].present? %> + <%= label_tag "search_past", t(".settings.past") %> +
    + <% end %> +
    +

    <%= submit_tag ts("Find") %>

    + <% end %> + + <% if @users %> +

    <%= ts("#{pluralize(@users.total_entries, 'user')} found") %>

    + <% if @users.size > 0 %> +
    + "> + + + + + + + + + + <% for role in @roles %> + + <% end %> + + + + + + + <% @users.each do |user| %> + + <%= render "user_form", user: user %> + + <% end %> + +
    <%= ts("List of Users") %>
    <%= ts("Username") %><%= ts("Email") %><%= role.name.try(:titleize) %><%= ts("Fannish Next of Kin") %><%= ts("Update") %><%= ts("Details") %>
    +
    + <% end %> + <%= will_paginate @users %> + <% end %> + +
    diff --git a/app/views/admin/admin_users/show.html.erb b/app/views/admin/admin_users/show.html.erb new file mode 100644 index 0000000..a910978 --- /dev/null +++ b/app/views/admin/admin_users/show.html.erb @@ -0,0 +1,144 @@ +
    + +

    <%= t(".page_heading", login: @user.login, id: @user.id) %>

    + + + + + + + +

    <%= t(".note") %>

    +

    <%= t(".info.heading") %>

    +
    +
    +
    <%= t(".info.email") %>
    +
    <%= @user.email %>
    +
    <%= t(".info.invitation") %>
    +
    <%= @user.invitation ? link_to(@user.invitation.id, invitation_path(@user.invitation.id)) : t(".info.no_invitation") %>
    +
    <%= t(".info.role", count: @user.roles.size) %>
    +
    <%= @user.roles.any? ? @user.roles.map { |role| t("activerecord.attributes.role.#{role.name}") }.to_sentence : t(".info.no_role") %>
    + <% if policy(User).can_view_past? %> + <% past_logins = @user.historic_values("login") %> + <% if past_logins.present? %> +
    <%= t(".info.past_logins", count: past_logins.size) %>
    +
    + <%= past_logins.to_sentence %> +
    + <% end %> + <% past_emails = @user.historic_values("email") %> + <% if past_emails.present? %> +
    <%= t(".info.past_emails", count: past_emails.size) %>
    +
    + <%= past_emails.to_sentence %> +
    + <% end %> + <% end %> +
    +
    + +

    <%= t(".fnok.heading") %>

    + <%= form_tag action: "update_next_of_kin", controller: "admin/admin_users" do %> + <%= error_messages_for @user.fannish_next_of_kin %> + <%= hidden_field_tag "user_login", @user.login %> + <%= field_set_tag t(".fnok.heading"), disabled: !policy(@user).can_manage_next_of_kin? do %> +
    +
    + <%= label_tag "next_of_kin_name", t(".fnok.form.name") %> +
    +
    + <%= text_field_tag "next_of_kin_name", @user.fannish_next_of_kin.try(:kin_name), autocomplete: "off" %> +
    +
    + <%= label_tag "next_of_kin_email", t(".fnok.form.email") %> +
    +
    + <%= text_field_tag "next_of_kin_email", @user.fannish_next_of_kin.try(:kin_email), autocomplete: "off" %> +
    +
    +

    <%= submit_tag t(".fnok.form.submit") %>

    + <% end %> + <% end %> + +

    <%= t(".status.heading") %>

    + <%= form_tag action: "update_status", controller: "admin/admin_users" do %> + <%= hidden_field_tag "user_login", @user.login %> + <%= field_set_tag t(".status.heading"), disabled: !policy(UserManager).can_manage_users? do %> +
    + <%= t(".status.form.admin_action.legend") %> +

    <%= t(".status.form.admin_action.legend") %>

    +
      +
    • + <%= radio_button_tag "admin_action", "note" %> + <%= label_tag "admin_action_note", t(".status.form.admin_action.note") %> +
    • +
    • + <%= radio_button_tag "admin_action", "warn" %> + <%= label_tag "admin_action_warn", t(".status.form.admin_action.warn") %> +
    • +
    • + <% if @user.suspended? %> + <%= radio_button_tag "admin_action", "unsuspend", false %> + <%= label_tag "admin_action_unsuspend", t(".status.form.admin_action.unsuspend") %> + <% else %> + <%= radio_button_tag "admin_action", "suspend", false, disabled: @user.banned? %> + <%= label_tag "admin_action_suspend", t(".status.form.admin_action.suspend") %> + <%= text_field_tag "suspend_days", "", class: "number", size: 2, disabled: @user.banned? %> + <%= live_validation_for_field("suspend_days", presence: false, numericality: true) %> + <% end %> +
    • +
    • + <% if @user.banned? %> + <%= radio_button_tag "admin_action", "unban", false %> + <%= label_tag "admin_action_unban", t(".status.form.admin_action.unban") %> + <% else %> + <%= radio_button_tag "admin_action", "ban", false %> + <%= label_tag "admin_action_ban", t(".status.form.admin_action.ban") %> + <% end %> +
    • +
    • + <%= radio_button_tag "admin_action", "spamban", false %> + <%= label_tag "admin_action_spamban", t(".status.form.admin_action.spamban") %> +
    • +
    +
    +
    + <%= t(".status.form.notes.legend") %> +

    <%= label_tag "admin_note", t(".status.form.notes.legend") %>

    +

    + <%= t(".status.form.notes.required") %> +

    +

    + <%= text_area_tag "admin_note", nil, class: "observe_textlength" %> + <%= generate_countdown_html("admin_note", ArchiveConfig.LOGNOTE_MAX) %> +

    + <% end %> +

    <%= submit_tag t(".status.form.submit") %>

    +
    + <% end %> + + <%= render "user_history", user: @user %> + +
    diff --git a/app/views/admin/api/_api_key_form.html.erb b/app/views/admin/api/_api_key_form.html.erb new file mode 100644 index 0000000..2c2c03a --- /dev/null +++ b/app/views/admin/api/_api_key_form.html.erb @@ -0,0 +1,39 @@ +<%= form_for @api_key, + url: @api_key.new_record? ? new_admin_api_path(@api_key) : admin_api_path(@api_key), + html: { class: "post" } do |f| %> + <%= error_messages_for @api_key %> + +

    * <%= ts("Required information") %>

    + +
    + <%= ts("API Token") %> +

    <%= ts("API Token") %>

    +
    +
    <%= f.label :name, ts("Name") + "*" %>
    +
    + <%= f.text_field :name %> + <%= live_validation_for_field("api_key_name", + :presence => true, + :maximum_length => Profile::PROFILE_TITLE_MAX) %> +
    +
    <%= f.check_box :banned %>
    +
    + <%= f.label :banned, ts("Banned?") %> +
    +
    + <%= f.label :access_token, ts("Token (automatically generated)") %> +
    +
    + <%= f.text_field :access_token, readonly: true %> +
    +
    +
    +
    + <%= ts("Actions") %> +

    <%= ts("Actions") %>

    + +

    + <%= f.submit @api_key.new_record? ? ts("Create API Token") : ts("Update API Token") %> +

    +
    +<% end %> diff --git a/app/views/admin/api/_navigation.html.erb b/app/views/admin/api/_navigation.html.erb new file mode 100644 index 0000000..8ff75c0 --- /dev/null +++ b/app/views/admin/api/_navigation.html.erb @@ -0,0 +1,4 @@ + diff --git a/app/views/admin/api/edit.html.erb b/app/views/admin/api/edit.html.erb new file mode 100644 index 0000000..e4bad48 --- /dev/null +++ b/app/views/admin/api/edit.html.erb @@ -0,0 +1,13 @@ +
    + +

    <%= ts("Edit API Token") %>

    + + + + <%= render "navigation" %> + + + + <%= render "api_key_form" %> + +
    diff --git a/app/views/admin/api/index.html.erb b/app/views/admin/api/index.html.erb new file mode 100644 index 0000000..3e1f7e6 --- /dev/null +++ b/app/views/admin/api/index.html.erb @@ -0,0 +1,54 @@ +
    +

    <%= t(".page_heading") %>

    + + + <%= render "navigation" %> + + + <%= will_paginate @api_keys %> + + + <%= form_tag url_for(controller: "admin/api", action: "index"), method: :get, class: "search", role: "search" do %> +

    <%= t(".search_by_name") %>

    +
    +
    <%= label_tag "query", t(".search_box.label") %>
    +
    <%= text_field_tag "query", params[:query] %> +
    +
    +

    <%= submit_tag t(".actions.find") %>

    + <% end %> + + "> + + + + + + + + + + + + + <% @api_keys.each do |api_key| %> + + + + + + + + + <% end %> + +
    <%= t(".table.caption") %>
    <%= t(".table.headings.name") %><%= t(".table.headings.token") %><%= t(".table.headings.banned") %><%= t(".table.headings.created") %><%= t(".table.headings.updated") %><%= t(".table.headings.actions") %>
    <%= api_key.name %><%= api_key.access_token %><%= check_box_tag :banned, api_key.banned, api_key.banned, disabled: true %><%= api_key.created_at %><%= api_key.updated_at %> +
      +
    • <%= link_to t(".table.actions.edit"), edit_admin_api_path(api_key) %>
    • +
    +
    + + + <%= will_paginate @api_keys %> + +
    diff --git a/app/views/admin/api/new.html.erb b/app/views/admin/api/new.html.erb new file mode 100644 index 0000000..9e7c6f7 --- /dev/null +++ b/app/views/admin/api/new.html.erb @@ -0,0 +1,13 @@ +
    + +

    <%= ts("New API Token") %>

    + + + + <%= render "navigation" %> + + + + <%= render "api_key_form" %> + +
    diff --git a/app/views/admin/banners/_banner.html.erb b/app/views/admin/banners/_banner.html.erb new file mode 100644 index 0000000..2e74720 --- /dev/null +++ b/app/views/admin/banners/_banner.html.erb @@ -0,0 +1,7 @@ +<% # expects admin_banner %> +<% # don't forget to update layouts/banner! %> +
    +
    + <%= raw sanitize_field(admin_banner, :content, image_safety_mode: true) %> +
    +
    diff --git a/app/views/admin/banners/_banner_form.html.erb b/app/views/admin/banners/_banner_form.html.erb new file mode 100644 index 0000000..2f89868 --- /dev/null +++ b/app/views/admin/banners/_banner_form.html.erb @@ -0,0 +1,55 @@ +<%= form_for @admin_banner, html: { class: 'post' } do |f| %> + <%= error_messages_for @admin_banner %> + +

    <%= ts('* Required information') %>

    + +
    + <%= ts('Admin Banner') %> +

    <%= ts('Admin Banner') %>

    +
    + +
    <%= f.label :content, ts('Banner text') + '*' %>
    +
    +

    <%= allowed_html_instructions %>

    + <%= f.text_area :content %> +
    + +
    <%= ts('Banner type') %>*
    +
    +
      +
    • + <%= f.radio_button :banner_type, '', checked: true %> + <%= f.label :banner_type_, ts('Default') %> +
    • +
    • + <%= f.radio_button :banner_type, 'event' %> + <%= f.label :banner_type_event, ts('Event (For membership drives, celebrations, etc.)') %> +
    • +
    • + <%= f.radio_button :banner_type, 'alert' %> + <%= f.label :banner_type_alert, ts('Alert (For bugs, important changes, expected downtime, etc.)') %> +
    • +
    +
    + +
    <%= f.check_box :active %>
    +
    <%= f.label :active, ts('Active') %>
    + + <% if @admin_banner.active? %> +
    <%= check_box_tag :admin_banner_minor_edit %>
    +
    <%= label_tag :admin_banner_minor_edit, ts('This is a minor update (Do not turn the banner back on for users who have dismissed it)') %>
    + <% end %> + +
    +
    + +
    + <%= ts('Actions') %> +

    <%= ts('Actions') %>

    + +

    + <%= f.submit @admin_banner.new_record? ? ts('Create Banner') : ts('Update Banner') %> +

    +
    + +<% end %> diff --git a/app/views/admin/banners/_navigation.html.erb b/app/views/admin/banners/_navigation.html.erb new file mode 100644 index 0000000..bb70f37 --- /dev/null +++ b/app/views/admin/banners/_navigation.html.erb @@ -0,0 +1,12 @@ + diff --git a/app/views/admin/banners/confirm_delete.html.erb b/app/views/admin/banners/confirm_delete.html.erb new file mode 100644 index 0000000..eac7f69 --- /dev/null +++ b/app/views/admin/banners/confirm_delete.html.erb @@ -0,0 +1,16 @@ +
    + +

    <%= ts('Delete Banner') %>

    + + + + <%= form_for(@admin_banner, html: {method: :delete, class: 'simple destroy'}) do |f| %> +

    + <%= ts('Are you sure you want to delete this banner?').html_safe %> +

    +

    + <%= f.submit ts('Yes, Delete Banner') %> +

    + <% end %> + +
    diff --git a/app/views/admin/banners/edit.html.erb b/app/views/admin/banners/edit.html.erb new file mode 100644 index 0000000..faacc8e --- /dev/null +++ b/app/views/admin/banners/edit.html.erb @@ -0,0 +1,13 @@ +
    + +

    <%= ts('Edit Banner') %>

    + + + + <%= render 'navigation' %> + + + + <%= render 'banner_form' %> + +
    diff --git a/app/views/admin/banners/index.html.erb b/app/views/admin/banners/index.html.erb new file mode 100644 index 0000000..c36cd46 --- /dev/null +++ b/app/views/admin/banners/index.html.erb @@ -0,0 +1,40 @@ +
    + +

    <%= t(".page_heading") %>

    + + + + <%= render 'navigation' %> + + + <%= will_paginate @admin_banners %> + + +
      + <% for admin_banner in @admin_banners %> +
    • + <%= render "banner", admin_banner: admin_banner %> +
        + <% if admin_banner.active? %> +
      • + <%= t(".actions.active") %> +
      • + <% end %> + +
      • + <%= link_to t(".actions.edit"), edit_admin_banner_path(admin_banner) %> +
      • + <% if policy(AdminBanner).destroy? %> +
      • + <%= link_to t(".actions.delete"), confirm_delete_admin_banner_path(admin_banner), data: { confirm: t(".actions.confirm_delete") } %> +
      • + <% end %> +
      +
    • + <% end %> +
    + + + <%= will_paginate @admin_banners %> + +
    diff --git a/app/views/admin/banners/new.html.erb b/app/views/admin/banners/new.html.erb new file mode 100644 index 0000000..7e9288d --- /dev/null +++ b/app/views/admin/banners/new.html.erb @@ -0,0 +1,13 @@ +
    + +

    <%= ts('New Banner') %>

    + + + + <%= render 'navigation' %> + + + + <%= render 'banner_form' %> + +
    diff --git a/app/views/admin/banners/show.html.erb b/app/views/admin/banners/show.html.erb new file mode 100644 index 0000000..e70a268 --- /dev/null +++ b/app/views/admin/banners/show.html.erb @@ -0,0 +1,13 @@ +
    + +

    <%= ts('Banner') %>

    + + + + <%= render 'navigation' %> + + + + <%= render 'banner', admin_banner: @admin_banner %> + +
    diff --git a/app/views/admin/blacklisted_emails/index.html.erb b/app/views/admin/blacklisted_emails/index.html.erb new file mode 100644 index 0000000..64f3dfd --- /dev/null +++ b/app/views/admin/blacklisted_emails/index.html.erb @@ -0,0 +1,62 @@ +
    + +

    <%= t(".page_heading") %>

    + +
      +
    • <%= t(".notes.guest_comments") %>
    • +
    • <%= t(".notes.unaffected_users") %>
    • +
    • <%= t(".notes.canonical_format") %>
    • +
    + + + +

    <%= t(".heading.new") %>

    + + <%= form_for(@admin_blacklisted_email, html: { class: "simple post" }) do |f| %> + <%= error_messages_for @admin_blacklisted_email %> +
    +

    + <%= f.label :email, t(".form.new.label") %> + <%= f.text_field :email %> + <%= f.submit t(".form.new.submit") %> +

    +
    + <% end %> + +

    <%= t(".heading.search")%>

    + + + <%= form_tag url_for(controller: "admin/blacklisted_emails", action: "index"), + method: :get, class: "simple search", role: "search" do %> +
    +

    + <%= label_tag "query", ts("Email to find") %> + <%= text_field_tag "query", params[:query] %> + <%= submit_tag t(".form.search.submit") %> +

    +
    + <% end %> + + + <% if @admin_blacklisted_emails %> +
    +

    + <%= t("admin.blacklist.emails_found", count: @admin_blacklisted_emails.count) %> +

    + <% if @admin_blacklisted_emails.count > 0 %> +
      + <% @admin_blacklisted_emails.each do |blacklisted_email| %> +
    1. + <%= link_to ts("Remove %{email}", email: blacklisted_email.email), + blacklisted_email, + method: :delete, + data: { confirm: t(".confirm_remove", email: blacklisted_email.email) } %> +
    2. + <% end %> + + <%= will_paginate @admin_blacklisted_emails %> + <% end %> +
    + <% end %> + +
    diff --git a/app/views/admin/mailer/password_change.html.erb b/app/views/admin/mailer/password_change.html.erb new file mode 100644 index 0000000..15ac5c8 --- /dev/null +++ b/app/views/admin/mailer/password_change.html.erb @@ -0,0 +1,13 @@ +<% content_for :message do %> +

    <%= t("mailer.general.greeting.formal.addressed_html", name: style_bold(@resource.login)) %>

    + +

    <%= t(".changed", date: l(Time.current), app_name: ArchiveConfig.APP_SHORT_NAME) %>

    + +

    <%= t(".made_change.html", + login_link: style_link(t(".made_change.login"), new_admin_session_url), + reset_password_link: style_link(t(".made_change.reset_password"), new_admin_password_url)) %>

    + +

    <%= t(".did_not_make_change", app_name: ArchiveConfig.APP_SHORT_NAME) %>

    + +

    <%= t(".secure_again.html", reset_password_link: style_link(t(".secure_again.reset_password"), new_admin_password_url)) %>

    +<% end %> diff --git a/app/views/admin/mailer/password_change.text.erb b/app/views/admin/mailer/password_change.text.erb new file mode 100644 index 0000000..cd20bad --- /dev/null +++ b/app/views/admin/mailer/password_change.text.erb @@ -0,0 +1,13 @@ +<% content_for :message do %> +<%= t("mailer.general.greeting.formal.addressed_html", name: @resource.login) %> + +<%= t(".changed", date: l(Time.current), app_name: ArchiveConfig.APP_SHORT_NAME) %> + +<%= t(".made_change.text", + login_url: new_admin_session_url, + reset_password_url: new_admin_password_url) %> + +<%= t(".did_not_make_change", app_name: ArchiveConfig.APP_SHORT_NAME) %> + +<%= t(".secure_again.text", reset_password_url: new_admin_password_url) %> +<% end %> diff --git a/app/views/admin/mailer/reset_password_instructions.html.erb b/app/views/admin/mailer/reset_password_instructions.html.erb new file mode 100644 index 0000000..289b10f --- /dev/null +++ b/app/views/admin/mailer/reset_password_instructions.html.erb @@ -0,0 +1,7 @@ +<% content_for :message do %> +

    <%= t("mailer.general.greeting.formal.addressed_html", name: style_bold(@resource.login)) %>

    +

    <%= t(".intro") %>

    +

    <%= style_link t(".link_title_html"), edit_admin_password_url(reset_password_token: @token) %>

    +

    <%= t(".expiration", count: ArchiveConfig.DAYS_UNTIL_ADMIN_RESET_PASSWORD_LINK_EXPIRES) %>

    +

    <%= t(".unrequested") %>

    +<% end %> diff --git a/app/views/admin/mailer/reset_password_instructions.text.erb b/app/views/admin/mailer/reset_password_instructions.text.erb new file mode 100644 index 0000000..7e1f1ad --- /dev/null +++ b/app/views/admin/mailer/reset_password_instructions.text.erb @@ -0,0 +1,12 @@ +<% content_for :message do %> +<%= t("mailer.general.greeting.formal.addressed_html", name: @resource.login) %> + +<%= t(".intro") %> + +<%= edit_admin_password_url(reset_password_token: @token) %> + +<%= t(".expiration", count: ArchiveConfig.DAYS_UNTIL_ADMIN_RESET_PASSWORD_LINK_EXPIRES) %> + +<%= t(".unrequested") %> + +<% end %> diff --git a/app/views/admin/passwords/edit.html.erb b/app/views/admin/passwords/edit.html.erb new file mode 100644 index 0000000..d068b7f --- /dev/null +++ b/app/views/admin/passwords/edit.html.erb @@ -0,0 +1,26 @@ + +

    <%= t(".page_heading") %>

    +<%= error_messages_for resource %> + + + +<%= form_for resource, url: admin_password_reset_path, html: { method: :put, class: "reset password post" } do |f| %> + <%= f.hidden_field :reset_password_token %> +
    +
    <%= label_tag :edit_password, t(".label.password") %>
    +
    + <%= f.password_field(:password, id: :edit_password, + "aria-describedby" => "password-field-description") %> +

    + <%= t(".describedby.password_length", + minimum: ArchiveConfig.ADMIN_PASSWORD_LENGTH_MIN, + maximum: ArchiveConfig.ADMIN_PASSWORD_LENGTH_MAX) %> +

    +
    +
    <%= label_tag :edit_password_confirmation, t(".label.confirmation") %>
    +
    <%= f.password_field :password_confirmation, id: :edit_password_confirmation %>
    +
    <%= t(".landmark.submit") %>
    +
    <%= f.submit t(".submit") %>
    +
    +<% end %> + diff --git a/app/views/admin/passwords/new.html.erb b/app/views/admin/passwords/new.html.erb new file mode 100644 index 0000000..6236923 --- /dev/null +++ b/app/views/admin/passwords/new.html.erb @@ -0,0 +1,15 @@ + +

    <%= t(".page_heading") %>

    +

    <%= t(".instructions") %>

    + + + +<%= form_for resource, url: admin_password_path, html: { method: :post, class: "reset password simple post" } do |f| %> + <%= error_messages_for resource %> +

    + <%= label_tag :reset_login, t(".reset_login_html") %> + <%= f.text_field :login, id: :reset_login %> + <%= f.submit t(".submit") %> +

    +<% end %> + diff --git a/app/views/admin/sessions/confirm_logout.html.erb b/app/views/admin/sessions/confirm_logout.html.erb new file mode 100644 index 0000000..04875c7 --- /dev/null +++ b/app/views/admin/sessions/confirm_logout.html.erb @@ -0,0 +1,8 @@ +

    <%= ts("Log Out as Admin") %>

    + +<%= form_tag destroy_admin_session_path, method: :delete, class: "simple destroy" do %> +

    <%= ts("Are you sure you want to log out?") %>

    +

    + <%= submit_tag ts("Yes, Log Out") %> +

    +<% end %> diff --git a/app/views/admin/sessions/new.html.erb b/app/views/admin/sessions/new.html.erb new file mode 100644 index 0000000..2b07ee5 --- /dev/null +++ b/app/views/admin/sessions/new.html.erb @@ -0,0 +1,17 @@ + +

    <%= t(".page_heading") %>

    + + + +<%= form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: "login post" } ) do |form| %> +
    +
    <%= form.label :login, t(".label.login") %>
    +
    <%= form.text_field :login %>
    +
    <%= form.label :password, t(".label.password") %>
    +
    <%= form.password_field :password %>
    +
    <%= t(".landmark.reset") %> +

    <%= link_to t(".reset_link"), new_admin_password_path %>

    +
    +

    <%= form.submit t(".submit") %>

    +<% end %> + diff --git a/app/views/admin/settings/index.html.erb b/app/views/admin/settings/index.html.erb new file mode 100644 index 0000000..c48dd3c --- /dev/null +++ b/app/views/admin/settings/index.html.erb @@ -0,0 +1,107 @@ + +

    <%= t(".heading") %>

    + + + +<%= form_for @admin_setting, html: { class: "verbose manage" } do |f| %> + <%= error_messages_for @admin_setting %> +
    + <%= t(".legend.account_and_invitations") %> +
    +
    <%= admin_setting_checkbox(f, :account_creation_enabled) %>
    +
    <%= f.label :account_creation_enabled, t(".fields.account_creation_enabled") %>
    + +
    <%= admin_setting_checkbox(f, :creation_requires_invite) %>
    +
    <%= f.label :creation_requires_invite, t(".fields.creation_requires_invite") %>
    + +
    <%= admin_setting_checkbox(f, :request_invite_enabled) %>
    +
    <%= f.label :request_invite_enabled, t(".fields.request_invite_enabled") %>
    + +
    <%= admin_setting_checkbox(f, :invite_from_queue_enabled) %>
    +
    <%= f.label :invite_from_queue_enabled, t(".fields.invite_from_queue_enabled") %>
    + +
    <%= f.label :invite_from_queue_number, t(".fields.invite_from_queue_number") %>
    +
    <%= admin_setting_text_field(f, :invite_from_queue_number, size: "3") %>
    + +
    <%= f.label :invite_from_queue_frequency, t(".fields.invite_from_queue_frequency") %>
    +
    <%= admin_setting_text_field(f, :invite_from_queue_frequency, size: "3") %>
    + +
    <%= f.label :days_to_purge_unactivated, t(".fields.days_to_purge_unactivated") %>
    +
    <%= admin_setting_text_field(f, :days_to_purge_unactivated, size: "3") %>
    +
    +
    + +
    + <%= t(".legend.disable_support_form") %> +
    +
    <%= admin_setting_checkbox(f, :disable_support_form) %>
    +
    + <%= f.label :disable_support_form, t(".fields.disable_support_form") %> +
    + <%= f.label :disabled_support_form_text, t(".fields.disabled_support_form_text") %> +

    <%= allowed_html_instructions %>

    + <%= f.text_area :disabled_support_form_text, + disabled: admin_setting_disabled?(:disabled_support_form_text) %> +
    +
    +
    +
    + +
    + <%= t(".legend.performance_and_misc") %> +
    +
    <%= admin_setting_checkbox(f, :suspend_filter_counts) %>
    +
    <%= f.label :suspend_filter_counts, t(".fields.suspend_filter_counts") %>
    + +
    <%= admin_setting_checkbox(f, :tag_wrangling_off) %>
    +
    <%= f.label :tag_wrangling_off, t(".fields.tag_wrangling_off") %>
    + +
    <%= admin_setting_checkbox(f, :downloads_enabled) %>
    +
    <%= f.label :downloads_enabled, t(".fields.downloads_enabled") %>
    + +
    <%= admin_setting_checkbox(f, :enable_test_caching) %>
    +
    <%= f.label :enable_test_caching, t(".fields.enable_test_caching") %>
    + +
    <%= f.label :cache_expiration, t(".fields.cache_expiration") %>
    +
    <%= admin_setting_text_field(f, :cache_expiration, size: "3") %>
    +
    +
    + +
    + <%= t(".legend.content") %> +
    +
    <%= admin_setting_checkbox(f, :hide_spam) %>
    +
    <%= f.label :hide_spam, t(".fields.hide_spam") %>
    + +
    <%= admin_setting_checkbox(f, :guest_comments_off) %>
    +
    <%= f.label :guest_comments_off, t(".fields.guest_comments_off") %>
    + +
    <%= f.label :account_age_threshold_for_comment_spam_check, t(".fields.account_age_threshold_for_comment_spam_check") %>
    +
    + <%= admin_setting_text_field(f, :account_age_threshold_for_comment_spam_check, size: "3", "aria-describedby": "account-age-threshold-for-comment-spam-check-note") %> +

    <%= t(".fields.account_age_threshold_for_comment_spam_check_note") %>

    +
    +
    +
    + +
    + <%= t(".legend.actions") %> +

    <%= f.submit t(".update") %>

    +
    +<% end %> + +<% if @admin_setting.invite_from_queue_enabled? %> +

    + <%= t(".queue_status", + count: @admin_setting.invite_from_queue_number, + time: l(@admin_setting.invite_from_queue_at, format: :long)) %> + <%= t(".queue_status_help") %> +

    +<% end %> + +

    + <%= t(".last_updated_by_admin", + updated_at: @admin_setting.updated_at, + admin_name: @admin_setting.last_updated ? @admin_setting.last_updated.login : "---") %> +

    + diff --git a/app/views/admin/skins/_navigation.html.erb b/app/views/admin/skins/_navigation.html.erb new file mode 100644 index 0000000..0ff09dd --- /dev/null +++ b/app/views/admin/skins/_navigation.html.erb @@ -0,0 +1,6 @@ +

    Navigation

    + \ 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 100644 index 0000000..26abe59 --- /dev/null +++ b/app/views/admin/skins/index.html.erb @@ -0,0 +1,71 @@ +
    + +

    <%= ts('Skin Approval Queue') %>

    + + + +<%= render :partial => 'admin/skins/navigation' %> + + + +

    <%= ts('Manage Skins') %>

    + +<%= form_tag update_admin_skin_path, {:method => :put} do %> + + +
    + <%= ts('Approval Queue') %> +

    <%= ts('Approval Queue') %>

    + +
    + + + + + + + + + + + + + + + + <% @unapproved_skins.each_with_index do |skin, i| %> + <% disabled = !policy(skin).update? %> + + + + + + + + + + + <% end %> + +
    <%= ts('Approval Queue for Skins') %>
    <%= ts('Skin') %><%= ts('Type') %><%= ts('Creator') %><%= ts('Preview') %><%= ts('Description') %><%= ts('Admin Note') %><%= ts('Approve') %><%= ts('Reject') %>
    + <% skin_name = skin.title.downcase.gsub(/ +/, '_') %> + <%= label_tag "make_official_#{skin_name}", (link_to skin.title, skin_path(skin)) %> + <%= skin.type == 'WorkSkin' ? 'Work Skin' : 'Site Skin' %><%= skin_author_link(skin) %><%= skin_preview_display(skin) %> + <%= skin.description.blank? ? ts("(No Description Provided)") : raw(strip_images(sanitize_field(skin, :description))) %> + <%= text_field_tag "skin_admin_note[#{skin.id}]", h(skin.admin_note), disabled: disabled %> + <%= check_box_tag "make_official[]", skin.id, false, id: "make_official_#{skin_name}", disabled: disabled %> + + <%= check_box_tag "make_rejected[]", skin.id, false, id: "make_rejected_#{skin_name}", disabled: disabled %> +
    +
    +
    + +

    <%= submit_tag ts('Update') %>

    + +<% end %> + + + + + +
    diff --git a/app/views/admin/skins/index_approved.html.erb b/app/views/admin/skins/index_approved.html.erb new file mode 100644 index 0000000..84632f9 --- /dev/null +++ b/app/views/admin/skins/index_approved.html.erb @@ -0,0 +1,100 @@ +
    + +

    <%= ts('Manage Approved Skins') %>

    + + + +<%= render :partial => 'admin/skins/navigation' %> + + + +<%= form_tag update_admin_skin_path, {:method => :put} do %> + + +
    + <%= ts('Approved Skins') %> +

    <%= ts('Approved Skins') %>

    + + + + + + + + + + + + + + + <% @approved_skins.each_with_index do |skin, i| %> + <% disabled = !policy(skin).update? %> + + + + + + + + + <% end %> + +
    <%= ts('Approved Skins') %>
    <%= ts('Skin') %><%= ts('Type') %><%= ts('Creator') %><%= ts('Users') %><%= ts('Admin Note') %><%= ts('Actions') %>
    <%= link_to skin.title, skin_path(skin) %><%= skin.type == 'WorkSkin' ? 'Work Skin' : 'Site Skin' %><%= skin_author_link(skin) %><%= skin.preferences.count %><%= skin.admin_note %> + <% skin_name = skin.title.downcase.gsub(/ +/, '_') %> + <%= label_tag "make_unofficial_#{skin_name}", class: ["action", disabled ? "disabled" : ""] do %> + <%= ts("Unapprove") %> + <%= check_box_tag "make_unofficial[]", skin.id, false, id: "make_unofficial_#{skin_name}", disabled: disabled %> + <% end %> + + <% %w(cached featured in_chooser).each do |type| %> + <% if skin.send("#{type}?") %> + <%= label_tag "make_un#{type}_#{skin_name}", class: ["action", disabled ? "disabled" : ""] do %> + <%= + case type + when "cached" + ts("Uncache") + when "featured" + ts("Unfeature") + when "in_chooser" + ts("Not In Chooser") + end + %> + <%= check_box_tag "make_un#{type}[]", skin.id, false, id: "make_un#{type}_#{skin_name}", disabled: disabled %> + <% end %> + <% else %> + <%= label_tag "make_#{type}_#{skin_name}", class: ["action", disabled ? "disabled" : ""] do %> + <%= + case type + when "cached" + ts("Cache") + when "featured" + ts("Feature") + when "in_chooser" + ts("Chooser") + end + %> + <%= check_box_tag "make_#{type}[]", skin.id, false, id: "make_#{type}_#{skin_name}", disabled: disabled %> + <% end %> + <% end %> + <% end %> +
    +
    + + <% if policy(Skin).set_default? %> +
    + <%= ts("Set Default Archive Skin") %> +

    <%= ts("Set Default Archive Skin") %>

    +

    <%= ts("This will change the default skin FOR ALL USERS! Don't use unless you are REALLY SURE.") %>

    +

    + <%= label_tag "set_default", ts("Default Skin Title: ") %> + <%= text_field_tag "set_default", AdminSetting.default_skin.try(:title), autocomplete_options("site_skins", data: { autocomplete_token_limit: 1 }) %> + <%= hidden_field_tag :last_updated_by, current_admin.id %> +

    +
    + <% end %> + +

    <%= submit_tag ts("Update") %>

    + +<% end %> +
    diff --git a/app/views/admin/skins/index_rejected.html.erb b/app/views/admin/skins/index_rejected.html.erb new file mode 100644 index 0000000..0385cee --- /dev/null +++ b/app/views/admin/skins/index_rejected.html.erb @@ -0,0 +1,51 @@ +
    + +

    <%= ts('Manage Rejected Skins') %>

    + + + +<%= render :partial => 'admin/skins/navigation' %> + + + +<%= form_tag update_admin_skin_path, {:method => :put} do %> + +
    + <%= ts('Rejected Skins') %> +

    <%= ts('Rejected Skins') %>

    + + + + + + + + + + + + + <% @rejected_skins.each_with_index do |skin, i| %> + <% disabled = !policy(skin).update? %> + + + + + <% skin_name = skin.title.downcase.gsub(/ +/, '_') %> + + + <% end %> + +
    <%= ts('Rejected Skins') %>
    <%= ts('Skin') %><%= ts('Type') %><%= ts('Creator') %><%= ts('Admin Note') %><%= ts('Unreject') %>
    <%= link_to skin.title, skin_path(skin) %> + <%= skin.type == 'WorkSkin' ? 'Work Skin' : 'Site Skin' %><%= skin_author_link(skin) %><%=h skin.admin_note %><%= check_box_tag "make_unrejected[]", skin.id, false, id: "make_unrejected_#{skin_name}", disabled: disabled %>
    +
    + +

    <%= submit_tag ts('Update') %>

    + +<% end %> + + + + + +
    diff --git a/app/views/admin/spam/index.html.erb b/app/views/admin/spam/index.html.erb new file mode 100644 index 0000000..30e1c1d --- /dev/null +++ b/app/views/admin/spam/index.html.erb @@ -0,0 +1,88 @@ + +

    <%= ts("Manage Potential Spam") %>

    + + + + + + + +<%= form_tag "/admin/spam/bulk_update", method: :post do %> + "> + + + + + + + + + + + + <% @works.each do |work| %> + + + + + + + + <% end %> + +
    <%= ts("Works Marked as Spam") %>
    <%= ts("Title") %><%= ts("Creator") %><%= ts("Revised At") %> + <%= ts("Confirm As Spam") %> +
      +
    • + +
    • +
    • + +
    • +
    +
    + <%= ts("Mark As Not Spam") %> +
      +
    • + +
    • +
    • + +
    • +
    +
    + <%= link_to work.title, work_path(id: work.work_id) %> + <%= work.admin_user_links %><%= work.revised_at %> + <%= check_box_tag 'spam[]', work.id, nil, id: "spam_#{work.id}" %> + <%= label_tag "spam_#{work.id}", "Spam" %> + + <%= check_box_tag 'ham[]', work.id, nil, id: "ham_#{work.id}" %> + <%= label_tag "ham_#{work.id}", "Not Spam" %> +
    + <%= submit_button nil, ts("Update Works") %> +<% end %> + +<%= will_paginate @works %> + + +<% content_for :footer_js do %> + <%= javascript_include_tag "select_all", skip_pipeline: true %> +<% end %> diff --git a/app/views/admin/user_creations/confirm_remove_pseud.html.erb b/app/views/admin/user_creations/confirm_remove_pseud.html.erb new file mode 100644 index 0000000..b2fcf6e --- /dev/null +++ b/app/views/admin/user_creations/confirm_remove_pseud.html.erb @@ -0,0 +1,30 @@ + +

    <%= t(".page_heading") %>

    + + +<%= form_with url: remove_pseud_admin_user_creation_path(@work), method: :put, class: "simple destroy" do |f| %> + <% if @orphan_pseuds.length > 1 %> +

    + <%= t(".choose") %> +

    +
      + <%= f.collection_check_boxes :pseuds, @orphan_pseuds, :id, :byline, { include_hidden: false } do |builder| %> +
    • + <%= builder.check_box %> + <%= builder.label %> +
    • + <% end %> +
    +

    + <%= f.submit t(".submit_multiple") %> +

    + <% else %> +

    + <%= t(".caution") %> +

    +

    + <%= f.submit t(".submit_one") %> +

    + <% 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 100644 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 100644 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 100644 index 0000000..3c630a4 --- /dev/null +++ b/app/views/admin_mailer/set_password_notification.html.erb @@ -0,0 +1,8 @@ +<% content_for :message do %> +

    <%= t("mailer.general.greeting.informal.addressed_html", name: style_bold(@admin.login)) %>

    +

    <%= t(".created") %>

    +

    <%= style_metadata_label(t(".username")) %><%= @admin.login %>

    +

    <%= style_metadata_label(t(".url")) %><%= style_link(new_admin_session_url, new_admin_session_url) %>

    +

    <%= t(".finish_html", set_password_link: style_link(t(".set_password"), edit_admin_password_url(reset_password_token: @token))) %>

    +

    <%= t(".expiration_html", count: ArchiveConfig.DAYS_UNTIL_ADMIN_RESET_PASSWORD_LINK_EXPIRES, request_reset_link: style_link(t(".request_reset"), new_admin_password_url)) %>

    +<% end %> diff --git a/app/views/admin_mailer/set_password_notification.text.erb b/app/views/admin_mailer/set_password_notification.text.erb new file mode 100644 index 0000000..674fc3b --- /dev/null +++ b/app/views/admin_mailer/set_password_notification.text.erb @@ -0,0 +1,12 @@ +<% content_for :message do %> +<%= t("mailer.general.greeting.informal.addressed_html", name: @admin.login) %> + +<%= t(".created") %> + +<%= metadata_label(t(".username")) %><%= @admin.login %> +<%= metadata_label(t(".url")) %><%= new_admin_session_url %> + +<%= t(".finish", set_password_url: edit_admin_password_url(reset_password_token: @token)) %> + +<%= t(".expiration", count: ArchiveConfig.DAYS_UNTIL_ADMIN_RESET_PASSWORD_LINK_EXPIRES, request_reset_url: new_admin_password_url) %> +<% end %> diff --git a/app/views/admin_posts/_admin_index.html.erb b/app/views/admin_posts/_admin_index.html.erb new file mode 100644 index 0000000..8a386a1 --- /dev/null +++ b/app/views/admin_posts/_admin_index.html.erb @@ -0,0 +1,27 @@ + +

    <%= ts("AO3 News (includes Release Notes)") %>

    + + +<%= render 'filters' %> +<%= render :partial => 'admin/admin_nav' %> + + + +

    <%= ts("Manage News Postings") %>

    +
    + <% @admin_posts.each do |admin_post| %> +
    <%= link_to admin_post.title.html_safe, admin_post %>
    +
    +

    <%= ts("Created at %{created_date} and updated at %{updated_date}", :created_date => admin_post.created_at, :updated_date => admin_post.updated_at) %>

    +
      +
    • <%= link_to ts("Show"), admin_post %>
    • +
    • <%= link_to ts("Edit"), edit_admin_post_path(admin_post) %>
    • +
    • <%= link_to ts("Delete"), admin_post, data: {confirm: 'Are you sure?'}, :method => :delete %>
    • +
    +
    + <% end %> +
    + + + <%= will_paginate @admin_posts %> + diff --git a/app/views/admin_posts/_admin_post.html.erb b/app/views/admin_posts/_admin_post.html.erb new file mode 100644 index 0000000..dc4d6cc --- /dev/null +++ b/app/views/admin_posts/_admin_post.html.erb @@ -0,0 +1,39 @@ +<% # expects "admin_post" %> +
    dir="rtl"<% end %> class="header"> +

    + <%= link_to admin_post.title.html_safe, admin_post %> +

    +
    +

    <%= ts("Post Header") %>

    +
    +
    +
    <%= ts("Published:") %>
    +
    <%= admin_post.created_at %>
    + <% if admin_post.translated_post %> +
    <%= ts("Original:") %>
    +
    <%= link_to admin_post.translated_post.title, admin_post.translated_post %>
    + <% elsif !admin_post.translations.empty? %> +
    <%= ts("Translations:") %>
    +
    +
      + <% for translation in sorted_translations(admin_post) %> +
    • <%= link_to translation.language.name, translation, lang: translation.language.short %>
    • + <% end %> +
    +
    + <% end %> + <% if admin_post.tags.length > 0 %> +
    <%= ts("Tags:") %>
    +
    +
      + <% for tag in admin_post.tags %> +
    • <%= link_to tag.name, admin_posts_path(tag: tag.id), class: "tag" %>
    • + <% end %> +
    +
    + <% end %> +
    +
    +
    dir="rtl"<% end %> class="userstuff"> + <%= raw sanitize_field(admin_post, :content) %> +
    diff --git a/app/views/admin_posts/_admin_post_form.html.erb b/app/views/admin_posts/_admin_post_form.html.erb new file mode 100644 index 0000000..eac6aca --- /dev/null +++ b/app/views/admin_posts/_admin_post_form.html.erb @@ -0,0 +1,105 @@ +
    + <%= form_for(@admin_post) do |f| %> +

    <%= ts("All news posts need a title and some content.") %>

    + <%= error_messages_for @admin_post %> +
    + <%= ts("Post News") %> +

    + <%= f.text_field :title, :title =>"title" %>

    + + +

    + <%= allowed_html_instructions %> + +

    + <% use_tinymce %> +
    +

    <%= f.text_area :content, :class => "mce-editor observe_textlength", :id => "content", :title =>"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) %> +
    +
    +
    + <%= ts("Set Preferences") %> +

    <%= ts("Set Preferences") %>

    +
    +
    <%= f.label :tag_list, t(".tags.label") %>
    +
    + <% if @admin_post.translated_post %> +
      + <% for tag in @admin_post.tags %> +
    • <%= link_to tag.name, admin_posts_path(tag: tag.id), class: "tag" %>
    • + <% end %> +
    + <% else %> + <%= f.text_field :tag_list, autocomplete_options("admin_post_tags") %> +

    + <%= ts("Comma separated, %{max} characters per tag", max: ArchiveConfig.TAG_MAX) %> +

    + <% end %> +
    +
    + <%= f.label :language_id, t(".language.label") %> +
    +
    + +
    +
    + <%= f.label :translated_post_id, t(".translated_post.label") %> +
    +
    + <%= f.text_field :translated_post_id, + autocomplete_options('admin_posts', + title: ts('translation of'), + data: { autocomplete_token_limit: 1 }) %> + <% unless @admin_post.translated_post %> +

    <%= t(".translated_post.footnote_comment_permissions") %>

    +

    <%= t(".translated_post.footnote_tags") %>

    + <% end %> +
    +
    + <%= f.check_box :moderated_commenting_enabled %> +
    +
    + <%= f.label :moderated_commenting_enabled, t("comments.commentable.permissions.moderated_commenting.enable") %> +
    +
    + <%= t(".comment_permissions.label") %> +
    +
    + <% if @admin_post.translated_post %> +

    <%= t("comments.commentable.permissions.options.#{@admin_post.comment_permissions}") %>

    + <% else %> +
    + <%= + radio_button_list(f, :comment_permissions, [ + [:enable_all, t("comments.commentable.permissions.options.enable_all")], + [:disable_anon, t("comments.commentable.permissions.options.disable_anon")], + [:disable_all, t("comments.commentable.permissions.options.disable_all")] + ]) + %> +
    + <% end %> +
    +
    +
    + +
    + <%= ts('Post') %> +

    <%= ts("Post") %>

    +

    + <%= submit_tag ts('Post'), :name => 'post_button' %> +

    +
    + <% end %> +
    diff --git a/app/views/admin_posts/_filters.html.erb b/app/views/admin_posts/_filters.html.erb new file mode 100755 index 0000000..f5c161a --- /dev/null +++ b/app/views/admin_posts/_filters.html.erb @@ -0,0 +1,8 @@ +

    <%= ts("Filters") %>

    +<%= form_tag admin_posts_path, :method => :get do %> +

    <%= label_tag :tag, ts("Tag:") %> + <%= select_tag :tag, ('' + options_from_collection_for_select(@tags, :id, :name, params[:tag])).html_safe %> + <%= label_tag :language_id, ts("Language:") %> + <%= select_tag :language_id, options_for_select(language_options_for_select(@news_languages, "short"), params[:language_id] || Language.default.short) %> + <%= submit_tag ts('Go') %>

    +<% end %> diff --git a/app/views/admin_posts/_sidebar.html.erb b/app/views/admin_posts/_sidebar.html.erb new file mode 100644 index 0000000..0ff7f2b --- /dev/null +++ b/app/views/admin_posts/_sidebar.html.erb @@ -0,0 +1,8 @@ + diff --git a/app/views/admin_posts/edit.html.erb b/app/views/admin_posts/edit.html.erb new file mode 100644 index 0000000..2fa716f --- /dev/null +++ b/app/views/admin_posts/edit.html.erb @@ -0,0 +1,11 @@ + +

    <%=h 'Edit AO3 News Post' %>

    + + + +<%= render :partial => 'admin/admin_nav' %> + + + +<%= render :partial => 'admin_post_form' %> + diff --git a/app/views/admin_posts/index.html.erb b/app/views/admin_posts/index.html.erb new file mode 100755 index 0000000..c5a5448 --- /dev/null +++ b/app/views/admin_posts/index.html.erb @@ -0,0 +1,43 @@ +
    + <% if policy(@admin_posts).can_post? %> + <%= render "admin_index" %> + <% else %> +
    + +

    <%= link_to ts("AO3 News"), admin_posts_path %>

    +
    + + + + + +
    + + <% @admin_posts.each do |admin_post| %> + +
    + <%= render "admin_post", admin_post: admin_post %> + +

    <%= ts("Comment") %>

    + + +
    + <% end %> + +
    + + + <%= will_paginate @admin_posts %> + + <% end %> +
    diff --git a/app/views/admin_posts/index.rss.builder b/app/views/admin_posts/index.rss.builder new file mode 100644 index 0000000..d68796d --- /dev/null +++ b/app/views/admin_posts/index.rss.builder @@ -0,0 +1,19 @@ +xml.instruct! :xml, :version => "1.0" +xml.rss :version => "2.0" do + xml.channel do + xml.title "AO3 News" + xml.description "Latest updates from archiveofourown.org" + xml.link admin_posts_url + + @admin_posts.each do |post| + xml.item do + xml.title post.title + xml.description post.content + xml.pubDate post.created_at.to_fs(:rfc822) + xml.link admin_post_url(post) + xml.guid admin_post_url(post) + end + end + end +end + diff --git a/app/views/admin_posts/new.html.erb b/app/views/admin_posts/new.html.erb new file mode 100644 index 0000000..968622e --- /dev/null +++ b/app/views/admin_posts/new.html.erb @@ -0,0 +1,11 @@ + +

    <%= ts("New AO3 News Post") %>

    + + + +<%= render :partial => 'admin/admin_nav' %> + + + +<%= render :partial => 'admin_post_form' %> + \ No newline at end of file diff --git a/app/views/admin_posts/show.html.erb b/app/views/admin_posts/show.html.erb new file mode 100755 index 0000000..3ee21f0 --- /dev/null +++ b/app/views/admin_posts/show.html.erb @@ -0,0 +1,55 @@ +
    +
    + +

    <%= link_to t(".page_heading"), admin_posts_path %>

    +
    + + + + +
    + +
    + <%= render "admin_post", admin_post: @admin_post %> +
    + + + + <%= render "comments/commentable", commentable: @admin_post %> + +
    diff --git a/app/views/admins/index.html.erb b/app/views/admins/index.html.erb new file mode 100644 index 0000000..ea9700b --- /dev/null +++ b/app/views/admins/index.html.erb @@ -0,0 +1,29 @@ + +

    <%= t(".page_title", login: current_admin.login) %>

    + + + + + + +
    +

    <%= t(".confidentiality_reminder") %>

    + +

    <%= t(".responsibility") %>

    + +

    <%= t(".roles.heading") %>

    + <% if current_admin.roles.any? %> +
      + <% current_admin.roles.each do |role| %> +
    • <%= t("activerecord.attributes.admin/role.#{role}") %>
    • + <% end %> +
    + <% else %> +

    <%= t(".roles.none") %>

    + <% end %> + +
    + +

    <%= t(".log_out_reminder") %>

    +
    + diff --git a/app/views/archive_faqs/_admin_index.html.erb b/app/views/archive_faqs/_admin_index.html.erb new file mode 100644 index 0000000..4690932 --- /dev/null +++ b/app/views/archive_faqs/_admin_index.html.erb @@ -0,0 +1,37 @@ +
    + +

    <%= t(".page_heading") %>

    + + + +<%= render "archive_faqs/filters" %> +<%= render "admin/admin_nav" %> + + + + +

    <%= t(".manage_faqs") %>

    +
    + <% @archive_faqs.each do |archive_faq| %> +
    <%= link_to archive_faq.title, archive_faq %>
    +
    +

    <%= t(".created_date", date_created: l(archive_faq.created_at)) %>

    + +
    + <% end %> +
    + +
    diff --git a/app/views/archive_faqs/_archive_faq_form.html.erb b/app/views/archive_faqs/_archive_faq_form.html.erb new file mode 100644 index 0000000..136581f --- /dev/null +++ b/app/views/archive_faqs/_archive_faq_form.html.erb @@ -0,0 +1,61 @@ + + +<%= form_for(@archive_faq, html: { class: "create faq" }) do |form| %> + <%= error_messages_for @archive_faq %> +

    <%= ts("* Required information") %>

    +
    + <%= ts("Set Preferences") %> +

    <%= ts("Set Preferences") %>

    +
    +
    + <%= form.label :title, ts("Category name") + "*" %> +
    +
    + <%= 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") %> +

    <%= 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" %> +

    + <%= link_to_add_section("Add Question", form, :questions, "question_answer_fields") %> +

    + <% end %> +
    + +
    + <%= ts("Submit") %> +

    <%= ts("Submit") %>

    +

    + <%= submit_tag ts("Post"), name: "post_button" %> +

    +
    +<% end %> diff --git a/app/views/archive_faqs/_archive_faq_order.html.erb b/app/views/archive_faqs/_archive_faq_order.html.erb new file mode 100644 index 0000000..71912d4 --- /dev/null +++ b/app/views/archive_faqs/_archive_faq_order.html.erb @@ -0,0 +1,33 @@ +
    + <%= form_tag url_for(:action => 'update_positions') do %> +
      + <% for archive_faq in @archive_faqs %> +
    • + <%= text_field_tag "archive_faqs[]", nil, :size => 3, :maxlength => 3, :id => "archive_faqs_#{archive_faq.id}", :class => "number" %> + <%= archive_faq.position %>. +

      <%=h archive_faq.title %>

      +
    • + <% end %> +
    +

    <%= submit_tag "Update Positions" %>

    + <% end %> +
    + +<%= content_for :footer_js do %> + <%= javascript_tag do %> + $j("#sortable_archive_faq_list").sortable({ + delay: 300, + update: function(event, ui) { + $j(".archive_faq-position-list").each(function(index, li){ + var faqId = $j(li).attr("id").replace("archive_faq_",""); + $j("#position-for-"+faqId).html(index+1); + }); + $j.ajax({ + type: 'post', + data: $j("#sortable_archive_faq_list").sortable("serialize"), + dataType: 'script', + url: "<%= url_for(:action => :update_positions) %>"}) + } + }) + <% end %> +<% end %> diff --git a/app/views/archive_faqs/_archive_faq_questions_order.html.erb b/app/views/archive_faqs/_archive_faq_questions_order.html.erb new file mode 100644 index 0000000..424c80e --- /dev/null +++ b/app/views/archive_faqs/_archive_faq_questions_order.html.erb @@ -0,0 +1,33 @@ +
    + <%= form_tag url_for(:action => 'update_positions') do %> +
      + <% for archive_faq in @archive_faqs %> +
    • + <%= text_field_tag "archive_faqs[]", nil, :size => 3, :maxlength => 3, :id => "archive_faqs_#{archive_faq.id}", :class => "number" %> + <%= archive_faq.position %>. +

      <%=h archive_faq.title %>

      +
    • + <% end %> +
    +

    <%= submit_tag "Update Positions" %>

    + <% end %> +
    + +<%= content_for :footer_js do %> + <%= javascript_tag do %> + $j("#sortable_archive_faq_list").sortable({ + delay: 300, + update: function(event, ui) { + $j(".archive_faq-position-list").each(function(index, li){ + var faqId = $j(li).attr("id").replace("archive_faq_",""); + $j("#position-for-"+faqId).html(index+1); + }); + $j.ajax({ + type: 'post', + data: $j("#sortable_archive_faq_list").sortable("serialize"), + dataType: 'script', + url: "<%= url_for(:action => :update_positions) %>"}) + } + }) + <% end %> +<% end %> diff --git a/app/views/archive_faqs/_faq_index.html.erb b/app/views/archive_faqs/_faq_index.html.erb new file mode 100644 index 0000000..faa4675 --- /dev/null +++ b/app/views/archive_faqs/_faq_index.html.erb @@ -0,0 +1,86 @@ + +

    <%= ts("Archive FAQ") %>

    + + + + + + + +

    + <%= 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") %> <%= link_to ts("TOS FAQ"), tos_faq_path %>. + <%= 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.") %>

    +<% else %> + <% if rtl? %> +
    + <% else %> +
    + <% end %> +

    <%= ts("Available Categories") %> + +

    + +
    + +
    + +
    + + <%= content_for :footer_js do %> + <%= javascript_tag do %> + $j(document).ready(function() { + $j(".category").children("a").click(function(e) { + $j(this).next().toggle(); + e.preventDefault(); + }).next().hide(); + + $j("#expand-categories").find("a").click(function(e) { + $j(".category").children("ul").show(); + e.preventDefault(); + }); + + $j("#collapse-categories").find("a").click(function(e) { + $j(".category").children("ul").hide(); + e.preventDefault(); + }); + }); + <% end %> + <% end %> +<% end %> diff --git a/app/views/archive_faqs/_filters.html.erb b/app/views/archive_faqs/_filters.html.erb new file mode 100644 index 0000000..6767c09 --- /dev/null +++ b/app/views/archive_faqs/_filters.html.erb @@ -0,0 +1,10 @@ +

    <%= ts("Filters") %>

    +<%= form_tag archive_faqs_path, :method => :get do %> +

    <%= label_tag :language_id, ts("Language:") %> + <% if User.current_user.is_a?(Admin) %> + <%= select_tag :language_id, ("" + options_for_select(locale_options_for_select(Locale.default_order, "iso"), params[:language_id])).html_safe %> + <% else %> + <%= select_tag :language_id, ("" + options_for_select(locale_options_for_select(available_faq_locales, "iso"), params[:language_id])).html_safe %> + <% end %> + <%= submit_tag ts('Go') %>

    +<% end %> diff --git a/app/views/archive_faqs/_question_answer_fields.html.erb b/app/views/archive_faqs/_question_answer_fields.html.erb new file mode 100644 index 0000000..c701599 --- /dev/null +++ b/app/views/archive_faqs/_question_answer_fields.html.erb @@ -0,0 +1,67 @@ +
    + <% index ||= 0 %> +
    + <% if index.is_a? String %> + <% question_label = ts("Question #{index}") %> + <% else %> + <% question_label = ts("Question #{(index + 1)}") %> + <% end %> + <%= question_label %> +

    <%= question_label %>

    + +
    +
    + <%= form.label :question, ts("Question") + "*" %> +
    +
    + <%= form.text_field :question %> + <%= live_validation_for_field(field_id(form, "question").to_sym, failureMessage: ts("Please enter a question.")) %> +
    + +
    + <%= form.label :anchor, ts("Anchor name") + "*" %> +
    +
    + <%= form.text_field :anchor, disabled: (I18n.locale != I18n.default_locale) %> + <%= live_validation_for_field(field_id(form, "anchor").to_sym, failureMessage: ts("Please enter an anchor name.")) %> +
    + +
    + <%= form.label :content, ts("Answer") + "*" %> +
    +
    + <%= 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)) %> +
    + +
    + <%= form.label :screencast, ts("Screencast URL") %> +
    +
    + <%= form.text_field :screencast %> +
    + <% if Globalize.locale.to_s != "en" %> +
    <%= form.check_box :is_translated %>
    +
    <%= form.label :is_translated, ts("Question translated") %>
    + <% end %> +
    + <% if Globalize.locale.to_s == "en" %> + <% # TODO: DELETE A QUESTION %> +

    + <% removetext = ts("Remove Question") %> + <%= link_to_remove_section(removetext, form) %> + <% # TODO: DELETE QUESTION WITHOUT JAVASCRIPT %> + +

    + <% end %> + +
    +
    + diff --git a/app/views/archive_faqs/confirm_delete.html.erb b/app/views/archive_faqs/confirm_delete.html.erb new file mode 100644 index 0000000..4350fd2 --- /dev/null +++ b/app/views/archive_faqs/confirm_delete.html.erb @@ -0,0 +1,11 @@ + +

    <%= ts("Delete FAQ Category") %>

    + + +<%= form_for(@archive_faq, :html => {:method => :delete, :class => "simple destroy"}) do |f| %> +

    <%= ts("Are you sure you want to delete the FAQ Category \"%{category_name}\"?", :category_name => @archive_faq.title).html_safe %>

    +

    + <%= f.submit ts("Yes, Delete FAQ Category") %> +

    +<% 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 100644 index 0000000..fa065f3 --- /dev/null +++ b/app/views/archive_faqs/edit.html.erb @@ -0,0 +1,14 @@ +
    + +

    <%= ts("Edit Archive FAQ Category") %>

    + + + + <%= render :partial => 'filters' %> + <%= render :partial => 'admin/admin_nav' %> + + + + <%= render :partial => 'archive_faq_form' %> + +
    diff --git a/app/views/archive_faqs/index.html.erb b/app/views/archive_faqs/index.html.erb new file mode 100644 index 0000000..d59fc41 --- /dev/null +++ b/app/views/archive_faqs/index.html.erb @@ -0,0 +1,5 @@ +<% if policy(ArchiveFaq).translation_access? %> + <%= render "admin_index" %> +<% else %> + <%= render "faq_index" %> +<% end %> \ No newline at end of file diff --git a/app/views/archive_faqs/manage.html.erb b/app/views/archive_faqs/manage.html.erb new file mode 100644 index 0000000..3e774cd --- /dev/null +++ b/app/views/archive_faqs/manage.html.erb @@ -0,0 +1,11 @@ + +

    <%=h 'Manage Archive FAQs' %>

    + + + +<%= render :partial => 'admin/admin_nav' %> + + + +<%= render :partial => 'archive_faq_order' %> + \ No newline at end of file diff --git a/app/views/archive_faqs/new.html.erb b/app/views/archive_faqs/new.html.erb new file mode 100644 index 0000000..a9ad577 --- /dev/null +++ b/app/views/archive_faqs/new.html.erb @@ -0,0 +1,13 @@ +
    + +

    <%= ts("Create New Archive FAQ Category") %>

    + + + + <%= render :partial => 'admin/admin_nav' %> + + + + <%= render :partial => 'archive_faq_form' %> + +
    diff --git a/app/views/archive_faqs/show.html.erb b/app/views/archive_faqs/show.html.erb new file mode 100644 index 0000000..a72d0b1 --- /dev/null +++ b/app/views/archive_faqs/show.html.erb @@ -0,0 +1,58 @@ + +

    <%= link_to t(".page_heading"), archive_faqs_path %> > <%= @archive_faq.title %>

    + + + + + + +<% if @archive_faq.slug == "search-and-browse" %> +

    + <%= t(".elasticsearch_update_notice_html", elasticsearch_news_link: link_to(t(".elasticsearch_news"), admin_post_path(10_575))) %> +

    +<% end %> + +
    + <% if policy(ArchiveFaq).translation_access? %> +
    +

    + <% if Globalize.locale.to_s != "en" || policy(ArchiveFaq).full_access? -%> + <%= link_to t(".edit"), edit_archive_faq_path(@archive_faq) %> + <% end %> +

    +
    + <% end %> + + <% if @archive_faq.questions.blank? %> +

    <%= t(".no_category_entries") %>

    + <% else %> +
    dir="rtl"<% end %> class="userstuff"> + + +
    + <% for q in @questions %> +

    + <%= q.question %> +

    + <% unless q.screencast.to_s == "" %> +

    + <%= t(".screencast") %> <%= link_to q.question, q.screencast.to_s %> +

    + <% end %> + <%= raw sanitize_field(q, :content) %> + <% end %> +
    +
    + <% end %> +
    + diff --git a/app/views/blocked/users/_blocked_user_blurb.html.erb b/app/views/blocked/users/_blocked_user_blurb.html.erb new file mode 100644 index 0000000..be79109 --- /dev/null +++ b/app/views/blocked/users/_blocked_user_blurb.html.erb @@ -0,0 +1,9 @@ +<% pseud = block.blocked.default_pseud %> +
  • + <%= render "pseuds/pseud_module", pseud: pseud, date: block.created_at %> + +
    User Actions
    + +
  • diff --git a/app/views/blocked/users/confirm_block.html.erb b/app/views/blocked/users/confirm_block.html.erb new file mode 100644 index 0000000..03b6045 --- /dev/null +++ b/app/views/blocked/users/confirm_block.html.erb @@ -0,0 +1,31 @@ +

    <%= t(".title", name: @blocked.login) %>

    + +<%= form_tag user_blocked_users_path(@user, blocked_id: @blocked) do %> +
    +

    + <%= t(".sure_html", block: tag.strong(t(".block")), username: @blocked.login) %> + <%= t(".will.intro") %> +

    + +
      +
    • <%= t(".will.commenting") %>
    • +
    • <%= t(".will.replying") %>
    • +
    • <%= t(".will.gifting") %>
    • +
    + +

    <%= t(".will_not.intro") %>

    + +
      +
    • <%= t(".will_not.hide_works") %>
    • +
    • <%= t(".will_not.comments_on_works") %>
    • +
    • <%= t(".will_not.comments_elsewhere") %>
    • +
    + +

    <%= t(".mute_users_instead_html", muted_users_link: link_to(t(".muted_users_link_text"), user_muted_users_path(@user))) %>

    +
    + +

    + <%= link_to t(".cancel"), user_blocked_users_path(@user) %> + <%= submit_tag t(".button") %> +

    +<% end %> diff --git a/app/views/blocked/users/confirm_unblock.html.erb b/app/views/blocked/users/confirm_unblock.html.erb new file mode 100644 index 0000000..7e8b2fc --- /dev/null +++ b/app/views/blocked/users/confirm_unblock.html.erb @@ -0,0 +1,21 @@ +

    <%= t(".title", name: @block.blocked.login) %>

    + +<%= form_tag user_blocked_user_path(@user, @block), method: :delete do %> +
    +

    + <%= t(".sure_html", unblock: tag.strong(t(".unblock")), username: @block.blocked.login) %> + <%= t(".resume.intro") %> +

    + +
      +
    • <%= t(".resume.commenting") %>
    • +
    • <%= t(".resume.replying") %>
    • +
    • <%= t(".resume.gifting") %>
    • +
    +
    + +

    + <%= link_to t(".cancel"), user_blocked_users_path(@user) %> + <%= submit_tag t(".button") %> +

    +<% end %> diff --git a/app/views/blocked/users/index.html.erb b/app/views/blocked/users/index.html.erb new file mode 100644 index 0000000..55389bc --- /dev/null +++ b/app/views/blocked/users/index.html.erb @@ -0,0 +1,52 @@ +

    <%= t(".title") %>

    + + +

    <%= t("a11y.navigation") %>

    + + +
    +

    <%= t(".will.intro", block_limit: number_with_delimiter(ArchiveConfig.MAX_BLOCKED_USERS), count: ArchiveConfig.MAX_BLOCKED_USERS) %>

    + +
      +
    • <%= t(".will.commenting") %>
    • +
    • <%= t(".will.replying") %>
    • +
    • <%= t(".will.gifting") %>
    • +
    + +

    <%= t(".will_not.intro") %>

    + +
      +
    • <%= t(".will_not.hide_works") %>
    • +
    • <%= t(".will_not.comments_on_works") %>
    • +
    • <%= t(".will_not.comments_elsewhere") %>
    • +
    + +

    <%= t(".mute_users_instead_html", muted_users_link: link_to(t(".muted_users_link_text"), user_muted_users_path(@user))) %>

    +
    + +<% # form for blocking users %> + +<%= form_tag confirm_block_user_blocked_users_path(@user), method: :get, class: "single simple post" do %> +
    + <%= t(".legend") %> +

    + <%= label_tag :blocked_id, t(".label"), class: "landmark" %> + <%= text_field_tag :blocked_id, "", autocomplete_options("pseud", data: { autocomplete_token_limit: 1 }) %> + <%= submit_tag t(".button") %> +

    +
    +<% end %> + +

    <%= t(".heading.landmark.blocked_users") %>

    +<% if @blocks.present? %> +
      + <%= render partial: "blocked_user_blurb", collection: @blocks, as: :block %> +
    +<% else %> +

    <%= t(".none") %>

    +<% end %> + +<%= will_paginate @blocks %> diff --git a/app/views/bookmarks/_bookmark_blurb.html.erb b/app/views/bookmarks/_bookmark_blurb.html.erb new file mode 100644 index 0000000..8a35ee3 --- /dev/null +++ b/app/views/bookmarks/_bookmark_blurb.html.erb @@ -0,0 +1,47 @@ +<% # expects "bookmark" %> +<% bookmarkable = bookmark.bookmarkable %> +<% bookmark_form_id = (bookmarkable.blank? ? "#{bookmark.id}" : "#{bookmarkable.id}") %> +
  • + + <% if bookmarkable.blank? %> +

    <%= ts("This has been deleted, sorry!") %>

    + <% # Bookmarks of deleted items need a div because they can still be edited. %> +
    + <% else %> + + <% bookmark_count = bookmarkable.public_bookmark_count %> +

    + <%= get_symbol_for_bookmark(bookmark) %> + <%= get_count_for_bookmark_blurb(bookmarkable) %> +

    + + + <%= render "bookmarks/bookmark_item_module", bookmarkable: bookmarkable %> + + + <% if (bookmark_count > 1 && params[:tag_id]) || (bookmark_count > 1 && (@owner.blank? && @bookmarkable.blank?)) || (logged_in? && !is_author_of?(bookmark)) %> + + <% end %> + + <% # bookmark form loaded here if requested %> +
    + <% end %> + + <%= render "bookmarks/bookmark_user_module", bookmark: bookmark %> + + <% if logged_in_as_admin? && bookmarkable.class == ExternalWork %> + <%= render "admin/admin_options", item: bookmarkable %> + <% end %> + +
  • diff --git a/app/views/bookmarks/_bookmark_blurb_short.html.erb b/app/views/bookmarks/_bookmark_blurb_short.html.erb new file mode 100755 index 0000000..8a4529c --- /dev/null +++ b/app/views/bookmarks/_bookmark_blurb_short.html.erb @@ -0,0 +1,43 @@ +<% # This partial requires local variable 'bookmark' %> +
  • +
    + +

    <%= set_format_for_date(bookmark.created_at) %>

    +

    + <%= get_symbol_for_bookmark(bookmark) %> +

    +
    + + <% unless bookmark.tag_string.blank? %> +
    <%= ts("Bookmark Tags:") %>
    +
      + <% bookmark.tags.each do |tag| %> +
    • <%= link_to tag.name, tag_bookmarks_path(tag), class: "tag" %>
    • + <% end %> +
    + <% end %> + <% unless bookmark.collections.blank? %> +
    <%= ts("Bookmark Collections:") %>
    +
      + <% bookmark.collections.each do |coll| %> +
    • <%= link_to coll.title, collection_path(coll) %>
    • + <% end %> +
    + <% end %> + + <% if bookmark.bookmarker_notes.present? %> +
    <%= ts("Bookmark Notes:") %>
    +
    + <%= raw sanitize_field(bookmark, :bookmarker_notes, image_safety_mode: true) %> +
    + <% end %> + + <% if is_author_of?(bookmark) %> + <%= render "bookmark_owner_navigation", bookmark: bookmark %> + <% elsif logged_in_as_admin? %> + <%= render "admin/admin_options", item: bookmark %> + <% end %> +
  • diff --git a/app/views/bookmarks/_bookmark_form.html.erb b/app/views/bookmarks/_bookmark_form.html.erb new file mode 100644 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 +%> +
    +

    <%= ts("Bookmark") %>

    + + <% bookmark ||= Bookmark.new %> + <% bookmarkable ||= bookmark.bookmarkable %> + + <% in_page ||= false %> + <% dynamic ||= false %> + + <% bookmark_form_id = (bookmarkable.blank? ? "#{bookmark.id}" : "#{bookmarkable.id}") %> + <% notes_id = "bookmark_notes" + (dynamic ? "_#{bookmark_form_id}" : "") %> + + <% # Note telling users about our bookmarklet for external links %> + <% if !dynamic && bookmarkable.class == ExternalWork %><%= render "bookmarks/bookmarklet" %><% end %> + + <%= form_for(:bookmark, :url => bookmark_form_path(bookmark, bookmarkable), :html => {:method => bookmark.new_record? ? :post : :put}) do |f| %> + <% if bookmarkable.class == ExternalWork && bookmarkable.new_record? %> +

    * <%= ts('Required information') %>

    + <% end %> +
    + <%= ts("Bookmark") %> + <% if in_page %> +

    + <% if dynamic %> + × + <% else %> + × + <% end %> +

    + <% end %> + +

    + <% if current_user.pseuds.count > 1 %> + <%= select_tag "bookmark[pseud_id]", + options_for_select(current_user.pseuds.map{|pseud| [pseud.name, pseud.id]}, + bookmark.pseud ? bookmark.pseud.id : current_user.default_pseud.id), title: ts("choose pseud") %> + <% else %> + + <% end %> + <%= ts(" save a bookmark!") %> +

    + + <% # What we're bookmarking %> + <% if bookmarkable.class == ExternalWork && bookmarkable.new_record? %> + <%= fields_for :external_work, bookmarkable do |ew_form| %> + <%= render "bookmarks/external_work_fields", ew: ew_form %> + <% end %> + <% end %> + +
    + <%= ts("Write Comments") %> +
    +
    <%= f.label :bookmarker_notes, ts("Notes"), for: notes_id %>
    +
    +

    + <% if bookmarkable.class != ExternalWork %> + <%= ts("The creator's summary is added automatically.") %> + <% end %> + <%= allowed_html_instructions %> +

    + <%= f.text_area :bookmarker_notes, rows: 4, id: notes_id, class: "observe_textlength", + "aria-describedby" => "notes-field-description" %> + <%= generate_countdown_html(notes_id, ArchiveConfig.NOTES_MAX) %> +
    + +
    <%= f.label :tag_string, ts("Your tags") %>
    +
    + <% if bookmarkable.class != ExternalWork %> +

    + <%= ts("The creator's tags are added automatically.") %> +

    + <% end %> + <%= f.text_field :tag_string, autocomplete_options('tag?type=all', size: (in_page ? 60 : 80), "aria-describedby" => "tag-string-description") %> +

    + <%= ts("Comma separated, %{max} characters per tag", max: ArchiveConfig.TAG_MAX) %> +

    +
    + +
    <%= f.label :collection_names, ts("Add to collections") %>
    +
    + <%= f.text_field :collection_names, autocomplete_options('open_collection_names', size: (in_page ? 60 : 80)) %> +
    +
    +
    + +
    + <%= ts("Choose Type and Post") %> +

    + <%= f.check_box :private %> <%= f.label :private, ts("Private bookmark") %> + <%= f.check_box :rec %> <%= f.label :rec, ts("Rec") %> +

    +

    + <%= f.submit(button_name) %> + <% unless in_page %> + <%= link_to ts("My Bookmarks"), user_bookmarks_path(current_user) %> + <% end %> +

    +
    +
    + <% end %> +
    diff --git a/app/views/bookmarks/_bookmark_item_module.html.erb b/app/views/bookmarks/_bookmark_item_module.html.erb new file mode 100644 index 0000000..889692f --- /dev/null +++ b/app/views/bookmarks/_bookmark_item_module.html.erb @@ -0,0 +1,8 @@ +<% # expects "bookmarkable" %> +<% if bookmarkable.is_a?(ExternalWork) %> + <%= render 'external_works/work_module', external_work: bookmarkable, bookmarkable: bookmarkable %> +<% elsif bookmarkable.is_a?(Series) %> + <%= render 'series/series_module', series: bookmarkable %> +<% else %> + <%= render 'works/work_module', work: bookmarkable %> +<% end %> \ No newline at end of file diff --git a/app/views/bookmarks/_bookmark_owner_navigation.html.erb b/app/views/bookmarks/_bookmark_owner_navigation.html.erb new file mode 100644 index 0000000..b3bf21e --- /dev/null +++ b/app/views/bookmarks/_bookmark_owner_navigation.html.erb @@ -0,0 +1,12 @@ +<% # expects "bookmark" %> +<% bookmark_form_id = (bookmark.bookmarkable.blank? ? "#{bookmark.id}" : "#{bookmark.bookmarkable.id}") %> + diff --git a/app/views/bookmarks/_bookmark_user_module.html.erb b/app/views/bookmarks/_bookmark_user_module.html.erb new file mode 100644 index 0000000..bda5ac2 --- /dev/null +++ b/app/views/bookmarks/_bookmark_user_module.html.erb @@ -0,0 +1,60 @@ +<%# expects "bookmark" %> +
    + + <%# If you update the cache key, update flush_bookmark_cache in tag.rb %> + <% blurb_cache_key = (is_author_of?(bookmark) ? "bookmark-owner-blurb-#{bookmark.cache_key}-v3" : "bookmark-blurb-#{bookmark.cache_key}-v3") %> + <% cache(blurb_cache_key, skip_digest: true) do %> +

    <%= set_format_for_date(bookmark.created_at) %>

    + + <%# information added by the bookmark owner %> + <% unless bookmark.tag_string.blank? %> +
    <%= ts('Bookmarker\'s Tags:') %>
    +
      + <% bookmark.tags.each do |tag| %> +
    • <%= link_to(tag.name, tag_bookmarks_path(tag), class: "tag") %>
    • + <% end %> +
    + <% 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) %> +
    <%= ts('Bookmarker\'s Collections:') %>
    + <% end %> + <% unless bookmark.approved_collections.empty? && !is_author_of?(bookmark) %> +
      + <% if is_author_of?(bookmark) || logged_in_as_admin? %> + <% bookmark.collections.each do |coll| %> +
    • <%= link_to coll.title, collection_path(coll) %>
    • + <% end %> + <% else %> + <% bookmark.approved_collections.each do |coll| %> +
    • <%= link_to coll.title, collection_path(coll) %>
    • + <% end %> + <% end %> +
    + <% end %> + <% end %> + + <% if bookmark.bookmarker_notes.present? %> +
    <%= ts('Bookmarker\'s Notes') %>
    +
    + <%= raw sanitize_field(bookmark, :bookmarker_notes, image_safety_mode: true) %> +
    + <% end %> + <%# end of information added by the bookmark owner %> + <% end %> + + <% if is_author_of?(bookmark) %> + <%= render "bookmarks/bookmark_owner_navigation", bookmark: bookmark %> + <% elsif policy(bookmark).show_admin_options? %> + <%= render "admin/admin_options", item: bookmark %> + <% end %> +
    diff --git a/app/views/bookmarks/_bookmarkable_blurb.html.erb b/app/views/bookmarks/_bookmarkable_blurb.html.erb new file mode 100644 index 0000000..672f57e --- /dev/null +++ b/app/views/bookmarks/_bookmarkable_blurb.html.erb @@ -0,0 +1,54 @@ +<% # expects "bookmarkable" (a BookmarkableDecorator object) %> +<% # Unwrap the bookmarkable to avoid type mis-identification. %> +<% unwrapped = bookmarkable.__getobj__ %> +
  • + + + <% bookmark_count = bookmarkable.public_bookmark_count %> +

    + <%= get_count_for_bookmark_blurb(bookmarkable) %> +

    + + + <%= render "bookmarks/bookmark_item_module", bookmarkable: unwrapped %> + + + + + <% if logged_in_as_admin? && bookmarkable.class == ExternalWork %> + <%= render "admin/admin_options", item: bookmarkable %> + <% end %> + + <% # bookmark form loaded here if requested %> +
    + +
    +
      + <% bookmarkable.matching_bookmarks.each do |bookmark| %> + <%= render "bookmarks/bookmark_blurb_short", bookmark: bookmark %> + <% end %> +
    +
    + + +
  • diff --git a/app/views/bookmarks/_bookmarklet.html.erb b/app/views/bookmarks/_bookmarklet.html.erb new file mode 100644 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 100644 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 100644 index 0000000..2663ec0 --- /dev/null +++ b/app/views/bookmarks/_external_work_fields.html.erb @@ -0,0 +1,119 @@ +
    + <%= ts("External Work") %> +
    +
    <%= 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 %> + +
    +
    <%= ew.label :author, ts("Creator") + "*" %>
    +
    + <%= ew.text_field :author, class: "observe_textlength" %> + <%= generate_countdown_html("external_work_author", ExternalWork::AUTHOR_LENGTH_MAX) %> +
    +
    <%= ew.label :title, ts("Title") + "*" %>
    +
    + <% if params[:url_from_external].present? %> + <%= ew.text_field :title, value: params[:title_from_external], class: "observe_textlength" %> + <% else %> + <%= ew.text_field :title, class: "observe_textlength" %> + <% end %> + <%= generate_countdown_html("external_work_title", ArchiveConfig.TITLE_MAX) %> +
    +
    + <%= ew.label :summary, ts("Creator's Summary") %> + <%= ts("(please copy and paste from original work)") %> +
    +
    + <%= ew.text_area :summary, rows: 5, class: "observe_textlength" %> + <%= generate_countdown_html("external_work_summary", ArchiveConfig.SUMMARY_MAX) %> +
    +
    +
    +
    + <%= ts("Creator's Tags") %> +

    + <%= 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) %> +

    +
    +
    + <%= ew.label :fandom_string, Fandom::NAME.pluralize + "*" %> <%= link_to_help "fandom-help" %> +
    +
    + <%= ew.text_field :fandom_string, autocomplete_options("fandom") %> +
    +
    + <%= ew.label :rating_string, Rating::NAME %> <%= link_to_help "rating-help" %> +
    +
    + <%= ew.collection_select :rating_string, + Rating.defaults_by_severity, + :name, + :name, + selected: ew.object.rating_string.presence || ArchiveConfig.RATING_DEFAULT_TAG_NAME + %> +
    +
    + <%= Category::NAME.pluralize %> <%= link_to_help "categories-help" %> +
    +
    +
      + <%= ew.collection_check_boxes :category_strings, Category.canonical.by_name.sort, :name, :name do |builder| %> +
    • + <%= builder.check_box %> + <%= builder.label %> +
    • + <% end %> +
    +
    +
    + <%= ew.label :relationship_string, Relationship::NAME.pluralize %> <%= link_to_help "relationships-help" %> +
    +
    + <%= ew.text_field :relationship_string, autocomplete_options("relationship_in_fandom", data: { autocomplete_live_params: "fandom=external_work_fandom_string" }) %> +
    +
    + <%= ew.label :character_string, Character::NAME.pluralize %> <%= link_to_help "characters-help" %> +
    +
    + <%= ew.text_field :character_string, autocomplete_options("character_in_fandom", data: { autocomplete_live_params: "fandom=external_work_fandom_string" }) %> +
    +
    +
    diff --git a/app/views/bookmarks/_filters.html.erb b/app/views/bookmarks/_filters.html.erb new file mode 100644 index 0000000..2eeb66c --- /dev/null +++ b/app/views/bookmarks/_filters.html.erb @@ -0,0 +1,154 @@ +<%= form_for @search, as: :bookmark_search, + url: (@collection ? collection_bookmarks_path(@collection) : bookmarks_path), + html: { + method: :get, + class: "narrow-hidden filters", + id: "bookmark-filters" + } do |f| %> +

    <%= ts("Filters") %>

    + <%= field_set_tag ts("Filter results:") do %> +
    +
    <%= ts("Submit") %>
    +
    <%= f.submit ts("Sort and Filter") %>
    +
    + <%= f.label :sort_column, ts("Sort by") %> +
    +
    + <%= f.select :sort_column, options_for_select(@search.sort_options, @search.sort_column) %> +
    + + <% %w(include exclude).each do |filter_action| %> +
    +

    + <%= ts("%{filter_action}", filter_action: filter_action.titleize) %> +

    + <%= link_to_help "bookmark-filters-#{filter_action}-tags" %> +
    +
    +
    + <% %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 %> +
    + + <% end %> + <% field_name = filter_action == "include" ? "other_tag_names" : "excluded_tag_names" %> + + + <% field_name = filter_action == "include" ? "other_bookmark_tag_names" : "excluded_bookmark_tag_names" %> + + +
    +
    + <% end %> + +
    +

    <%= ts("More Options") %>

    +
    +
    +
    + + + + + + +
    + <%= f.label :language_id, ts("Work language") %> +
    +
    + <%= f.select(:language_id, language_options_for_select(Language.default_order, "short"), include_blank: true) %> +
    + +
    <%= ts("Bookmark types") %>
    +
    +
      +
    • + <%= f.label :rec do %> + <%= f.check_box :rec %> + <%= label_indicator_and_text(ts("Recs only")) %> + <% end %> +
    • +
    • + <%= f.label :with_notes do %> + <%= f.check_box :with_notes %> + <%= label_indicator_and_text(ts("Only bookmarks with notes")) %> + <% end %> +
    • +
    +
    +
    +
    +
    <%= ts("Submit") %>
    +
    <%= f.submit ts("Sort and Filter") %>
    +
    + + <% if @owner %> +

    + <%= link_to ts("Clear Filters"), bookmarks_original_path %> +

    + <% end %> + +
    + <%= hidden_field_tag("collection_id", @collection.id) if @collection %> + <%= hidden_field_tag("tag_id", @tag.to_param) if @tag %> + <%= hidden_field_tag("fandom_id", @fandom.id) if @fandom %> + <%= hidden_field_tag("pseud_id", @pseud.name) if @pseud %> + <%= hidden_field_tag("user_id", @user.login) if @user %> +
    + <% end %> + <% # On narrow screens, link jumps to top of index when JavaScript is disabled and closes filters when JavaScript is enabled %> + +<% end %> diff --git a/app/views/bookmarks/_search_form.html.erb b/app/views/bookmarks/_search_form.html.erb new file mode 100644 index 0000000..b14a480 --- /dev/null +++ b/app/views/bookmarks/_search_form.html.erb @@ -0,0 +1,121 @@ +<%= form_for @search, as: :bookmark_search, url: search_bookmarks_path, + html: { class: "search", method: :get } do |f| %> +
    + <%= ts("Bookmarked Item") %> +

    <%= ts("Bookmarked Item") %>

    +
    +
    + <%= f.label :bookmarkable_query, ts("Any field on work") %> + <%= link_to_help "bookmark-search-text-help" %> +
    +
    + <%= f.text_field :bookmarkable_query %> +
    +
    + <%= f.label :other_tag_names, ts("Work tags") %> + <%= link_to_help "bookmark-search-work-tag" %> +
    +
    + <%= f.text_field :other_tag_names, autocomplete_options("tag") %> +
    +
    + <%= f.label :bookmarkable_type, ts("Type") %> + <%= link_to_help "bookmark-search-type-help" %> +
    +
    + <%= f.select :bookmarkable_type, + options_for_select(["", "Work", "Series", "External Work"], + @search.bookmarkable_type) %> +
    +
    + <%= f.label :language_id, ts("Work language") %> +
    +
    + <%= f.select(:language_id, language_options_for_select(@languages, "short"), include_blank: true) %> +
    +
    + <%= f.label :bookmarkable_date, ts("Date updated") %> + <%= link_to_help "bookmark-search-date-updated-help" %> +
    +
    + <%= f.text_field :bookmarkable_date %> +
    +
    +
    + +
    + <%= ts("Bookmark") %> +

    <%= ts("Bookmark") %>

    +
    +
    + <%= f.label :bookmark_query, ts("Any field on bookmark") %> + <%= link_to_help "bookmark-search-text-help" %> +
    +
    + <%= f.text_field :bookmark_query %> +
    +
    + <%= f.label :other_bookmark_tag_names, ts("Bookmarker's tags") %> + <%= link_to_help "bookmark-search-bookmarker-tag" %> +
    +
    + <%= f.text_field :other_bookmark_tag_names, autocomplete_options("tag") %> +
    +
    + <%= f.label :bookmarker, ts("Bookmarker") %> + <%= link_to_help "bookmark-search-text-help" %> +
    +
    + <%= f.text_field :bookmarker %> +
    +
    + <%= f.label :bookmark_notes, ts("Notes") %> + <%= link_to_help "bookmark-search-text-help" %> +
    +
    + <%= f.text_field :bookmark_notes %> +
    +
    + <%= ts("Bookmark type") %> +
    +
    +
      +
    • + <%= f.check_box :rec %> + <%= f.label :rec, ts("Rec") %> + <%= link_to_help "bookmark-search-rec-help" %> +
    • +
    • + <%= f.check_box :with_notes %> + <%= f.label :with_notes, ts("With notes") %> + <%= link_to_help "bookmark-search-notes-help" %> +
    • +
    +
    +
    + <%= f.label :date, ts("Date bookmarked") %> + <%= link_to_help "bookmark-search-date-bookmarked-help" %> +
    +
    + <%= f.text_field :date %> +
    +
    +
    + +
    + <%= ts("Search") %> +

    <%= ts("Search") %>

    +
    +
    + <%= f.label :sort_column, ts("Sort by") %> +
    +
    + <%= f.select :sort_column, + options_for_select(@search.sort_options, + @search.sort_column), + { include_blank: ts("Best Match") } %> +
    +
    +

    <%= f.submit ts("Search Bookmarks") %>

    +
    +<% end %> diff --git a/app/views/bookmarks/bookmark_form_dynamic.js.erb b/app/views/bookmarks/bookmark_form_dynamic.js.erb new file mode 100644 index 0000000..c969550 --- /dev/null +++ b/app/views/bookmarks/bookmark_form_dynamic.js.erb @@ -0,0 +1,22 @@ +<% # Use the bookmarkable item's id unless we're dealing with a bookmark of a deleted item %> +<% bookmark_form_id = (@bookmarkable.blank? ? "#{@bookmark.id}" : "#{@bookmarkable.id}") %> + +var bookmark_div = $j('#bookmark_form_placement_for_<%= bookmark_form_id %>'); +var bookmark_close = $j('#bookmark_form_close_for_<%= bookmark_form_id %>'); +var bookmark_open = $j('#bookmark_form_trigger_for_<%= bookmark_form_id %>'); + +bookmark_div.html("<%= escape_javascript(render "bookmarks/bookmark_form", :bookmarkable => @bookmarkable, :bookmark => @bookmark, :button_name => @button_name, :action => @action, :in_page => true, :dynamic => true) %>"); +bookmark_open.hide(); + +$j('#bookmark_form_close_for_<%= bookmark_form_id %>').click(function(){ + bookmark_div.hide(); + bookmark_open.show(); +}); + +// if canceled we don't want to generate the form a second time, just reopen it +bookmark_open.attr('href', '#'); +$j("#bookmark_form_trigger_for_<%= bookmark_form_id %>").click(function(event){ + bookmark_div.show(); + bookmark_open.hide(); + event.preventDefault(); +}); diff --git a/app/views/bookmarks/confirm_delete.html.erb b/app/views/bookmarks/confirm_delete.html.erb new file mode 100644 index 0000000..312564a --- /dev/null +++ b/app/views/bookmarks/confirm_delete.html.erb @@ -0,0 +1,17 @@ + +

    <%= ts("Delete Bookmark") %>

    + + +<%= form_for(@bookmark, :html => {:method => :delete, :class => "simple destroy"}) do |f| %> +

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

    + +<%= error_messages_for :bookmark %> + + + + + + + +<%= render 'bookmark_form', :button_name => ts('Update'), :action => 'update', :bookmark => @bookmark, :bookmarkable => @bookmarkable %> + diff --git a/app/views/bookmarks/index.html.erb b/app/views/bookmarks/index.html.erb new file mode 100755 index 0000000..baeb832 --- /dev/null +++ b/app/views/bookmarks/index.html.erb @@ -0,0 +1,71 @@ + +<%= render "muted/muted_items_notice" %> + +

    + <% if @bookmarkable_items %> + <%= search_header @bookmarkable_items, @search, "Bookmarked Item", @owner %> + <% else %> + <%= search_header @bookmarks, @search, "Bookmark", @owner %> + <% end %> +

    + + + +<% if current_user.is_a?(User) || @tag || @facets.present? %> + +<% end %> + + +<% if params[:work_id] || params[:series_id] || params[:external_work_id] %> + <% # bookmark form loaded here if requested %> +
    +<% end %> + +<% unless @owner.present? || @bookmarkable.present? %> +

    <%= t(".recent_bookmarks_html", choose_fandom_link: link_to(t(".choose_fandom"), media_index_path), advanced_search_link: link_to(t(".advanced_search"), search_bookmarks_path)) %> +<% end %> + +<%== pagy_nav @pagy %> + + +

    <%= ts("List of Bookmarks") %>

    +
      + <%= render "bookmarks/bookmarks" %> +
    + + + +<% if @facets.present? %> + <%= render "filters" %> +<% end %> + + + + + +<%== pagy_nav @pagy %> + diff --git a/app/views/bookmarks/new.html.erb b/app/views/bookmarks/new.html.erb new file mode 100644 index 0000000..3184a06 --- /dev/null +++ b/app/views/bookmarks/new.html.erb @@ -0,0 +1,9 @@ + +

    <%= (ts("New bookmark for ") + link_to(@bookmarkable.title, @bookmarkable)).html_safe %>

    + +<%= error_messages_for :bookmark %> + + + +<%= render :partial => 'bookmark_form', :locals => { :bookmark => @bookmark, :bookmarkable => @bookmarkable, :button_name => ts("Create"), :action => 'create' }%> + diff --git a/app/views/bookmarks/search.html.erb b/app/views/bookmarks/search.html.erb new file mode 100644 index 0000000..765d461 --- /dev/null +++ b/app/views/bookmarks/search.html.erb @@ -0,0 +1,12 @@ + + +

    <%= ts("Bookmark Search") %>

    + + + +<%= render 'shared/search_nav' %> + + + +<%= render "bookmarks/search_form" %> + diff --git a/app/views/bookmarks/search_results.html.erb b/app/views/bookmarks/search_results.html.erb new file mode 100644 index 0000000..70032e4 --- /dev/null +++ b/app/views/bookmarks/search_results.html.erb @@ -0,0 +1,35 @@ + +<%= render "muted/muted_items_notice" %> + +

    <%= ts('Search Results') %>

    + +

    + <%= ts("You searched for:") %> + <%= (sanitize @search.summary, tags: %w[span], attributes: %w[lang]).html_safe %> +

    + + + + + + + +<% if @bookmarks.blank? %> +

    <%= ts("No results found. You may want to edit your search to make it less specific.") %>

    +<% else %> +

    <%= search_results_found(@bookmarks) %> <%= link_to_help "bookmark-search-results-help" %>

    + +

    Bookmarks List

    +
      + <% @bookmarks.each do |bookmark| %> + <% unless bookmark.nil? %> + <%= render 'bookmarks/bookmark_blurb', :bookmark => bookmark %> + <% end %> + <% end %> +
    + + <%= will_paginate @bookmarks %> +<% end %> + diff --git a/app/views/bookmarks/share.html.erb b/app/views/bookmarks/share.html.erb new file mode 100644 index 0000000..64f2915 --- /dev/null +++ b/app/views/bookmarks/share.html.erb @@ -0,0 +1 @@ +<%= render 'share/share', shareable: @bookmark %> diff --git a/app/views/bookmarks/show.html.erb b/app/views/bookmarks/show.html.erb new file mode 100644 index 0000000..100ffbd --- /dev/null +++ b/app/views/bookmarks/show.html.erb @@ -0,0 +1,23 @@ + +

    + <%= ts("Bookmark") %> +

    + + + + + + + +
      + <%= render 'bookmark_blurb', :bookmark => @bookmark %> +
    + diff --git a/app/views/challenge/gift_exchange/_challenge_main.html.erb b/app/views/challenge/gift_exchange/_challenge_main.html.erb new file mode 100644 index 0000000..10056b2 --- /dev/null +++ b/app/views/challenge/gift_exchange/_challenge_main.html.erb @@ -0,0 +1 @@ + diff --git a/app/views/challenge/gift_exchange/_challenge_meta.html.erb b/app/views/challenge/gift_exchange/_challenge_meta.html.erb new file mode 100644 index 0000000..d394f2c --- /dev/null +++ b/app/views/challenge/gift_exchange/_challenge_meta.html.erb @@ -0,0 +1 @@ +<%= render "challenge/shared/challenge_meta" %> diff --git a/app/views/challenge/gift_exchange/_challenge_navigation_maintainer.html.erb b/app/views/challenge/gift_exchange/_challenge_navigation_maintainer.html.erb new file mode 100644 index 0000000..331129c --- /dev/null +++ b/app/views/challenge/gift_exchange/_challenge_navigation_maintainer.html.erb @@ -0,0 +1,6 @@ +<% if @collection.challenge.user_allowed_to_see_signups?(current_user) %> +
  • <%= link_to ts("Sign-ups"), collection_signups_path(@collection) %>
  • +<% end %> +<% if @collection.user_is_owner?(current_user) %> +
  • <%= link_to ts("Challenge Settings"), edit_collection_gift_exchange_path(@collection) %>
  • +<% end %> diff --git a/app/views/challenge/gift_exchange/_challenge_navigation_user.html.erb b/app/views/challenge/gift_exchange/_challenge_navigation_user.html.erb new file mode 100644 index 0000000..23ee157 --- /dev/null +++ b/app/views/challenge/gift_exchange/_challenge_navigation_user.html.erb @@ -0,0 +1 @@ +<%= render "challenge/shared/challenge_navigation_user", :collection => (collection ||= @collection) %> \ No newline at end of file diff --git a/app/views/challenge/gift_exchange/_challenge_requests.html.erb b/app/views/challenge/gift_exchange/_challenge_requests.html.erb new file mode 100644 index 0000000..ef85178 --- /dev/null +++ b/app/views/challenge/gift_exchange/_challenge_requests.html.erb @@ -0,0 +1 @@ +<%= render "challenge/shared/challenge_requests" %> diff --git a/app/views/challenge/gift_exchange/_challenge_sidebar.html.erb b/app/views/challenge/gift_exchange/_challenge_sidebar.html.erb new file mode 100644 index 0000000..337a286 --- /dev/null +++ b/app/views/challenge/gift_exchange/_challenge_sidebar.html.erb @@ -0,0 +1,46 @@ +<% if logged_in? %> +

    <%= ts("Gift Exchange") %>

    + +<% end %> diff --git a/app/views/challenge/gift_exchange/_challenge_signups.html.erb b/app/views/challenge/gift_exchange/_challenge_signups.html.erb new file mode 100755 index 0000000..f6e6486 --- /dev/null +++ b/app/views/challenge/gift_exchange/_challenge_signups.html.erb @@ -0,0 +1,64 @@ + +

    + <% if @query %> + <%= search_header @challenge_signups, nil, t(".heading.for_search") %> + <% else %> + <%= t(".heading.for_collection", collection: @collection.title) %> + <% end %> +

    + + + + + + + +<% if @challenge_signups.empty? %> +

    <%= t(".no_sign_ups_yet") %>

    +<% else %> + <%= will_paginate(@challenge_signups) %> + +
    + <% @challenge_signups.each do |signup| %> +
    + <%= link_to signup.pseud.byline, collection_signup_path(@collection, signup) %> + <%= mailto_link signup.pseud.user, subject: "[#{h(@collection.title)}] Message from Collection Maintainer" %> +
    +
    + <%= render "challenge_signups/signup_controls", challenge_signup: signup, subnav: false %> +
      +
    • + <%= link_to t(".requests_html"), "#", class: "requests_#{signup.id}_open" %> + <%= link_to t(".close_requests_html"), "#", class: "requests_#{signup.id}_close" %> +
    • +
    • + <%= link_to t(".offers_html"), "#", class: "offers_#{signup.id}_open" %> + <%= link_to t(".close_offers_html"), "#", class: "offers_#{signup.id}_close" %> +
    • +
    +
    "> + <%= render "challenge_signups/show_requests", challenge_signup: signup %> +
    +
    "> + <%= render "challenge_signups/show_offers", challenge_signup: signup %> +
    +
    + <% end %> +
    + + <%= will_paginate(@challenge_signups) %> +<% end %> + diff --git a/app/views/challenge/gift_exchange/_challenge_signups_summary.html.erb b/app/views/challenge/gift_exchange/_challenge_signups_summary.html.erb new file mode 100644 index 0000000..2681e29 --- /dev/null +++ b/app/views/challenge/gift_exchange/_challenge_signups_summary.html.erb @@ -0,0 +1,42 @@ +<% # display a summary version of what users have requested and offered %> +<% # requires locals :challenge_collection :tag_type :summary_tags :generated_live %> +

    <%= ts("Sign-up Summary for %{challenge_collection}", :challenge_collection => challenge_collection.title) %>

    + +<% unless generated_live %> +

    + <%= ts("Last generated at:") %> <%= time_in_zone(Time.now, (challenge_collection.challenge.time_zone || Time.zone.name)) %> + <%= ts("(Generated hourly on request while sign-ups are open.)") %> +

    +<% end %> + +<% if summary_tags.empty? %> +

    <%= ts("Tags were not used in this Challenge, so there is no summary to display here.") %>

    +<% else %> +

    <%= ts("Requested %{tag_type}", tag_type: tag_type_label_name(tag_type).pluralize) %>

    +

    + <%= ts('Listed by fewest offers and most requests.') %> +

    + "> + + + + + + + + + + <% summary_tags.each do |tag| %> + + + + + + <% end %> + +
    <%=ts("Requests and Offers for Requested %{tag_type}", tag_type: tag_type_label_name(tag_type)) %>
    <%= tag_type_label_name(tag_type) %><%= ts('Requests') %><%= ts('Offers') %>
    + + <%= tag.name %> + + <%= tag.requests.to_i %><%= tag.offers.to_i %>
    +<% end %> diff --git a/app/views/challenge/gift_exchange/_gift_exchange_form.html.erb b/app/views/challenge/gift_exchange/_gift_exchange_form.html.erb new file mode 100644 index 0000000..5b80e8e --- /dev/null +++ b/app/views/challenge/gift_exchange/_gift_exchange_form.html.erb @@ -0,0 +1,57 @@ + +

    <%= ts("Setting Up the %{title} Gift Exchange", :title => @collection.title) %>

    + + + +<% if @collection.challenge %> + +<% end %> + + + +

    <%= ts('Notes') %>

    +
      +
    • <%= 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.') %>
    • +
    + +

    <%= ts("Gift Exchange Settings") %>

    +<%= form_for([@collection, @challenge], :url => collection_gift_exchange_path(@collection), :html => {:class => "verbose create challenge"}) do |f| %> + <%= error_messages_for @challenge %> + <%= render "challenge/shared/challenge_form_schedule", :f => f %> + +
    + <%= ts("Requests and Offers") %> +

    <%= ts("Requests and Offers") %>

    +
    +
    <%= f.label :requests_summary_visible, ts("Requests visible?") %> <%= link_to_help "challenge-requests-summary" %>
    +
    <%= f.check_box :requests_summary_visible %>
    + +
    <%= f.label :requests_num_required, ts("Number of requests per sign-up:") %>
    + <%= required_and_allowed(f, "requests", @collection.prompts.empty?, false) %> + +
    <%= f.label :offers_num_required, ts("Number of offers per sign-up:") %>
    + <%= required_and_allowed(f, "offers", @collection.prompts.empty?, false) %> +
    +
    + + + <%= render "prompt_restrictions/prompt_restriction_form", :challenge_form => f, :is_offer => false, :show_tag_options => false, :type => "gift_exchange" %> + + + <%= render "prompt_restrictions/prompt_restriction_form", :challenge_form => f, :is_offer => true, :show_tag_options => true, :type => "gift_exchange" %> + + + <%= render "potential_match_settings/potential_match_settings_form", :challenge_form => f %> + + + <%= render "challenge/shared/challenge_form_instructions", :f => f %> + + <%= submit_fieldset f %> + +<% end %> + + diff --git a/app/views/challenge/gift_exchange/confirm_delete.html.erb b/app/views/challenge/gift_exchange/confirm_delete.html.erb new file mode 100644 index 0000000..dde3cf9 --- /dev/null +++ b/app/views/challenge/gift_exchange/confirm_delete.html.erb @@ -0,0 +1,6 @@ + +

    <%= ts("Delete Gift Exchange") %>

    + + +<%= render "challenge/shared/challenge_form_confirm_delete" %> + diff --git a/app/views/challenge/gift_exchange/edit.html.erb b/app/views/challenge/gift_exchange/edit.html.erb new file mode 100644 index 0000000..eeb120a --- /dev/null +++ b/app/views/challenge/gift_exchange/edit.html.erb @@ -0,0 +1,2 @@ +<%= render :partial => "gift_exchange_form" %> + diff --git a/app/views/challenge/gift_exchange/new.html.erb b/app/views/challenge/gift_exchange/new.html.erb new file mode 100644 index 0000000..1c201ae --- /dev/null +++ b/app/views/challenge/gift_exchange/new.html.erb @@ -0,0 +1 @@ +<%= render :partial => "gift_exchange_form" %> \ No newline at end of file diff --git a/app/views/challenge/prompt_meme/_challenge_main.html.erb b/app/views/challenge/prompt_meme/_challenge_main.html.erb new file mode 100644 index 0000000..10056b2 --- /dev/null +++ b/app/views/challenge/prompt_meme/_challenge_main.html.erb @@ -0,0 +1 @@ + diff --git a/app/views/challenge/prompt_meme/_challenge_meta.html.erb b/app/views/challenge/prompt_meme/_challenge_meta.html.erb new file mode 100644 index 0000000..602ce63 --- /dev/null +++ b/app/views/challenge/prompt_meme/_challenge_meta.html.erb @@ -0,0 +1,4 @@ +<%= render "challenge/shared/challenge_meta" %> + +
    <%= ts("Prompts") %>:
    +
    <%= @collection.prompts.count.to_s %>
    diff --git a/app/views/challenge/prompt_meme/_challenge_navigation_maintainer.html.erb b/app/views/challenge/prompt_meme/_challenge_navigation_maintainer.html.erb new file mode 100644 index 0000000..caa267f --- /dev/null +++ b/app/views/challenge/prompt_meme/_challenge_navigation_maintainer.html.erb @@ -0,0 +1,6 @@ +<% if @collection.challenge.user_allowed_to_see_signups?(current_user) %> +
  • <%= link_to ts("Sign-ups"), collection_signups_path(@collection) %>
  • +<% end %> +<% if @collection.user_is_owner?(current_user) %> +
  • <%= link_to ts("Challenge Settings"), edit_collection_prompt_meme_path(@collection) %>
  • +<% end %> diff --git a/app/views/challenge/prompt_meme/_challenge_navigation_user.html.erb b/app/views/challenge/prompt_meme/_challenge_navigation_user.html.erb new file mode 100644 index 0000000..23ee157 --- /dev/null +++ b/app/views/challenge/prompt_meme/_challenge_navigation_user.html.erb @@ -0,0 +1 @@ +<%= render "challenge/shared/challenge_navigation_user", :collection => (collection ||= @collection) %> \ No newline at end of file diff --git a/app/views/challenge/prompt_meme/_challenge_requests.html.erb b/app/views/challenge/prompt_meme/_challenge_requests.html.erb new file mode 100644 index 0000000..3db34bb --- /dev/null +++ b/app/views/challenge/prompt_meme/_challenge_requests.html.erb @@ -0,0 +1 @@ +<%= render "challenge/shared/challenge_requests" %> \ No newline at end of file diff --git a/app/views/challenge/prompt_meme/_challenge_sidebar.html.erb b/app/views/challenge/prompt_meme/_challenge_sidebar.html.erb new file mode 100755 index 0000000..60b2258 --- /dev/null +++ b/app/views/challenge/prompt_meme/_challenge_sidebar.html.erb @@ -0,0 +1,28 @@ +

    <%= ts("Prompt Meme") %>

    + diff --git a/app/views/challenge/prompt_meme/_challenge_signups.html.erb b/app/views/challenge/prompt_meme/_challenge_signups.html.erb new file mode 100644 index 0000000..cdbb282 --- /dev/null +++ b/app/views/challenge/prompt_meme/_challenge_signups.html.erb @@ -0,0 +1,11 @@ +

    <%= ts("Sign-ups for %{collection}", :collection => @collection.title) %>

    + +<% @challenge ||= @collection.challenge -%> + diff --git a/app/views/challenge/prompt_meme/_prompt_meme_form.html.erb b/app/views/challenge/prompt_meme/_prompt_meme_form.html.erb new file mode 100644 index 0000000..9c12780 --- /dev/null +++ b/app/views/challenge/prompt_meme/_prompt_meme_form.html.erb @@ -0,0 +1,52 @@ + +

    <%= ts("Setting Up the %{title} Prompt Meme", :title => @collection.title) -%>

    + + + +<% if @collection.challenge %> + +<% end %> + + + + +

    <%= ts("Notes") %>

    +
      +
    • <%= 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.') %>
    • +
    + +

    <%= ts("Prompt Meme Settings") %>

    + +<%= form_for([@collection, @challenge], :url => collection_prompt_meme_path(@collection), :html => {:class => "verbose create challenge"}) do |f| %> + <%= error_messages_for @challenge %> + <%= render "challenge/shared/challenge_form_schedule", :f => f %> + +
    + <%= ts("Prompts") %> +

    <%= ts("Prompts") %>

    +
    +
    <%= f.label :anonymous, ts("Prompts anonymous by default?") %>
    +
    <%= f.check_box :anonymous %> +

    (Participants can override)

    +
    +
    <%= f.label :requests_num_required, ts("Number of prompts per sign-up:") %>
    + <%= required_and_allowed(f, "requests", @collection.prompts.empty?, false) %> + +
    +
    + + + <%= render "prompt_restrictions/prompt_restriction_form", :challenge_form => f, :is_offer => false, :show_tag_options => true, :type => "prompt_meme" -%> + + <%= render "challenge/shared/challenge_form_instructions", :f => f %> + + <%= submit_fieldset f %> + +<% end %> + + diff --git a/app/views/challenge/prompt_meme/confirm_delete.html.erb b/app/views/challenge/prompt_meme/confirm_delete.html.erb new file mode 100644 index 0000000..bdfeeb3 --- /dev/null +++ b/app/views/challenge/prompt_meme/confirm_delete.html.erb @@ -0,0 +1,6 @@ + +

    <%= ts("Delete Prompt Meme") %>

    + + +<%= 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 index 0000000..57afc8e --- /dev/null +++ b/app/views/challenge/shared/_challenge_form_instructions.html.erb @@ -0,0 +1,44 @@ +
    + <%= ts("Sign-up Instructions") %> +

    <%= ts("Sign-up Instructions") %>

    +

    + <%= ts("Explain to your members how you want them to sign up.") %> <%= allowed_html_instructions %> +

    +
    +
    <%= f.label :signup_instructions_general, ts("General Sign-up Instructions:") %>
    +
    + <%= f.text_area :signup_instructions_general, :id => field_id(f, "signup_instructions_general").to_sym, :rows => 6, :cols => 60, :class => "observe_textlength" %> + <%= live_validation_for_field(field_id(f, "signup_instructions_general").to_sym, :presence => false, :maximum_length => ArchiveConfig.INFO_MAX) -%> + <%= generate_countdown_html(field_id(f, "signup_instructions_general").to_sym, ArchiveConfig.INFO_MAX) -%> +
    + + <% f.object.class::PROMPT_TYPES.each do |prompt_type| %> +
    <%= f.label "signup_instructions_#{prompt_type}".to_sym, ts("#{prompt_type.singularize.capitalize} Instructions: ") %>
    +
    + <%= f.text_area "signup_instructions_#{prompt_type}".to_sym, :id => field_id(f, "signup_instructions_#{prompt_type}").to_sym, :rows => 6, :cols => 60, :class => "observe_textlength" %> + <%= live_validation_for_field(field_id(f, "signup_instructions_#{prompt_type}").to_sym, :presence => false, :maximum_length => ArchiveConfig.INFO_MAX) -%> + <%= generate_countdown_html(field_id(f, "signup_instructions_#{prompt_type}").to_sym, ArchiveConfig.INFO_MAX) -%> +
    + <% end %> +
    + +

    <%= ts("Change the Labels") %>

    +

    <%= ts("You can change these form fields to something more useful for your own challenge.") %>

    +
    + <% f.object.class::PROMPT_TYPES.each do |prompt_types| %> + <% prompt_type = prompt_types.singularize %> +
    <%= ts("In #{prompt_types.capitalize} change:") %>
    +
    +
    +
    +
    <%= f.label "#{prompt_type}_url_label".to_sym, ts("\"Prompt URL\" to:") %>
    +
    <%= f.text_field "#{prompt_type}_url_label".to_sym %>
    + +
    <%= f.label "#{prompt_type}_description_label".to_sym, ts("\"Description\" to:") %>
    +
    <%= f.text_field "#{prompt_type}_description_label".to_sym %>
    +
    +
    +
    + <% end %> +
    +
    \ 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 100644 index 0000000..3b20046 --- /dev/null +++ b/app/views/challenge/shared/_challenge_form_schedule.html.erb @@ -0,0 +1,50 @@ +
    + <%= ts("Schedule") %> +

    <%= 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.") %>
    • +
    +
    +
    <%= f.label :signup_open, ts("Sign-up open?") %>
    +
    <%= f.check_box :signup_open %>
    + +
    <%= f.label :time_zone, ts("Time zone:") %>
    +
    <%= f.time_zone_select :time_zone, nil, :default => Time.zone.name %>
    + +
    <%= f.label :signups_open_at_string, ts("Sign-up opens:")%>
    +
    <%= f.text_field :signups_open_at_string, :class => 'timepicker' %>
    + +
    <%= f.label :signups_close_at_string, ts("Sign-up closes:")%>
    +
    <%= f.text_field :signups_close_at_string, :class => 'timepicker' %>
    + +
    <%= f.label :assignments_due_at_string, ts("Assignments due:")%>
    +
    <%= f.text_field :assignments_due_at_string, :class => 'timepicker' %>
    + + <% if @collection.unrevealed? %> +
    <%= f.label :works_reveal_at_string, ts("Works revealed:")%>
    +
    <%= f.text_field :works_reveal_at_string, :class => 'timepicker' %>
    + <% end %> + + <% if @collection.anonymous? %> +
    <%= f.label :authors_reveal_at_string, ts("Creators revealed:")%>
    +
    <%= f.text_field :authors_reveal_at_string , :class => 'timepicker' %>
    + <% end %> +
    +
    + +<%= content_for :footer_js do %> + + <%= javascript_tag do %> + $j('.timepicker').datetimepicker({ + ampm: true, + dateFormat: 'yy-mm-dd', + timeFormat: 'hh:mmTT', + hourGrid: 5, + minuteGrid: 10 + }); + <% end %> +<% end %> \ No newline at end of file diff --git a/app/views/challenge/shared/_challenge_meta.html.erb b/app/views/challenge/shared/_challenge_meta.html.erb new file mode 100644 index 0000000..6955d19 --- /dev/null +++ b/app/views/challenge/shared/_challenge_meta.html.erb @@ -0,0 +1,74 @@ + +<% challenge = @collection.challenge %> +<% zone = (challenge.time_zone || Time.zone.name) %> +<% if challenge.signup_open %> +
    <%= ts("Sign-up:")%>
    +
    <%= link_to ts("Open"), new_collection_signup_path(@collection) %>
    + +
    <%= ts("Sign-up Closes:")%>
    +
    <%= time_in_zone(challenge.signups_close_at, zone) %>
    + +<% elsif Time.now < (challenge.signups_open_at || 1.day.ago) %> + +
    <%= ts("Sign-up Opens:")%>
    +
    <%= time_in_zone(challenge.signups_open_at, zone) %>
    + +
    <%= ts("Sign-up Closes:")%>
    +
    <%= time_in_zone(challenge.signups_close_at, zone) %>
    + +<% elsif Time.now < (challenge.signups_close_at || 1.day.ago) %> +
    <%= ts("Sign-up:")%>
    +
    <%= ts("Closed")%>
    +<% end %> + +
    <%= ts("Assignments Due:")%>
    +
    <%= time_in_zone(challenge.assignments_due_at, zone) %>
    + +<% if @collection.unrevealed? %> +
    <%= ts("Works Revealed:")%>
    +
    <%= time_in_zone(challenge.works_reveal_at, zone) %>
    +<% end %> + +<% if @collection.anonymous? %> +
    <%= ts("Creators Revealed:")%>
    +
    <%= time_in_zone(challenge.authors_reveal_at, zone) %>
    +<% end %> + +
    <%= ts("Signed 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 %> +
    + +
      + <% end %> +
    • <%= "#{pseud.byline}" + (index != (num_to_show-1) && index != (signup_count-1) ? "," : "") %>
    • + <% end %> +
    + <% if signup_count > 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 100644 index 0000000..885f571 --- /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("Edit Sign-up"), edit_collection_signup_path(collection, @challenge_signup) %>
  • +
  • + <%= 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 %> +
  • + <% else %> +
  • <%= link_to ts("Sign Up"), new_collection_signup_path(collection) %>
  • + <% 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 %> + <%= link_to ts("Join"), join_collection_participants_path(collection) %> + <% 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 100644 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) %>

    + + + + + + +<%= 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 100644 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 ||= @collection.challenge %> + + + + +<% @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..42aae0b --- /dev/null +++ b/app/views/challenge_assignments/_assignment_blurb.html.erb @@ -0,0 +1,68 @@ +<% # 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) %> +
    <%= ts("Words:") %>
    +
    <%= ts("%{count}", :count => assignment.creation.word_count) %>
    + <% end %> + <% end %> +
    + + <% unless assignment.fulfilled? %> + + <% end %> + +
    diff --git a/app/views/challenge_assignments/_maintainer_index.html.erb b/app/views/challenge_assignments/_maintainer_index.html.erb new file mode 100644 index 0000000..583ed25 --- /dev/null +++ b/app/views/challenge_assignments/_maintainer_index.html.erb @@ -0,0 +1,82 @@ + +

    + <%= ts("Assignments for %{collection_title}", collection_title: @collection.title) %> + <%= link_to_help "challenge-assignments" %> +

    + + + + + + +<% if @assignments.count < 1 %> + +

    <%= ts("No assignments to review!") %>

    + +<% else %> + + <%= will_paginate @assignments %> + + + <% if params[:fulfilled] %> + <%= render "maintainer_index_fulfilled" %> + <% else %> + <%= form_tag update_multiple_collection_assignments_path(@collection), method: :put do %> +
    + + <% if !(params[:pinch_hit] || params[:unfulfilled] || params[:fulfilled]) %> + <%= render "maintainer_index_defaulted" %> + <% elsif params[:pinch_hit] %> + <%= ts("Pinch Hit Assignments") %> +

    <%=ts("Pinch Hit Assignments") %>

    + <%= render "maintainer_index_unfulfilled" %> + <% elsif params[:unfulfilled] %> + <%= ts("Open Assignments") %> +

    <%=ts("Open Assignments") %>

    + <%= render "maintainer_index_unfulfilled" %> + <% end %> + +
    + <%= 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 %> +<%= ts("Defaulted Assignments") %> +

    <%=ts("Defaulted Assignments") %>

    +
    + <% @assignments.each do |assignment| %> +
    + + <%= link_to assignment.request_byline, collection_signup_path(@collection, assignment.request_signup) %> + <% assignments = ChallengeAssignment.in_collection(@collection).by_offering_user(assignment.request_signup.pseud.user) %> + <% if (defaulted = assignments.defaulted) && assignments && defaulted.size == assignments.size %> + <%= ts("(Also defaulted)") %> + <% end %> +
    +
    + <%= label_tag "undefault_#{assignment.id}", :class => 'action' do %> + <%= ts("Undefault") %> <%= assignment.offer_byline %> <%= check_box_tag "undefault_#{assignment.id}" %> + <% end %> + +
    + + <% end %> +
    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 100644 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 %> +
    + <% @assignments.each do |assignment| %> +
    + + <%= challenge_assignment_byline(assignment) %> <%= challenge_assignment_email(assignment) %> + + <%= ts("for") %> + <% if assignment.request_signup.nil? %> + <%= ts("No Recipient!")%> + <% else %> + <%= link_to assignment.request_byline, collection_signup_path(@collection, assignment.request_signup) %> + <% end %> + +
    +
    + <% if @collection.moderated? %> + <% # we might have works posted that haven't yet been approved %> + <% if assignment.posted? %> + <%= link_to ts("Gift posted on %{published_at}", :published_at => assignment.creation.published_at), assignment.creation %> + <% if assignment.creation.respond_to?(:word_count) %> + <%= ts("(%{count} words)", :count => assignment.creation.word_count) %> + <% end %> + <% else %> + <%= ts("Not yet posted") %> + <% end %> + <% end %> + +
      + <% if assignment.posted? %> +
    • + <%= label_tag "approve_#{assignment.id}" do %> + <%= ts("Approve") %> + <%= check_box_tag "approve_#{assignment.id}" %> + <% end %> +
    • + <% end %> +
    • + <%= label_tag "default_#{assignment.id}" do %> + <%= ts("Default") %> + <%= check_box_tag "default_#{assignment.id}" %> + <% end %> +
    • +
    +
    + <% end %> +
    diff --git a/app/views/challenge_assignments/_user_index.html.erb b/app/views/challenge_assignments/_user_index.html.erb new file mode 100644 index 0000000..9454149 --- /dev/null +++ b/app/views/challenge_assignments/_user_index.html.erb @@ -0,0 +1,20 @@ + +

    <%= ts("My Assignments") %> <%= @collection ? ts("in %{collection_link}", :collection_link => link_to(@collection.title, @collection)).html_safe : "" %>

    + + + + + + +<% unless @collection %> +

    <%= ts('Looking for prompts you claimed in a prompt meme? Try') %> <%= link_to ts("My Claims"), user_claims_path(@user) %>

    +<% end %> + +
    + + <% @challenge_assignments.each do |assignment| %> + <%= render "assignment_blurb", :assignment => assignment %> + <% end %> + +
    + diff --git a/app/views/challenge_assignments/confirm_purge.html.erb b/app/views/challenge_assignments/confirm_purge.html.erb new file mode 100644 index 0000000..9aec476 --- /dev/null +++ b/app/views/challenge_assignments/confirm_purge.html.erb @@ -0,0 +1,19 @@ + +

    + <%= ts("Purge Assignments for %{collection_title}", + collection_title: @collection.title) %> +

    + + +<%= form_tag purge_collection_assignments_path(@collection), class: "simple destroy" do %> +

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

    +

    + <%= submit_tag ts("Yes, Purge Assignments") %> +

    +<% end %> + diff --git a/app/views/challenge_assignments/index.html.erb b/app/views/challenge_assignments/index.html.erb new file mode 100644 index 0000000..8d25664 --- /dev/null +++ b/app/views/challenge_assignments/index.html.erb @@ -0,0 +1,5 @@ +<% if @user %> + <%= render "user_index" %> +<% elsif @challenge && @challenge.user_allowed_to_see_assignments?(current_user) %> + <%= render "maintainer_index" %> +<% end %> \ No newline at end of file diff --git a/app/views/challenge_assignments/show.html.erb b/app/views/challenge_assignments/show.html.erb new file mode 100644 index 0000000..ce8871a --- /dev/null +++ b/app/views/challenge_assignments/show.html.erb @@ -0,0 +1,39 @@ +<% # accessed through My Assignment on challenge dashboard %> + +

    <%= ts("Assignment for") %> <%= @challenge_assignment.offer_byline %> <% if @challenge_assignment.collection.challenge.user_allowed_to_see_assignments?(current_user) %><%= challenge_assignment_email(@challenge_assignment) %><% end %>

    + + + +<% if !@challenge_assignment.fulfilled? && @challenge_assignment.try(:offering_pseud).try(:user) == current_user %> + +<% end %> + + + + +<% if @challenge_assignment.request_signup %> + <%= render "challenge_signups/show_requests", challenge_signup: @challenge_assignment.request_signup %> +<% else %> +

    <%= ts("No request!") %>

    +

    <%= ts("Contact challenge moderators for help.") %>

    +<% end %> + +<% if @challenge_assignment.offer_signup %> + <%= render "challenge_signups/show_offers", :challenge_signup => @challenge_assignment.offer_signup %> +<% elsif @challenge_assignment.pinch_hitter %> +

    <%= ts("Pinch Hitter") %>

    +<% else %> +

    <%= ts("No assigned giver!") %>

    +

    <%= ts("Contact challenge moderators for help.") %>

    +<% end %> + diff --git a/app/views/challenge_claims/_maintainer_index.html.erb b/app/views/challenge_claims/_maintainer_index.html.erb new file mode 100644 index 0000000..21c4824 --- /dev/null +++ b/app/views/challenge_claims/_maintainer_index.html.erb @@ -0,0 +1,36 @@ + +

    <%= ts("Unposted Claims for") %> <%= @collection.title %>

    + + + + + + +<% # we just briefly list all the unposted claims so a mod can clean them up if there are a ton of old ones %> + +<% if @claims.empty? %> + +

    <%= ts("No claims to review!") %>

    + +<% else %> + + <%= will_paginate @claims %> + + + <% # this used to be a listbox, but if we ever decide to display posted claims as well we should model it after challenge_assignments/_maintainer_index instead %> +
      + <% @claims.each do |claim| %> + <%= render "challenge_claims/unposted_claim_blurb", :claim => claim %> + <% end %> +
    + + <%= will_paginate @claims %> + +<% end %> diff --git a/app/views/challenge_claims/_unposted_claim_blurb.html.erb b/app/views/challenge_claims/_unposted_claim_blurb.html.erb new file mode 100755 index 0000000..0d8ea6c --- /dev/null +++ b/app/views/challenge_claims/_unposted_claim_blurb.html.erb @@ -0,0 +1,59 @@ +<% # expects "claim" %> + +<% prompt = claim.request_prompt %> +<% collection = claim.collection %> +<% challenge = claim.collection.challenge %> +
  • +
    +

    + <%= prompt.title || ts("Request") %> +

    + + +
    + <%= ts("by") %> + + <%= ts("claimed by") %> + +
    + + +

    + <%= ts("Claimed") %> <%= set_format_for_date(claim.created_at) %> + <% if @challenge.try(:assignments_due_at).present? %> + + <%= ts("Due") %> <%= time_in_zone(@challenge.assignments_due_at, (@challenge.time_zone || Time.zone.name)) %> + <% end %> +

    + +
    + <%= collection_icon_display(claim.collection) %> +
    +
    + + + <%= prompt_tags(prompt) %> + + + <% unless prompt.description.blank? %> +
    <%= ts("Summary") %>
    +
    + <%=raw sanitize_field(prompt, :description) %> +
    + <% end %> + + + <% if @collection.user_is_maintainer?(current_user) %> + + <% end %> +
  • diff --git a/app/views/challenge_claims/_user_index.html.erb b/app/views/challenge_claims/_user_index.html.erb new file mode 100644 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 %>

    + + + +<% unless params[:for_user] %> + +<% end %> + + +<%= will_paginate @claims %> + + +<% unless @collection %> +

    <%= 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 100644 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 100644 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 100644 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" %> + +<% end %> diff --git a/app/views/challenge_signups/_signup_form.html.erb b/app/views/challenge_signups/_signup_form.html.erb new file mode 100644 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? %> +

    + <%= t(".notice.preference.#{@collection.challenge_type.underscore}", + preferences_link: link_to( + t(".notice.preference.preferences_link_text"), + user_preferences_path(@current_user)), + refuse_link: link_to( + t(".notice.preference.refuse_link_text"), + archive_faq_path("your-account", anchor: "refusegift")) + ).html_safe %> +

    + <% 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.") %>

    +
    + <% end %> + + <%= error_messages_for @challenge_signup %> + + <%= render "signup_form_general_information", :form => signup_form %> + + <% # requests and offers section %> + <% @challenge.class::PROMPT_TYPES.each do |prompt_type| %> +
    + <%= prompt_type.capitalize %> +

    + <%= prompt_type.capitalize %> (<%= @challenge.allowed_range_string(prompt_type) %>) +

    + <% unless sanitize_field(@challenge, "signup_instructions_#{prompt_type}".to_sym).blank? %> +
    + <%=raw sanitize_field(@challenge, "signup_instructions_#{prompt_type}".to_sym) %> +
    + <% end %> + + <% @challenge_signup.send("#{prompt_type}").each_with_index do |prompt, index| %> + <%= error_messages_for(prompt).html_safe unless !prompt.errors.present? %> + <%= signup_form.fields_for prompt_type.to_sym, prompt do |prompt_form| %> + <%= render "prompts/prompt_form", :form => prompt_form, :index => index, :required => (index < @challenge.required(prompt_type)) %> + <% end %> + <% end %> + + <% if @challenge.allowed(prompt_type) > @challenge.required(prompt_type) %> +

    + <% linktext = ts("Add another %{type}? (Up to %{allowed} allowed.)", :type => prompt_type.singularize, :allowed => @challenge.allowed(prompt_type)) %> + <%= link_to_add_section(linktext, signup_form, prompt_type.to_sym, "prompts/prompt_form", :required => false) %> +

    + <% end %> + +
    + <% end # requests & offers section %> + + <%= submit_fieldset signup_form %> + +<% end %> diff --git a/app/views/challenge_signups/_signup_form_general_information.html.erb b/app/views/challenge_signups/_signup_form_general_information.html.erb new file mode 100755 index 0000000..42897cb --- /dev/null +++ b/app/views/challenge_signups/_signup_form_general_information.html.erb @@ -0,0 +1,21 @@ +

    <%= ts("Sign Up as") %> + <% if (form.object.new_record? || @challenge.allow_name_change?) && current_user.pseuds.size > 1 %> + <%= form.select :pseud_id, options_from_collection_for_select(current_user.pseuds, :id, :name, current_user.default_pseud.id) %> + <% else %> + <% pseud = form.object.new_record? ? current_user.default_pseud : form.object.pseud %> + + <%= form.hidden_field :pseud_id, :value => pseud.id %> + <% end %> +

    + +<% if @challenge.signup_instructions_general.present? %> +
    +

    <%= ts("General Sign Up Instructions") %>

    + <% if @challenge.signup_instructions_general %> +
    + <%=raw sanitize_field(@challenge, :signup_instructions_general) %> +
    + <% end %> +
    +<% end %> + diff --git a/app/views/challenge_signups/confirm_delete.html.erb b/app/views/challenge_signups/confirm_delete.html.erb new file mode 100644 index 0000000..37b5b60 --- /dev/null +++ b/app/views/challenge_signups/confirm_delete.html.erb @@ -0,0 +1,17 @@ + +

    <%= ts("Delete Sign-up") %>

    + + +<%= form_for(@challenge_signup, :url => collection_signup_path(@collection, @challenge_signup), :html => {:method => :delete, :class => "simple destroy"}) do |f| %> +

    <%= 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 100644 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) %>

    + + + + + + + + +<% end %> diff --git a/app/views/challenge_signups/index.xls.erb b/app/views/challenge_signups/index.xls.erb new file mode 100644 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 100644 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) %>

    + + + +<%= render "signup_controls", :challenge_signup => @challenge_signup, :subnav => true %> + + + +<%= render "show_requests", :challenge_signup => @challenge_signup %> +<%= render "show_offers", :challenge_signup => @challenge_signup %> + diff --git a/app/views/challenge_signups/summary.html.erb b/app/views/challenge_signups/summary.html.erb new file mode 100644 index 0000000..1c7c313 --- /dev/null +++ b/app/views/challenge_signups/summary.html.erb @@ -0,0 +1,13 @@ +<% if @generated_live %> + <% # render differently based on the challenge %> + <%= render :partial => "challenge/#{challenge_class_name(@collection)}/challenge_signups_summary", + :locals => {:challenge_collection => @collection, :tag_type => @tag_type, :summary_tags => @summary_tags, :generated_live => @generated_live} %> +<% elsif (cached_summary = @summary.cached_contents).present? %> + <%=raw cached_summary %> +<% else %> +

    <%= 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 100644 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 %> + +
    +

    + <%= link_to chapter.chapter_header, [chapter.work, chapter] %><% unless chapter.title.blank? %>: <%= chapter.title.html_safe %><% end %> +

    + + + <% if (!chapter.pseuds.blank? && (chapter.pseuds.sort != @work.pseuds.sort) && (!@work.anonymous?)) || @preview_mode %> + + <% end %> + + <% if (@work.number_of_posted_chapters > 1) || @work.chaptered? %> + <% unless chapter.summary.blank? %> +
    +

    <%= ts('Summary:') %>

    +
    + <%=raw sanitize_field(chapter, :summary) %> +
    +
    + <% end %> + + <% unless chapter.notes.blank? && chapter.endnotes.blank? %> +
    +

    <%=h ts('Notes:') %>

    + <% if chapter.notes.blank? %> +

    + (<%= ts("See the end of the chapter for ") %> <%= link_to(h(ts("notes")), "#chapter_#{chapter.position.to_s}_endnotes") %>.) +

    + <% else %> +
    <%=raw sanitize_field(chapter, :notes) %>
    + <% unless chapter.endnotes.blank? %> +

    + (<%= ts("See the end of the chapter for ") %> <%= link_to(h(ts("more notes")), "#chapter_#{chapter.position.to_s}_endnotes") %>.) +

    + <% end %> + <% end %> +
    + <% end %> + <% end %> +
    + + +
    +

    <%= ts("Chapter Text") %>

    + <%=raw sanitize_field(chapter, :content) %> +
    + + + <% unless chapter.endnotes.blank? %> +
    +
    +

    <%=h ts('Notes:') %>

    +
    + <%=raw sanitize_field(chapter, :endnotes) %> +
    +
    +
    + <% end %> + +
    + +<% end %> diff --git a/app/views/chapters/_chapter_form.html.erb b/app/views/chapters/_chapter_form.html.erb new file mode 100644 index 0000000..06167ad --- /dev/null +++ b/app/views/chapters/_chapter_form.html.erb @@ -0,0 +1,101 @@ +
    + <%= form_for([@work, chapter]) do |f| %> +

    <%= ts("* Required information") %>

    +
    + <%= ts("Name, Order and Date") %> +

    <%= ts("Name, Order and Date") %>

    +
    +
    <%= f.label :title, ts("Chapter Title") %> <%= link_to_help "chapter-title" %>
    +
    + <%= f.text_field :title, class: "observe_textlength" %> + <%= live_validation_for_field("chapter_title", presence: false, maximum_length: ArchiveConfig.TITLE_MAX) %> + <%= generate_countdown_html("chapter_title", ArchiveConfig.TITLE_MAX) %> +
    + +
    <%= f.label :position, ts("Chapter Number") %>
    +
    +

    + <%= f.text_field :position, class: "number" %> + <%= f.label :wip_length, ts("of"), title: ts("of total chapters") %> + <%= f.text_field :wip_length, class: "number" %> +

    +
    +
    <%= f.label :published_at, ts("Chapter Publication Date") %> <%= link_to_help "backdating-help" %>
    +
    <%= f.date_select "published_at", start_year: Date.current.year, end_year: 1950, default: @work.default_date, order: [:day, :month, :year] %>
    +
    +
    + +
    + <%= ts("Chapter Preface") %> +

    <%= ts("Chapter Preface") %>

    +
    + <%= render "pseuds/byline", form: f, object: @chapter %> + +
    <%= f.label :summary, ts("Chapter Summary") %>
    +
    + <%= f.text_area :summary, rows: 4, cols: 60, class: "observe_textlength" %> + <%= live_validation_for_field("chapter_summary", presence: false, maximum_length: ArchiveConfig.SUMMARY_MAX) %> + <%= generate_countdown_html("chapter_summary", ArchiveConfig.SUMMARY_MAX) %> +
    + + <%= render "works/notes_form", f: f, type: "chapter" %> + +
    +
    + +
    + <%= ts("Chapter Text") + "*" %> +

    <%= ts("Chapter Text") %>

    + +

    + <%= allowed_html_instructions %> + +

    +
    + <%= 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("Post Chapter") %> +

    + <%= t(".post_notice_html", + content_policy_link: link_to(t(".content_policy"), content_path), + tos_faq_link: link_to(t(".tos_faq"), tos_faq_path(anchor: "content_faq"))) %> +

    +
      + <% unless @chapter.new_record? || @chapter.posted? %> +
    • <%= submit_tag ts("Save As Draft"), name: "save_button" %>
    • + <% end %> +
    • <%= submit_tag ts("Preview"), name: "preview_button" %>
    • +
    • <%= submit_tag ts("Post"), name: "post_without_preview_button", data: { disable_with: ts("Please wait...") } %>
    • +
    • <%= submit_tag ts("Cancel"), name: "cancel_button" %>
    • +
    +
    + + <% end # form_for %> +
    + +<%= 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 %> + + <% use_tinymce %> +<% end %> diff --git a/app/views/chapters/_chapter_management.html.erb b/app/views/chapters/_chapter_management.html.erb new file mode 100644 index 0000000..323e78c --- /dev/null +++ b/app/views/chapters/_chapter_management.html.erb @@ -0,0 +1,11 @@ +

    <%= ts("Chapter Management") %>

    + diff --git a/app/views/chapters/_hidden_fields.html.erb b/app/views/chapters/_hidden_fields.html.erb new file mode 100644 index 0000000..ff47fb4 --- /dev/null +++ b/app/views/chapters/_hidden_fields.html.erb @@ -0,0 +1,24 @@ + + + +<%= form.fields_for :author_attributes do |creator_form| %> + <% (@chapter.current_user_pseuds || []).each do |pseud| %> + <%= creator_form.hidden_field :ids, value: pseud.id, multiple: true %> + <% end %> + + <% @chapter.creatorships.each do |creatorship| %> + <% if creatorship.new_record? %> + <%= creator_form.hidden_field :coauthors, value: creatorship.pseud_id, multiple: true %> + <% end %> + <% end %> +<% end %> + +<%= form.hidden_field :title, :value => "#{@chapter.title}" %> +<%= form.hidden_field :summary, :value => "#{@chapter.summary}" %> +<%= form.hidden_field :notes, :value => "#{@chapter.notes}" %> +<%= form.hidden_field :endnotes, :value => "#{@chapter.endnotes}" %> +<%= form.hidden_field :content, :value => "#{@chapter.content}" %> + +<%= form.hidden_field :wip_length, :value => "#{@chapter.wip_length}" %> +<%= form.hidden_field :position, :value => "#{@chapter.position}" %> +<%= form.hidden_field :published_at, :value => "#{@chapter.published_at}" %>

    diff --git a/app/views/chapters/confirm_delete.html.erb b/app/views/chapters/confirm_delete.html.erb new file mode 100644 index 0000000..df49148 --- /dev/null +++ b/app/views/chapters/confirm_delete.html.erb @@ -0,0 +1,12 @@ + +

    <%= ts("Delete Chapter") %>

    + + +<%= form_for(@chapter, :html => {:method => :delete, :class => "simple destroy"}) do |f| %> +

    <%= 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 100644 index 0000000..0002d9e --- /dev/null +++ b/app/views/chapters/edit.html.erb @@ -0,0 +1,27 @@ + + +

    <%= ts("Edit Chapter") %>

    + +<%= error_messages_for :chapter %> + + + + + + + + +<%= render 'chapter_form', chapter: @chapter %> + diff --git a/app/views/chapters/manage.html.erb b/app/views/chapters/manage.html.erb new file mode 100644 index 0000000..94d031a --- /dev/null +++ b/app/views/chapters/manage.html.erb @@ -0,0 +1,77 @@ + +

    <%= ts('Manage Chapters') %>

    + + + + + + +
    + +

    <%= ts("Drag chapters to change their order.") %>

    +

    <%= ts("Enter new chapter numbers.") %>

    + + <%= form_tag url_for(action: 'update_positions') do %> + +

      + <% for chapter in @chapters %> +
    • + + <%= text_field_tag 'chapters[]', nil, + size: 3, + maxlength: 3, + class: "number chapter-position-field", + id: "chapters_" + chapter.position.to_s %> + <%= chapter.position %>. + <%= chapter.chapter_title.html_safe %> + <% if !chapter.posted %>(Draft)<% end %> + +
        +
      • <%= link_to ts("Edit"), [:edit, @work, chapter] %>
      • + <% if @work.chapters.count > 1 %> +
      • + <%= link_to ts("Delete"), [@work, chapter], + data: { confirm: ts("Are you sure?") }, + method: :delete %> +
      • + <% if @work.pseuds.size > 1 && chapter.pseuds.size > 1 && current_user.is_author_of?(chapter) %> +
      • + <%= link_to ts("Remove Me As Chapter Co-Creator"), + {action: "edit", id: chapter.id, remove: "me"}, + data: { confirm: ts("Are you sure you want to remove yourself as a co-creator of this chapter?") } %> +
      • + <% end %> + <% end %> +
      + +
    • + <% end %> +
    + +

    + <%= submit_tag ts("Update Positions") %> + <%= link_to ts("Back"), url_for(@work) %> +

    + + <% end %> +
    + +<%= content_for :footer_js do %> + <%= javascript_tag do %> + $j("#sortable_chapter_list").sortable({ + delay: 300, + update: function(event, ui) { + $j(".chapter-position-list").each(function(index, li){ + var chapterId = $j(li).attr("id").replace("chapter_",""); + $j("#position-for-"+chapterId).html(index+1); + }); + $j.ajax({ + type: 'post', + data: $j("#sortable_chapter_list").sortable("serialize") + "&work_id=<%= @work.id %>", + dataType: 'script', + url: "<%= url_for(:action => :update_positions) %>"}) + } + }) + <% end %> +<% end %> + diff --git a/app/views/chapters/new.html.erb b/app/views/chapters/new.html.erb new file mode 100644 index 0000000..3376025 --- /dev/null +++ b/app/views/chapters/new.html.erb @@ -0,0 +1,14 @@ + +

    <%= ts('Post New Chapter') %>

    +

    <%= @work.title %>

    + +<%= error_messages_for :chapter %> + + + + + + +<%= render 'chapter_form', :chapter => @chapter %> + + diff --git a/app/views/chapters/preview.html.erb b/app/views/chapters/preview.html.erb new file mode 100644 index 0000000..5f00278 --- /dev/null +++ b/app/views/chapters/preview.html.erb @@ -0,0 +1,47 @@ + +

    <%= ts("Preview") %>

    +<%= error_messages_for :chapter %> + + +<% if @work.work_skin && !Preference.disable_work_skin?(params[:style]) %> + <% cache("#{@work.work_skin.id}-#{@work.work_skin.updated_at}-work-skin", skip_digest: true) do %> + <%= render "skins/skin_style_block", skin: @work.work_skin %> + <% end %> +<% end %> + +
    +
    + <%= render @chapter %> +
    +
    + +<%= form_for([@work, @chapter]) do |f| %> + + <%= render "hidden_fields", form: f %> + +
    + <%= ts("Post Chapter") %> +

    + <%= t(".post_notice_html", + content_policy_link: link_to(t(".content_policy"), content_path), + tos_faq_link: link_to(t(".tos_faq"), tos_faq_path(anchor: "content_faq"))) %> +

    +
      + <% if @chapter.posted? %> +
    • <%= submit_tag ts("Update"), name: "update_button" %>
    • + <% else %> +
    • + <%= submit_tag ts("Post"), + name: "post_button", + data: { disable_with: ts("Please wait...") } %> +
    • +
    • + <%= submit_tag ts("Save As Draft"), name: "save_button" %> +
    • + <% end %> +
    • <%= submit_tag ts("Edit"), name: "edit_button" %>
    • +
    • <%= submit_tag ts("Cancel"), name: "cancel_button" %>
    • +
    +
    + +<% end %> diff --git a/app/views/chapters/show.html.erb b/app/views/chapters/show.html.erb new file mode 100644 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) %> + +

     

    + <%= render :partial => 'works/work_header' %> + +
    + <%= render :partial => 'chapters/chapter', :locals => { :chapter => @chapter } %> +
    + + <% inspired_by = get_inspired_by(@work) %> + <% last_chapter = @work.posted? ? @work.last_posted_chapter : @work.last_chapter %> + <% if @chapter == last_chapter && (@work.endnotes.present? || @work.series.present? || inspired_by.present?) %> + +
    + <% if !@work.endnotes.blank? %> + <%= render :partial => 'works/work_endnotes' %> + <% end %> + <% unless @preview_mode || @work.series.blank? %> + <%= render :partial => 'works/work_series_links' %> + <% end %> + <% unless inspired_by.empty? %> + <%= render :partial => 'works/work_approved_children', :locals => {:inspired_by => inspired_by} %> + <% end %> +
    + + <% end %> + +
    + + + + + + <%= render :partial => 'comments/commentable', :locals => {:commentable => @chapter} %> + + + <% if @next_chapter %> + + <% end %> +<% end %> + +<%= render "hit_count/include" %> diff --git a/app/views/collectibles/_collectible_form.html.erb b/app/views/collectibles/_collectible_form.html.erb new file mode 100644 index 0000000..075850a --- /dev/null +++ b/app/views/collectibles/_collectible_form.html.erb @@ -0,0 +1,17 @@ +<% # expects 'form' and any of 'collectibles', 'collectible' or '@collectible which can be 1 or more collectibles %> + +<% collectibles = [collectible] if (!collectibles && collectible) %> +<% collectibles = [@collectible] if (!collectibles && @collectible) %> + +<% unless (collections = collectibles.collect(&:collections).flatten.uniq).empty? %> +
    <%= form.label :collections_to_remove, ts("Current Collections") %>
    +
    +

    <%= ts("Check to remove") %>

    + <%= checkbox_section(form, :collections_to_remove, collections, :name_method => "title") %> +
    +<% end %> + +
    <%= form.label :collections_to_add, ts("Add to collections") %> <%= link_to_help "add-collectible-to-collection" %>
    +
    + <%= form.text_field :collections_to_add, autocomplete_options("open_collection_names") %> +
    diff --git a/app/views/collection_items/_collection_item_controls.html.erb b/app/views/collection_items/_collection_item_controls.html.erb new file mode 100644 index 0000000..856ca98 --- /dev/null +++ b/app/views/collection_items/_collection_item_controls.html.erb @@ -0,0 +1,75 @@ +<% # expects collection_item, form %> +
      + <% if collection_item.item %> +
    • + <% # TODO: AO3-6508 Revamp this so we don't have two useless links showing when JavaScript is disabled. %> + <% id = "blurb_#{collection_item.item_type}_#{collection_item.item_id}_#{collection_item.collection.name}" %> + <%= link_to h(ts("Details")) + ' ↓'.html_safe, "#", :class => "#{id}_open" %> + <%= link_to h(ts("Close Details")) + ' ↑'.html_safe, "#", :class => "#{id}_close" %> +
    • + <% end %> + +
    • + <%= form.label(:user_approval_status, + options = {}, + html_options = { class: @collection ? "disabled" : nil }) do %> + <%= collection_item_approval_options_label( + actor: "user", + item_type: collection_item.item_type) %> + <%= form.select(:user_approval_status, + collection_item_approval_options( + actor: "user", + item_type: collection_item.item_type), + options = {}, + html_options = { disabled: @collection ? true : nil }) %> + <% end %> +
    • + +
    • + <%= form.label(:collection_approval_status, + options = {}, + html_options = { class: @user ? "disabled" : nil }) do %> + <%= collection_item_approval_options_label( + actor: "collection", + item_type: collection_item.item_type) %> + <%= form.select(:collection_approval_status, + collection_item_approval_options( + actor: "collection", + item_type: collection_item.item_type), + options = {}, + html_options = { disabled: @user ? true : nil }) %> + <% end %> +
    • + + <% if collection_item.collection.unrevealed? %> +
    • + <%= form.label(:unrevealed, + options = {}, + html_options = { class: @user ? "disabled" : nil }) do %> + <%= form.check_box :unrevealed, disabled: @user %> + <%= ts("Unrevealed") %> + <% end %> +
    • + <% end %> + + <% if collection_item.collection.anonymous? %> +
    • + <%= form.label(:anonymous, + options = {}, + html_options = { class: @user ? "disabled" : nil }) do %> + <%= form.check_box :anonymous, disabled: @user %> + <%= ts("Anonymous") %> + <% end %> +
    • + <% end %> + +
    • + <% disable_destroy = !collection_item.user_allowed_to_destroy?(@current_user) %> + <%= form.label(:remove, + options = {}, + html_options = { class: disable_destroy ? "disabled" : nil }) do %> + <%= form.check_box :remove, disabled: disable_destroy %> + <%= ts("Remove") %> + <% end %> +
    • +
    diff --git a/app/views/collection_items/_collection_item_form.html.erb b/app/views/collection_items/_collection_item_form.html.erb new file mode 100644 index 0000000..b8bd75e --- /dev/null +++ b/app/views/collection_items/_collection_item_form.html.erb @@ -0,0 +1,42 @@ +<% item ||= @item %> +<% @collection_item ||= CollectionItem.new %> +<% in_page ||= false %> +
    + <% if item.is_a?(Work) %> +

    + <% if current_user.archivist %> + <%= t(".add_work_header_html", title: item.title) %> + <% else %> + <%= t(".invite_header_html", title: item.title) %> + <% end %> +

    + <% else %> +

    <%= t(".add_bookmark_header") %>

    + <% 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| %> +
    +
    <%= label_tag :collection_names, ts("Collection name(s): ") %>
    +
    + <%= text_field_tag :collection_names, nil, autocomplete_options("open_collection_names", :size => 40) %> +
    +
    <%= ts("Submit") %>
    +
    + <% if item.is_a?(Work) && !current_user.archivist %> + <%= form.submit(t(".invite")) %> + <% else %> + <%= form.submit(t(".add")) %> + <% end %> + <% if in_page %> + <%= ts("Cancel") %> + <% else %> + <%= link_to ts("Back"), polymorphic_path(item) %> + <% end %> +
    +
    + <% end %> +
    diff --git a/app/views/collection_items/_item_fields.html.erb b/app/views/collection_items/_item_fields.html.erb new file mode 100755 index 0000000..2868382 --- /dev/null +++ b/app/views/collection_items/_item_fields.html.erb @@ -0,0 +1,62 @@ +
  • + <%= fields_for("collection_items[]", collection_item) do |form| %> +
    + +

    + <%= error_messages_for collection_item %> + <%= link_to collection_item_display_title(collection_item), collection_item.item %> +

    + +
    + <% if @user %> + <% # user version %> + <%= ts("in") %> <%= link_to collection_item.collection.title, collection_path(collection_item.collection) %> + <% if collection_item.collection.user_is_posting_participant?(@user) %> + <%= ts("(Member)") %> + <% end %> + <% else %> + <% # mod version %> + <% collection_item.item_creator_pseuds.each do |pseud| %> + <%= pseud.byline %> + <% if collection_item.collection.user_is_posting_participant?(pseud.user) %> + <%= ts("(Member)")%> + <% end %> + <% end %> + <% # TODO: Make recipients and bylines links, separate recipients with space (currently "snow,astolat") %> + <% unless collection_item.recipients.blank? %> + <%= ts("for") %> <%= collection_item.recipients %> + <% end %> + <% end %> +
    + + <% if collection_item.item %> +

    <%= collection_item.item_date.to_date %>

    + <% end %> +
    + <%= collection_icon_display(collection_item.collection) %> +
    +
    + + <% 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}" %> +
    + <% if collection_item.item_type == 'Work' %> + <%= render "works/work_module", work: collection_item.item, is_unrevealed: false %> + <% elsif collection_item.item_type == 'Bookmark' %> + <% bookmark = collection_item.item %> + <% bookmarkable = bookmark.bookmarkable %> + <% if bookmarkable.blank? %> +

    <%= ts("This has been deleted, sorry!") %>

    + <% else %> + <%= render 'bookmarks/bookmark_item_module', bookmarkable: bookmarkable %> + <% end %> + <%= render 'bookmarks/bookmark_user_module', bookmark: bookmark %> + <% end %> +
    + <% end %> + + + <%= render 'collection_item_controls', collection_item: collection_item, form: form %> + <% end %> +
  • diff --git a/app/views/collection_items/index.html.erb b/app/views/collection_items/index.html.erb new file mode 100755 index 0000000..8118603 --- /dev/null +++ b/app/views/collection_items/index.html.erb @@ -0,0 +1,50 @@ + +

    + <% if @user %> + <%= t(".user.page_heading", username: @user.login) %> + <% else %> + <%= t(".collection.page_heading_html", collection_link: link_to(@collection.title, @collection)) %> + <% end %> +

    + + + + + +<% if @collection && params[:status] == "unreviewed_by_user" %> +

    <%= t(".collection.notice.unreviewed_by_user") %>

    +<% end %> +<% if @user && params[:status] == "unreviewed_by_collection" %> +

    <%= t(".user.notice.unreviewed_by_collection") %>

    +<% end %> +<% if @collection_items.count < 1 %> +

    <%= t(".no_items") %>

    +<% else %> + <%= form_tag (@user ? update_multiple_user_collection_items_path(@user) : update_multiple_collection_items_path(@collection)), method: :patch do %> +
      + <% @collection_items.each do |collection_item| %> + <%= render "item_fields", collection_item: collection_item %> + <% end %> +
    + <%= submit_fieldset %> + <% end %> + + <%= will_paginate @collection_items %> +<% end %> + diff --git a/app/views/collection_items/new.html.erb b/app/views/collection_items/new.html.erb new file mode 100644 index 0000000..ece1d4a --- /dev/null +++ b/app/views/collection_items/new.html.erb @@ -0,0 +1,12 @@ + +

    <%= ts("Add To Collection") %>

    + +<%= error_messages_for :collection_item %> + + + + + + +<%= render 'collection_item_form', :item => @item %> + diff --git a/app/views/collection_mailer/item_added_notification.html.erb b/app/views/collection_mailer/item_added_notification.html.erb new file mode 100644 index 0000000..749e9e7 --- /dev/null +++ b/app/views/collection_mailer/item_added_notification.html.erb @@ -0,0 +1,21 @@ +<% content_for :message do %> + + <% pseuds = @creation.is_a?(Work) ? @creation.pseuds.map{|p| style_pseud_link(p)}.to_sentence.html_safe : @creation.pseud.name %> + + <%= @creation.is_a?(Work) ? @creation.pseuds.length > 0 ? pseuds : style_bold(@creation.authors_to_sort_on) : pseuds %> posted a + <%= @creation.is_a?(Work) ? @creation.backdate ? "backdated " : "new " : "new " %> <%= @creation.is_a?(Work) ? "work" : "bookmark" %> to your collection, + <%= style_link(@collection.name, collection_url(@collection)) %>: + +
    + <% if @creation.is_a?(Work) %> + <%= style_link(@creation.title.html_safe, work_url(@creation)) %> + <% else %> + <%= style_link("A bookmark of: " + @creation.bookmarkable.title.html_safe, bookmark_url(@creation)) %> + <% end %> + + +<% end %> + +<% content_for :footer_note do %> + You're receiving this email because you've chosen to be notified when new items are added to your collection <%= style_footer_link(@collection.name, collection_url(@collection)) %>. Follow the link to change settings if you no longer wish to receive updates. +<% end %> \ No newline at end of file diff --git a/app/views/collection_mailer/item_added_notification.text.erb b/app/views/collection_mailer/item_added_notification.text.erb new file mode 100644 index 0000000..cc8b5fb --- /dev/null +++ b/app/views/collection_mailer/item_added_notification.text.erb @@ -0,0 +1,18 @@ +<% content_for :message do %> +<% pseuds = @creation.is_a?(Work) ? @creation.pseuds.map{|p| text_pseud(p)}.to_sentence.html_safe : @creation.pseud.name %> + + +<%= @creation.is_a?(Work) ? @creation.pseuds.length > 0 ? pseuds : @creation.authors_to_sort_on : pseuds %> posted a <%= @creation.is_a?(Work) ? @creation.backdate ? "backdated " : "new " : "new " %> <%= @creation.is_a?(Work) ? "work" : "bookmark" %> to your collection, "<%= @collection.name %>" (<%= collection_url(@collection) %>: + +<% if @creation.is_a?(Work) %> +"<%= @creation.title.html_safe %>" +<%= work_url(@creation) %> +<% else %> +<%= "A bookmark of: " + @creation.bookmarkable.title.html_safe %> +<%= bookmark_url(@creation) %> +<% end %> +<% end %> + +<% content_for :footer_note do %> +You're receiving this email because you've chosen to be notified when new items are added to your collection "<%= @collection.name %>" (<%= collection_url(@collection) %>). Follow the link to change settings if you no longer wish to receive updates. +<% end %> \ No newline at end of file diff --git a/app/views/collection_participants/_add_participants_form.html.erb b/app/views/collection_participants/_add_participants_form.html.erb new file mode 100644 index 0000000..bc09031 --- /dev/null +++ b/app/views/collection_participants/_add_participants_form.html.erb @@ -0,0 +1,10 @@ +<%= form_tag add_collection_participants_path(@collection), class: "single simple post", method: :get do %> +
    + Add new members +

    + <%= label_tag :participants_to_invite, ts("Add new members"), class: "landmark" %> + <%= text_field_tag :participants_to_invite, nil, autocomplete_options("pseud") %> + <%= submit_tag ts("Submit") %> +

    +
    +<% end %> diff --git a/app/views/collection_participants/_participant_form.html.erb b/app/views/collection_participants/_participant_form.html.erb new file mode 100644 index 0000000..3d9a551 --- /dev/null +++ b/app/views/collection_participants/_participant_form.html.erb @@ -0,0 +1,19 @@ + + +<% if participant %> +
  • + <%= form_for(participant, url: collection_participant_path(@collection, participant), as: :collection_participant) do |form| %> + + + +
  • +<% end %> diff --git a/app/views/collection_participants/index.html.erb b/app/views/collection_participants/index.html.erb new file mode 100644 index 0000000..4fb615c --- /dev/null +++ b/app/views/collection_participants/index.html.erb @@ -0,0 +1,10 @@ + +

    <%= ts("Members of %{title}", title: @collection.title) %>

    + + +<%= render partial: "add_participants_form" %> + +

    <%= ts("Listing Collection Members") %>

    +
      + <%= render partial: "participant_form", collection: @collection_participants, as: :participant %> +
    diff --git a/app/views/collection_profile/_navigation.html.erb b/app/views/collection_profile/_navigation.html.erb new file mode 100644 index 0000000..8085d1c --- /dev/null +++ b/app/views/collection_profile/_navigation.html.erb @@ -0,0 +1,14 @@ +

    <%= ts("Navigation") %>

    +<% if show_collection_profile_navigation(@collection, section) %> + +<% 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 100644 index 0000000..730805a --- /dev/null +++ b/app/views/collection_profile/show.html.erb @@ -0,0 +1,112 @@ +
    + <%= render 'collections/header', :collection => @collection %> + +

    + <%= ts('About') %> + <%= @collection.title %> + <% if @collection.title != @collection.name %> + + (<%= @collection.name %>) + + <% end %> +

    +
    +
    +
    <%= ts('Active since:') %> +
    <%= l(@collection.created_at.to_date) %> +
    <%= ts('Maintainers:') %>
    +
    +
      + <%= (@collection.all_owners + @collection.all_moderators).map {|owner| content_tag(:li, link_to(owner.byline, owner.user))}.join("\n").html_safe %> +
    +
    + <% unless @collection.email.blank? %> +
    <%= ts('Contact:') %>
    +
    <%= @collection.email %>
    + <% end %> + <% if @collection.challenge.present? %> + <% tag_sets_counted = tag_set_count(@collection) %> + <% if tag_sets_counted.present? %> +
    <%= tag_sets_counted > 1 ? ts('Tag Sets:') : ts('Tag Set:') %>
    +
    +
      + <%= (@collection.challenge_type == "GiftExchange" ? @collection.challenge.offer_restriction.owned_tag_sets : @collection.challenge.request_restriction.owned_tag_sets).map {|tag_set| content_tag(:li, link_to(tag_set.title, tag_set_path(tag_set)))}.join("\n").html_safe %> +
    +
    + <% end %> + <% end %> + <% if @collection.challenge %> + + <%= render "challenge/#{challenge_class_name(@collection)}/challenge_meta" %> + + <% end %> +
    +
    + + + <% cache("collection-profile-#{@collection.id}", skip_digest: true) do %> + + + + <% if show_collection_preface(@collection) %> +
    + <% if show_collection_section(@collection, "intro") %> +
    + <%= render 'navigation', :section => 'intro' %> +

    <%= ts('Intro:') %>

    +
    + <%=raw sanitize_field(@collection.collection_profile.intro.blank? ? @collection.parent.collection_profile : @collection.collection_profile, :intro) %> +
    +
    + <% end %> + + <% if show_collection_section(@collection, "faq") %> +
    + <%= render 'navigation', :section => 'faq' %> +

    <%= ts('FAQ:') %>

    +
    + <%=raw sanitize_field(@collection.collection_profile.faq.blank? ? @collection.parent.collection_profile : @collection.collection_profile, :faq) %> +
    +
    + <% end %> + + <% if show_collection_section(@collection, "rules") %> +
    + <%= render 'navigation', :section => 'rules' %> +

    <%= ts('Rules:') %>

    +
    + <%=raw sanitize_field(@collection.collection_profile.rules.blank? ? @collection.parent.collection_profile : @collection.collection_profile, :rules) %> +
    +
    + <% end %> +
    + <% end %> + + <% end %> + + + <% if @collection.user_is_owner?(current_user) || @collection.user_is_maintainer?(current_user) %> +

    <%= ts('Actions') %>

    + + <% end %> + + +
    diff --git a/app/views/collections/_bookmarks_module.html.erb b/app/views/collections/_bookmarks_module.html.erb new file mode 100644 index 0000000..c85c52f --- /dev/null +++ b/app/views/collections/_bookmarks_module.html.erb @@ -0,0 +1,17 @@ +
    +

    + <% if collection.collection_preference.show_random || params[:show_random] %> + <%= ts("Random bookmarks") %> + <% else %> + <%= ts("Recent bookmarks") %> + <% end %> +

    +
      + <% for bookmark in bookmarks %> + <%= render 'bookmarks/bookmark_blurb', :bookmark => bookmark %> + <% end %> +
    + <% if bookmarks.total_entries > ArchiveConfig.NUMBER_OF_ITEMS_VISIBLE_IN_DASHBOARD %> + + <% end %> +
    diff --git a/app/views/collections/_challenge_collections.html.erb b/app/views/collections/_challenge_collections.html.erb new file mode 100644 index 0000000..830fd98 --- /dev/null +++ b/app/views/collections/_challenge_collections.html.erb @@ -0,0 +1,12 @@ +<% if @challenge_collections.blank? %> +

    <%= ts("There are no challenges currently open for sign-ups in the archive.") %>

    +<% else %> +

    <%= ts("The following challenges are currently open for sign-ups! Those closing soonest are at the top.") %>

    + +

    <%= ts("List of Collections") %>

    +
      + <% @challenge_collections.each do |collection| %> + <%= render :partial => "collection_blurb", :locals => {:collection => collection} %> + <% end %> +
    +<% 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 100644 index 0000000..5e499ce --- /dev/null +++ b/app/views/collections/_challenge_list_top_navigation.html.erb @@ -0,0 +1,7 @@ +

    <%= ts("Navigation") %>

    + \ 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 100644 index 0000000..5fc67b1 --- /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 %> +
    +

    + <%= link_to collection.title, collection_path(collection) %> + (<%= collection.name %>) + <%= ts('by') %> + <%= collection.all_owners.map {|owner| link_to(owner.byline, owner.user)}.join(", ").html_safe %> +

    + +
    + <%= collection_icon_display(collection) %> +
    +

    <%= set_format_for_date(collection.updated_at) %>

    + <% if collection.all_moderators.length > 0%> +
    <%= ts("Mods") %>
    +
      + <%= collection.all_moderators.map {|mod| content_tag(:li, link_to(mod.byline, mod.user))}.join("\n").html_safe %> +
    + <% end %> +
    + + +
    <%= ts("Summary") %>
    +
    + <%=raw strip_images(sanitize_field(collection, :description)) || " ".html_safe %> + + <% if collection.challenge && collection.challenge.signup_open %> +

    <%= ts("Sign-ups close at:") %> <%= time_in_zone(collection.challenge.signups_close_at, (collection.challenge.time_zone || Time.zone.name)) %>

    + <% end %> +
    + +

    + (<%= collection.closed? ? ts("Closed") : ts("Open") %>, <%= collection.moderated? ? ts("Moderated") : ts("Unmoderated") %><%= collection.unrevealed? ? ts(", Unrevealed") : "" %><%= collection.anonymous? ? ts(", Anonymous") : "" %><%= collection.gift_exchange? ? ts(", Gift Exchange Challenge") : "" %><%= collection.prompt_meme? ? ts(", Prompt Meme Challenge") : "" %>) +

    + + +
    + <% if (@challenges_count = collection.children.count) > 0 %> +
    <%= ts("Challenges/Subcollections:") %>
    +
    <%= link_to(@challenges_count, collection_collections_path(collection)) %>
    + <% end %> + <% if (@works_count = SearchCounts.work_count_for_collection(collection)) > 0 %> +
    <%= ts("Fandoms:") %>
    +
    <%= link_to(SearchCounts.fandom_count_for_collection(collection), collection_fandoms_path(collection)) %>
    + <% end %> + <% if collection.challenge? && collection.prompt_meme? %> +
    <%= ts("Prompts:") %>
    +
    <%= link_to(collection.prompts.count.to_s, collection_requests_path(collection)) %>
    + <% end %> +
    <%= ts("Works:") %>
    +
    <%= link_to(@works_count, collection_works_path(collection)) %>
    + <% if (@bookmarks_count = SearchCounts.bookmarkable_count_for_collection(collection)) > 0 %> +
    <%= ts("Bookmarked Items:") %>
    +
    <%= link_to(@bookmarks_count, collection_bookmarks_path(collection)) %>
    + <% end %> +
    + + <% end %> + + <% if collection.user_is_owner?(current_user) || (collection.challenge && collection.challenge.signup_open && logged_in?) || (collection.moderated? && logged_in?) %> +
    <%= ts("User Actions") %>
    + + <% end %> +
  • + diff --git a/app/views/collections/_collection_form_delete.html.erb b/app/views/collections/_collection_form_delete.html.erb new file mode 100644 index 0000000..eb9c2b8 --- /dev/null +++ b/app/views/collections/_collection_form_delete.html.erb @@ -0,0 +1,6 @@ + diff --git a/app/views/collections/_collection_profile_navigation.html.erb b/app/views/collections/_collection_profile_navigation.html.erb new file mode 100644 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) %> + +<% end %> \ No newline at end of file diff --git a/app/views/collections/_filters.html.erb b/app/views/collections/_filters.html.erb new file mode 100644 index 0000000..5e3c226 --- /dev/null +++ b/app/views/collections/_filters.html.erb @@ -0,0 +1,151 @@ +<%= form_tag collections_path, method: :get, class: "narrow-hidden filters", + id: "collection-filters" do %> +

    <%= ts("Filters") %>

    + <%= field_set_tag ts("Filter collections:") do %> +
    +
    <%= ts("Sort and Filter") %>
    +
    <%= submit_tag ts("Sort and Filter") %>
    +
    + <%= label_tag :sort_column, ts("Sort by") %> +
    +
    + <%= select_tag :sort_column, + options_for_select({ + ts("Title") => "collections.title", + ts("Date Created") => "collections.created_at" }, + params[:sort_column]) %> +
    +
    + <%= label_tag :sort_direction, ts("Sort direction") %> +
    +
    + <%= select_tag :sort_direction, + options_for_select({ + ts("Ascending") => "ASC", + ts("Descending") => "DESC" }, + params[:sort_direction]) %> +
    + + + + + +
    <%= ts("Closed") %>
    +
    +
      +
    • + <%= label_tag "collection_filters_closed_true" do %> + <%= radio_button_tag "collection_filters[closed]", true, + params[:collection_filters][:closed] == "true" %> + <%= label_indicator_and_text(ts("Yes")) %> + <% end %> +
    • +
    • + <%= label_tag "collection_filters_closed_false" do %> + <%= radio_button_tag "collection_filters[closed]", false, + params[:collection_filters][:closed] == "false" %> + <%= label_indicator_and_text(ts("No")) %> + <% end %> +
    • +
    • + <%= label_tag "collection_filters_closed_" do %> + <%= radio_button_tag "collection_filters[closed]", "", + params[:collection_filters][:closed] == "" %> + <%= label_indicator_and_text(ts("Either")) %> + <% end %> +
    • +
    +
    +
    <%= ts("Moderated") %>
    +
    +
      +
    • + <%= label_tag "collection_filters_moderated_true" do %> + <%= radio_button_tag "collection_filters[moderated]", true, + params[:collection_filters][:moderated] == "true" %> + <%= label_indicator_and_text(ts("Yes")) %> + <% end %> +
    • +
    • + <%= label_tag "collection_filters_moderated_false" do %> + <%= radio_button_tag "collection_filters[moderated]", false, + params[:collection_filters][:moderated] == "false" %> + <%= label_indicator_and_text(ts("No")) %> + <% end %> +
    • +
    • + <%= label_tag "collection_filters_moderated_" do %> + <%= radio_button_tag "collection_filters[moderated]", "", + params[:collection_filters][:moderated] == "" %> + <%= label_indicator_and_text(ts("Either")) %> + <% end %> +
    • +
    +
    +
    <%= ts("Collection Type") %>
    +
    +
      +
    • + <%= label_tag "collection_filters_challenge_type_gift_exchange" do %> + <%= radio_button_tag "collection_filters[challenge_type]", + "gift_exchange", + params[:collection_filters][:challenge_type] == "gift_exchange" + %> + <%= label_indicator_and_text(ts("Gift Exchange Challenge")) %> + <% end %> +
    • +
    • + <%= label_tag "collection_filters_challenge_type_prompt_meme" do %> + <%= radio_button_tag "collection_filters[challenge_type]", + "prompt_meme", + params[:collection_filters][:challenge_type] == "prompt_meme" + %> + <%= label_indicator_and_text(ts("Prompt Meme Challenge")) %> + <% end %> +
    • +
    • + <%= label_tag "collection_filters_challenge_type_no_challenge" do %> + <%= radio_button_tag "collection_filters[challenge_type]", + "no_challenge", + params[:collection_filters][:challenge_type] == "no_challenge" + %> + <%= label_indicator_and_text(ts("No Challenge")) %> + <% end %> +
    • +
    • + <%= label_tag "collection_filters_challenge_type_" do %> + <%= radio_button_tag "collection_filters[challenge_type]", "", + params[:collection_filters][:challenge_type] == "" %> + <%= label_indicator_and_text(ts("Any")) %> + <% end %> +
    • +
    +
    +
    <%= ts("Submit") %>
    +
    <%= submit_tag ts("Sort and Filter") %>
    +
    +

    + <%= link_to ts("Clear Filters"), collections_path %> +

    + <% end %> + <% # On narrow screens, link jumps to top of index when JavaScript is disabled and closes filters when JavaScript is enabled %> + +<% end %> diff --git a/app/views/collections/_form.html.erb b/app/views/collections/_form.html.erb new file mode 100644 index 0000000..07055c3 --- /dev/null +++ b/app/views/collections/_form.html.erb @@ -0,0 +1,250 @@ +<%= error_messages_for :collection %> +<%= form_for(@collection, html: { multipart: true, class: "verbose post collection" }) do |collection_form| %> +

    * <%= ts('Required information') %>

    +
    + <%= ts("Header") %> +
    + + <% if @collection.new_record? && current_user.pseuds.size > 1 %> +
    + <%= label_tag "owner_pseuds[]", ts("Owner pseud(s)") %> +
    +
    + <%= select_tag "owner_pseuds[]", options_from_collection_for_select(current_user.pseuds, :id, :name, current_user.default_pseud_id), multiple: true %> +
    + <% else %> +

    <%= hidden_field_tag "owner_pseuds[]", [current_user.default_pseud.id] %>

    + <% end %> + +
    + <%= collection_form.label :name, ts("Collection name" + "*") %> + <%= link_to_help "collection-name" %> +
    +
    + <%= collection_form.text_field :name, "aria-describedby" => "name-field-notes" %> +

    + <%= 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) %> +

    +
    + +
    + <%= collection_form.label :title, ts("Display title") + "*" %> +
    +
    + <%= collection_form.text_field :title, "aria-describedby" => "title-field-notes" %> +

    + <%= ts("(text only)") %> +

    +
    + + <% if @collection.children.empty? %> +
    + <%= collection_form.label :parent_name, ts("Parent collection (that you maintain)") %> +
    +
    + <%= collection_form.text_field :parent_name, autocomplete_options("collection_parent_name", data: { autocomplete_token_limit: 1 }) %> +
    + <% end %> + +
    <%= collection_form.label :email, ts("Collection email") %>
    +
    <%= collection_form.text_field :email, size: 40 %>
    + +
    + <%= collection_form.label :header_image_url, ts("Custom header URL") %> +
    +
    + <%= collection_form.text_field :header_image_url, "aria-describedby" => "header-image-field-description" %> +

    + <%= ts("JPG, GIF, PNG") %> +

    +
    + +
    <%= ts("Icon") %>
    +
    +
      + <% unless @collection.new_record? %> +
    • + <%= collection_icon_display(@collection) %> + <%= ts("This is the collection's icon.") %> +
    • + <% end %> +
    • <%= ts("Each collection can have one icon") %>
    • +
    • <%= ts("Icons can be in png, jpeg or gif form") %>
    • +
    • <%= ts("Icons should be sized 100x100 pixels for best results") %>
    • +
    + <% if @collection.icon.attached? %> + <%= collection_form.check_box :delete_icon, {:checked => false} %> + <%= collection_form.label :delete_icon, t(".icon.delete") %> + <% end %> +
    + +
    <%= collection_form.label :icon, ts("Upload a new icon") %>
    +
    + <%= collection_form.file_field :icon %> +
    + +
    + <%= collection_form.label :icon_alt_text, ts("Icon alt text") %> + <%= link_to_help "icon-alt-text" %> +
    +
    + <%= collection_form.text_field :icon_alt_text, class: "observe_textlength" %> + <%= generate_countdown_html("collection_icon_alt_text", ArchiveConfig.ICON_ALT_MAX) %> +
    + +
    + <%= collection_form.label :icon_comment_text, ts("Icon comment text") %> + <%= link_to_help "pseud-icon-comment" %> +
    +
    + <%= collection_form.text_field :icon_comment_text, class: "observe_textlength" %> + <%= generate_countdown_html("collection_icon_comment_text", ArchiveConfig.ICON_COMMENT_MAX) %> +
    + +
    <%= collection_form.label :description, ts("Brief description") %>
    +
    + <%= collection_form.text_area :description, rows: 4, cols: 60, class: "description-field observe_textlength" %> + <%= live_validation_for_field('collection_description', + presence: false, maximum_length: ArchiveConfig.SUMMARY_MAX) %> + <%= generate_countdown_html("collection_description", ArchiveConfig.SUMMARY_MAX) %> +
    +
    +
    + + <% @collection.build_collection_preference unless @collection.collection_preference %> + <%= collection_form.fields_for :collection_preference do |preference_form| %> +
    + <%= ts("Preferences") %> +

    + <%= ts("You can also individually ") %> + <% if @collection.new_record? %> + <%= ts("Manage Items") %> + <% else %> + <%= link_to ts("Manage Items"), collection_items_path(@collection) %> + <% end %> + <%= ts(" in your collection.") %> +

    +
    +
    <%= preference_form.check_box :moderated %>
    +
    + <%= preference_form.label :moderated, ts("This collection is moderated") %> + <%= link_to_help "collection-moderated" %> +
    + +
    <%= preference_form.check_box :closed %>
    +
    + <%= preference_form.label :closed, ts("This collection is closed") %> + <%= link_to_help "collection-closed" %> +
    + +
    <%= preference_form.check_box :unrevealed %>
    +
    + <%= preference_form.label :unrevealed, ts("This collection is unrevealed") %> +
    + +
    <%= preference_form.check_box :anonymous %>
    +
    + <%= preference_form.label :anonymous, ts("This collection is anonymous") %> +
    + +
    <%= preference_form.check_box :show_random %>
    +
    + <%= preference_form.label :show_random, ts("Show random works on the front page instead of the most recent") %> +
    + +
    <%= preference_form.check_box :email_notify %>
    +
    + <%= preference_form.label :email_notify, ts("Send a message to the collection email when a work is added") %> +
    + +
    <%= label_tag :challenge_type, ts("Type of challenge, if any") %>
    + <% type = @collection.challenge ? @collection.challenge.class.name : @challenge_type %> +
    + <%= select_tag :challenge_type, options_for_select(Collection::CHALLENGE_TYPE_OPTIONS, type) %> +
    +
    <%= ts("Notice to challenge creators") %>
    +
    +
      +
    • <%= 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| %> +
    + <%= ts("Profile") %> +

    <%= 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.") %> +

    + +

    + <%= profile_form.label :intro, ts("Introduction") %> +

    +

    + <%= profile_form.text_area :intro, rows: 10, cols: 80, class: "observe_textlength" %> + <%= live_validation_for_field('collection_collection_profile_attributes_intro', + presence: false, maximum_length: ArchiveConfig.INFO_MAX) %> + <%= generate_countdown_html("collection_collection_profile_attributes_intro", ArchiveConfig.INFO_MAX) %> +

    + +

    + <%= profile_form.label :faq, ts("FAQ"), title: ts("frequently asked questions") %> +

    +

    + <%= profile_form.text_area :faq, rows: 10, cols: 80, class: "observe_textlength" %> + <%= live_validation_for_field('collection_collection_profile_attributes_faq', + presence: false, maximum_length: ArchiveConfig.INFO_MAX) %> + <%= generate_countdown_html("collection_collection_profile_attributes_faq", ArchiveConfig.INFO_MAX) %> +

    + +

    + <%= profile_form.label :rules, ts("Rules") %> +

    +

    <%= profile_form.text_area :rules, rows: 10, cols: 80, class: "observe_textlength" %> + <%= live_validation_for_field('collection_collection_profile_attributes_rules', + presence: false, maximum_length: ArchiveConfig.INFO_MAX) %> + <%= generate_countdown_html("collection_collection_profile_attributes_rules", ArchiveConfig.INFO_MAX) %> +

    + +

    + <%= profile_form.label :assignment_notification, ts("Assignment notification message") %> +

    +

    + <%= ts('This will be sent out with assignments in a gift exchange challenge. Plain text only.') %> +

    +

    + <%= profile_form.text_area :assignment_notification, rows: 8, cols: 80, + class: "observe_textlength", + "aria-describedby" => "assignment-notification-field-description" %> + <%= live_validation_for_field('collection_collection_profile_attributes_assignment_notification', + presence: false, maximum_length: ArchiveConfig.SUMMARY_MAX) %> + <%= generate_countdown_html("collection_collection_profile_attributes_assignment_notification", ArchiveConfig.SUMMARY_MAX) %> +

    + +

    + <%= profile_form.label :gift_notification, ts("Gift notification message") %> +

    +

    + <%= ts('This will be sent out with each work notification when you "reveal" a gift exchange or prompt meme. Plain text only.') %> +

    +

    + <%= profile_form.text_area :gift_notification, rows: 8, cols: 80, + class: "observe_textlength", + "aria-describedby" => "gift-notification-field-description" %> + <%= live_validation_for_field('collection_collection_profile_attributes_gift_notification', + presence: false, maximum_length: ArchiveConfig.SUMMARY_MAX) %> + <%= generate_countdown_html("collection_collection_profile_attributes_gift_notification", ArchiveConfig.SUMMARY_MAX) %> +

    + + <% end %> +
    + +<%= submit_fieldset collection_form %> +<% end %> diff --git a/app/views/collections/_header.html.erb b/app/views/collections/_header.html.erb new file mode 100644 index 0000000..087ffbd --- /dev/null +++ b/app/views/collections/_header.html.erb @@ -0,0 +1,45 @@ + +
    +

    <%= link_to_unless_current(@collection.title, @collection) %>

    +
    + <%= collection_icon_display(@collection) %> +
    + + + + + +
    <%=raw sanitize_field(@collection, :description) %>
    +

    + (<%= collection.closed? ? ts("Closed") : ts("Open") %>, <%= collection.moderated? ? ts("Moderated") : ts("Unmoderated") %><%= collection.unrevealed? ? ts(", Unrevealed") : "" %><%= collection.anonymous? ? ts(", Anonymous") : "" %><%= collection.gift_exchange? ? ts(", Gift Exchange Challenge") : "" %><%= collection.prompt_meme? ? ts(", Prompt Meme Challenge") : "" %>) +

    +
    diff --git a/app/views/collections/_sidebar.html.erb b/app/views/collections/_sidebar.html.erb new file mode 100644 index 0000000..76968e2 --- /dev/null +++ b/app/views/collections/_sidebar.html.erb @@ -0,0 +1,54 @@ +
    +

    <%= t(".landmark.dashboard") %>

    + + + + <% if @collection.challenge %> + <%= render partial: "challenge/#{challenge_class_name(@collection)}/challenge_sidebar" %> + <% end %> + +

    <%= t(".landmark.contents") %>

    + + + <% if @collection.user_is_maintainer?(current_user) %> +

    <%= t(".landmark.choices") %>

    + + <% end %> +
    diff --git a/app/views/collections/_works_module.html.erb b/app/views/collections/_works_module.html.erb new file mode 100644 index 0000000..b7d353b --- /dev/null +++ b/app/views/collections/_works_module.html.erb @@ -0,0 +1,17 @@ +
    +

    + <% if collection.collection_preference.show_random || params[:show_random] %> + <%= ts("Random works") %> + <% else %> + <%= ts("Recent works") %> + <% end %> +

    +
      + <% for work in works %> + <%= render 'works/work_blurb', :work => work %> + <% end %> +
    + <% if works.total_entries > ArchiveConfig.NUMBER_OF_ITEMS_VISIBLE_IN_DASHBOARD %> + + <% end %> +
    diff --git a/app/views/collections/confirm_delete.html.erb b/app/views/collections/confirm_delete.html.erb new file mode 100644 index 0000000..826e510 --- /dev/null +++ b/app/views/collections/confirm_delete.html.erb @@ -0,0 +1,11 @@ + +

    <%= ts("Delete Collection") %>

    + + +<%= form_for(@collection, :html => {:method => :delete, :class => "simple destroy"}) do |f| %> +

    <%= 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 100644 index 0000000..68b3220 --- /dev/null +++ b/app/views/collections/edit.html.erb @@ -0,0 +1,13 @@ + +

    <%= ts("Edit Collection") %>

    + + + + <%= render "collections/collection_form_delete" %> + + + +

    <%= ts("Edit Collection Form") %>

    +<%= render :partial => 'form' %> + + diff --git a/app/views/collections/index.html.erb b/app/views/collections/index.html.erb new file mode 100755 index 0000000..f332900 --- /dev/null +++ b/app/views/collections/index.html.erb @@ -0,0 +1,71 @@ + +

    + <% if @user %> + <%= t(".page_heading.users_collections", user: @user.login) %> + <% elsif @collection %> + <%= t(".page_heading.challenges_subcollections_in", collection: @collection.title) %> + <% elsif @work %> + <%= t(".page_heading.collections_including", work: @work.title) %> + <% else %> + <%= t(".page_heading.collections_in_the", archive: ArchiveConfig.APP_NAME) %> + <% end %> +

    + +

    + <% if @collections.empty? %> + <%= ts("Sorry, there were no collections found.") %> + <% else %> + <%= search_header @collections, @query, ts("Collection") %> + <% end %> +

    + + + +

    <%= ts("Navigation") %>

    + + +<% unless @collections.blank? %> + + <%= will_paginate @collections %> + + +

    <%= ts("List of Collections") %>

    +
      + <% @collections.each do |collection| %> + <%= render :partial => "collection_blurb", :locals => {:collection => collection} %> + <% end %> +
    +<% end %> + +<% if @sort_and_filter %> + + <%= render 'collections/filters' %> + +<% end %> + +<% unless @collections.blank? %> + <%= will_paginate @collections %> +<% end %> diff --git a/app/views/collections/list_challenges.html.erb b/app/views/collections/list_challenges.html.erb new file mode 100644 index 0000000..0f5c7db --- /dev/null +++ b/app/views/collections/list_challenges.html.erb @@ -0,0 +1,10 @@ +

    <%= ts("Open Challenges") %>

    + + +<%= render :partial => "challenge_list_top_navigation" %> + + + +<%= render :partial => "challenge_collections" %> + + diff --git a/app/views/collections/list_ge_challenges.html.erb b/app/views/collections/list_ge_challenges.html.erb new file mode 100644 index 0000000..f2442d8 --- /dev/null +++ b/app/views/collections/list_ge_challenges.html.erb @@ -0,0 +1,10 @@ +

    <%= ts("Open Gift Exchange Challenges") %>

    + + +<%= render :partial => "challenge_list_top_navigation" %> + + + +<%= render :partial => "challenge_collections" %> + + diff --git a/app/views/collections/list_pm_challenges.html.erb b/app/views/collections/list_pm_challenges.html.erb new file mode 100644 index 0000000..76f03ac --- /dev/null +++ b/app/views/collections/list_pm_challenges.html.erb @@ -0,0 +1,10 @@ +

    <%= ts("Open Prompt Meme Challenges") %>

    + + +<%= render :partial => "challenge_list_top_navigation" %> + + + +<%= render :partial => "challenge_collections" %> + + diff --git a/app/views/collections/new.html.erb b/app/views/collections/new.html.erb new file mode 100644 index 0000000..fdff652 --- /dev/null +++ b/app/views/collections/new.html.erb @@ -0,0 +1,23 @@ + +

    <%= ts("New Collection") %>

    + + + + +

    <%= ts('Suggestions') %>

    +
      +
    • <%= 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.") %> +
    • +
    + + + +

    <%= ts("New Collection Form") %>

    +<%= render :partial => 'form' %> + diff --git a/app/views/collections/show.html.erb b/app/views/collections/show.html.erb new file mode 100644 index 0000000..1f208f2 --- /dev/null +++ b/app/views/collections/show.html.erb @@ -0,0 +1,20 @@ +
    + + <%= render "collections/header", :collection => @collection %> + + + <% # expects @works, @bookmarks %> + <% unless @works.blank? %> + <%= render 'works_module', :collection => @collection, :works => @works %> + <% end %> + + <% unless @bookmarks.blank? %> + <%= render 'bookmarks_module', :collection => @collection, :bookmarks => @bookmarks %> + <% end %> + + <% if @works.blank? && @bookmarks.blank? %> +

    <%= ts("There are no works or bookmarks in this collection yet.") %>

    + <% end %> + + +
    diff --git a/app/views/comment_mailer/_comment_notification_footer.html.erb b/app/views/comment_mailer/_comment_notification_footer.html.erb new file mode 100644 index 0000000..43715a3 --- /dev/null +++ b/app/views/comment_mailer/_comment_notification_footer.html.erb @@ -0,0 +1,12 @@ +

    + <%= ts("Posted") %>: <%= @comment.created_at %> + <% unless @comment.edited_at.blank? %> +
    <%= ts("Last edited") %>: <%= @comment.edited_at %> + <% end %> +

    + +<% if @comment.ultimate_parent.is_a?(Tag) %> + <%= render "comment_mailer/comment_notification_footer_for_tag" %> +<% else %> + <%= render "comment_mailer/comment_notification_footer_for_other" %> +<% end %> diff --git a/app/views/comment_mailer/_comment_notification_footer.text.erb b/app/views/comment_mailer/_comment_notification_footer.text.erb new file mode 100644 index 0000000..2a8cf25 --- /dev/null +++ b/app/views/comment_mailer/_comment_notification_footer.text.erb @@ -0,0 +1,6 @@ +<%= text_divider %> +Posted: <%= @comment.created_at %><% unless @comment.edited_at.blank? %> +Last edited: <%= @comment.edited_at %><% end %> +<% if @comment.ultimate_parent.is_a?(Tag) %> +<%= render 'comment_mailer/comment_notification_footer_for_tag', formats: [:text] %><% else %> +<%= render 'comment_mailer/comment_notification_footer_for_other', formats: [:text] %><% end %> \ No newline at end of file diff --git a/app/views/comment_mailer/_comment_notification_footer_for_other.html.erb b/app/views/comment_mailer/_comment_notification_footer_for_other.html.erb new file mode 100644 index 0000000..f852ac0 --- /dev/null +++ b/app/views/comment_mailer/_comment_notification_footer_for_other.html.erb @@ -0,0 +1,40 @@ +<% if @comment.unreviewed? %> + <% if @owner && @comment.ultimate_parent.is_a?(Work) %> +

    Comments on this work are moderated and will not appear until you approve them.

    + <% elsif @comment.ultimate_parent.is_a?(AdminPost) %> +

    Comments on this news post are moderated and will not appear until approved.

    + <% else %> +

    Comments on this work are moderated and will not appear until approved by the work creator.

    + <% end %> +<% end %> + +<% if @comment.unreviewed? && @owner %> + <%= style_link("Review comments on " + @comment.ultimate_parent.commentable_name + "", + polymorphic_url([:unreviewed, @comment.ultimate_parent, :comments])) %> +
    +<% end %> + +<% unless (@comment.unreviewed? || @noreply) %> + <%= style_link("Reply to this comment", + comment_url(@comment, :add_comment_reply_id => @comment.id, :only_path => false)) %> +
    +<% end %> + +<%= style_link("Go to the thread starting from this comment", + comment_url(@comment, :only_path => false)) %> +
    + +<% unless @comment.id == @comment.thread %> + <%= style_link("Go to the thread to which this comment belongs", + comment_url(:id => @comment.thread, :only_path => false)) %> +
    +<% end %> + +<% if @comment.parent.kind_of?(Chapter) && @comment.ultimate_parent.chaptered? %> + <%= style_link("Read all comments on Chapter #{@comment.parent.position} of #{@comment.ultimate_parent.commentable_name}", + work_chapter_url(@comment.parent.work, @comment.parent, show_comments: true, anchor: :comments)) %> +
    +<% else %> + <%= style_link("Read all comments on #{@comment.ultimate_parent.commentable_name}", + polymorphic_url(@comment.ultimate_parent, view_full_work: true, show_comments: true, anchor: :comments)) %> +<% end %> diff --git a/app/views/comment_mailer/_comment_notification_footer_for_other.text.erb b/app/views/comment_mailer/_comment_notification_footer_for_other.text.erb new file mode 100644 index 0000000..a1f766f --- /dev/null +++ b/app/views/comment_mailer/_comment_notification_footer_for_other.text.erb @@ -0,0 +1,14 @@ +<% if @comment.unreviewed? %><% if @owner && @comment.ultimate_parent.is_a?(Work) %> +Comments on this work are moderated and will not appear until you approve them.<% elsif @comment.ultimate_parent.is_a?(AdminPost) %> +Comments on this news post are moderated and will not appear until approved.<% else %> +Comments on this work are moderated and will not appear until approved by the work creator.<% end %> +<% end %><% if @comment.unreviewed? && @owner %> +Review comments on "<%= @comment.ultimate_parent.commentable_name %>": <%= polymorphic_url([:unreviewed, @comment.ultimate_parent, :comments]) %><% end %><% unless @noreply || @comment.unreviewed? %> +Reply to this comment: <%= comment_url(@comment, :add_comment_reply_id => @comment.id, :only_path => false) %><% end %> +Go to the thread starting from this comment: <%= comment_url(@comment, :only_path => false) %><% unless @comment.id == @comment.thread %> +Go to the thread to which this comment belongs: <%= comment_url(:id => @comment.thread, :only_path => false) %><% end %> +<% if @comment.parent.kind_of?(Chapter) && @comment.parent.work.chaptered? -%> +Read all comments on Chapter <%= @comment.parent.position %> of "<%= @comment.ultimate_parent.commentable_name %>": <%= work_chapter_url(@comment.parent.work, @comment.parent, show_comments: true, anchor: :comments) %> +<%- else -%> +Read all comments on "<%= @comment.ultimate_parent.commentable_name %>": <%= polymorphic_url(@comment.ultimate_parent, view_full_work: true, show_comments: true, anchor: :comments) %> +<%- end %> diff --git a/app/views/comment_mailer/_comment_notification_footer_for_tag.html.erb b/app/views/comment_mailer/_comment_notification_footer_for_tag.html.erb new file mode 100644 index 0000000..76a0516 --- /dev/null +++ b/app/views/comment_mailer/_comment_notification_footer_for_tag.html.erb @@ -0,0 +1,18 @@ +<%= style_link("Read all comments on " + @comment.ultimate_parent.name, + tag_comments_url(@comment.ultimate_parent)) %> +
    + +<% unless @noreply %> + <%= style_link("Reply to this comment", + comment_url(@comment, add_comment_reply_id: @comment.id)) %> +
    +<% end %> + +<%= style_link("Go to the thread starting from this comment", + comment_url(@comment)) %> +
    + +<% unless @comment.id == @comment.thread %> + <%= style_link("Go to the thread to which this comment belongs", + comment_url(@comment.thread)) %> +<% end %> diff --git a/app/views/comment_mailer/_comment_notification_footer_for_tag.text.erb b/app/views/comment_mailer/_comment_notification_footer_for_tag.text.erb new file mode 100644 index 0000000..a9c8525 --- /dev/null +++ b/app/views/comment_mailer/_comment_notification_footer_for_tag.text.erb @@ -0,0 +1,4 @@ +Read all comments on <%= @comment.ultimate_parent.name %>: <%= tag_comments_url(@comment.ultimate_parent) %><% unless @noreply %> +Reply to this comment: <%= comment_url(@comment, add_comment_reply_id: @comment.id) %><% end %> +Go to the thread starting from this comment: <%= comment_url(@comment) %><% unless @comment.id == @comment.thread %> +Go to the thread to which this comment belongs: <%= comment_url(@comment.thread) %><% end %> diff --git a/app/views/comment_mailer/comment_notification.html.erb b/app/views/comment_mailer/comment_notification.html.erb new file mode 100644 index 0000000..0512243 --- /dev/null +++ b/app/views/comment_mailer/comment_notification.html.erb @@ -0,0 +1,13 @@ +<% content_for :message do %> + <%# i18n-tasks-use t("comment_mailer.comment_notification.chapter.titled") %> + <%# i18n-tasks-use t("comment_mailer.comment_notification.chapter.untitled") %> + <%# i18n-tasks-use t("comment_mailer.comment_notification.content.chapter.html") %> + <%# i18n-tasks-use t("comment_mailer.comment_notification.content.other.html") %> + <%# i18n-tasks-use t("comment_mailer.comment_notification.content.tag.html") %> + <%= content_for_commentable_html(@comment) %> + + <%= style_quote(raw(@comment.sanitized_mailer_content)) %> + + <%= render 'comment_notification_footer' %> + +<% end %> diff --git a/app/views/comment_mailer/comment_notification.text.erb b/app/views/comment_mailer/comment_notification.text.erb new file mode 100644 index 0000000..5a8a938 --- /dev/null +++ b/app/views/comment_mailer/comment_notification.text.erb @@ -0,0 +1,11 @@ +<% content_for :message do %> +<%# i18n-tasks-use t("comment_mailer.comment_notification.content.chapter.titled_text") %> +<%# i18n-tasks-use t("comment_mailer.comment_notification.content.chapter.untitled_text") %> +<%# i18n-tasks-use t("comment_mailer.comment_notification.content.other.text") %> +<%# i18n-tasks-use t("comment_mailer.comment_notification.content.tag.text") %> +<%= content_for_commentable_text(@comment) %> +<%= text_divider %> + +<%= to_plain_text(raw(@comment.sanitized_mailer_content))%> + +<%= render 'comment_notification_footer', formats: [:text] %><% end %> diff --git a/app/views/comment_mailer/comment_reply_notification.html.erb b/app/views/comment_mailer/comment_reply_notification.html.erb new file mode 100644 index 0000000..f47a227 --- /dev/null +++ b/app/views/comment_mailer/comment_reply_notification.html.erb @@ -0,0 +1,22 @@ +<% content_for :message do %> + + <%# i18n-tasks-use t("comment_mailer.comment_reply_notification.chapter.titled") %> + <%# i18n-tasks-use t("comment_mailer.comment_reply_notification.chapter.untitled") %> + <%# i18n-tasks-use t("comment_mailer.comment_reply_notification.content.chapter.html") %> + <%# i18n-tasks-use t("comment_mailer.comment_reply_notification.content.other.html") %> + <%# i18n-tasks-use t("comment_mailer.comment_reply_notification.content.tag.html") %> + <%= content_for_commentable_html(@comment) %> + +

    + You wrote: + <%= style_quote(raw(@your_comment.sanitized_mailer_content)) %> +

    + +

    + <%= commenter_pseud_or_name_link(@comment) %> responded: + <%= style_quote(raw(@comment.sanitized_mailer_content)) %> +

    + + <%= render 'comment_notification_footer' %> + +<% end %> diff --git a/app/views/comment_mailer/comment_reply_notification.text.erb b/app/views/comment_mailer/comment_reply_notification.text.erb new file mode 100644 index 0000000..c3f6440 --- /dev/null +++ b/app/views/comment_mailer/comment_reply_notification.text.erb @@ -0,0 +1,20 @@ +<% content_for :message do %> +<%# i18n-tasks-use t("comment_mailer.comment_reply_notification.content.chapter.titled_text") %> +<%# i18n-tasks-use t("comment_mailer.comment_reply_notification.content.chapter.untitled_text") %> +<%# i18n-tasks-use t("comment_mailer.comment_reply_notification.content.other.text") %> +<%# i18n-tasks-use t("comment_mailer.comment_reply_notification.content.tag.text") %> +<%= content_for_commentable_text(@comment) %> + +You wrote: +<%= text_divider %> + +<%= to_plain_text(raw(@your_comment.sanitized_mailer_content)) %> + +<%= text_divider %> + +<%= commenter_pseud_or_name_text(@comment) %> responded: +<%= text_divider %> + +<%= to_plain_text(raw(@comment.sanitized_mailer_content)) %> + +<%= render 'comment_notification_footer', formats: [:text] %><% end %> diff --git a/app/views/comment_mailer/comment_reply_sent_notification.html.erb b/app/views/comment_mailer/comment_reply_sent_notification.html.erb new file mode 100644 index 0000000..6dd04ad --- /dev/null +++ b/app/views/comment_mailer/comment_reply_sent_notification.html.erb @@ -0,0 +1,22 @@ +<% content_for :message do %> + + <%# i18n-tasks-use t("comment_mailer.comment_reply_sent_notification.chapter.titled") %> + <%# i18n-tasks-use t("comment_mailer.comment_reply_sent_notification.chapter.untitled") %> + <%# i18n-tasks-use t("comment_mailer.comment_reply_sent_notification.content.chapter.html") %> + <%# i18n-tasks-use t("comment_mailer.comment_reply_sent_notification.content.other.html") %> + <%# i18n-tasks-use t("comment_mailer.comment_reply_sent_notification.content.tag.html") %> + <%= content_for_commentable_html(@comment) %> + +

    + <%= commenter_pseud_or_name_link(@parent_comment) %> wrote: + <%= style_quote(raw(@parent_comment.sanitized_mailer_content)) %> +

    + +

    + You responded: + <%= style_quote(raw(@comment.sanitized_mailer_content)) %> +

    + + <%= render 'comment_notification_footer' %> + +<% end %> diff --git a/app/views/comment_mailer/comment_reply_sent_notification.text.erb b/app/views/comment_mailer/comment_reply_sent_notification.text.erb new file mode 100644 index 0000000..2ff3483 --- /dev/null +++ b/app/views/comment_mailer/comment_reply_sent_notification.text.erb @@ -0,0 +1,20 @@ +<% content_for :message do %> +<%# i18n-tasks-use t("comment_mailer.comment_reply_sent_notification.content.chapter.titled_text") %> +<%# i18n-tasks-use t("comment_mailer.comment_reply_sent_notification.content.chapter.untitled_text") %> +<%# i18n-tasks-use t("comment_mailer.comment_reply_sent_notification.content.other.text") %> +<%# i18n-tasks-use t("comment_mailer.comment_reply_sent_notification.content.tag.text") %> +<%= content_for_commentable_text(@comment) %> + +<%= commenter_pseud_or_name_text(@parent_comment) %> wrote: +<%= text_divider %> + +<%= to_plain_text(raw(@parent_comment.sanitized_mailer_content)) %> + +<%= text_divider %> + +You responded: +<%= text_divider %> + +<%= to_plain_text(raw(@comment.sanitized_mailer_content)) %> + +<%= render 'comment_notification_footer', formats: [:text] %><% end %> diff --git a/app/views/comment_mailer/comment_sent_notification.html.erb b/app/views/comment_mailer/comment_sent_notification.html.erb new file mode 100644 index 0000000..7141c69 --- /dev/null +++ b/app/views/comment_mailer/comment_sent_notification.html.erb @@ -0,0 +1,14 @@ +<% content_for :message do %> + + <%# i18n-tasks-use t("comment_mailer.comment_sent_notification.chapter.titled") %> + <%# i18n-tasks-use t("comment_mailer.comment_sent_notification.chapter.untitled") %> + <%# i18n-tasks-use t("comment_mailer.comment_sent_notification.content.chapter.html") %> + <%# i18n-tasks-use t("comment_mailer.comment_sent_notification.content.other.html") %> + <%# i18n-tasks-use t("comment_mailer.comment_sent_notification.content.tag.html") %> + <%= content_for_commentable_html(@comment) %> + + <%= style_quote(raw(@comment.sanitized_mailer_content)) %> + + <%= render 'comment_notification_footer' %> + +<% end %> diff --git a/app/views/comment_mailer/comment_sent_notification.text.erb b/app/views/comment_mailer/comment_sent_notification.text.erb new file mode 100644 index 0000000..fe3f651 --- /dev/null +++ b/app/views/comment_mailer/comment_sent_notification.text.erb @@ -0,0 +1,12 @@ +<% content_for :message do %> +<%# i18n-tasks-use t("comment_mailer.comment_sent_notification.content.chapter.titled_text") %> +<%# i18n-tasks-use t("comment_mailer.comment_sent_notification.content.chapter.untitled_text") %> +<%# i18n-tasks-use t("comment_mailer.comment_sent_notification.content.other.text") %> +<%# i18n-tasks-use t("comment_mailer.comment_sent_notification.content.tag.text") %> +<%= content_for_commentable_text(@comment) %> + +<%= text_divider %> + +<%= to_plain_text(raw(@comment.sanitized_mailer_content)) %> + +<%= render 'comment_notification_footer', formats: [:text] %><% end %> diff --git a/app/views/comment_mailer/edited_comment_notification.html.erb b/app/views/comment_mailer/edited_comment_notification.html.erb new file mode 100644 index 0000000..1bda4f2 --- /dev/null +++ b/app/views/comment_mailer/edited_comment_notification.html.erb @@ -0,0 +1,16 @@ +<% content_for :message do %> + + <%# i18n-tasks-use t("comment_mailer.edited_comment_notification.chapter.titled") %> + <%# i18n-tasks-use t("comment_mailer.edited_comment_notification.chapter.untitled") %> + <%# i18n-tasks-use t("comment_mailer.edited_comment_notification.content.chapter.html") %> + <%# i18n-tasks-use t("comment_mailer.edited_comment_notification.content.other.html") %> + <%# i18n-tasks-use t("comment_mailer.edited_comment_notification.content.tag.html") %> + <%= content_for_commentable_html(@comment) %> + +

    + <%= style_quote(raw(@comment.sanitized_mailer_content)) %> +

    + + <%= render 'comment_notification_footer' %> + +<% end %> diff --git a/app/views/comment_mailer/edited_comment_notification.text.erb b/app/views/comment_mailer/edited_comment_notification.text.erb new file mode 100644 index 0000000..3d4e6d5 --- /dev/null +++ b/app/views/comment_mailer/edited_comment_notification.text.erb @@ -0,0 +1,11 @@ +<% content_for :message do %> +<%# i18n-tasks-use t("comment_mailer.edited_comment_notification.content.chapter.titled_text") %> +<%# i18n-tasks-use t("comment_mailer.edited_comment_notification.content.chapter.untitled_text") %> +<%# i18n-tasks-use t("comment_mailer.edited_comment_notification.content.other.text") %> +<%# i18n-tasks-use t("comment_mailer.edited_comment_notification.content.tag.text") %> +<%= content_for_commentable_text(@comment) %> +<%= text_divider %> + +<%= to_plain_text(raw(@comment.sanitized_mailer_content)) %> + +<%= render 'comment_notification_footer', formats: [:text] %><% end %> diff --git a/app/views/comment_mailer/edited_comment_reply_notification.html.erb b/app/views/comment_mailer/edited_comment_reply_notification.html.erb new file mode 100644 index 0000000..885e925 --- /dev/null +++ b/app/views/comment_mailer/edited_comment_reply_notification.html.erb @@ -0,0 +1,22 @@ +<% content_for :message do %> + + <%# i18n-tasks-use t("comment_mailer.edited_comment_reply_notification.chapter.titled") %> + <%# i18n-tasks-use t("comment_mailer.edited_comment_reply_notification.chapter.untitled") %> + <%# i18n-tasks-use t("comment_mailer.edited_comment_reply_notification.content.chapter.html") %> + <%# i18n-tasks-use t("comment_mailer.edited_comment_reply_notification.content.other.html") %> + <%# i18n-tasks-use t("comment_mailer.edited_comment_reply_notification.content.tag.html") %> + <%= content_for_commentable_html(@comment) %> + +

    + You wrote: + <%= style_quote(raw(@your_comment.sanitized_mailer_content)) %> +

    + +

    + <%= commenter_pseud_or_name_link(@comment) %> edited their response to: + <%= style_quote(raw(@comment.sanitized_mailer_content)) %> +

    + + <%= render 'comment_notification_footer' %> + +<% end %> diff --git a/app/views/comment_mailer/edited_comment_reply_notification.text.erb b/app/views/comment_mailer/edited_comment_reply_notification.text.erb new file mode 100644 index 0000000..25c57f8 --- /dev/null +++ b/app/views/comment_mailer/edited_comment_reply_notification.text.erb @@ -0,0 +1,20 @@ +<% content_for :message do %> +<%# i18n-tasks-use t("comment_mailer.edited_comment_reply_notification.content.chapter.titled_text") %> +<%# i18n-tasks-use t("comment_mailer.edited_comment_reply_notification.content.chapter.untitled_text") %> +<%# i18n-tasks-use t("comment_mailer.edited_comment_reply_notification.content.other.text") %> +<%# i18n-tasks-use t("comment_mailer.edited_comment_reply_notification.content.tag.text") %> +<%= content_for_commentable_text(@comment) %> + +You wrote: +<%= text_divider %> + +<%= to_plain_text(raw(@your_comment.sanitized_mailer_content)) %> + +<%= text_divider %> + +<%= commenter_pseud_or_name_text(@comment) %> edited their response to: +<%= text_divider %> + +<%= to_plain_text(raw(@comment.sanitized_mailer_content)) %> + +<%= render 'comment_notification_footer', formats: [:text] %><% end %> diff --git a/app/views/comments/_comment_abbreviated_list.html.erb b/app/views/comments/_comment_abbreviated_list.html.erb new file mode 100644 index 0000000..d2cea62 --- /dev/null +++ b/app/views/comments/_comment_abbreviated_list.html.erb @@ -0,0 +1,15 @@ +<% # this partial is used for an abbreviated list of unthreaded comments (by a single user) %> +<% # expects local "comments" %> + +
    + <% comments.each do |comment| %> +
    + <%= comment_link_with_commentable_name(comment) %> + <%= time_in_zone(comment.created_at) %> +
    +
    + <%= truncate(comment.comment_content, length: 100, separator: ' ') %> +
    + <% end %> +
    + diff --git a/app/views/comments/_comment_actions.html.erb b/app/views/comments/_comment_actions.html.erb new file mode 100644 index 0000000..aecccb0 --- /dev/null +++ b/app/views/comments/_comment_actions.html.erb @@ -0,0 +1,76 @@ +
    <%= ts("Comment Actions") %>
    + + + + + +<% if params[:delete_comment_id] && params[:delete_comment_id] == comment.id.to_s %> +
    + <%= render 'comments/confirm_delete', :comment => comment %> +<% else %> + + +<% 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) %> +
    "> + <%= render 'comments/comment_form', + :comment => Comment.new, + :commentable => comment, + :button_name => ts("Comment") %> + <% else %> + +<% end %> diff --git a/app/views/comments/_comment_form.html.erb b/app/views/comments/_comment_form.html.erb new file mode 100644 index 0000000..47067ec --- /dev/null +++ b/app/views/comments/_comment_form.html.erb @@ -0,0 +1,113 @@ + +<% if !commentable && @commentable %> + <% commentable = @commentable %> +<% end %> +
    + <%= form_for value_for_comment_form(commentable, comment), remote: !comment.new_record?, authenticity_token: true, html: { id: "comment_for_#{commentable.id}" } do |f| %> +
    + <%= t(".legend") %> + + <% if local_assigns[:show_errors] %> + <%= error_messages_for :comment %> + <% end %> + + <%# here come the hacks (hidden fields to transmit various info to the create action) %> + <% if commentable.is_a?(Tag) %> + <%= hidden_field_tag :tag_id, commentable.name %> + <% end %> + + <% if params[:view_full_work] %> + <%= hidden_field_tag :view_full_work, params[:view_full_work] %> + <% end %> + + <% if controller.controller_name == "inbox" && params[:filters] %> + <%= hidden_field_tag "filters[read]", params[:filters][:read] %> + <%= hidden_field_tag "filters[replied_to]", params[:filters][:replied_to] %> + <%= hidden_field_tag "filters[date]", params[:filters][:date] %> + <% end %> + + <% if params[:page] %> + <%= hidden_field_tag :page, params[:page] %> + <% end %> + + <% if comments_are_moderated(commentable) && !current_user_is_work_creator(commentable) %> +

    + <% if commentable.is_a?(AdminPost) %> + <%= t("comments.commentable.permissions.moderated_commenting.notice.admin_post") %> + <% else %> + <%= t("comments.commentable.permissions.moderated_commenting.notice.work") %> + <% end %> +

    + <% end %> + + <% if logged_in? %> + <% if current_user_is_anonymous_creator(commentable) %> +

    + <%= t(".anonymous_forewarning") %> +

    + <% end %> + + <% if current_user.pseuds.count > 1 %> +

    <%= t(".comment_as") %> <%= f.collection_select :pseud_id, current_user.pseuds, :id, :name, { selected: (comment.pseud ? comment.pseud.id.to_s : current_user.default_pseud.id.to_s) }, id: "comment_pseud_id_for_#{commentable.id}", title: t(".choose_name_field_title") %> + <% if controller.controller_name == "inbox" %> + <%= t(".inbox_reference_html", + commentable_creator: commentable.by_anonymous_creator? ? t(".anonymous_creator") : get_commenter_pseud_or_name(commentable), + commentable_link: commentable_description_link(commentable)) %> + <% end %> + (<%= allowed_html_instructions %>) +

    + <% else %> +

    <%= t(".comment_as") %> + <%= f.hidden_field :pseud_id, value: current_user.default_pseud.id.to_s, id: "comment_pseud_id_for_#{commentable.id}" %> + <% if controller.controller_name == "inbox" %> + <%= t(".inbox_reference_html", + commentable_creator: commentable.by_anonymous_creator? ? t(".anonymous_creator") : get_commenter_pseud_or_name(commentable), + commentable_link: commentable_description_link(commentable)) %> + <% end %> +

    +

    (<%= allowed_html_instructions %>)

    + <% end %> + + <% else %> +
    +
    <%= t(".landmark.note") %>:
    +
    <%= t(".guest_instructions") %>
    +
    <%= f.label "name_for_#{commentable.id}", t(".guest_name") %>
    +
    + <%= f.text_field :name, id: "comment_name_for_#{commentable.id}" %> + <%= live_validation_for_field("comment_name_for_#{commentable.id}", failureMessage: t(".guest_name_failure")) %> +
    +
    <%= f.label "email_for_#{commentable.id}", t(".guest_email") %>
    +
    + <%= f.text_field :email, id: "comment_email_for_#{commentable.id}" %> + <%= live_validation_for_field("comment_email_for_#{commentable.id}", failureMessage: t(".guest_email_failure")) %> +
    +
    +

    (<%= allowed_html_instructions %>)

    + <% end %> + +

    + <% content_id = "comment_content_for_#{commentable.id}" %> + + <%= f.text_area :comment_content, id: content_id, class: "comment_form observe_textlength", title: t(".comment_field_title") %> + +

    + <%= generate_countdown_html("comment_content_for_#{commentable.id}", ArchiveConfig.COMMENT_MAX) %> + <%= live_validation_for_field "comment_content_for_#{commentable.id}", + failureMessage: t(".comment_too_short"), + maximum_length: ArchiveConfig.COMMENT_MAX, + tooLongMessage: t(".comment_too_long", count: ArchiveConfig.COMMENT_MAX) %> +

    + <%= f.submit button_name, id: "comment_submit_for_#{commentable.id}", data: { disable_with: t(".processing_message") } %> + <% if controller.controller_name == 'inbox' %> + <%= t(".cancel_action") %> + <% elsif comment.persisted? %> + <%= cancel_edit_comment_link(comment) %> + <% elsif commentable.is_a?(Comment) || commentable.is_a?(CommentDecorator) %> + <%= cancel_comment_reply_link(commentable) %> + <% end %> +

    +
    + <% end %> +
    +
    diff --git a/app/views/comments/_comment_thread.html.erb b/app/views/comments/_comment_thread.html.erb new file mode 100644 index 0000000..13e27eb --- /dev/null +++ b/app/views/comments/_comment_thread.html.erb @@ -0,0 +1,23 @@ + +
      + <% for comment in comments %> + <% if comment.approved? || logged_in_as_admin? %> + + <%= render 'comments/single_comment', single_comment: comment %> + <% child_comments = comment.reviewed_replies %> + + <% if child_comments && child_comments.size > 0 %> + + <% if depth >= ArchiveConfig.COMMENT_THREAD_MAX_DEPTH && child_comments.size > 1 %> +
    1. (<%= link_to ts("%{count} more comments in this thread", count: comment.children_count), comment_path(comment) %>)

    2. + <% else %> +
    3. + <%= render 'comments/comment_thread', comments: child_comments, depth: depth+1 %> +
    + + <% end %> + <% end %> + + <% end %> + <% end %> + diff --git a/app/views/comments/_commentable.html.erb b/app/views/comments/_commentable.html.erb new file mode 100644 index 0000000..0c1c5d1 --- /dev/null +++ b/app/views/comments/_commentable.html.erb @@ -0,0 +1,159 @@ + + + diff --git a/app/views/comments/_confirm_delete.html.erb b/app/views/comments/_confirm_delete.html.erb new file mode 100644 index 0000000..a7cde8c --- /dev/null +++ b/app/views/comments/_confirm_delete.html.erb @@ -0,0 +1,5 @@ +

    <%= ts("Are you sure you want to delete this 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 100644 index 0000000..c87a82a --- /dev/null +++ b/app/views/comments/_single_comment.html.erb @@ -0,0 +1,78 @@ + +
  • + <% if params[:edit_comment_id] && params[:edit_comment_id] == single_comment.id.to_s && can_edit_comment?(single_comment) %> + + <%= render 'comments/comment_form', + :comment => single_comment, + :commentable => single_comment.commentable, + :button_name => ts('Update') %> + <% else %> + <% if single_comment.is_deleted %> +

    + <%= ts("(Previous comment deleted.)") %> +

    + <% elsif !can_see_hidden_comment?(single_comment) %> +

    <%= ts("(This comment is under review by an admin and is currently unavailable.)") %>

    + <% else %> + <%# FRONT END, update this if a.user comes in %> + + <% cache([single_comment, single_comment.comment_owner, "body", image_safety_mode_cache_key(single_comment)], expires_in: 1.week) do %> +
    + <% if !single_comment.pseud.nil? %> + <% if single_comment.by_anonymous_creator? %> + + <% else %> + <%= icon_display(single_comment.pseud.user, single_comment.pseud) %> + <% end %> + <% else %> + + <% end %> +
    + <% unless single_comment.approved? %> +

    <%= ts("This comment has been marked as spam.") %>

    + <% end %> + <% if single_comment.hidden_by_admin? %> +

    <%= ts("This comment has been hidden by an admin.") %>

    + <% end %> +
    + <%= raw single_comment.sanitized_content %> +
    + <% end %> + <% if single_comment.edited_at.present? %> +

    + <%= ts("Last Edited") %> <%= time_in_zone(single_comment.edited_at) %> +

    + <% end %> + + <% if policy(single_comment).show_email? && single_comment.email.present? %> + + <% end %> + <% if policy(:user_creation).show_ip_address? %> +

    <%= ts("IP Address: %{ip_address}", ip_address: single_comment.ip_address.blank? ? "No address recorded" : single_comment.ip_address) %>

    + <% 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 index 0000000..0bd7dd7 --- /dev/null +++ b/app/views/comments/edit.html.erb @@ -0,0 +1,21 @@ + +

    <%= t(".page_heading") %>

    + +<%= error_messages_for :comment %> + + + + + + +
  • + <%= render partial: "comments/comment_form", locals: { comment: @comment, commentable: @comment.commentable, button_name: t(".update") } %> +
  • + + + + + diff --git a/app/views/comments/edit.js.erb b/app/views/comments/edit.js.erb new file mode 100644 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 100644 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 100644 index 0000000..93b0294 --- /dev/null +++ b/app/views/comments/index.html.erb @@ -0,0 +1,10 @@ + +

    <%= title_for_comment_page(@commentable) %>

    + + + +<%= render "comments/commentable", commentable: @commentable %> + + + + diff --git a/app/views/comments/new.html.erb b/app/views/comments/new.html.erb new file mode 100644 index 0000000..1802d05 --- /dev/null +++ b/app/views/comments/new.html.erb @@ -0,0 +1,17 @@ + +

    <%= ts("New comment on %{name}", :name => @name) %>

    +<%= error_messages_for :comment %> + + + +<%= render :partial => 'comments/comment_form', :locals => { + :comment => @comment, + :commentable => @commentable, + :button_name => ts("Comment") + } +%> + + + + + diff --git a/app/views/comments/review.js.erb b/app/views/comments/review.js.erb new file mode 100644 index 0000000..9576742 --- /dev/null +++ b/app/views/comments/review.js.erb @@ -0,0 +1,10 @@ +<% comment_container_id = "#feedback_comment_#{@comment.id}" %> +<% read_comment_form_id = "#read_comment_form_#{@comment.id}" %> +<% review_comment_list_item_id = "#review_comment_link_#{@comment.id}" %> +<% reply_button_html = "#{j render('inbox/reply_button', feedback_comment: @comment)}" %> + +$j("<%= comment_container_id %>").toggleClass("unreviewed unread read"); +$j("<%= comment_container_id %>").children("h4").children(".unreviewed").remove(); +$j("<%= comment_container_id %>").children(".actions").find(".unread").remove(); +$j("<%= read_comment_form_id %>").remove(); +$j("<%= review_comment_list_item_id %>").replaceWith("
  • " + "<%= reply_button_html.html_safe %>" + "
  • "); diff --git a/app/views/comments/show.html.erb b/app/views/comments/show.html.erb new file mode 100644 index 0000000..06290d6 --- /dev/null +++ b/app/views/comments/show.html.erb @@ -0,0 +1,7 @@ +<%= flash_div :comment_error, :comment_notice %> + +

    + <%= t(".comment_on_html", commentable_link: link_to_comment_ultimate_parent(@comment)) %> +

    + +<%= render :partial => 'comments/comment_thread', :locals => { :comments => @comments, :depth => 0 } %> diff --git a/app/views/comments/show_comments.js.erb b/app/views/comments/show_comments.js.erb new file mode 100644 index 0000000..79089b4 --- /dev/null +++ b/app/views/comments/show_comments.js.erb @@ -0,0 +1,11 @@ +/* replace the top "Comments" link on works with "Hide Comments" */ +$j("#show_comments_link_top").html("<%= escape_javascript(show_hide_comments_link(@commentable, :link_type => "hide")) %>"); +/* replace the top "Comments (#)" link with "Hide Comments (#)" */ +$j("#show_comments_link").html("<%= escape_javascript(show_hide_comments_link(@commentable, :link_type => "hide", :show_count => true)) %>"); +/* load up the comments in their placeholder div */ +$j("#comments_placeholder").html("<%= escape_javascript(will_paginate(@comments, {:remote => true})) %>"); +$j("#comments_placeholder").append("<%= escape_javascript(render "comments/comment_thread", {:comments => @comments, :depth => 0}) %>"); +$j("#comments_placeholder").append(""); +$j("#comments_placeholder").append("<%= escape_javascript(will_paginate(@comments, {:remote => true})) %>"); +/* roll down the comments */ +$j("#comments_placeholder").slideDown(); diff --git a/app/views/comments/unreviewed.html.erb b/app/views/comments/unreviewed.html.erb new file mode 100644 index 0000000..f70d576 --- /dev/null +++ b/app/views/comments/unreviewed.html.erb @@ -0,0 +1,31 @@ + +

    <%= t(".page_heading_html", commentable_link: link_to(@commentable.commentable_name, @commentable)) %>

    + + + +<% if can_review_all_comments?(@commentable) %> + +<% end %> + + + +<%# i18n-tasks-use t("comments.unreviewed.note.admin_post") + i18n-tasks-use t("comments.unreviewed.note.work") %> +

    <%= t(".note.#{@commentable.model_name.i18n_key}") %>

    + +<% if @comments.count > 0 %> + <%= will_paginate @comments %> + <% @comments.each do |comment| %> + <%= render partial: "comments/single_comment", object: comment %> + <% end %> + <%= will_paginate @comments %> +<% else %> +

    <%= t(".no_unreviewed") %>

    +<% end %> + + + + diff --git a/app/views/comments/update.js.erb b/app/views/comments/update.js.erb new file mode 100644 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 100644 index 0000000..e513b36 --- /dev/null +++ b/app/views/creatorships/show.html.erb @@ -0,0 +1,63 @@ + +

    <%= ts("Co-Creator Requests") %>

    + + + + + + +<%= will_paginate @creatorships %> +<% if @creatorships.blank? %> +

    <%= ts("No co-creator requests found.") %>

    +<% else %> + <%= form_tag user_creatorships_path(@user, page: params[:page]), method: :put do %> +
    + <%= ts("Mass Edit Options") %> + + +
    + +
    + <%= ts("List of Co-Creator Requests") %> + + + + + + + + + + <%= collection_check_boxes "", :selected, @creatorships, :id, :id do |builder| %> + <% creatorship = builder.object %> + <% next unless (creation = creatorship.creation) %> + + + + + + + + <% end %> +
    <%= ts("Creation") %><%= ts("Type") %><%= ts("Invited Pseud") %><%= ts("Date") %><%= ts("Selected?") %>
    <%= link_to title_for_creation(creation), creation %><%= creatorship.creation_type %><%= creatorship.pseud.name %><%= set_format_for_date(creatorship.created_at) %><%= builder.check_box %>
    +
    + +
    + <%= ts("Mass Edit Options") %> + + +
    + <% end %> +<% end %> +<%= will_paginate @creatorships %> + diff --git a/app/views/downloads/_download_afterword.html.erb b/app/views/downloads/_download_afterword.html.erb new file mode 100644 index 0000000..5ae55c1 --- /dev/null +++ b/app/views/downloads/_download_afterword.html.erb @@ -0,0 +1,32 @@ +
    +

    <%= t(".afterword") %>

    + + <% unless @work.endnotes.blank? && @work.approved_children.blank? %> +
    + <% unless @work.endnotes.blank? %> +
    +

    <%= t(".end_notes") %>

    +
    <%= raw sanitize_field(@work, :endnotes) %>
    +
    + <% end %> + + <%# i18n-tasks-use t("downloads.download_afterword.inspired_by.restricted_html") %> + <%# i18n-tasks-use t("downloads.download_afterword.inspired_by.revealed_html") %> + <%# i18n-tasks-use t("downloads.download_afterword.inspired_by.unrevealed") %> + <% if @work.approved_children.present? %> +
    +
    <%= t(".inspired_by.title") %>
    + <% for child_work in @work.approved_related_works %> + <% next if child_work.translation %> + +
    + <%= related_work_note(child_work.work, "inspired_by", download: true) %> +
    + <% end %> +
    + <% end %> +
    + <% end %> + +

    <%= t(".please_comment_html", comment_link: link_to(t(".comment"), new_work_comment_url(@work))) %>

    +
    diff --git a/app/views/downloads/_download_chapter.html.erb b/app/views/downloads/_download_chapter.html.erb new file mode 100644 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 %> + + <% end %> + + <% unless @chapter.summary.blank? %> +

    <%= t(".chapter_summary") %>

    +
    <%= raw sanitize_field(@chapter, :summary) %>
    + <% end %> + + <% unless @chapter.notes.blank? && @chapter.endnotes.blank? %> +

    <%= t(".chapter_notes") %>

    + <% unless @chapter.notes.blank? %> +
    <%= raw sanitize_field(@chapter, :notes) %>
    + <% end %> + <% unless @chapter.endnotes.blank? %> + + <% end %> + <% end %> +
    + + +
    + <%= raw defined?(@content) ? @content : sanitize_field(@chapter, :content) %> +
    + + +<% unless @chapter.endnotes.blank? %> +
    +

    <%= t(".section_chapter_end_notes") %>

    +
    <%= raw sanitize_field(@chapter, :endnotes) %>
    +
    +<% end %> diff --git a/app/views/downloads/_download_preface.html.erb b/app/views/downloads/_download_preface.html.erb new file mode 100644 index 0000000..08cf5c3 --- /dev/null +++ b/app/views/downloads/_download_preface.html.erb @@ -0,0 +1,92 @@ +
    +

    <%= t(".preface") %>

    + +

    + <%= @work.title %>
    + <%= t(".originally_posted_html", + archive_link: link_to(ArchiveConfig.APP_NAME, root_url), + work_url: link_to(work_url(@work), work_url(@work))) %> +

    + +
    +
    + <% Tag::VISIBLE.each do |type| %> + <% tags = @work.tag_groups[type] %> + <% unless tags.blank? %> +
    <%= t(".tag_type", tag_type: tags.size == 1 ? type.constantize::NAME : type.constantize::NAME.pluralize) %>
    +
    <%= safe_join(tags.map { |t| link_to(t.display_name, tag_url(t)) }, t("support.array.words_connector")) %>
    + <% end %> + <% end %> + + <% unless @work.language.blank? %> +
    <%= t(".language") %>
    +
    <%= @work.language.name %>
    + <% end %> + + <% series_list = @work.serial_works.reject { |sw| sw.series.nil? } %> + <% unless series_list.blank? %> +
    <%= t(".series") %>
    +
    <%= safe_join(series_list.map { |s| t(".series_list_html", position: s.position, series_link: link_to(s.series.title, series_url(s.series))) }, t("support.array.words_connector")) %>
    + <% end %> + <% unless @work.approved_collections.empty? %> +
    <%= t(".collections") %>
    +
    <%= safe_join(@work.approved_collections.map { |c| link_to(c.title, collection_url(c)) }, t("support.array.words_connector")) %>
    + <% end %> +
    <%= t(".stats") %>
    +
    + <%= t(".published", date: l(@work.first_chapter.published_at)) %> + <% if @work.first_chapter.published_at < @work.revised_at.to_date %> + <%= @work.is_wip ? t(".updated", date: l(@work.revised_at.to_date)) : t(".completed", date: l(@work.revised_at.to_date)) %> + <% end %> + <%= t(".words", count_with_delimiters: number_with_delimiter(@work.word_count)) %> + <%= t(".chapters", chapter_total_display: chapter_total_display(@work)) %> +
    +
    +

    <%= @work.title %>

    + + <% unless @work.summary.blank? %> +

    <%= t(".summary") %>

    +
    <%= raw sanitize_field(@work, :summary) %>
    + <% end %> + + <% unless @work.notes.blank? && @work.endnotes.blank? %> +

    <%= t(".notes") %>

    + <% unless @work.notes.blank? %> +
    <%= raw sanitize_field(@work, :notes) %>
    + <% end %> + <% unless @work.endnotes.blank? %> + + <% end %> + <% end %> + + <%# i18n-tasks-use t("downloads.download_preface.translated_to.restricted_html") %> + <%# i18n-tasks-use t("downloads.download_preface.translated_to.revealed_html") %> + <%# i18n-tasks-use t("downloads.download_preface.translated_to.unrevealed_html") %> + <%# i18n-tasks-use t("downloads.download_preface.inspired_by.restricted_html") %> + <%# i18n-tasks-use t("downloads.download_preface.inspired_by.revealed_html") %> + <%# i18n-tasks-use t("downloads.download_preface.inspired_by.unrevealed") %> + <%# i18n-tasks-use t("downloads.download_preface.translation_of.restricted_html") %> + <%# i18n-tasks-use t("downloads.download_preface.translation_of.revealed_html") %> + <%# i18n-tasks-use t("downloads.download_preface.translation_of.unrevealed") %> + <% translations = @work.approved_related_works.where(translation: true) %> + <% related_works = @work.parent_work_relationships.reject { |wr| !wr.parent } %> + <% if translations.any? || related_works.any? %> +
      + <% translations.each do |related_work| %> +
    • + <%= related_work_note(related_work.work, "translated_to", download: true) %> +
    • + <% end %> + <% related_works.each do |work| %> +
    • + <% relation = work.translation ? "translation_of" : "inspired_by" %> + <%= related_work_note(work.parent, relation, download: true) %> +
    • + <% end %> +
    + <% end %> + +
    +
    diff --git a/app/views/downloads/show.html.erb b/app/views/downloads/show.html.erb new file mode 100644 index 0000000..03feed1 --- /dev/null +++ b/app/views/downloads/show.html.erb @@ -0,0 +1,17 @@ +<%= render "downloads/download_preface" %> + +
    + <% 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) %> +
    + <% end %> +
    + +<%= render "downloads/download_afterword" %> diff --git a/app/views/errors/403.html.erb b/app/views/errors/403.html.erb new file mode 100644 index 0000000..d857368 --- /dev/null +++ b/app/views/errors/403.html.erb @@ -0,0 +1,3 @@ +

    Error 403

    +

    Forbidden

    +

    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 100644 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 100644 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 100644 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 100644 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 100644 index 0000000..81df9fb --- /dev/null +++ b/app/views/errors/timeout_error.html.erb @@ -0,0 +1,3 @@ +

    <%= t(".page_heading") %>

    +

    <%= t(".subtitle") %>

    +

    <%= t(".html", contact_support_link: link_to(t(".contact_support"), new_feedback_report_path)) %>

    diff --git a/app/views/external_authors/_external_author_blurb.html.erb b/app/views/external_authors/_external_author_blurb.html.erb new file mode 100644 index 0000000..90d5302 --- /dev/null +++ b/app/views/external_authors/_external_author_blurb.html.erb @@ -0,0 +1,19 @@ +
  • + <%= render "external_authors/external_author_description", :external_author => external_author %> + + +
  • diff --git a/app/views/external_authors/_external_author_description.html.erb b/app/views/external_authors/_external_author_description.html.erb new file mode 100644 index 0000000..275cd7e --- /dev/null +++ b/app/views/external_authors/_external_author_description.html.erb @@ -0,0 +1,35 @@ +<% # expects "external_author" local %> +

    <%= ts("External Author Listing") %>

    + +

    "> + <%= external_author.email %> + <% if !@user && external_author.is_claimed? %> + (Claimed by <%= link_to external_author.user.login, external_author.user %>) + <% end %> +

    + +<% if external_author.do_not_import %> +

    + <%= ts("No imports may be made for this email address.") %> +

    +<% end %> + +<% if external_author.do_not_email %> +

    + <%= ts("No import notifications may be sent to this email address.") %> +

    +<% end %> + +

    Names used:

    +
      + <% external_author.external_author_names.pluck(:name).each do |name| %> +
    • <%= name %>
    • + <% end %> +
    + +

    Imported Works:

    +
      + <% external_author.external_creatorships.each do |ext_creatorship| %> +
    • <%= link_to(ext_creatorship.creation.title, work_path(ext_creatorship.creation)) %>
    • + <% end %> +
    diff --git a/app/views/external_authors/_external_author_form.html.erb b/app/views/external_authors/_external_author_form.html.erb new file mode 100644 index 0000000..50efa7f --- /dev/null +++ b/app/views/external_authors/_external_author_form.html.erb @@ -0,0 +1,38 @@ +<%= form_for([@user, @external_author]) do |f| %> + <%= error_messages_for @external_author %> +
    + External Author Identity +
    +
    <%= f.label :email %>
    +
    <%= f.text_field :email %>
    +
    +
    + +
    + External Author Names + <%= f.fields_for :external_author_names do |name_form| %> + <%= render :partial => 'external_author_name', :locals => {:form => name_form} %> + <% end %> + +

    <%= add_name_link(f) %>

    +
    + +
    + External Author Preferences +
    +
    <%= f.label :do_not_email, ts("Don't email when works are imported: ") %>
    +
    <%= f.check_box :do_not_email %>
    + +
    <%= f.label :do_not_import, ts("Don't import works: ") %>
    +
    <%= f.check_box :do_not_import %>
    +
    +
    + +
    + <%= ts("Submit") %> +

    + <%= f.submit ts("Submit") %> +

    +
    + +<% end %> diff --git a/app/views/external_authors/_external_author_name.html.erb b/app/views/external_authors/_external_author_name.html.erb new file mode 100644 index 0000000..1442461 --- /dev/null +++ b/app/views/external_authors/_external_author_name.html.erb @@ -0,0 +1,9 @@ +
    +
    +
    <%= form.label :name, t('.label_external_author_name', :default => "Name: ") %>
    +
    + <%= form.text_field :name %> + <%= remove_name_link(form) %> +
    +
    +
    \ 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 100644 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 100644 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("Other Options") %> +

    <%= 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 100644 index 0000000..c6ec48e --- /dev/null +++ b/app/views/external_authors/edit.html.erb @@ -0,0 +1,6 @@ +

    <%= t('.edit_external_author', :default => 'Editing external author identity') %>

    + +<%= render "external_author_form" %> + + + diff --git a/app/views/external_authors/index.html.erb b/app/views/external_authors/index.html.erb new file mode 100644 index 0000000..d40471f --- /dev/null +++ b/app/views/external_authors/index.html.erb @@ -0,0 +1,52 @@ +<% if @user && current_user == @user %> +

    <%= ts("Author Identities for %{user}", :user => @user.login) %>

    + +

    + <%= ts("If an archive that includes your works is backed up to the AO3, here's where you'll get control over those works.") %> +

    + + +<% elsif current_user.archivist %> +

    <%= ts("External Authors Imported") %>

    +<% end %> + +
      + <% @external_authors.each do |external_author| %> +
    • +

      Identity: <%= external_author.external_author_names.collect(&:name).join(", ") %> +

      + <% if external_author.do_not_email %> +

      <%= ts("No notifications will be sent to this email address." ) %>

      + <% end %> + + <% if external_author.do_not_import %> +

      <%= ts("Archivists may not import works with this email." ) %>

      + <% end %> + +

      Works:

      +
        + <% external_author.external_creatorships.each do |ext_creatorship| %> +
      • <%= link_to(ext_creatorship.creation.title, work_path(ext_creatorship.creation)) %>
      • + <% end %> +
      + + <% if @user && current_user == @user %> + + <% end %> +
    • + <% end %> +
    + diff --git a/app/views/external_works/_blurb.html.erb b/app/views/external_works/_blurb.html.erb new file mode 100644 index 0000000..f36eb46 --- /dev/null +++ b/app/views/external_works/_blurb.html.erb @@ -0,0 +1,57 @@ +
  • + + +
    + +

    + <%= link_to external_work.title, external_work.url %> + <%= ts("by") %> + <%= byline(external_work) %> +

    + +
    + <%= ts("Fandoms") %>: + <% fandoms = external_work.tag_groups["Fandom"] %> + <%= fandoms.collect{ |tag| link_to_tag_works(tag) }.join(", ").html_safe if fandoms %> +
    + + <%= get_symbols_for(external_work) %> +

    <%= set_format_for_date(external_work.created_at) %>

    +
    + +

    <%= t("external_works.notice") %>

    + + +
    <%= ts('Tags') %>
    +
      + <%= blurb_tag_block(external_work) %> +
    + + + <% unless external_work.summary.blank? %> +
    <%= ts('Summary') %>
    +
    + <%=raw strip_images(sanitize_field(external_work, :summary)) %> +
    + <% end %> + + +
    + <% unless external_work.language.blank? %> +
    <%= ts("Language:") %>
    +
    <%= external_work.language.name %>
    + <% end %> + <% if Bookmark.count_visible_bookmarks(external_work) > 0 %> +
    <%= Bookmark.model_name.human(count: :many) %>:
    +
    <%= link_to_bookmarkable_bookmarks(external_work) %>
    + <% end %> + +
    <%= RelatedWork.model_name.human(count: :many) %>:
    +
    <%= link_to number_with_delimiter(external_work.related_works.count), external_work %>
    +
    + + <% 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 100644 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) %> +

    + +
    + <%= ts("Fandoms") %>: + <% fandoms = external_work.tag_groups["Fandom"] %> + <%= fandoms.collect{ |tag| link_to_tag_works(tag) }.join(", ").html_safe if fandoms %> +
    + + <%= get_symbols_for(external_work) %> +

    <%= set_format_for_date(external_work.created_at) %>

    +
    + +

    <%= t("external_works.notice") %>

    + + +
    <%= ts("Tags") %>
    +
      + <%= blurb_tag_block(external_work) %> +
    + + +<% unless external_work.summary.blank? %> +
    <%= ts("Summary") %>
    +
    + <%=raw strip_images(sanitize_field(external_work, :summary)) %> +
    +<% end %> + + +
    + <% unless external_work.language.blank? %> +
    <%= ts("Language:") %>
    +
    <%= external_work.language.name %>
    + <% end %> + + <% if Bookmark.count_visible_bookmarks(external_work) > 0 %> +
    <%= Bookmark.model_name.human(count: :many) %>:
    +
    <%= link_to_bookmarkable_bookmarks(external_work) %>
    + <% end %> + + <% if external_work.related_works.count > 0 %> +
    <%= RelatedWork.model_name.human(count: :many) %>:
    +
    <%= link_to number_with_delimiter(external_work.related_works.count), external_work %>
    + <% end %> +
    diff --git a/app/views/external_works/edit.html.erb b/app/views/external_works/edit.html.erb new file mode 100644 index 0000000..1347b25 --- /dev/null +++ b/app/views/external_works/edit.html.erb @@ -0,0 +1,56 @@ + +

    + <%= ts("Edit External Work") %> +

    + + + + + + +<%= form_for(@external_work, html: { class: "external work post" }) do |f| %> +

    * <%= ts("Required information") %>

    +
    + <%= ts("Work Preface") %> +
    +
    <%= f.label :url, ts("URL") + "*" %>
    +
    + <%= f.text_field :url %> +
    +
    <%= f.label :author, ts("Creator") + "*" %>
    +
    + <%= f.text_field :author, class: "observe_textlength" %> + <%= generate_countdown_html("external_work_author", + ExternalWork::AUTHOR_LENGTH_MAX) %> +
    +
    <%= f.label :title, ts("Title") + "*" %>
    +
    + <%= f.text_field :title, class: "observe_textlength" %> + <%= generate_countdown_html("external_work_title", + ArchiveConfig.TITLE_MAX) %> +
    +
    + <%= f.label :language_id, ts("Language") %> + <%= link_to_help "languages-help" %> +
    +
    + <%= f.select(:language_id, language_options_for_select(sorted_languages, "id"), prompt: " ") %> +
    +
    + <%= f.label :summary, ts("Creator's Summary") %> + <%= ts("(please copy and paste from original work)") %> +
    +
    + <%= f.text_area :summary, rows: 5, class: "observe_textlength" %> + <%= generate_countdown_html("external_work_summary", + ArchiveConfig.SUMMARY_MAX) %> +
    +
    +
    + + <%= render 'works/work_form_tags', work: @external_work, + include_blank: false %> + +

    <%= f.submit %>

    +<% end %> + diff --git a/app/views/external_works/fetch.js.erb b/app/views/external_works/fetch.js.erb new file mode 100644 index 0000000..cb20f79 --- /dev/null +++ b/app/views/external_works/fetch.js.erb @@ -0,0 +1,20 @@ +<% unless @external_work.blank? %> + $j('#external_work_author').val("<%= escape_javascript(@external_work.author.html_safe) %>").change(); + $j('#external_work_title').val("<%= escape_javascript(@external_work.title) %>").change(); + $j('#external_work_summary').val("<%= escape_javascript(@external_work.summary&.html_safe) %>").change(); + $j('#fetched').val("<%= @external_work.id %>"); + $j('#external_work_rating_string').val("<%= @external_work.rating_string %>"); + var categories = <%= @external_work.category_strings.to_json.html_safe %>; + $j('input[id^="external_work_category_strings_"]').each(function() { + $j(this).prop("checked", categories.indexOf(this.value) >= 0 ); + }); + + <% # clear existing autocompletes %> + $j("#external_work_fandom_string_autocomplete").parent().parent().find(".delete a").click() + $j("#external_work_relationship_string_autocomplete").parent().parent().find(".delete a").click() + $j("#external_work_character_string_autocomplete").parent().parent().find(".delete a").click() + + $j('#external_work_fandom_string_autocomplete').val("<%= @external_work.fandom_string %>"); + $j('#external_work_relationship_string_autocomplete').val("<%= @external_work.relationship_string %>"); + $j('#external_work_character_string_autocomplete').val("<%= @external_work.character_string %>"); +<% end %> diff --git a/app/views/external_works/index.html.erb b/app/views/external_works/index.html.erb new file mode 100644 index 0000000..4f5368f --- /dev/null +++ b/app/views/external_works/index.html.erb @@ -0,0 +1,23 @@ +

    External Works

    + + +<% if logged_in_as_admin? %> + +<% end %> + +<%= will_paginate @external_works %> + +<% unless @external_works.blank? %> +

    Listing External Works

    + +
      + <% for external_work in @external_works %> + <%= render "external_works/blurb", { external_work: external_work } %> + <% end %> +
    + + <%= will_paginate @external_works %> +<% end %> diff --git a/app/views/external_works/new.html.erb b/app/views/external_works/new.html.erb new file mode 100644 index 0000000..4ba948a --- /dev/null +++ b/app/views/external_works/new.html.erb @@ -0,0 +1,2 @@ +

    <%= ts("Bookmark an external work") %>

    +<%= render 'bookmarks/bookmark_form', :bookmarkable => @bookmarkable, :bookmark => @bookmark, :button_name => ts("Create"), :action => 'create' %> diff --git a/app/views/external_works/show.html.erb b/app/views/external_works/show.html.erb new file mode 100644 index 0000000..b8c71ac --- /dev/null +++ b/app/views/external_works/show.html.erb @@ -0,0 +1,6 @@ +<% unless @external_work.blank? %> +

    <%= ts('External Work') %>

    +
      + <%= render 'external_works/blurb', external_work: @external_work %> +
    +<% end %> diff --git a/app/views/fandoms/index.html.erb b/app/views/fandoms/index.html.erb new file mode 100644 index 0000000..4dc171f --- /dev/null +++ b/app/views/fandoms/index.html.erb @@ -0,0 +1,61 @@ + +

    + <% if @collection %> + <%= link_to(@collection.title, @collection) %> > <%= t(".page_heading") %> + <% elsif @medium %> + <%= link_to t(".page_heading"), media_index_path %> > <%= @medium.name %> + <% else %> + <%= link_to t(".page_heading"), media_index_path %> + <% end %> +

    +

    You can search this page by pressing ctrl F / cmd F and typing in what you are looking for.

    +<% if @collection %> +

    <%= ts("Filters") %>

    + <%= form_tag "", method: :get, class: "filter", id: "media-filter" do %> +
    +

    "> + <%= select_tag :media_id, options_for_select(["All Media Types"] + @media.map(&:name), params[:media_id]) %> + <%= submit_tag ts("Show") %> +

    +
    + <% end %> +<% end %> + + + +<% if @fandoms_by_letter && !@fandoms_by_letter.empty? %> +

    <%= ts("Alphabet Navigation") %>

    + +

    <%= ts("Alphabetised List of Fandoms") %>

    +
      "> + <% @fandoms_by_letter.each_pair do |letter, fandoms| %> +
    1. +

      + <%= letter %> + + <%= link_to "↑".html_safe, "#alphabet" %> + +

      +
        + <% for fandom in fandoms %> +
      • + <%= link_to_tag_works(fandom, collection: @collection) %> + <% if fandom.respond_to?(:count) %> + (<%= fandom.count %>) + <% elsif @counts&.key?(fandom.id) %> + (<%= @counts[fandom.id] %>) + <% end %> +
      • + <% end %> +
      +
    2. + <% end %> +
    +<% else %> +

    <%= ts("No fandoms found") %>

    +<% end %> + diff --git a/app/views/fandoms/show.html.erb b/app/views/fandoms/show.html.erb new file mode 100644 index 0000000..2937e09 --- /dev/null +++ b/app/views/fandoms/show.html.erb @@ -0,0 +1,31 @@ + +

    <%= @fandom.name %>

    + + + + + +<% cache("/v1/#{@fandom.name}", :expires_in => 20.minutes, skip_digest: true) do %> + +

    <%= ts('Relationships by Character') %>

    +<% unless @characters.blank? %> +
      + <% for character in @characters %> +
    • +

      <%= link_to character.name, tag_works_path(character) %>

      + <% unless character.relationships.empty? %> +
        + <% for relationship in character.relationships %> +
      • <%= link_to relationship.name, tag_works_path(relationship) %>
      • + <% end %> +
      + <% end %> +
    • + <% end %> +
    +<% end %> +<% end %> + diff --git a/app/views/fandoms/unassigned.html.erb b/app/views/fandoms/unassigned.html.erb new file mode 100644 index 0000000..ec6f879 --- /dev/null +++ b/app/views/fandoms/unassigned.html.erb @@ -0,0 +1,62 @@ + +

    <%= ts("Fandoms in Need of a Wrangler") %>

    + + + +<% # Filters button for narrow screens jumps to filters when JavaScript is disabled and opens filters when JavaScript is enabled %> + + + +<%= will_paginate(@fandoms) %> + + +

    <%= ts("Listing Fandoms") %>

    + +<% unless @fandoms.blank? %> +
      + <% for fandom in @fandoms %> +
    • + <%= link_to fandom.name + " (#{fandom.count})", {:controller => :tags, :action => :show, :id => fandom.to_param} %> +
    • + <% end %> +
    +<% end %> + + +<%= form_tag unassigned_fandoms_path, + method: :get, + class: 'narrow-hidden filters', + id: 'fandom-filters' do %> + <%= field_set_tag ts('Filter results:') do %> +

    <%= ts('Filters') %>

    +
    +
    <%= ts("Submit") %>
    +
    <%= submit_tag ts('Sort and Filter') %>
    +
    <%= label_tag :sort, ts("Sort by") %>
    +
    + <%= select_tag :sort, options_for_select({'Name' => 'name', 'Work Count' => 'count'}, params[:sort]) %> +
    +
    <%= label_tag :media_id, ts("Media") %>
    +
    + <%= select_tag :media_id, options_for_select([''] + Media.canonical.by_name.map{|m| m.name}, params[:media_id]) %> +
    +
    <%= ts("Submit") %>
    +
    <%= submit_tag ts('Sort and Filter') %>
    +
    + <% end %> + <% # On narrow screens, link jumps to top of index when JavaScript is disabled and closes filters when JavaScript is enabled %> + +<% end %> + + +<%= will_paginate(@fandoms) %> \ No newline at end of file diff --git a/app/views/favorite_tags/_form.html.erb b/app/views/favorite_tags/_form.html.erb new file mode 100644 index 0000000..7c52c1c --- /dev/null +++ b/app/views/favorite_tags/_form.html.erb @@ -0,0 +1,13 @@ +<% # expects favorite_tag and current_user %> +<%= form_for([current_user, favorite_tag], + method: favorite_tag.new_record? ? 'post' : 'delete', + html: { + class: 'ajax-create-destroy', + data: { + create_value: ts('Favorite Tag'), + destroy_value: ts('Unfavorite Tag') + } + }) do |f| %> + <%= f.hidden_field :tag_id %> + <%= f.submit favorite_tag.new_record? ? ts('Favorite Tag') : ts('Unfavorite Tag') %> +<% end %> diff --git a/app/views/feedbacks/new.html.erb b/app/views/feedbacks/new.html.erb new file mode 100644 index 0000000..4392909 --- /dev/null +++ b/app/views/feedbacks/new.html.erb @@ -0,0 +1,99 @@ + +<%= error_messages_for :feedback %> + +

    <%= t(".heading.page_title") %>

    + + + +

    <%= t(".heading.landmark.reference") %>

    + + + + +<% if @admin_setting.disable_support_form %> +
    + <%= raw sanitize_field(@admin_setting, :disabled_support_form_text) %> +
    +<% else %> +

    <%= t(".heading.instructions") %>

    +
    +

    <%= t(".status.current", twitter_link: link_to(t(".status.twitter"), "https://twitter.com/AO3_Status"), tumblr_link: link_to(t(".status.tumblr"), "https://ao3org.tumblr.com")).html_safe %>

    +

    <%= t(".abuse.reports", contact_link: link_to(t(".abuse.contact"), new_abuse_report_path)).html_safe %>

    +

    <%= t(".reportable.intro") %>

    +
      +
    • <%= t(".reportable.fnok.concerning_html", fnok_link: link_to(t(".reportable.fnok.fnok"), tos_faq_path(anchor: "next_of_kin"))) %>
    • +
    • <%= t(".reportable.bugs") %>
    • +
    • <%= t(".reportable.site_questions") %>
    • +
    • <%= t(".reportable.account_creation") %>
    • +
    • <%= t(".reportable.lost_access") %>
    • +
    • <%= t(".reportable.tag_changes") %>
    • +
    • <%= t(".reportable.new_features") %>
    • +
    • <%= t(".reportable.orphaned_works") %>
    • +
    • <%= t(".reportable.work_problems") %>
    • +
    • <%= t(".reportable.policy_questions") %>
    • +
    +

    <%= t(".do_not_spam_html") %>

    +

    <%= t(".languages_html", list: @support_languages.map { |language| tag.span(language.name, lang: language.short) }.to_sentence.html_safe) %>

    +
    + <%= form_for(@feedback, html: {class: "post feedback"}) do |f| %> +
    + <%= t(".form.legend.contact_info") %> +
    +
    <%= f.label :username, t(".form.name.label") %>
    +
    + <%= f.text_field :username %> +
    +
    <%= f.label :email, t(".form.email.label") %>
    +
    + <%= f.text_field :email, size: 60 %> +
    +
    +
    + +
    + <%= t(".form.legend.feedback") %> + <%= f.hidden_field :referer %> +
    +
    + <%= f.label :language, t(".form.language.label") %> +
    +
    + <%= f.select(:language, language_options_for_select(@support_languages, "name"), + { selected: @feedback.language || Language.default.name }) %> +
    +
    + <%= f.label :summary, t(".form.summary.label") %> +
    +
    + <%= f.text_field :summary, size: 60, class: "observe_textlength" %> + <%= generate_countdown_html("feedback_summary", ArchiveConfig.FEEDBACK_SUMMARY_MAX_DISPLAYED) %> + <%= live_validation_for_field("feedback_summary", + failureMessage: t(".form.summary.error")) %> +
    +
    + <%= f.label :comment, t(".form.comment.label") %> +
    +
    +

    + <%= t(".form.comment.description") %> +

    + <%= f.text_area :comment, cols: 60, "aria-describedby" => "comment-field-description" %> + <%= live_validation_for_field("feedback_comment", + failureMessage: t(".form.comment.error")) %> +
    +
    +
    + +
    + <%= t(".form.legend.send") %> +

    + <%= f.submit t(".form.submit.active"), data: { disable_with: t(".form.submit.disabled") } %> +

    +
    + <% end %> +<% end %> + diff --git a/app/views/gifts/_gift_blurb.html.erb b/app/views/gifts/_gift_blurb.html.erb new file mode 100644 index 0000000..19be590 --- /dev/null +++ b/app/views/gifts/_gift_blurb.html.erb @@ -0,0 +1,10 @@ +<% # expects "work" and "gift" %> +
  • + <%= render "works/work_module", work: work %> + <% if @user && @user == current_user && (gift = work.gifts.where(:pseud_id => current_user.pseuds.pluck(:id)).first) %> +
    <%= ts("Recipient Actions") %>
    +

    + <%= link_to (gift.rejected? ? ts("Accept Gift") : ts("Refuse Gift")), toggle_rejected_gift_path(gift), method: :post %> +

    + <% end %> +
  • diff --git a/app/views/gifts/_gift_search.html.erb b/app/views/gifts/_gift_search.html.erb new file mode 100644 index 0000000..4bcc7b7 --- /dev/null +++ b/app/views/gifts/_gift_search.html.erb @@ -0,0 +1,10 @@ +<%= form_tag (@collection ? collection_gifts_path(@collection) : gifts_path), :method => :get, :id => 'gift-search', :class => 'simple search' do %> +
    + Find gifts for +

    + <%= label_tag :recipient, t('.gifts.recipient_field', :default => "Find gifts for: ")%> + <%= text_field_tag :recipient, params[:recipient], :class => 'text', :title => 'gift search' %> + <%= submit_tag t('.forms.gift_search', :default => 'Search'), :class => 'button', :name => nil %> +

    +
    +<% end %> diff --git a/app/views/gifts/index.html.erb b/app/views/gifts/index.html.erb new file mode 100644 index 0000000..df9c938 --- /dev/null +++ b/app/views/gifts/index.html.erb @@ -0,0 +1,42 @@ + +

    + <% if @user && @user == current_user && params[:refused] %> + <%= ts("Refused Gifts for %{recipient}", recipient: @user ? @user.login : h(@recipient_name)) %> + <% else %> + <%= ts("Gifts for %{recipient}", recipient: @user ? @user.login : h(@recipient_name)) %> + <% end %> + <%= @collection ? ts("in %{collection}", collection: h(@collection.title)) : "" %> +

    + + + +<% if @user && @user == current_user %> +

    <%= ts("Navigation") %>

    + +<% 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 100644 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 100644 index 0000000..f6eec5c --- /dev/null +++ b/app/views/home/_content.html.erb @@ -0,0 +1,322 @@ +<%# 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". %> +

    <%= t(".intro.archive_description") %>

    +

    + <%= t(".intro.our_goal_html", + maximum_inclusiveness_link: link_to(t(".intro.maximum_inclusiveness"), tos_faq_path(anchor: "max_inclusiveness"))) %> +

    +

    + <%= t(".intro.review_before_posting_html", + all_content_must_comply_bold: tag.strong(t(".intro.all_content_must_comply_html", + content_link: link_to(t(".intro.content"), tos_faq_path(anchor: "define_content")))), + tos_faq_link: link_to(t(".intro.tos_faq"), tos_faq_path(anchor: "content_faq"))) %> +

    +

    + <%= t(".intro.you_can_report_html", + report_it_to_us_link: link_to(t(".intro.report_it_to_us"), new_abuse_report_path), + we_do_not_prescreen_bold: tag.strong(t(".intro.we_do_not_prescreen"))) %> +

    + +<% unless local_assigns[:suppress_toc] %> + +<% end %> + +

    <%= t(".content_policy_heading") %>

    +

    <%= t(".offensive_content.heading") %>

    +

    <%= t(".offensive_content.removal_not_just_offensiveness") %>

    +

    + + <%= t(".offensive_content.tos_faq_in_parens_html", + tos_faq_link: link_to( + t(".offensive_content.tos_faq"), + tos_faq_path(anchor: "offensive_content_faq"), + aria: { + label: t(".offensive_content.tos_faq_link_label") + } + )) %> + +

    + +

    <%= t(".fanworks.heading") %>

    +

    + <%= t(".fanworks.must_be_fanworks_html", + non_fanwork_content_link: link_to(t(".fanworks.non_fanwork_content"), tos_faq_path(anchor: "non_fanwork_examples"))) %> +

    +

    + <%= t(".fanworks.bookmarks_only_fanworks_html", + bookmarks_link: link_to(t(".fanworks.bookmarks"), archive_faq_path("bookmarks")), + external_bookmarks_link: link_to(t(".fanworks.external_bookmarks"), archive_faq_path("bookmarks", anchor: "externalbookmark"))) %> +

    +

    + + <%= t(".fanworks.tos_faq_in_parens_html", + tos_faq_link: link_to( + t(".fanworks.tos_faq"), + tos_faq_path(anchor: "non_fanwork_faq"), + aria: { + label: t(".fanworks.tos_faq_link_label") + } + )) %> + +

    + +

    <%= t(".commercial_promotion.heading") %>

    +

    <%= t(".commercial_promotion.not_allowed") %>

    +

    + + <%= t(".commercial_promotion.tos_faq_in_parens_html", + tos_faq_link: link_to( + t(".commercial_promotion.tos_faq"), + tos_faq_path(anchor: "commercial_promotion_faq"), + aria: { + label: t(".commercial_promotion.tos_faq_link_label") + } + )) %> + +

    + +

    <%= t(".copyright_infringement.heading") %>

    +

    <%= t(".copyright_infringement.not_allowed") %>

    +

    <%= t(".copyright_infringement.epigraphs_small_quotations_allowed") %>

    +

    + <%= t(".copyright_infringement.transformative_works_legal_html", + transformative_fanworks_link: link_to(t(".copyright_infringement.transformative_fanworks"), tos_faq_path(anchor: "define_transformative"))) %> +

    +

    + + <%= t(".copyright_infringement.tos_faq_in_parens_html", + tos_faq_link: link_to( + t(".copyright_infringement.tos_faq"), + tos_faq_path(anchor: "copyright_plagiarism_faq"), + aria: { + label: t(".copyright_infringement.tos_faq_link_label") + } + )) %> + +

    + +

    <%= t(".plagiarism.heading") %>

    +

    + <%= t(".plagiarism.html", + their_expressions_of_their_ideas_link: link_to(t(".plagiarism.their_expressions_of_their_ideas"), tos_faq_path(anchor: "define_transformative"))) %> +

    +

    + + <%= t(".plagiarism.tos_faq_in_parens_html", + tos_faq_link: link_to( + t(".plagiarism.tos_faq"), + tos_faq_path(anchor: "copyright_plagiarism_faq"), + aria: { + label: t(".plagiarism.tos_faq_link_label") + } + )) %> + +

    + +

    <%= t(".personal_information.heading") %>

    +

    <%= t(".personal_information.not_allowed") %>

    +
      +
    1. + <%= t(".personal_information.revealing_orphaned_creator_html", + orphaned_link: link_to(t(".personal_information.orphaned"), archive_faq_path("glossary", anchor: "orphandef"))) %> +
    2. +
    3. <%= t(".personal_information.linking_fannish_identity") %>
    4. +
    5. <%= t(".personal_information.sharing_sufficient_information") %>
    6. +
    7. + <%= t(".personal_information.disclosing_personal_data_html", + special_categories_of_personal_data_link: link_to(t(".personal_information.special_categories_of_personal_data"), "https://gdpr-info.eu/art-9-gdpr/")) %> +
        +
      1. <%= t(".personal_information.rpf_exception") %>
      2. +
      +
    8. +
    +

    <%= t(".personal_information.right_to_hide_delete") %>

    +

    + + <%= t(".personal_information.tos_faq_in_parens_html", + tos_faq_link: link_to( + t(".personal_information.tos_faq"), + tos_faq_path(anchor: "identity_impersonation_faq"), + aria: { + label: t(".personal_information.tos_faq_link_label") + } + )) %> + +

    + +

    <%= t(".impersonation.heading") %>

    +

    + <%= t(".impersonation.html", + function_link: link_to(t(".impersonation.function"), tos_faq_path(anchor: "impersonate_function"))) %> +

    +

    + + <%= t(".impersonation.tos_faq_in_parens_html", + tos_faq_link: link_to( + t(".impersonation.tos_faq"), + tos_faq_path(anchor: "identity_impersonation_faq"), + aria: { + label: t(".impersonation.tos_faq_link_label") + } + )) %> + +

    + +

    <%= t(".harassment.heading") %>

    +

    <%= t(".harassment.definition") %>

    +

    <%= t(".harassment.not_allowed_and_context") %>

    +

    + <%= t(".harassment.threatening_versus_annoying_html", + blocking_link: link_to(t(".harassment.blocking"), tos_faq_path(anchor: "blocking")), + muting_link: link_to(t(".harassment.muting"), tos_faq_path(anchor: "muting")), + filtering_link: link_to(t(".harassment.filtering"), tos_faq_path(anchor: "filters"))) %> +

    +

    + <%= t(".harassment.policy_applicability_html", + applies_to_all_link: link_to(t(".harassment.applies_to_all"), tos_faq_path(anchor: "harassment_scope")), + otw_abbreviation: tag.abbr(t(".harassment.otw.abbreviated"), title: t(".harassment.otw.full"))) %> +

    +
    <%= t(".harassment.rpf.heading") %>
    +

    <%= t(".harassment.rpf.text") %> +

    <%= t(".harassment.advocating_harm.heading") %>
    +

    <%= t(".harassment.advocating_harm.text") %> +

    + + <%= t(".harassment.tos_faq_in_parens_html", + tos_faq_link: link_to( + t(".harassment.tos_faq"), + tos_faq_path(anchor: "harassment_faq"), + aria: { + label: t(".harassment.tos_faq_link_label") + } + )) %> + +

    + +

    <%= t(".user_icons.heading") %>

    +

    <%= t(".user_icons.text") %> +

    + + <%= t(".user_icons.tos_faq_in_parens_html", + tos_faq_link: link_to( + t(".user_icons.tos_faq"), + tos_faq_path(anchor: "username_icon_faq"), + aria: { + label: t(".user_icons.tos_faq_link_label") + } + )) %> + +

    + +

    <%= t(".mandatory_tags.heading") %>

    +
      +
    1. +

      + <%= t(".mandatory_tags.ao3_may_designate_html", + minimum_criteria_link: link_to(t(".mandatory_tags.minimum_criteria"), tos_faq_path(anchor: "minimum_tags"))) %> +

      +
    2. +
    3. +

      + <%= t(".mandatory_tags.choose_no_warnings_html", + rating_link: link_to(t(".mandatory_tags.rating"), tos_faq_path(anchor: "ratings_list")), + archive_warning_link: link_to(t(".mandatory_tags.archive_warning"), tos_faq_path(anchor: "warnings_list")), + non_specific_tags_link: link_to(t(".mandatory_tags.non_specific_tags"), tos_faq_path(anchor: "nonspecific_tags"))) %> +

      +
    4. +
    5. +

      + <%= t(".mandatory_tags.applying_nonspecific_tag_html", + any_archive_warning_link: link_to(t(".mandatory_tags.any_archive_warning"), tos_faq_path(anchor: "warnings_list"))) %> +

      +
    6. +
    7. +

      + <%= t(".mandatory_tags.tags_applied_automatically_html", + not_available_link: link_to(t(".mandatory_tags.not_available"), tos_faq_path(anchor: "no_language_tag_exists")), + tos_faq_link: link_to(t(".mandatory_tags.tos_faq"), tos_faq_path(anchor: "ratings_warnings_faq"))) %> +

      +
    8. +
    +

    + + <%= t(".mandatory_tags.tos_faq_in_parens_html", + tos_faq_link: link_to( + t(".mandatory_tags.tos_faq_endnote"), + tos_faq_path(anchor: "ratings_warnings_faq"), + aria: { + label: t(".mandatory_tags.tos_faq_link_label") + } + )) %> + +

    + +

    <%= t(".illegal_inappropriate_content.heading") %>

    +
      +
    1. +

      + <%= t(".illegal_inappropriate_content.no_illegal_content_html", + images_of_real_children_link: link_to(t(".illegal_inappropriate_content.images_of_real_children"), + tos_faq_path(anchor: "underage_images"))) %> +

      +

      + <%= t(".illegal_inappropriate_content.conduct_threatening_technical_integrity_html", + technical_integrity_link: link_to(t(".illegal_inappropriate_content.technical_integrity"), tos_faq_path(anchor: "technical_integrity_faq"))) %> +

      +
    2. +
    3. +

      + <%= t(".illegal_inappropriate_content.spamming_behavior") %> +

      +

      + <%= t(".illegal_inappropriate_content.automated_spam_check_html", + contact_ao3_administrators_link: link_to(t(".illegal_inappropriate_content.contact_ao3_administrators"), + new_abuse_report_path)) %> +

      +
    4. +
    +

    + <%= t(".illegal_inappropriate_content.violates_us_law_html", + report_it_to_us_link: link_to(t(".illegal_inappropriate_content.report_it_to_us"), new_abuse_report_path)) %> +

    +

    + + <%= t(".illegal_inappropriate_content.tos_faq_in_parens_html", + tos_faq_link: link_to( + t(".illegal_inappropriate_content.tos_faq"), + tos_faq_path(anchor: "offensive_content_faq"), + aria: { + label: t(".illegal_inappropriate_content.tos_faq_link_label") + } + )) %> + +

    + +
    + +<% unless local_assigns[:suppress_footer] %> +

    <%= t(".effective") %>

    +

    + + <%= t(".license_html", + terms_of_service_link: link_to(t(".terms_of_service"), tos_path), + content_policy_link: link_to(t(".content_policy"), content_path), + privacy_policy_link: link_to(t(".privacy_policy"), privacy_path), + cc_attribution_4_0_international_link: link_to(t(".cc_attribution_4_0_international"), + "https://creativecommons.org/licenses/by/4.0/", + rel: "nofollow")) %> + +

    +<% end %> diff --git a/app/views/home/_dmca.html.erb b/app/views/home/_dmca.html.erb new file mode 100644 index 0000000..978d039 --- /dev/null +++ b/app/views/home/_dmca.html.erb @@ -0,0 +1,188 @@ +

    + <%= t(".intro.purpose_html", + reproduce_unauthorized_link: link_to(t(".intro.reproduce_unauthorized"), tos_faq_path(anchor: "copyright_plagiarism_faq")), + uscode_link: link_to(t(".intro.uscode"), "https://www.law.cornell.edu/uscode/text/17/512")) %> +

    +

    + <%= t(".intro.legality_html", + requirements_link: link_to(t(".intro.requirements"), "#takedown_requirements"), + transformative_works_legal_link: link_to(t(".intro.transformative_works_legal"), "https://www.transformativeworks.org/faq/#faq-WhydoestheOTWbelievethattransformativeworksarelegal"), + contact_legal_link: link_to(t(".intro.contact_legal"), "https://www.transformativeworks.org/contact_us/?who=Legal%20Advocacy")) %> +

    + + + +

    <%= t(".removal.heading") %>

    +

    <%= t(".removal.intro") %>

    +
      +
    • + <%= t(".removal.abuse_report_html", + submit_abuse_bold: tag.strong(t(".removal.submit_abuse_html", + submit_abuse_link: link_to(t(".removal.submit_abuse"), new_abuse_report_path))), + tos_link: link_to(t(".removal.tos"), tos_path), + confidentiality_link: link_to(t(".removal.confidentiality"), tos_faq_path(anchor: "report_anonymity")), + tos_faq_link: link_to(t(".removal.tos_faq"), tos_faq_path(anchor: "report_infringement"))) %> +
    • +
    • + <%= t(".removal.dmca_html", + file_dmca_bold: tag.strong(t(".removal.file_dmca_html", + file_dmca_link: link_to(t(".removal.file_dmca"), "#takedown_instructions"))), + requirements_link: link_to(t(".removal.requirements"), "#takedown_requirements"), + not_confidential_link: link_to(t(".removal.not_confidential"), tos_faq_path(anchor: "dmca_complaint"))) %> +
    • +
    +

    + <%= t(".removal.do_not_spam_html", + do_not_bold: tag.strong(t(".removal.do_not"))) %> +

    + +

    <%= t(".takedown_instructions.heading") %>

    +

    + <%= t(".takedown_instructions.initiate_html", + contact_legal_link: link_to(t(".takedown_instructions.contact_legal"), "https://www.transformativeworks.org/contact_us/?who=Legal%20Advocacy")) %> +

    +
    +

    + <%= t(".takedown_instructions.address_org") %>
    + <%= t(".takedown_instructions.address_street") %>
    + <%= t(".takedown_instructions.address_state") %>
    + <%= t(".takedown_instructions.address_attn") %> +

    +
    +

    <%= t(".takedown_instructions.prefer") %>

    + +

    <%= t(".takedown_requirements.heading") %>

    +

    <%= t(".takedown_requirements.intro") %>

    +
      +
    1. <%= t(".takedown_requirements.signature") %>
    2. +
    3. <%= t(".takedown_requirements.contact") %>
    4. +
    5. <%= t(".takedown_requirements.source") %>
    6. +
    7. <%= t(".takedown_requirements.url") %>
    8. +
    9. <%= t(".takedown_requirements.good_faith") %>
    10. +
    11. +
        +
      1. <%= t(".takedown_requirements.authorized_html", + perjury_bold: tag.strong(t(".takedown_requirements.perjury"))) %>
      2. +
      3. <%= t(".takedown_requirements.accurate") %>
      4. +
      +
    12. +
    +

    <%= t(".takedown_requirements.liability") %>

    + +

    <%= t(".takedown_process.heading") %>

    +

    + <%= t(".takedown_process.valid_html", + requirements_link: link_to(t(".takedown_process.requirements"), "#takedown_requirements")) %> +

    +

    + <%= t(".takedown_process.notification_html", + lumen_link: link_to(t(".takedown_process.lumen"), "https://lumendatabase.org/")) %> +

    +

    <%= t(".takedown_process.dispute") %>

    +

    <%= t(".takedown_process.counternotify") %>

    + +

    <%= t(".appeal.heading") %>

    +

    + <%= t(".appeal.violations_html", + copyright_link: link_to(t(".appeal.copyright"), content_path(anchor: "II.D")), + plagiarism_link: link_to(t(".appeal.plagiarism"), content_path(anchor: "II.E")), + tos_link: link_to(t(".appeal.tos"), tos_path), + tos_faq_link: link_to(t(".appeal.tos_faq"), tos_faq_path(anchor: "copyright_plagiarism_faq"))) %> +

    +

    + <%= t(".appeal.removal_html", + notify_link: link_to(t(".appeal.notify"), tos_faq_path(anchor: "complaint_notification")), + lumen_link: link_to(t(".appeal.lumen"), "https://lumendatabase.org/")) %> +

    +

    + <%= t(".appeal.reupload_html", + dispute_bold: tag.strong(t(".appeal.dispute_html", cannot_italic: tag.em(t(".appeal.cannot")))), + suspend_link: link_to(t(".appeal.suspend"), "#repeat_offenses")) %> +

    + +

    <%= t(".counternotice_instructions.heading") %>

    +

    + <%= t(".counternotice_instructions.dispute_html", + defend_bold: tag.strong(t(".counternotice_instructions.defend"))) %> +

    +

    + <%= t(".counternotice_instructions.consider_html", + lumen_faq_link: link_to(t(".counternotice_instructions.lumen_faq"), "https://lumendatabase.org/topics/14")) %> +

    +

    + <%= t(".counternotice_instructions.initiate_html", + contact_legal_link: link_to(t(".counternotice_instructions.contact_legal"), "https://www.transformativeworks.org/contact_us/?who=Legal%20Advocacy")) %> +

    +
    +

    + <%= t(".counternotice_instructions.address_org") %>
    + <%= t(".counternotice_instructions.address_street") %>
    + <%= t(".counternotice_instructions.address_state") %>
    + <%= t(".counternotice_instructions.address_attn") %> +

    +
    +

    <%= t(".counternotice_instructions.prefer") %>

    +

    <%= t(".counternotice_instructions.time_limit") %>

    + +

    <%= t(".counternotice_requirements.heading") %>

    +

    <%= t(".counternotice_requirements.intro") %>

    +
      +
    1. <%= t(".counternotice_requirements.signature") %>
    2. +
    3. <%= t(".counternotice_requirements.contact") %>
    4. +
    5. <%= t(".counternotice_requirements.url") %>
    6. +
    7. <%= t(".counternotice_requirements.mistake_html", + perjury_bold: tag.strong(t(".counternotice_requirements.perjury"))) %> +
    8. +
    9. <%= t(".counternotice_requirements.consent") %> +
        +
      1. <%= t(".counternotice_requirements.usa_resident") %>
      2. +
      3. + <%= t(".counternotice_requirements.non_us_resident_html", + new_york_link: link_to(t(".counternotice_requirements.new_york"), tos_path(anchor: "I.A.3"))) %> +
      4. +
      +
    10. +
    11. <%= t(".counternotice_requirements.service") %>
    12. +
    +

    <%= t(".counternotice_requirements.liability") %>

    + +

    <%= t(".counternotice_process.heading") %>

    +

    + <%= t(".counternotice_process.valid") %> +

    +

    + <%= t(".counternotice_process.notification_html", + lumen_link: link_to(t(".counternotice_process.lumen"), "https://lumendatabase.org/")) %> +

    + +

    <%= t(".repeat.heading") %>

    +

    + <%= t(".repeat.required_html", + suspend_link: link_to(t(".repeat.suspend"), tos_faq_path(anchor: "penalty"))) %> +

    +
    +

    + + <%= t(".license.credit_html", + cc_license_link: link_to(t(".license.cc_license"), "https://creativecommons.org/licenses/by-sa/4.0/")) %> + +

    diff --git a/app/views/home/_fandoms.html.erb b/app/views/home/_fandoms.html.erb new file mode 100644 index 0000000..6d073c8 --- /dev/null +++ b/app/views/home/_fandoms.html.erb @@ -0,0 +1,11 @@ +
      +
    • <%= link_to t(".all_fandoms"), media_index_path %>
    • + <%# When changing the cache key, also update Media#expire_caches %> + <% cache "homepage-fandoms-version2", skip_digest: true do %> + <% Media.for_menu.each do |medium| %> + <% unless medium.id.nil? %> +
    • <%= link_to medium.name, media_fandoms_path(medium) %>
    • + <% end %> + <% end %> + <% end %> +
    diff --git a/app/views/home/_inbox_module.html.erb b/app/views/home/_inbox_module.html.erb new file mode 100644 index 0000000..ee0847e --- /dev/null +++ b/app/views/home/_inbox_module.html.erb @@ -0,0 +1,41 @@ +<% # expects inbox_comments %> +
    +

    + <%= ts('Unread messages') %> + <%= link_to ts('My Inbox'), user_inbox_path(current_user) %> +

    +
    +

    <%= ts('The latest unread items from your inbox.') %>

    +
      + <% inbox_comments.each do |inbox_comment| %> +
    • + <% if !can_see_hidden_comment?(inbox_comment.feedback_comment) %> +

      <%= ts("(This comment is under review by an admin and is currently unavailable.)") %>

      + <% else %> + <%= render 'inbox/inbox_comment_contents', feedback_comment: inbox_comment.feedback_comment %> + <% end %> +
        + <% if inbox_comment.feedback_comment.iced? %> +
      • <%= frozen_comment_indicator %>
      • + <% end %> + <% if inbox_comment.feedback_comment.unreviewed? %> + + <% elsif can_reply_to_comment?(inbox_comment.feedback_comment) %> +
      • + <%= render 'inbox/reply_button', feedback_comment: inbox_comment.feedback_comment %> +
      • + <% end %> +
      • + <%= render 'inbox/read_form', inbox_comment: inbox_comment, current_user: current_user %> +
      • +
      • + <%= render 'inbox/delete_form', inbox_comment: inbox_comment, current_user: current_user %> +
      • +
      +
    • + <% end %> +
    + +
    diff --git a/app/views/home/_intro_module.html.erb b/app/views/home/_intro_module.html.erb new file mode 100644 index 0000000..5be848f --- /dev/null +++ b/app/views/home/_intro_module.html.erb @@ -0,0 +1,40 @@ +
    +

    <%= ts("A fan-created, fan-run, nonprofit, noncommercial archive for transformative fanworks, like fanfiction, fanart, fan videos, and podfic") %>

    +

    <%= ts("more than %{fandom_count} fandoms | %{user_count} users | %{work_count} works", fandom_count: content_tag(:span, number_with_delimiter(@fandom_count), class: "count"), user_count: content_tag(:span, number_with_delimiter(@user_count), class: "count"), work_count: content_tag(:span, number_with_delimiter(@work_count), class: "count")).html_safe %>

    +

    <%= ts("Symphony is a project of ") %> <%= link_to ts("Agnes the Alien."), "http://kissing.computer" %>.

    + + +
    diff --git a/app/views/home/_news_module.html.erb b/app/views/home/_news_module.html.erb new file mode 100644 index 0000000..4ac4b39 --- /dev/null +++ b/app/views/home/_news_module.html.erb @@ -0,0 +1,35 @@ +<% # expects admin_posts %> +
    +

    + <%= ts('News') %> + <%= link_to ts('All News'), admin_posts_path %> +

    +
      + <% admin_posts.each do |admin_post| %> +
    • +
      +

      + <%= link_to admin_post.title.html_safe, admin_post %> +

      +

      + + <%= ts('Published:') %> <%= time_in_zone(admin_post.created_at) %> + + + <%= ts('Comments:') %> <%= link_to(admin_post.count_visible_comments, polymorphic_path(admin_post, show_comments: true, anchor: :comments)) %> + +

      +
      +
      + <%= first_paragraph(admin_post.content, 'No preview is available for this news post.') %> +
      +

      + <%= link_to ts('Read more...'), + admin_post, + id: "post_#{admin_post.id}_more", + :"aria-labelledby" => "post_#{admin_post.id}_more post_#{admin_post.id}_title" %> +

      +
    • + <% end %> +
    +
    diff --git a/app/views/home/_privacy.html.erb b/app/views/home/_privacy.html.erb new file mode 100644 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". %> +

    <%= t(".intro.archive_description") %>

    +

    + <%= t(".intro.ao3_exists_to_host_html", + personal_information_link: link_to(t(".intro.personal_information"), "#III.A.1")) %> +

    +
      +
    • <%= t(".intro.host_your_fanworks") %>
    • +
    • <%= t(".intro.show_you_works") %>
    • +
    • <%= t(".intro.enable_post_information") %>
    • +
    +

    + <%= t(".intro.details_how_and_why_html", + common_questions_bold: tag.strong(t(".intro.answers_common_questions_html", + tos_faq_link: link_to(t(".intro.tos_faq"), tos_faq_path(anchor: "privacy_faq"))))) %> +

    + +<% unless local_assigns[:suppress_toc] %> + +<% end %> + +

    <%= t(".privacy_policy_heading") %>

    +

    <%= t(".applicability.heading") %>

    +
      +
    1. <%= t(".applicability.policy_covers") %>

    2. +
    3. +

      + <%= t(".applicability.global_subprocessors_html", + subprocessors_link: link_to(t(".applicability.subprocessors"), + "https://www.transformativeworks.org/otw_tos/organization-for-transformative-works-subprocessor-list/")) %> +

      +

      + <%= t(".applicability.transfers_necessary_html", consent_to_us_processing_bold: tag.strong(t(".applicability.consent_to_us_processing"))) %> +

      +
    4. +
    + +

    <%= t(".information_scope.heading") %>

    +
      +
    1. +

      + <%= t(".information_scope.information_in_content_html", + content_link: link_to(t(".information_scope.content"), tos_path(anchor: "I.A.1")), + special_categories_link: link_to(t(".information_scope.special_categories"), "https://gdpr-info.eu/art-9-gdpr/")) %> +

      +
    2. +
    3. <%= t(".information_scope.collect_through_use") %>

    4. +
    + +

    <%= t(".types_of_information.heading") %>

    +
      +
    1. +

      <%= t(".types_of_information.emails.heading") %>

      +

      <%= t(".types_of_information.emails.collect_process_retain") %>

      +

      + <%= t(".types_of_information.emails.address_usage_html", + challenge_link: link_to(t(".types_of_information.emails.challenge"), archive_faq_path("glossary", anchor: "challengedef"))) %> +

      +

      <%= t(".types_of_information.emails.unsubscribe") %>

      +
    2. +
    3. +

      + <%= t(".types_of_information.ip_addresses.heading") %> + <%= t(".types_of_information.ip_addresses.text") %> +

      +
    4. +
    5. +

      + <%= t(".types_of_information.logs.heading") %> + <%= t(".types_of_information.logs.text") %> +

      +
    6. +
    7. +

      + <%= t(".types_of_information.cookies.heading") %> + <%= t(".types_of_information.cookies.text") %> +

      +
    8. +
    9. +

      + <%= link_to t(".types_of_information.fnok.heading"), archive_faq_path("fannish-next-of-kin") %> + <%= t(".types_of_information.fnok.text") %> +

      +
    10. +
    11. +

      + <%= t(".types_of_information.other_information.heading") %> +

      +

      <%= t(".types_of_information.other_information.to_maintain_integrity") %>

      +

      + <%= t(".types_of_information.other_information.to_make_content_available_html", + tos_faq_link: link_to(t(".types_of_information.other_information.tos_faq"), tos_faq_path(anchor: "feature_information"))) %> +

      +
    12. +
    + +

    <%= t(".aggregate_anonymous_info.heading") %>

    +
      +
    1. <%= t(".aggregate_anonymous_info.understand_ao3_usage") %>

    2. +
    3. <%= t(".aggregate_anonymous_info.anonymous_non_personal") %>

    4. +
    + +

    <%= t(".your_rights.heading") %>

    +
      +
    1. +

      + <%= t(".your_rights.request_data_html", + applicable_jurisdiction_link: link_to(t(".your_rights.applicable_jurisdiction"), tos_faq_path(anchor: "privacy_rights"))) %> +

      +
    2. +
    3. +

      + <%= t(".your_rights.potential_other_rights_html", + other_rights_link: link_to(t(".your_rights.other_rights"), tos_faq_path(anchor: "privacy_rights_faq"))) %> +

      +
    4. +
    5. <%= t(".your_rights.require_user_specific_proof") %>

    6. +
    + +

    <%= t(".third_parties.heading") %>

    +
      +
    1. <%= t(".third_parties.do_not_sell_information") %>

    2. +
    3. <%= t(".third_parties.third_party_tools") %>

    4. +
    5. +

      <%= t(".third_parties.sharing_exceptions.intro") %>

      +
        +
      1. +

        + <%= t(".third_parties.sharing_exceptions.external_processing.heading") %> + <%= t(".third_parties.sharing_exceptions.external_processing.html", + subprocessor_list_link: link_to(t(".third_parties.sharing_exceptions.external_processing.subprocessor_list"), "https://www.transformativeworks.org/otw_tos/organization-for-transformative-works-subprocessor-list/")) %> +

        +
      2. +
      3. +

        + + <%= t(".third_parties.sharing_exceptions.challenge_signup.heading_html", + challenge_link: link_to(t(".third_parties.sharing_exceptions.challenge_signup.challenge"), archive_faq_path("glossary", anchor: "challengedef"))) %> + + <%= t(".third_parties.sharing_exceptions.challenge_signup.text") %> +

        +
      4. +
      5. +

        + + <%= t(".third_parties.sharing_exceptions.open_doors_import.heading_html", + open_doors_link: link_to(t(".third_parties.sharing_exceptions.open_doors_import.open_doors"), "https://opendoors.transformativeworks.org/")) %> + + <%= t(".third_parties.sharing_exceptions.open_doors_import.text") %> +

        +
      6. +
      7. +

        + <%= t(".third_parties.sharing_exceptions.handle_complaints.heading") %> + <%= t(".third_parties.sharing_exceptions.handle_complaints.html", + dmca_notice_link: link_to(t(".third_parties.sharing_exceptions.handle_complaints.dmca_notice"), tos_faq_path(anchor: "dmca_complaint")), + pac_confidentiality_policy_link: link_to(t(".third_parties.sharing_exceptions.handle_complaints.pac_confidentiality_policy"), "https://www.transformativeworks.org/committees/policy-abuse-confidentiality-policy/")) %> +

        +
      8. + +
      9. + <%= t(".third_parties.sharing_exceptions.legal_reasons.heading") %> + <%= t(".third_parties.sharing_exceptions.legal_reasons.intro") %> +
          +
        1. <%= t(".third_parties.sharing_exceptions.legal_reasons.legally_compelled") %>
        2. +
        3. <%= t(".third_parties.sharing_exceptions.legal_reasons.good_faith_comply") %>
        4. +
        5. <%= t(".third_parties.sharing_exceptions.legal_reasons.cooperating_law_enforcement") %>
        6. +
        +

        <%= t(".third_parties.sharing_exceptions.legal_reasons.law_enforcement_cooperation_details") %>

        +

        <%= t(".third_parties.sharing_exceptions.legal_reasons.attempt_to_notify") %>

        +
      10. +
      +
    6. +
    + +

    <%= t(".account_termination.heading") %>

    +
      +
    1. +

      + <%= t(".account_termination.deletion_after_termination_html", + terminate_your_account_link: link_to(t(".account_termination.terminate_your_account"), archive_faq_path("your-account", anchor: "deleteaccount"))) %> +

      +
        +
      1. +

        + <%= t(".account_termination.orphans_excluded_html", + orphan_link: link_to(t(".account_termination.orphan"), archive_faq_path("orphaning")), + pseud_link: link_to(t(".account_termination.pseud"), archive_faq_path("pseuds", anchor: "whatisapseud"))) %> +

        +
      2. +
      3. +

        + <%= t(".account_termination.backup_copies_html", + general_principles_link: link_to(t(".account_termination.general_principles"), tos_path(anchor: "I.E.2"))) %> +

        +
      4. +
      +
    2. +
    3. <%= t(".account_termination.legal_enforcement_retention") %>

    4. +
    + +

    <%= t(".retention_of_information.heading") %>

    +

    <%= t(".retention_of_information.text") %>

    + +

    <%= t(".contact_us.heading") %>

    +

    + <%= t(".contact_us.html", + contact_pac_link: link_to(t(".contact_us.contact_pac"), new_abuse_report_path)) %> +

    + +
    +

    <%= t(".effective") %>

    +

    + + <%= t(".license_html", + terms_of_service_link: link_to(t(".terms_of_service"), tos_path), + content_policy_link: link_to(t(".content_policy"), content_path), + privacy_policy_link: link_to(t(".privacy_policy"), privacy_path), + cc_attribution_4_0_international_link: link_to(t(".cc_attribution_4_0_international"), + "https://creativecommons.org/licenses/by/4.0/", + rel: "nofollow")) %> + +

    diff --git a/app/views/home/_tos.html.erb b/app/views/home/_tos.html.erb new file mode 100644 index 0000000..817776b --- /dev/null +++ b/app/views/home/_tos.html.erb @@ -0,0 +1,350 @@ +<%# 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". %> +

    <%= t(".archive_description") %>

    + +

    <%= t(".what_we_believe.header") %>

    +
      +
    1. + <%= t(".what_we_believe.our_goal_html", + maximum_inclusiveness_link: link_to(t(".what_we_believe.maximum_inclusiveness"), tos_faq_path(anchor: "max_inclusiveness"))) %> +
    2. +
    3. + <%= t(".what_we_believe.ao3_run_by_html", + defending_fanworks_link: link_to(t(".what_we_believe.defending_fanworks"), "https://www.transformativeworks.org/faq/#faq-legalfaq"), + on_the_otw_site_link: link_to(t(".what_we_believe.on_the_otw_site"), "https://www.transformativeworks.org/legal")) %> +
    4. +
    5. + <%= t(".what_we_believe.we_do_not_sell_html", + ao3_link: link_to("archiveofourown.org", root_path), + fanlore_link: link_to("fanlore.org", "https://fanlore.org/"), + transformativeworks_link: link_to("transformativeworks.org", "https://www.transformativeworks.org/")) %> +
    6. +
    7. + <%= t(".what_we_believe.readability_html", + tos_faq_link: link_to(t(".what_we_believe.tos_faq_html", + faq_abbreviation: tag.abbr(t(".what_we_believe.faq.abbreviated"), title: t(".what_we_believe.faq.full"))), + tos_faq_path)) %> +
    8. +
    + + + +

    <%= t(".general_principles_heading") %>

    +

    <%= t(".general_terms.heading") %>

    +
      +
    1. +

      + <%= t(".general_terms.agreement.html", + agreement: tag.strong(t(".general_terms.agreement.agreement")), + content_policy_link: link_to(t(".general_terms.agreement.content_policy"), content_path), + privacy_policy_link: link_to(t(".general_terms.agreement.privacy_policy"), privacy_path), + personal_information_link: link_to(t(".general_terms.agreement.personal_information"), privacy_path(anchor: "III.A.1")), + any_other_form_link: link_to(t(".general_terms.agreement.any_other_form"), tos_faq_path(anchor: "define_content"))) %> +

      +
    2. +
    3. +

      + <%= t(".general_terms.entirety_of_agreement.html", + entirety_of_agreement: tag.strong(t(".general_terms.entirety_of_agreement.entirety_of_agreement"))) %> +

      +
    4. +
    5. +

      + <%= t(".general_terms.jurisdiction.html", + jurisdiction: tag.strong(t(".general_terms.jurisdiction.jurisdiction")), + the_state_of_new_york_link: link_to(t(".general_terms.jurisdiction.the_state_of_new_york"), tos_faq_path(anchor: "ny_law"))) %> +

      +
    6. +
    7. +

      + <%= t(".general_terms.non_severability.html", + non_severability: tag.strong(t(".general_terms.non_severability.non_severability"))) %> +

      +
    8. +
    9. +

      + <%= t(".general_terms.limitation_on_claims.html", + limitation_on_claims: tag.strong(t(".general_terms.limitation_on_claims.limitation_on_claims"))) %> +

      +
    10. +
    11. +

      + <%= t(".general_terms.no_assignment.html", + no_assignment: tag.strong(t(".general_terms.no_assignment.no_assignment"))) %> +

      +
    12. +
    + +

    <%= t(".updates_to_the_tos.heading") %>

    +

    + <%= t(".updates_to_the_tos.html", + content_policy_link: link_to(t(".updates_to_the_tos.content_policy"), content_path), + privacy_policy_link: link_to(t(".updates_to_the_tos.privacy_policy"), privacy_path)) %> +

    + +

    <%= t(".potential_problems.heading") %>

    +
      +
    1. <%= t(".potential_problems.service_as_is") %>

    2. +
    3. <%= t(".potential_problems.breach_notification") %>

    4. +
    5. <%= t(".potential_problems.own_risk") %>

    6. +
    7. +

      + + <%= t(".potential_problems.disclaim_warranties_html", + merchantability_link: link_to(t(".potential_problems.merchantability"), tos_faq_path(anchor: "merchantability")), + fitness_for_purpose_link: link_to(t(".potential_problems.fitness_for_purpose"), tos_faq_path(anchor: "fitness"))) %> + +

      +
    8. +
    9. <%= t(".potential_problems.damage_liability") %>

    10. +
    11. <%= t(".potential_problems.account_termination_liability") %>

    12. +
    13. <%= t(".potential_problems.content_access_liability") %>

    14. +
    15. <%= t(".potential_problems.not_personal_storage_html", sole_backup_responsibility: tag.strong(t(".potential_problems.sole_backup_responsibility"))) %>

    16. +
    + +

    <%= t(".content_you_access.heading") %>

    +
      +
    1. <%= t(".content_you_access.external_links_html", here_link: link_to(t(".content_you_access.here"), "https://www.transformativeworks.org/where-find-us/")) %>

    2. +
    3. +

      + <%= t(".content_you_access.third_party_content_html", + hosted_by_third_party_link: link_to(t(".content_you_access.hosted_by_third_party"), tos_faq_path(anchor: "nontextual_fanworks")), + content_policy_link: link_to(t(".content_you_access.content_policy"), content_path), + privacy_policy_link: link_to(t(".content_you_access.privacy_policy"), privacy_path)) %> +

      +
    4. +
    5. <%= t(".content_you_access.no_prescreen") %>

    6. +
    7. +

      + <%= t(".content_you_access.no_otw_endorsement_html", + official_statement_link: link_to(t(".content_you_access.official_statement"), tos_faq_path(anchor: "official_statement"))) %> +

      +
    8. +
    9. <%= t(".content_you_access.otw_not_liable") %>

    10. +
    + +

    <%= t(".what_we_do_with_content.heading") %>

    +

    <%= t(".what_we_do_with_content.no_copyright_ownership_html", we_repeat: tag.strong(t(".what_we_do_with_content.we_repeat"))) %>

    +
      +
    1. +

      + <%= t(".what_we_do_with_content.agree_otw_can_copy_html", + worldwide_royalty_free_nonexclusive_license_link: link_to(t(".what_we_do_with_content.worldwide_royalty_free_nonexclusive_license"), tos_faq_path(anchor: "nonexclusive_license")), + modifying_or_adapting_link: link_to(t(".what_we_do_with_content.modifying_or_adapting"), tos_faq_path(anchor: "modify_adapt")), + tag_wrangling_link: link_to(t(".what_we_do_with_content.tag_wrangling"), archive_faq_path("tags", anchor: "wrangling"))) %> +

      +
    2. +
    3. <%= t(".what_we_do_with_content.license_duration") %>

    4. +
    5. +

      + <%= t(".what_we_do_with_content.content_not_completely_controlled_html", + orphan_link: link_to(t(".what_we_do_with_content.orphan"), archive_faq_path("orphaning")), + challenge_link: link_to(t(".what_we_do_with_content.challenge"), archive_faq_path("glossary", anchor: "challengedef")), + subject_to_moderation_link: link_to(t(".what_we_do_with_content.subject_to_moderation"), "https://www.transformativeworks.org/otw-news-post-moderation-policy/"), + rules_for_removing_such_content_link: link_to(t(".what_we_do_with_content.rules_for_removing_such_content"), tos_faq_path(anchor: "partial_control"))) %> +

      +
    6. +
    7. +

      + <%= t(".what_we_do_with_content.some_content_open_doors_html", + open_doors_link: link_to(t(".what_we_do_with_content.open_doors"), "https://opendoors.transformativeworks.org/en/")) %> +

      +
    8. +
    9. <%= t(".what_we_do_with_content.preserve_for_legal_reasons_html", privacy_policy_link: link_to(t(".what_we_do_with_content.privacy_policy"), privacy_path)) %>

    10. +
    + +

    <%= t(".what_you_cant_do.heading") %>

    +

    <%= t(".what_you_cant_do.you_agree_not_to") %>

    +
      +
    1. +

      + <%= t(".what_you_cant_do.content_violating_policy_html", + content_policy_link: link_to(t(".what_you_cant_do.content_policy"), content_path)) %> +

      +
    2. +
    3. +

      + <%= t(".what_you_cant_do.impersonate_person_or_entity_html", + impersonate_any_person_or_entity_link: link_to(t(".what_you_cant_do.impersonate_any_person_or_entity"), content_path(anchor: "II.G")), + function_link: link_to(t(".what_you_cant_do.function"), tos_faq_path(anchor: "impersonate_function"))) %> +

      +
    4. +
    5. <%= t(".what_you_cant_do.forge_identifiers") %>

    6. +
    7. +

      + <%= t(".what_you_cant_do.copyright_infringement_html", + infringement_of_a_copyright_link: link_to(t(".what_you_cant_do.infringement_of_a_copyright"), content_path(anchor: "II.D")), + position_on_fanwork_legality_link: link_to(t(".what_you_cant_do.position_on_fanwork_legality"), "https://www.transformativeworks.org/faq/#faq-WhydoestheOTWbelievethattransformativeworksarelegal")) %> +

      +
    8. +
    9. +

      + <%= t(".what_you_cant_do.commercial_activity_html", + making_available_any_advertising_link: link_to(t(".what_you_cant_do.making_available_any_advertising"), content_path(anchor: "II.C"))) %> +

      +
    10. +
    11. <%= t(".what_you_cant_do.software_viruses") %>

    12. +
    13. +

      + <%= t(".what_you_cant_do.interfere_disrupt_ao3_html", + interfere_disrupt_ao3_link: link_to(t(".what_you_cant_do.interfere_disrupt_ao3"), content_path(anchor: "technical_integrity"))) %> +

      +
    14. +
    15. +

      + <%= t(".what_you_cant_do.account_if_age_barred_html", + age_barred_individual_link: link_to(t(".what_you_cant_do.age_barred_individual"), "#age")) %> +

      +
    16. +
    17. +

      + <%= t(".what_you_cant_do.resident_embargo_country_html", + comprehensive_trade_embargo_link: link_to(t(".what_you_cant_do.comprehensive_trade_embargo"), tos_faq_path(anchor: "trade_embargo"))) %> +

      +
    18. +
    19. <%= t(".what_you_cant_do.break_applicable_law") %>

    20. +
    + +

    <%= t(".registration_and_email_addresses.heading") %>

    +
      +
    1. +

      + <%= t(".registration_and_email_addresses.agree_current_address_html", + suspend_your_account_link: link_to(t(".registration_and_email_addresses.suspend_your_account"), tos_faq_path(anchor: "invalid_email"))) %> +

      +
    2. +
    3. +

      + <%= t(".registration_and_email_addresses.email_is_yours_html", + lawfully_communicate_with_you_link: link_to(t(".registration_and_email_addresses.lawfully_communicate_with_you"), privacy_path(anchor: "III.C.1"))) %> +

      +
    4. +
    + +

    <%= t(".age_policy.heading") %>

    +

    <%= t(".age_policy.intro") %>

    +
      +
    1. <%= t(".age_policy.individuals_under_13") %>
    2. +
    3. + <%= t(".age_policy.individuals_under_16") %> +
        +
      1. + <%= t(".age_policy.eu_country_html", + special_categories_of_personal_data_link: link_to(t(".age_policy.special_categories_of_personal_data"), "https://gdpr-info.eu/art-9-gdpr/")) %> +
      2. +
      3. <%= t(".age_policy.country_disallowing_childrens_data") %>
      4. +
      +
    4. +
    +

    + <%= t(".age_policy.age_barred_not_permitted_html", + not_permitted_account_upload_link: link_to(t(".age_policy.not_permitted_account_upload"), tos_faq_path(anchor: "age_faq"))) %> +

    +

    <%= t(".age_policy.addressing_violations") %>

    +

    <%= t(".age_policy.ask_parent_to_upload") %>

    + +

    <%= t(".abuse_policy.heading") %>

    +

    + <%= t(".abuse_policy.no_prescreen_html", + content_policy_link: link_to(t(".abuse_policy.content_policy"), content_path)) %> +

    +

    + <%= t(".abuse_policy.answers_common_questions_html", + tos_faq_link: link_to(t(".abuse_policy.tos_faq"), tos_faq_path(anchor: "policy_procedures_faq"))) %> +

    +
      +
    1. + <%= t(".abuse_policy.submitting_a_complaint.heading") %> +

      + <%= t(".abuse_policy.submitting_a_complaint.html", + policy_and_abuse_form_link: link_to(t(".abuse_policy.submitting_a_complaint.policy_and_abuse_form"), new_abuse_report_path), + dmca_policy_link: link_to(t(".abuse_policy.submitting_a_complaint.dmca_policy"), dmca_path)) %> +

      +
    2. +
    3. + <%= t(".abuse_policy.treatment_of_complaints.heading") %> +

      + <%= t(".abuse_policy.treatment_of_complaints.html", + privacy_policy_link: link_to(t(".abuse_policy.treatment_of_complaints.privacy_policy"), privacy_path), + pac_confidentiality_policy_link: link_to(t(".abuse_policy.treatment_of_complaints.pac_confidentiality_policy"), + "https://www.transformativeworks.org/committees/policy-abuse-confidentiality-policy/")) %> +

      +
    4. +
    5. + <%= t(".abuse_policy.resolution_of_complaints.heading") %> +

      + <%= t(".abuse_policy.resolution_of_complaints.administrators_determine_content_removal_html", + determine_removal_link: link_to(t(".abuse_policy.resolution_of_complaints.determine_removal"), + tos_faq_path(anchor: "determine_removal"))) %> +

      +

      + <%= t(".abuse_policy.resolution_of_complaints.potentially_legitimate_fanwork_html", + illegal_and_inappropriate_content_policy_link: link_to(t(".abuse_policy.resolution_of_complaints.illegal_and_inappropriate_content_policy"), + content_path(anchor: "II.K"))) %> +

      +

      <%= t(".abuse_policy.resolution_of_complaints.voluntary_removal") %>

      +

      + <%= t(".abuse_policy.resolution_of_complaints.add_or_edit_tags_html", + mandatory_tags_policy_link: link_to(t(".abuse_policy.resolution_of_complaints.mandatory_tags_policy"), + content_path(anchor: "II.J"))) %> +

      +

      <%= t(".abuse_policy.resolution_of_complaints.immediate_removal") %>

      +
    6. +
    7. + <%= t(".abuse_policy.penalties.heading") %> +

      + <%= t(".abuse_policy.penalties.violations_warnings_suspensions_html", + tos_faq_link: link_to(t(".abuse_policy.penalties.tos_faq"), tos_faq_path(anchor: "penalty"))) %> +

      +

      + <%= t(".abuse_policy.penalties.open_doors_removal_html", + content_policy_ii_k_1_link: link_to(t(".abuse_policy.penalties.content_policy_ii_k_1"), content_path(anchor: "II.K.1"))) %> +

      +

      + <%= t(".abuse_policy.penalties.remove_resolve_lawsuit_html", + age_barred_individual_link: link_to(t(".abuse_policy.penalties.age_barred_individual"), "#age")) %> +

      +

      + <%= t(".abuse_policy.penalties.non_violating_content_html", + illegal_inappropriate_content_policy_link: link_to(t(".abuse_policy.penalties.illegal_inappropriate_content_policy"), + content_path(anchor: "II.K"))) %> +

      +

      <%= t(".abuse_policy.penalties.edit_post_while_suspended") %>

      +
    8. +
    9. + <%= t(".abuse_policy.appeals.heading") %> +

      + <%= t(".abuse_policy.appeals.html", + appeal_decision_link: link_to(t(".abuse_policy.appeals.appeal_decision"), tos_faq_path(anchor: "appeal"))) %> +

      +
    10. +
    + +
    + +<% unless local_assigns[:suppress_footer] %> +

    <%= t(".effective") %>

    +

    + + <%= t(".license_html", + terms_of_service_link: link_to(t(".terms_of_service"), tos_path), + content_policy_link: link_to(t(".content_policy"), content_path), + privacy_policy_link: link_to(t(".privacy_policy"), privacy_path), + cc_attribution_4_0_international_link: link_to(t(".cc_attribution_4_0_international"), + "https://creativecommons.org/licenses/by/4.0/", + rel: "nofollow")) %> + +

    +<% end %> diff --git a/app/views/home/_tos_navigation.html.erb b/app/views/home/_tos_navigation.html.erb new file mode 100644 index 0000000..6f325eb --- /dev/null +++ b/app/views/home/_tos_navigation.html.erb @@ -0,0 +1,9 @@ + diff --git a/app/views/home/about.html.erb b/app/views/home/about.html.erb new file mode 100644 index 0000000..32a21ff --- /dev/null +++ b/app/views/home/about.html.erb @@ -0,0 +1,7 @@ + +

    About the Symphony +

    +
    +Greetings. Symphony is a side project of mine.

    +It's a general archive for, well, meeeeeeeeeeeeeeeee. +
    diff --git a/app/views/home/content.html.erb b/app/views/home/content.html.erb new file mode 100644 index 0000000..eddea4e --- /dev/null +++ b/app/views/home/content.html.erb @@ -0,0 +1,23 @@ + +

    <%= t(".page_heading") %>

    + + + +

    <%= t("a11y.navigation") %>

    +<%= render "tos_navigation" %> + + + +

    <%= t(".page_content_landmark") %>

    +
    + Everything is allowed with the following exceptions: +

    +-Explicit content of real minors, e.g. RPF smut, in any form. +
    -Art of underage sex that is photorealistic or near-photorealistic. +
    -AI generated content of any kind.
    + + + +

    <%= t("a11y.navigation") %>

    +<%= render "tos_navigation", top_link: true %> + diff --git a/app/views/home/diversity_statement.html.erb b/app/views/home/diversity_statement.html.erb new file mode 100644 index 0000000..f8cf5e0 --- /dev/null +++ b/app/views/home/diversity_statement.html.erb @@ -0,0 +1,47 @@ +

    <%= t(".welcome_header") %>

    + +
    +

    <%= t(".archive_for_you") %>

    + +

    + <%= t(".archive_description_html", + your_feedback_link: link_to(t(".your_feedback"), new_feedback_report_path)) %> +

    + +

    + <%= t(".what_we_do_html", + archive_team_link: link_to(t(".archive_team"), admin_posts_path)) %> +

    + +

    + <%= t(".you_can_html", + few_restrictions_link: link_to(t(".few_restrictions"), content_path), + terms_of_service_link: link_to(t(".terms_of_service"), tos_path)) %> +

    + +

    + <%= t(".still_missing_html", + some_essential_parts_link: link_to(t(".some_essential_parts"), admin_post_path(295))) %> +

    + +

    <%= t(".why_we_build") %>

    + +

    <%= t(".we_build_for") %>

    + +
    +

    + <%= t(".dreamwidth_remix_html", + dreamwidth_link: link_to(t(".dreamwidth"), "http://www.dreamwidth.org"), + diversity_statement_link: link_to(t(".diversity_statement"), "http://www.dreamwidth.org/legal/diversity")) %> +

    + +

    + + <%= t(" style="border-width:0" src="http://i.creativecommons.org/l/by-sa/3.0/88x31.png" /> + +
    + <%= t(".license.html", + creative_commons_by_sa_link: link_to(t(".license.creative_commons_by_sa"), + "http://creativecommons.org/licenses/by-sa/3.0/")) %> +

    +
    diff --git a/app/views/home/dmca.html.erb b/app/views/home/dmca.html.erb new file mode 100644 index 0000000..957c740 --- /dev/null +++ b/app/views/home/dmca.html.erb @@ -0,0 +1,4 @@ +

    <%= t(".page_heading") %>

    +
    + <%= render "dmca" %> +
    diff --git a/app/views/home/donate.html.erb b/app/views/home/donate.html.erb new file mode 100644 index 0000000..6601121 --- /dev/null +++ b/app/views/home/donate.html.erb @@ -0,0 +1,4 @@ +

    Donate to me

    +
    + You can donate to me here, on Ko-Fi.
    + diff --git a/app/views/home/first_login_help.html.erb b/app/views/home/first_login_help.html.erb new file mode 100644 index 0000000..f5694fb --- /dev/null +++ b/app/views/home/first_login_help.html.erb @@ -0,0 +1,155 @@ +<%# Expects current_user %> +
    +

    + <%= t(".welcome_header", app_name: ArchiveConfig.APP_NAME) %> +

    +

    <%= t(".tips_to_start") %>

    + +

    <%= t(".table_of_contents") %>

    +
      +
    • <%= link_to t(".logging_in_out.header"), "#logging_in" %>
    • +
    • <%= link_to t(".editing_profile.header"), "#edit_profile" %>
    • +
    • <%= link_to t(".pseuds.header"), "#pseuds" %>
    • +
    • <%= link_to t(".posting_works.header"), "#posting" %>
    • +
    • <%= link_to t(".browsing.header"), "#browsing" %>
    • +
    • <%= link_to t(".tags.header"), "#tags" %>
    • +
    • <%= link_to t(".warnings.header"), "#warnings" %>
    • +
    • <%= link_to t(".bookmarking_works.header"), "#bookmarking" %>
    • +
    • <%= link_to t(".preferences.header"), "#preferences" %>
    • +
    • + <%= link_to t(".additional_info.header"), "#additional" %> +
        +
      • <%= link_to t(".additional_info.history_mark_later.header"), "#history" %>
      • +
      • <%= link_to t(".additional_info.subscriptions.header"), "#subscriptions" %>
      • +
      +
    • +
    • <%= link_to t(".tos.header"), "#legal_stuff" %>
    • +
    • <%= link_to t(".support_and_feedback.header"), "#support" %>
    • +
    + +

    <%= t(".logging_in_out.header") %>

    +

    + <%= t(".logging_in_out.html", + forgot_password_link: link_to(t(".logging_in_out.forgot_password"), new_user_password_path)) %> +

    + +

    <%= t(".editing_profile.header") %>

    +

    + <%= t(".editing_profile.html", + your_dashboard_link: link_to(t(".editing_profile.your_dashboard"), user_path(current_user)), + profile_link: link_to(t(".editing_profile.profile"), user_profile_path(current_user)), + edit_my_profile_link: link_to(t(".editing_profile.edit_my_profile"), edit_user_path(current_user)), + profile_faq_link: link_to(t(".editing_profile.profile_faq"), archive_faq_path("profile"))) %> +

    + +

    <%= t(".pseuds.header") %>

    +

    + <%= t(".pseuds.html", + manage_your_pseuds_link: link_to(t(".pseuds.manage_your_pseuds"), user_pseuds_path(current_user)), + pseuds_faq_link: link_to(t(".pseuds.pseuds_faq"), archive_faq_path("pseuds"))) %> +

    + +

    <%= t(".posting_works.header") %>

    +

    + <%= t(".posting_works.html", + post_new_link: link_to(t(".posting_works.post_new"), new_work_path), + posting_editing_faq_link: link_to(t(".posting_works.posting_editing_faq"), + archive_faq_path("posting-and-editing")), + tutorial_link: link_to(t(".posting_works.tutorial"), + archive_faq_path("tutorial-posting-a-work-on-ao3"))) %> +

    + +

    <%= t(".browsing.header") %>

    +

    + <% media_movie = Media.find_by_name("Movies") %> + <%= t(".browsing.html", + fandoms_link: link_to(t(".browsing.fandoms"), menu_fandoms_path), + all_fandoms_link: link_to(t(".browsing.all_fandoms"), media_index_path), + movies_link: if media_movie + link_to(t(".browsing.movies"), media_fandoms_path(media_movie)) + else + t(".browsing.movies") + end, + search_feature_link: link_to(t(".browsing.search_feature"), search_works_path), + search_browse_faq_link: link_to(t(".browsing.search_browse_faq"), archive_faq_path("search-and-browse")), + search_browse_tutorial_link: link_to(t(".browsing.search_browse_tutorial"), admin_post_path(259))) %> +

    + +

    <%= t(".tags.header") %>

    +

    + <%= t(".tags.html", + tags_faq_link: link_to(t(".tags.tags_faq"), archive_faq_path("tags"))) %> +

    + +

    <%= t(".warnings.header") %>

    +

    + <%= t(".warnings.description_html", + archive_specific_warnings_link: link_to(t(".warnings.archive_specific_warnings"), + tos_faq_path(anchor: "warnings_list"))) %> +

    +

    + <%= t(".warnings.symbols_html", + symbols_key_chart_link: link_to(t(".warnings.symbols_key_chart"), "/help/symbols-key.html")) %> +

    + +

    <%= t(".bookmarking_works.header") %>

    +

    + <%= t(".bookmarking_works.html", + bookmarks_faq_link: link_to(t(".bookmarking_works.bookmarks_faq"), archive_faq_path("bookmarks"))) %> +

    + +

    <%= t(".preferences.header") %>

    +

    + <%= t(".preferences.html", + set_my_preferences_link: link_to(t(".preferences.set_my_preferences"), user_preferences_path(current_user)), + edit_my_profile_link: link_to(t(".preferences.edit_my_profile"), edit_user_path(current_user)), + preferences_faq_link: link_to(t(".preferences.preferences_faq"), archive_faq_path("preferences"))) %> +

    +

    + <%= t(".preferences.skins_detail_html", + skins_faq_link: link_to(t(".preferences.skins_faq"), + archive_faq_path("skins-and-archive-interface")), + tutorials_list_link: link_to(t(".preferences.tutorials_list"), + archive_faq_path("tutorials"))) %> +

    + +

    <%= t(".additional_info.header") %>

    +

    <%= t(".additional_info.history_mark_later.header") %>

    +

    + <%= t(".additional_info.history_mark_later.html", + your_dashboard_link: link_to(t(".additional_info.history_mark_later.your_dashboard"), user_path(current_user)), + history_link: link_to(t(".additional_info.history_mark_later.history"), user_readings_path(current_user)), + history_faq_link: link_to(t(".additional_info.history_mark_later.history_faq"), + archive_faq_path("History-and-mark-for-later"))) %>

    + +

    <%= t(".additional_info.subscriptions.header") %>

    +

    + <%= t(".additional_info.subscriptions.html", + your_dashboard_link: link_to(t(".additional_info.subscriptions.your_dashboard"), user_path(current_user)), + subscriptions_feed_faq_link: link_to(t(".additional_info.subscriptions.subscriptions_feed_faq"), + archive_faq_path("subscriptions-and-feeds"))) %> +

    + + +

    + <%= t(".tos.info_html", + tos_link: link_to(t(".tos.tos"), tos_path), + content_policy_link: link_to(t(".tos.content_policy"), content_path), + privacy_policy_link: link_to(t(".tos.privacy_policy"), privacy_path), + tos_faq_link: link_to(t(".tos.tos_faq"), tos_faq_path)) %> +

    +

    + <%= t(".tos.additional_questions_html", + contact_abuse_link: link_to(t(".tos.contact_abuse"), new_abuse_report_path)) %> +

    + +

    <%= t(".support_and_feedback.header") %>

    +

    + <%= t(".support_and_feedback.html", + archive_faq_link: link_to(t(".support_and_feedback.archive_faq"), archive_faqs_path), + known_issues_link: link_to(t(".support_and_feedback.known_issues"), known_issues_path), + contact_support_link: link_to(t(".support_and_feedback.contact_support"), new_feedback_report_path), + unofficial_tools_faq_link: link_to(t(".support_and_feedback.unofficial_tools_faq"), + archive_faq_path("unofficial-browser-tools"))) %> +

    +
    diff --git a/app/views/home/index.html.erb b/app/views/home/index.html.erb new file mode 100644 index 0000000..74cb922 --- /dev/null +++ b/app/views/home/index.html.erb @@ -0,0 +1,57 @@ +
    + <% if !logged_in? %> + <%= render 'intro_module' %> + <% end %> + + <% if logged_in? && @homepage.favorite_tags.present? %> +
    +

    <%= t(".find_your_favorites") %>

    +
      + <% @homepage.favorite_tags.each do |favorite_tag| %> +
    • + <%= link_to_tag_works(favorite_tag.tag) %> +
    • + <% end %> +
    +
    + <% else %> +
    " class="browse module"> +

    <%= t(".find_your_favorites") %>

    + <% if logged_in? %> +

    <%= t(".browse_or_favorite", count: ArchiveConfig.MAX_FAVORITE_TAGS) %>

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

    + <%= t(".readings.heading.title") %> + <%= link_to t(".readings.heading.history_link"), user_readings_path(current_user) %> +

    +
    +

    <%= t(".readings.note") %>

    +
      + <% @homepage.readings.each do |reading| %> + <%= render "readings/reading_blurb", work: reading.work, reading: reading %> + <% end %> +
    +
    + <% 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 100644 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 100644 index 0000000..af20944 --- /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") %>

    +
    + <%= render "privacy" %> +
    + + + +

    <%= t("a11y.navigation") %>

    +<%= render "tos_navigation", top_link: true %> + diff --git a/app/views/home/site_map.html.erb b/app/views/home/site_map.html.erb new file mode 100644 index 0000000..280a4cb --- /dev/null +++ b/app/views/home/site_map.html.erb @@ -0,0 +1,61 @@ +

    <%= t(".page_heading") %>

    +
    +

    <%= t(".explore.header") %>

    +
      +
    • <%= link_to t(".explore.homepage"), root_path %>
    • +
    • <%= link_to t(".explore.fandoms"), media_index_path %>
    • +
    • <%= link_to t(".explore.recent_works"), works_path %>
    • +
    • <%= link_to t(".explore.people"), search_people_path %>
    • +
    • <%= link_to t(".explore.bookmarks"), bookmarks_path %>
    • +
    • <%= link_to t(".explore.additional_tags_cloud"), tags_path %>
    • +
    • <%= link_to t(".explore.languages"), languages_path %>
    • +
    • <%= link_to t(".explore.collections_and_challenges"), collections_path %>
    • +
    + +

    <%= t(".about.header") %>

    +
      +
    • <%= link_to t(".about.archive_faq"), archive_faqs_path %>
    • +
    • <%= link_to t(".about.ao3_news"), admin_posts_path %>
    • +
    • <%= link_to t(".about.known_issues"), known_issues_path %>
    • + +
    + + <% if logged_in? %> +

    <%= t(".access_your_account.header") %>

    +
      +
    • <%= link_to t(".access_your_account.my_home"), user_path(current_user) %>
    • +
    • <%= link_to t(".access_your_account.post_new"), new_work_path %>
    • +
    • <%= link_to t(".access_your_account.my_works"), user_works_path(current_user) %>
    • +
    • <%= link_to t(".access_your_account.drafts"), drafts_user_works_path(current_user) %>
    • +
    • <%= link_to t(".access_your_account.my_series"), user_series_index_path(current_user) %>
    • +
    • <%= link_to t(".access_your_account.my_bookmarks"), user_bookmarks_path(current_user) %>
    • +
    • <%= link_to t(".access_your_account.my_collections_and_challenges"), user_collections_path(current_user) %>
    • +
    • <%= link_to t(".access_your_account.my_inbox"), user_inbox_path(current_user) %>
    • + <% if current_user.preference.history_enabled? %> +
    • <%= link_to t(".access_your_account.my_history"), user_readings_path(current_user) %>
    • + <% end %> +
    • <%= link_to t(".access_your_account.my_subscriptions"), user_subscriptions_path(current_user) %>
    • +
    • <%= link_to t(".access_your_account.set_my_preferences"), user_preferences_path(current_user) %>
    • +
    + +

    <%= t(".change_your_account_settings.header") %>

    +
      +
    • <%= link_to t(".change_your_account_settings.edit_my_profile"), edit_user_path(current_user) %>
    • +
    • <%= link_to t(".change_your_account_settings.my_profile"), user_profile_path(current_user) %>
    • +
    • <%= link_to t(".change_your_account_settings.manage_my_pseuds"), user_pseuds_path(current_user) %>
    • +
    • + <%= link_to t(".change_your_account_settings.delete_my_account"), + user_path(current_user), + data: { confirm: t(".change_your_account_settings.delete_account_confirmation") }, + method: :delete %> +
    • +
    + <% end %> + +

    <%= t(".contact_us.header") %>

    +
      +
    • <%= link_to t(".contact_us.technical_support_and_feedback"), new_feedback_report_path %>
    • +
    • <%= link_to t(".contact_us.policy_questions_and_abuse_reports"), new_abuse_report_path %>
    • +
    • <%= link_to t(".contact_us.donations"), donate_path %>
    • +
    +
    diff --git a/app/views/home/site_pages.html.erb b/app/views/home/site_pages.html.erb new file mode 100644 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. +

    + +

    Pages

    +
      + <%= @paths.sort {|a,b| a[0] <=> b[0]}.map {|path, name| content_tag(:li, link_to("#{path} (#{name})", path))}.join("\n").html_safe %> +
    + +<% unless @errors.empty? %> +

    Errors

    +

    + 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. +

    + +
      + <%= @errors.map {|error| content_tag(:li, error)}.join("\n").html_safe %> +
    +<% end %> + + +
    \ 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 100644 index 0000000..b75738d --- /dev/null +++ b/app/views/home/tos.html.erb @@ -0,0 +1,23 @@ + +

    <%= t(".page_heading") %>

    + + + +

    <%= t("a11y.navigation") %>

    +<%= render "tos_navigation" %> + + + +

    <%= t(".page_content_landmark") %>

    +
    + Everything is allowed with the following exceptions: +

    +-Explicit content of real minors, e.g. RPF smut, in any form. +
    -Art of underage sex that is photorealistic or near-photorealistic. +
    -AI generated content of any kind.
    + + + +

    <%= t("a11y.navigation") %>

    +<%= render "tos_navigation", top_link: true %> + diff --git a/app/views/home/tos_faq.html.erb b/app/views/home/tos_faq.html.erb new file mode 100644 index 0000000..bb4bfd2 --- /dev/null +++ b/app/views/home/tos_faq.html.erb @@ -0,0 +1,1247 @@ + +

    Terms of Service FAQ

    + + + +

    <%= t("a11y.navigation") %>

    +<%= render "tos_navigation" %> + + + +

    Main Text

    +
    +

    This document addresses common questions about the AO3 Terms of Service and how our policies are implemented. If you have any questions not answered by this FAQ, you can contact the Policy & Abuse committee.

    + +

    General Principles FAQ

    +

    Answers to common questions about the General Principles of the AO3 Terms of Service are available below. If you have additional questions that are not covered here, you can contact the Policy & Abuse committee.

    + +

    General Questions

    +
    + +
    Table of Contents
    +
    + +
    +
    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.

    +
    What do you mean by modifying or adapting content?
    +

    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?
    +
      +
    • Orphaning a work: Orphaned works will not be edited or removed unless they contain unauthorized information which may identify the creator or otherwise violate the Terms of Service.
    • +
    • Participation in a Challenge: The maintainer(s) of the challenge may edit or delete the sign-up or prompt, or remove a work from their collection, at any time.
    • +
    • + Comments on someone else's work: +
        +
      • 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:

    +
      +
    1. Give appropriate credit: You need to say that the material was created by us and provide a link to the CC license.
    2. +
    3. Indicate if changes were made: If you changed something from our original material, say so when crediting us.
    4. +
    5. 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.
    6. +
    7. Don't impose additional restrictions: You may not apply legal terms or technological measures that restrict others from doing anything that this license permits.
    8. +
    +

    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.

    +

    Back to Top | General Questions

    +
    +

    Age Policy

    +
    + +
    Table of Contents
    +
    + +
    +
    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).

    +

    Back to Top | Age Policy FAQ

    +
    +

    Abuse Policy and Procedures

    +
    + +
    Table of Contents
    +
    + +
    +
    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.

    +

    If we determine that you have in fact violated the Terms of Service, an email will be sent to the address associated with your account.

    +
    How do I find out who reported or complained about me?
    +

    Anonymity and privacy are essential to maintaining a fair reporting system. The Policy & Abuse committee will not disclose the identity of any complainant as part of an Abuse case.

    +

    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.

    +

    Back to Top | Abuse Policy and Procedures FAQ

    +
    +

    Content Policy FAQ

    +

    Answers to common questions about the Content Policy are available below. If you have additional questions that are not covered here, you can contact the Policy & Abuse committee.

    + +

    General Questions about the Content Policy

    +
    + +
    Table of Contents
    +
    + +
    +
    What is "content"?
    +

    Content is anything that you post on AO3 or otherwise submit to us. This includes, but is not limited to:

    +
      +
    • works
    • +
    • bookmarks
    • +
    • comments
    • +
    • tags
    • +
    • collections
    • +
    • links
    • +
    • icons
    • +
    • embedded text, image, audio, or video files
    • +
    • usernames and pseuds
    • +
    • profile and pseud descriptions
    • +
    • fannish next-of-kin information
    • +
    • Personal Information, such as an email address
    • +
    • any other item of information or type of content
    • +
    +

    All content on AO3 must comply with our Content Policy.

    +
    What happens if someone posts content that violates the Content Policy?
    +

    We do not prescreen content on AO3, nor do we review content that has not been reported to us. If you believe you have encountered content that violates our Terms of Service, you will need to submit an Abuse report.

    +

    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.

    +

    Back to Top | General Questions about the Content Policy

    +
    +

    Offensive Content vs Illegal Content

    +
    + +
    Table of Contents
    +
    + +
    +
    Why doesn't AO3 remove extremely offensive content?
    +

    The Archive of Our Own was created in 2008 as a response to several challenges fandom faced at the time.

    +

    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.

    +

    If you need help using filters, please contact the Support committee.

    +
    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.

    +
    Block guest (anonymous) users
    +

    If you want to prevent a guest user from commenting on your works, you can disable guest comments or only allow registered users to access your works. You may also want to enable comment moderation.

    +

    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.

    +

    Back to Top | Offensive Content vs Illegal Content FAQ

    +
    +

    Fanworks and Non-Fanwork Content

    +
    + +
    Table of Contents
    +
    + +
    +
    What kinds of fanworks can I post?
    +

    AO3 allows a wide range of fanworks other than fanfiction, including but not limited to art, videos, crafts, games, fanmixes, authorized podfics, authorized translations, fannish nonfiction, original fiction, and more. You can post any non-commercial, non-ephemeral fanwork. Here are some examples of allowed fanwork content:

    +
      +
    • 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.

    +

    Examples of fannish nonfiction and things that are not fanworks are available below.

    +
    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
    • +
    • links, lists, or requests for recommendations, whether of fanworks or published works
    • +
    • ads for roleplaying partners, sessions, servers, sites, or games
    • +
    • advertisements, offers, and giveaways
    • +
    • technical instructions (for example, a recipe for making ice cream, or a list of steps explaining how to assemble an ice cream machine)
    • +
    • faceclaims, fancastings, or other reference lists (such as collections of photographs, media, or other resources)
    • +
    • a single word or phrase repeated hundreds or thousands of times
    • +
    • prompts or requests for prompts
    • +
    • 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.

    +
    Can I post "incorrect quotes"?
    +

    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)?
    +

    We currently don't host multimedia content other than user icons, though you can embed various kinds of files that are hosted elsewhere if it's a fannish transformative work (or part of one) and otherwise complies with the Content Policy. However, for technical and legal reasons we don't allow all kinds of embeds. Please read our policies about embedding images that you did not create, podfics of stories that you did not write, images that depict explicit content, and images in works featuring underage sexual content. In addition, keep in mind that embeds may break for various reasons, including trouble with the hosting site.

    +
    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.

    + +

    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.

    +

    Back to Top | Fanworks and Non-Fanwork Content FAQ

    +
    +

    Commercial Promotion

    +
    + +
    Table of Contents
    +
    + +
    +
    Why is commercial promotion prohibited on AO3?
    +

    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"?
    +

    Some examples of commercial activities include:

    +
      +
    • linking to or referencing the use of a commercial platform or the monetization features of a non-commercial platform
    • +
    • 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.

    +

    Back to Top | Commercial Promotion FAQ

    +
    + +
    + +
    Table of Contents
    +
    + +
    +
    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.

    + +

    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.

    +

    If the original work is no longer under copyright because it is old enough that it has entered the public domain, refer to How do these rules apply to works that are in the public domain?

    +
    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?
    +

    Yes. Before filing a DMCA takedown notice, please read our DMCA Policy carefully. If you have any questions, contact the Legal committee.

    +

    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.

    +

    Back to Top | Copyright Infringement and Plagiarism FAQ

    +
    +

    Fannish Identities and Impersonation

    +
    + +
    Table of Contents
    +
    + +
    +
    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.

    +

    Back to Top | Fannish Identities and Impersonation FAQ

    +
    +

    Harassment

    +
    + +
    Table of Contents
    +
    + +
    +
    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.

    +

    Back to Top | Harassment FAQ

    +
    +

    Usernames, Icons, and Profiles

    +
    + +
    Table of Contents
    +
    + +
    +
    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?

    +
    What can be on a user profile?
    +

    User profiles can contain information about the user, such as their fandom preferences and links to other sites on which the user can be found. User profiles must comply with AO3's policies on commercial promotion, harassment, impersonation, copyright, and other general content rules.

    +

    Back to Top | Usernames, Icons, and Profiles FAQ

    +
    +

    Ratings and Archive Warnings

    +
    + +
    Table of Contents
    +
    + +
    +
    What kind of content do you allow?
    +

    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.

    +
    What do you mean by non-specific Rating or Archive Warning tags?
    +

    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.
    • +
    • Creator Chose Not To Use Archive Warnings: This non-specific warning tag means that the work may contain content pertaining to any of the Archive Warnings.
    • +
    • 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.

    +

    Back to Top | Ratings and Archive Warnings FAQ

    +
    +

    Other Tags

    +
    + +
    Table of Contents
    +
    + +
    +
    What are the minimum criteria for tags in mandatory fields?
    +

    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.
    • +
    • Language: The language tag must indicate a language used in a major portion of the work text, unless no such language tag is available.
    • +
    +

    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.

    +

    Back to Top | Other Tags FAQ

    +
    +

    Spam and Technical Integrity

    +
    + +
    Table of Contents
    +
    + +
    +
    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.

    +

    Back to Top | Spam and Technical Integrity FAQ

    +
    +

    Privacy Policy FAQ

    +

    Answers to common questions about the Privacy Policy are available below. If you have additional questions that are not covered here, you can contact the Policy & Abuse committee.

    + +

    Information Collection and Use

    +
    + +
    Table of Contents
    +
    + +
    +
    Why do you use cookies?
    +

    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?
    +

    Some AO3 features may display your content to the public, to other AO3 users, and/or to yourself and AO3 administrators.

    +
    Display to the public
    +

    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
    • +
    • Submit a prompt to a Prompt Meme Challenge
    • +
    • Submit a sign-up to a Gift Exchange Challenge (note: the owners and moderators of the Challenge will have access to your email address)
    • +
    • 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:

    + +

    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.

    +

    Back to Top | Information Collection and Use FAQ

    +
    +

    User Privacy Rights

    +
    + +
    Table of Contents
    +
    + +
    +
    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 we provide access to, correct, or delete your personal information
    • +
    • 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.

    +

    Back to Top | User Privacy Rights FAQ

    +
    + + + +

    <%= t("a11y.navigation") %>

    +<%= render "tos_navigation", top_link: true %> + diff --git a/app/views/home/unicorn_test.html.erb b/app/views/home/unicorn_test.html.erb new file mode 100644 index 0000000..c92ac77 --- /dev/null +++ b/app/views/home/unicorn_test.html.erb @@ -0,0 +1,3 @@ +

    +All is well +

    diff --git a/app/views/inbox/_approve_button.html.erb b/app/views/inbox/_approve_button.html.erb new file mode 100644 index 0000000..cbbf7d0 --- /dev/null +++ b/app/views/inbox/_approve_button.html.erb @@ -0,0 +1,7 @@ +<% # expects feedback_comment and approved_from %> + +<% # approve link that works with JavaScript enabled and is otherwise hidden %> +<%= link_to(ts("Approve"), review_comment_path(feedback_comment, approved_from: approved_from.to_s, page: params[:page], filters: @filters), method: :put, remote: true, class: 'hidden') %> + +<% # unreviewed comments page link for users with JavaScript disabled %> +<%= link_to(ts("Unreviewed Comments"), unreviewed_work_comments_path(feedback_comment.ultimate_parent), class: 'hideme') %> \ No newline at end of file diff --git a/app/views/inbox/_delete_form.html.erb b/app/views/inbox/_delete_form.html.erb new file mode 100644 index 0000000..59ca01e --- /dev/null +++ b/app/views/inbox/_delete_form.html.erb @@ -0,0 +1,6 @@ +<% # expects inbox_comment and current_user %> +<%= form_tag user_inbox_path(current_user), method: 'put', class: 'ajax-remove' do %> + <%= hidden_field_tag 'delete', true %> + <%= hidden_field_tag 'inbox_comments[]', inbox_comment.id, id: "inbox_comments_#{inbox_comment.id}" %> + <%= submit_tag ts('Delete') %> +<% end %> diff --git a/app/views/inbox/_inbox_comment_contents.html.erb b/app/views/inbox/_inbox_comment_contents.html.erb new file mode 100644 index 0000000..14f68d8 --- /dev/null +++ b/app/views/inbox/_inbox_comment_contents.html.erb @@ -0,0 +1,31 @@ + + +
    + <% if !feedback_comment.pseud.nil? %> + <% if feedback_comment.by_anonymous_creator? %> + + <% else %> + <%= icon_display(feedback_comment.pseud.user, feedback_comment.pseud) %> + <% end %> + <% else %> + + <% end %> +
    + +
    + <%= raw feedback_comment.sanitized_content %> +
    diff --git a/app/views/inbox/_read_form.html.erb b/app/views/inbox/_read_form.html.erb new file mode 100644 index 0000000..24eb345 --- /dev/null +++ b/app/views/inbox/_read_form.html.erb @@ -0,0 +1,6 @@ +<% # expects inbox_comment and current_user %> +<%= form_tag user_inbox_path(current_user), method: 'put', class: 'ajax-remove' do %> + <%= hidden_field_tag 'read', true %> + <%= hidden_field_tag 'inbox_comments[]', inbox_comment.id, id: "inbox_comments_#{inbox_comment.id}" %> + <%= submit_tag ts('Mark Read') %> +<% end %> diff --git a/app/views/inbox/_reply_button.html.erb b/app/views/inbox/_reply_button.html.erb new file mode 100644 index 0000000..e55bccc --- /dev/null +++ b/app/views/inbox/_reply_button.html.erb @@ -0,0 +1,4 @@ +<% # expects feedback_comment %> +<% unless feedback_comment.ultimate_parent.blank? %> + <%= link_to ts('Reply'), reply_user_inbox_path(current_user, comment_id: feedback_comment, filters: @filters), remote: true %> +<% end %> diff --git a/app/views/inbox/reply.js.erb b/app/views/inbox/reply.js.erb new file mode 100644 index 0000000..a34d6d4 --- /dev/null +++ b/app/views/inbox/reply.js.erb @@ -0,0 +1,9 @@ +$j('#reply-to-comment').show(); +window.location.hash = 'reply-to-comment'; +$j('#reply-to-comment').draggable({ opacity: 0.80 }); +$j('#reply-to-comment').html("<%= escape_javascript(render :partial => "comments/comment_form", + :locals => {:comment => @comment, :commentable => @commentable, :button_name => ts("Comment")}) %>"); +$j('#comment_cancel').click(function() { + $j('#reply-to-comment').hide(); + history.back(1); + }); diff --git a/app/views/inbox/show.html.erb b/app/views/inbox/show.html.erb new file mode 100755 index 0000000..23dbb40 --- /dev/null +++ b/app/views/inbox/show.html.erb @@ -0,0 +1,189 @@ + +

    <%= ts("My Inbox") %> (<%= ts("%{total} comments, %{unread} unread", total: @inbox_total, unread: @unread) %>)

    + +<%= 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 %> + + + + +<% unless @inbox_comments.blank? %> + + <%= will_paginate @inbox_comments %> + + <%= form_tag user_inbox_path(@user, page: params[:page], filters: @filters), method: :put, id: "inbox-form", class: "inbox manage" do %> +
    + <%= ts("Mass Edit Options") %> + + +
    + +
    + <%= ts("List of Comments") %> + +
      + <% @inbox_comments.each do |inbox_comment| %> + <% feedback_comment = inbox_comment.feedback_comment %> + + +
    1. + <% if !can_see_hidden_comment?(feedback_comment) %> +

      <%= ts("(This comment is under review by an admin and is currently unavailable.)") %>

      + <% else %> + <%= render "inbox_comment_contents", feedback_comment: feedback_comment %> + <% end %> + +
      <%= ts("Comment Actions") %>
      + + +
    2. + + <% end %> +
    +
    + +
    + <%= ts("Mass Edit Options") %> + + +
    + + <% end %> + + + + +<% end %> + + + + +<%= form_tag(user_inbox_path(@user), method: :get, class: "narrow-hidden filters", id: "inbox-filters") do %> +

    <%= ts("Filter") %>

    + <%= field_set_tag do %> +
    +
    <%= ts("Filter by read") %>
    +
    +
      +
    • + <%= label_tag "filters_read_all" do %> + <%= radio_button_tag "filters[read]", "all", (!%w(true false).include?(@filters[:read])) %> + <%= label_indicator_and_text(ts("Show all")) %> + <% end %> +
    • +
    • + <%= label_tag "filters_read_false" do %> + <%= radio_button_tag "filters[read]", "false", @filters[:read] == "false" %> + <%= label_indicator_and_text(ts("Show unread")) %> + <% end %> +
    • +
    • + <%= label_tag "filters_read_true" do %> + <%= radio_button_tag "filters[read]", "true", @filters[:read] == "true" %> + <%= label_indicator_and_text(ts("Show read")) %> + <% end %> +
    • +
    +
    + +
    <%= ts("Filter by replied to") %>
    +
    +
      +
    • + <%= label_tag "filters_replied_to_all" do %> + <%= radio_button_tag "filters[replied_to]", "all", (!%w(true false).include?(@filters[:replied_to])) %> + <%= label_indicator_and_text(ts("Show all")) %> + <% end %> +
    • +
    • + <%= label_tag "filters_replied_to_false" do %> + <%= radio_button_tag "filters[replied_to]", "false", @filters[:replied_to] == "false" %> + <%= label_indicator_and_text(ts("Show without replies")) %> + <% end %> +
    • +
    • + <%= label_tag "filters_replied_to_true" do %> + <%= radio_button_tag "filters[replied_to]", "true", @filters[:replied_to] == "true" %> + <%= label_indicator_and_text(ts("Show replied to")) %> + <% end %> +
    • +
    +
    + +
    <%= ts("Sort by date") %>
    +
    +
      +
    • + <%= label_tag "filters_date_desc" do %> + <%= radio_button_tag "filters[date]", "desc", @filters[:date] != "asc" %> + <%= label_indicator_and_text(ts("Newest first")) %> + <% end %> +
    • +
    • + <%= label_tag "filters_date_asc" do %> + <%= radio_button_tag "filters[date]", "asc", @filters[:date] == "asc" %> + <%= label_indicator_and_text(ts("Oldest first")) %> + <% end %> +
    • +
    +
    +
    <%= ts("Submit") %>
    +
    <%= submit_tag ts("Filter") %>
    +
    + <% end %> + <% # On narrow screens, link jumps to top of index when JavaScript is disabled and closes filters when JavaScript is enabled %> + +<% end %> + + +<%= will_paginate @inbox_comments %> diff --git a/app/views/invitations/_invitation.html.erb b/app/views/invitations/_invitation.html.erb new file mode 100644 index 0000000..f3ba9a3 --- /dev/null +++ b/app/views/invitations/_invitation.html.erb @@ -0,0 +1,35 @@ +
    +
    <%= t(".sender") %>
    +
    <%= creator_link(@invitation) %>
    +
    <%= t(".invitation_token") %>
    +
    <%= @invitation.token %>
    +
    <%= t(".copy_link") %>
    +
    + <% unless @invitation.redeemed_at %> + <%= link_to t(".copy_and_use"), signup_path(invitation_token: @invitation.token) %> + <% end %> +
    +
    <%= t(".sent_to") %>
    +
    + <% if @invitation.redeemed_at %> + <%= @invitation.invitee_email %> + <% else %> + <%= form_for(@invitation) do |f| %> + <%= error_messages_for @invitation %> +

    <%= f.label :invitee_email, t(".email_address_label") %> <%= f.text_field :invitee_email %>

    +

    <%= hidden_field_tag :user_id, @user.try(:login) %>

    +

    <%= f.submit %>

    + <% end %> + <% end %> +
    +
    <%= t(".redeemed_by") %>
    +
    <%= invitee_link(@invitation) || "-" %>
    +
    <%= t(".created_at") %>
    +
    <%= @invitation.created_at || "-" %>
    +
    <%= t(".sent_at") %>
    +
    <%= @invitation.sent_at || "-" %>
    +
    <%= t(".last_resent_at") %>
    +
    <%= @invitation.resent_at || "-" %>
    +
    <%= t(".redeemed_at") %>
    +
    <%= @invitation.redeemed_at || "-" %>
    +
    diff --git a/app/views/invitations/_user_invitations.html.erb b/app/views/invitations/_user_invitations.html.erb new file mode 100644 index 0000000..a3a5d6c --- /dev/null +++ b/app/views/invitations/_user_invitations.html.erb @@ -0,0 +1,42 @@ +<% unless @invitations.blank? %> + "> + + + + + + + + + + + + <% @invitations.each do |invitation| %> + + + + + + + <% if logged_in_as_admin? && invitation.redeemed_at.blank? %> + + <% end %> + + <% end %> + +
    <%= t(".table.caption") %>
    <%= t(".table.headings.token") %><%= t(".table.headings.sent_to") %><%= t(".table.headings.username") %><%= t(".table.headings.external_author") %><%= t(".table.headings.copy_link") %>
    <%= link_to invitation.token, (invitation.creator.is_a?(User) ? [invitation.creator, invitation] : invitation) %><%= invitation.invitee_email %><%= invitee_link(invitation) %> + <%# TODO: internationalize this, including .to_sentence (and also do something about the parentheses) %> + <%= invitation.external_author ? "#{invitation.external_author.email} (#{invitation.external_author.names.collect(&:name).delete_if { |name| name == invitation.external_author.email } +.join(',')})" : "" %> + + <% unless invitation.redeemed_at %> + <% if invitation.external_author %> + <%= link_to t(".table.actions.copy_and_use"), claim_path(invitation_token: invitation.token) %> + <% else %> + <%= link_to t(".table.actions.copy_and_use"), signup_path(invitation_token: invitation.token) %><% end %> + <% end %> + + <%= link_to(t(".table.actions.delete"), + invitation, data: { confirm: t(".table.actions.delete_confirmation") }, method: :delete) %> +
    +<% end %> diff --git a/app/views/invitations/_user_invitations_navigation.html.erb b/app/views/invitations/_user_invitations_navigation.html.erb new file mode 100644 index 0000000..abaadc2 --- /dev/null +++ b/app/views/invitations/_user_invitations_navigation.html.erb @@ -0,0 +1,7 @@ +<% if logged_in? %> + +<% end %> diff --git a/app/views/invitations/index.html.erb b/app/views/invitations/index.html.erb new file mode 100644 index 0000000..834bcee --- /dev/null +++ b/app/views/invitations/index.html.erb @@ -0,0 +1,46 @@ + +<% if logged_in_as_admin? %> +

    <%= ts('Create more invitations for this user') %>

    + <%= form_for :invitation, url: user_invitations_url(@user) do |f| %> +

    <%= label_tag "invitation[number_of_invites]", ts('Number of invitations:') %> <%= f.text_field :number_of_invites %> <%= submit_tag 'Create' %>

    + <% end %> +<% else %> + +

    Invite a friend

    + + + + <%= render "user_invitations_navigation" %> + + + +
    + <% if @unsent_invitations.blank? %> +

    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 %> +
    +
    <%= label_tag "invitation[invitee_email]", t('.email address', :default => 'Email address') %>
    +
    <%= text_field_tag "invitation[invitee_email]" %>
    +
    <%= label_tag :id, t('.choose_invite', :default => 'Choose an invitation') %>
    +
    +
      + <% for unsent_invite in @unsent_invitations %> +
    • + <%= radio_button_tag "invitation[id]", unsent_invite.id, checked: (unsent_invite == @unsent_invitations.first) %> + <%= label_tag 'invitation_id_' + unsent_invite.id.to_s, unsent_invite.token %> +
    • + <% end %> +
    +
    +
    +

    <%= submit_tag t('.submit_invite', :default => 'Send Invitation') %>

    + <% end %> + <% end %> +
    + + + + +<% end %> diff --git a/app/views/invitations/manage.html.erb b/app/views/invitations/manage.html.erb new file mode 100644 index 0000000..97589ea --- /dev/null +++ b/app/views/invitations/manage.html.erb @@ -0,0 +1,31 @@ + +

    <%= logged_in_as_admin? ? (@user.login + "'s") : "Your" %> Invitations

    + + + +<%= render "user_invitations_navigation" %> + + + + + + +<%= render "invitations/user_invitations", invitations: @invitations %> + diff --git a/app/views/invitations/show.html.erb b/app/views/invitations/show.html.erb new file mode 100644 index 0000000..5f4e7e4 --- /dev/null +++ b/app/views/invitations/show.html.erb @@ -0,0 +1,9 @@ + +<% if @user %> + +<% end %> + + + +<%= render :partial => 'invitations/invitation', :locals => {:user => @user, :invitation => @invitation} %> + \ No newline at end of file diff --git a/app/views/invite_requests/_index_closed.html.erb b/app/views/invite_requests/_index_closed.html.erb new file mode 100644 index 0000000..47545d8 --- /dev/null +++ b/app/views/invite_requests/_index_closed.html.erb @@ -0,0 +1,18 @@ + +

    + <%= t(".page_heading") %> +

    + +

    + <%= t(".queue_disabled.html", + closed_bold: tag.strong(t(".queue_disabled.closed")), + news_link: link_to(t(".queue_disabled.news"), admin_posts_path(tag: 143))) %> +

    +

    + <%= 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) %> +

    + + + + diff --git a/app/views/invite_requests/_index_open.html.erb b/app/views/invite_requests/_index_open.html.erb new file mode 100644 index 0000000..c11168f --- /dev/null +++ b/app/views/invite_requests/_index_open.html.erb @@ -0,0 +1,35 @@ + +

    + <%= t(".page_heading") %> +

    + +

    + <%= t(".details_html", + tos_link: link_to(t(".tos"), tos_path), + content_policy_link: link_to(t(".content_policy"), content_path), + privacy_policy_link: link_to(t(".privacy_policy"), privacy_path)) %> +

    + + + +

    <%= t(".request_invitation_header") %>

    + +<%= form_for(@invite_request, html: { class: "simple" }) do |f| %> + <%= error_messages_for @invite_request %> +
    +

    + <%= f.label :email %> + <%= f.text_field :email %> + <%= f.submit t(".add_to_list") %> +

    +
    +<% end %> + +

    + <%= 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)) %> +

    + diff --git a/app/views/invite_requests/_invitation.html.erb b/app/views/invite_requests/_invitation.html.erb new file mode 100644 index 0000000..217beb0 --- /dev/null +++ b/app/views/invite_requests/_invitation.html.erb @@ -0,0 +1,28 @@ + +

    + <%= t(".title", email: invitation.invitee_email) %> +

    + + + +

    + <% status = invitation.resent_at ? "resent" : "not_resent" %> + <%= t(".info.#{status}", + sent_at: l((invitation.sent_at || invitation.created_at).to_date), + resent_at: invitation.resent_at ? l(invitation.resent_at.to_date) : nil) %> +

    + +

    + <% if invitation.can_resend? %> + <%# i18n-tasks-use t("invite_requests.invitation.after_cooldown_period.not_resent") + i18n-tasks-use t("invite_requests.invitation.after_cooldown_period.resent_html")-%> + <% status = invitation.resent_at ? "resent_html" : "not_resent" %> + <%= t(".after_cooldown_period.#{status}", + count: ArchiveConfig.HOURS_BEFORE_RESEND_INVITATION, + contact_support_link: link_to(t(".contact_support"), new_feedback_report_path)) %> + <%= button_to t(".resend_button"), resend_invite_requests_path(email: invitation.invitee_email) %> + <% else %> + <%= t(".before_cooldown_period", count: ArchiveConfig.HOURS_BEFORE_RESEND_INVITATION) %> + <% end %> +

    + diff --git a/app/views/invite_requests/_invite_request.html.erb b/app/views/invite_requests/_invite_request.html.erb new file mode 100644 index 0000000..fd0e4a0 --- /dev/null +++ b/app/views/invite_requests/_invite_request.html.erb @@ -0,0 +1,14 @@ + +

    + <%= t(".title", email: invite_request.email) %> +

    + + + +

    + <%= t(".position_html", position: tag.strong(@position_in_queue)) %> + <% if AdminSetting.current.invite_from_queue_enabled? %> + <%= t(".date", date: l(invite_request.proposed_fill_time.to_date, format: :long)) %> + <% end %> +

    + diff --git a/app/views/invite_requests/_no_invitation.html.erb b/app/views/invite_requests/_no_invitation.html.erb new file mode 100644 index 0000000..dc55ef5 --- /dev/null +++ b/app/views/invite_requests/_no_invitation.html.erb @@ -0,0 +1,3 @@ +

    + <%= t(".email_not_found") %> +

    diff --git a/app/views/invite_requests/index.html.erb b/app/views/invite_requests/index.html.erb new file mode 100644 index 0000000..fbb4d62 --- /dev/null +++ b/app/views/invite_requests/index.html.erb @@ -0,0 +1,6 @@ +<% # Page changes depending on whether queue is enabled %> +<% if AdminSetting.current.invite_from_queue_enabled? %> + <%= render "index_open" %> +<% else %> + <%= render "index_closed" %> +<% end %> diff --git a/app/views/invite_requests/manage.html.erb b/app/views/invite_requests/manage.html.erb new file mode 100644 index 0000000..e33b2be --- /dev/null +++ b/app/views/invite_requests/manage.html.erb @@ -0,0 +1,64 @@ +
    + +

    <%= ts("Manage the Invitation Queue") %>

    + + + + + + + + "> + + + + + + + + + + + <% @invite_requests.each_with_index do |request, index| %> + <% ip_address = if request.ip_address.present? + request.ip_address + else + ts("No IP recorded") + end %> + + <% position = if @filtered + request.position + else + @invite_requests.offset + index + 1 + end %> + + + + + + + + <% end %> + +
    <%= ts("Manage the Invitation Queue") %>
    <%= ts("Queue Position") %><%= ts("Email Address") %><%= ts("IP Address") %><%= ts("Action") %>
    <%= position %><%= request.email %><%= ip_address %> + <%= form_tag invite_request_path(request, page: params[:page], query: params[:query]), method: "delete", class: "ajax-remove" do |f| %> + <%= submit_tag ts("Delete") %> + <% end %> +
    + + + + <%= will_paginate @invite_requests %> + +
    diff --git a/app/views/invite_requests/show.html.erb b/app/views/invite_requests/show.html.erb new file mode 100644 index 0000000..aa36bc9 --- /dev/null +++ b/app/views/invite_requests/show.html.erb @@ -0,0 +1,11 @@ +<% if @invite_request %> + <%= render "invite_request", invite_request: @invite_request %> +<% elsif @invitation %> + <%= render "invitation", invitation: @invitation %> +<% else %> + <%= render "no_invitation" %> +<% end %> + +

    + <%= t(".instructions_html", status_link: link_to("Invitation Request Status page", status_invite_requests_path)) %> +

    diff --git a/app/views/invite_requests/show.js.erb b/app/views/invite_requests/show.js.erb new file mode 100644 index 0000000..05ea39c --- /dev/null +++ b/app/views/invite_requests/show.js.erb @@ -0,0 +1,14 @@ +<% if @invite_request %> + $j("#invite-status").html("<%= escape_javascript(render "invite_requests/invite_request", invite_request: @invite_request) %>"); +<% elsif @invitation %> + $j("#invite-status").html("<%= escape_javascript(render "invitation", invitation: @invitation) %>"); + + <%# Correct heading size for JavaScript display %> + $j(document).ready(function(){ + $j('#invite-heading').replaceWith(function () { + return '

    ' + $j(this).html() + "

    "; + }); + }) +<% else %> + $j("#invite-status").html("<%= escape_javascript(render "no_invitation") %>"); +<% end %> diff --git a/app/views/invite_requests/status.html.erb b/app/views/invite_requests/status.html.erb new file mode 100644 index 0000000..ae0d240 --- /dev/null +++ b/app/views/invite_requests/status.html.erb @@ -0,0 +1,30 @@ + +

    + <%= t(".heading") %> +

    + +

    + <%= t(".waiting_list", count: InviteRequest.count) %> + <% if AdminSetting.current.invite_from_queue_enabled? %> + <%= 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)) %> + <% end %> +

    + + + +<%= form_tag show_invite_request_path, { remote: true, method: :get, class: "simple" } do %> +
    +

    + <%= label_tag :email %> + <%= text_field_tag :email %> + + <%= submit_tag t(".search") %> + +

    +
    +<% end %> + +
    + diff --git a/app/views/known_issues/_admin_index.html.erb b/app/views/known_issues/_admin_index.html.erb new file mode 100644 index 0000000..69d3f6c --- /dev/null +++ b/app/views/known_issues/_admin_index.html.erb @@ -0,0 +1,32 @@ + +

    <%= ts("Known Issues") %>

    +<% if @known_issues.empty? %> +

    There are no known issues posted, but you can <%= link_to 'make a new known issues post', new_known_issue_path %>

    +<% end %> + + + +<%= render :partial => 'admin/admin_nav' %> + + + +
      +<% @known_issues.each do |known_issue| %> +
    • + +
      +

      <%=raw sanitize_field(known_issue, :title) %>

      +
      <%= ts("Updated at:") %> <%= known_issue.updated_at %>
      + <%=raw sanitize_field(known_issue, :content) %> +
      +
    • +<% end %> +
    + + + + diff --git a/app/views/known_issues/_known_issues_form.html.erb b/app/views/known_issues/_known_issues_form.html.erb new file mode 100644 index 0000000..748c4ce --- /dev/null +++ b/app/views/known_issues/_known_issues_form.html.erb @@ -0,0 +1,37 @@ +
    + <%= form_for(@known_issue) do |f| %> + <%= error_messages_for @known_issue %> +
    +
    <%= f.label :title, ts("Beta Revision") + "*" %>
    +
    <%= f.text_field :title %>
    +
    <%= f.label :content, ts("Recent Known Issues") %>
    +
    + +

    + <%= allowed_html_instructions %> + +

    + <% use_tinymce %> + +
    + <%= 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) %> +
    +
    + +
    <%= ts("Post") %>
    +
    + <%= submit_tag ts("Post"), :name => 'post_button' %> +
    +
    + <% end %> +
    diff --git a/app/views/known_issues/edit.html.erb b/app/views/known_issues/edit.html.erb new file mode 100644 index 0000000..0b5d2d1 --- /dev/null +++ b/app/views/known_issues/edit.html.erb @@ -0,0 +1,14 @@ + +

    <%= ts("Edit Known Issues Post") %>

    + + + +<%= render :partial => 'admin/admin_nav' %> + + + +<%= render :partial => 'known_issues_form' %> + + + + diff --git a/app/views/known_issues/index.html.erb b/app/views/known_issues/index.html.erb new file mode 100644 index 0000000..6d550cd --- /dev/null +++ b/app/views/known_issues/index.html.erb @@ -0,0 +1,20 @@ + +<% if policy(KnownIssue).admin_index? %> + <%= render :partial => "admin_index" %> +<% else %> +

    <%= ts("Known Issues") %>

    + + + + + + + + <% @known_issues.each do |known_issue| %> +
    +

    <%= ts("Updated") %> <%= known_issue.updated_at %>

    + <%=raw sanitize_field(known_issue, :content) %> +
    + <% end %> + +<% end %> diff --git a/app/views/known_issues/new.html.erb b/app/views/known_issues/new.html.erb new file mode 100644 index 0000000..76703a4 --- /dev/null +++ b/app/views/known_issues/new.html.erb @@ -0,0 +1,11 @@ + +

    <%= ts("New Known Issues Post") %>

    + + + +<%= render :partial => 'admin/admin_nav' %> + + + +<%= render :partial => 'known_issues_form' %> + diff --git a/app/views/known_issues/show.html.erb b/app/views/known_issues/show.html.erb new file mode 100644 index 0000000..1d43d66 --- /dev/null +++ b/app/views/known_issues/show.html.erb @@ -0,0 +1,23 @@ + +

    <%= ts("Known Issues") %>

    + + + + + + +
    +

    + <%= @known_issue.title %> +

    +

    <%= ts("Updated:") %> <%= @known_issue.updated_at %> + <% if logged_in_as_admin? %> + <%= link_to ts("Edit"), edit_known_issue_path(@known_issue) %> + <% end %> +

    + <%=raw sanitize_field(@known_issue, :content) %> +
    + + + + \ No newline at end of file diff --git a/app/views/kudo_mailer/batch_kudo_notification.html.erb b/app/views/kudo_mailer/batch_kudo_notification.html.erb new file mode 100644 index 0000000..c92e33f --- /dev/null +++ b/app/views/kudo_mailer/batch_kudo_notification.html.erb @@ -0,0 +1,26 @@ +<% content_for :message do %> + + <% @commentables.each_with_index do |commentable, index| %> + <% + commentable_link = style_creation_link(commentable.commentable_name, polymorphic_url(commentable)) + givers_hash = @user_kudos["#{commentable.class.name}_#{commentable.id}"] + names = givers_hash["names"] + guest_count = givers_hash["guest_count"].to_i + kudo_count = names.size + guest_count + givers = names.map { |name| style_link(name, user_url(name)) } + givers << t(".guest", count: guest_count) unless guest_count.zero? + givers_list = to_sentence(givers.map { |k| style_bold(k) }) + %> + + <% if kudo_count == 1 && guest_count == 1 %> + <%= t(".single_guest.html", giver: style_bold(t(".single_guest.giver")), commentable_link: commentable_link) %> + <% else %> + <%= t(".left_kudos.html", givers_list: givers_list, commentable_link: commentable_link, count: kudo_count) %> + <% end %> + + <% if (index < @commentables.length - 1) %> + <%= styled_divider %> + <% end %> + + <% end %> +<% end %> diff --git a/app/views/kudo_mailer/batch_kudo_notification.text.erb b/app/views/kudo_mailer/batch_kudo_notification.text.erb new file mode 100644 index 0000000..a79104b --- /dev/null +++ b/app/views/kudo_mailer/batch_kudo_notification.text.erb @@ -0,0 +1,28 @@ +<% content_for :message do %> +<% @commentables.each_with_index do |commentable, index| %> +<% + commentable_title = commentable.commentable_name.html_safe + commentable_url = polymorphic_url(commentable).html_safe + givers_hash = @user_kudos["#{commentable.class.name}_#{commentable.id}"] + names = givers_hash["names"] + guest_count = givers_hash["guest_count"].to_i + kudo_count = names.size + guest_count + # dup so we don't add "a guest" or "5 guests" to the original array. If that + # happens, kudo_count ends up too high whenever both named and guest kudos are + # present. + givers = names.dup + givers << t(".guest", count: guest_count) unless guest_count.zero? + givers_list = to_sentence(givers) +%> + +<% if kudo_count == 1 && guest_count == 1 %> +<%= t(".single_guest.text", commentable_title: commentable_title, commentable_url: commentable_url) %> +<% else %> +<%= t(".left_kudos.text", givers_list: givers_list, commentable_title: commentable_title, commentable_url: commentable_url, count: kudo_count) %> +<% end %> + +<% if (index < @commentables.length - 1) %> +<%= text_divider %> +<% end %> +<% end %> +<% end %> diff --git a/app/views/kudos/_kudos.html.erb b/app/views/kudos/_kudos.html.erb new file mode 100644 index 0000000..a93827a --- /dev/null +++ b/app/views/kudos/_kudos.html.erb @@ -0,0 +1,21 @@ +<% # expects local variables kudos, guest_kudos_count, commentable %> +

    <%= ts("Applause!") %>

    +
    + <% has_user_kudos = kudos.exists? %> + <% if has_user_kudos || guest_kudos_count > 0 %> + <% cache "#{commentable.cache_key}/kudos-v4", expires_in: ArchiveConfig.MINUTES_UNTIL_COMMENTABLE_KUDOS_LISTS_EXPIRE.minutes, race_condition_ttl: 10.seconds, skip_digest: true do %> +

    + <% 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 applause for this work!") %> +

    + <% end %> + <% end %> +
    diff --git a/app/views/kudos/create.js.erb b/app/views/kudos/create.js.erb new file mode 100644 index 0000000..451421b --- /dev/null +++ b/app/views/kudos/create.js.erb @@ -0,0 +1,2 @@ +$j("#kudos").replaceWith("<%= escape_javascript(render "kudos/kudos", + { kudos: @kudos, commentable: @commentable, guest_kudos_count: @commentable.guest_kudos_count }) %>"); diff --git a/app/views/kudos/index.html.erb b/app/views/kudos/index.html.erb new file mode 100644 index 0000000..a6d982a --- /dev/null +++ b/app/views/kudos/index.html.erb @@ -0,0 +1,22 @@ +

    + <%= search_header(@kudos, nil, "User") %> Who Left Kudos on <%= link_to(@work.title, @work) %> +

    + +<% if @guest_kudos_count.positive? %> +

    + <%= t("kudos.guest_header", count: @guest_kudos_count) %> +

    +<% end %> + +<% if @kudos.present? %> + <%= will_paginate @kudos %> + +
    +

    + <%= @kudos.map { |kudo| link_to kudo.user.login, kudo.user }.to_sentence.html_safe %> + <%= ts(" left applause for this work!") %> +

    +
    + + <%= will_paginate @kudos %> +<% end %> diff --git a/app/views/kudos/index.js.erb b/app/views/kudos/index.js.erb new file mode 100644 index 0000000..b072f03 --- /dev/null +++ b/app/views/kudos/index.js.erb @@ -0,0 +1,2 @@ +$j("#kudos_more_connector").remove(); +$j("#kudos_more_link").replaceWith("<%= j kudos_user_links(@work, @kudos) %>"); diff --git a/app/views/languages/_form.html.erb b/app/views/languages/_form.html.erb new file mode 100644 index 0000000..cdaff60 --- /dev/null +++ b/app/views/languages/_form.html.erb @@ -0,0 +1,51 @@ +<%= error_messages_for :language %> + +<%= form_for(@language, html: { class: "post" }) do |f| %> + +

    * <%= t(".required_notice") %>

    + +
    +
    +
    + <%= f.label :name, "#{t('.name')} *" %> +
    +
    + <%= f.text_field :name, disabled: !policy(@language).can_edit_non_abuse_fields? %> +
    + +
    + <%= f.label :short, "#{t('.short')} *" %> +
    +
    + <%= f.text_field :short, disabled: !policy(@language).can_edit_non_abuse_fields? %> +
    + +
    + <%= f.label :sortable_name, t(".sortable_name") %> +
    +
    + <%= f.text_field :sortable_name, disabled: !policy(@language).can_edit_non_abuse_fields? %> +
    + +
    + <%= f.check_box :support_available, disabled: !policy(@language).can_edit_non_abuse_fields? %> +
    +
    + <%= f.label :support_available, t(".support_available") %> +
    +
    + <%= f.check_box :abuse_support_available, disabled: !policy(@language).can_edit_abuse_fields? %> +
    +
    + <%= f.label :abuse_support_available, t(".abuse_support_available") %> +
    +
    +

    + <% if @language.new_record? %> + <%= f.submit t(".submit.create") %> + <% else %> + <%= f.submit t(".submit.update") %> + <% end %> +

    +
    +<% end %> diff --git a/app/views/languages/edit.html.erb b/app/views/languages/edit.html.erb new file mode 100644 index 0000000..03f5b6d --- /dev/null +++ b/app/views/languages/edit.html.erb @@ -0,0 +1,3 @@ +

    <%= t('.edit_language', :default => 'Edit Language') %>

    + +<%= render 'form' %> \ No newline at end of file diff --git a/app/views/languages/index.html.erb b/app/views/languages/index.html.erb new file mode 100644 index 0000000..6240561 --- /dev/null +++ b/app/views/languages/index.html.erb @@ -0,0 +1,43 @@ + +

    <%= t(".page_heading") %>

    + + + + + + + +
    + <% @languages.each do |language| %> + <% works_count = estimate_number(@works_counts[language.short] || 0) %> + <% if language == Language.default %> +
    + <%= language.name %> + <%= t(".language_code_format_html", code: content_tag(:abbr, language.short)) %> +
    +
    + <%= link_to t(".works_count", count: works_count, formatted_count: number_with_delimiter(works_count)), works_path %> +
    + <% else %> +
    + <%= link_to language.name, language, lang: language.short %> + <%= t(".language_code_format_html", code: content_tag(:abbr, language.short)) %> +
    +
    + <%= link_to t(".works_count", count: works_count, formatted_count: number_with_delimiter(works_count)), language_works_path(language) %> + <% if policy(Language).edit? %> +

    + <%= link_to t(".navigation.edit"), edit_language_path(language) %> +

    + <% end %> +
    + <% end %> + <% end %> +
    + diff --git a/app/views/languages/new.html.erb b/app/views/languages/new.html.erb new file mode 100644 index 0000000..777b2ef --- /dev/null +++ b/app/views/languages/new.html.erb @@ -0,0 +1,3 @@ +

    <%= t('.new_language', :default => 'New Language') %>

    + +<%= render 'form' %> \ No newline at end of file diff --git a/app/views/layouts/_banner.html.erb b/app/views/layouts/_banner.html.erb new file mode 100644 index 0000000..904afe9 --- /dev/null +++ b/app/views/layouts/_banner.html.erb @@ -0,0 +1,24 @@ +<% if @admin_banner&.active? %> + <% unless session[:hide_banner] || current_user&.preference&.banner_seen %> +
    +
    + <%= raw sanitize_field(@admin_banner, :content, image_safety_mode: true) %> +
    + <% if current_user.nil? %> +

    + <%= link_to current_path_with(hide_banner: true), "aria-label": t(".hide"), class: "showme action" do + content_tag(:span, "×".html_safe, "aria-hidden": true) + end %> +

    + <% else %> + <%= form_tag end_banner_user_path(current_user), method: :post, remote: true do %> +

    + <%= button_tag "aria-label": t(".hide"), class: "showme action" do + content_tag(:span, "×".html_safe, "aria-hidden": true) + end %> +

    + <% end %> + <% end %> +
    + <% end %> +<% end %> diff --git a/app/views/layouts/_footer.html.erb b/app/views/layouts/_footer.html.erb new file mode 100644 index 0000000..34989d4 --- /dev/null +++ b/app/views/layouts/_footer.html.erb @@ -0,0 +1,57 @@ + + + diff --git a/app/views/layouts/_header.html.erb b/app/views/layouts/_header.html.erb new file mode 100644 index 0000000..13bb357 --- /dev/null +++ b/app/views/layouts/_header.html.erb @@ -0,0 +1,74 @@ + + + +<% if @collection %> + + +<% end %> + + + +<%= render "layouts/banner" %> + + diff --git a/app/views/layouts/_includes.html.erb b/app/views/layouts/_includes.html.erb new file mode 100644 index 0000000..9610585 --- /dev/null +++ b/app/views/layouts/_includes.html.erb @@ -0,0 +1,11 @@ +<% # load and cache site skin %> +<%= skin_tag %> + + +<%= stylesheet_link_tag 'sandbox', skip_pipeline: true %> + +<%= mute_css %> + +<%= javascript_include_tag 'livevalidation_standalone', skip_pipeline: true %> + +<%= csrf_meta_tag %> diff --git a/app/views/layouts/_javascripts.html.erb b/app/views/layouts/_javascripts.html.erb new file mode 100644 index 0000000..b4ae300 --- /dev/null +++ b/app/views/layouts/_javascripts.html.erb @@ -0,0 +1,114 @@ + +<% if allow_tinymce?(controller) %> + <%= yield :tinymce %> +<% end %> + + + + + +<% if Rails.env.test? %> + + + + +<% end %> + + +<%= javascript_include_tag "jquery.scrollTo.min", "jquery.livequery.min", "rails", "application", "bootstrap/bootstrap-dropdown.min", "jquery-shuffle", "jquery.tokeninput.min", "jquery.trap.min", "ao3modal.min", "js.cookie.min", skip_pipeline: true %> + +<%= javascript_include_tag "filters.min", skip_pipeline: true %> + +<% if allow_tinymce?(controller) %> + <%= yield :tinymce_init %> +<% end %> + +<% unless Rails.env.test? || current_user.try(:accepted_tos_version) == @current_tos_version || tos_exempt_page? %> + <%= javascript_tag do %> + // We can't rely on !window.localStorage to test localStorage support in + // browsers like Safari 9, which technically support it, but which have a + // storage length of 0 in private mode. + // Credit: https://github.com/getgrav/grav-plugin-admin/commit/cfe2188f10c4ca604e03c96f3e21537fda1cdf9a + function isSupported() { + var item = "localStoragePolyfill"; + try { + localStorage.setItem(item, item); + localStorage.removeItem(item); + return true; + } catch (e) { + return false; + } + } + + function acceptTOS() { + if (isSupported()) { + localStorage.setItem("accepted_tos", "<%= @current_tos_version %>"); + } else { + Cookies.set("accepted_tos", "<%= @current_tos_version %>", { expires: 365 }); + } + } + + $j(document).ready(function() { + <% if current_user %> + <%# Users who haven't accepted this TOS need the popup. %> + $j("body").prepend("<%= escape_javascript(render "layouts/tos_prompt") %>"); + <% elsif params[:tos] == "yes" %> + <%# Guests can bypass the popup using ?tos=yes in the URLs. %> + acceptTOS(); + <% else %> + <%# Otherwise, guests who don't have the localStorage item or the + cookie need the popup. + We have to use JavaScript to remember their choice or risk issues + when full page caching is enabled. %> + if (localStorage.getItem("accepted_tos") !== "<%= @current_tos_version %>" && Cookies.get("accepted_tos") !== "<%= @current_tos_version %>") { + $j("body").prepend("<%= escape_javascript(render "layouts/tos_prompt") %>"); + } + <% end %> + }); + <% end %> +<% end %> + +<% # Renders layouts/proxy_notice below skip links in layouts/application. %> +<% if %w(staging production).include?(Rails.env) %> + <%= javascript_tag do %> + $j(document).ready(function() { + var permitted_hosts = <%= ArchiveConfig.PERMITTED_HOSTS.to_json.html_safe %>; + var current_host = window.location.hostname; + + if (!permitted_hosts.includes(current_host) && Cookies.get("proxy_notice") !== "0" && window.location.protocol !== "file:") { + $j("#skiplinks").after("<%= escape_javascript(render "layouts/proxy_notice") %>"); + } + }); + <% end %> +<% end %> + +<%= yield :footer_js %> diff --git a/app/views/layouts/_proxy_notice.html.erb b/app/views/layouts/_proxy_notice.html.erb new file mode 100644 index 0000000..92bc7c0 --- /dev/null +++ b/app/views/layouts/_proxy_notice.html.erb @@ -0,0 +1,35 @@ +
    +
    + <%# TODO: When the interface is localized, this should be revised to only show the notice in the user's selected language. %> +

    <%= t(".faux_heading") %>

    +
      +
    1. <%= t(".point1") %>
    2. +
    3. <%= t(".point2") %>
    4. +
    +

    <%= t(".faux_heading", locale: :ru) %>

    +
      +
    1. <%= t(".point1", locale: :ru) %>
    2. +
    3. <%= t(".point2", locale: :ru) %>
    4. +
    +

    <%= t(".faux_heading", locale: :uk) %>

    +
      +
    1. <%= t(".point1", locale: :uk) %>
    2. +
    3. <%= t(".point2", locale: :uk) %>
    4. +
    +

    <%= t(".faux_heading", locale: :"zh-CN") %>

    +
      +
    1. <%= t(".point1", locale: :"zh-CN") %>
    2. +
    3. <%= t(".point2", locale: :"zh-CN") %>
    4. +
    +

    +
    +
    + +<%= javascript_tag do %> + $j(document).ready(function() { + $j("#proxy-notice-dismiss").on("click", function() { + Cookies.set("proxy_notice", "0"); + $j("#proxy-notice").slideUp(); + }); + }); +<% end %> diff --git a/app/views/layouts/_tos_prompt.html.erb b/app/views/layouts/_tos_prompt.html.erb new file mode 100644 index 0000000..3da9b5c --- /dev/null +++ b/app/views/layouts/_tos_prompt.html.erb @@ -0,0 +1,71 @@ + + +<%# content_for footer renders this when we don't want it %> +<%= javascript_tag do %> + $j(document).ready(function() { + var container = $j("#tos_prompt"); + var outer = $j("#outer"); + var button = $j("#accept_tos"); + var tosCheckbox = document.getElementById("tos_agree"); + var dataProcessingCheckbox = document.getElementById("data_processing_agree"); + + var checkboxClicked = function() { + button.attr("disabled", !tosCheckbox.checked || !dataProcessingCheckbox.checked); + if (this.checked) { + button.on("click", function() { + acceptTOS(); + outer.removeClass("hidden").removeAttr("aria-hidden"); + $j.when(container.fadeOut(500)).done(function() { + container.remove(); + }); + }); + }; + }; + + setTimeout(showTOSPrompt, 1500); + + function showTOSPrompt() { + $j.when(container.fadeIn(500)).done(function() { + outer.addClass("hidden").attr("aria-hidden", "true"); + }); + + $j("#tos_agree").on("click", checkboxClicked).change(); + $j("#data_processing_agree").on("click", checkboxClicked).change(); + }; + }); +<% end %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb new file mode 100755 index 0000000..ae91450 --- /dev/null +++ b/app/views/layouts/application.html.erb @@ -0,0 +1,57 @@ + + + + + + + + + + + + + <% item = @user || @work || @series %> + <% if disallow_robots?(item) %> + + + <% end %> + + + <%= browser_page_title(@page_title, @page_subtitle) %> + + <%= render :partial => 'layouts/includes' %> + <%= phraseapp_in_context_editor_js %> + + + > +
    + + <% # layouts/proxy_notice is rendered here by JavaScript found in layouts/javascripts. %> + <%= render :partial => 'layouts/header' %> +
    + + <% if @admin_posts && !@hide_dashboard %> + <%= render :partial => 'admin_posts/sidebar' %> + <% elsif @user && !@hide_dashboard %> + <%= render :partial => 'users/sidebar' %> + <% elsif @collection && !@hide_dashboard %> + <%= render :partial => 'collections/sidebar' %> + <% elsif show_wrangling_dashboard %> + <%= render :partial => 'tag_wranglings/wrangler_dashboard' %> + <% end %> + + + +
    + <%= flash_div :error, :caution, :notice, :alert %> +
    + <%= yield %> +
    +
    + +
    + <%= render :partial => 'layouts/footer' %> +
    + <%= render :partial => 'layouts/javascripts' %> + + diff --git a/app/views/layouts/barebones.html.erb b/app/views/layouts/barebones.html.erb new file mode 100644 index 0000000..8bb75af --- /dev/null +++ b/app/views/layouts/barebones.html.erb @@ -0,0 +1,29 @@ + + + + + + <%= @page_title %> + + + + +<%= yield %> + + + diff --git a/app/views/layouts/home.html.erb b/app/views/layouts/home.html.erb new file mode 100644 index 0000000..dae3dd5 --- /dev/null +++ b/app/views/layouts/home.html.erb @@ -0,0 +1,47 @@ + + + + + + + + + + + + + <%= phraseapp_in_context_editor_js %> + + <% if defined?(@page_title) %> + <%= @page_title %> + <% else %> + <% if defined?(@page_subtitle) %> + <%= @page_subtitle %> + <% else %> + <%= controller.action_name=="index" ? "" : controller.action_name.humanize.titleize %> + <%= controller.action_name=="index" ? controller.controller_name.humanize.titleize : controller.controller_name.singularize.humanize.titleize %> + <% end %> + | + <%= ArchiveConfig.APP_NAME %> + <% end %> + + + <%= render 'layouts/includes' %> + + + > +
    + + + + + <%= yield %> + + + + <%= render 'layouts/footer' %> +
    +<%= render 'layouts/javascripts' %> + + diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb new file mode 100644 index 0000000..7bb8b8f --- /dev/null +++ b/app/views/layouts/mailer.html.erb @@ -0,0 +1,126 @@ + + + + + + + + + + +
    + + + + + + + + + + +
    + + + + + +
    + + + + + +
    +
    + + + Archive of Our Own + + + +
    +
    + + + + + +
    +
    + + + + + + + + +
    + + + + + + + + + + + + + + + +
    + + + + + + + +
    +
    + + + <% if content_for?(:message) %> + <%= yield(:message) %> + <% end %> + + +
    +
    +
    +
    + + + <% if content_for?(:footer_note) %> +

    <%= yield(:footer_note) %>

    + <% end %> + + <% if @pac_footer %> +

    <%= t("mailer.general.footer.why_policy_abuse.html", contact_policy_abuse_link: style_footer_link(t("mailer.general.footer.why_policy_abuse.contact_policy_abuse"), new_abuse_report_url)) %>

    + <% else %> +

    <%= t("mailer.general.footer.why_support.html", contact_support_link: style_footer_link(t("mailer.general.footer.why_support.contact_support"), new_feedback_report_url)) %>

    + <% end %> + +

    <%= t("mailer.general.footer.about.html", your_donations_link: style_footer_link(t("mailer.general.footer.about.your_donations"), donate_url)) %>

    + + <% if content_for?(:sent_at) %> +

    <%= t("mailer.general.footer.sent_at", sent_at: yield(:sent_at).strip) %>

    + <% end %> + + +
    +
    +
    +
    +
    + diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb new file mode 100644 index 0000000..d636dcd --- /dev/null +++ b/app/views/layouts/mailer.text.erb @@ -0,0 +1,25 @@ + +Archive of Our Own +========================================= + + +<% if content_for?(:message) then %><%= yield(:message) %><% end %> + + +----------------------------------------- +<% if content_for?(:footer_note) %> +<%= yield(:footer_note) %> + +<% end %> +<% if @pac_footer %> +<%= t("mailer.general.footer.why_policy_abuse.text", contact_policy_abuse_url: new_abuse_report_url) %> +<% else %> +<%= t("mailer.general.footer.why_support.text", contact_support_url: new_feedback_report_url) %> +<% end %> + +<%= t("mailer.general.footer.about.text", your_donations_url: donate_url) %> +<% if content_for?(:sent_at) %> + +<%= t("mailer.general.footer.sent_at", sent_at: yield(:sent_at).strip) %> + +<% end %> diff --git a/app/views/layouts/session.html.erb b/app/views/layouts/session.html.erb new file mode 100644 index 0000000..0ebc0b6 --- /dev/null +++ b/app/views/layouts/session.html.erb @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + <% if defined?(@page_title) %> + <%= @page_title %> + <% else %> + <% if defined?(@page_subtitle) %> + <%= @page_subtitle %> + <% else %> + <%= controller.action_name=="index" ? "" : process_title(controller.action_name) %> + <%= controller.action_name=="index" ? process_title(controller.controller_name) : process_title(controller.controller_name.singularize) %> + <% end %> + | + <%= ArchiveConfig.APP_NAME %> + <% end %> + + + <%= render "layouts/includes" %> + <%= phraseapp_in_context_editor_js %> + + + > +
    + + <%= render "layouts/header" %> +
    + +
    + <%= flash_div :error, :caution, :notice, :alert %> +
    +
    + <%= yield %> +
    +
    + +
    + <%= render "layouts/footer" %> +
    + <%= render "layouts/javascripts" %> + + diff --git a/app/views/locales/_locale_form.html.erb b/app/views/locales/_locale_form.html.erb new file mode 100644 index 0000000..3cbb9c0 --- /dev/null +++ b/app/views/locales/_locale_form.html.erb @@ -0,0 +1,32 @@ +<%= form_for(@locale, html: { class: 'post' }) do |f| %> +

    * <%= t('.required_notice', :default => "Required information") %>

    + +
    + <%= t('.locale_legend', :default => "Locale") %> +

    <%= t('.locale_heading', :default => "Locale") %>

    +
    +
    <%= f.label :name, t('.name', :default => "Name") + '*' %>
    +
    <%= f.text_field :name %>
    + +
    <%= f.label :iso, t('.iso', :default => "ISO code") + '*' %>
    +
    <%= f.text_field :iso %>
    + +
    <%= f.label :language_id, t('.language', :default => "Language") + '*' %>
    +
    <%= f.select(:language_id, language_options_for_select(@languages, "id")) %>
    + +
    <%= f.check_box :email_enabled %>
    +
    <%= f.label :email_enabled, t('.enable_email', :default => "Use this locale to send email") %>
    + +
    <%= f.check_box :interface_enabled %>
    +
    <%= f.label :interface_enabled, t('.enable_interface', :default => "Use this locale for the interface") %>
    + +
    +
    +
    + <%= t('.actions_legend', :default => "Actions") %> +

    <%= t('.actions_heading', :default => "Actions") %>

    +

    + <%= f.submit @locale.new_record? ? t('.create_button', :default => "Create Locale") : t('.edit_button', :default => "Update Locale") %> +

    +
    +<% end %> diff --git a/app/views/locales/_navigation.html.erb b/app/views/locales/_navigation.html.erb new file mode 100644 index 0000000..3693e07 --- /dev/null +++ b/app/views/locales/_navigation.html.erb @@ -0,0 +1,4 @@ + \ 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 100644 index 0000000..30d0e1d --- /dev/null +++ b/app/views/locales/edit.html.erb @@ -0,0 +1,13 @@ + +

    <%= t('.edit_locale', :default => 'Edit Locale') %>

    +<%= error_messages_for :locale %> + + + +<%= render 'navigation' %> + + + +<%= render 'locale_form' %> + + diff --git a/app/views/locales/index.html.erb b/app/views/locales/index.html.erb new file mode 100644 index 0000000..422f14a --- /dev/null +++ b/app/views/locales/index.html.erb @@ -0,0 +1,40 @@ + +

    <%= t('.supported_locales', :default => 'Supported Locales') %>

    + + + +<%= render 'navigation' %> + + + + + + + + + + + + + + + + <% for locale in @locales %> + + + + + + + + + + <% end %> + +
    <%= t('.locale_table_caption', :default => 'Supported Locales') %>
    NameISO CodePrimary LocaleUse for EmailUse for InterfaceCreated atActions
    <%= locale.name %><%= locale.iso %><%= locale.main %><%= locale.email_enabled %><%= locale.interface_enabled %><%= locale.updated_at %> + <%= link_to ts('Edit'), {controller: :locales, action: :edit, id: locale.iso} %> +
    + + + + diff --git a/app/views/locales/new.html.erb b/app/views/locales/new.html.erb new file mode 100644 index 0000000..ddbe831 --- /dev/null +++ b/app/views/locales/new.html.erb @@ -0,0 +1,13 @@ + +

    <%= t('.add_new_locale', :default => 'New Locale') %>

    +<%= error_messages_for :locale %> + + + +<%= render 'navigation' %> + + + +<%= render 'locale_form' %> + + diff --git a/app/views/media/index.html.erb b/app/views/media/index.html.erb new file mode 100644 index 0000000..20f5b28 --- /dev/null +++ b/app/views/media/index.html.erb @@ -0,0 +1,28 @@ + +

    <%= t(".page_heading") %>

    + + + +
      +<% for medium in @media %> +
    • +

      <%= link_to medium.name, media_fandoms_path(medium) %>

      + +
        + <% for fandom in @fandom_listing[medium] %> +
      1. + <%= link_to_tag_works_with_text(fandom, fandom.name) %> + <% if fandom.respond_to?(:count) %> + (<%= fandom.count.to_s %>) + <% end %> +
      2. + <% end %> +
      + + <% unless @fandom_listing[medium].size < 5 %> + + <% end %> +
    • +<% end %> +
    + diff --git a/app/views/menu/_menu_about.html.erb b/app/views/menu/_menu_about.html.erb new file mode 100644 index 0000000..e6959b9 --- /dev/null +++ b/app/views/menu/_menu_about.html.erb @@ -0,0 +1,7 @@ + diff --git a/app/views/menu/_menu_browse.html.erb b/app/views/menu/_menu_browse.html.erb new file mode 100644 index 0000000..d9bb54b --- /dev/null +++ b/app/views/menu/_menu_browse.html.erb @@ -0,0 +1,6 @@ + diff --git a/app/views/menu/_menu_fandoms.html.erb b/app/views/menu/_menu_fandoms.html.erb new file mode 100644 index 0000000..b6a0209 --- /dev/null +++ b/app/views/menu/_menu_fandoms.html.erb @@ -0,0 +1,11 @@ + diff --git a/app/views/menu/_menu_search.html.erb b/app/views/menu/_menu_search.html.erb new file mode 100644 index 0000000..c63ecad --- /dev/null +++ b/app/views/menu/_menu_search.html.erb @@ -0,0 +1,6 @@ + diff --git a/app/views/menu/about.html.erb b/app/views/menu/about.html.erb new file mode 100644 index 0000000..4265705 --- /dev/null +++ b/app/views/menu/about.html.erb @@ -0,0 +1,4 @@ +

    <%= ts('About', key: 'header') %>

    +
    + <%= render 'menu/menu_about' %> +
    diff --git a/app/views/menu/browse.html.erb b/app/views/menu/browse.html.erb new file mode 100644 index 0000000..b656c57 --- /dev/null +++ b/app/views/menu/browse.html.erb @@ -0,0 +1,4 @@ +

    <%= ts('Browse', key: 'header') %>

    +
    + <%= render 'menu/menu_browse' %> +
    diff --git a/app/views/menu/fandoms.html.erb b/app/views/menu/fandoms.html.erb new file mode 100644 index 0000000..53eaf23 --- /dev/null +++ b/app/views/menu/fandoms.html.erb @@ -0,0 +1,4 @@ +

    <%= ts('Fandoms', key: 'header') %>

    +
    + <%= render 'menu/menu_fandoms' %> +
    diff --git a/app/views/menu/search.html.erb b/app/views/menu/search.html.erb new file mode 100644 index 0000000..f8768aa --- /dev/null +++ b/app/views/menu/search.html.erb @@ -0,0 +1,4 @@ +

    <%= ts('Search', key: 'header') %>

    +
    + <%= render 'menu/menu_search' %> +
    diff --git a/app/views/muted/_muted_items_notice.html.erb b/app/views/muted/_muted_items_notice.html.erb new file mode 100644 index 0000000..c2cb516 --- /dev/null +++ b/app/views/muted/_muted_items_notice.html.erb @@ -0,0 +1,3 @@ +<% if user_has_muted_users? %> +

    <%= t("muted.muted_items_notice_html", muted_users_link: link_to(t("muted.muted_users_link_text"), user_muted_users_path(current_user))) %>

    +<% end %> diff --git a/app/views/muted/users/_muted_user_blurb.html.erb b/app/views/muted/users/_muted_user_blurb.html.erb new file mode 100644 index 0000000..b62bd1c --- /dev/null +++ b/app/views/muted/users/_muted_user_blurb.html.erb @@ -0,0 +1,9 @@ +<% pseud = mute.muted.default_pseud %> +
  • + <%= render "pseuds/pseud_module", pseud: pseud, date: mute.created_at %> + +
    User Actions
    + +
  • diff --git a/app/views/muted/users/confirm_mute.html.erb b/app/views/muted/users/confirm_mute.html.erb new file mode 100644 index 0000000..b9b6b31 --- /dev/null +++ b/app/views/muted/users/confirm_mute.html.erb @@ -0,0 +1,34 @@ +

    <%= t(".title", name: @muted.login) %>

    + +<%= form_tag user_muted_users_path(@user, muted_id: @muted) do %> +
    +

    + <%= t(".sure_html", mute: tag.strong(t(".mute")), username: @muted.login) %> + <%= t(".will.intro") %> +

    + +
      +
    • <%= t(".will.seeing_content") %>
    • +
    + +

    <%= t(".will_not.intro") %>

    + +
      +
    • <%= t(".will_not.prevent_emails") %>
    • +
    • <%= t(".will_not.hide_content_for_others") %>
    • +
    + +

    + <%= t(".block_users_instead_html", blocked_users_link: link_to(t(".blocked_users_link_text"), user_blocked_users_path(@user))) %> +

    + +

    + <%= t(".site_skin_warning_html", restore_site_skin_faq_link: link_to(t(".restore_site_skin_faq_link_text"), archive_faq_path("skins-and-archive-interface", anchor: :restoresiteskin))) %> +

    +
    + +

    + <%= link_to t(".cancel"), user_muted_users_path(@user) %> + <%= submit_tag t(".button") %> +

    +<% end %> diff --git a/app/views/muted/users/confirm_unmute.html.erb b/app/views/muted/users/confirm_unmute.html.erb new file mode 100644 index 0000000..cc8eeea --- /dev/null +++ b/app/views/muted/users/confirm_unmute.html.erb @@ -0,0 +1,19 @@ +

    <%= t(".title", name: @mute.muted.login) %>

    + +<%= form_tag user_muted_user_path(@user, @mute), method: :delete do %> +
    +

    + <%= t(".sure_html", unmute: tag.strong(t(".unmute")), username: @mute.muted.login) %> + <%= t(".resume.intro") %> +

    + +
      +
    • <%= t(".resume.see_content") %>
    • +
    +
    + +

    + <%= link_to t(".cancel"), user_muted_users_path(@user) %> + <%= submit_tag t(".button") %> +

    +<% end %> diff --git a/app/views/muted/users/index.html.erb b/app/views/muted/users/index.html.erb new file mode 100644 index 0000000..461b2b5 --- /dev/null +++ b/app/views/muted/users/index.html.erb @@ -0,0 +1,55 @@ +

    <%= t(".title") %>

    + + +

    <%= t("a11y.navigation") %>

    + + +
    +

    <%= t(".will.intro", mute_limit: number_with_delimiter(ArchiveConfig.MAX_MUTED_USERS), count: ArchiveConfig.MAX_MUTED_USERS) %>

    + +
      +
    • <%= t(".will.seeing_content") %>
    • +
    + +

    <%= t(".will_not.intro") %>

    + +
      +
    • <%= t(".will_not.prevent_emails") %>
    • +
    • <%= t(".will_not.hide_content_for_others") %>
    • +
    + +

    + <%= t(".block_users_instead_html", blocked_users_link: link_to(t(".blocked_users_link_text"), user_blocked_users_path(@user))) %> +

    + +

    + <%= t(".site_skin_warning_html", restore_site_skin_faq_link: link_to(t(".restore_site_skin_faq_link_text"), archive_faq_path("skins-and-archive-interface", anchor: :restoresiteskin))) %> +

    +
    + +<%# form for muting users %> + +<%= form_tag confirm_mute_user_muted_users_path(@user), method: :get, class: "single simple post" do %> +
    + <%= t(".legend") %> +

    + <%= label_tag :muted_id, t(".label"), class: "landmark" %> + <%= text_field_tag :muted_id, "", autocomplete_options("pseud", data: { autocomplete_token_limit: 1 }) %> + <%= submit_tag t(".button") %> +

    +
    +<% end %> + +

    <%= t(".heading.landmark.muted_users") %>

    +<% if @mutes.present? %> +
      + <%= render partial: "muted_user_blurb", collection: @mutes, as: :mute %> +
    +<% else %> +

    <%= t(".none") %>

    +<% end %> + +<%= will_paginate @mutes %> diff --git a/app/views/opendoors/external_authors/index.html.erb b/app/views/opendoors/external_authors/index.html.erb new file mode 100644 index 0000000..0ca5bfc --- /dev/null +++ b/app/views/opendoors/external_authors/index.html.erb @@ -0,0 +1,50 @@ +
    + +

    + <%= @query ? ts("External Author Identities") : ts("Unclaimed External Author Identities") %> +

    + + + +<%= render "opendoors/tools/tools_navigation" %> + + + +<% if @external_authors.empty? %> +

    <%= ts("No matching external authors found.") %>

    +

    <%= ts("Please try a different search.") %>

    +<% else %> + <%= will_paginate @external_authors %> + +

    <%= ts("Listing External Authors") %>

    + <% if params[:query] %>

    <%= ts("Matching: %{query}", :query => params[:query]) %>

    <% end %> +
    + <% @external_authors.each do |external_author| %> +
    + <%= external_author.email %> + <% if external_author.claimed? %> + (Claimed by <%= link_to external_author.user.login, external_author.user %>) + <% end %> +
    +
    + +
    + <% end %> +
    + + <%= will_paginate @external_authors %> +<% end %> +
    \ No newline at end of file diff --git a/app/views/opendoors/external_authors/show.html.erb b/app/views/opendoors/external_authors/show.html.erb new file mode 100644 index 0000000..76b36f3 --- /dev/null +++ b/app/views/opendoors/external_authors/show.html.erb @@ -0,0 +1,3 @@ +<%= render "opendoors/tools/tools_navigation" %> + +<%= render "external_authors/external_author_description", :external_author => @external_author %> diff --git a/app/views/opendoors/tools/_tools_navigation.html.erb b/app/views/opendoors/tools/_tools_navigation.html.erb new file mode 100644 index 0000000..744a662 --- /dev/null +++ b/app/views/opendoors/tools/_tools_navigation.html.erb @@ -0,0 +1,14 @@ + diff --git a/app/views/opendoors/tools/index.html.erb b/app/views/opendoors/tools/index.html.erb new file mode 100644 index 0000000..a7dd622 --- /dev/null +++ b/app/views/opendoors/tools/index.html.erb @@ -0,0 +1,46 @@ + +<% unless @imported_from_url.blank? %> +

    + <%= ts('Test redirect: ') %> + <%= link_to @imported_from_url, redirect_path(:original_url => @imported_from_url) %> +

    +<% end %> + +

    <%= ts("Open Doors Tools") %>

    + + + +<%= render "opendoors/tools/tools_navigation" %> + + + +<%= form_tag url_for(:controller => 'opendoors/tools', :action => 'url_update') do %> +
    + Update Redirect URL +

    <%= ts("Update Redirect URL") %>

    + +
    +
    <%= ts("Imported From")%>
    +
    <%= text_field_tag "imported_from_url" %>
    +
    <%= ts("Archive URL") %>
    +
    <%= text_field_tag "work_url" %>
    +
    + <%= submit_button %> +
    +<% end %> + +<%= form_for [:opendoors, @external_author] do |f| %> +
    + Block Email Address +

    <%= ts("Block Email Address") %>

    +
    +
    <%= f.label :email, ts("Email: ")%>
    +
    + <%= f.text_field :email %> + <%= f.hidden_field :do_not_import, :value => true %> +
    +
    + <%= submit_button(f) %> +
    +<% end %> + diff --git a/app/views/orphans/_choose_pseud.html.erb b/app/views/orphans/_choose_pseud.html.erb new file mode 100644 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 100644 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?")%> +

    + + + + + + + +<%= form_tag orphans_path do %> +

    + <% works.each do |work| %> + <%= hidden_field_tag "work_ids[]", work.id, id: "work_ids_#{work.id}" %> + <% end %> + <%= hidden_field_tag :pseud_id, pseud.id %> +

    + + <%= render "orphans/choose_pseud" %> +

    <%= submit_tag ts("Yes, I'm sure") %>

    +<% end %> + diff --git a/app/views/orphans/_orphan_series.html.erb b/app/views/orphans/_orphan_series.html.erb new file mode 100644 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 %> +

    + + + + + + +<%= form_tag orphans_path do %> + + <%= render :partial => 'orphans/choose_pseud' %> + +

    <%= hidden_field_tag 'series_id', series.id %>

    +

    <%= submit_tag ts("Yes, I'm sure") %>

    +<% end %> + + + + diff --git a/app/views/orphans/_orphan_user.html.erb b/app/views/orphans/_orphan_user.html.erb new file mode 100644 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 100644 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?")%> +

    + + + + + + +<%= form_tag orphans_path do %> + + <%= render 'orphans/choose_pseud' %> + +

    + <% works.each_with_index do |work, index| %><%= hidden_field_tag "work_ids[]", work.id, :id => "work_ids_#{index}" %><% end %> +

    + +

    <%= submit_tag ts("Yes, I'm sure") %>

    +<% end %> + + + + diff --git a/app/views/orphans/index.html.erb b/app/views/orphans/index.html.erb new file mode 100644 index 0000000..34d97fa --- /dev/null +++ b/app/views/orphans/index.html.erb @@ -0,0 +1,17 @@ + +

    <%=h t('.orphaned_works', :default => 'Orphaned Works') %>

    + + + + + + +
      + <% for work in @works %> + <%= render :partial => 'works/work_blurb', :locals => {:work => work} %> + <% end %> +
    + + + + \ No newline at end of file diff --git a/app/views/orphans/new.html.erb b/app/views/orphans/new.html.erb new file mode 100644 index 0000000..a70d6e7 --- /dev/null +++ b/app/views/orphans/new.html.erb @@ -0,0 +1,6 @@ +<%= render_orphan_partial(@to_be_orphaned) %> + + diff --git a/app/views/owned_tag_sets/_internal_tag_set_fields.html.erb b/app/views/owned_tag_sets/_internal_tag_set_fields.html.erb new file mode 100644 index 0000000..b77e1ae --- /dev/null +++ b/app/views/owned_tag_sets/_internal_tag_set_fields.html.erb @@ -0,0 +1,30 @@ +<% TagSet::TAG_TYPES.each do |tag_type| %> +
    + <% unless TagSet::TAGS_AS_CHECKBOXES.include?(tag_type) %> + <% # fandoms, chars, relationships, freeforms %> + + <% if @tag_set.with_type(tag_type).empty? %> +

    <%= form.label "#{tag_type}_tagnames_to_add".to_sym, ts("Add #{tag_type_label_name(tag_type).pluralize}:") %>

    + <% else %> +

    <%= form.label "#{tag_type}_tags_to_remove".to_sym, ts("#{tag_type_label_name(tag_type).pluralize}") %>

    + <%= check_all_none %> + <%= checkbox_section(form, "#{tag_type}_tags_to_remove", @tag_set.with_type(tag_type).by_name_without_articles) %> +
    <%= form.label "#{tag_type}_tagnames_to_add".to_sym, ts("Add:") %>
    + <% end %> + +
    <%= form.text_field "#{tag_type}_tagnames_to_add".to_sym, autocomplete_options(tag_type) %>
    + + <% else %> + <% # ratings, categories, archive_warnings %> +

    + <%= ts("%{tag_type}", tag_type: tag_type_label_name(tag_type).pluralize) %> +

    + + <%= check_all_none %> + <% raise "Redshirt: Attempted to constantize invalid class initialize _internal_tag_set_fields.html.erb (2) #{tag_type.gsub(/\s+/, "").classify}" unless %w(Category Rating ArchiveWarning).include?(tag_type.gsub(/\s+/, "").classify) %> + <%= checkbox_section(form, "#{tag_type}_tagnames", tag_type.gsub(/\s+/, "").classify.constantize.pluck(:name), + :checked_method => "#{tag_type}_tagnames", :value_method => "to_s", :name_method => "to_s", + :checkbox_side => "left") %> + <% end %> +
    +<% end %> diff --git a/app/views/owned_tag_sets/_navigation.html.erb b/app/views/owned_tag_sets/_navigation.html.erb new file mode 100644 index 0000000..55855e4 --- /dev/null +++ b/app/views/owned_tag_sets/_navigation.html.erb @@ -0,0 +1,26 @@ +<% if logged_in? %> + +<% 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 100644 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| %> +
  • +

    + <%= ts("%{media}", :media => media) %> + <% list_id = "list_for_media_#{media.gsub(/[^\w]/, '_')}" %> + (<%= @tag_hash[:fandom][media].size %>) + <%= expand_contract_shuffle(list_id) %><%= expand_contract_all %> +

    +
      + <% 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 :( + %> +
    1. + <% if (has_relationships + has_characters) > 0 %> +

      + <%= ts("%{fandom}", :fandom => fandom) %> + <% list_id = "list_for_fandom_#{fandom.gsub(/[^\w]/, '_')}_in_#{media.gsub(/[^\w]/, '_')}" %> + (<%= has_relationships + has_characters %>) + <%= expand_contract_shuffle(list_id, shuffle: false) %> +

      +
        + <% if has_characters > 0 %> + <%= @tag_hash[:character][fandom].map {|character| @character_seen[character] = true; content_tag(:li, character)}.join("\n").html_safe %> + <% end %> + <% if has_relationships > 0 %> + <%= @tag_hash[:relationship][fandom].map {|relationship| @relationship_seen[relationship] = true; content_tag(:li, relationship)}.join("\n").html_safe %> + <% end %> +
      + <% else %> +

      <%= ts("%{fandom}", :fandom => fandom) %> (0)

      + <% end %> +
    2. + <% end %> + <% end %> +
    +
  • +<% 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 100644 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| %> +
    1. +

      <%= assoc.parent %>

      +
        + <%= assoc.children.split(',').map {|child| content_tag(:li, child)}.join("\n").html_safe %> +
      +
    2. + <% 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 100644 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? %> +
    1. +

      + <%= ts("Unassociated Characters & Relationships") %> (<%= @unassociated_chars.size + @unassociated_rels.size %>) + <%= expand_contract_shuffle("list_for_unassociated_char_and_rel") %> +

      +

      <%= 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.") %>

      +
        + <%= @unassociated_chars.sort {|a,b| a.gsub(/^(the |an |a )/, '') <=> b.gsub(/^(the |an |a )/, '')}.map {|tag| content_tag(:li, tag)}.join("\n").html_safe %> + <%= @unassociated_rels.sort {|a,b| a.gsub(/^(the |an |a )/, '') <=> b.gsub(/^(the |an |a )/, '')}.map {|tag| content_tag(:li, tag)}.join("\n").html_safe %> +
      +
    2. + <% 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 %> +
    3. + <% if (has_relationships + has_characters) > 0 %> +

      + <%= ts("%{fandom}", :fandom => fandom) %> + (<%= has_relationships + has_characters %>) + <% list_id = "list_for_fandom_#{fandom.gsub(/[^\w]/, '_')}" %> + <%= expand_contract_shuffle(list_id) %> +

      +
        + <% if has_characters > 0 %> + <%= @tag_hash[:character][fandom].map {|character| content_tag(:li, character)}.join("\n").html_safe %> + <% end %> + <% if has_relationships > 0 %> + <%= @tag_hash[:relationship][fandom].map {|relationship| content_tag(:li, relationship)}.join("\n").html_safe %> + <% end %> +
      + <% else %> +

      <%= ts("%{fandom}", :fandom => fandom) %> (0)

      + <% end %> +
    4. + <% 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 100644 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_type}", tag_type: tag_type_label_name(tag_type).pluralize) %> + <%= expand_contract_all %> +

    +
      + <% slice_index = 0 %> + <% tagname_list.each_slice(itemcount) do |tagnames| %> +
    1. +

      + + <%= tagnames.first %> + <% unless tagnames.size < 2 %> → <%= tagnames.last %><% end %> + + <%= expand_contract_shuffle("list_for_#{tag_type}_#{slice_index}") %> +

      +
        + <%= tagnames.map {|tn| content_tag(:li, tn)}.join(" ").html_safe %> +
      +
    2. + <% slice_index += 1 %> + <% end %> +
    +
  • diff --git a/app/views/owned_tag_sets/_show_tags_by_alpha.html.erb b/app/views/owned_tag_sets/_show_tags_by_alpha.html.erb new file mode 100644 index 0000000..55f29b5 --- /dev/null +++ b/app/views/owned_tag_sets/_show_tags_by_alpha.html.erb @@ -0,0 +1,9 @@ +<% # expects tag_type %> +<% if @tag_set.has_type?(tag_type) %> + <% tags = @tag_set.with_type(tag_type) %> + <% if tags.count <= ArchiveConfig.MAX_OPTIONS_TO_SHOW %> + <%= render "show_tags_in_single_list", :tag_type => tag_type %> + <% else %> + <%= render "show_tags_alpha_listbox", :tagname_list => tags.by_name_without_articles.pluck(:name), :tag_type => tag_type %> + <% end %> +<% end %> diff --git a/app/views/owned_tag_sets/_show_tags_in_single_list.html.erb b/app/views/owned_tag_sets/_show_tags_in_single_list.html.erb new file mode 100644 index 0000000..6144404 --- /dev/null +++ b/app/views/owned_tag_sets/_show_tags_in_single_list.html.erb @@ -0,0 +1,12 @@ +<% # not too many, so just show them in a single group %> +
  • +

    + <%= ts("%{tag_type}", tag_type: tag_type_label_name(tag_type).pluralize) %> + + (<%= @tag_set.with_type(tag_type).count %>) + <%= expand_contract_shuffle("list_for_#{tag_type}") %> +

    +
      + <%= tag_relation_to_list @tag_set.with_type(tag_type) %> +
    +
  • diff --git a/app/views/owned_tag_sets/_tag_set_association_fields.html.erb b/app/views/owned_tag_sets/_tag_set_association_fields.html.erb new file mode 100644 index 0000000..fd473a9 --- /dev/null +++ b/app/views/owned_tag_sets/_tag_set_association_fields.html.erb @@ -0,0 +1,18 @@ +<% index ||= 0 %> +
    +
    +

    <%= link_to_remove_section ts("x"), form %>

    +
    +
    <%= form.label :tag_id, ts("Tag: ") %>
    +
    + <%= form.hidden_field :create_association, :value => true %> + <%= form.select :tag_id, options_for_select(@child_tags_in_set, form.object.tag_id), :include_blank => true %> +
    +
    <%= form.label :parent_tag_id, ts("Parent tag: ") %>
    +
    <%= form.select :parent_tag_id, options_for_select(@parent_tags_in_set, form.object.parent_tag_id), :include_blank => true %>
    +
    +
    +
    + + + \ No newline at end of file diff --git a/app/views/owned_tag_sets/_tag_set_associations_remove.html.erb b/app/views/owned_tag_sets/_tag_set_associations_remove.html.erb new file mode 100644 index 0000000..17065ca --- /dev/null +++ b/app/views/owned_tag_sets/_tag_set_associations_remove.html.erb @@ -0,0 +1,12 @@ +<% # expects "form" and @tag_set %> +<% associations_for_remove = @tag_set.tag_set_associations.joins(:tag, :parent_tag).order("parent_tags_tag_set_associations.name, tags.name") %> +
    +
    +
    <%= ts('Currently in set') %>
    +
    +

    <%= ts('Tags') %>

    + <%= check_all_none %> + <%= checkbox_section(form, :associations_to_remove, associations_for_remove, :name_method => "to_s") %> +
    +
    +
    diff --git a/app/views/owned_tag_sets/_tag_set_blurb.html.erb b/app/views/owned_tag_sets/_tag_set_blurb.html.erb new file mode 100644 index 0000000..f25d0f4 --- /dev/null +++ b/app/views/owned_tag_sets/_tag_set_blurb.html.erb @@ -0,0 +1,49 @@ +
  • +
    +

    + <%= link_to tag_set.title, tag_set_path(tag_set) %>

    +

    <%= l(tag_set.updated_at.to_date) %>

    +
    <%= ts('Owners and Mods') %>
    +
      + <%= tag_set.owners.map {|owner| content_tag(:li, link_to(owner.byline, owner.user))}.join("\n").html_safe %> +
    + <% if tag_set.moderators.length > 0 %> +
      + <%= tag_set.moderators.map {|mod| content_tag(:li, link_to(mod.byline, mod.user))}.join("\n").html_safe %> +
    + <% end %> +
    +
    + + +
    <%= ts('Summary') %>
    +
    + <%=raw strip_images(sanitize_field(tag_set, :description)) || " ".html_safe %> +
    + + + <% if tag_set.tag_set %> +
    + <% %w(fandom character relationship freeform).each do |tag_type| %> +
    <%= ts("%{tag_type}", tag_type: tag_type_label_name(tag_type).pluralize) %>:
    +
    <%= tag_set.with_type(tag_type).count %>
    + <% end %> +
    + <% end %> + + <% if logged_in? && (tag_set.user_is_moderator?(current_user) || tag_set.nominated) %> +
    <%= ts('User Actions') %>
    + + <% end %> +
  • + diff --git a/app/views/owned_tag_sets/_tag_set_form.html.erb b/app/views/owned_tag_sets/_tag_set_form.html.erb new file mode 100755 index 0000000..ebe70fb --- /dev/null +++ b/app/views/owned_tag_sets/_tag_set_form.html.erb @@ -0,0 +1,73 @@ + +<% if @tag_set.new_record? %> + +<% else %> + <%= render "navigation" %> +<% end %> + + + +

    <%= ts('Notes') %>

    +
      +
    • <%= 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.') %>
    • +
    + +

    <%= ts('Tag Set Settings') %>

    + +<%= form_for(@tag_set, :url => (@tag_set.new_record? ? tag_sets_path : tag_set_path(@tag_set)), :html => {:method => (@tag_set.new_record? ? :post : :put), :class => "tagset verbose post"}) do |tag_set_form| %> + <%= error_messages_for @tag_set %> + +
    + <%= ts('Management') %> + <%= render "tag_set_form_management", form: tag_set_form %> +
    + +
    + Tags In Set <% unless @tag_set.new_record? || !@tag_set.tag_set || @tag_set.tag_set.tags.empty? %><%= ts('(check to remove)') %><% end %> +

    <%= ts('Tags in Set') %>

    + <% @tag_set.build_tag_set unless @tag_set.tag_set %> + <%= tag_set_form.fields_for :tag_set do |internal_tag_set_form| %> +
    <%= internal_tag_set_form.hidden_field :from_owned_tag_set, :value => true %>
    + + <%= render "internal_tag_set_fields", :form => internal_tag_set_form %> + + <% end %> +
    + + +
    + <% associations_for_remove = @tag_set.tag_set_associations.reject {|assoc| assoc.new_record?} %> + <% associations_for_add = @tag_set.tag_set_associations.select {|assoc| assoc.new_record?} %> + Tag Associations <%= associations_for_remove.empty? ? '' : ts('(check to remove)') %> <%= link_to_help('tagset-tag-associations')%> + +

    <%= ts('Tag Associations') %>

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

    + <%= link_to_add_section(ts('Add Association'), tag_set_form, :tag_set_associations, "tag_set_association_fields") %> +

    + <% 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 100644 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("Current Owners") %>
    +
    +
      + <%= @tag_set.new_record? ? content_tag(:li, current_user.default_pseud.byline) : @tag_set.owners.collect(&:byline).sort.map {|owner| content_tag(:li, owner)}.join("\n").html_safe =%> +
    +
    +
    <%= form.label :owner_changes, ts("Add/Remove Owners: ") %>
    +
    <%= form.text_field :owner_changes, autocomplete_options("pseud", :size => 80) %>
    + +
    <%= ts("Current Moderators") %>
    +
    +
      <%= @tag_set.moderators.empty? ? content_tag(:li, ts("None")) : @tag_set.moderators.collect(&:byline).sort.map {|mod| content_tag(:li, mod)}.join("\n").html_safe =%>
    +
    + +
    <%= form.label :moderator_changes, ts("Add/Remove Moderators: ") %>
    +
    <%= form.text_field :moderator_changes, autocomplete_options("pseud", :size => 80) %>
    +
    + + +
    + <%= ts("Description") %> +

    <%= ts("Description") %>

    +
    +
    <%= form.label :title, ts("Title* (text only)") %>
    +
    <%= form.text_field :title %>
    + +
    <%= form.label :description, ts("Brief Description") %>
    +
    <%= form.text_field :description %>
    + +
    <%= form.label :visible, ts("Visible tag list?") %>
    +
    <%= form.check_box :visible %>
    + +
    <%= form.label :usable, ts("Usable by others?") %>
    +
    <%= form.check_box :usable %>
    + +
    <%= form.label :nominated, ts("Currently taking nominations?") %>
    +
    <%= form.check_box :nominated, :onchange => "if ($j(this).is(':checked')){$j('#nomination_limits').show();} else {$j('#nomination_limits').hide();}" %>
    +
    +
    + +
    + <%= ts("Nomination Limits") %> +

    <%= ts("Nomination Limits") %>

    +
      +
    • <%= 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| %> +
    <%= form.label "#{tag_type}_nomination_limit".to_sym %>
    +
    <%= form.text_field "#{tag_type}_nomination_limit".to_sym, :class => "number" %>
    + <% end %> +
    +<% # implied closing fieldset tag %> diff --git a/app/views/owned_tag_sets/batch_load.html.erb b/app/views/owned_tag_sets/batch_load.html.erb new file mode 100644 index 0000000..ec2129d --- /dev/null +++ b/app/views/owned_tag_sets/batch_load.html.erb @@ -0,0 +1,30 @@ + +
    + +

    <%= ts('Batch Loading') %> <%= link_to_help "tagset-batch-load" %>

    + + + + <%= render "navigation" %> + + + + <%= form_for(@tag_set, :url => do_batch_load_tag_set_path(@tag_set), :html => {:method => :put, :class => "tagset post"}) do |form| %> +
    + <%= ts('Batch Load') %> +

    <%= ts('Batch Load Tag Associations') %>

    +
    +
    +
    +

    One line per fandom in the form: fandom,character,character,character,...

    + <%= text_area_tag "batch_associations", @failed_batch_associations %> +
    +
    <%= label_tag "batch_do_relationships", ts('Relationships instead?') %>
    +
    <%= check_box_tag "batch_do_relationships" %>
    +
    <%= ts('Submit') %>
    +
    <%= form.submit ts('Submit') %>
    +
    +
    + + <% end %> +
    \ 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 100644 index 0000000..af9b12f --- /dev/null +++ b/app/views/owned_tag_sets/confirm_delete.html.erb @@ -0,0 +1,12 @@ + +

    <%= ts("Delete Tag Set?") %>

    + + +<%= form_for(@tag_set, :url => tag_set_path(@tag_set), :html => {:method => :delete, :class => "simple destroy"}) do |f| %> +

    <%= 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 100644 index 0000000..48b3052 --- /dev/null +++ b/app/views/owned_tag_sets/edit.html.erb @@ -0,0 +1,7 @@ + +
    +

    <%= ts("Edit %{title}", :title => @tag_set.title) %>

    + + <%= render "tag_set_form" %> + +
    \ 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 @@ + +
    + +

    + <% if @user %> + <%= ts('%{user_name}\'s Tag Sets', :user_name => @user.login) %> <%= link_to_help 'tagset-about' %> + <% elsif @restriction %> + <%= ts('Tag Sets Used In This Challenge') %> + <% elsif @query %> + <%= search_header @tag_sets, nil, ts('Tag Set') %> <%= link_to_help 'tagset-about' %> + <% else %> + <%= ts('Tag Sets') %> <%= link_to_help 'tagset-about' %> <%= ts('in the %{archive_name}', :archive_name => ArchiveConfig.APP_NAME) %> + <% 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 100644 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("About %{name}", :name => @tag_set.title) %>

    + + + + <%= render "navigation" %> + + + +

    <%= ts("Metadata") %>

    +
    +
    +
    <%= ts("Created on: ") %>
    +
    <%= l(@tag_set.created_at.to_date) %>
    +
    <%= ts("Maintainers:") %>
    +
    +
      + <%= (@tag_set.owners + @tag_set.moderators).map {|owner| content_tag(:li, link_to(owner.byline, owner.user))}.join("\n").html_safe %> +
    +
    +
    <%= ts("Description:")%>
    +
    + <%=raw sanitize_field(@tag_set, :description) %> + <% if @tag_set.description.blank? %> +

    <%= ts("No description given.") %>

    + <% end %> + <% if @tag_set.usable %> +

    <%= ts("This tag set can be used in challenge settings by anybody.") %>

    + <% end %> +
    + <% if @tag_set.nominated %> +
    <%= ts("Status:") %>
    +
    <%= ts("Open") %> <%= ts("to the public.") %>
    +
    <%= ts("Stats:")%>
    +
    +

    <%= ts("Nominations allowed per person:") %>

    +
    + <% TagSet::TAG_TYPES_INITIALIZABLE.each do |tag_type| %> +
    <%= ts("#{tag_type.pluralize.capitalize}: ") %>
    +
    <%= @tag_set.send("#{tag_type}_nomination_limit") %>
    + <% end %> +
    + <% end %> +
    +
    +
    + + <% if @tag_set.visible || @tag_set.user_is_moderator?(current_user) %> + +

    <%= ts("Listing Tags") %>

    + + <% tagset_cache_key = @tag_type ? "tag_set_show_#{@tag_set.tag_set_id}_#{@tag_type}" : "tag_set_show_#{@tag_set.tag_set_id}" %> + <%= cache(tagset_cache_key, skip_digest: true) do %> + <%= render "show_tag_set_tags" %> + <%= render "show_tag_set_associations" %> + <% end %> + + <% else %> +

    <%= 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 100644 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 %> +

    + <%= ts("%{tag_type} Options From", tag_type: tag_type_label_name(@tag_type)) %>: + <%= to_sentence(@tag_sets.map { |ts| link_to(ts.title, tag_set_path(ts)) }) %> +

    + + <% if @tags.empty? %> +

    <%= ts("None to list!") %>

    + <% 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 100644 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 100644 index 0000000..b94922b --- /dev/null +++ b/app/views/people/_author_blurb.html.erb @@ -0,0 +1,25 @@ +
  • +
    +

    <%= link_to(author.name, user_pseud_path(author.user, author)) %> + <% if (author.name != author.user.login) %> + (<%= link_to(author.user_name, user_path(author.user)) %>) + <% end %> +

    + <% if @search.present? %> + <% creations = author.authored_items_links(fandom_id: @search.options[:fandom_ids]).html_safe %> + <% else %> + <% creations = authored_items(author, @work_counts, @rec_counts) %> + <% end %> + <% if creations.present? %> +
    <%= creations %>
    + <% end %> +
    + <%= icon_display(author.user, author) %> +
    +
    + <% unless author.description.blank? %> +
    +

    <%=raw sanitize_field(author, :description) %>

    +
    + <% end %> +
  • \ No newline at end of file diff --git a/app/views/people/_search_form.html.erb b/app/views/people/_search_form.html.erb new file mode 100644 index 0000000..c98380a --- /dev/null +++ b/app/views/people/_search_form.html.erb @@ -0,0 +1,27 @@ +<%= form_for @search, as: :people_search, url: search_people_path, html: { class: "search", method: :get } do |f| %> +
    + <%= ts("Search people") %> +
    +
    + <%= f.label :query, ts("Search all fields") %> + <%= link_to_help "people-search-all-fields" %> +
    +
    + <%= f.text_field :query %> +
    +
    + <%= f.label :name, ts("Name") %> +
    +
    + <%= f.text_field :name, autocomplete_options("pseud") %> +
    +
    + <%= f.label :fandom, ts("Fandom") %> +
    +
    + <%= f.text_field :fandom, autocomplete_options("fandom") %> +
    +
    +

    <%= f.submit ts("Search People") %>

    +
    +<% end %> diff --git a/app/views/people/index.html.erb b/app/views/people/index.html.erb new file mode 100755 index 0000000..7ecd857 --- /dev/null +++ b/app/views/people/index.html.erb @@ -0,0 +1,22 @@ + + +

    + <%= ts("Participants in %{collection}", :collection => @collection.title) %> +

    + + +<% if @people.empty? %> +

    <%= ts("No matching people found.") %>

    +<% else %> + <%= will_paginate(@people) %> + +

    Listing People

    +
      + <% for person in @people %> + <% unless person.nil? %> + <%= render 'people/author_blurb', :author => person %> + <% end %> + <% end %> +
    + <%= will_paginate(@people) %> +<% end %> diff --git a/app/views/people/search.html.erb b/app/views/people/search.html.erb new file mode 100644 index 0000000..604b428 --- /dev/null +++ b/app/views/people/search.html.erb @@ -0,0 +1,19 @@ + +

    <%= ts("People Search") %>

    + +<%= render 'shared/search_nav' %> + +<%= render :partial => 'people/search_form' %> + +<% if @people %> +

    <%= search_results_found(@people) %>

    +

    People List

    +
      + <% 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 100644 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("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.") %> +

    + +
    +
    + <%= match_settings_form.label(:num_required_prompts, ts("Requests:")) %> +
    +
    <%= match_settings_form.select(:num_required_prompts, PotentialMatchSettings::REQUIRED_MATCH_OPTIONS.select {|k,v| k != "0"}, :selected => potential_match_settings.num_required_prompts) %>
    + + <% TagSet::TAG_TYPES.map {|t| t.pluralize }.each do |type| %> +
    <%= match_settings_form.label("num_required_#{type}", ts("%{tag_type}:", tag_type: tag_type_label_name(type).pluralize)) %>
    +
    <%= match_settings_form.select("num_required_#{type}", PotentialMatchSettings::REQUIRED_MATCH_OPTIONS, :selected => potential_match_settings.send("num_required_#{type}")) %>
    + <% end %> + +
    + <%= ts("Count optional tags for...") %> + <%= link_to_help("challenge-include-optional-tags") %> +
    +
    +
    +
      + <% TagSet::TAG_TYPES.map.each do |type| %> +
    • + <%= match_settings_form.check_box("include_optional_#{type.pluralize}", :checked => potential_match_settings.send("include_optional_#{type.pluralize}")) %> + <%= match_settings_form.label("include_optional_#{type.pluralize}", ts("%{tag_type}", tag_type: tag_type_label_name(type).pluralize)) %> +
    • + <% end %> +
    +
    +
    +
    + +
    + +<% end %> diff --git a/app/views/potential_matches/_assignment_with_request.html.erb b/app/views/potential_matches/_assignment_with_request.html.erb new file mode 100644 index 0000000..e02f6d8 --- /dev/null +++ b/app/views/potential_matches/_assignment_with_request.html.erb @@ -0,0 +1,36 @@ + +<%= fields_for "challenge_assignments[]", assignment do |assignment_form| %> + <% request_signup = assignment.request_signup %> + <% offer_signup = assignment.offer_signup %> + + + <%= link_to assignment.id, collection_assignment_path(@collection, assignment) %> + + + <% if offer_signup %> + <% # offer an autocomplete of potential matches %> + <%= assignment_form.text_field :request_signup_pseud, autocomplete_options("potential_requests?signup_id=#{offer_signup.id}", + data: { autocomplete_min_chars: 0, autocomplete_token_limit: 1 }, size: ArchiveConfig.POTENTIAL_MATCHES_MAX) %> + <% else %> + <%= request_signup.pseud.byline %> + <% end %> + + + + <% if request_signup %> + <% # offer an autocomplete of potential matches %> + <%= assignment_form.text_field :offer_signup_pseud, autocomplete_options("potential_offers?signup_id=#{request_signup.id}", + data: { autocomplete_min_chars: 0, autocomplete_token_limit: 1 }, size: ArchiveConfig.POTENTIAL_MATCHES_MAX) %> + <% else %> + <%= offer_signup.pseud.byline %> + <% end %> + + + <% unless params[:no_recipient] %> + + <%= assignment_form.text_field :pinch_hitter_byline, autocomplete_options("pseud", data: { autocomplete_token_limit: 1 }, size: 20) %> + + <% end %> + + +<% end %> diff --git a/app/views/potential_matches/_assignments.html.erb b/app/views/potential_matches/_assignments.html.erb new file mode 100644 index 0000000..e85d57b --- /dev/null +++ b/app/views/potential_matches/_assignments.html.erb @@ -0,0 +1,32 @@ +<%= form_tag set_collection_assignments_path, :method => :put do %> + <% if @assignments.blank? %> +

    <%= ts("None here! Please check the other tabs.") %>

    + <% else %> + + + + "> + + + + + + + <% unless params[:no_recipient] %> + + <% end %> + + + + <% @assignments.each do |assignment| %> + <%= render "potential_matches/assignment_with_request", :assignment => assignment %> + <% end %> +
    <%= ts("Assignments") %>
    <%= ts("Assignment ID") %><%= ts("Recipient") %><%= ts("Giver") %><%= ts("Write-In Giver") %> <%= link_to_help "challenge-pinch-hitter" %>
    + + <% 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 100644 index 0000000..506d5dd --- /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.") %> +

    + +

    + <%= ts("Potential match generation progress:") %> <%= @progress %>% +

    + +

    <%= link_to ts("Cancel Potential Match Generation"), cancel_generate_collection_potential_matches_path(@collection) %>

    diff --git a/app/views/potential_matches/_invalid_signups.html.erb b/app/views/potential_matches/_invalid_signups.html.erb new file mode 100644 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.") %> +

    + +
      +<% @invalid_signups.each do |signup| %> +
    • <%= link_to signup.pseud.byline, collection_signup_path(@collection, signup) %>
    • +<% end %> +
    diff --git a/app/views/potential_matches/_match_navigation.html.erb b/app/views/potential_matches/_match_navigation.html.erb new file mode 100644 index 0000000..3486d1a --- /dev/null +++ b/app/views/potential_matches/_match_navigation.html.erb @@ -0,0 +1,25 @@ +<% if !@collection.challenge.signup_open %> +
    + +
    +<% 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 100644 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 100644 index 0000000..c8832cc --- /dev/null +++ b/app/views/potential_matches/_no_potential_givers.html.erb @@ -0,0 +1,20 @@ +<% if @assignments_with_no_potential_givers.present? %> +
    +

    <%= ts("Participants with No Potential Givers") %>

    +
    + <% @assignments_with_no_potential_givers.each do |assignment| %> +
    + +
    + <% end %> +
    +
    +<% else %> +

    <%= ts("All participants have at least one potential giver!")%>

    +<% end %> diff --git a/app/views/potential_matches/_no_potential_recipients.html.erb b/app/views/potential_matches/_no_potential_recipients.html.erb new file mode 100644 index 0000000..0fb9ac0 --- /dev/null +++ b/app/views/potential_matches/_no_potential_recipients.html.erb @@ -0,0 +1,20 @@ +<% if @assignments_with_no_potential_recipients.present? %> +
    +

    <%= ts("Participants with No Potential Recipients") %>

    +
    + <% @assignments_with_no_potential_recipients.each do |assignment| %> +
    + +
    + <% end %> +
    +
    +<% else %> +

    <%= 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 100644 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 %> +

    + + + + + +<% 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 100644 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.") %> +

    +<% elsif @in_progress %> + <%= render "in_progress"%> +<% elsif @assignment_in_progress %> +

    <%= ts("Assignments Generating") %> <%= link_to_help "challenge-matching" %>

    +

    + <%= ts("We're now in the process of generating assignments. This should take less time than potential matching.") %> +

    +<% elsif @collection.potential_matches.empty? %> +

    <%= ts("Potential Match Generation") %> <%= link_to_help "challenge-matching" %>

    + + <%= render "match_navigation" %> +

    + <%= ts("No potential matches yet!") %> +

    + + <% if !@settings || @settings.no_match_required? %> + <%= render "no_match_required" %> + <% end %> + + <% if @invalid_signups %> + <%= render "invalid_signups" %> + <% end %> + +<% else %> + <% # we have potential matches! %> + <%= render "potential_matches/potential_matches" %> + +<% end %> + diff --git a/app/views/potential_matches/list_potential_matches.html.erb b/app/views/potential_matches/list_potential_matches.html.erb new file mode 100644 index 0000000..e69de29 diff --git a/app/views/potential_matches/show.html.erb b/app/views/potential_matches/show.html.erb new file mode 100755 index 0000000..96ae045 --- /dev/null +++ b/app/views/potential_matches/show.html.erb @@ -0,0 +1,31 @@ + +

    <%= ts("Potential Matches") %>

    + + + + + + +

    + <%= ts("Potential Match for %{requester}: %{offerer}", + :requester => @potential_match.request_signup.pseud.byline, :offerer => @potential_match.offer_signup.pseud.byline) %> +

    + +
    +
    +
    <%= ts("Requests Matched:") %>
    +
    <%= @potential_match.num_prompts_matched %>
    + +
    <%= ts("Most Tags Matched:") %>
    +
    <%= @potential_match.max_tags_matched %>
    +
    +
    + + + <%= render :partial => "challenge_signups/show_requests", :locals => {:challenge_signup => @potential_match.request_signup} %> + + <%= render :partial => "challenge_signups/show_offers", :locals => {:challenge_signup => @potential_match.offer_signup} %> + + + + diff --git a/app/views/preferences/index.html.erb b/app/views/preferences/index.html.erb new file mode 100644 index 0000000..a8c5447 --- /dev/null +++ b/app/views/preferences/index.html.erb @@ -0,0 +1,156 @@ + +

    <%= t(".page_heading") %>

    +<%= error_messages_for :preference %> + + + + +

    <%= t(".navigation.landmark") %>

    + + + + +<%= form_for(@preference, url: user_preference_path(@user, @preference), autocomplete: "off") do |f| %> +
    + <%= t(".privacy.legend") %> +

    <%= t(".privacy.heading") %> <%= link_to_help "privacy-preferences" %>

    +
      +
    • + <%= f.check_box :minimize_search_engines %> + <%= f.label :minimize_search_engines, t(".privacy.hide_work_from_search_engines") %> +
    • +
    • + <%= f.check_box :disable_share_links %> + <%= f.label :disable_share_links, t(".privacy.hide_share_buttons") %> +
    • +
    • + <%= f.check_box :allow_cocreator %> + <%= f.label :allow_cocreator, t(".privacy.allow_co_creator_invite") %> +
    • +
    +
    +
    + <%= t(".display.legend") %> +

    <%= t(".display.heading") %> <%= link_to_help "display-preferences" %>

    +
      +
    • + <%= f.check_box :adult %> + <%= f.label :adult, t(".display.show_adult_content") %> +
    • +
    • + <%= f.check_box :view_full_works %> + <%= f.label :view_full_works, t(".display.show_whole_work_default") %> +
    • +
    • + <%= f.check_box :hide_warnings %> + <%= f.label :hide_warnings, t(".display.hide_warnings") %> +
    • +
    • + <%= f.check_box :hide_freeform %> + <%= f.label :hide_freeform, t(".display.hide_additional_tags") %> +
    • +
    • + <%= f.check_box :disable_work_skins %> + <%= f.label :disable_work_skins, t(".display.hide_work_skins") %> <%= link_to_help "skins-basics" %> +
    • +
    +
    +
    +
    +
    <%= f.label :skin_id, t(".your_site_skin") %> <%= link_to_help "skins-basics" %>
    +
    + <%= link_to t(".public_site_skins"), skins_path %> + <%= f.select :skin_id, (@available_skins.collect { |s| [s.title, s.id] }) %> +
    +
    <%= f.label :time_zone, t(".your_time_zone") %>
    +
    <%= f.time_zone_select :time_zone, nil, default: Time.zone.name %>
    + <% if $rollout.active?(:set_locale_preference, @user) %> +
    <%= f.label :preferred_locale, t(".your_locale") %> <%= link_to_help "locale-preferences" %>
    +
    <%= f.select :preferred_locale, locale_options_for_select(@available_locales, "id"), + default: @preference.preferred_locale %>
    + <% end %> +
    <%= f.label :work_title_format, t(".browser_page_title_format") %> <%= link_to_help "work_title_format" %>
    +
    <%= f.text_field :work_title_format %>
    +
    +
    +
    + <%= t(".comments.legend") %> +

    <%= t(".comments.heading") %> <%= link_to_help "comment-preferences" %>

    +
      +
    • + <%= f.check_box :comment_emails_off %> + <%= f.label :comment_emails_off, t(".comments.turn_off_emails") %> +
    • +
    • + <%= f.check_box :comment_inbox_off %> + <%= f.label :comment_inbox_off, t(".comments.turn_off_inbox") %> +
    • +
    • + <%= f.check_box :comment_copy_to_self_off %> + <%= f.label :comment_copy_to_self_off, t(".comments.turn_off_copies_own_comments") %> +
    • +
    • + <%= f.check_box :kudos_emails_off %> + <%= f.label :kudos_emails_off, t(".comments.turn_off_kudos_emails") %> +
    • +
    • + <%= f.check_box :guest_replies_off %> + <%= f.label :guest_replies_off, t(".comments.guest_replies_off") %> +
    • +
    +
    +
    + <%= t(".collections_challenges_gifts.legend") %> +

    <%= t(".collections_challenges_gifts.heading") %> <%= link_to_help "collection-preferences" %>

    +
      +
    • + <%= f.check_box :allow_collection_invitation %> + <%= f.label :allow_collection_invitation, t(".collections_challenges_gifts.allow_collection_invitation") %> +
    • +
    • + <%= f.check_box :allow_gifts %> + <%= f.label :allow_gifts, t(".collections_challenges_gifts.allow_gifts") %> +
    • +
    • + <%= f.check_box :collection_emails_off %> + <%= f.label :collection_emails_off, t(".collections_challenges_gifts.turn_off_collection_emails") %> +
    • +
    • + <%= f.check_box :collection_inbox_off %> + <%= f.label :collection_inbox_off, t(".collections_challenges_gifts.turn_off_collection_inbox") %> +
    • +
    • + <%= f.check_box :recipient_emails_off %> + <%= f.label :recipient_emails_off, t(".collections_challenges_gifts.turn_off_gift_emails") %> +
    • +
    +
    +
    + <%= t(".misc.legend") %> +

    <%= t(".misc.heading") %> <%= link_to_help "misc-preferences" %>

    +
      +
    • + <%= f.check_box :history_enabled %> + <%= f.label :history_enabled, t(".misc.turn_on_history") %> +
    • +
    • + <%= f.check_box :first_login %> + <%= f.label :first_login, t(".misc.turn_on_new_user_help") %> +
    • +
    • + <%= f.check_box :banner_seen %> + <%= f.label :banner_seen, t(".misc.turn_off_banner_every_page") %> +
    • +
    +
    + <%= submit_fieldset(f) %> +<% end %> + diff --git a/app/views/profile/pseuds.js.erb b/app/views/profile/pseuds.js.erb new file mode 100644 index 0000000..12e98e9 --- /dev/null +++ b/app/views/profile/pseuds.js.erb @@ -0,0 +1,2 @@ +$j("#more_pseuds_connector").remove(); +$j("#more_pseuds").replaceWith("<%= j print_pseud_list(@user, @pseuds, first: false) %>") diff --git a/app/views/profile/show.html.erb b/app/views/profile/show.html.erb new file mode 100644 index 0000000..c4d9670 --- /dev/null +++ b/app/views/profile/show.html.erb @@ -0,0 +1,46 @@ +
    + <%= render 'users/header' %> + + + <% if @profile.title.present? %> +

    <%=h @profile.title %>

    + <% end %> + +
    +
    +
    <%= ts("My pseuds:") %>
    +
    <%= print_pseud_list(@user, @pseuds) %>
    +
    <%= ts("I joined on:") %>
    +
    <%= l(@user.created_at.to_date) %>
    +
    <%= ts("My user ID is:") %>
    +
    <%= @user.id %>
    +
    +
    + + <% if @profile.about_me.present? %> +
    +

    <%=h ts("Bio") %>

    +
    <%=raw sanitize_field(@profile, :about_me) %>
    +
    + <% end %> + + + + <% if logged_in? && current_user == @user %> +

    <%= ts("Actions") %>

    + + <% elsif policy(@user.profile).can_edit_profile? %> +

    <%= t("admin.admin_options.landmark") %>

    + + <% end %> + + +
    diff --git a/app/views/prompt_restrictions/_prompt_restriction_form.html.erb b/app/views/prompt_restrictions/_prompt_restriction_form.html.erb new file mode 100644 index 0000000..5b8d7d4 --- /dev/null +++ b/app/views/prompt_restrictions/_prompt_restriction_form.html.erb @@ -0,0 +1,90 @@ + +<% if is_offer %> + <% @challenge.build_offer_restriction unless @challenge.offer_restriction %> + <% restriction = @challenge.offer_restriction %> +<% else %> + <% @challenge.build_request_restriction unless @challenge.request_restriction %> + <% restriction = @challenge.request_restriction %> +<% end %> + +<%= challenge_form.fields_for(is_offer ? :offer_restriction : :request_restriction) do |prompt_restriction_form| %> + +
    + + <% if is_offer %> + + <%= ts("Offer Settings") %> +

    <%= ts("Offer Settings") %>

    +

    + <%= 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("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.") %>

    + <% end %> + <% if type == "gift_exchange" %> + <%= prompt_restriction_settings(prompt_restriction_form, (is_offer ? false : true), true, (@challenge.new_record? || !@challenge.collection.prompts.exists?)) %> + <% else %> + <%= prompt_restriction_settings(prompt_restriction_form, (is_offer ? false : true), false, (@challenge.new_record? || !@challenge.collection.prompts.exists?)) %> + <% end %> +
    +
    + + <% if show_tag_options %> +
    + <%= ts("Tag Options") %> +

    <%= ts("Tag Options") %> <%= link_to_help("prompt-restriction-tag-set" + (type == "gift_exchange" ? "" : "-promptmeme")) %>

    +

    + <%= 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.") %> +

    +
    + <% unless restriction.owned_tag_sets.empty? %> +
    <%= prompt_restriction_form.label :tag_sets_to_remove, ts("Current Tag Sets:") %>
    +
    +

    <%= ts("Check to remove") %>

    + <%= checkbox_section(prompt_restriction_form, :tag_sets_to_remove, restriction.owned_tag_sets, :name_method => "title") %> +
    + <% end %> +
    <%= prompt_restriction_form.label :tag_sets_to_add, ts("Tag Sets To Use:") %>
    +
    <%= prompt_restriction_form.text_field :tag_sets_to_add, autocomplete_options("owned_tag_sets") %>
    + <% TagSet::TAG_TYPES_RESTRICTED_TO_FANDOM.each do |tag_type| %> +
    + <%= ts("%{tag_type} Settings:", tag_type: tag_type_label_name(tag_type)) %> <%= link_to_help("prompt-restriction-character-and-relationship") %> +
    +
    +
      +
    • + <%= prompt_restriction_form.check_box "#{tag_type}_restrict_to_fandom", :checked => restriction.send("#{tag_type}_restrict_to_fandom") %> + <%= prompt_restriction_form.label "#{tag_type}_restrict_to_fandom", ts("Fandom only?") %> +
    • +
    • + <%= prompt_restriction_form.check_box "#{tag_type}_restrict_to_tag_set", :checked => restriction.send("#{tag_type}_restrict_to_tag_set") %> + <%= prompt_restriction_form.label "#{tag_type}_restrict_to_tag_set", ts("Tag set fandom only?") %> +
    • +
    +
    + <% end %> +
    +
    + <% 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 %> +
  • +
    +

    + <% if !prompt.title.blank? %> + <%= prompt.title %> + <% else %> + <%= ts("#{prompt.class.to_s}%{index}", :index => (index ||= false) ? " #{index+1}" : '') %> + <% end %> + <% if prompt.anonymous? %> + <%= ts("by Anonymous") %> + <% else %> + <%= ts("by %{person}", :person => (prompt.pseud ? prompt.pseud.byline : prompt.challenge_signup.pseud.byline)) %> + <% end %> + <% unless @collection %> + <%= ts("in") %> <%= link_to prompt.collection.title, collection_path(prompt.collection) %> + <% end %> +

    + + <% 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 %> +
    + <%= ts('Fandom') %>: + <%= tag_groups['Fandom'].collect{|tag| link_to_tag_works(tag) }.join(', ').html_safe if tag_groups['Fandom'] %> +   +
    + + + <%= get_symbols_for(prompt, tag_groups) %> +

    <%= set_format_for_date(prompt.created_at) %>

    +
    + +
    <%= ts("Tags") %>
    +
      + <%= blurb_tag_block(prompt, tag_groups) %> + <% any_types = TagSet::TAG_TYPES.select {|type| prompt.send("any_#{type}")} %> + <% unless any_types.empty? %> + <%= any_types.map { |type| content_tag(:li, ts("Any %{tag_type}", tag_type: tag_type_label_name(type)), :class => "tag") }.join("\n").html_safe %> + <% end %> +
    + <% if prompt.optional_tag_set && !prompt.optional_tag_set.tags.empty? %> +
    <%= ts("Optional Tags:") %>
    +
      + <%= tag_link_list(prompt.optional_tag_set.tags, link_to_works=true) %> +
    + <% end %> + + <% unless prompt.description.blank? %> +
    <%= ts("Summary") %>
    +
    + <%=raw sanitize_field(prompt, :description) %> +
    + <% end %> + + <% unless prompt.url.blank? %> +

    + <% url_label = prompt.collection.challenge.send("request_url_label") %><%= url_label.blank? ? ts("URL:") : url_label %> + <%= link_to(prompt.url, prompt.url) %> +

    + <% end %> + + <% if logged_in? %> + <%= render "prompts/prompt_controls", :challenge_signup => prompt.challenge_signup, :prompt => prompt %> + <% end %> + + <% unless (suppress_claims ||= false) %> + <% # if prompt has been claimed list by whom %> + <% unless prompt.unfulfilled_claims.empty? %> +
    +
    <%= ts("Claimed By")%>
    +
      + <% if prompt.collection.anonymous? %> + <%= ts("%{count} anonymous claimants", :count => prompt.unfulfilled_claims.count) %> + <% else %> + <%= User.for_claims(prompt.unfulfilled_claims.pluck(:id)).pluck(:login).map {|user| content_tag(:li, user)}.join("\n").html_safe %> + <% end %> +
    +
    + <% 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" %> + +<% 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") %> + +
    + <% if index.is_a? String %> + <% prompt_label += " #{index}" %> + <% else %> + <% prompt_label += " #{(index + 1)}" %> + <% end %> + + <%= prompt_label %> +

    <%= form.object.new_record? ? prompt_label : link_to(prompt_label, collection_prompt_path(@collection, form.object)) %>

    + +
    + <% if restriction.title_allowed %> + > + <% if restriction.title_required %> + <%= form.label :title, (ts("Title:") + " *") %> + <% else %> + <%= form.label :title, ts("Title:") %> + <% end %> + +
    <%= form.text_field :title %>
    + <% end %> + + + <%= render "prompts/prompt_form_tag_options", :form => form, :restriction => restriction %> + + <% if restriction.url_allowed %> + > + <% url_label = @challenge.send("#{prompt_type}_url_label") %> + <% if restriction.url_required %> + <%= form.label :url , (url_label.blank? ? (ts("Prompt URL:") + " *") : url_label + " *") %> + <% else %> + <%= form.label :url , (url_label.blank? ? ts("Prompt URL:") : url_label) %> + <% end %> + +
    <%= form.text_field :url %>
    + <% end %> + + <% if restriction.description_allowed %> + > + <% desc_label = @challenge.send("#{prompt_type}_description_label") %> + <% if restriction.description_required %> + <%= form.label :description, (desc_label.blank? ? (ts("Description:") + " *") : desc_label + " *") %> + <% else %> + <%= form.label :description, (desc_label.blank? ? ts("Description:") : desc_label) %> + <% end %> + +
    <%= form.text_area :description, :rows => 6, :cols => 50, :class => "observe_textlength" %> + <%= live_validation_for_field(field_id(form, "description").to_sym, :presence => false, :maximum_length => ArchiveConfig.NOTES_MAX) -%> + <%= generate_countdown_html(field_id(form, "description").to_sym, ArchiveConfig.NOTES_MAX) %> +
    + <% end %> + + <% if restriction.optional_tags_allowed %> + <% form.object.build_optional_tag_set unless form.object.optional_tag_set %> + <%= form.fields_for :optional_tag_set_attributes do |optional_tag_set_form| %> +
    + <%= optional_tag_set_form.label :tagnames, ts("Optional Tags:") %> <%= link_to_help("challenge-optional-tags-user")%> +
    +
    + <%= optional_tag_set_form.text_field :tagnames, autocomplete_options("tag?type=all", :value => form.object.optional_tag_set.tagnames) %> +
    + <% 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. + %> +
    <%= form.label :anonymous, ts("Semi-anonymous Prompt?") %>
    +
    + <%= form.check_box :anonymous, :checked => @collection.challenge.anonymous? ? true : form.object.anonymous %> +

    <%= ts("(Note: This is not totally secure, and is still guessable in some places.)") %>

    +
    + <% end %> +
    + + <% unless required %> +

    + <%= link_to_remove_section(ts("Remove?"), form) %> + +

    + <% end %> +
    +
    + + + + + diff --git a/app/views/prompts/_prompt_form_tag_options.html.erb b/app/views/prompts/_prompt_form_tag_options.html.erb new file mode 100644 index 0000000..8efbd60 --- /dev/null +++ b/app/views/prompts/_prompt_form_tag_options.html.erb @@ -0,0 +1,117 @@ + +<% form.object.build_tag_set unless form.object.tag_set %> +<%= form.fields_for :tag_set do |tag_set_form| %> + + <% TagSet::TAG_TYPES.each do |tag_type| %> + <% tag_label = tag_type_label_name(tag_type) %> + + <% tag_name = tag_type.classify.constantize::NAME # constantize safe as iterating over safe list %> + <% num_allowed = restriction.allowed(tag_type) %> + <% num_required = restriction.required(tag_type) %> + + <% if num_allowed > 0 %> + <% # save some local variables to make the tag_set_form cleaner %> + <% tag_fieldname = field_name(tag_set_form, "#{tag_type}_tagnames") %> + <% tag_field_id = field_id(tag_set_form, "#{tag_type}_tagnames") %> + + 0 ? ' class="required"'.html_safe : '' %>> + <%= tag_set_form.label "#{tag_type}_tagnames".to_sym, challenge_signup_label(tag_name, num_allowed, num_required), class: tag_type_css_class(tag_type) %> + + <% # kludge required for bug in nested_attributes_for:https://rails.lighthouseapp.com/projects/8994/tickets/2646-validations-not-called-when-model-updating-using-nested-attributes %> + + + <% # 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 %> +
    "> +

    <%= ts("#{tag_name.pluralize.titleize}") %>

    + <%= checkbox_section tag_set_form, "#{tag_type}_tagnames", + (restriction.has_tags?(tag_type) ? restriction.tags(tag_type) : tag_type.classify.constantize.canonical).map {|t| t.name}, # constantize safe as itterating over safe list + :checked_method => "#{tag_type}_tagnames", :value_method => "to_s", :name_method => "to_s" %> + <%= tag_set_form.hidden_field :updated_at, :value => Time.now %> +
    + + <% elsif TagSet::TAG_TYPES_RESTRICTED_TO_FANDOM.include?(tag_type) && (restriction.restricted?(tag_type, "fandom") || restriction.restricted?(tag_type, "tag_set")) %> + <% # characters or relationships restricted to fandom: use an autocomplete and either do/don't include wrangled tags %> + <% autocomplete_method = "associated_tags?fallback=false&tag_type=#{tag_type}&tag_set=#{restriction.tag_set_ids.join(',')}" %> + <% autocomplete_method += "&include_wrangled=false" if restriction.restricted?(tag_type, "tag_set") %> +
    "> + <%= tag_set_form.text_field "#{tag_type}_tagnames", + autocomplete_options(autocomplete_method, + data: { + autocomplete_min_chars: 0, + autocomplete_token_limit: num_allowed, + autocomplete_hint_text: ts("Please select a fandom first!"), + autocomplete_searching_text: ts("Searching by fandom..."), + autocomplete_live_params: "fandom=#{@fandom_tag_id}" + }) %> + <%= tag_set_form.hidden_field :updated_at, :value => Time.now %> +
    + <% 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 %> +
    listbox group" title="<%= ts("choose #{tag_name.pluralize.downcase}") %>"> +

    <%= ts("#{tag_name.titleize}") %>

    + <%= checkbox_section tag_set_form, "#{tag_type}_tagnames", restriction.tags(tag_type).by_name_without_articles.pluck(:name), + :checked_method => "#{tag_type}_tagnames", :value_method => "to_s", :name_method => "to_s" %> + <%= tag_set_form.hidden_field :updated_at, :value => Time.now %> +
    + <% else # we have set options but too many for tickyboxes, do autocomplete instead %> +
    "> + <%= tag_set_form.text_field "#{tag_type}_tagnames", + autocomplete_options("tags_in_sets?tag_type=#{tag_type}&tag_set=#{restriction.tag_set_ids.join(',')}", + data: { autocomplete_token_limit: num_allowed }) %> +

    + <% # 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") %> +

    + <%= tag_set_form.hidden_field :updated_at, :value => Time.now %> +
    + <% end %> + <% else # no specified tags so let's just go with standard autocomplete %> +
    "> + <%= tag_set_form.text_field "#{tag_type}_tagnames", autocomplete_options(tag_type, data: { autocomplete_token_limit: num_allowed }) %> + <%= tag_set_form.hidden_field :updated_at, :value => Time.now %> +
    + <% end %> + + <% end # long if for the dd %> + + <% if restriction.allow_any?(tag_type) %> +
    + <%= form.label "any_#{tag_type}", :class => 'action' do %> + <%= form.check_box "any_#{tag_type}".to_sym %> + <%= ts("Any #{tag_name.singularize.titleize}") %> + <% end %> + <%= link_to_help("challenge-any") %> +
    + <% 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 100644 index 0000000..7e59cea --- /dev/null +++ b/app/views/prompts/_prompt_navigation.html.erb @@ -0,0 +1,10 @@ + diff --git a/app/views/prompts/edit.html.erb b/app/views/prompts/edit.html.erb new file mode 100644 index 0000000..a6741c7 --- /dev/null +++ b/app/views/prompts/edit.html.erb @@ -0,0 +1,18 @@ + +

    <%= ts("Edit Prompt") %>

    + + + +<% if @prompt.challenge_signup %> + <%= render "prompt_navigation" %> +<% end %> + + + +<%= form_for @prompt, :as => :prompt, :url => collection_prompt_path(@collection, @prompt), :html => {:method => :put} do |form| %> +

    <%= ts("* Required information") %>

    + <%= 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 100644 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 100644 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 %> + +

    <%= ts("#{@prompt.class.name} by %{person}", :person => @prompt.anonymous? ? "Anonymous" : @prompt.challenge_signup.pseud.byline) %>

    + + + + + + +
      + <%= render "prompts/prompt_blurb", :prompt => @prompt %> +
    + diff --git a/app/views/pseuds/_byline.html.erb b/app/views/pseuds/_byline.html.erb new file mode 100644 index 0000000..896b99d --- /dev/null +++ b/app/views/pseuds/_byline.html.erb @@ -0,0 +1,90 @@ +<% # expects locals form, object %> +<% + editor_pseuds = User.current_user.pseuds.to_a + editor_selected = (object.current_user_pseuds || object.pseuds) & editor_pseuds + editor_selected << User.current_user.default_pseud if editor_selected.empty? + + valid_creatorships = object.creatorships_after_saving + + selected = valid_creatorships.map(&:pseud) + + invited = valid_creatorships.reject(&:approved?).map(&:pseud) - editor_pseuds + approved = valid_creatorships.select(&:approved?).map(&:pseud) - editor_pseuds + saved = valid_creatorships.reject(&:new_record?).map(&:pseud) + + if object.is_a?(Chapter) + approved = (approved + object.work.pseuds - editor_pseuds).uniq + end +%> +<%= form.fields_for :author_attributes do |creator_form| %> + <% if editor_pseuds.size == 1 %> + <%= creator_form.hidden_field :ids, value: editor_pseuds.first.id, multiple: true %> + <% else %> + + + <% end %> + + <% if approved.any? %> + + + <% end %> + + <% if invited.any? %> + + + <% end %> + + + +<% end %> diff --git a/app/views/pseuds/_pseud_blurb.html.erb b/app/views/pseuds/_pseud_blurb.html.erb new file mode 100644 index 0000000..fa12d9b --- /dev/null +++ b/app/views/pseuds/_pseud_blurb.html.erb @@ -0,0 +1,45 @@ +
  • + <%= render "pseud_module", pseud: pseud %> + + <% if current_user == pseud.user || policy(pseud).edit? %> +
    <%= t(".user_actions") %>
    + + <% end %> +
  • diff --git a/app/views/pseuds/_pseud_module.html.erb b/app/views/pseuds/_pseud_module.html.erb new file mode 100644 index 0000000..2e4dd36 --- /dev/null +++ b/app/views/pseuds/_pseud_module.html.erb @@ -0,0 +1,22 @@ +
    +

    <%= link_to(pseud.name, user_pseud_path(pseud.user, pseud)) %> + <% if (pseud.name != pseud.user_name || pseud.user.pseuds.size > 1) %> + (<%= link_to(pseud.user_name, user_path(pseud.user)) %>) + <% end %> +

    + <% unless authored_items(pseud, @work_counts, @rec_counts).blank? %> +
    <%= authored_items(pseud, @work_counts, @rec_counts) %>
    + <% end %> +
    + <%= icon_display(pseud.user, pseud) %> +
    + <%# created_at date to display to admins for mutes and blocks %> + <% if local_assigns[:date] && logged_in_as_admin? %> +

    <%= date %>

    + <% end %> +
    +<% if pseud.description.present? %> +
    + <%= raw sanitize_field(pseud, :description, image_safety_mode: true) %> +
    +<% end %> diff --git a/app/views/pseuds/_pseuds_form.html.erb b/app/views/pseuds/_pseuds_form.html.erb new file mode 100644 index 0000000..03640d7 --- /dev/null +++ b/app/views/pseuds/_pseuds_form.html.erb @@ -0,0 +1,72 @@ +<%= form_for([@user, @pseud], html: { multipart: true }) do |f| %> +
    +
    <%= f.label :name, t(".name") %>
    +
    + <% if @pseud&.name == @user.login %> +

    <%= @pseud.name %>

    +

    <%= t(".cannot_change_matching_pseud_html", change_username_link: link_to(t(".change_username"), change_username_user_path(@user))) %>

    + <% else %> + <%= f.text_field :name, class: "observe_textlength", disabled: logged_in_as_admin? %> + <%= generate_countdown_html("pseud_name", Pseud::NAME_LENGTH_MAX) %> + <% end %> +
    + +
    <%= f.label :is_default, t(".make_default") %>
    +
    <%= f.check_box :is_default, disabled: ((@pseud.name && @user.login == @pseud.name && @pseud.is_default?) || logged_in_as_admin?) %>
    + +
    <%= f.label :description, t(".description") %>
    +
    +

    <%= allowed_html_instructions %>

    + <%= f.text_area :description, class: "observe_textlength" %> + <%= generate_countdown_html("pseud_description", Pseud::DESCRIPTION_MAX) %> +
    + +
    <%= t(".icon") %>
    +
    +
      + <% unless @pseud.new_record? %> +
    • <%= icon_display(@user, @pseud) %> <%= t(".icon_notes.current") %>
    • + <% end %> +
    • <%= t(".icon_notes.limit") %>
    • +
    • <%= t(".icon_notes.format") %>
    • +
    • <%= t(".icon_notes.size") %>
    • +
    + <% if @pseud.icon.attached? %> + <%= f.check_box :delete_icon, checked: false %> + <%= f.label :delete_icon, t(".icon_delete") %> + <% end %> +
    + +
    <%= f.label :icon, t(".icon_upload") %>
    +
    <%= f.file_field :icon, disabled: logged_in_as_admin? %>
    + +
    + <%= f.label :icon_alt_text, t(".icon_alt") %> + <%= link_to_help "icon-alt-text" %> +
    +
    + <%= f.text_field :icon_alt_text, class: "observe_textlength", disabled: logged_in_as_admin? %> + <%= generate_countdown_html("pseud_icon_alt_text", ArchiveConfig.ICON_ALT_MAX) %> +
    + +
    + <%= f.label :icon_comment_text, t(".icon_comment") %> + <%= link_to_help("pseud-icon-comment") %> +
    +
    + <%= f.text_field :icon_comment_text, class: "observe_textlength", disabled: logged_in_as_admin? %> + <%= generate_countdown_html("pseud_icon_comment_text", ArchiveConfig.ICON_COMMENT_MAX) %> +
    + + <% if policy(@pseud).can_edit? %> +
    <%= f.label :ticket_number, class: "required" %>
    +
    + <%= f.text_field :ticket_number, class: "required" %> +
    + <% end %> + +
    <%= t(".submit") %>
    +
    <%= f.submit button_text %>
    +
    + +<% end %> diff --git a/app/views/pseuds/delete_preview.html.erb b/app/views/pseuds/delete_preview.html.erb new file mode 100644 index 0000000..c2d8a94 --- /dev/null +++ b/app/views/pseuds/delete_preview.html.erb @@ -0,0 +1,26 @@ +

    <%=h t(".heading") %>

    + +

    + <%= t(".notice") %> +

    + +<% if @pseud.bookmarks %> + <%= form_tag({:action => "destroy"}, :method => :delete) do %> +

    <%= t(".saved_bookmarks", count: @pseud.bookmarks.count) %> +

    + +

    + <%= radio_button_tag 'bookmarks_action', "delete_bookmarks", true %> + <%= label_tag 'bookmarks_action_delete_bookmarks', t(".delete", count: @pseud.bookmarks.count) %> +

    + +

    + <%= radio_button_tag 'bookmarks_action', "transfer_bookmarks" %> + <%= label_tag 'bookmarks_action_transfer_bookmarks', t(".transfer", count: @pseud.bookmarks.count) %> +

    + +

    + <%= submit_tag ts("Submit"), data: {confirm: t(".confirm")} %> <%= submit_tag t(".cancel"), :name => 'cancel_button', :class => 'cancel' %> +

    + <% end %> +<% end %> diff --git a/app/views/pseuds/edit.html.erb b/app/views/pseuds/edit.html.erb new file mode 100644 index 0000000..a79eca1 --- /dev/null +++ b/app/views/pseuds/edit.html.erb @@ -0,0 +1,18 @@ + +

    <%= ts('Editing pseud') %>

    +<%= error_messages_for :pseud %> + + + + + + + +<%= render :partial => 'pseuds_form', :locals => { :button_text => t('.forms.update', :default => "Update") } %> + + + + \ No newline at end of file diff --git a/app/views/pseuds/index.html.erb b/app/views/pseuds/index.html.erb new file mode 100644 index 0000000..db2071e --- /dev/null +++ b/app/views/pseuds/index.html.erb @@ -0,0 +1,26 @@ + +

    <%= ts("Pseuds for %{user}", :user => @user.login) %>

    + + + +<% if current_user == @user %> +

    <%= ts("Navigation") %>

    + +<% end %> +<%= will_paginate @pseuds %> + + + +

    <%= ts("List of Pseuds") %>

    +
      + <% @pseuds.each do |pseud| %> + <%= render :partial => 'pseuds/pseud_blurb', :locals => {:pseud => pseud} %> + <% end %> +
    + + + +<%= will_paginate @pseuds %> + diff --git a/app/views/pseuds/new.html.erb b/app/views/pseuds/new.html.erb new file mode 100644 index 0000000..889f08a --- /dev/null +++ b/app/views/pseuds/new.html.erb @@ -0,0 +1,14 @@ + +

    <%= ts('New pseud') %>

    +<%= error_messages_for :pseud %> + + + + + + + +<%= render :partial => 'pseuds_form', :locals => { :button_text => ts("Create") }%> + \ No newline at end of file diff --git a/app/views/pseuds/show.html.erb b/app/views/pseuds/show.html.erb new file mode 100644 index 0000000..ac1cd9e --- /dev/null +++ b/app/views/pseuds/show.html.erb @@ -0,0 +1,14 @@ +
    + <%= render "users/header" %> + <%= render "users/contents" %> +
    + + +

    Navigation

    + + diff --git a/app/views/questions/manage.html.erb b/app/views/questions/manage.html.erb new file mode 100644 index 0000000..e127cad --- /dev/null +++ b/app/views/questions/manage.html.erb @@ -0,0 +1,41 @@ +
    + +

    <%= ts("Reorder questions") %>

    + +

    <%= ts("Drag questions to change their order.") %>

    + + <%= form_tag url_for(action: 'update_positions') do %> +
      + <% for question in @questions %> +
    • + <%= text_field_tag 'questions[]', nil, size: 3, maxlength: 3, class: 'number question-position-field', id: 'questions_' + question.position.to_s %> + <%= question.position %>. + <%= question.question.html_safe %> +
    • + <% end %> +
    +

    + <%= submit_tag ts("Update Positions") %> + <%= link_to ts("Back"), url_for(@archive_faq) %> +

    + <% end %> +
    + +<%= content_for :footer_js do %> + <%= javascript_tag do %> + $j("#sortable_question_list").sortable({ + delay: 300, + update: function(event, ui) { + $j(".question-position-list").each(function(index, li){ + var questionId = $j(li).attr("id").replace("question_",""); + $j("#position-for-"+questionId).html(index+1); + }); + $j.ajax({ + type: 'post', + data: $j("#sortable_question_list").sortable("serialize") + "&archive_faq_id=<%= @archive_faq.id %>", + dataType: 'script', + url: "<%= url_for(action: :update_positions) %>"}) + } + }) + <% end %> +<% end %> \ No newline at end of file diff --git a/app/views/readings/_reading_blurb.html.erb b/app/views/readings/_reading_blurb.html.erb new file mode 100644 index 0000000..4020c93 --- /dev/null +++ b/app/views/readings/_reading_blurb.html.erb @@ -0,0 +1,64 @@ +<% # expects "work" and "reading" %> +<% css_classes = if work.nil? + "deleted reading work blurb group" + elsif is_author_of?(work) + "own reading work #{css_classes_for_creation_blurb(work)}" + else + "reading work #{css_classes_for_creation_blurb(work)}" + end %> +
  • id="work_<%= work.id %>"<% end %> class="<%= css_classes %>" role="article"> + + <% unless reading.work.nil? %> + + <%= render 'works/work_module', work: reading.work %> + + <% end %> + +
    +

    + + <% if work.nil? %> + + <%= ts('(Deleted work, last visited %{date})', date: set_format_for_date(reading.last_viewed)) %> + + <% else %> + + <%= ts('Last visited:') %> <%= set_format_for_date(reading.last_viewed) %> + + <% if reading.major_version_read != work.major_version %> + <%= ts('(Update available.)') %> + <% elsif reading.minor_version_read != work.minor_version %> + <%= ts('(Minor edits made since then.)') %> + <% else %> + <%= ts('(Latest version.)') %> + <% end %> + + <% if reading.view_count == 1 %> + <%= ts('Visited once') %> + <% else %> + <%= ts('Visited %{count} times', count: reading.view_count) %> + <% end %> + + <% if reading.toread? %> + <%= ts('(Marked for Later.)') %> + <% end %> + <% if reading.toskip? %> + <%= ts('(Flagged to skip.)') %> + <% end %> + + <% end %> + +

    + + + +
    + +
  • diff --git a/app/views/readings/index.html.erb b/app/views/readings/index.html.erb new file mode 100644 index 0000000..90d1969 --- /dev/null +++ b/app/views/readings/index.html.erb @@ -0,0 +1,34 @@ + +

    <%= ts('History') %>

    + + + +<% if logged_in? && !current_user.readings.empty? %> + +<% end %> + + + +

    <%= ts('List of History Items') %>

    +<% 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 100644 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 %> +
    + Find story by URL +

    + <%= label_tag "original_url", ts('Original URL of work:') %>
    + <%= text_field_tag "original_url", "", :size => 70 %> + <%= submit_tag "Go" %> +

    +
    + +<% end %> diff --git a/app/views/related_works/_approve.html.erb b/app/views/related_works/_approve.html.erb new file mode 100644 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 100644 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 100644 index 0000000..14898dd --- /dev/null +++ b/app/views/related_works/index.html.erb @@ -0,0 +1,156 @@ + +<% if @user %> +

    <%= t(".page_heading", login: @user.login) %>

    +<% end %> + + + + + + +<% unless @translations_of_user.blank? %> +

    <%= 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"> + + + + + + + <% if current_user == @user %> + + <% end %> + + + + <% @translations_of_user.each do |related_work| %> + <% if related_work.parent && related_work.work %> + + <% if related_work.work.unrevealed? %> + + <% else %> + + + + <% if current_user == @user %> + + <% end %> + <% end %> + + <% end %> + <% end %> + +
    <%= ts("Translations of %{user_login}'s Works", :user_login => @user.login) %>
    <%= ts("Translation") %><%= ts("Original") %><%= ts("Language") %><%= ts("Modify Link") %>
    <%= ts("A work in an unrevealed collection") %><%= link_to related_work.work.title, related_work.work %> <%= ts("by") %> <%= byline(related_work.work) %><%= link_to related_work.parent.title, related_work.parent %> + <%= ts("From") %> <%= language_link(related_work.parent) %> <%= ts("to") %> <%= language_link(related_work.work) %> + + <% if related_work.reciprocal? %> + <%= link_to ts("Remove"), related_work %> + <% else %> + <%= link_to ts("Approve"), related_work %> + <% end %> +
    +<% end %> + +<% unless @translations_by_user.blank? %> +

    <%= ts("Works translated by %{user_login}", :user_login => @user.login) %>

    + @user.login) %><% if current_user == @user %><%= ' ' + ts("Also includes management options.") %><% end %>" id="translationsbyme"> + + + + + + + <% if current_user == @user %> + + <% end %> + + + + <% @translations_by_user.each do |related_work| %> + <% if related_work.parent && related_work.work %> + + + + + <% if current_user == @user %> + + <% end %> + + <% end %> + <% end %> + +
    <%= ts("Works Translated by %{user_login}", :user_login => @user.login) %>
    <%= ts("Original") %><%= ts("Translation") %><%= ts("Language") %><%= ts("Modify Link") %>
    + <% if related_work.parent.is_a?(Work) && related_work.parent.unrevealed? %> + <%= ts("A work in an unrevealed collection") %> + <% else %> + <%= link_to related_work.parent.title, related_work.parent %> <%= ts("by") %> <%= byline(related_work.parent) %> + <% end %> + <%= link_to related_work.work.title, related_work.work %> + <%= ts("From") %> <%= language_link(related_work.parent) %> <%= ts("to") %> <%= language_link(related_work.work) %> + <%= button_to ts("Remove"), related_work_path(related_work), data: { confirm: ts("Are you sure?") }, method: :delete %>
    +<% end %> + +<% unless @remixes_of_user.blank? %> +

    <%= ts("Works inspired by %{user_login}", :user_login => @user.login) %>

    +
    + <% @remixes_of_user.each do |related_work| %> + <% if related_work.parent && related_work.work %> +
    + <% if related_work.work.unrevealed? %> + <%= ts("A work in an unrevealed collection") %> + <% else %> + <%= link_to related_work.work.title, related_work.work %> <%= ts("by") %> <%= byline(related_work.work) %> +
    +
    <%= ts("was inspired by ") %><%= link_to related_work.parent.title, related_work.parent %> + <% if current_user == @user %> + + <% if related_work.reciprocal? %> + <%= link_to ts("Remove"), related_work %> + <% else %> + <%= link_to ts("Approve"), related_work %> + <% end %> + + <% end %> +
    + <% end %> + <% end %> + <% end %> +
    +<% end %> + +<% unless @remixes_by_user.blank? %> +

    <%= ts("Works that inspired %{user_login}", :user_login => @user.login) %>

    +
    + <% @remixes_by_user.each do |related_work| %> + <% unless related_work.translation? %> + <% if related_work.parent && related_work.work %> +
    + <% if related_work.parent.is_a?(Work) && related_work.parent.unrevealed? %> + <%= ts("A work in an unrevealed collection") %> + <% else %> + <%= link_to related_work.parent.title, related_work.parent %> <%= ts("by") %> <%= byline(related_work.parent) %> + <% end %> +
    +
    + <%= ts("inspired") %> + <%= link_to related_work.work.title, related_work.work %> + <% if current_user == @user %> + <%= button_to ts("Remove"), related_work_path(related_work), data: { confirm: ts("Are you sure?") }, method: :delete %> + <% end %> +
    + <% end %> + <% end %> + <% end %> +
    +<% end %> + + diff --git a/app/views/related_works/show.html.erb b/app/views/related_works/show.html.erb new file mode 100644 index 0000000..f59919d --- /dev/null +++ b/app/views/related_works/show.html.erb @@ -0,0 +1,13 @@ + + + + + + + +<% if @related_work.reciprocal? %> + <%= render :partial => 'remove', :locals => { :related_work => @related_work } %> +<% else %> + <%= render :partial => 'approve', :locals => { :related_work => @related_work } %> +<% end %> + \ No newline at end of file diff --git a/app/views/series/_series_blurb.html.erb b/app/views/series/_series_blurb.html.erb new file mode 100644 index 0000000..d0f8565 --- /dev/null +++ b/app/views/series/_series_blurb.html.erb @@ -0,0 +1,7 @@ +
  • + <%= render 'series/series_module', :series => series %> + <% if is_author_of?(series) %> +
    <%= ts("Author Actions") %>
    + + <% end %> +
  • diff --git a/app/views/series/_series_module.html.erb b/app/views/series/_series_module.html.erb new file mode 100644 index 0000000..c2a0414 --- /dev/null +++ b/app/views/series/_series_module.html.erb @@ -0,0 +1,52 @@ +<% # expects "series" %> + + +
    +

    + <%= link_to series.title, series %> + <%= ts('by') %> + <%= byline(series) %> + <% if series.restricted %> + <%= image_tag("lockblue.png", size: "15x15", alt: ts("(Restricted)"), + title: ts("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 %> +

    +
    + <%= ts('Fandom') %>: + <%= series.work_tags.select{ |tag| tag.type == "Fandom" }. + sort.collect{ |tag| link_to_tag_works(tag) }. + join(', ').html_safe %> +
    + <%= get_symbols_for(series) %> +

    <%= set_format_for_date(series.revised_at) %>

    +
    + + +
      + <%= blurb_tag_block(series) %> +
    + + +<% unless series.summary.blank? %> +
    <%= ts("Summary") %>
    +
    + <%=raw strip_images(sanitize_field(series, :summary)) %> +
    +<% end %> + +
    +
    <%= Work.human_attribute_name("word_count") %>:
    +
    <%= number_with_delimiter(series.visible_word_count) %>
    +
    <%= Work.model_name.human(count: :many) %>:
    +
    <%= number_with_delimiter(series.visible_work_count) %>
    + <% if (bookmark_count = series.bookmarks.is_public.count) > 0 %> +
    <%= Bookmark.model_name.human(count: :many) %>:
    +
    <%= link_to_bookmarkable_bookmarks(series, number_with_delimiter(bookmark_count)) %>
    + <% end %> +
    diff --git a/app/views/series/_series_navigation.html.erb b/app/views/series/_series_navigation.html.erb new file mode 100644 index 0000000..3763c36 --- /dev/null +++ b/app/views/series/_series_navigation.html.erb @@ -0,0 +1,19 @@ + diff --git a/app/views/series/_series_order.html.erb b/app/views/series/_series_order.html.erb new file mode 100644 index 0000000..ab42bc5 --- /dev/null +++ b/app/views/series/_series_order.html.erb @@ -0,0 +1,35 @@ +
    + <%= form_tag update_positions_series_path, method: :post do %> +
      + <% @serial_works.each_with_index do |serial, i| %> +
    • + <%= text_field_tag 'serial_works[]', nil, :size => 3, :maxlength => 3, :class => 'number serial-position-field', :id => "serial_#{i}" %> + <%= serial.position %>. +

      + <%= serial.work.posted? ? serial.work.title : t(".draft_work_title", title: serial.work.title) %> +

      +
    • + <% end %> +
    +

    <%= submit_tag "Update Positions" %>

    + <% end %> +
    + +<%= content_for :footer_js do %> + <%= javascript_tag do %> + $j("#sortable_series_list").sortable({ + delay: 300, + update: function(event, ui) { + $j(".serial-position-list").each(function(index, li){ + var serialId = $j(li).attr("id").replace("serial_",""); + $j("#position-for-"+serialId).html(index+1); + }); + $j.ajax({ + type: 'post', + data: $j("#sortable_series_list").sortable("serialize"), + dataType: 'json', + url: "<%= update_positions_series_path %>"}) + } + }) + <% end %> +<% end %> diff --git a/app/views/series/confirm_delete.html.erb b/app/views/series/confirm_delete.html.erb new file mode 100644 index 0000000..cb3edaa --- /dev/null +++ b/app/views/series/confirm_delete.html.erb @@ -0,0 +1,11 @@ + +

    <%= ts("Delete Series") %>

    + + +<%= form_for(@series, :html => {:method => :delete, :class => "simple destroy"}) do |f| %> +

    <%= 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 100644 index 0000000..8abba95 --- /dev/null +++ b/app/views/series/edit.html.erb @@ -0,0 +1,82 @@ + +

    <%= ts("Edit Series") %>

    + + + +<%= render "series/series_navigation" %> + + + + +
    +<%= form_for(@series) do |f| %> + <%= error_messages_for @series %> +

    <%= ts("* Required information") %>

    +
    + <%= ts('Meta') %> +
    +
    + <%= f.label :title, ts("Series Title*"), :class => "required" %> +
    +
    + <%= f.text_field :title, :class => "observe_textlength text" %> + <%= live_validation_for_field('series_title', + :maximum_length => ArchiveConfig.TITLE_MAX, :minimum_length => ArchiveConfig.TITLE_MIN, + :failureMessage => ts("We need a title! (At least %{min_length} characters long, please.)", :min_length => ArchiveConfig.TITLE_MIN.to_s)) + %> + <%= generate_countdown_html("series_title", ArchiveConfig.TITLE_MAX) %> +
    + + + <%= render 'pseuds/byline', form: f, object: @series %> + +
    + <%= f.label :summary, ts("Series Description", :max => ArchiveConfig.SUMMARY_MAX.to_s) %> +
    +
    + <%= allowed_html_instructions %> + <%= f.text_area :summary, :class => "observe_textlength" %> + <%= live_validation_for_field('series_summary', :presence => false, :maximum_length => ArchiveConfig.SUMMARY_MAX) %> + <%= generate_countdown_html("series_summary", ArchiveConfig.SUMMARY_MAX) %> +
    + +
    + <%= f.label :series_notes, ts("Series Notes") %> +
    +
    + <%= allowed_html_instructions %> + <%= f.text_area :series_notes, :class => "observe_textlength" %> + <%= live_validation_for_field('series_notes', :presence => false, :maximum_length => ArchiveConfig.NOTES_MAX) %> + <%= generate_countdown_html("series_notes", ArchiveConfig.NOTES_MAX) %> +
    +
    + <%= f.check_box :complete %> +
    +
    + <%= f.label :complete, ts("This series is complete") %> +
    +
    +
    + +
    + <%= ts('Post') %> +

    + <%= f.submit ts("Update") %> + <%= link_to ts("Cancel"), series_path %> +

    +
    +<% 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/series/index.html.erb b/app/views/series/index.html.erb new file mode 100644 index 0000000..9c1391b --- /dev/null +++ b/app/views/series/index.html.erb @@ -0,0 +1,28 @@ + +<%= render "muted/muted_items_notice" %> + +

    + <% if @user %> + <%= search_header @series, nil, "Series", @pseud ? @pseud : @user %> + <% else %> + <%=h "View All Series" %> + <% end %> +

    + + + +<%= will_paginate @series %> + + + +

    Listing Series

    +
      + <% for series in @series %> + <%= render :partial => 'series/series_blurb', :locals => {:series => series} %> + <% end %> +
    + + + +<%= will_paginate @series %> + diff --git a/app/views/series/manage.html.erb b/app/views/series/manage.html.erb new file mode 100644 index 0000000..3357be9 --- /dev/null +++ b/app/views/series/manage.html.erb @@ -0,0 +1,10 @@ + +

    <%=h t('.manage_series', :default => "Manage Series:") %> <%=h @series.title %>

    + + + + + + +<%= render :partial => 'series_order' %> + \ No newline at end of file diff --git a/app/views/series/new.html.erb b/app/views/series/new.html.erb new file mode 100644 index 0000000..1419b26 --- /dev/null +++ b/app/views/series/new.html.erb @@ -0,0 +1,22 @@ + +

    <%=h 'New series' %>

    + + + + + + +<%= form_for(@series) do |f| %> + <%= error_messages_for @series %> + +

    + <%= f.submit "Create" %> +

    +<% end %> + + + + + \ 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 100644 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? %> +
    <%= render "admin/admin_options", item: @series %>
    +<% end %> + + +<% if logged_in? %> + <%= render "series/series_navigation" %> +<% end %> + + + +

    <%= ts("Series Metadata") %>

    +
    +
    + + <% if @series.pseuds.many?%> +
    <%= ts("Creators:") %>
    +
    <%= byline(@series) %>
    + <% else %> +
    <%= ts("Creator:") %>
    +
    <%= byline(@series) %>
    + <% end %> + +
    <%= ts("Series Begun:") %>
    +
    <%= @series.published_at.to_date %>
    +
    <%= ts("Series Updated:") %>
    +
    <%= @series.revised_at ? @series.revised_at.to_date : @series.published_at %>
    + + <% unless @series.summary.blank? %> +
    <%= ts("Description:") %>
    +
    <%=raw sanitize_field(@series, :summary) %>
    + <% end %> + <% unless @series.series_notes.blank? %> +
    <%= ts("Notes:") %>
    +
    <%=raw sanitize_field(@series, :series_notes) %>
    + <% end %> + +
    <%= ts("Stats:") %>
    +
    +
    +
    <%= Work.human_attribute_name("word_count") %>:
    +
    <%= number_with_delimiter(@series.visible_word_count) %>
    +
    <%= Work.model_name.human(count: :many) %>:
    +
    <%= number_with_delimiter(@series.visible_work_count) %>
    +
    <%= ts("Complete:") %>
    +
    <%= @series.complete? ? ts("Yes") : ts("No") %>
    + <% if (bookmark_count = @series.bookmarks.is_public.count) > 0 %> +
    <%= Bookmark.model_name.human(count: :many) %>:
    +
    <%= link_to_bookmarkable_bookmarks(@series, number_with_delimiter(bookmark_count)) %>
    + <% end %> +
    +
    + +
    +
    +<% if logged_in? %> +
    + <% if @bookmark %> + <%= render "bookmarks/bookmark_form", bookmarkable: @series, bookmark: @bookmark, button_name: ts("Update"), action: :update, in_page: true %> + <% else %> + <%= render "bookmarks/bookmark_form", bookmarkable: @series, bookmark: Bookmark.new, button_name: ts("Create"), action: :create, in_page: true %> + <% end %> +
    +<% end %> + +

    <%= ts("Listing Series") %>

    +<% unless @works.blank? %> + + <%= will_paginate @works %> +<% end %> +
      + <%= render partial: "works/work_blurb", collection: @works, as: :work %> +
    + +<% 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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.") %>

    + + <%# HTML share code %> +

    + <% id_suffix = shareable.is_a?(Bookmark) ? "_#{shareable.id}" : "" %> + <% embed_partial = shareable.is_a?(Bookmark) ? "share/embed_link_bookmark" : "share/embed_link_work" %> + +

    + +
      + + +
    • + <% work = shareable.is_a?(Bookmark) ? shareable.bookmarkable : shareable %> + <% tumblr_url = work_url(work) %> + <% tumblr_title = get_tumblr_embed_link_title(work) %> + <% tumblr_caption = shareable.is_a?(Bookmark) ? render("share/embed_link_bookmark", item: shareable) : render("share/embed_meta", work: shareable) %> + <% tumblr_address = "http://tumblr.com/widgets/share/tool?canonicalUrl=#{u(tumblr_url)}&title=#{u(tumblr_title)}&caption=#{u(tumblr_caption)}" %> + <%= sharing_button("tumblr", tumblr_address, ts("Tumblr"), target: "_blank") %> +
    • +
    +
    diff --git a/app/views/shared/_search_nav.html.erb b/app/views/shared/_search_nav.html.erb new file mode 100644 index 0000000..bccc12b --- /dev/null +++ b/app/views/shared/_search_nav.html.erb @@ -0,0 +1,6 @@ + \ 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 @@ +

    * <%= ts('Required information') %>

    +
    + <%= ts('About') %> +

    <%= ts('About') %>

    +
    +
    <%= label_tag 'skin_type', ts('Type') + "*" %> <%= link_to_help 'skins-basics' %>
    +
    + <% if @skin.id %> + <% # don't let user change the type of an already-created skin %> + <%= (@skin.type && @skin.type == 'WorkSkin') ? ts('Work Skin') : ts('Site Skin') %> + <% else %> + <%= select_tag 'skin_type', options_for_select(Skin::TYPE_OPTIONS, @skin.type || params[:skin_type] ), + :onchange => 'if ($j(this).val() == "WorkSkin") {$j("#advanced_skin_fieldset").hide();} else {$j("#advanced_skin_fieldset").show();}' %> + <% end %> +
    + +
    <%= f.label :title, ts('Title') + "*" %>
    +
    <%= f.text_field :title %>
    + +
    <%= f.label :description, ts('Description') %>
    +
    <%= f.text_field :description %>
    + +
    <%= f.label :icon, ts('Upload a preview (png, jpeg or gif)') %>
    +
    <%= f.file_field :icon %>
    + +
    <%= f.label :public, ts('Apply to make public') %> <%= link_to_help 'skins-approval' %>
    +
    <%= f.check_box :public %>
    +
    +
    + +
    + <%= ts('CSS') %> +

    <%= f.label :css, ts('CSS') %> <%= link_to_help 'skins-creating' %>

    +

    + <%= f.text_area :css, :cols => 70, :class => 'large observe_textlength' %> + <%= live_validation_for_field("skin_css", maximum_length: ArchiveConfig.CONTENT_MAX_DISPLAYED) %> + <%= generate_countdown_html("skin_css", ArchiveConfig.CONTENT_MAX_DISPLAYED) %> +

    +
    + +
    + <%= ts('Advanced') %> + + + + + +
    +
    + <%= ts('Conditions') %> <%= link_to_help 'skins-conditions' %> +

    <%= ts('Conditions') %>

    +
    +
    <%= f.label :role, ts('What it does: ')%>
    +
    + <%= f.select :role, options_for_select(Skin::ROLE_NAMES.invert, @skin.role || Skin::DEFAULT_ROLE), {}, + :onchange => 'if ($j(this).val() == "override") {$j(".archive_parents").show();} else {$j(".archive_parents").hide();}' %> +
    + +
    <%= f.label :ie_condition, ts('IE Only: ')%>
    +
    <%= f.select :ie_condition, options_for_select(Skin::IE_CONDITIONS, @skin.ie_condition), :include_blank => true %>
    + +
    <%= f.label :unusable, ts('Parent Only: ')%>
    +
    <%= f.check_box :unusable %>
    + +
    <%= f.label :media, ts('Media: ')%>
    +
    +
    <%= ts('Choose @media') %>
    + <%= checkbox_section(f, :media, Skin::MEDIA, :checked_method => @skin.media || Skin::DEFAULT_MEDIA, :value_method => "to_s", :name_method => "to_s", :include_blank => false) %> +
    +
    +
    + +
    + <%= ts('Parent Skins') %> <%= link_to_help 'skins-parents' %> +

    <%= ts('Parent Skins') %>

    +
      + <% f.object.skin_parents.each_with_index do |parent, index| %> + <%= f.fields_for :skin_parents, parent do |parent_form| %> + <%= render 'skin_parent_fields', :form => parent_form, :index => index %> + <% end %> + <% end %> + + <% if f.object.skin_parents.count == 0 %><% end %> +
    • <%= link_to_add_section(ts('Add parent skin'), f, :skin_parents, "skin_parent_fields") %>
    • + +
    • +

      + +

      +
    • +
    +
    +
    +
    + +<%= submit_fieldset(f) %> diff --git a/app/views/skins/_header.html.erb b/app/views/skins/_header.html.erb new file mode 100644 index 0000000..5e90a2e --- /dev/null +++ b/app/views/skins/_header.html.erb @@ -0,0 +1,42 @@ +
    +

    + <%= ts("%{title} skin by %{creator}", title: @skin.title, creator: skin_author_link(@skin)).html_safe %> +

    + +

    <%= skin_preview_display(@skin) %>

    + + + +
    + <%= @skin.description.blank? ? ts('(No Description Provided)') : raw(sanitize_field(@skin, :description)) %> +
    + +
    +
    <%= ts('Role:') %>
    +
    <%= @skin.role %>
    +
    <%= ts('Media:') %>
    +
    <%= @skin.get_media %>
    +
    <%= ts('Condition:') %>
    +
    + <% unless @skin.ie_condition.present? || @skin.unusable? %>Normal<% end %> + <% if @skin.ie_condition.present? %> + <%= @skin.ie_condition %> + <% end %> + <% if @skin.unusable? %>Parent only<% end %> +
    +
    +
    diff --git a/app/views/skins/_revert_skin_form.html.erb b/app/views/skins/_revert_skin_form.html.erb new file mode 100644 index 0000000..774d448 --- /dev/null +++ b/app/views/skins/_revert_skin_form.html.erb @@ -0,0 +1,10 @@ +<% # Revert from session skin (for admins, users, maybe one day guests again) %> +<% if session[:site_skin] %> + <%= link_to ts("Revert to Default Skin"), unset_skins_path %> +<% # Revert skin a logged in user set in their preferences %> +<% elsif logged_in? && current_user.preference.skin_id != AdminSetting.default_skin_id %> + <%= form_for(current_user.preference, url: user_preference_path(current_user, current_user.preference), method: :patch) do |f| %> + <%= f.hidden_field :skin_id, value: AdminSetting.default_skin_id %> + <%= f.submit ts("Revert to Default Skin") %> + <% end %> +<% end %> diff --git a/app/views/skins/_skin_actions.html.erb b/app/views/skins/_skin_actions.html.erb new file mode 100644 index 0000000..eb2573b --- /dev/null +++ b/app/views/skins/_skin_actions.html.erb @@ -0,0 +1,25 @@ +<% # expects skin %> +<% # shared between skin blurbs and the bottom of skin pages %> +<% if (logged_in? && !skin.is_a?(WorkSkin)) || skin.editable? %> +
      + <% if logged_in? && !skin.is_a?(WorkSkin) && !skin.unusable? %> +
    • <%= render "update_skin_form", skin: skin %>
    • +
    • <%= span_if_current ts("Preview"), preview_skin_path(skin) %>
    • + <% end %> + + <% if (logged_in? || logged_in_as_admin?) && skin.cached? %> +
    • <%= link_to ts("Set For Session"), set_skin_path(skin) %>
    • + <% end %> + + <% if skin.editable? && (policy(skin).update? || !logged_in_as_admin?) %> +
    • <%= span_if_current ts('Edit'), edit_skin_path(skin) %>
    • + <% unless logged_in_as_admin? %> +
    • + <%= link_to ts("Delete"), + confirm_delete_skin_path(skin), + data: { confirm: ts("Are you sure you want to delete this skin?") } %> +
    • + <% end %> + <% end %> +
    +<% end %> diff --git a/app/views/skins/_skin_module.html.erb b/app/views/skins/_skin_module.html.erb new file mode 100644 index 0000000..f0a61b5 --- /dev/null +++ b/app/views/skins/_skin_module.html.erb @@ -0,0 +1,28 @@ +<% # expects skin %> +
    +

    + <%= link_to skin.title, skin %> + <%= ts("by %{byline}", byline: skin.byline) %> +

    + <% if @user %> + <% if skin.official? %> +

    (<%= ts('Approved') %>)

    + <% elsif skin.rejected? %> +

    (<%= ts('Declined:')%> <%= link_to_help 'skins-approval' %> <%= skin.admin_note %>)

    + <% elsif skin.public? %> +

    (<%= ts('Not yet reviewed') %>) <%= link_to_help 'skins-approval' %>

    + <% end %> + <% end %> + +
    <%= skin_preview_display(skin) %>
    +

    <%= set_format_for_date(skin.created_at) %>

    +
    + + +
    <%= ts('Description') %>
    +
    + <%=raw skin.description.blank? ? ts('(No Description Provided)') : strip_images(sanitize_field(skin, :description)) %> +
    + +<%= render "skin_actions", skin: skin %> + diff --git a/app/views/skins/_skin_navigation.html.erb b/app/views/skins/_skin_navigation.html.erb new file mode 100644 index 0000000..fdeb052 --- /dev/null +++ b/app/views/skins/_skin_navigation.html.erb @@ -0,0 +1,36 @@ +<% work_skin = params[:skin_type] && params[:skin_type] == 'WorkSkin' %> + + +

    <%= ts('Navigation') %>

    + + diff --git a/app/views/skins/_skin_parent_fields.html.erb b/app/views/skins/_skin_parent_fields.html.erb new file mode 100755 index 0000000..ed36d1e --- /dev/null +++ b/app/views/skins/_skin_parent_fields.html.erb @@ -0,0 +1,11 @@ +<% index ||= 1 %> +
  • +
    +

    + <%= link_to_remove_section ts('x'), form %> + <%= ts('Parent #') %><%= form.text_field :position, :class => 'number', :value => (form.object.position || index), :title => ts('position of parent as integer') %> +

    + <%= form.text_field :parent_skin_title, autocomplete_options('site_skins', data: { autocomplete_token_limit: 1 }, title: ts('title of parent skin')) %> +
    +
  • + diff --git a/app/views/skins/_skin_style_block.html.erb b/app/views/skins/_skin_style_block.html.erb new file mode 100644 index 0000000..4230b82 --- /dev/null +++ b/app/views/skins/_skin_style_block.html.erb @@ -0,0 +1,4 @@ +<% # expects local "skin" and is cached one level up %> + diff --git a/app/views/skins/_skin_type_navigation.html.erb b/app/views/skins/_skin_type_navigation.html.erb new file mode 100644 index 0000000..b108abb --- /dev/null +++ b/app/views/skins/_skin_type_navigation.html.erb @@ -0,0 +1,14 @@ + +

    <%= ts('Skin Type Navigation') %>

    + + + diff --git a/app/views/skins/_update_skin_form.html.erb b/app/views/skins/_update_skin_form.html.erb new file mode 100644 index 0000000..a255329 --- /dev/null +++ b/app/views/skins/_update_skin_form.html.erb @@ -0,0 +1,10 @@ +<% # expects skin %> +<%= form_for(current_user.preference, url: user_preference_path(current_user, current_user.preference), method: "put") do |f| %> + <% if skin.id == current_user.preference.skin_id %> + <%= f.hidden_field :skin_id, value: AdminSetting.default_skin_id %> + <%= f.submit ts("Stop Using") %> + <% else %> + <%= f.hidden_field :skin_id, value: skin.id %> + <%= f.submit ts("Use") %> + <% end %> +<% end %> diff --git a/app/views/skins/_wizard_form.html.erb b/app/views/skins/_wizard_form.html.erb new file mode 100644 index 0000000..f2060b9 --- /dev/null +++ b/app/views/skins/_wizard_form.html.erb @@ -0,0 +1,132 @@ +

    * <%= ts('Required information') %>

    +
    + <%= ts('About') %> +
    +
    <%= f.label :title, ts('Title') + "*" %>
    +
    <%= f.text_field :title %>
    + +
    <%= f.label :description, ts('Description') %>
    +
    <%= f.text_field :description %>
    +
    +
    + +
    + <%= ts("Fonts and Whitespace") %> +
    +
    + <%= f.label :font, ts('Font') %> + <%= link_to_help('skins-wizard-font') %> +
    +
    + <%= f.text_field :font, "aria-describedby" => "font-field-notes" %> +

    + <%= ts("Comma-separated list of font names.") %> +

    +
    + +
    + <%= f.label :base_em, ts('Percent of browser font size') %> + <%= link_to_help('skins-wizard-font-size') %> +
    +
    + <%= f.text_field :base_em, "aria-describedby" => "base-em-field-notes" %> +

    + <%= ts("Numbers only, treated as a percentage of the browser's default font size. Default: 100").html_safe %> +

    +
    + +
    + <%= f.label :margin, ts('Work margin width') %> +
    +
    + <%= f.text_field :margin, "aria-describedby" => "margin-field-notes" %> +

    + <%= ts("Numbers only, treated as a percentage of the page width.") %> +

    +
    + +
    + <%= f.label :paragraph_margin, ts('Vertical gap between paragraphs') %> + <%= link_to_help('skins-wizard-vertical-gap') %> +
    +
    + <%= f.text_field :paragraph_margin %> +

    + <%= ts("Numbers only, treated as a multipler of the paragraph font size. Default: 1.286").html_safe %> +

    +
    +
    +
    + +
    + <%= ts("Colors") %> +

    + <%= ts('You may wish to refer to this handy list of colors.').html_safe %> +

    +
    +
    + <%= f.label :background_color, ts('Background color') %> +
    +
    + <%= f.text_field :background_color, "aria-describedby" => "background-color-field-notes" %> +

    + <%= ts("Name or hex code. Default: #fff".html_safe) %> +

    +
    + +
    + <%= f.label :foreground_color, ts('Text color') %> +
    +
    + <%= f.text_field :foreground_color, "aria-describedby" => "foreground-color-field-notes" %> +

    + <%= ts("Name or hex code. Default: #2a2a2a".html_safe) %> +

    +
    + +
    + <%= f.label :headercolor, ts('Header color') %> +
    +
    + <%= f.text_field :headercolor, "aria-describedby" => "header-color-field-notes" %> +

    + <%= ts("Name or hex code. Default: #900".html_safe) %> +

    +
    + +
    + <%= f.label :accent_color, ts('Accent color') %> + <%= link_to_help('skins-wizard-accent-color') %> +
    +
    + <%= f.text_field :accent_color, "aria-describedby" => "accent-color-field-notes" %> +

    + <%= ts("Name or hex code. Default: #ddd".html_safe) %> +

    +
    +
    + <%= hidden_field_tag 'wizard', true %> +
    +
    + + <%= ts("Parent Skins") %> + <%= link_to_help "skins-parents" %> + +

    + <%= ts("Parent Skins") %> +

    +
      + <% f.object.skin_parents.each_with_index do |parent, index| %> + <%= f.fields_for :skin_parents, parent do |parent_form| %> + <%= render "skin_parent_fields", form: parent_form, index: index %> + <% end %> + <% end %> + <% if f.object.skin_parents.count == 0 %> + + <% end %> +
    • + <%= link_to_add_section(ts("Add parent skin"), f, :skin_parents, "skin_parent_fields") %> +
    • +
    +
    +<%= submit_fieldset(f) %> diff --git a/app/views/skins/confirm_delete.html.erb b/app/views/skins/confirm_delete.html.erb new file mode 100644 index 0000000..b682638 --- /dev/null +++ b/app/views/skins/confirm_delete.html.erb @@ -0,0 +1,13 @@ + +

    <%= ts("Delete Skin") %>

    + + +<%= form_for(@skin, html: { method: :delete, class: "simple destroy" }) do |f| %> +

    + <%= t(".confirm_html", skin_title: @skin.title) %> +

    +

    + <%= f.submit ts("Yes, Delete Skin") %> +

    +<% end %> + diff --git a/app/views/skins/edit.html.erb b/app/views/skins/edit.html.erb new file mode 100644 index 0000000..9692ee1 --- /dev/null +++ b/app/views/skins/edit.html.erb @@ -0,0 +1,17 @@ +

    <%= ts('Edit Skin') %>

    + +<%= error_messages_for :skin %> + + +<%= render 'skin_navigation' %> + + +

    <%= ts('Edit Skin Form') %>

    +<%= 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 100644 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' %> +

    + + + +<%= render 'skin_type_navigation' %> + + + +

    <%= ts('List of Skins') %>

    +<% if @skins.empty? %> +

    + <%= 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 100644 index 0000000..ffc0382 --- /dev/null +++ b/app/views/skins/new.html.erb @@ -0,0 +1,21 @@ + +

    <%= ts('Create New Skin') %>

    + +<%= error_messages_for :skin %> + + + +<%= render 'skin_navigation' %> + + + +

    + <%= 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 100644 index 0000000..da04f11 --- /dev/null +++ b/app/views/skins/new_wizard.html.erb @@ -0,0 +1,22 @@ + +

    <%= ts('Site Skin Wizard') %>

    + +<%= error_messages_for :skin %> + + + +<%= render "skin_navigation" %> + + + +

    + <%= 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 100644 index 0000000..8480ef5 --- /dev/null +++ b/app/views/skins/show.html.erb @@ -0,0 +1,94 @@ + +
    + + <%= render 'header' %> + + + <% if @skin.wizard_settings? %> +
    +

    <%= ts('Wizard Settings') %>

    +

    <%= ts("Can be overridden by custom CSS.").html_safe %>

    + +
    +
    + <% unless @skin.font.blank? %> +
    <%= ts('Font:') %>
    +
    + <%= @skin.font %> +
    + <% end %> + + <% if @skin.base_em %> +
    <%= ts('Percent of browser font size:') %>
    +
    + <%= @skin.base_em %>% +
    + <% end %> + + <% if @skin.margin %> +
    <%= ts('Work margin width:') %>
    +
    + <%= @skin.margin %>% +
    + <% end %> + + <% if @skin.paragraph_margin %> +
    <%= ts('Vertical gap between paragraphs:') %>
    +
    + <%= @skin.paragraph_margin %>em +
    + <% end %> + + <% unless @skin.background_color.blank? %> +
    <%= ts('Background color:') %>
    +
    + <%= @skin.background_color %> +
    + <% end %> + + <% unless @skin.foreground_color.blank? %> +
    <%= ts('Text color:') %>
    +
    + <%= @skin.foreground_color %> +
    + <% end %> + + <% unless @skin.headercolor.blank? %> +
    <%= ts('Header color:') %>
    +
    + <%= @skin.headercolor %> +
    + <% end %> + + <% unless @skin.accent_color.blank? %> +
    <%= ts('Accent color:') %>
    +
    + <%= @skin.accent_color %> +
    + <% end %> +
    +
    +
    + <% end %> + + <% unless @skin.css.blank? && @skin.filename.blank? %> +
    +

    CSS

    +
    <%= @skin.get_css %>
    +
    + <% end %> + + <% unless @skin.parent_skins.empty? %> +
    +

    <%= ts('Parent Skins') %>

    +
      + <% @skin.parent_skins.each do |parent| %> +
    • <%= link_to parent.title, skin_path(parent) %>
    • + <% end %> +
    +
    + <% end %> + + <%= render "skin_actions", skin: @skin %> + +
    diff --git a/app/views/stats/_navigation.html.erb b/app/views/stats/_navigation.html.erb new file mode 100644 index 0000000..05195eb --- /dev/null +++ b/app/views/stats/_navigation.html.erb @@ -0,0 +1,4 @@ + diff --git a/app/views/stats/index.html.erb b/app/views/stats/index.html.erb new file mode 100644 index 0000000..05b1e3c --- /dev/null +++ b/app/views/stats/index.html.erb @@ -0,0 +1,105 @@ + +

    <%= ts("Statistics") %>

    + + + +

    <%= ts('Navigation and Sorting') %>

    +<%= render "stats/navigation" %> + + + +
      + <% @years.each do |year| %> +
    1. + <% if @current_year == year %> + <%= year %> + <% else %> + <%= link_to year, current_path_with(year: year) %> + <% end %> +
    2. + <% end %> +
    + +

    <%= ts("Totals") %>

    +
    + +
    + + + +
    +<%= render_chart(@chart, 'stat_chart') %> + +
    +

    <%= ts('View Sorting and Actions') %>

    + + + +
    + +

    <%= ts('Listing Statistics') %>

    +
      + <% @works.keys.sort.each do |fandom| %> +
    • +
      + <%= fandom %> +
      +
        + <% @works[fandom].each do |work| %> +
      • +
        +
        + <%= link_to work.title, work_path(:id => work.id) %> + <% if params[:flat_view] %>(<%= work.fandom_string %>)<% end %> + (<%= ts("%{wordcount} words", wordcount: number_with_delimiter(work.word_count)) %>) +
        +
        +
        + <% if (work_subscriber_count = Subscription.where(:subscribable_id => work.id, :subscribable_type => 'Work').count) > 0 %> +
        <%= ts("Subscriptions:") %>
        +
        <%= number_with_delimiter(work_subscriber_count) %>
        + <% end %> +
        <%= ts("Hits:") %>
        +
        <%= number_with_delimiter(work.hits) %>
        + <% # dt ts("Downloads:") /dt dd work.downloads /dd %> +
        <%= ts("Kudos:") %>
        +
        <%= number_with_delimiter(work.kudos.count) %>
        +
        <%= ts("Comment Threads:") %>
        +
        <%= number_with_delimiter(work.comment_thread_count) %>
        +
        <%= ts("Bookmarks:") %>
        +
        <%= number_with_delimiter(work.bookmarks.count) %>
        +
        +
        +
        +
      • + <% end %> +
      +
    • + <% end %> +
    + diff --git a/app/views/stats/no_stats.html.erb b/app/views/stats/no_stats.html.erb new file mode 100644 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..9007a3b --- /dev/null +++ b/app/views/statuses/edit.html.erb @@ -0,0 +1,43 @@ +

    Edit Status

    + +<% use_tinymce %> + +<%= form_with(model: [current_user, @status]) do |f| %> +
    + <%= f.label :icon %>
    + <%= f.file_field :icon %> +
    + +
    + <%= f.label :text, "Status Text" %>
    + <%= f.text_area :text, class: "mce-editor observe_textlength", id: "status" %> + + <%= live_validation_for_field( + 'content', + maximum_length: ArchiveConfig.STATUS_MAX, + minimum_length: ArchiveConfig.CONTENT_MIN, + tooLongMessage: ts("Too long lmao"), + tooShortMessage: ts("Too short lmao"), + failureMessage: ts("You need to post some content here.") + ) + %> + + <%= generate_countdown_html("status", 3000) %> +
    + +
    + <%= f.label :mood %>
    + <%= f.text_field :mood %> +
    + +
    + <%= f.label :music %>
    + <%= f.text_field :music %> +
    + +
    + <%= f.submit "Save Status" %> +
    +<% end %> + + diff --git a/app/views/statuses/index.html.erb b/app/views/statuses/index.html.erb new file mode 100644 index 0000000..a6bb9d0 --- /dev/null +++ b/app/views/statuses/index.html.erb @@ -0,0 +1,34 @@ +

    <%= @user&.login || @user&.id || "Statuses" %>

    +All Statuses +
    + +
    + <% if @statuses.present? %> + <% @statuses.each do |status| %> + <% if status.icon.attached? %> + <%= image_tag status.icon %> + <% end %> + + <%= time_ago_in_words(status.created_at) %> | + <% if current_user %> + <%= link_to 'Edit', edit_user_status_path(status.user, status) %> | + <%= link_to t("Delete"), user_status_path(status.user, status), + data: { confirm: t("Are you sure? All information in this status will be lost.") }, + method: :delete %> + <% end %> + +

    <%= raw sanitize_field(status, :text) %>

    + + <% if status.mood.present? %> +

    Mood: <%= status.mood %>

    + <% end %> + + <% if status.music.present? %> +

    Music: <%= status.music %>

    + <% end %> + +
    + <% end %> + <% end %> +
    + diff --git a/app/views/statuses/new.html.erb b/app/views/statuses/new.html.erb new file mode 100644 index 0000000..fbfcd76 --- /dev/null +++ b/app/views/statuses/new.html.erb @@ -0,0 +1,41 @@ +

    New Status

    + +<% use_tinymce %> +<%= form_with(model: [current_user, @status]) do |f| %> + + <%= f.label :icon %>
    + <%= f.file_field :icon %> + + +
    + <%= f.label :text, "Status Text" %>
    + <%= f.text_area :text, class: "mce-editor observe_textlength", id: "status" %> + + <%= live_validation_for_field( + 'status', + maximum_length: ArchiveConfig.STATUS_MAX, + minimum_length: ArchiveConfig.CONTENT_MIN, + tooLongMessage: ts("Too long lmao"), + tooShortMessage: ts("Too short lmao"), + failureMessage: ts("You need to post some content here.") + ) + %> + + <%= generate_countdown_html("status", ArchiveConfig.STATUS_MAX) %> +
    + +
    + <%= f.label :mood %>
    + <%= f.text_field :mood %> +
    + +
    + <%= f.label :music %>
    + <%= f.text_field :music %> +
    + +
    + <%= f.submit "Save Status" %> +
    +<% end %> + diff --git a/app/views/statuses/show.html.erb b/app/views/statuses/show.html.erb new file mode 100644 index 0000000..ccc4a9b --- /dev/null +++ b/app/views/statuses/show.html.erb @@ -0,0 +1,23 @@ +
    +

    Status

    + + <% if @status.icon.attached? %> + <%= image_tag @status.icon %> + <% end %> + + <%= time_ago_in_words(@status.created_at) %> | + <% if current_user == @status.user %> + <%= link_to 'Edit', edit_user_status_path(@status.user, @status) %> | + <%= link_to t("Delete"), user_status_path(@status.user, @status), + data: { confirm: t("Are you sure? All information in this status will be lost.") }, + method: :delete %> + <% end %> +

    <%= raw sanitize_field(@status, :text) %>

    + <% if @status.mood.present? %> +

    Mood: <%= @status.mood %>

    + <% end %> + + <% if @status.music.present? %> +

    Music: <%= @status.music %>

    +
    <% end %> +
    diff --git a/app/views/statuses/timeline.html.erb b/app/views/statuses/timeline.html.erb new file mode 100644 index 0000000..980081b --- /dev/null +++ b/app/views/statuses/timeline.html.erb @@ -0,0 +1,29 @@ +
    +

    Recent Statuses

    +<% @statuses.each do |status| %>

    + <%= link_to "##{status.user.login}", user_path(status.user) %> said... +
    + <% if status.icon.attached? %> + <%= image_tag status.icon %> + <% end %> + + <%= time_ago_in_words(status.created_at) %> | + <% if current_user == status.user %> + <%= link_to 'Edit', edit_user_status_path(status.user, status) %> | + <%= link_to t("Delete"), user_status_path(status.user, status), + data: { confirm: t("Are you sure? All information in this status will be lost.") }, + method: :delete %> + <% end %> +

    <%= raw sanitize_field(status, :text) %>

    + <% if status.mood.present? %> +

    Mood: <%= status.mood %>

    + <% end %> + + <% if status.music.present? %> +

    Music: <%= status.music %>

    +
    <% end %> +
    + <% end %> + +
    + diff --git a/app/views/subscriptions/_form.html.erb b/app/views/subscriptions/_form.html.erb new file mode 100644 index 0000000..4b18ea3 --- /dev/null +++ b/app/views/subscriptions/_form.html.erb @@ -0,0 +1,14 @@ +<% # expects subscription and current_user %> +<%= form_for([current_user, subscription], + method: subscription.new_record? ? 'post' : 'delete', + html: { + class: 'ajax-create-destroy', + data: { + create_value: ts('Subscribe'), + destroy_value: ts('Unsubscribe') + } + }) do |f| %> + <%= f.hidden_field :subscribable_id %> + <%= f.hidden_field :subscribable_type %> + <%= f.submit subscription.new_record? ? ts('Subscribe') : ts('Unsubscribe') %> +<% end %> diff --git a/app/views/subscriptions/confirm_delete_all.erb b/app/views/subscriptions/confirm_delete_all.erb new file mode 100644 index 0000000..2e3e90b --- /dev/null +++ b/app/views/subscriptions/confirm_delete_all.erb @@ -0,0 +1,23 @@ + +

    + <% if @subscribable_type %> + <%= t(".page_heading.#{@subscribable_type}") %> + <% else %> + <%= t(".page_heading.all") %> + <% end %> +

    + + +<%= form_with(model: @user, url: delete_all_user_subscriptions_path(@user, type: @subscribable_type), method: :post, class: "simple destroy") do |f| %> +

    + <%= t(".caution_html") %> +

    +

    + <% if @subscribable_type %> + <%= f.submit t(".submit.#{@subscribable_type}") %> + <% else %> + <%= f.submit t(".submit.all") %> + <% end %> +

    +<% end %> + diff --git a/app/views/subscriptions/index.html.erb b/app/views/subscriptions/index.html.erb new file mode 100644 index 0000000..758dd11 --- /dev/null +++ b/app/views/subscriptions/index.html.erb @@ -0,0 +1,76 @@ + +

    + <% if @subscribable_type %> + <%= t(".page_heading.#{@subscribable_type}") %> + <% else %> + <%= t(".page_heading.all") %> + <% end %> +

    + + + +

    <%= t("a11y.navigation") %>

    + + + + +<%= will_paginate @subscriptions %> + +

    <%= t(".heading.landmark.list") %>

    +
    + <% @subscriptions.each do |subscription| %> +
    + <% if subscription.subscribable %> + <%= link_to(subscription.name, subscription.subscribable) %> + <% case subscription.subscribable_type %> + <% when "Work" %> + <%= t(".work") if @subscribable_type.blank? %> + <%= t(".byline_html", creators: byline(subscription.subscribable)) %> + <% when "Series" %> + <%= t(".series") if @subscribable_type.blank? %> + <%= t(".byline_html", creators: byline(subscription.subscribable)) %> + <% end %> + <% else %> + <%= subscription.name %> + <% end %> +
    +
    + <%= form_for [current_user, subscription], :html => {:method => :delete} do |f| %> + <%= f.submit t(".button_html", name: subscription.name) %> + <% end %> +
    + <% end %> +
    + +<%= will_paginate @subscriptions %> + diff --git a/app/views/tag_set_associations/index.html.erb b/app/views/tag_set_associations/index.html.erb new file mode 100644 index 0000000..cd87d96 --- /dev/null +++ b/app/views/tag_set_associations/index.html.erb @@ -0,0 +1,48 @@ +
    + +

    <%= ts("Review Associations for %{title}", :title => @tag_set.title) %>

    + + + +<%= render "owned_tag_sets/navigation" %> + + + +

    <%= ts("Notes") %>

    +
      +
    • Nominated associations will appear only for tags that have already been added to your set.
    • +
    • If tags have been nominated in fandoms that are not in the archive and not in your set, you must first add the fandom to your set to associate them.
    • +
    + +<%= form_tag update_multiple_tag_set_associations_path(@tag_set), :method => :put, :class => "tagset review" do %> + <%= error_messages_formatted @errors %> + + <% if @tags_to_associate.empty? %> +

    <%= ts("No nominated associations to review!") %>

    + <% else %> +
    + Approve Nominated Associations +

    <%= ts("Approve Nominated Associations") %>

    + <%= check_all_none %> +
      + <% @tags_to_associate.each do |tag| %> + <% # this is necessary or else rails thinks the [] characters represent a sub-model! %> + <% tagname = tag.name.gsub('[', '#LBRACKET').gsub(']', '#RBRACKET') %> + <% parent_tagname = tag.parent_tagname.gsub('[', '#LBRACKET').gsub(']', '#RBRACKET') %> +
    • + <% fieldname = "create_association_#{tag.id}_#{parent_tagname}" %> + +
    • + <% end %> +
    +
    + <% 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 100644 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) %>

    + + + + + + + +
      +
    • <%= 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.') %>
    • +
    + +

    <%= ts("Nominate Tags Form") %>

    + +<%= error_messages_for :tag_set_nomination %> + +<%= form_for(@tag_set_nomination, :url => (@tag_set_nomination.new_record? ? tag_set_nominations_path(@tag_set) : tag_set_nomination_path(@tag_set, @tag_set_nomination)), :html => {:method => (@tag_set_nomination.new_record? ? :post : :put), :id => "tag_set_nomination_form", :class => "tagset"}) do |f| %> +
    + Basic Information +
    +
    <%= ts("Nominating For: ") %>
    +
    <%= link_to @tag_set_nomination.owned_tag_set.title, tag_set_path(@tag_set_nomination.owned_tag_set) %>
    + +
    <%= f.label :pseud_id, ts("Pseud: ") %>
    +
    <%= f.select :pseud_id, current_user.pseuds.collect {|p| [p.name, p.id] } %>
    +
    +
    +
    +

    <%= ts("Tag Nominations") %>

    +

    <%= nomination_notes(@limit) %>

    + <% if @limit[:relationship] > 0 && @limit[:fandom] > 1 %> +

    <%= ts("If crossover relationships are allowed, you can enter them under either fandom.") %>

    + <% end %> + + <% if @limit[:fandom] > 0 %> + + <% # char and relationship nominations grouped with fandom %> + <%= render 'tag_nominations_by_fandom', :f => f %> + + <% # freeforms on their own %> + <% if @limit[:freeform] > 0 %> + <%= render 'tag_nominations', :f => f, :tag_type => "freeform" %> + <% end %> + + <% else %> + + <% # nominations all on same level in fieldset/dls %> + <% %w(character relationship freeform).each do |tag_type| %> + <% if @limit[tag_type] > 0 %> + <%= render 'tag_nominations', :f => f, :tag_type => tag_type %> + <% end %> + <% end %> + + <% end %> +
    + +
    + <%= ts('Submit') %> +

    + <%= f.submit ts('Submit') %> +

    +
    +<% end %> + diff --git a/app/views/tag_set_nominations/_review.html.erb b/app/views/tag_set_nominations/_review.html.erb new file mode 100644 index 0000000..caa4729 --- /dev/null +++ b/app/views/tag_set_nominations/_review.html.erb @@ -0,0 +1,78 @@ +
    + + +

    <%= ts("Review Nominations for %{title}", :title => @tag_set.title) %>

    + + + + <%= render "owned_tag_sets/navigation" %> + + + +
      +
    • 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.
    • +
    + + <%= form_for :tag_set_nominations, :url => update_multiple_tag_set_nominations_path(@tag_set), :html => { :method => :put, :class => "tagset review" } do |form| %> + + <%= error_messages_formatted @errors %> + + <% @tagnames_seen = {} %> + + <% %w(cast fandom character relationship freeform).each do |tag_type| %> + <% if @nominations[tag_type] && @nominations[tag_type].count > 0 %> +
    + <% heading = tag_type == "cast" ? ts("Already Approved Fandoms") : ts("#{tag_type_label_name(tag_type).pluralize} (%{count} left to review)", :count => @tag_set.send("#{tag_type}_nominations").unreviewed.count) %> + <%= heading %> +

    <%= heading %>

    + <%= check_all_none("Reject All", "Reject None", "reject") %> + <%= check_all_none("Approve All", "Approve None", "approve") %> +
      + <% if tag_type == "fandom" %> + <%= render "review_fandoms", :fandoms => @nominations[:fandom] %> + <% elsif tag_type == "cast" %> + <%= render "review_cast", :cast => @nominations[:cast] %> + <% else %> + <% @nominations[tag_type].each do |nom| %> +
    1. <%= render "review_individual_nom", :nom => nom %>
    2. + <% end %> + <% end %> +
    +
    + <% end %> + <% end %> + +
    +

    <%= submit_tag ts("Submit") %>

    +
    + + <% end %> + + +
    + +<%= content_for :footer_js do %> + <%= javascript_include_tag '/javascripts/jquery.qtip.min.js' %> + <%= javascript_tag do %> + $j(document).ready(function() { + setupTooltips(); + }); + function setupTooltips() { + $j('[data-tooltip]').each(function(){ + $j(this).qtip({ + content: $j(this).attr('data-tooltip'), + position: {corner: {target: 'topMiddle'}} + }); + }); + } + <% end %> +<% end %> diff --git a/app/views/tag_set_nominations/_review_cast.html.erb b/app/views/tag_set_nominations/_review_cast.html.erb new file mode 100644 index 0000000..a77e9ac --- /dev/null +++ b/app/views/tag_set_nominations/_review_cast.html.erb @@ -0,0 +1,25 @@ +<% # this lists extra characters/rels in already-approved fandoms. expects "cast" as a list of nominated chars/rels sorted in order of their parent tagname %> +<% current_fandom = "" %> +<% cast.each do |cast_nom| %> + <% unless @tagnames_seen[cast_nom.tagname] %> + <% if cast_nom.parent_tagname != current_fandom %> + <% unless current_fandom.blank? %> + <% # close the prior fandom listbox %> + + + <% end %> + <% # start a new fandom listbox %> + <% current_fandom = cast_nom.parent_tagname %> +
  • +

    <%= current_fandom %>

    +
      + <% end %> +
    1. + <%= render "review_individual_nom", :nom => cast_nom %> +
    2. + <% end %> +<% end %> + +<% # close the last listbox %> +
    +
  • diff --git a/app/views/tag_set_nominations/_review_fandoms.html.erb b/app/views/tag_set_nominations/_review_fandoms.html.erb new file mode 100644 index 0000000..47eb645 --- /dev/null +++ b/app/views/tag_set_nominations/_review_fandoms.html.erb @@ -0,0 +1,20 @@ +<% # expects fandoms %> +<% fandoms.each do |fandom| %> + <% unless @tagnames_seen[fandom.tagname] %> + <% cast = TagNomination.for_tag_set(@tag_set).where(:parent_tagname => fandom.tagname).unreviewed.order(:type, :tagname) %> +
  • +

    + <%= fandom.tagname %>

    + <%= render "review_individual_nom", :nom => fandom %> + <% if cast.count > 0 %> +
      + <% cast.each do |cast_nom| %> +
    1. + <%= render "review_individual_nom", :nom => cast_nom %> +
    2. + <% end %> +
    + <% end %> +
  • + <% end %> +<% end %> diff --git a/app/views/tag_set_nominations/_review_individual_nom.html.erb b/app/views/tag_set_nominations/_review_individual_nom.html.erb new file mode 100644 index 0000000..0f5e74b --- /dev/null +++ b/app/views/tag_set_nominations/_review_individual_nom.html.erb @@ -0,0 +1,41 @@ +<% # expects nom %> +<% unless @tagnames_seen[nom.tagname] %> + <% @tagnames_seen[nom.tagname] = 1 %> + +
    + <% tag_type = nom.class.name.gsub(/Nomination/, '').downcase %> +
    + <%= nomination_tag_information(nom) %> +
    + <% if (nom_count = nom.times_nominated(@tag_set)) > 1 %> +
    (x<%= nom_count %>)
    + <% end %> + + <% # this is necessary or else rails thinks the [] characters represent a sub-model! %> + <% tagname = nom.tagname.gsub('[', '#LBRACKET').gsub(']', '#RBRACKET') %> + +
    + <%= label_tag "#{tag_type}_approve_#{tagname}", :class => "action" do %> + + + <%= check_box_tag "#{tag_type}_approve_#{tagname}", 1, params["#{tag_type}_approve_#{tagname}"] %> + <% end %> + <%= label_tag "#{tag_type}_reject_#{tagname}", :class => "action" do %> + x + <%= check_box_tag "#{tag_type}_reject_#{tagname}", 1, params["#{tag_type}_reject_#{tagname}"] %> + <% end %> +
    + +
    <%= text_field_tag "#{tag_type}_change_#{tagname}", nil, autocomplete_options("tag?type=#{tag_type}", data: { autocomplete_token_limit: 1 }, title: "change tag name") %>
    + + + <% if nom.synonym %> +
    + <%= label_tag "#{tag_type}_synonym_#{tagname}" do %> + (Choose <%= nom.synonym %> instead + <%= check_box_tag "#{tag_type}_synonym_#{tagname}", nom.synonym, params["#{tag_type}_synonym_#{tagname}"] %>) + <% end %> +
    + <% end %> +
    + +<% end %> diff --git a/app/views/tag_set_nominations/_review_thead.html.erb b/app/views/tag_set_nominations/_review_thead.html.erb new file mode 100644 index 0000000..e9850aa --- /dev/null +++ b/app/views/tag_set_nominations/_review_thead.html.erb @@ -0,0 +1,10 @@ + + + + <%= ts("Nomination") %> + <%= ts("Approve") %> + <%= ts("Reject") %> + <% if section == "existing" %><%= ts("Synonym") %><% end %> + <%= ts("Edit") %> + + diff --git a/app/views/tag_set_nominations/_show_by_tag_type.html.erb b/app/views/tag_set_nominations/_show_by_tag_type.html.erb new file mode 100644 index 0000000..4b374ec --- /dev/null +++ b/app/views/tag_set_nominations/_show_by_tag_type.html.erb @@ -0,0 +1,16 @@ +<% if @limit[tag_type] > 0 %> +
  • +

    + <%= ts("%{tag_type}", tag_type: tag_type_label_name(tag_type).pluralize) %> +

    +
      + <% if @tag_set_nomination.nominated_tags(tag_type).count == 0 %> +
    1. <%= ts("None nominated in this category.") %>
    2. + <% else %> + <% @tag_set_nomination.send("#{tag_type}_nominations").each do |tag| %> +
    3. <%= render "show_tag_status", :tag => tag %>
    4. + <% end %> + <% end %> +
    +
  • +<% end %> diff --git a/app/views/tag_set_nominations/_show_tag_status.html.erb b/app/views/tag_set_nominations/_show_tag_status.html.erb new file mode 100644 index 0000000..4263d39 --- /dev/null +++ b/app/views/tag_set_nominations/_show_tag_status.html.erb @@ -0,0 +1,2 @@ +<%= nomination_status(tag) %> +<%= nomination_tag_information(tag) %> diff --git a/app/views/tag_set_nominations/_tag_nominations.html.erb b/app/views/tag_set_nominations/_tag_nominations.html.erb new file mode 100644 index 0000000..904fd22 --- /dev/null +++ b/app/views/tag_set_nominations/_tag_nominations.html.erb @@ -0,0 +1,31 @@ +
    +
    + <% @tag_set_nomination.send("#{tag_type}_nominations").each_with_index do |nom, index| %> + <%= f.fields_for "#{tag_type}_nominations".to_sym, nom do |nom_form| %> + +
    <%= nom_form.label :tagname, ts("%{tag_type} %{index}", tag_type: tag_type_label_name(tag_type), index: index + 1) %> +
    + <% if nom.approved || nom.rejected %> + <%= nom.tagname %> <%= nomination_status(nom) %> + <% else %> + +
    "> + <%= nom_form.text_field :tagname, autocomplete_options(tag_type, data: { autocomplete_token_limit: 1 }) %> +
    + <% end %> +
    + <% unless tag_type == "freeform" %> +
    + <%= nom_form.label :parent_tagname, ts("Fandom?") %> + <% unless @past_first %> + <%= link_to_help 'tagset-fandom-for-child' %> + <% @past_first = true %> + <% end %> +
    +
    "><%= nom_form.text_field :parent_tagname, autocomplete_options("fandom", data: { autocomplete_token_limit: 1 }) %>
    + <% end %> + + <% end %> + <% end %> +
    +
    diff --git a/app/views/tag_set_nominations/_tag_nominations_by_fandom.html.erb b/app/views/tag_set_nominations/_tag_nominations_by_fandom.html.erb new file mode 100644 index 0000000..5268ce2 --- /dev/null +++ b/app/views/tag_set_nominations/_tag_nominations_by_fandom.html.erb @@ -0,0 +1,39 @@ +<% @tag_set_nomination.fandom_nominations.each_with_index do |nom, index| %> + <%= f.fields_for :fandom_nominations, nom do |nom_form| %> +
    + <%= ts("Fandom %{index}", index: index + 1) %> +
    +
    <%= nom_form.label :tagname, ts("Fandom %{index}", index: index + 1) %>
    +
    + <% if nom.approved || nom.rejected %> + <%= nom.tagname %> <%= nomination_status(nom) %> + <% else %> +
    "><%= nom_form.text_field :tagname, autocomplete_options("fandom", data: { autocomplete_token_limit: 1 }, class: "autocomplete") %>
    + <% end %> +
    + + <% # 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| %> +
    <%= nom_form2.label :tagname, ts("%{tag_type}", tag_type: tag_type_label_name(tag_type)) %> +
    + <% if nom_form2.object.approved || nom_form2.object.rejected %> + <%= nom_form2.object.tagname %> <%= nomination_status(nom_form2.object) %> + <% else %> +
    "> <%= nom_form2.text_field :tagname, + autocomplete_options("#{tag_type}_in_fandom", + data: { + autocomplete_token_limit: 1, + autocomplete_live_params: "fandom=tag_set_nomination_fandom_nominations_attributes_#{index}_tagname" + }) %>
    + <% end %> + <%= nom_form2.hidden_field :from_fandom_nomination, :value => true %> +
    + <% end %> + <% end %> + <% end %> +
    +
    + <% end %> +<% end %> diff --git a/app/views/tag_set_nominations/confirm_delete.html.erb b/app/views/tag_set_nominations/confirm_delete.html.erb new file mode 100644 index 0000000..9b1fbaf --- /dev/null +++ b/app/views/tag_set_nominations/confirm_delete.html.erb @@ -0,0 +1,12 @@ + +

    <%= ts("Delete Tag Set Nomination?") %>

    + + +<%= form_for(@tag_set, :url => tag_set_nomination_path(@tag_set, @tag_set_nomination), :html => {:method => :delete, :class => "simple destroy"}) do |f| %> +

    <%= 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 100644 index 0000000..48b3d5c --- /dev/null +++ b/app/views/tag_set_nominations/confirm_destroy_multiple.html.erb @@ -0,0 +1,12 @@ + +

    <%= ts("Clear all Tag Set Nominations?") %>

    + + +<%= form_for(@tag_set, :url => destroy_multiple_tag_set_nominations_path(@tag_set), :html => {:class => "simple destroy"}, method: :delete) do |f| %> +

    <%= ts("Are you certain you want to clear all Tag Set Nominations?") %>

    +

    + <%= f.submit ts("Yes, Clear Tag Set Nominations") %> + <%= link_to ts("Back to the Tag Set Nominations"), tag_set_path(@tag_set) %> +

    +<% end %> + diff --git a/app/views/tag_set_nominations/edit.html.erb b/app/views/tag_set_nominations/edit.html.erb new file mode 100644 index 0000000..c3a5efc --- /dev/null +++ b/app/views/tag_set_nominations/edit.html.erb @@ -0,0 +1,18 @@ +<%= render 'nomination_form' %> + +<%= content_for :footer_js do %> + <%= javascript_include_tag '/javascripts/jquery.qtip.min.js' %> + <%= javascript_tag do %> + $j(document).ready(function() { + setupTooltips(); + }); + function setupTooltips() { + $j('span[data-tooltip]').each(function(){ + $j(this).qtip({ + content: $j(this).attr('data-tooltip'), + position: {corner: {target: 'topMiddle'}} + }); + }); + } + <% end %> +<% end %> \ No newline at end of file diff --git a/app/views/tag_set_nominations/index.html.erb b/app/views/tag_set_nominations/index.html.erb new file mode 100644 index 0000000..d116c5e --- /dev/null +++ b/app/views/tag_set_nominations/index.html.erb @@ -0,0 +1,11 @@ +<% if @user %> +

    <%= ts("My Nominations") %>

    + +
      + <% @tag_set_nominations.each do |tag_set_nomination| %> +
    • <%= link_to ts("Nominations for %{title}", :title => @tag_set.title) %>
    • + <% end %> +
    +<% elsif @tag_set %> + <%= render "review" %> +<% end %> diff --git a/app/views/tag_set_nominations/new.html.erb b/app/views/tag_set_nominations/new.html.erb new file mode 100644 index 0000000..8bccef8 --- /dev/null +++ b/app/views/tag_set_nominations/new.html.erb @@ -0,0 +1 @@ +<%= render 'nomination_form' %> diff --git a/app/views/tag_set_nominations/show.html.erb b/app/views/tag_set_nominations/show.html.erb new file mode 100644 index 0000000..1df1cba --- /dev/null +++ b/app/views/tag_set_nominations/show.html.erb @@ -0,0 +1,104 @@ + +
    + +

    <%= @tag_set.title %>

    + + + + + + +

    <%= 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("Nominated Tags") %> <%= link_to_help "tagset-nominated-tags" %>

    + +
      + <% if @limit[:fandom] > 0 && (@limit[:character] > 0 || @limit[:relationship] > 0) %> + <% # group fandom, char, rel together %> + <% @tag_set_nomination.fandom_nominations.each do |fandom_nom| %> +
    1. +

      <%= render "show_tag_status", :tag => fandom_nom %>

      + <% TagSet::TAG_TYPES_RESTRICTED_TO_FANDOM.each do |tag_type| %> + <% if @limit[tag_type] > 0 %> +
      +
      <%= ts("%{tag_type}", tag_type: tag_type_label_name(tag_type).pluralize) %>
      +
        + <% if fandom_nom.send("#{tag_type}_nominations").count == 0 %> +
      • <%= ts("None nominated in this fandom.") %>
      • + <% else %> + <% fandom_nom.send("#{tag_type}_nominations").each do |tag| %> +
      • <%= render "show_tag_status", :tag => tag %>
      • + <% end %> + <% end %> +
      +
      + <% end %> + <% end %> +
    2. + <% end %> + + <% # show freeforms separately %> + <%= render "show_by_tag_type", :tag_type => "freeform" %> + + <% else %> + + <% # list char, rel, freeform individually %> + <% TagSet::TAG_TYPES_INITIALIZABLE.each do |tag_type| %> + <%= render "show_by_tag_type", :tag_type => tag_type %> + <% end %> + <% end %> +
    + + +
    + +<%= content_for :footer_js do %> + <%= javascript_include_tag '/javascripts/jquery.qtip.min.js' %> + <%= javascript_tag do %> + $j(document).ready(function() { + setupTooltips(); + }); + function setupTooltips() { + $j('span[data-tooltip]').each(function(){ + $j(this).qtip({ + content: $j(this).attr('data-tooltip'), + position: {corner: {target: 'topMiddle'}} + }); + }); + } + <% end %> +<% end %> diff --git a/app/views/tag_wranglers/index.html.erb b/app/views/tag_wranglers/index.html.erb new file mode 100644 index 0000000..0f879a7 --- /dev/null +++ b/app/views/tag_wranglers/index.html.erb @@ -0,0 +1,101 @@ +
    + +

    Tag Wrangling Assignments

    + + + + <%= will_paginate(@assignments) %> + + + + <%= form_tag url_for(:controller => 'tag_wranglers', :action => "create"), :id => "tag_wranglers" do %> +
    + Assign fandoms to yourself +

    Assign fandoms to yourself

    +

    + (<%= label_tag "tag_fandom_string", "Enter as many fandoms as you like." %>) +

    +
    + <%= text_field_tag "tag_fandom_string", params[:sign_up_fandoms], autocomplete_options("fandom") %> +
    +

    <%= submit_tag 'Assign' %>

    +
    + + <% redirect_params = { fandom_string: params[:fandom_string], media_id: params[:media_id], wrangler_id: params[:wrangler_id] } %> + +
    + Assign Wranglers to Fandoms +

    Assign Wranglers to Fandoms

    +
    + <% @assignments.group_by(&:name).each do |name, assignments| %> +
    <%= link_to name, {:controller => :tags, :action => :edit, :id => assignments.first} %>
    +       <% wranglers = assignments.collect(&:wrangler).compact %> +       
    + <% unless wranglers.empty? %> + + <% end %> + +

    + <% select_tag_to_add = select_tag("assignments[#{assignments.first.id}][]", options_for_select([''] + @wranglers.collect(&:login))) %> + <%= select_tag_to_add %> + + <%= link_to_function("+", + "$j('#wranglers-for-#{assignments.first.id}').append('

    " + escape_javascript(select_tag_to_add) + "

    ')") %> + +

    +
    + <% end %> +
    +

    + <%= hidden_field_tag :fandom_string, params[:fandom_string] %> + <%= hidden_field_tag :media_id, params[:media_id] %> + <%= hidden_field_tag :wrangler_id, params[:wrangler_id] %> + <%= submit_tag 'Assign' %> +

    +
    + <% end %> + + + + <%= form_tag tag_wranglers_path, :class => 'filters', :method => :get do %> +
    +

    Filters

    + +
    + <% end %> + +
    + <%= will_paginate(@assignments) %> + +
    diff --git a/app/views/tag_wranglers/show.html.erb b/app/views/tag_wranglers/show.html.erb new file mode 100644 index 0000000..0c39cf9 --- /dev/null +++ b/app/views/tag_wranglers/show.html.erb @@ -0,0 +1,70 @@ +
    +
    + +

    <%= ts("%{wrangler_login}'s Wrangling Page", :wrangler_login => @wrangler.login) %>

    +

    <%= icon_display(@wrangler) %>

    + + + <% if policy(:wrangling).report_csv? %> + + <% end %> + +
    + + + +
    +

    <%= ts('Assigned Fandoms') %>

    + <% if @fandoms.empty? %> +

    <%= 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.') %>

    + <% end %> + <% if (@wrangler == @current_user || logged_in_as_admin?) && @wrangler.last_wrangling_activity %> + <% wrangled_time_html = tag.span(time_in_zone(@wrangler.last_wrangling_activity.updated_at), class: "datetime") %> +

    <%= t(".last_wrangled_html", wrangler_login: @wrangler.login, time: wrangled_time_html) %>

    + <% end %> + <% if @fandoms.any? %> + + + + + + + + + + + + + + + + + + + <% @fandoms.each do |fandom| %> + + + + + + + + + + <% end %> + +
    <%= ts('Unfilterable and Unwrangled Tag Counts for Your Assigned Fandoms') %>
    <%= ts('Fandom') %><%= ts('Unfilterable') %><%= ts('Unwrangled') %>
    <%= ts('Characters') %><%= ts('Relationships') %><%= ts('Freeforms') %><%= ts('Characters') %><%= ts('Relationships') %><%= ts('Freeforms') %>
    <%= link_to fandom.name, {:controller => :tags, :action => :wrangle, :id => fandom} %><%= (tag_count = fandom.characters.unfilterable.size) > 0 ? link_to(tag_count, {:controller => :tags, :action => :wrangle, :id => fandom, :show => :characters, :status => :unfilterable}) : " " %><%= (tag_count = fandom.relationships.unfilterable.size) > 0 ? link_to(tag_count, {:controller => :tags, :action => :wrangle, :id => fandom, :show => :relationships, :status => :unfilterable}) : " " %><%= (tag_count = fandom.freeforms.unfilterable.size) > 0 ? link_to(tag_count, {:controller => :tags, :action => :wrangle, :id => fandom, :show => :freeforms, :status => :unfilterable}) : " " %><%= (tag_count = fandom.unwrangled_tag_count("Character")) > 0 ? link_to(tag_count, {:controller => :tags, :action => :wrangle, :id => fandom, :show => :characters, :status => :unwrangled}) : " " %><%= (tag_count = fandom.unwrangled_tag_count("Relationship")) > 0 ? link_to(tag_count, {:controller => :tags, :action => :wrangle, :id => fandom, :show => :relationships, :status => :unwrangled}) : " " %><%= (tag_count = fandom.unwrangled_tag_count("Freeform")) > 0 ? link_to(tag_count, {:controller => :tags, :action => :wrangle, :id => fandom, :show => :freeforms, :status => :unwrangled}) : " " %>
    + <% end %> +
    +
    + + + + + +
    diff --git a/app/views/tag_wrangling_supervisor_mailer/wrangler_username_change_notification.html.erb b/app/views/tag_wrangling_supervisor_mailer/wrangler_username_change_notification.html.erb new file mode 100644 index 0000000..a144b62 --- /dev/null +++ b/app/views/tag_wrangling_supervisor_mailer/wrangler_username_change_notification.html.erb @@ -0,0 +1,10 @@ +<% content_for :message do %> +

    <%= t("mailer.general.greeting.informal.addressed_html", name: style_bold(t("mailer.general.greeting.tag_wrangler_supervisors"))) %>

    + +

    <%= t(".name_changed.html", old_username: style_bold(@old_username), new_username: style_bold(@new_username)) %>

    + +

    + <%= t("mailer.general.closing.informal") %>
    + <%= t("mailer.general.signature.app_short_name") %> +

    +<% end %> diff --git a/app/views/tag_wrangling_supervisor_mailer/wrangler_username_change_notification.text.erb b/app/views/tag_wrangling_supervisor_mailer/wrangler_username_change_notification.text.erb new file mode 100644 index 0000000..5315e40 --- /dev/null +++ b/app/views/tag_wrangling_supervisor_mailer/wrangler_username_change_notification.text.erb @@ -0,0 +1,8 @@ +<% content_for :message do %> +<%= t("mailer.general.greeting.informal.addressed_html", name: t("mailer.general.greeting.tag_wrangler_supervisors")) %> + +<%= t(".name_changed.html", old_username: @old_username, new_username: @new_username) %> + +<%= t("mailer.general.closing.informal") %> +<%= t("mailer.general.signature.app_short_name") %> +<% end %> diff --git a/app/views/tag_wranglings/_wrangler_dashboard.html.erb b/app/views/tag_wranglings/_wrangler_dashboard.html.erb new file mode 100644 index 0000000..a1e0bcd --- /dev/null +++ b/app/views/tag_wranglings/_wrangler_dashboard.html.erb @@ -0,0 +1,62 @@ +<% if (logged_in_as_admin? && policy(:wrangling).read_access?) || current_user.try(:is_tag_wrangler?) || @counts %> +
    + <% if (logged_in_as_admin? && policy(:wrangling).read_access?) || current_user.try(:is_tag_wrangler?) %> + + <% end %> + + <% if @counts %> + + <% end %> +
    +<% end %> diff --git a/app/views/tag_wranglings/index.html.erb b/app/views/tag_wranglings/index.html.erb new file mode 100755 index 0000000..7f07bf6 --- /dev/null +++ b/app/views/tag_wranglings/index.html.erb @@ -0,0 +1,159 @@ +
    + +

    <%= ts('Tag Wrangling') %>

    + <% unless params[:show] %> +

    <%= ts('Notes') %>

    + +

    Additional Resources:

    + + <% end %> + + + + + + + + + <% unless params[:show].blank? %> +

    <%= ts('Mass Wrangle New/Unwrangled Tags') %>

    + <% end %> + + <% if @tags && @tags.empty? %> +

    <%= ts('There are no unwrangled tags in this category at the moment.') %>

    + <% elsif @tags %> + <%= will_paginate @tags %> + + <%= form_tag url_for(controller: 'tag_wranglings', action: 'wrangle'), method: :post, id: 'wrangulator' do %> +
    + <%= ts('Assign and Mass Select') %> + +
    + <% if params[:show] == 'fandoms' %> +
    <%= label_tag :media, ts('Wrangle to Media') %>
    +
    <%= select_tag :media, options_for_select(@media_names) %>
    + <% else %> +
    <%= label_tag :fandom_string, ts('Wrangle to Fandom(s)') %>
    +
    <%= text_field_tag 'fandom_string', params[:fandom_string], autocomplete_options('fandom') %>
    + <% end %> +
    <%= ts('Submit') %>
    +
    <%= submit_tag ts('Wrangle') %>
    +
    +
    + +
    + <%= ts('Choose tags from a table') %> + +

    <%= ts('Individual Selection Table') %>

    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + <% @tags.each do |tag| %> + <% if tag.unwrangled? %> + + + + + + + + + + + + <% end %> + <% end %> + +
    <%= @tags.first.class.to_s.pluralize %> <%= ts('to be Wrangled') %>
    + <%= sort_link ts('Tag Name'), :name %> + + + <%= sort_link ts('Created'), :created_at, {sort_default: true} %> + + <%= ts('Canonical') %> + + + <%= sort_link ts('Taggings'), :taggings_count_cache, { desc_default: true } %> + <%= ts('Manage') %>
    <%= ts('Action:') %><%= submit_tag ts('Wrangle') %>
    "> + <%= check_box_tag "selected_tags[]", tag.id, nil, id: "selected_tags_#{tag.id}" %> + <%= label_tag "selected_tags_#{tag.id}", tag.name.to_s %> + "><%= l(tag.created_at.to_date) %>"> + <% if tag.canonical? %> + <%= ts("Yes") %> + <% else %> + <%= check_box_tag "canonicals[]", tag.id, tag.canonical?, id: "canonicals_#{tag.id}" %> + <% end %> + "><%= tag.taggings_count_cache %> + +
    +
    +
    + +
    + <%= hidden_field_tag :show, params[:show] %> + <%= hidden_field_tag :sort_column, params[:sort_column] %> + <%= hidden_field_tag :sort_direction, params[:sort_direction] %> + <%= hidden_field_tag :page, params[:page] %> +
    + <% end %> + +
    + + + <%= will_paginate @tags %> + <% end %> + +
    + +<% content_for :footer_js do %> + <%= javascript_include_tag 'select_all', skip_pipeline: true %> +<% end %> diff --git a/app/views/tags/_search_form.html.erb b/app/views/tags/_search_form.html.erb new file mode 100644 index 0000000..5dcbea1 --- /dev/null +++ b/app/views/tags/_search_form.html.erb @@ -0,0 +1,85 @@ +<%= form_for @search, as: :tag_search, url: search_tags_path, html: { class: "search", method: :get } do |f| %> +
    +
    +
    + <%= f.label :name, t(".tag_name") %> + <%= link_to_help "tag-search-text-help" %> +
    +
    + <%= f.text_field :name %> +
    +
    + <%= f.label :fandoms, t(".fandoms") %> +
    +
    + <%= f.text_field :fandoms, autocomplete_options("fandom", "aria-describedby" => "fandom-field-description") %> +

    + <%= t(".fandoms_footnote") %> +

    +
    +
    <%= t(".type") %>
    +
    +
    +
      + <% types = Tag::USER_DEFINED %> + <% types.each do |type| %> +
    • + <%= f.radio_button :type, type %> + <%= f.label :type, ts("%{type}", type: type), value: type %> +
    • + <% end %> +
    • + <%= f.radio_button :type, "" %> + <%= f.label :type, ts("Any type"), value: "" %> +
    • +
    +
    +
    +
    <%= t(".wrangling_status") %>
    +
    +
    +
      +
    • + <%= f.radio_button :wrangling_status, "canonical" %> + <%= f.label :wrangling_status, t(".status_option.canonical"), value: "canonical" %> +
    • +
    • + <%= f.radio_button :wrangling_status, "noncanonical" %> + <%= f.label :wrangling_status, t(".status_option.noncanonical"), value: "noncanonical" %> +
    • +
    • + <%= f.radio_button :wrangling_status, "synonymous" %> + <%= f.label :wrangling_status, t(".status_option.synonymous"), value: "synonymous" %> +
    • +
    • + <%= f.radio_button :wrangling_status, "canonical_synonymous" %> + <%= f.label :wrangling_status, t(".status_option.canonical_or_synonymous"), value: "canonical_synonymous" %> +
    • +
    • + <%= f.radio_button :wrangling_status, "noncanonical_nonsynonymous" %> + <%= f.label :wrangling_status, t(".status_option.noncanonical_and_nonsynonymous"), value: "noncanonical_nonsynonymous" %> +
    • +
    • + <%= f.radio_button :wrangling_status, "" %> + <%= f.label :wrangling_status, t(".status_option.any_status"), value: "" %> +
    • +
    +
    +
    +
    + <%= f.label :sort_column, t(".sort_by") %> +
    +
    + <%= f.select :sort_column, options_for_select(@search.sort_options, @search.sort_column) %> +
    +
    + <%= f.label :sort_direction, t(".sort_direction") %> +
    +
    + <%= f.select :sort_direction, + options_for_select([["Ascending", "asc"], ["Descending", "desc"]], @search.sort_direction) %> +
    +
    +

    <%= f.submit t(".search_tags") %>

    +
    +<% end %> diff --git a/app/views/tags/edit.html.erb b/app/views/tags/edit.html.erb new file mode 100644 index 0000000..a66c463 --- /dev/null +++ b/app/views/tags/edit.html.erb @@ -0,0 +1,235 @@ + +

    + <%= ts("Edit %{tag_link} Tag", tag_link: link_to_tag(@tag)).html_safe %> +

    +<%= error_messages_for :tag %> + + + + + + + + +<% if logged_in_as_admin? %> +

    <%= ts("Last updated by %{wrangler} on %{date}", wrangler: @tag.last_wrangler.try(:login) || '---', date: @tag.updated_at) %>

    +<% end %> + +<%= form_for @tag, as: :tag, url: { action: "update", id: @tag}, html: { method: :put } do |f| %> +
    + <%= t(".submit_legend") %> +

    + <%= submit_tag t(".save_changes") %> +

    +
    +
    + <%= ts("Tag Info") %> +

    <%= ts("Tag Info") %>

    + +
    +
    <%= f.label :name, ts("Name") %>
    +
    + <% if Tag::USER_DEFINED.include?(@tag.class.name) || (@tag[:type] == "Media" && logged_in_as_admin?) %> + <%= f.text_field :name, size: (@tag.name.length + 5) %> + <% unless logged_in_as_admin? %> +

    + <%= ts("Only changes to capitalization and diacritic marks are permitted.") %> +

    + <% end %> + <% else %> + <%= @tag.name %> + <% end %> +
    + + <% if @tag.type.to_s == "Fandom" %> +
    + <%= f.label :sortable_name, ts("Name To Use For Alphabetical Sorting") %> +
    +
    + <%= f.text_field :sortable_name, size: (@tag.sortable_name.length + 5) %> +
    + <% end %> + +
    <%= ts("Category") %>
    +
    + <% types = logged_in_as_admin? ? (Tag::USER_DEFINED + %w[Media]) : Tag::USER_DEFINED %> + <% if @tag.can_change_type? %> + <%= f.select :type, + options_for_select(types + %w[UnsortedTag], + @tag.type.to_s) %> + <% else %> + <%= ts("%{tag_type}", tag_type: tag_type_label_name(@tag.type.tableize)) %> + <% end %> +
    + + <% if @wranglers %> +
    <%= ts("Wranglers") %>
    +
    <%= wrangler_list(@wranglers, @tag) %>
    + <% end %> + +
    <%= f.label :canonical, ts("Canonical") %>
    +
    + <%= f.check_box("canonical", disabled: !(logged_in_as_admin? || Tag::USER_DEFINED.include?(@tag.class.name)) || !@tag.mergers.empty? || !@tag.children.empty?) %>  +

    + <%= ts("This is the official name for the %{tag_type}", + tag_type: tag_type_label_name(@tag.type.tableize)) %> +

    + <% if logged_in_as_admin? && Tag::USER_DEFINED.include?(@tag.class.name) && @tag.canonical? && (!@tag.mergers.empty? || !@tag.children.empty?) %> + + <% end %> +
    + + <% if @tag.is_a?(Rating)%> +
    <%= f.label :adult, ts("Adult") %>
    +
    + <%= f.check_box("adult", disabled: !logged_in_as_admin? )%>  + <%= ts("This tag indicates adult content.") %> +
    + <% end %> + + <% if Tag::USER_DEFINED.include?(@tag[:type]) && !(@tag.canonical? && !logged_in_as_admin?) %> +
    <%= f.label :syn_string, ts("Synonym of") %>
    +
    + <%= f.text_field :syn_string, + autocomplete_options("tag?type=#{@tag.type.downcase}", + class: "autocomplete tags", + data: { autocomplete_token_limit: 1 }) %> +

    + <%= ts("Choose an existing tag or add a new tag name here to create a new canonical and make this tag its synonym.") %> +

    + <% if @tag.merger %> + + <% elsif @tag.canonical? %> +

    + <%= ts("Adding a synonym to a canonical tag will make it non-canonical and move its associations to the other tag. (Be careful with this!)") %> +

    + <% end %> +
    + <% end %> + +
    <%= f.label :unwrangleable %>
    +
    + <%= f.check_box(:unwrangleable, disabled: (@tag.class.name == "UnsortedTag")) %>  +

    <%= ts("This tag will never be merged or made canonical and should not be included on wrangling pages.") %>

    +
    +
    +
    + + <% if (Tag::USER_DEFINED + ["Media"]).include?(@tag[:type]) %> +
    + <%= ts("Parent Tags") %> +

    <%= ts("Parent Tags") %>

    +
    + <% @tag.parent_types.each do |tag_type| %> + <% if tag_type == "Fandom" && !@suggested_fandoms.blank? %> +
    <%= ts("Suggested Fandoms") %>:
    +
    +
      + <% @suggested_fandoms.each do |tag| %> +
    • <%= link_to_edit_tag(tag) %>
    • + <% end %> +
    +
    + <% end %> +
    <%= tag_category_name(tag_type) %>
    +
    + <% if @parents[tag_type].present? %> +

    <%= ts("Check to remove:") %>

    + <%= check_all_none("All", "None", tag_type) %> + <%= checkbox_section(f, "associations_to_remove", @parents[tag_type], + name_helper_method: "remove_tag_association_label", + extra_info_method: "link_to_edit_tag", + field_id: "parent_#{tag_type}_associations_to_remove", + concise: true) %> +
    + <%= f.label tag_type.underscore + "_string", ts("Add:") %> +
    + <% else %> +

    + <%= f.label tag_type.underscore + "_string", + ts("Add %{catname}:", catname: tag_category_name(tag_type)) %> +

    + <% end %> +
    "> + <%= f.text_field tag_type.underscore + "_string", + autocomplete_options("tag?type=#{(tag_type.downcase == 'metatag' ? @tag.type.downcase : tag_type.downcase)}", + class: "tags autocomplete") %> +
    +
    + <% end %> +
    +
    + + <% if @tag.canonical? %> +
    + <%= ts("Child Tags") %> +

    <%= ts("Child Tags") %>

    + +
    + <% @tag.child_types.each do |tag_type| %> +
    <%= tag_category_name(tag_type) %>
    +
    + <% if @children[tag_type].present? %> +

    <%= ts("Check to remove: ") %>

    + <%= check_all_none("All", "None", tag_type) %> + <%= checkbox_section(f, "associations_to_remove", + @children[tag_type][0..19], + name_helper_method: "remove_tag_association_label", + extra_info_method: "link_to_edit_tag", + field_id: "child_#{tag_type}_associations_to_remove", + concise: true) %> + <% if @children[tag_type].length > 20 %> + + <% end %> +
    + <%= f.label tag_type.underscore + "_string", ts("Add:") %> +
    + <% else %> +

    + <%= f.label tag_type.underscore + "_string", ts("Add %{catname}:", + catname: tag_category_name(tag_type)) %> +

    + <% end %> +
    "> + <%= f.text_field tag_type.underscore + "_string", + autocomplete_options("#{tag_type == 'Merger' ? ('noncanonical_tag?type=' + @tag.type.downcase) : (tag_type == 'SubTag' ? @tag.type.downcase : tag_type.downcase)}", + class: "tags autocomplete") %> +
    +
    + <% end %> +
    +
    + <% end %> + + <% elsif @tag.is_a?(Media) %> + + <% end %> + +
    + <%= t(".submit_legend") %> +

    + <%= submit_tag t(".save_changes") %> +

    +
    +<% end %> + + + + diff --git a/app/views/tags/feed.atom.builder b/app/views/tags/feed.atom.builder new file mode 100644 index 0000000..9f01c5e --- /dev/null +++ b/app/views/tags/feed.atom.builder @@ -0,0 +1,17 @@ +atom_feed do |feed| + feed.title "AO3 works tagged '#{@tag.name}'" + feed.updated @works.first.created_at if @works.respond_to?(:first) && @works.first.present? + + @works.each do |work| + unless work.unrevealed? + feed.entry work do |entry| + entry.title work.title + entry.summary feed_summary(work), :type => 'html' + + entry.author do |author| + author.name text_byline(work, :visibility => 'public') + end + end + end + end +end diff --git a/app/views/tags/index.html.erb b/app/views/tags/index.html.erb new file mode 100644 index 0000000..0658c8b --- /dev/null +++ b/app/views/tags/index.html.erb @@ -0,0 +1,48 @@ + + +

    <%= ts("Tags") %>

    + + + +<% unless @collection %> + +<% end %> + + + +<% unless @collection %> +

    + <% if params[:show] == "random" %> + <%= ts('Browse Random Tags')%> + <% else %> + <%= ts('Browse Popular Tags')%> + <% end %> +

    +<% end %> + +<% if @tags %> + <% if params[:show] == "random" %> + <% if @collection %> +

    <%= t(".about.random_in_collection") %>

    + <% else %> +

    <%= t(".about.random", search_tags_link: (link_to t(".search_tags"), search_tags_path)).html_safe %>

    + <% end %> + <% else %> + <% if @collection %> +

    <%= t(".about.popular_in_collection") %>

    + <% else %> +

    <%= t(".about.popular", search_tags_link: (link_to t(".search_tags"), search_tags_path)).html_safe %>

    + <% end %> + <% end %> +
      + + <% tag_cloud @tags, %w(cloud1 cloud2 cloud3 cloud4 cloud5 cloud6 cloud7 cloud8) do |tag, css_class| %> +
    • <%= link_to_tag_works(tag, class: css_class.dup, collection: @collection) %>
    • + <% end %> +
    +<% end %> + diff --git a/app/views/tags/new.html.erb b/app/views/tags/new.html.erb new file mode 100644 index 0000000..f1eb488 --- /dev/null +++ b/app/views/tags/new.html.erb @@ -0,0 +1,47 @@ + +

    <%= ts('New Tag') %>

    + +<%= error_messages_for :tag %> + +

    <%= ts('Once you have created the tag, the attributes that are particular for that tag category will be available for editing.') %>

    + + + + + + + +<%= form_for @tag, as: :tag, url: { action: "create" } do |f| %> +
    +
    <%= f.label :name, ts('Name') %>
    +
    <%= f.text_field :name, size: ArchiveConfig.TAG_MAX %>
    + +
    <%= f.label :canonical, ts('Canonical') %>
    +
    <%= f.check_box :canonical %>
    + +
    <%= ts('Category') %>
    +
    + <% if logged_in_as_admin? %> + <% types = Tag::TYPES %> + <% else %> + <% types = Tag::USER_DEFINED %> + <% end %> +
      + <% types.each do |type| %> +
    • + <%= f.radio_button("type", type) %> + <%= f.label("type_#{type.downcase}", ts("%{type}", type: tag_type_label_name(type))) %> +
    • + <% end %> +
    +
    +
    + +

    + <%= f.submit %> +

    +<% end %> + + + + diff --git a/app/views/tags/search.html.erb b/app/views/tags/search.html.erb new file mode 100644 index 0000000..b77048d --- /dev/null +++ b/app/views/tags/search.html.erb @@ -0,0 +1,18 @@ +

    <%= ts("Tag Search") %>

    + +<%= render 'shared/search_nav' %> + +<%= render :partial => 'tags/search_form' %> + +<% if @tags %> +

    <%= search_results_found(@tags) %> <%= link_to_help "tag-search-results-help" %>

    +

    Listing Tags

    +
      + <% for tag in @tags %> + <% unless tag.nil? %> +
    1. <%= tag_search_result(tag) %>
    2. + <% end %> + <% end %> +
    + <%= will_paginate @tags %> +<% end %> diff --git a/app/views/tags/show.html.erb b/app/views/tags/show.html.erb new file mode 100644 index 0000000..6334bd7 --- /dev/null +++ b/app/views/tags/show.html.erb @@ -0,0 +1,143 @@ + +
    +
    +

    <%= @tag.name %>

    +
    + <% if @tag.canonical || @tag.merger || can_wrangle? %> + + <% end %> +
    + + <%= error_messages_for :tag %> + + + +

    <%= 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 %>

    +
    + <% end %> + + <% unless @tag_children['Merger'].blank? %> +
    +

    <%= ts('Tags with the same meaning') %>:

    +
      <%= tag_link_list(@tag_children['Merger'].sort) %>
    +
    + <% end %> + + <% unless @tag.direct_meta_tags.blank? %> +
    +

    <%= ts('Metatags') %>:

    + <% cache "tag-#{@tag.cache_key}-metatags-v2", expires_in: 24.hours, skip_digest: true do %> + <%= meta_tag_tree(@tag) %> + <% end %> +
    + <% end %> + + <% unless @tag.direct_sub_tags.blank? %> +
    +

    <%= ts('Subtags') %>:

    + <% cache "tag-#{@tag.cache_key}-subtags-v3", expires_in: 24.hours, skip_digest: true do %> + <%= sub_tag_tree(@tag) %> + <% end %> +
    + <% end %> + + <% unless (@tag_children.keys - ['Merger']).blank? %> + + <% cache("tag-#{@tag.cache_key}-children-v3", skip_digest: true) do %> +
    +

    <%= ts('Child tags (displaying the first %{count} of each type)', :count => ArchiveConfig.TAG_LIST_LIMIT) %>:

    + <% %w(Character Relationship Freeform).each do |type| %> + <% if @tag_children[type].present? %> +
    +

    <%= type.constantize::NAME.pluralize %>:

    +
      + <%= tag_link_list(@tag_children[type].sort) %> + <% if @tag_children[type].length > ArchiveConfig.TAG_LIST_LIMIT %> +
    • <%= ts("and more") %>
    • + <% end %> +
    +
    + <% end %> + <% end %> +
    + <% end %> + <% end %> + + <% if @works %> +

    <%= ts("This tag has not been marked common and can't be filtered on (yet).") %>

    + <% unless @works.blank? %> +
    +

    <%= ts('Works which have used it as a tag') %>:

    + <%= paginated_section @works do %> +
      + <% for work in @works %> + <%= render :partial => 'works/work_blurb', :locals => {:work => work} %> + <% end %> +
    + <% end %> +
    + <% end %> + + <% unless @bookmarks.blank? %> +
    +

    <%= ts('Bookmarks which have used it as a tag') %>:

    + <%= paginated_section @bookmarks, {:previous_label => '« Previous', :next_label => 'Next »'} do %> +
      + <% for bookmark in @bookmarks %> + <%= render 'bookmarks/bookmark_blurb', :bookmark => bookmark %> + <% end %> +
    + <% end %> +
    + <% end %> + <% end %> +
    + diff --git a/app/views/tags/show_hidden.js.erb b/app/views/tags/show_hidden.js.erb new file mode 100644 index 0000000..283d892 --- /dev/null +++ b/app/views/tags/show_hidden.js.erb @@ -0,0 +1,12 @@ +<% +last_tag = @display_tags.last +if @display_category == 'warnings' + open_tags = "
  • " + closing_tags = "
  • " +else + open_tags = "
  • " + closing_tags = "
  • " +end +tag_list = @display_tags.map {|tag| open_tags + link_to_tag_works(tag) + closing_tags } +%> +$j("#<%= "#{@display_creation.class.to_s.underscore}_#{@display_creation.id}_category_#{@display_category}" %>").replaceWith("<%= escape_javascript(tag_list.join(" ").html_safe) %>"); diff --git a/app/views/tags/wrangle.html.erb b/app/views/tags/wrangle.html.erb new file mode 100644 index 0000000..5eef96a --- /dev/null +++ b/app/views/tags/wrangle.html.erb @@ -0,0 +1,177 @@ + +

    <%= ts('Wrangle Tags for') %> <%= link_to_tag(@tag) %>

    + + + + + + +<% + showing_header = '' + if params[:show] && params[:status] + showing_header = ts('Showing %{status} %{tag_type} Tags', status: params[:status].to_s.capitalize, tag_type: params[:show].to_s.titleize.singularize) + elsif params[:show] + showing_header = ts('Showing All %{tag_type} Tags', tag_type: params[:show].to_s.titleize.singularize) + end +%> +<% unless showing_header.blank? %> +

    <%= showing_header %>

    +<% end %> + +<% if params[:show] %> + +<% end %> + +<% if @tags && @tags.empty? %> +

    <%= ts('There are no tags in this category at the moment.') %>

    +<% elsif @tags %> + <%= will_paginate @tags %> + + <%= form_tag url_for(controller: 'tags', action: 'mass_update'), method: :post, id: 'wrangulator' do %> + + <% if @tag.is_a?(Fandom) && !%w(sub_tags mergers).include?(params[:show]) %> +
    + <%= ts('Assign and Mass Select') %> + +
    +
    <%= label_tag :fandom_string, ts('Wrangle to Fandom(s)') %>
    +
    <%= text_field_tag 'fandom_string', params[:fandom_string], autocomplete_options('fandom') %>
    + +
    <%= ts('Submit') %>
    +
    <%= submit_tag ts('Wrangle') %>
    +
    +
    + <% end %> + +

    <%= submit_tag ts('Wrangle') %>

    + + <% sort_url_options = {show: params[:show], status: params[:status], sort_column: params[:sort_column]} %> + + + + + + + + + <% if params[:show] == 'relationships' %> + + <% end %> + + <% if params[:status] == 'canonical' %> + + <% else %> + + <% end %> + + + + + + + + <% @tags.each do |tag| %> + + <% if @tag.is_a?(Fandom) %> + + <% else %> + + <% end %> + + <% if params[:show] == 'relationships' %> + + <% end %> + + + + + + + + + + + + <% end %> + +
    <%= ts('Tags to Wrangle') %>
    + <%= sort_link ts('Tag Name'), :name, {sort_default: true} %> + + <%= ts('Characters') %><%= ts('Canonical') %><%= ts('Metatag') %><%= ts('Synonym') %> + <%= sort_link ts('Created'), :created_at, {desc_default: true} %> + + <%= sort_link ts('Taggings'), :taggings_count_cache, {desc_default: true} %> + <%= ts('Manage') %>
    + <%= check_box_tag 'selected_tags[]', tag.id, nil, id: "selected_tags_#{tag.id}" %> + <%= label_tag "selected_tags_#{tag.id}", "#{tag.name}" %> + + <%= label_tag "canonicals_#{tag.id}", tag.name %> + + <% unless !tag.canonical? || tag.characters.empty? %> +
      <%= tag_link_list(tag.characters) %>
    + <% end %> +
    + <% if tag.canonical? %> + <%= ts('Yes') %> + <% elsif tag.unwrangleable? %> + <%= ts('Unwrangleable') %> + <% else %> + <%= check_box_tag 'canonicals[]', tag.id, tag.canonical?, id: "canonicals_#{tag.id}" %> + <% end %> + + <% if params[:status] == 'canonical' %> + <% unless tag.direct_meta_tags.blank? %><%= tag_link_list(tag.direct_meta_tags) %><% end %> + <% else %> + <% if tag.merger %><%= link_to_tag(tag.merger) %><% end %> + <% end %> + <%= tag.created_at.to_date %>"><%= tag.taggings_count_cache %> + +
    + +
    + <%= hidden_field_tag :show, params[:show] %> + <%= hidden_field_tag :sort_column, params[:sort_column] %> + <%= hidden_field_tag :sort_direction, params[:sort_direction] %> + <%= hidden_field_tag :page, params[:page] %> + <%= hidden_field_tag :status, params[:status] %> +
    + +

    <%= submit_tag ts('Wrangle') %>

    + <% end %> + + + + <%= will_paginate @tags %> +<% end %> + + +<% content_for :footer_js do %> + <%= javascript_include_tag 'select_all', skip_pipeline: true %> +<% end %> diff --git a/app/views/tos_update_mailer/tos_update_notification.html.erb b/app/views/tos_update_mailer/tos_update_notification.html.erb new file mode 100644 index 0000000..502a99e --- /dev/null +++ b/app/views/tos_update_mailer/tos_update_notification.html.erb @@ -0,0 +1,16 @@ +<% content_for :message do %> +

    <%= t("mailer.general.greeting.formal.addressed_html", name: style_bold(@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. <%= 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 100644 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 100644 index 0000000..e86d76c --- /dev/null +++ b/app/views/troubleshooting/show.html.erb @@ -0,0 +1,31 @@ +

    <%= t(".page_title.#{@item_type}") %>

    +

    <%= t(".page_description.#{@item_type}") %>

    + +<% if @item.is_a?(Tag) %> +

    <%= link_to @item.name, tag_path(@item) %> (<%= @item.type %>)

    +<% elsif @item.is_a?(Work) %> +
      + <%= render "works/work_blurb", work: @item %> +
    +<% end %> + +<%= form_tag troubleshooting_path, method: "patch" do %> +
    + <%= collection_check_boxes "", :actions, @allowed_actions, :to_s, :to_s do |builder| %> +
    + <%= builder.label do %> + <%= builder.check_box %> + <%= t(".#{builder.text}.title") %> + <% end %> +
    +
    + <%= t(".#{builder.text}.description") %> +
    + <% end %> +
    +
      +
    • + <%= submit_tag "Troubleshoot" %> +
    • +
    +<% end %> diff --git a/app/views/unsorted_tags/index.html.erb b/app/views/unsorted_tags/index.html.erb new file mode 100644 index 0000000..feb9609 --- /dev/null +++ b/app/views/unsorted_tags/index.html.erb @@ -0,0 +1,43 @@ + +

    <%= ts("Unsorted Tags") %>

    + + +<% if @tags %> + + <%= will_paginate @tags %> + + <%= form_tag mass_update_unsorted_tags_path do %> + +

    <%= submit_tag ts("Update") %>

    + + <%= hidden_field_tag :page, params[:page] %> + + "> + + + + + + + + + + <% @tags.each do |tag| %> + <% cache("unsorted-#{tag.cache_key}", skip_digest: true) do %> + + + + + + <% end %> + <% end %> + +
    <%= ts("Unsorted Tags") %>
    <%= ts("Tag") %><%= ts("Uses") %><%= ts("Category") %>
    <%= link_to tag.name, edit_tag_path(tag) %><%= link_to ts("Bookmarks (%{count})", :count => tag.bookmarks.count), tag_bookmarks_path(tag) %><%= select_tag "tags[#{tag.id}]", options_for_select(["", ts("Fandom"), ts("Character"), ts("Relationship"), ts("Freeform")]) %>
    + +

    <%= submit_tag ts("Update") %>

    + + <%= 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 100644 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.") %>

    + + + <%= will_paginate @user_invite_requests %> + + + + <%= form_tag url_for(:controller => 'user_invite_requests', :action => 'update'), method: :patch do %> + + + + + + + + + + + + + <% @user_invite_requests.each do |request| %> + + + + + + + <% end %> + +
    <%= ts('Allot Invites to Users') %>
    <%= ts('User') %><%= ts('Reason') %><%= ts('Quantity') %><%= ts('Created at') %><%= ts('Previous') %>
    <%= link_to request.user.login, request.user %><%= request.reason %><%= text_field_tag "requests[#{request.id}]", request.quantity, :size => 2, :id => "requests[#{request.user.login}]" %><%= request.created_at %> + <%= link_to_previous_invite_requests(request) %>
    +

    <%= submit_tag ts("Update") %><%= submit_tag ts("Decline All"), :name => 'decline_all', data: {confirm: "This will decline ALL requests. Are you sure?"} %>

    + <% end %> + + + + <%= will_paginate @user_invite_requests %> + +
    diff --git a/app/views/user_invite_requests/new.html.erb b/app/views/user_invite_requests/new.html.erb new file mode 100644 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.") %> +

    + + + +<%= render "invitations/user_invitations_navigation" %> + + + +<%= form_for(@user_invite_request) do |f| %> +

    <%= ts("* Required information") %>

    + <%= error_messages_for @user_invite_request %> +
    +
    <%= f.label :quantity, ts("How many invitations would you like? (max %{max})", :max => ArchiveConfig.MAX_USER_INVITE_REQUEST.to_s) + "*" %>
    +
    <%= f.text_field :quantity, :size => '2', :class => 'number' %>
    +
    <%= f.label :reason, ts("Please specify why you'd like them:") + "*" %>
    +
    <%= f.text_area :reason, :size => '50x5' %>
    +
    Submit
    +
    <%= f.submit ts("Send Request") %>
    +
    +<% end %> + diff --git a/app/views/user_mailer/_work_info.html.erb b/app/views/user_mailer/_work_info.html.erb new file mode 100644 index 0000000..096b7a2 --- /dev/null +++ b/app/views/user_mailer/_work_info.html.erb @@ -0,0 +1,24 @@ +<% # expects work %> +

    + <%= style_metadata_label(Work.human_attribute_name("chapter_total_display")) %><%= chapter_total_display(work) %> +
    <%= style_work_tag_metadata(work.tag_groups["Fandom"]) %> +
    <%= style_work_tag_metadata(work.tag_groups["Rating"]) %> +
    <%= style_work_tag_metadata(work.tag_groups["ArchiveWarning"]) %> + <% unless work.tag_groups["Relationship"].empty? %> +
    <%= style_work_tag_metadata(work.tag_groups["Relationship"]) %> + <% end %> + <% unless work.tag_groups["Character"].empty? %> +
    <%= style_work_tag_metadata(work.tag_groups["Character"]) %> + <% end %> + <% unless work.tag_groups["Freeform"].empty? %> +
    <%= style_work_tag_metadata(work.tag_groups["Freeform"]) %> + <% end %> + <% unless work.series.count.zero? %> +
    <%= style_metadata_label(Series.model_name.human(count: work.series.count)) %><%= series_list_for_feeds(work).html_safe %> + <% end %> +

    + +<% unless work.summary.blank? %> +

    <%= style_metadata_label(Work.human_attribute_name("summary")) %>

    + <%= style_quote(raw sanitize_field(work, :summary)) %> +<% end %> diff --git a/app/views/user_mailer/_work_info.text.erb b/app/views/user_mailer/_work_info.text.erb new file mode 100644 index 0000000..decbb00 --- /dev/null +++ b/app/views/user_mailer/_work_info.text.erb @@ -0,0 +1,22 @@ +<% # expects work %> +<%= metadata_label(Work.human_attribute_name("chapter_total_display"))%><%= chapter_total_display(work) %> +<%= work_tag_metadata(work.tag_groups["Fandom"]) %> +<%= work_tag_metadata(work.tag_groups["Rating"]) %> +<%= work_tag_metadata(work.tag_groups["ArchiveWarning"]) %> +<% unless work.tag_groups["Relationship"].empty? %> +<%= work_tag_metadata(work.tag_groups["Relationship"]) %> +<% end %> +<% unless work.tag_groups["Character"].empty? %> +<%= work_tag_metadata(work.tag_groups["Character"]) %> +<% end %> +<% unless work.tag_groups["Freeform"].empty? %> +<%= work_tag_metadata(work.tag_groups["Freeform"]) %> +<% end %> +<% unless work.series.count.zero? %> +<%= metadata_label(Series.model_name.human(count: work.series.count)) %><%= raw to_plain_text(series_list_for_feeds(work)) %> +<% end %> +<% unless work.summary.blank? %> + +<%= metadata_label(Work.human_attribute_name("summary")) %> + <%= raw to_plain_text(sanitize_field(work, :summary)) %> +<% end %> diff --git a/app/views/user_mailer/abuse_report.html.erb b/app/views/user_mailer/abuse_report.html.erb new file mode 100644 index 0000000..b493d29 --- /dev/null +++ b/app/views/user_mailer/abuse_report.html.erb @@ -0,0 +1,38 @@ +<% content_for :message do %> +

    + <% if @username.present? %> + <%= t("mailer.general.greeting.informal.addressed_html", name: style_bold(@username)) %>

    + <% else %> + <%= t("mailer.general.greeting.informal.unaddressed") %> +

    + <% end %> + +

    <%= t(".report_received") %>

    + +

    <%= t(".resubmission") %>

    + +

    <%= t(".copy.intro") %>

    + +

    + <%= style_metadata_label(t(".copy.url")) %><%= style_link(@url, @url) %> +

    + +

    + <%# TODO: Remove to_plain_text when AO3-6519 is fixed. %> + <%= style_metadata_label(t(".copy.summary")) %><%= to_plain_text(raw(@summary)) %> +

    + +

    <%= style_metadata_label(t(".copy.comment")) %>

    +

    <%= style_quote(raw(strip_images(@comment, keep_src: true))) %>

    + +

    <%= t(".thank_you") %>

    + +

    + <%= t("mailer.general.closing.formal") %>
    + <%= style_bold(t("mailer.general.signature.abuse_team")) %> +

    +<% end %> + +<% content_for :sent_at do %> + <%= l(Time.current) %> +<% end %> diff --git a/app/views/user_mailer/abuse_report.text.erb b/app/views/user_mailer/abuse_report.text.erb new file mode 100644 index 0000000..f42af34 --- /dev/null +++ b/app/views/user_mailer/abuse_report.text.erb @@ -0,0 +1,33 @@ +<% content_for :message do %> +<% if @username.present? %> +<%= t("mailer.general.greeting.informal.addressed_html", name: @username) %> +<% else %> +<%= t("mailer.general.greeting.informal.unaddressed") %> +<% end %> + +<%= t(".report_received") %> + +<%= t(".resubmission") %> + +<%= t(".copy.intro") %> + +<%= text_divider %> + +<%= metadata_label(t(".copy.url")) %> +<%= to_plain_text(@url) %> + +<%= metadata_label(t(".copy.summary")) %> +<%= to_plain_text(raw @summary) %> + +<%= metadata_label(t(".copy.comment")) %> +<%= to_plain_text(raw(strip_images(@comment, keep_src: true))) %> + +<%= text_divider %> + +<%= t(".thank_you") %> + +<%= t("mailer.general.closing.formal") %> +<%= t("mailer.general.signature.abuse_team") %> +<% end %> + +<% content_for :sent_at do %><%= l(Time.current) %><% end %> diff --git a/app/views/user_mailer/admin_deleted_work_notification.html.erb b/app/views/user_mailer/admin_deleted_work_notification.html.erb new file mode 100644 index 0000000..846be04 --- /dev/null +++ b/app/views/user_mailer/admin_deleted_work_notification.html.erb @@ -0,0 +1,11 @@ +<% content_for :message do %> +

    <%= t("mailer.general.greeting.formal.addressed_html", name: style_bold(@user.login)) %>

    + +

    <%= t(".deleted.html", title: style_creation_title(@work.title)) %>

    + +

    <%= t(".import_project.html", opendoors_link: opendoors_link(t(".opendoors"))) %>

    + +

    <%= t(".tos_violation.html", contact_abuse_link: abuse_link(t(".contact_abuse"))) %>

    + +

    <%= t(".bye") %>

    +<% end %> diff --git a/app/views/user_mailer/admin_deleted_work_notification.text.erb b/app/views/user_mailer/admin_deleted_work_notification.text.erb new file mode 100644 index 0000000..0ddc7b1 --- /dev/null +++ b/app/views/user_mailer/admin_deleted_work_notification.text.erb @@ -0,0 +1,11 @@ +<% content_for :message do %> +<%= t("mailer.general.greeting.formal.addressed_html", name: @user.login) %> + +<%= t(".deleted.text", title: @work.title) %> + +<%= t(".import_project.text", opendoors_link: "https://opendoors.transformativeworks.org/contact-open-doors/") %> + +<%= t(".tos_violation.text", contact_abuse_url: new_abuse_report_url) %> + +<%= t(".bye") %> +<% end %> diff --git a/app/views/user_mailer/admin_hidden_work_notification.html.erb b/app/views/user_mailer/admin_hidden_work_notification.html.erb new file mode 100644 index 0000000..fab73ff --- /dev/null +++ b/app/views/user_mailer/admin_hidden_work_notification.html.erb @@ -0,0 +1,22 @@ +<% content_for :message do %> +

    <%= t("mailer.general.greeting.formal.addressed_html", name: style_bold(@user.login)) %>

    + + <% if @works.size == 1 %> +

    <%= t(".hidden_one.html", title: style_creation_link(@works.first.title, @works.first)) %>

    + <% else %> +

    <%= t(".hidden_multiple", count: @works.size) %>

    +
      + <% @works.each do |work| %> +
    • <%= style_creation_link(work.title, work) %>
    • + <% end %> +
    + <% end %> + +

    <%= t(".access", count: @works.size) %>

    + +

    <%= t(".check_email", count: @works.size) %>

    + +

    <%= t(".tos_violation.html", count: @works.size, tos_link: tos_link(t(".tos")), content_policy_link: style_link(t(".content_policy"), content_url), tos_faq_link: style_link(t(".tos_faq"), tos_faq_url(anchor: "policy_procedures_faq"))) %>

    + +

    <%= t(".help.html", count: @works.size, contact_abuse_link: abuse_link(t(".contact_abuse"))) %>

    +<% end %> diff --git a/app/views/user_mailer/admin_hidden_work_notification.text.erb b/app/views/user_mailer/admin_hidden_work_notification.text.erb new file mode 100644 index 0000000..d8c971b --- /dev/null +++ b/app/views/user_mailer/admin_hidden_work_notification.text.erb @@ -0,0 +1,20 @@ +<% content_for :message do %> +<%= t("mailer.general.greeting.formal.addressed_html", name: @user.login) %> + +<% if @works.size == 1 %> +<%= t(".hidden_one.text", title: @works.first.title, work_url: work_url(@works.first)) %> +<% else %> +<%= t(".hidden_multiple", count: @works.size) %> +<% @works.each do |work| %> +<%= t(".work_info", title: work.title, work_url: work_url(work)) %> +<% end %> +<% end %> + +<%= t(".access", count: @works.size) %> + +<%= t(".check_email", count: @works.size) %> + +<%= t(".tos_violation.text", count: @works.size, tos_url: tos_url, content_policy_url: content_url, tos_faq_url: tos_faq_url(anchor: "policy_procedures_faq")) %> + +<%= t(".help.text", count: @works.size, contact_abuse_url: new_abuse_report_url) %> +<% end %> diff --git a/app/views/user_mailer/admin_spam_work_notification.html.erb b/app/views/user_mailer/admin_spam_work_notification.html.erb new file mode 100644 index 0000000..91616c3 --- /dev/null +++ b/app/views/user_mailer/admin_spam_work_notification.html.erb @@ -0,0 +1,9 @@ +<% content_for :message do %> +

    <%= t("mailer.general.greeting.formal.addressed_html", name: style_bold(@user.login)) %>

    + +

    <%= t(".flagged_as_spam.html", work_link: style_creation_link(@work.title, @work)) %>

    + +

    <%= t(".future") %>

    + +

    <%= t(".questions.html", contact_abuse_link: abuse_link(t(".contact_abuse"))) %>

    +<% end %> diff --git a/app/views/user_mailer/admin_spam_work_notification.text.erb b/app/views/user_mailer/admin_spam_work_notification.text.erb new file mode 100644 index 0000000..4b33211 --- /dev/null +++ b/app/views/user_mailer/admin_spam_work_notification.text.erb @@ -0,0 +1,9 @@ +<% content_for :message do %> +<%= t("mailer.general.greeting.formal.addressed_html", name: @user.login) %> + +<%= t(".flagged_as_spam.text", work_title: @work.title, work_url: work_url(@work)) %> + +<%= t(".future") %> + +<%= t(".questions.text", contact_abuse_url: new_abuse_report_url) %> +<% end %> diff --git a/app/views/user_mailer/anonymous_or_unrevealed_notification.html.erb b/app/views/user_mailer/anonymous_or_unrevealed_notification.html.erb new file mode 100644 index 0000000..0d0f582 --- /dev/null +++ b/app/views/user_mailer/anonymous_or_unrevealed_notification.html.erb @@ -0,0 +1,26 @@ +<% content_for :message do %> +

    <%= t("mailer.general.greeting.formal.addressed_html", name: style_bold(@user.login)) %>

    + +

    <%= t(".changed_status.#{@status}.html", + collection_link: style_link(@collection.title, collection_url(@collection)), + work_link: style_creation_link(@work.title, work_url(@work))) %>

    + + <% if @becoming_anonymous && @becoming_unrevealed %> +

    <%= t(".unrevealed_info") %>

    + +

    <%= t(".anonymous_unrevealed_info") %>

    + <% elsif @becoming_anonymous %> +

    <%= t(".anonymous_info") %>

    + <% else %> +

    <%= t(".unrevealed_info") %>

    + <% end %> + +

    <%= t(".do_not_want.#{@status}.html", + collection_items_link: style_link(t(".collection_items_link_text"), + user_collection_items_url(@user, status: "approved"))) %>

    + +

    <%= t(".more_info.html", + faq_link: style_link(t(".faq_link_text"), + archive_faq_url("collections", + anchor: :collectionoptions))) %>

    +<% end %> diff --git a/app/views/user_mailer/anonymous_or_unrevealed_notification.text.erb b/app/views/user_mailer/anonymous_or_unrevealed_notification.text.erb new file mode 100644 index 0000000..5d0545c --- /dev/null +++ b/app/views/user_mailer/anonymous_or_unrevealed_notification.text.erb @@ -0,0 +1,26 @@ +<% content_for :message do %> +<%= t("mailer.general.greeting.formal.addressed_html", name: @user.login) %> + +<%= t(".changed_status.#{@status}.text", + collection_title: @collection.title, + collection_url: collection_url(@collection), + work_title: @work.title, + work_url: work_url(@work)) %> + +<% if @becoming_anonymous && @becoming_unrevealed %> +<%= t(".unrevealed_info") %> + +<%= t(".anonymous_unrevealed_info") %> +<% elsif @becoming_anonymous %> +<%= t(".anonymous_info") %> +<% else %> +<%= t(".unrevealed_info") %> +<% end %> + +<%= t(".do_not_want.#{@status}.text", + collection_items_url: user_collection_items_url(@user, status: "approved")) %> + +<%= t(".more_info.text", + faq_url: archive_faq_url("collections", + anchor: :collectionoptions)) %> +<% end %> diff --git a/app/views/user_mailer/archivist_added_to_collection_notification.html.erb b/app/views/user_mailer/archivist_added_to_collection_notification.html.erb new file mode 100644 index 0000000..cf68aaa --- /dev/null +++ b/app/views/user_mailer/archivist_added_to_collection_notification.html.erb @@ -0,0 +1,16 @@ +<% content_for :message do %> +

    <%= t("mailer.general.greeting.formal.addressed_html", name: style_bold(@user.login)) %>

    + +

    + <%= t(".work_added.html", + collection_link: style_link(@collection.title, collection_url(@collection)), + work_link: style_creation_link(@work.title, work_url(@work))) %> +

    + +

    <%= t(".archivist_notice") %>

    + +

    + <%= t(".removal_instructions.html", + approved_items_link: style_link(t(".approved_collection_items_page"), user_collection_items_url(@user, status: "approved"))) %> +

    +<% end %> diff --git a/app/views/user_mailer/archivist_added_to_collection_notification.text.erb b/app/views/user_mailer/archivist_added_to_collection_notification.text.erb new file mode 100644 index 0000000..4ace282 --- /dev/null +++ b/app/views/user_mailer/archivist_added_to_collection_notification.text.erb @@ -0,0 +1,15 @@ +<% content_for :message do %> +<%= t("mailer.general.greeting.formal.addressed_html", name: @user.login) %> + +<%= t(".work_added.text", + collection_title: @collection.title, + collection_url: collection_url(@collection), + work_title: @work.title, + work_url: work_url(@work)) %> + +<%= t(".archivist_notice") %> + +<%= t(".removal_instructions.text", + approved_items_url: user_collection_items_url(@user, status: "approved")) %> + +<% end %> diff --git a/app/views/user_mailer/batch_subscription_notification.html.erb b/app/views/user_mailer/batch_subscription_notification.html.erb new file mode 100644 index 0000000..b691420 --- /dev/null +++ b/app/views/user_mailer/batch_subscription_notification.html.erb @@ -0,0 +1,77 @@ +<% content_for :message do %> + <% @seen = {} %> + <% @seen_summary = {} %> + <% @creations.each_with_index do |creation, index| %> + + <% this_work = creation.respond_to?(:work) ? creation.work : creation %> + <% work_link = style_link(this_work.title, work_url(this_work)) %> + + <% cache("subscription-email-preface-#{creation.cache_key}") do %> + <%= creator_links(this_work) %> posted a + <% if creation.is_a?(Work) %> + <%= this_work.backdate ? "backdated" : "new" %> work: + <% else %> + new chapter of <%= work_link %> (<%= this_work.word_count %> words): + <% end %> + <% end %> + +

    + + <%= style_bold(creation.is_a?(Work) ? work_link : + style_link(creation.full_chapter_title.html_safe, work_chapter_url(this_work, creation))) %> + (<%= creation.word_count%> words) + + <% unless @seen[this_work.id] %> +
    + + by <%= creator_links(this_work) %> + <% end %> +

    + + <% if creation.summary.present? && creation.is_a?(Chapter) %> +

    <%= style_bold("Chapter Summary:") %>

    + <%= style_quote(raw sanitize_field(creation, :summary)) %> + <% end %> + + <% unless @seen[this_work.id] %> + <% cache("subscription-email-meta-#{creation.cache_key}") do %> +

    + <%= style_bold("Chapters:") %> <%= chapter_total_display(this_work) %> +
    <%= style_bold("Fandom:") %> <%= this_work.fandoms.map{|f| style_link(f.name, fandom_url(f))}.to_sentence.html_safe %> +
    <%= style_bold("Rating:") %> <%= this_work.rating_string %> +
    <%= style_bold("Warnings:") %> <%= this_work.archive_warning_string %> + <% if this_work.relationship_string && !this_work.relationship_string.blank? %> +
    <%= style_bold("Relationships:") %> <%= this_work.relationship_string %> + <% end %> + <% if this_work.character_string && !this_work.character_string.blank? %> +
    <%= style_bold("Characters:") %> <%= this_work.character_string %> + <% end %> + <% if this_work.freeform_string && !this_work.freeform_string.blank? %> +
    <%= style_bold("Additional Tags:") %> <%= this_work.freeform_string %> + <% end %> + <% if this_work.series.count > 0 %> +
    <%= style_bold("Series:") %> <%= series_list_for_feeds(this_work).html_safe %> + <% end %> +

    + <% end %> + + <% @seen[this_work.id] = true %> + <% end %> + + <% unless @seen_summary[this_work.id] || this_work.summary.blank? %> +

    <%= style_bold("Summary:") %>

    + <%= style_quote(raw sanitize_field(this_work, :summary)) %> + <% @seen_summary[this_work.id] = true %> + <% end %> + + <% if (index < @creations.length-1) %> + <%= styled_divider %> + <% end %> + + <% end %> +<% end %> + + +<% content_for :footer_note do %> + You're receiving this email because you've subscribed to <%= style_footer_link(@subscription.name, polymorphic_url(@subscription.subscribable)) %>. Follow the link to unsubscribe if you no longer wish to receive updates. +<% end %> diff --git a/app/views/user_mailer/batch_subscription_notification.text.erb b/app/views/user_mailer/batch_subscription_notification.text.erb new file mode 100644 index 0000000..fe090aa --- /dev/null +++ b/app/views/user_mailer/batch_subscription_notification.text.erb @@ -0,0 +1,40 @@ +<% content_for :message do %> +<% @seen = {} %> +<% @seen_summary = {} %> +<% @creations.each_with_index do |creation, index| %> +<% this_work = creation.respond_to?(:work) ? creation.work : creation %> +<% cache "subscription-email-txt-preface-#{creation.cache_key}" do %> +<%= creator_text(this_work) %> posted a <% if creation.is_a?(Work) then %><%= this_work.backdate ? "backdated" : "new" %> work:<% else %>new chapter of <%= this_work.title %> (<%= this_work.word_count %> words):<% end %> +<%= creation.is_a?(Work) ? work_url(this_work) : work_chapter_url(this_work, creation) %> +<% end %> + +<%= creation.is_a?(Work) ? this_work.title : creation.full_chapter_title.html_safe %> (<%= creation.word_count%> words)<% unless @seen[this_work.id] %> +by <%= creator_text(this_work) %><% end %> + +<% if !creation.summary.blank? && creation.is_a?(Chapter) %> +Chapter Summary: <%= raw to_plain_text(sanitize_field(creation, :summary)) %> +<% end %> + +<% unless @seen[this_work.id] %> +<% cache "subscription-email-txt-meta-#{creation.cache_key}" do %> +Chapters: <%= chapter_total_display(this_work) %> +Fandom: <%= this_work.fandoms.map{|f| f.name}.to_sentence.html_safe %> +Rating: <%= this_work.rating_string %> +Warnings: <%= this_work.archive_warning_string %> +<% if this_work.relationship_string && !this_work.relationship_string.blank? then %>Relationships: <%= this_work.relationship_string %><% end %> +<% if this_work.character_string && !this_work.character_string.blank? then %>Characters: <%= this_work.character_string %><% end %> +<% if this_work.freeform_string && !this_work.freeform_string.blank? %>Additional Tags: <%= this_work.freeform_string %><% end %> +<% if this_work.series.count > 0 %>Series: <%=raw to_plain_text(series_list_for_feeds(this_work)) %><% end %> +<% end %><% @seen[this_work.id] = true %><% end %> + +<% unless @seen_summary[this_work.id] || this_work.summary.blank? %> +Summary: + <%= raw to_plain_text(sanitize_field(this_work, :summary)) %><% @seen_summary[this_work.id] = true %><% end %><% if (index < @creations.length-1) %> + +<%= text_divider %> + +<% end %><% end %><% end %> + +<% content_for :footer_note do %> +You're receiving this email because you've subscribed to <%= @subscription.name %>. Follow the link to unsubscribe if you no longer wish to receive updates: <%= polymorphic_url(@subscription.subscribable) %> +<% end %> diff --git a/app/views/user_mailer/challenge_assignment_notification.html.erb b/app/views/user_mailer/challenge_assignment_notification.html.erb new file mode 100644 index 0000000..f00e34c --- /dev/null +++ b/app/views/user_mailer/challenge_assignment_notification.html.erb @@ -0,0 +1,93 @@ +<% content_for :message do %> +

    <%= t(".assignment.html", link: style_link(@collection.title, collection_url(@collection))) %>

    + +

    + <%= style_metadata_label(t(".recipient")) %><%= @request.nil? ? t(".recipient_missing") : style_link(@request.pseud.byline, user_pseud_url(@request.pseud.user, @request.pseud)) %> +

    + + <%= style_bold(t(".prompts")) %> + + <% @request.requests.each_with_index do |prompt, index| %> + <% tag_groups = prompt.tag_groups %> + + <% def styled_tag_list(tags) %> + <% return nil if !tags || tags.empty? %> + <% to_sentence(tags.map { |tag| style_link(tag.name, tag_works_url(tag)) }) %> + <% end %> + + <% fandoms = prompt.any_fandom ? t(".any") : styled_tag_list(tag_groups["Fandom"]) %> + <% chars = prompt.any_character ? t(".any") : styled_tag_list(tag_groups["Character"]) %> + <% ships = prompt.any_relationship ? t(".any") : styled_tag_list(tag_groups["Relationship"]) %> + <% ratings = prompt.any_rating ? t(".any") : (tag_groups["Rating"] ? get_title_string(tag_groups["Rating"]) : nil) %> + <% warnings = prompt.any_archive_warning ? t(".any") : (tag_groups["ArchiveWarning"] ? get_title_string(tag_groups["ArchiveWarning"]) : nil) %> + <% categories = prompt.any_category ? t(".any") : (tag_groups["Category"] ? get_title_string(tag_groups["Category"]) : nil) %> + <% atags = prompt.any_freeform ? t(".any") : styled_tag_list(tag_groups["Freeform"]) %> + <% otags = prompt.optional_tag_set ? styled_tag_list(prompt.optional_tag_set.tags) : nil %> + + <%= styled_divider %> + <%= index + 1 %>. <%= style_bold(prompt.title) %> + +

    + <% if fandoms %> + <%= style_metadata_label(t("activerecord.models.fandom", count: prompt.any_fandom ? 1 : tag_groups["Fandom"].count)) %><%= fandoms %> +
    + <% end %> + <% if chars %> + <%= style_metadata_label(t("activerecord.models.character", count: prompt.any_character ? 1 : tag_groups["Character"].count)) %><%= chars %> +
    + <% end %> + <% if ships %> + <%= style_metadata_label(t("activerecord.models.relationship", count: prompt.any_relationship ? 1 : tag_groups["Relationship"].count)) %><%= ships %> +
    + <% end %> + <% if ratings %> + <%= style_metadata_label(t("activerecord.models.rating", count: prompt.any_rating ? 1 : tag_groups["Rating"].count)) %><%= ratings %> +
    + <% end %> + <% if warnings %> + <%= style_metadata_label(t("activerecord.models.archive_warning", count: prompt.any_archive_warning ? 1 : tag_groups["ArchiveWarning"].count)) %><%= warnings %> +
    + <% end %> + <% if categories %> + <%= style_metadata_label(t("activerecord.models.category", count: prompt.any_category ? 1 : tag_groups["Category"].count)) %><%= categories %> +
    + <% end %> + <% if atags %> + <%= style_metadata_label(t("activerecord.models.freeform", count: prompt.any_freeform ? 1 : tag_groups["Freeform"].count)) %><%= atags %> +
    + <% end %> + <% if otags %> + <%= style_metadata_label(t(".optional_tags")) %><%= otags %> +
    + <% end %> + <% if prompt.url && !prompt.url.blank? %> + <%= style_metadata_label(t(".prompt_url")) %><%= style_link(prompt.url, prompt.url) %> +
    + <% end %> + <% if prompt.description && !prompt.description.blank? %> + <%= style_metadata_label(t(".description")) %> + <%= style_quote(prompt.description) %> + <% end %> +

    + <% end %> + + <%= styled_divider %> + +

    + <%= style_metadata_label(t(".due")) %><%= time_in_zone(@collection.challenge.assignments_due_at, (@collection.challenge.time_zone || Time.zone.name), @assigned_user) %>. +

    + +

    <%= t(".look_up.html", your_assignments_link: style_link(t(".look_up.your_assignments"), user_assignments_url(@assigned_user))) %>

    + + <% if @collection && !@collection.assignment_notification.blank? %> +

    <%= escape_html_and_create_linebreaks(@collection.assignment_notification) %>

    + <% end %> +<% end %> + +<% content_for :footer_note do %> + <%= t(".footer.html", title: style_footer_link(@collection.title, collection_url(@collection)), challenge_profile_link: style_footer_link(t(".footer.challenge_profile"), collection_profile_url(@collection))) %> +<% end %> + +<% content_for :sent_at do %> + <%= l(@assignment.sent_at) %> +<% end %> diff --git a/app/views/user_mailer/challenge_assignment_notification.text.erb b/app/views/user_mailer/challenge_assignment_notification.text.erb new file mode 100644 index 0000000..2149651 --- /dev/null +++ b/app/views/user_mailer/challenge_assignment_notification.text.erb @@ -0,0 +1,71 @@ +<% content_for :message do %> +<%= t(".assignment.text", collection_title: @collection.title, collection_url: collection_url(@collection)) %> + +<%= metadata_label(t(".recipient")) %><%= @request.nil? ? t(".recipient_missing") : text_pseud(@request.pseud) %> + +<%= t(".prompts") %> +<% @request.requests.each_with_index do |prompt, index| %> +<% tag_groups = prompt.tag_groups %> +<% def tag_list(tags) %> +<% return nil if !tags || tags.empty? %> +<% tags.map { |tag| tag.name }.to_sentence.html_safe %> +<% end %> +<% fandoms = prompt.any_fandom ? t(".any") : tag_list(tag_groups["Fandom"]) %> +<% chars = prompt.any_character ? t(".any") : tag_list(tag_groups["Character"]) %> +<% ships = prompt.any_relationship ? t(".any") : tag_list(tag_groups["Relationship"]) %> +<% ratings = prompt.any_rating ? t(".any") : (tag_groups["Rating"] ? get_title_string(tag_groups["Rating"]) : nil) %> +<% warnings = prompt.any_archive_warning ? t(".any") : (tag_groups["ArchiveWarning"] ? get_title_string(tag_groups["ArchiveWarning"]) : nil) %> +<% categories = prompt.any_category ? t(".any") : (tag_groups["Category"] ? get_title_string(tag_groups["Category"]) : nil) %> +<% atags = prompt.any_freeform ? t(".any") : tag_list(tag_groups["Freeform"]) %> +<% otags = prompt.optional_tag_set ? tag_list(prompt.optional_tag_set.tags) : nil %> +<%= text_divider %> + +<%= index + 1 %>. <%= prompt.title %> + +<% if fandoms %> +<%= metadata_label(t("activerecord.models.fandom", count: prompt.any_fandom ? 1 : tag_groups["Fandom"].count)) %><%= fandoms %> +<% end %> +<% if chars %> +<%= metadata_label(t("activerecord.models.character", count: prompt.any_character ? 1 : tag_groups["Character"].count)) %><%= chars %> +<% end %> +<% if ships %> +<%= metadata_label(t("activerecord.models.relationship", count: prompt.any_relationship ? 1 : tag_groups["Relationship"].count)) %><%= ships %> +<% end %> +<% if ratings %> +<%= metadata_label(t("activerecord.models.rating", count: prompt.any_rating ? 1 : tag_groups["Rating"].count)) %><%= ratings %> +<% end %> +<% if warnings %> +<%= metadata_label(t("activerecord.models.archive_warning", count: prompt.any_archive_warning ? 1 : tag_groups["ArchiveWarning"].count)) %><%= warnings %> +<% end %> +<% if categories %> +<%= metadata_label(t("activerecord.models.category", count: prompt.any_category ? 1 : tag_groups["Category"].count)) %><%= categories %> +<% end %> +<% if atags %> +<%= metadata_label(t("activerecord.models.freeform", count: prompt.any_freeform ? 1 : tag_groups["Freeform"].count)) %><%= atags %> +<% end %> +<% if otags %> +<%= metadata_label(t(".optional_tags")) %><%= otags %> +<% end %> +<% if prompt.url && !prompt.url.blank? %> +<%= metadata_label(t(".prompt_url")) %><%= prompt.url %> +<% end %> +<% if prompt.description && !prompt.description.blank? %> +<%= metadata_label(t(".description")) %> + <%= to_plain_text(prompt.description) %> +<% end %> + +<% end %><%= text_divider %> + +<%= metadata_label(t(".due")) %><%= to_plain_text(time_in_zone(@collection.challenge.assignments_due_at, (@collection.challenge.time_zone || Time.zone.name), @assigned_user)).gsub(/\n\s*/, "") %>. + +<%= t(".look_up.text", your_assignments_url: user_assignments_url(@assigned_user)) %> +<% if @collection && !@collection.assignment_notification.blank? %> + + +<%= @collection.assignment_notification %><% end %><% end %> +<% content_for :footer_note do %> +<%= t(".footer.text", title: @collection.title, url: collection_url(@collection), challenge_profile_url: collection_profile_url(@collection)) -%> +<% end %> +<% content_for :sent_at do %> +<%= l(@assignment.sent_at) -%> +<% end %> diff --git a/app/views/user_mailer/change_email.html.erb b/app/views/user_mailer/change_email.html.erb new file mode 100644 index 0000000..f95039b --- /dev/null +++ b/app/views/user_mailer/change_email.html.erb @@ -0,0 +1,14 @@ +<% content_for :message do %> +

    <%= t("mailer.general.greeting.formal.addressed_html", name: style_bold(@user.login)) %>

    + +

    <%= t(".made_request", app_name: ArchiveConfig.APP_SHORT_NAME) %>

    + +

    <%= t(".confirm_change.html", unconfirmed_email: style_email(@new_email), count: User.confirm_within.in_days.to_i, contact_support_link: style_link(t(".contact_support"), new_feedback_report_url)) %>

    + +

    <%= t(".wrong_email.html", email_change_form_link: style_link(t(".email_change_form"), change_email_user_url(@user))) %>

    + +

    <%= t(".not_made_request.html", + reset_password_link: style_link(t(".reset_password"), new_user_password_url), + contact_policy_abuse_link: style_link(t(".contact_policy_abuse"), new_abuse_report_url), + app_name: ArchiveConfig.APP_SHORT_NAME) %>

    +<% end %> diff --git a/app/views/user_mailer/change_email.text.erb b/app/views/user_mailer/change_email.text.erb new file mode 100644 index 0000000..0f64121 --- /dev/null +++ b/app/views/user_mailer/change_email.text.erb @@ -0,0 +1,14 @@ +<% content_for :message do %> +<%= t("mailer.general.greeting.formal.addressed_html", name: @user.login) %> + +<%= t(".made_request", app_name: ArchiveConfig.APP_SHORT_NAME) %> + +<%= t(".confirm_change.text", unconfirmed_email: @new_email, count: User.confirm_within.in_days.to_i, contact_support_url: new_feedback_report_url) %> + +<%= t(".wrong_email.text", email_change_form_url: change_email_user_url(@user)) %> + +<%= t(".not_made_request.text", + reset_password_url: new_user_password_url, + contact_policy_abuse_url: new_abuse_report_url, + app_name: ArchiveConfig.APP_SHORT_NAME) %> +<% end %> diff --git a/app/views/user_mailer/change_username.html.erb b/app/views/user_mailer/change_username.html.erb new file mode 100644 index 0000000..848a0be --- /dev/null +++ b/app/views/user_mailer/change_username.html.erb @@ -0,0 +1,22 @@ +<% content_for :message do %> +

    <%= t("mailer.general.greeting.formal.unaddressed") %>

    + +

    <%= t(".changed_html", + app_name: ArchiveConfig.APP_SHORT_NAME, + old_username: style_bold(@old_username), + new_username: style_bold(@new_username)) %>

    + +

    <%= t(".effects.html", + refer_faq_link: style_link(t(".refer_faq"), archive_faq_url("your-account", anchor: "changenameaffect")), + contact_support_link: support_link(t(".contact_support"))) %>

    + +

    <%= t(".change_cooldown_html", + app_name: ArchiveConfig.APP_SHORT_NAME, + count: ArchiveConfig.USER_RENAME_LIMIT_DAYS, + date: l(@next_change_time)) %>

    + +

    <%= t(".did_not_make_change.html", + reset_password_link: style_link(t(".reset_password"), new_user_password_url), + contact_policy_abuse_link: abuse_link(t(".contact_policy_abuse")), + app_name: ArchiveConfig.APP_SHORT_NAME) %>

    +<% end %> diff --git a/app/views/user_mailer/change_username.text.erb b/app/views/user_mailer/change_username.text.erb new file mode 100644 index 0000000..af00455 --- /dev/null +++ b/app/views/user_mailer/change_username.text.erb @@ -0,0 +1,22 @@ +<% content_for :message do %> +<%= t("mailer.general.greeting.formal.unaddressed") %> + +<%= t(".changed_html", + app_name: ArchiveConfig.APP_SHORT_NAME, + old_username: @old_username, + new_username: @new_username) %> + +<%= t(".effects.text", + refer_faq_url: archive_faq_url("your-account", anchor: "changenameaffect"), + contact_support_url: new_feedback_report_url) %> + +<%= t(".change_cooldown_html", + app_name: ArchiveConfig.APP_SHORT_NAME, + count: ArchiveConfig.USER_RENAME_LIMIT_DAYS, + date: l(@next_change_time)) %> + +<%= t(".did_not_make_change.text", + reset_password_url: new_user_password_url, + contact_policy_abuse_url: new_abuse_report_url, + app_name: ArchiveConfig.APP_SHORT_NAME) %> +<% end %> diff --git a/app/views/user_mailer/claim_notification.html.erb b/app/views/user_mailer/claim_notification.html.erb new file mode 100644 index 0000000..9657568 --- /dev/null +++ b/app/views/user_mailer/claim_notification.html.erb @@ -0,0 +1,41 @@ +<% content_for :message do %> +

    + <%= t("mailer.general.greeting.introductory") %> +

    + +

    <%= t(".introduction.html", open_doors_name_link: style_link(t(".introduction.open_doors_name"), "https://opendoors.transformativeworks.org"), app_link: style_link(t(".introduction.ao3_name"), root_url)) %>

    + +

    <%= t(".more_info.html", ao3_news_link: style_link(t(".more_info.ao3_news"), admin_posts_url(tag: 18)), faq_page_link: style_link(t(".more_info.faq_page"), "https://opendoors.transformativeworks.org/faq"), tutorial_page_link: style_link(t(".more_info.tutorial_page"), "https://opendoors.transformativeworks.org/tutorials"), contact_support_link: support_link(t(".more_info.contact_support"))) %>

    + +

    <%= t(".mistake.html", contact_open_doors_link: opendoors_link(t(".mistake.contact_open_doors"))) %>

    + +

    <%= t(".access.html", contact_support_link: support_link(t(".access.contact_support"))) %>

    + +

    <%= t(".works_by", email: @external_email) %>

    + +
      + <% @claimed_works.each do |work| %> +
    • + <% if work.fandom_string.present? %> + <%= t(".work_info.html", work_link: style_link(work.title, work_url(work)), fandom: work.fandom_string.split(",").to_sentence) %> + <% else %> + <%= style_link(work.title, work_url(work)) %> + <% end %> +
    • + <% end %> +
    + +

    <%= t(".redirects.html", negation: style_bold(t(".redirects.negation"))) %>

    + +

    <%= t(".update_redirect.html", contact_open_doors_link: opendoors_link(t(".update_redirect.contact_open_doors"))) %>

    + +

    <%= t(".other_works.html", contact_open_doors_link: opendoors_link(t(".other_works.contact_open_doors"))) %>

    + +

    <%= t(".questions.html", contact_support_link: support_link(t(".questions.contact_support"))) %>

    + +

    <%= t(".email_tips") %>

    + +

    <%= t("mailer.general.closing.informal") %>
    + <%= style_bold(t("mailer.general.signature.open_doors")) %>
    + <%= style_bold(t("mailer.general.signature.parent_org")) %>

    +<% end %> diff --git a/app/views/user_mailer/claim_notification.text.erb b/app/views/user_mailer/claim_notification.text.erb new file mode 100644 index 0000000..bb876ab --- /dev/null +++ b/app/views/user_mailer/claim_notification.text.erb @@ -0,0 +1,34 @@ +<% content_for :message do %> +<%= t("mailer.general.greeting.introductory") %> + +<%= t(".introduction.text", open_doors_url: "https://opendoors.transformativeworks.org", app_url: root_url) %> + +<%= t(".more_info.text", news_url: admin_posts_url(tag: 18), open_doors_faq_url: "https://opendoors.transformativeworks.org/faq", open_doors_tutorial_url: "https://opendoors.transformativeworks.org/tutorials", support_url: new_feedback_report_url) %> + +<%= t(".mistake.text", open_doors_url: "https://opendoors.transformativeworks.org/en/contact-open-doors/") %> + +<%= t(".access.text", support_url: new_feedback_report_url) %> + +<%= t(".works_by", email: @external_email) %> +<% @claimed_works.each do |work| %> + <% if work.fandom_string.present? %> +<%= t(".work_info.text.with_fandom", work_title: work.title, work_url: work_url(work), fandom: work.fandom_string.split(",").to_sentence) %> + <% else %> +<%= t(".work_info.text.no_fandom", work_title: work.title, work_url: work_url(work)) %> + <% end %> +<% end %> + +<%= t(".redirects.html", negation: t(".redirects.negation")) %> + +<%= t(".update_redirect.text", open_doors_url: "https://opendoors.transformativeworks.org/en/contact-open-doors/") %> + +<%= t(".other_works.text") %> + +<%= t(".questions.text", support_url: new_feedback_report_url) %> + +<%= t(".email_tips") %> + +<%= t("mailer.general.closing.informal") %> +<%= t("mailer.general.signature.open_doors") %> +<%= t("mailer.general.signature.parent_org") %> +<% end %> diff --git a/app/views/user_mailer/collection_notification.html.erb b/app/views/user_mailer/collection_notification.html.erb new file mode 100644 index 0000000..d8b6a9f --- /dev/null +++ b/app/views/user_mailer/collection_notification.html.erb @@ -0,0 +1,12 @@ +<% content_for :message do %> + +

    + <%= t(".html.received_message", collection_link: style_link(@collection.title, collection_url(@collection))).html_safe %> +

    + + <%= style_quote(raw @message) %> +<% end %> + +<% content_for :sent_at do %> + <%= l(Time.current) %> +<% end %> diff --git a/app/views/user_mailer/collection_notification.text.erb b/app/views/user_mailer/collection_notification.text.erb new file mode 100644 index 0000000..f8624bb --- /dev/null +++ b/app/views/user_mailer/collection_notification.text.erb @@ -0,0 +1,8 @@ +<% content_for :message do %> +<%= t(".text.received_message", collection_title: @collection.title, collection_url: collection_url(@collection)) %> +<%= text_divider %> + +<%= raw @message %> + +<% end %> +<% content_for :sent_at do %><%= l(Time.current) %><% end %> diff --git a/app/views/user_mailer/creatorship_notification.html.erb b/app/views/user_mailer/creatorship_notification.html.erb new file mode 100644 index 0000000..1f6fcbf --- /dev/null +++ b/app/views/user_mailer/creatorship_notification.html.erb @@ -0,0 +1,26 @@ +<% creation_type = @creation.class.name.underscore %> +<% content_for :message do %> +

    + <%# i18n-tasks-use t('user_mailer.creatorship_notification.intro_chapter') + i18n-tasks-use t('user_mailer.creatorship_notification.intro_series')-%> + <%= t(".intro_#{creation_type}", adding_user: @adding_user.login, pseud: @creatorship.pseud.name) %> +

    + +

    + <%= t(".html.creation", + creation_link: "#{style_link(creation_title(@creation), polymorphic_url(@creation))}".html_safe, + pseud_links: @creation.pseuds.map{ |p| style_pseud_link(p) }.to_sentence.html_safe).html_safe %> +

    + +

    + <%= t ".explanation" %> +

    + +

    + <%# i18n-tasks-use t('user_mailer.creatorship_notification.html.remove_chapter') + i18n-tasks-use t('user_mailer.creatorship_notification.html.remove_series') + i18n-tasks-use t('user_mailer.creatorship_notification.html.edit_chapter') + i18n-tasks-use t('user_mailer.creatorship_notification.html.edit_series')-%> + <%= t(".html.remove_#{creation_type}", "edit_#{creation_type}_link": style_link(t(".html.edit_#{creation_type}"), edit_polymorphic_url(@creation))).html_safe %> +

    +<% end %> diff --git a/app/views/user_mailer/creatorship_notification.text.erb b/app/views/user_mailer/creatorship_notification.text.erb new file mode 100644 index 0000000..153cd53 --- /dev/null +++ b/app/views/user_mailer/creatorship_notification.text.erb @@ -0,0 +1,16 @@ +<% creation_type = @creation.class.name.underscore %> +<% content_for :message do %> +<%# i18n-tasks-use t('user_mailer.creatorship_notification.intro_chapter') + i18n-tasks-use t('user_mailer.creatorship_notification.intro_series')-%> +<%= t(".intro_#{creation_type}", adding_user: @adding_user.login, pseud: @creatorship.pseud.name) %> + +<%= t ".text.creation", title: creation_title(@creation), + url: polymorphic_url(@creation), + pseuds: @creation.pseuds.map(&:byline).to_sentence %> + +<%= t ".explanation" %> + +<%# i18n-tasks-use t('user_mailer.creatorship_notification.text.remove_chapter') + i18n-tasks-use t('user_mailer.creatorship_notification.text.remove_series')-%> +<%= t(".text.remove_#{creation_type}", url: edit_polymorphic_url(@creation)) %> +<% end %> diff --git a/app/views/user_mailer/creatorship_notification_archivist.html.erb b/app/views/user_mailer/creatorship_notification_archivist.html.erb new file mode 100644 index 0000000..9868d6a --- /dev/null +++ b/app/views/user_mailer/creatorship_notification_archivist.html.erb @@ -0,0 +1,27 @@ +<% creation_type = @creation.class.name.underscore %> +<% content_for :message do %> +

    + <%# i18n-tasks-use t('user_mailer.creatorship_notification_archivist.intro_chapter') + i18n-tasks-use t('user_mailer.creatorship_notification_archivist.intro_series') + i18n-tasks-use t('user_mailer.creatorship_notification_archivist.intro_work')-%> + <%= t(".intro_#{creation_type}", archivist: @archivist.login, pseud: @creatorship.pseud.name) %> +

    + +

    + <%= t(".html.creation", + creation_link: "#{style_link(creation_title(@creation), polymorphic_url(@creation))}".html_safe, + pseud_links: @creation.pseuds.map{ |p| style_pseud_link(p) }.to_sentence.html_safe).html_safe %> +

    + +

    <%= t(".explanation") %>

    + +

    + <%# i18n-tasks-use t('user_mailer.creatorship_notification_archivist.html.remove_chapter') + i18n-tasks-use t('user_mailer.creatorship_notification_archivist.html.remove_series') + i18n-tasks-use t('user_mailer.creatorship_notification_archivist.html.remove_work') + i18n-tasks-use t('user_mailer.creatorship_notification_archivist.html.edit_chapter') + i18n-tasks-use t('user_mailer.creatorship_notification_archivist.html.edit_series') + i18n-tasks-use t('user_mailer.creatorship_notification_archivist.html.edit_work')-%> + <%= t(".html.remove_#{creation_type}", "edit_#{creation_type}_link": style_link(t(".html.edit_#{creation_type}"), edit_polymorphic_url(@creation))).html_safe %> +

    +<% end %> diff --git a/app/views/user_mailer/creatorship_notification_archivist.text.erb b/app/views/user_mailer/creatorship_notification_archivist.text.erb new file mode 100644 index 0000000..4054525 --- /dev/null +++ b/app/views/user_mailer/creatorship_notification_archivist.text.erb @@ -0,0 +1,18 @@ +<% creation_type = @creation.class.name.underscore %> +<% content_for :message do %> +<%# i18n-tasks-use t('user_mailer.creatorship_notification_archivist.intro_chapter') + i18n-tasks-use t('user_mailer.creatorship_notification_archivist.intro_series') + i18n-tasks-use t('user_mailer.creatorship_notification_archivist.intro_work')-%> +<%= t(".intro_#{creation_type}", archivist: @archivist.login, pseud: @creatorship.pseud.name) %> + +<%= t ".text.creation", title: creation_title(@creation), + url: polymorphic_url(@creation), + pseuds: @creation.pseuds.map(&:byline).to_sentence %> + +<%= t(".explanation") %> + +<%# i18n-tasks-use t('user_mailer.creatorship_notification_archivist.text.remove_chapter') + i18n-tasks-use t('user_mailer.creatorship_notification_archivist.text.remove_series') + i18n-tasks-use t('user_mailer.creatorship_notification_archivist.text.remove_work')-%> +<%= t(".text.remove_#{creation_type}", url: edit_polymorphic_url(@creation)) %> +<% end %> diff --git a/app/views/user_mailer/creatorship_request.html.erb b/app/views/user_mailer/creatorship_request.html.erb new file mode 100644 index 0000000..d29cd8e --- /dev/null +++ b/app/views/user_mailer/creatorship_request.html.erb @@ -0,0 +1,20 @@ +<% creation_type = @creation.class.name.underscore %> +<% content_for :message do %> +

    + <%# i18n-tasks-use t('user_mailer.creatorship_request.intro_chapter') + i18n-tasks-use t('user_mailer.creatorship_request.intro_series') + i18n-tasks-use t('user_mailer.creatorship_request.intro_work')-%> + <%= t(".intro_#{creation_type}", inviting_user: @inviting_user.login, pseud: @creatorship.pseud.name) %> +

    + +

    + <%= t(".html.creation", + creation_link: "#{style_link(creation_title(@creation), polymorphic_url(@creation))}".html_safe, + pseud_links: @creation.pseuds.map{ |p| style_pseud_link(p) }.to_sentence.html_safe).html_safe %> +

    + +

    + <%= t(".html.instructions", + page_name: style_link(t(".html.page_name"), user_creatorships_url(@user))).html_safe %> +

    +<% end %> diff --git a/app/views/user_mailer/creatorship_request.text.erb b/app/views/user_mailer/creatorship_request.text.erb new file mode 100644 index 0000000..1e03e97 --- /dev/null +++ b/app/views/user_mailer/creatorship_request.text.erb @@ -0,0 +1,14 @@ +<% creation_type = @creation.class.name.underscore %> +<% content_for :message do %> +<%# i18n-tasks-use t('user_mailer.creatorship_request.intro_chapter') + i18n-tasks-use t('user_mailer.creatorship_request.intro_series') + i18n-tasks-use t('user_mailer.creatorship_request.intro_work')-%> +<%= t(".intro_#{creation_type}", inviting_user: @inviting_user.login, pseud: @creatorship.pseud.name) %> + +<%= t(".text.creation", + title: creation_title(@creation), + url: polymorphic_url(@creation), + pseuds: @creation.pseuds.map(&:byline).to_sentence) %> + +<%= t(".text.instructions", url: user_creatorships_url(@user)) %> +<% end %> diff --git a/app/views/user_mailer/delete_signup_notification.text.erb b/app/views/user_mailer/delete_signup_notification.text.erb new file mode 100644 index 0000000..e69de29 diff --git a/app/views/user_mailer/delete_work_notification.html.erb b/app/views/user_mailer/delete_work_notification.html.erb new file mode 100644 index 0000000..fe54199 --- /dev/null +++ b/app/views/user_mailer/delete_work_notification.html.erb @@ -0,0 +1,10 @@ +<% content_for :message do %> +

    <%= t("mailer.general.greeting.formal.addressed_html", name: style_bold(@user.login)) %>

    + <% if @user == @deleter || !@deleter %> +

    <%= t(".deleted_yourself.html", title: style_creation_title(@work.title)) %>

    + <% else %> +

    <%= t(".deleted_other.html", title: style_creation_title(@work.title), pseud: style_pseud_link(@deleter.default_pseud)) %>

    + <% end %> +

    <%= t(".questions.html", support: support_link(t(".support"))) %>

    +

    <%= t(".attachment") %>

    +<% end %> diff --git a/app/views/user_mailer/delete_work_notification.text.erb b/app/views/user_mailer/delete_work_notification.text.erb new file mode 100644 index 0000000..d411cf9 --- /dev/null +++ b/app/views/user_mailer/delete_work_notification.text.erb @@ -0,0 +1,13 @@ +<% content_for :message do %> +<%= t("mailer.general.greeting.formal.addressed_html", name: @user.login) %> + +<% if @user == @deleter || !@deleter %> +<%= t(".deleted_yourself.text", title: @work.title) %> +<% else %> +<%= t(".deleted_other.text", title: @work.title, pseud: text_pseud(@deleter.default_pseud)) %> +<% end %> + +<%= t(".questions.text", support: t(".support"), url: new_feedback_report_url) %> + +<%= t(".attachment") %> +<% end %> diff --git a/app/views/user_mailer/feedback.html.erb b/app/views/user_mailer/feedback.html.erb new file mode 100644 index 0000000..1c2ec1d --- /dev/null +++ b/app/views/user_mailer/feedback.html.erb @@ -0,0 +1,24 @@ +<% content_for :message do %> +

    + <% if @username.present? %> + <%= t("mailer.general.greeting.informal.addressed_html", name: style_bold(@username)) %> + <% else %> + <%= t("mailer.general.greeting.informal.unaddressed") %> + <% end %> +

    + +

    <%= t(".introduction") %>

    + + <%= style_quote("#{raw(strip_images(@summary))} #{raw(strip_images(@comment, keep_src: true))}") %> + +

    <%= t(".additional_ticket") %>

    + +

    + <%= t("mailer.general.closing.formal") %>
    + <%= style_bold(t("mailer.general.signature.support")) %> +

    +<% end %> + +<% content_for :sent_at do %> + <%= l(Time.current) %> +<% end %> diff --git a/app/views/user_mailer/feedback.text.erb b/app/views/user_mailer/feedback.text.erb new file mode 100644 index 0000000..90fbe28 --- /dev/null +++ b/app/views/user_mailer/feedback.text.erb @@ -0,0 +1,24 @@ +<% content_for :message do %> + +<% if @username.present? %> +<%= t("mailer.general.greeting.informal.addressed_html", name: @username) %> +<% else %> +<%= t("mailer.general.greeting.informal.unaddressed") %> +<% end %> + +<%= t(".introduction") %> + +<%= text_divider %> + +* <%= to_plain_text(raw @summary) %> * +<%= to_plain_text(raw(strip_images(@comment, keep_src: true))) %> + +<%= text_divider %> + +<%= t(".additional_ticket") %> + +<%= t("mailer.general.closing.formal") %> +<%= t("mailer.general.signature.support") %> +<% end %> + +<% content_for :sent_at do %><%= l(Time.current) %><% end %> diff --git a/app/views/user_mailer/invalid_signup_notification.html.erb b/app/views/user_mailer/invalid_signup_notification.html.erb new file mode 100644 index 0000000..1809071 --- /dev/null +++ b/app/views/user_mailer/invalid_signup_notification.html.erb @@ -0,0 +1,19 @@ +<% content_for :message do %> +

    <%= t(".found_invalid.html", collection_link: style_link(@collection.title, collection_url(@collection))) %>

    + +

    <%= t(".see_details.html", challenge_matching_help_link: style_link(t(".see_details.challenge_matching_help"), "#{root_url}help/challenge-matching.html#invalid_signups")) %>

    + +

    <%= t(".signups_here") %> +

      + <% @invalid_signups.each do |signup_id| %> +
    • <%= style_link(t(".signup", signup_id: signup_id), collection_signup_url(@collection, signup_id)) %>
    • + <% end %> +
    +

    +<% 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/invalid_signup_notification.text.erb b/app/views/user_mailer/invalid_signup_notification.text.erb new file mode 100644 index 0000000..9782118 --- /dev/null +++ b/app/views/user_mailer/invalid_signup_notification.text.erb @@ -0,0 +1,16 @@ +<% content_for :message do %> +<%= t(".found_invalid.text", collection_title: @collection.title, collection_url: collection_url(@collection)) %> + +<%= t(".see_details.text", challenge_matching_help_url: "#{root_url}help/challenge-matching.html#invalid_signups") %> + +<%= t(".signups_here") %> +<% @invalid_signups.each do |signup_id| %> +- <%= collection_signup_url(@collection, signup_id) %> +<% end %> +<% 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/invitation.html.erb b/app/views/user_mailer/invitation.html.erb new file mode 100644 index 0000000..6ba70b0 --- /dev/null +++ b/app/views/user_mailer/invitation.html.erb @@ -0,0 +1,29 @@ +<% content_for :message do %> +

    + <% if !@user_name.blank? %> + <%= t(".has_invited", user_name: style_bold(@user_name)).html_safe %> + <% else %> + <%= t ".been_invited" %> + <% end %> +

    + +

    + <%= t(".html.about", otw_link: style_link(t(".html.otw_link_text"), "https://www.transformativeworks.org")).html_safe %> +

    + +

    + <%= t ".features" %> +

    + +

    + <%= t(".html.join", invitation_link: style_link(t(".html.invitation_link_text"), signup_url(invitation_token: @invitation.token))).html_safe %> +

    + +

    + <%= t(".html.activation_support", support_link: style_link(t(".html.support_link_text"), new_feedback_report_url)).html_safe %> +

    + +

    + <%= t(".html.faq", faq_link: style_link(t(".html.faq_link_text"), archive_faqs_url)).html_safe %> +

    +<% end %> diff --git a/app/views/user_mailer/invitation.text.erb b/app/views/user_mailer/invitation.text.erb new file mode 100644 index 0000000..00fedc6 --- /dev/null +++ b/app/views/user_mailer/invitation.text.erb @@ -0,0 +1,17 @@ +<% content_for :message do %> +<% if !@user_name.blank? %> +<%= t ".has_invited", user_name: @user_name %> +<% else %> +<%= t ".been_invited" %> +<% end %> + +<%= t ".text.about", otw_url: "https://www.transformativeworks.org" %> + +<%= t ".features" %> + +<%= t ".text.join", invitation_url: signup_url(invitation_token: @invitation.token) %> + +<%= t ".text.activation_support", support_url: new_feedback_report_url %> + +<%= t ".text.faq", faq_url: archive_faqs_url %> +<% end %> diff --git a/app/views/user_mailer/invitation_to_claim.html.erb b/app/views/user_mailer/invitation_to_claim.html.erb new file mode 100644 index 0000000..28c581b --- /dev/null +++ b/app/views/user_mailer/invitation_to_claim.html.erb @@ -0,0 +1,40 @@ +<% content_for :message do %> +

    + <%= t("mailer.general.greeting.introductory") %> +

    + +

    <%= t(".introduction.html", open_doors_name_link: style_link(t(".html.open_doors_name"), "https://opendoors.transformativeworks.org"), app_short_name: ArchiveConfig.APP_SHORT_NAME, app_link: style_link(ArchiveConfig.APP_NAME, root_url)) %>

    + +

    <%= t(".more_info.html", ao3_news_link: style_link(t(".html.ao3_news"), "#{admin_posts_url}?tag=18"), faq_page_link: style_link(t(".html.faq_page"), "https://opendoors.transformativeworks.org/faq"), tutorial_page_link: style_link(t(".html.tutorial_page"), "https://opendoors.transformativeworks.org/tutorials"), contact_support_link: support_link(t(".html.contact_support"))) %>

    + +

    <%= t('.mistake.html', contact_open_doors_link: style_link(t('.html.contact_open_doors'), 'https://opendoors.transformativeworks.org/en/contact-open-doors/')) %>

    + +

    <%= t('.access.html', contact_support_link: support_link(t '.html.contact_support')) %>

    + +

    <%= style_link(t('.claim_or_remove.html'), claim_url(:invitation_token => @token)) %>

    + +

    <%= t '.uploaded_list' %>

    + +
      + <% @external_author.works.each do |work| %> +
    • <%= style_link(work.title, work_url(work)) %> + <%= (work.fandom_string.blank? ? "" : " (#{work.fandom_string})") %>
    • + <% end %> +
    + +

    <%= t('.unwanted.html', contact_support_link: support_link(t '.html.contact_support')) %>

    + +

    <%= t '.redirects' %>

    + +

    <%= t('.update_redirect.html', contact_open_doors_link: style_link(t('.html.contact_open_doors'), 'https://opendoors.transformativeworks.org/en/contact-open-doors/')) %>

    + +

    <%= t('.other_works.html', contact_open_doors_link: style_link(t('.html.contact_open_doors'), 'https://opendoors.transformativeworks.org/en/contact-open-doors/')) %>

    + +

    <%= t('.questions.html', contact_support_link: support_link(t '.html.contact_support')) %>

    + +

    <%= t '.email_tips' %>

    + +

    <%= t("mailer.general.closing.informal") %>
    + <%= style_bold(t("mailer.general.signature.open_doors")) %>
    + <%= style_bold(t("mailer.general.signature.parent_org")) %>

    +<% end %> diff --git a/app/views/user_mailer/invitation_to_claim.text.erb b/app/views/user_mailer/invitation_to_claim.text.erb new file mode 100644 index 0000000..d310548 --- /dev/null +++ b/app/views/user_mailer/invitation_to_claim.text.erb @@ -0,0 +1,38 @@ +<% content_for :message do %> +<%= t("mailer.general.greeting.introductory") %> + +<%= t(".introduction.text", open_doors_link: "https://opendoors.transformativeworks.org", app_name: ArchiveConfig.APP_NAME, app_short_name: ArchiveConfig.APP_SHORT_NAME, app_url: root_url) %> + +<%= t(".more_info.text", news_link: admin_posts_url + "?tag=18", open_doors_faq_link: "https://opendoors.transformativeworks.org/faq", open_doors_tutorial_link: "https://opendoors.transformativeworks.org/tutorials", support_link: new_feedback_report_url) %> + +<%= t(".mistake.text", open_doors_link: "https://opendoors.transformativeworks.org/en/contact-open-doors/") %> + +<%= t(".access.text") %> + +<%= t(".claim_or_remove.text", claim_url: claim_url(:invitation_token => @token)) %> + +<%= t(".uploaded_list") %> +<% @external_author.works.each do |work| %> +<% if work.fandom_string.present? %> +<%= t(".work_info.with_fandom", work_title: work.title, work_url: work_url(work), fandom: to_sentence(work.fandom_string.split(","))) %> +<% else %> +<%= t(".work_info.no_fandom", work_title: work.title, work_url: work_url(work)) %> +<% end %> +<% end %> + +<%= t(".unwanted.text", support_link: new_feedback_report_url) %> + +<%= t(".redirects") %> + +<%= t(".update_redirect.text", open_doors_link: "https://opendoors.transformativeworks.org/en/contact-open-doors/") %> + +<%= t(".other_works.text") %> + +<%= t(".questions.text", support_link: new_feedback_report_url) %> + +<%= t(".email_tips") %> + +<%= t("mailer.general.closing.informal") %> +<%= t("mailer.general.signature.open_doors") %> +<%= t("mailer.general.signature.parent_org") %> +<% end %> diff --git a/app/views/user_mailer/invite_increase_notification.html.erb b/app/views/user_mailer/invite_increase_notification.html.erb new file mode 100644 index 0000000..eb5036e --- /dev/null +++ b/app/views/user_mailer/invite_increase_notification.html.erb @@ -0,0 +1,10 @@ +<% content_for :message do %> +

    <%= t("mailer.general.greeting.informal.addressed_html", name: style_bold(@user.login)) %>

    + +

    <%= t(".body.html", count: @total, invitation_page_link: style_link(t(".invitation_page_link_text"), user_invitations_url(@user))) %>

    + +

    + <%= t("mailer.general.closing.informal") %>
    + <%= t("mailer.general.signature.app_short_name") %> +

    +<% end %> diff --git a/app/views/user_mailer/invite_increase_notification.text.erb b/app/views/user_mailer/invite_increase_notification.text.erb new file mode 100644 index 0000000..499d6c0 --- /dev/null +++ b/app/views/user_mailer/invite_increase_notification.text.erb @@ -0,0 +1,8 @@ +<% content_for :message do %> +<%= t("mailer.general.greeting.informal.addressed_html", name: @user.login) %> + +<%= t(".body.text", count: @total, invitation_page_url: user_invitations_url(@user)) %> + +<%= t("mailer.general.closing.informal") %> +<%= t("mailer.general.signature.app_short_name") %> +<% end %> diff --git a/app/views/user_mailer/invite_request_declined.html.erb b/app/views/user_mailer/invite_request_declined.html.erb new file mode 100644 index 0000000..e608b75 --- /dev/null +++ b/app/views/user_mailer/invite_request_declined.html.erb @@ -0,0 +1,10 @@ +<% content_for :message do %> +

    <%= t("mailer.general.greeting.formal.addressed_html", name: style_bold(@user.login)) %>

    + +

    <%= t(".main", count: @total) %>

    +

    <%= t(".reason") %>

    +

    <%= style_quote(@reason) %>

    + +

    <%= t("mailer.general.closing.formal") %>
    + <%= t("mailer.general.signature.app_short_name") %>

    +<% end %> diff --git a/app/views/user_mailer/invite_request_declined.text.erb b/app/views/user_mailer/invite_request_declined.text.erb new file mode 100644 index 0000000..d8181e2 --- /dev/null +++ b/app/views/user_mailer/invite_request_declined.text.erb @@ -0,0 +1,12 @@ +<% content_for :message do %> +<%= t("mailer.general.greeting.formal.addressed_html", name: @user.login) %> + +<%= t(".main", count: @total) %> + +<%= t(".reason") %> + +<%= to_plain_text(raw @reason) %> + +<%= t("mailer.general.closing.formal") %> +<%= t("mailer.general.signature.app_short_name") %> +<% end %> diff --git a/app/views/user_mailer/invited_to_collection_notification.html.erb b/app/views/user_mailer/invited_to_collection_notification.html.erb new file mode 100644 index 0000000..dc78d80 --- /dev/null +++ b/app/views/user_mailer/invited_to_collection_notification.html.erb @@ -0,0 +1,10 @@ +<% content_for :message do %> +

    <%= t("mailer.general.greeting.formal.addressed_html", name: style_bold(@user.login)) %>

    + +

    <%= t(".would_like_to_include.html", + collection_link: style_link(@collection.title, collection_url(@collection)), + work_link: style_creation_link(@work.title, work_url(@work))) %>

    + +

    <%= t(".approve.html", collection_items_page_link: style_link(t(".approve.collection_items_page"), user_collection_items_url(@user))) %>

    + +<% end %> diff --git a/app/views/user_mailer/invited_to_collection_notification.text.erb b/app/views/user_mailer/invited_to_collection_notification.text.erb new file mode 100644 index 0000000..5ec17bc --- /dev/null +++ b/app/views/user_mailer/invited_to_collection_notification.text.erb @@ -0,0 +1,12 @@ +<% content_for :message do %> +<%= t("mailer.general.greeting.formal.addressed_html", name: @user.login) %> + +<%= t(".would_like_to_include.text", + collection_title: @collection.title, + collection_url: collection_url(@collection), + work_title: @work.title, + work_url: work_url(@work)) %> + +<%= t(".approve.text", collection_items_page_url: user_collection_items_url(@user)) %> + +<% end %> diff --git a/app/views/user_mailer/potential_match_generation_notification.html.erb b/app/views/user_mailer/potential_match_generation_notification.html.erb new file mode 100644 index 0000000..cca750f --- /dev/null +++ b/app/views/user_mailer/potential_match_generation_notification.html.erb @@ -0,0 +1,13 @@ +<% content_for :message do %> +

    <%= t(".finished_generating.html", collection_link: style_link(@collection.title, collection_url(@collection))) %>

    + +

    <%= t(".matches_available.html", matching_page_link: style_link(t(".matches_available.matching_page"), collection_potential_matches_url(@collection))) %>

    +<% 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 100644 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 100644 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! +

    + +

    + <% if @collection.nil? %> + <%= style_creation_link(@work.title, work_url(@work)) %> (<%= @work.word_count %> words) + <% else %> + <%= style_creation_link(@work.title, collection_work_url(@collection, @work)) %> (<%= @work.word_count %> words) + <% end %> +
    + by <%= @work.anonymous? ? style_bold("an anonymous responder") : (@work.pseuds.map{|p| style_pseud_link(p)}.to_sentence.html_safe) %> +
    + <% unless @work.fandom_string.blank? %> + <%= style_bold("Fandom:") %> <%= @work.fandom_string %> + <% end %> +

    + + <% unless @work.summary.blank? %> + <%= style_bold("Summary:") %> + <%= style_quote(raw sanitize_field(@work, :summary)) %> + <% end %> + + <% if @collection && !@collection.gift_notification.blank? %> +

    <%= @collection.gift_notification %>

    + <% end %> + +<% end %> diff --git a/app/views/user_mailer/prompter_notification.text.erb b/app/views/user_mailer/prompter_notification.text.erb new file mode 100644 index 0000000..78cae08 --- /dev/null +++ b/app/views/user_mailer/prompter_notification.text.erb @@ -0,0 +1,14 @@ +<% content_for :message do %> +A response to your prompt has been posted<% if @collection %> in the "<%= @collection.title %>" collection (<%= collection_url(@collection) %>)<% end %> at the Archive of Our Own! + +"<%= @work.title %>" (<%= @work.word_count %> words) +<%= @collection ? collection_work_url(@collection, @work) : work_url(@work) %> + +by <%= @work.anonymous? ? "an anonymous responder" : (@work.pseuds.map{|p| text_pseud(p)}.to_sentence) %> + +<% unless @work.fandom_string.blank? %>Fandom: <%= @work.fandom_string %><% end %> + +<% unless @work.summary.blank? %>Summary: +<%= to_plain_text(raw sanitize_field(@work, :summary)) %><% end %> + +<% if @collection && !@collection.gift_notification.blank? %><%= @collection.gift_notification %><% end %><% end %> diff --git a/app/views/user_mailer/recipient_notification.html.erb b/app/views/user_mailer/recipient_notification.html.erb new file mode 100644 index 0000000..e43576e --- /dev/null +++ b/app/views/user_mailer/recipient_notification.html.erb @@ -0,0 +1,26 @@ +<% content_for :message do %> + +

    <%= t("mailer.general.greeting.informal.addressed_html", name: style_bold(@user.login)) %>

    + +

    + <% if @collection %> + <%= t(".collection.html", collection_link: style_link(@collection.title, collection_url(@collection))) %> + <% else %> + <%= t(".no_collection") %> + <% end %> +

    + +

    + <% url = @collection ? collection_work_url(@collection, @work) : work_url(@work) %> + <%= creation_link_with_word_count(@work, url) %> +
    + by <%= creator_links(@work) %> +

    + + <%= render "work_info", work: @work %> + + <% if @collection && !@collection.gift_notification.blank? %> +

    <%= @collection.gift_notification %>

    + <% end %> + +<% end %> diff --git a/app/views/user_mailer/recipient_notification.text.erb b/app/views/user_mailer/recipient_notification.text.erb new file mode 100644 index 0000000..46e94ff --- /dev/null +++ b/app/views/user_mailer/recipient_notification.text.erb @@ -0,0 +1,17 @@ +<% content_for :message do %> +<%= t("mailer.general.greeting.informal.addressed_html", name: @user.login) %> + +<% if @collection %> +<%= t(".collection.text", collection_title: @collection.title, collection_url: collection_url(@collection)) %> +<% else %> +<%= t(".no_collection") %> +<% end %> + +<%= creation_title_with_word_count(@work) %> +<%= @collection ? collection_work_url(@collection, @work) : work_url(@work) %> + +by <%= creator_text(@work) %> + +<%= render "work_info", work: @work %> + +<% if @collection && !@collection.gift_notification.blank? %><%= @collection.gift_notification %><% end %><% end %> diff --git a/app/views/user_mailer/related_work_notification.html.erb b/app/views/user_mailer/related_work_notification.html.erb new file mode 100644 index 0000000..bedbef2 --- /dev/null +++ b/app/views/user_mailer/related_work_notification.html.erb @@ -0,0 +1,18 @@ +<% content_for :message do %> +

    + <%= t(".work_listed.html", + work_link: style_creation_link(@related_work.parent.title, @related_parent_link), + related_work_link: style_creation_link(@related_work.work.title, @related_child_link), + work_creators: if @related_work.work.anonymous? + t(".anonymous") + else + to_sentence(@related_work.work.pseuds.map { |p| style_pseud_link(p) }) + end) %> +

    + +

    + <%= style_link(t(".approve.html"), related_work_url(@related_work)) %> +

    + +

    <%= t(".can_come_back") %>

    +<% end %> diff --git a/app/views/user_mailer/related_work_notification.text.erb b/app/views/user_mailer/related_work_notification.text.erb new file mode 100644 index 0000000..c4fafc5 --- /dev/null +++ b/app/views/user_mailer/related_work_notification.text.erb @@ -0,0 +1,15 @@ +<% content_for :message do %> +<%= t(".work_listed.text", work_title: @related_work.parent.title, + work_url: @related_parent_link, + related_work_title: @related_work.work.title, + related_work_url: @related_child_link, + work_creators: if @related_work.work.anonymous? + t(".anonymous") + else + to_sentence(@related_work.work.pseuds.map { |p| text_pseud(p) }) + end) %> + +<%= t(".approve.text", related_work_url: related_work_url(@related_work)) %> + +<%= t(".can_come_back") %> +<% end %> diff --git a/app/views/user_mailer/signup_notification.html.erb b/app/views/user_mailer/signup_notification.html.erb new file mode 100644 index 0000000..cd76a1d --- /dev/null +++ b/app/views/user_mailer/signup_notification.html.erb @@ -0,0 +1,12 @@ +<% content_for :message do %> +

    <%= t('.welcome', + login: style_bold(@user.login)).html_safe %>

    +

    <%= t('.activate.html', + activate_account_link: style_link(t('.activate_your_account'), activate_url(id: @user.confirmation_token))) %>

    +

    <%= t('.features.html') %>

    +

    <%= t('.information.html', + faq_link: style_link(t('.faq'), archive_faqs_url), + admin_posts_link: style_link(t('.admin_posts'), admin_posts_url), + contact_support_link: support_link(t '.contact_support')) %>

    +

    <%= t('.bye') %>

    +<% end %> diff --git a/app/views/user_mailer/signup_notification.text.erb b/app/views/user_mailer/signup_notification.text.erb new file mode 100644 index 0000000..239596f --- /dev/null +++ b/app/views/user_mailer/signup_notification.text.erb @@ -0,0 +1,16 @@ +<% content_for :message do %> +<%= t('.welcome', + login: @user.login) %> + +<%= t('.activate.text', + activate_account_url: activate_url(id: @user.confirmation_token)) %> + +<%= t('.features.text') %> + +<%= t('.information.text', + faq_url: archive_faqs_url, + admin_posts_url: admin_posts_url, + contact_support_url: new_feedback_report_url) %> + +<%= t('.bye') %> +<% end %> diff --git a/app/views/users/_admin_change_username.html.erb b/app/views/users/_admin_change_username.html.erb new file mode 100644 index 0000000..6b23fda --- /dev/null +++ b/app/views/users/_admin_change_username.html.erb @@ -0,0 +1,29 @@ +

    <%= t(".page_heading") %>

    +<%= error_messages_for :user %> +

    <%= t(".description") %>

    + + + +<%= form_tag changed_username_user_path(@user), autocomplete: "off" do %> + <%= t("users.change_username.legend") %> +

    <%= t("users.change_username.heading") %>

    +
    +
    <%= t("users.change_username.current_username") %>
    +

    <%= @user.login %>

    +
    <%= label_tag :new_login, t("users.change_username.new_username") %>
    +
    + <%= text_field_tag :new_login, "user#{@user.id}", readonly: true, "aria-describedby": "new-login-field-description" %> +

    + <%= t(".new_username_footnote") %> +

    +
    +
    <%= label_tag :ticket_number, t(".ticket_id") %>
    +
    <%= text_field_tag :ticket_number %>
    +
    <%= t("users.change_username.submit_landmark") %>
    +
    + <%= submit_tag t("users.change_username.submit") %> +
    +
    +<% end %> diff --git a/app/views/users/_contents.html.erb b/app/views/users/_contents.html.erb new file mode 100755 index 0000000..d0e86b4 --- /dev/null +++ b/app/views/users/_contents.html.erb @@ -0,0 +1,143 @@ + + +<% if @status.present? %> +
    +

    Current Status...

    +<%= link_to t("All My Statuses"), user_statuses_path(@status.user) %>
    +<%= image_tag @status.icon if @status.icon.attached? %> | <%= time_ago_in_words(@status.created_at) %> <% if current_user == @status.user %> + <%= link_to '| Edit', edit_user_status_path(@status.user, @status) %> | + <%= link_to t("Delete"), user_status_path(@status.user, @status), + data: { confirm: t("Are you sure? All information in this status will be lost.") }, + method: :delete %> + <% end %> +
    +

    <%= raw sanitize_field(@status, :text) %>

    +<% if @status.mood.present? %> +

    Mood: <%= @status.mood %> +<% end %> +<% if @status.music.present? %> +

    Music: <%= @status.music %>

    +<% end %> +
    +<% end %>
    <% unless @fandoms.empty? %> +
    +

    <%= ts("Fandoms") %>

    +
      + <% @fandoms.each_with_index do |fandom, i| %> + <% if i>= ArchiveConfig.NUMBER_OF_ITEMS_VISIBLE_IN_DASHBOARD && !params[:expand_fandoms] %> +
    1. + <% end %> + <% if fandom.merger %> + <%= link_to fandom.name, {:controller => :works, :user_id => @user, :pseud_id => @pseud, :fandom_id => fandom.merger.id} %> (<%= fandom.work_count %>) + <% else %> + <%= link_to fandom.name, {:controller => :works, :user_id => @user, :pseud_id => @pseud, :fandom_id => fandom.id} %> (<%= fandom.work_count %>) + <% end %> +
    2. + <% end %> +
    + <% if @fandoms.length > ArchiveConfig.NUMBER_OF_ITEMS_VISIBLE_IN_DASHBOARD %> +

    + + + +

    + <% end %> +
    + <% end %> + + <% unless @works.blank? %> +
    +

    <%= ts("Recent works") %>

    +
      + <%= render partial: 'works/work_blurb', collection: @works, as: :work %> +
    + <% if @pseud %> + <% if @pseud.works.count > ArchiveConfig.NUMBER_OF_ITEMS_VISIBLE_IN_DASHBOARD %> +
    + <% end %> + + <% unless @series.blank? %> +
    +

    <%= ts("Recent series") %>

    +
      + <%= render partial: 'series/series_blurb', collection: @series, as: :series %> +
    + <% if @pseud %> + <% if @pseud.series.count > ArchiveConfig.NUMBER_OF_ITEMS_VISIBLE_IN_DASHBOARD %> + + <% end %> + <% else %> + <% if @user.series.size > ArchiveConfig.NUMBER_OF_ITEMS_VISIBLE_IN_DASHBOARD %> + + <% end %> + <% end %> +
    + <% end %> + + <% unless @bookmarks.blank? %> +
    +

    <%= ts("Recent bookmarks") %>

    +
      + <%= render partial: 'bookmarks/bookmark_blurb', collection: @bookmarks, as: :bookmark %> +
    + <% unless @user == User.orphan_account %> + <% if @pseud %> + <% if @pseud.bookmarks.visible.size > ArchiveConfig.NUMBER_OF_ITEMS_VISIBLE_IN_DASHBOARD %> + + <% end %> + <% else %> + <% if @user.bookmarks.visible.size > ArchiveConfig.NUMBER_OF_ITEMS_VISIBLE_IN_DASHBOARD %> + + <% 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 100644 index 0000000..643714f --- /dev/null +++ b/app/views/users/_edit_header_navigation.html.erb @@ -0,0 +1,7 @@ + diff --git a/app/views/users/_edit_user_navigation.html.erb b/app/views/users/_edit_user_navigation.html.erb new file mode 100644 index 0000000..77e872c --- /dev/null +++ b/app/views/users/_edit_user_navigation.html.erb @@ -0,0 +1,7 @@ + diff --git a/app/views/users/_header.html.erb b/app/views/users/_header.html.erb new file mode 100644 index 0000000..12e5f37 --- /dev/null +++ b/app/views/users/_header.html.erb @@ -0,0 +1,15 @@ + +
    +

    + <%= @pseud ? @pseud.byline : @user.login %> +

    +

    <%= icon_display(@user, @pseud) %>

    + + + + <% if logged_in? || policy(User).can_manage_users? %> + <%= render "users/header_navigation" %> + <% end %> + + +
    diff --git a/app/views/users/_header_navigation.html.erb b/app/views/users/_header_navigation.html.erb new file mode 100644 index 0000000..89125ab --- /dev/null +++ b/app/views/users/_header_navigation.html.erb @@ -0,0 +1,23 @@ +<% # expects @user to be set %> + diff --git a/app/views/users/_sidebar.html.erb b/app/views/users/_sidebar.html.erb new file mode 100644 index 0000000..49c165a --- /dev/null +++ b/app/views/users/_sidebar.html.erb @@ -0,0 +1,66 @@ +
    " role="navigation region"> +

    <%= t(".landmark.choices") %>

    + + + +

    <%= t(".landmark.pitch") %>

    + + +<% if @user == current_user || policy(:inbox_comment).show? %> +

    <%= t(".landmark.catch") %>

    + +<% end %> + +

    <%= t(".landmark.switch") %>

    + + +
    diff --git a/app/views/users/change_email.html.erb b/app/views/users/change_email.html.erb new file mode 100644 index 0000000..78db45b --- /dev/null +++ b/app/views/users/change_email.html.erb @@ -0,0 +1,47 @@ + +

    <%= t(".heading") %>

    +<%= error_messages_for :user %> + + + +<%= render "edit_header_navigation" %> + + +<% reconfirmation_date = @user.confirmation_sent_at + User.confirm_within if @user.pending_reconfirmation? %> +<% if reconfirmation_date && (Time.current < reconfirmation_date) %> +
    +

    <%= t(".caution.requested_change_html", unconfirmed_email: tag.strong(@user.unconfirmed_email)) %>

    +

    <%= t(".caution.check_spam_html", must_confirm_bold: tag.strong(t(".caution.must_confirm")), contact_support_link: link_to(t(".caution.contact_support"), new_feedback_report_path)) %>

    +

    <%= t(".caution.confirm_by", date: l(reconfirmation_date)) %>

    +
    +<% end %> + +
    +

    <%= t(".request_for_confirmation") %>

    +

    <%= t(".must_confirm", count: User.confirm_within.in_days.to_i) %>

    +

    <%= t(".resubmission_html", invalidate_bold: tag.strong(t(".invalidate"))) %>

    +
    + + +<%= form_with model: @user, url: confirm_change_email_user_path(@user), method: :put do %> +
    +
    <%= t(".form.current_email") %>
    +
    <%= @user.email %>
    + +
    <%= label_tag :new_email, t(".form.new_email") %>
    +
    <%= email_field_tag :new_email, nil, autocomplete: "off" %>
    + +
    <%= label_tag :email_confirmation, t(".form.email_again") %>
    +
    <%= email_field_tag :email_confirmation, nil, autocomplete: "off" %>
    + +
    <%= label_tag :password_check, t(".form.password") %>
    +
    <%= password_field_tag :password_check %>
    + +
    <%= label_tag :submit, t(".form.submit_landmark") %>
    +
    + <%= submit_tag t(".form.confirm") %> +
    +
    +<% end %> + + diff --git a/app/views/users/change_password.html.erb b/app/views/users/change_password.html.erb new file mode 100644 index 0000000..db107e7 --- /dev/null +++ b/app/views/users/change_password.html.erb @@ -0,0 +1,33 @@ + +

    <%= ts("Change My Password") %>

    +<%= error_messages_for :user %> + + + +<%= render 'edit_header_navigation' %> + + + +<%= form_tag changed_password_user_path(@user) do %> +
    +
    <%= label_tag :password, ts("New password") %>
    +
    + <%= password_field_tag :password, nil, "aria-describedby" => "password-field-description" %> +

    + <%= ts("%{minimum} to %{maximum} characters", + minimum: ArchiveConfig.PASSWORD_LENGTH_MIN, + maximum: ArchiveConfig.PASSWORD_LENGTH_MAX) %> +

    +
    +
    <%= label_tag :password_confirmation, ts("Confirm new password") %>
    +
    <%= password_field_tag :password_confirmation %>
    +
    <%= label_tag :password_check, ts("Old password") %>
    +
    <%= password_field_tag :password_check%>
    +
    <%= label_tag :submit, ts("Submit") %>
    +
    + <%= submit_tag ts("Change Password") %> +
    +
    +<% end %> + + diff --git a/app/views/users/change_username.html.erb b/app/views/users/change_username.html.erb new file mode 100644 index 0000000..25e3895 --- /dev/null +++ b/app/views/users/change_username.html.erb @@ -0,0 +1,55 @@ +<% if policy(@user).change_username? %> + <%= render "admin_change_username" %> +<% else %> + +

    <%= t(".page_heading") %>

    + <%= error_messages_for :user %> +
    +

    + <%= t(".caution") %> + <%= t(".change_window", count: ArchiveConfig.USER_RENAME_LIMIT_DAYS) %> + <% if @user.renamed_at %> + <%= t(".last_renamed", renamed_at: l(@user.renamed_at)) %> + <% end %> +

    +

    + <%= t(".more_info_html", + account_faq_link: link_to(t(".account_faq"), archive_faq_path("your-account", anchor: "namechange")), + contact_support_link: link_to(t(".contact_support"), new_feedback_report_path)) %> +

    +
    +

    + <%= t(".new_pseud_instead_html", create_a_new_pseud_link: link_to(t(".create_a_new_pseud"), new_user_pseud_path(@user))) %> +

    + + + + <%= render "edit_header_navigation" %> + + + + + <%= form_tag changed_username_user_path(@user), autocomplete: "off" do %> + <%= t(".legend") %> +

    <%= t(".heading") %>

    +
    +
    <%= t(".current_username") %>
    +

    <%= @user.login %>

    +
    <%= label_tag :new_login, t(".new_username") %>
    +
    + <%= text_field_tag :new_login, @new_login, "aria-describedby": "new-login-field-description" %> +

    + <%= t(".username_requirements", + minimum: ArchiveConfig.LOGIN_LENGTH_MIN, + maximum: ArchiveConfig.LOGIN_LENGTH_MAX) %> +

    +
    +
    <%= label_tag :password, t(".password") %>
    +
    <%= password_field_tag :password, nil %>
    +
    <%= t(".submit_landmark") %>
    +
    + <%= submit_tag t(".submit"), data: { confirm: t(".confirm") } %> +
    +
    + <% end %> +<% end %> diff --git a/app/views/users/confirm_change_email.html.erb b/app/views/users/confirm_change_email.html.erb new file mode 100644 index 0000000..aa2efd3 --- /dev/null +++ b/app/views/users/confirm_change_email.html.erb @@ -0,0 +1,24 @@ + +

    <%= t(".heading") %>

    + + + +<%= render "edit_header_navigation" %> + + + +<%= form_with model: @user, url: changed_email_user_path(@user), method: :post do %> + <%= hidden_field_tag :new_email, @new_email %> + +
    +

    <%= t(".are_you_sure_html", unconfirmed_email: tag.strong(@new_email)) %>

    +

    <%= t(".confirm_via_link_html", if_not_confirm_bold: tag.strong(t(".if_not_confirm", count: User.confirm_within.in_days.to_i))) %>

    +
    + +

    + <%= link_to t(".cancel"), change_email_user_path(@user) %> + <%= submit_tag t(".confirm") %> +

    +<% end %> + + diff --git a/app/views/users/confirmation.html.erb b/app/views/users/confirmation.html.erb new file mode 100644 index 0000000..81bcaa8 --- /dev/null +++ b/app/views/users/confirmation.html.erb @@ -0,0 +1,21 @@ + +

    <%= t(".page_heading") %>

    + + +
    +

    + <%= t(".receive_email_html", return_address: tag.strong(ArchiveConfig.RETURN_ADDRESS)) %> +

    +

    + <%= t(".no_email_html", contact_support_link: link_to(t(".contact_support"), new_feedback_report_path)) %> +

    +

    + <%# 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 100644 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 100644 index 0000000..07d6a8f --- /dev/null +++ b/app/views/users/delete_preview.html.erb @@ -0,0 +1,74 @@ +

    <%= t(".heading") %>

    + +<%= form_tag(@user, method: :delete) do %> + <% unless @user.coauthors.blank? %> +
    + <%= t(".co_creations.legend") %> +

    + <%= t(".co_creations.summary", + work_count: @coauthored_works.size, + co_creators: print_coauthors(@user)) %> +

    +

    + <%= t(".co_creations.orphan_info", + orphan_link: link_to(t(".orphan"), archive_faq_path("orphaning")) + ).html_safe %> +

    +

    + <%= radio_button_tag :coauthor, :keep_pseud, true %> + <%= label_tag :coauthor_keep_pseud, t(".options.keep_pseud") %> +

    +

    + <%= radio_button_tag :coauthor, :orphan_pseud %> + <%= label_tag :coauthor_orphan_pseud, t(".options.orphan_pseud") %> +

    +

    + <%= radio_button_tag :coauthor, :remove %> + <%= label_tag :coauthor_remove, t(".options.remove") %> +

    +
    + <% end %> + + <% unless @sole_authored_works.empty? && @sole_owned_collections.empty? %> +
    + <%= t(".sole_creations.legend") %> +

    + <% unless @sole_authored_works.empty? %> + <%= t(".sole_creations.works_summary", + work_count: @sole_authored_works.size, + pseuds: print_pseuds(@user)) %> + <% end %> + <% unless @sole_owned_collections.empty? %> + <% unless @sole_authored_works.empty? %>
    <% end %> + <%= t(".sole_creations.collections_summary", + collection_count: @sole_owned_collections.size, + pseuds: @sole_owned_collections.collect(&:all_owners).flatten.uniq.collect(&:name).join(", ")) %> + <% end %> +

    +

    + <%= t(".sole_creations.options_info", + orphaning_link: link_to(t(".orphaning"), archive_faq_path("orphaning")) + ).html_safe %> +

    +

    + <%= radio_button_tag :sole_author, :keep_pseud, true %> + <%= label_tag :sole_author_keep_pseud, t(".options.keep_pseud") %> +

    +

    + <%= radio_button_tag :sole_author, :orphan_pseud %> + <%= label_tag :sole_author_orphan_pseud, t(".options.orphan_pseud") %> +

    +

    + <%= radio_button_tag :sole_author, :delete %> + <%= label_tag :sole_author_delete, t(".options.delete") %> +

    +
    + <% end %> + +
    +

    + <%= submit_tag t(".submit"), data: { confirm: t(".confirm") } %> + <%= submit_tag t(".cancel"), name: "cancel_button", class: "cancel" %> +

    +
    +<% end %> diff --git a/app/views/users/edit.html.erb b/app/views/users/edit.html.erb new file mode 100644 index 0000000..3c0bb73 --- /dev/null +++ b/app/views/users/edit.html.erb @@ -0,0 +1,48 @@ + +

    <%= t(".page_heading") %>

    +<%= error_messages_for :user %> +<%= error_messages_for @user.profile %> + + + +<%= render "edit_header_navigation" %> + + + +

    + <%= t(".public_information_notice_html", privacy_policy_link: link_to(t(".privacy_policy"), privacy_path)) %> +

    + +

    <%= t(".change_profile_landmark") %>

    +<%= form_for(@user) do |f| %> +
    + <%= fields_for :profile_attributes, @user.profile do |p| %> +
    <%= p.label :title, t(".title") %>
    +
    + <%= p.text_field :title, class: "observe_textlength" %> + <%= live_validation_for_field("profile_attributes_title", presence: false, maximum_length: Profile::PROFILE_TITLE_MAX) %> + <%= generate_countdown_html("profile_attributes_title", Profile::PROFILE_TITLE_MAX) %> +
    + +
    <%= p.label :about_me, t(".about_me") %>
    +
    +

    <%= allowed_html_instructions %>

    + <%= p.text_area :about_me, class: "observe_textlength" %> + <%= live_validation_for_field("profile_attributes_about_me", presence: false, maximum_length: Profile::ABOUT_ME_MAX) %> + <%= generate_countdown_html("profile_attributes_about_me", Profile::ABOUT_ME_MAX) %> +
    + + <% if policy(@user.profile).can_edit_profile? %> +
    <%= p.label :ticket_number, class: "required" %>
    +
    + <%= p.text_field :ticket_number, class: "required" %> +
    + <% end %> + +
    <%= p.label :update, t(".update") %>
    +
    <%= f.submit t(".update") %>
    + <% end %> +
    +<% end %> + + diff --git a/app/views/users/end_banner.js.erb b/app/views/users/end_banner.js.erb new file mode 100644 index 0000000..b834c33 --- /dev/null +++ b/app/views/users/end_banner.js.erb @@ -0,0 +1 @@ +$j('#admin-banner').hide(); diff --git a/app/views/users/end_first_login.js.erb b/app/views/users/end_first_login.js.erb new file mode 100644 index 0000000..eeb290e --- /dev/null +++ b/app/views/users/end_first_login.js.erb @@ -0,0 +1,2 @@ +$j('#first-login-help-banner').hide(); +ao3modal.hide(); diff --git a/app/views/users/mailer/confirmation_instructions.html.erb b/app/views/users/mailer/confirmation_instructions.html.erb new file mode 100644 index 0000000..7ca4914 --- /dev/null +++ b/app/views/users/mailer/confirmation_instructions.html.erb @@ -0,0 +1,11 @@ +<% content_for :message do %> +

    <%= t("mailer.general.greeting.formal.unaddressed") %>

    + +

    <%= t(".request_to_change_email_html", username: style_bold(@resource.login), app_name: ArchiveConfig.APP_SHORT_NAME) %>

    + +

    <%= t(".made_request.html", count: @resource.class.confirm_within.in_days.to_i, confirm_email_change_link: style_link(t(".confirm_email_change"), reconfirm_email_user_url(@resource, confirmation_token: @token))) %>

    + +

    <%= t(".confirm_by.html", date: l(@resource.confirmation_sent_at + @resource.class.confirm_within), email_change_form_link: style_link(t(".email_change_form"), change_email_user_url(@user))) %>

    + +

    <%= t(".did_not_make_request") %>

    +<% end %> diff --git a/app/views/users/mailer/confirmation_instructions.text.erb b/app/views/users/mailer/confirmation_instructions.text.erb new file mode 100644 index 0000000..12b6c46 --- /dev/null +++ b/app/views/users/mailer/confirmation_instructions.text.erb @@ -0,0 +1,11 @@ +<% content_for :message do %> +<%= t("mailer.general.greeting.formal.unaddressed") %> + +<%= t(".request_to_change_email_html", username: @resource.login, app_name: ArchiveConfig.APP_SHORT_NAME) %> + +<%= t(".made_request.text", count: @resource.class.confirm_within.in_days.to_i, confirm_email_change_url: reconfirm_email_user_url(@resource, confirmation_token: @token)) %> + +<%= t(".confirm_by.text", date: l(@resource.confirmation_sent_at + @resource.class.confirm_within), email_change_form_url: change_email_user_url(@user)) %> + +<%= t(".did_not_make_request") %> +<% end %> diff --git a/app/views/users/mailer/password_change.html.erb b/app/views/users/mailer/password_change.html.erb new file mode 100644 index 0000000..7bcdfaa --- /dev/null +++ b/app/views/users/mailer/password_change.html.erb @@ -0,0 +1,15 @@ +<% content_for :message do %> +

    <%= t("mailer.general.greeting.formal.addressed_html", name: style_bold(@resource.login)) %>

    + +

    <%= t(".changed", date: l(Time.current), app_name: ArchiveConfig.APP_SHORT_NAME) %>

    + +

    <%= t(".made_change.html", + login_link: style_link(t(".made_change.login"), new_user_session_url), + reset_password_link: style_link(t(".made_change.reset_password"), new_user_password_url), + contact_support_link: support_link(t(".made_change.contact_support"))) %>

    + +

    <%= t(".did_not_make_change.html", + reset_password_link: style_link(t(".did_not_make_change.reset_password"), new_user_password_url), + contact_policy_abuse_link: style_link(t(".did_not_make_change.contact_policy_abuse"), new_abuse_report_url), + app_name: ArchiveConfig.APP_SHORT_NAME) %>

    +<% end %> diff --git a/app/views/users/mailer/password_change.text.erb b/app/views/users/mailer/password_change.text.erb new file mode 100644 index 0000000..2cd7601 --- /dev/null +++ b/app/views/users/mailer/password_change.text.erb @@ -0,0 +1,15 @@ +<% content_for :message do %> +<%= t("mailer.general.greeting.formal.addressed_html", name: @resource.login) %> + +<%= t(".changed", date: l(Time.current), app_name: ArchiveConfig.APP_SHORT_NAME) %> + +<%= t(".made_change.text", + login_url: new_user_session_url, + reset_password_url: new_user_password_url, + contact_support_url: new_feedback_report_url) %> + +<%= t(".did_not_make_change.text", + reset_password_url: new_user_password_url, + contact_policy_abuse_url: new_abuse_report_url, + app_name: ArchiveConfig.APP_SHORT_NAME) %> +<% end %> diff --git a/app/views/users/mailer/reset_password_instructions.html.erb b/app/views/users/mailer/reset_password_instructions.html.erb new file mode 100644 index 0000000..bce2e17 --- /dev/null +++ b/app/views/users/mailer/reset_password_instructions.html.erb @@ -0,0 +1,7 @@ +<% content_for :message do %> +

    <%= t("mailer.general.greeting.formal.addressed_html", name: style_bold(@resource.login)) %>

    +

    <%= t(".intro") %>

    +

    <%= style_link t(".link_title"), edit_user_password_url(reset_password_token: @token) %>

    +

    <%= t(".expiration") %>

    +

    <%= t(".unrequested") %>

    +<% end %> diff --git a/app/views/users/mailer/reset_password_instructions.text.erb b/app/views/users/mailer/reset_password_instructions.text.erb new file mode 100644 index 0000000..0e320d1 --- /dev/null +++ b/app/views/users/mailer/reset_password_instructions.text.erb @@ -0,0 +1,12 @@ +<% content_for :message do %> +<%= t("mailer.general.greeting.formal.addressed_html", name: @resource.login) %> + +<%= t(".intro") %> + +<%= edit_user_password_url(reset_password_token: @token) %> + +<%= t(".expiration") %> + +<%= t(".unrequested") %> + +<% end %> diff --git a/app/views/users/passwords/edit.html.erb b/app/views/users/passwords/edit.html.erb new file mode 100644 index 0000000..f7ea8e7 --- /dev/null +++ b/app/views/users/passwords/edit.html.erb @@ -0,0 +1,28 @@ + +

    <%= ts("Change My Password") %>

    +<%= error_messages_for resource %> + + + +<%= form_for resource, url: users_password_reset_path, html: { method: :put, class: "reset password post" } do |f| %> + <%= f.hidden_field :reset_password_token %> +
    +
    <%= label_tag :edit_password, ts("New password") %>
    +
    + <%= f.password_field(:password, id: :edit_password, + "aria-describedby" => "password-field-description") %> +

    + <%= ts("%{minimum} to %{maximum} characters", + minimum: ArchiveConfig.PASSWORD_LENGTH_MIN, + maximum: ArchiveConfig.PASSWORD_LENGTH_MAX) %> +

    +
    +
    <%= label_tag :edit_password_confirmation, ts("Confirm new password") %>
    +
    <%= f.password_field :password_confirmation, id: :edit_password_confirmation %>
    +
    <%= ts("Submit") %>
    +
    + <%= f.submit ts("Change Password") %> +
    +
    +<% end %> + diff --git a/app/views/users/passwords/new.html.erb b/app/views/users/passwords/new.html.erb new file mode 100644 index 0000000..15fa21c --- /dev/null +++ b/app/views/users/passwords/new.html.erb @@ -0,0 +1,15 @@ + +

    <%= t(".page_heading") %>

    +

    <%= t(".instructions") %>

    + + + +<%= form_for resource, url: user_password_path, html: { method: :post, html: "reset password simple post" } do |f| %> + <%= error_messages_for resource %> +

    + <%= label_tag :reset_login, t(".email_or_username_html", or: tag.strong(t(".or"))) %> + <%= f.text_field :login, id: :reset_login %> + <%= f.submit t(".reset_password") %> +

    +<% end %> + diff --git a/app/views/users/registrations/_legal.html.erb b/app/views/users/registrations/_legal.html.erb new file mode 100644 index 0000000..65db354 --- /dev/null +++ b/app/views/users/registrations/_legal.html.erb @@ -0,0 +1,33 @@ + +

    <%= t(".over_thirteen_required") %>

    +

    + <%= f.check_box :age_over_13 %> + <%= f.label :age_over_13, t(".over_thirteen_confirm") %> +

    + +

    + <%= t(".agreement_required_html", + terms_of_service_link: link_to(t(".terms_of_service"), tos_path, target: "_blank", rel: "noopener"), + content_policy_link: link_to(t(".content_policy"), content_path, target: "_blank", rel: "noopener"), + privacy_policy_link: link_to(t(".privacy_policy"), privacy_path, target: "_blank", rel: "noopener")) %> +

    +
    +
    +
    +

    <%= t("home.tos.page_heading") %>

    + <%= render "home/tos", suppress_footer: true %> +

    <%= t("home.content.page_heading") %>

    + <%= render "home/content", suppress_toc: true, suppress_footer: true %> +

    <%= t("home.privacy.page_heading") %>

    + <%= render "home/privacy", suppress_toc: true %> +
    +
    +
    +

    + <%= f.check_box :terms_of_service %> + <%= f.label :terms_of_service, t(".agreement_confirm"), class: "important" %> +

    +

    + <%= f.check_box :data_processing %> + <%= f.label :data_processing, t(".data_processing_confirm"), class: "important" %> +

    diff --git a/app/views/users/registrations/_passwd.html.erb b/app/views/users/registrations/_passwd.html.erb new file mode 100644 index 0000000..68f5a38 --- /dev/null +++ b/app/views/users/registrations/_passwd.html.erb @@ -0,0 +1,44 @@ +
    +
    + <%= f.label :login, t(".username") %> +
    +
    + <%= f.text_field :login, "aria-describedby" => "login-field-description" %> + <%= live_validation_for_field("user_registration_login", + maximum_length: ArchiveConfig.LOGIN_LENGTH_MAX, + minimum_length: ArchiveConfig.LOGIN_LENGTH_MIN, + failureMessage: t(".username_validation", minimum: ArchiveConfig.LOGIN_LENGTH_MIN)) %> +

    + <%= t(".username_requirements", + minimum: ArchiveConfig.LOGIN_LENGTH_MIN, + maximum: ArchiveConfig.LOGIN_LENGTH_MAX) %> +

    +
    +
    + <%= f.label :password, t(".password") %> +
    +
    + <%= f.password_field :password, "aria-describedby" => "password-field-description" %> + <%= live_validation_for_field("user_registration_password", + minimum_length: ArchiveConfig.PASSWORD_LENGTH_MIN, + maximum_length: ArchiveConfig.PASSWORD_LENGTH_MAX, + failureMessage: t(".password_validation", minimum: ArchiveConfig.PASSWORD_LENGTH_MIN)) %> +

    + <%= t(".password_requirements", + minimum: ArchiveConfig.PASSWORD_LENGTH_MIN, + maximum: ArchiveConfig.PASSWORD_LENGTH_MAX) %> +

    +
    +
    <%= f.label :password_confirmation, t(".confirm_password") %>
    +
    + <%= f.password_field :password_confirmation %> + <%= live_validation_for_field("user_registration_password_confirmation", + minimum_length: ArchiveConfig.PASSWORD_LENGTH_MIN, + maximum_length: ArchiveConfig.PASSWORD_LENGTH_MAX, + failureMessage: t(".confirm_password_validation")) %> +
    +
    + <%= f.label :email, t(".valid_email") %> +
    +
    <%= f.text_field :email %>
    +
    diff --git a/app/views/users/registrations/new.html.erb b/app/views/users/registrations/new.html.erb new file mode 100644 index 0000000..56184ce --- /dev/null +++ b/app/views/users/registrations/new.html.erb @@ -0,0 +1,29 @@ + +

    <%= t(".heading") %>

    +<%= error_messages_for :user %> + + + + +<%= form_for resource, as: :user_registration, url: registration_path(resource_name), :html => {:id => "user_registration_form"} do |f| %> + <%= hidden_field_tag :invitation_token, resource.invitation_token %> + +
    + <%= t(".legend.user") %> + <%= render :partial => "passwd", :locals => {:f => f} %> +
    + +
    + <%= t(".legend.legal") %> + <%= render :partial => "legal", :locals => {:f => f} %> +
    + +
    + <%= t(".submit") %> +

    + <%= link_to t(".cancel"), root_path %> + <%= submit_tag t(".submit"), data: { disable_with: t(".wait") } %> +

    +
    +<% end %> + diff --git a/app/views/users/sessions/_greeting.html.erb b/app/views/users/sessions/_greeting.html.erb new file mode 100644 index 0000000..47fbf99 --- /dev/null +++ b/app/views/users/sessions/_greeting.html.erb @@ -0,0 +1,58 @@ + diff --git a/app/views/users/sessions/_login.html.erb b/app/views/users/sessions/_login.html.erb new file mode 100644 index 0000000..3342ba1 --- /dev/null +++ b/app/views/users/sessions/_login.html.erb @@ -0,0 +1,3 @@ + diff --git a/app/views/users/sessions/_passwd.html.erb b/app/views/users/sessions/_passwd.html.erb new file mode 100644 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 100644 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| %> +
    +
    <%= f.label :login, t(".username_or_email"), for: "user_session_login_small" %>
    +
    <%= f.text_field :login, autocomplete: "on", id: "user_session_login_small" %>
    +
    <%= f.label :password, t(".password"), for: "user_session_password_small" %>
    +
    <%= f.password_field :password, id: "user_session_password_small" %>
    +
    +

    + + <%= f.submit t(".log_in") %> +

    +<% end %> + +
      +
    • <%= link_to t(".forgot_password"), new_user_password_path %>
    • + <% if AdminSetting.current.account_creation_enabled? && !AdminSetting.current.creation_requires_invite? %> +
    • + <%= link_to t(".create_an_account"), signup_path %> +
    • + <% elsif AdminSetting.current.invite_from_queue_enabled? %> +
    • + <%= link_to t(".get_an_invitation"), invite_requests_path %> +
    • + <% end %> +
    diff --git a/app/views/users/sessions/confirm_logout.html.erb b/app/views/users/sessions/confirm_logout.html.erb new file mode 100644 index 0000000..10bb0b1 --- /dev/null +++ b/app/views/users/sessions/confirm_logout.html.erb @@ -0,0 +1,8 @@ +

    <%= ts("Log Out") %>

    + +<%= form_tag destroy_user_session_path, method: :delete, class: "simple destroy" do %> +

    <%= ts("Are you sure you want to log out?") %>

    +

    + <%= submit_tag ts("Yes, Log Out") %> +

    +<% end %> diff --git a/app/views/users/sessions/new.html.erb b/app/views/users/sessions/new.html.erb new file mode 100644 index 0000000..85215f8 --- /dev/null +++ b/app/views/users/sessions/new.html.erb @@ -0,0 +1,43 @@ +<% if params[:restricted] %> +

    <%= t(".restricted.sorry") %>

    +

    + <%= t(".restricted.work_unavailable") %> + <%= t(".restricted.account_exists") %> + <% if AdminSetting.current.invite_from_queue_enabled? && AdminSetting.current.creation_requires_invite? %> + <%= t(".restricted.no_account", request_invite_link: link_to(t(".restricted.request_invite"), invite_requests_path)).html_safe %> + <% elsif AdminSetting.current.account_creation_enabled? && !AdminSetting.current.creation_requires_invite? %> + <%= link_to t(".restricted.signup"), signup_path %> + <% end %> +

    +<% elsif params[:restricted_commenting] %> +

    <%= t(".restricted.sorry") %>

    +

    + <%= t(".restricted.commenting_unavailable") %> + <%= t(".restricted.account_exists") %> + <% if AdminSetting.current.invite_from_queue_enabled? && AdminSetting.current.creation_requires_invite? %> + <%= t(".restricted.no_account", request_invite_link: link_to(t(".restricted.request_invite"), invite_requests_path)).html_safe %> + <% elsif AdminSetting.current.account_creation_enabled? && !AdminSetting.current.creation_requires_invite? %> + <%= link_to t(".restricted.signup"), signup_path %> + <% end %> +

    +<% else %> +

    <%= t(".login.log_in") %>

    +

    + <%= t(".beta_reminder.reminder") %> + <%= t(".beta_reminder.warning") %> + <%= t(".beta_reminder.report_bugs", give_feedback_link: link_to(t(".beta_reminder.give_feedback"), feedbacks_path)).html_safe %> +

    +<% end %> + +
    + <%= render "users/sessions/passwd" %> +
    + +

    + <%= t(".login.forgot", reset_password_link: link_to(t(".login.reset_password"), new_user_password_path)).html_safe %> + <% if AdminSetting.current.invite_from_queue_enabled? && AdminSetting.current.creation_requires_invite? %> +
    <%= t(".login.no_account", join_link: link_to(t(".login.request_invite"), invite_requests_path)).html_safe %> + <% elsif AdminSetting.current.account_creation_enabled? && !AdminSetting.current.creation_requires_invite? %> +
    <%= t(".login.no_account", join_link: link_to(t(".login.create_account"), signup_path)).html_safe %> + <% end %> +

    diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb new file mode 100644 index 0000000..5f21f81 --- /dev/null +++ b/app/views/users/show.html.erb @@ -0,0 +1,37 @@ +<% if @user == current_user && @user.preference.try(:first_login) %> + +
    +

    + <%= t(".login_banner.welcome_html", + new_user_tips_link: link_to(t(".login_banner.new_user_tips"), first_login_help_path), + our_faqs_link: link_to(t(".login_banner.our_faqs"), archive_faqs_path)) %> +

    +

    + <%= t(".login_banner.help_html", + contact_support_link: link_to(t(".login_banner.contact_support"), new_feedback_report_path), + tos_link: link_to(t(".login_banner.tos"), tos_path), + content_policy_link: link_to(t(".login_banner.content_policy"), content_path), + privacy_policy_link: link_to(t(".login_banner.privacy_policy"), privacy_path), + contact_abuse_link: link_to(t(".login_banner.contact_abuse"), new_abuse_report_path)) %> +

    + <%= form_tag end_first_login_user_path(current_user), method: :post, remote: true do %> +

    + <%= submit_tag t(".login_banner.dismiss") %> + <%= link_to "×".html_safe, nil, remote: true, id: "hide-first-login-help", title: t(".login_banner.hide") %> +

    + <% end %> +
    +<% end %> + +
    + <%= render "users/header" %> + <%= render "users/contents" %> +
    + +<%= content_for :footer_js do %> + <%= javascript_tag do %> + $j("#hide-first-login-help").click(function() { + $j("#first-login-help-banner").hide(); + }) + <% end %> +<% end %> diff --git a/app/views/works/_adult.html.erb b/app/views/works/_adult.html.erb new file mode 100644 index 0000000..9d6b41d --- /dev/null +++ b/app/views/works/_adult.html.erb @@ -0,0 +1,29 @@ +

    <%= t(".page_title") %>

    + +

    + <%= t(".caution") %> +

    + +
      +
    • + <%= link_to t(".navigation.continue"), current_path_with(view_adult: true) %> +
    • +
    • + <%= link_to t(".navigation.back"), :back %> +
    • + <% if logged_in? %> +
    • + <%= link_to t(".navigation.preferences"), user_preferences_path(current_user) %> +
    • + <% end %> +
    + +

    + <%= t(".footnote") %> +

    + +
    + +
      + <%= render "works/work_blurb", work: @work %> +
    diff --git a/app/views/works/_collection_filters.html.erb b/app/views/works/_collection_filters.html.erb new file mode 100644 index 0000000..e939239 --- /dev/null +++ b/app/views/works/_collection_filters.html.erb @@ -0,0 +1,57 @@ +<%= form_for @search, as: :work_search, + url: collected_user_works_path(@user), + html: { + method: :get, + class: 'narrow-hidden filters', + id: 'works-in-collections-filters' + } do |f| %> +

    <%= ts("Filters") %>

    + <%= field_set_tag (ts('Filter results:') + link_to_help("filters")).html_safe do %> + +

    + <%= link_to ts("Clear Filters"), collected_user_works_path(@user) %> +

    +
    + <%= hidden_field_tag("user_id", @user.login) if @user %> +
    + <% end %> + <% # On narrow screens, link jumps to top of index when JavaScript is disabled and closes filters when JavaScript is enabled %> + +<% end %> + diff --git a/app/views/works/_filters.html.erb b/app/views/works/_filters.html.erb new file mode 100644 index 0000000..62f6121 --- /dev/null +++ b/app/views/works/_filters.html.erb @@ -0,0 +1,199 @@ +<%= form_for @search, as: :work_search, + url: (@collection ? collection_works_path(@collection) : works_path), + html: { + method: :get, + class: "narrow-hidden filters", + id: "work-filters" + } do |f| %> +

    <%= ts("Filters") %>

    + <%= field_set_tag ts("Filter results:") do %> +
    +
    <%= ts("Submit") %>
    +
    <%= f.submit ts("Sort and Filter") %>
    +
    + <%= f.label :sort_column, ts("Sort by") %> +
    +
    + <%= f.select :sort_column, options_for_select(@search.sort_options, @search.sort_column) %> +
    + + <% %w(include exclude).each do |filter_action| %> +
    +

    + <%= ts("%{filter_action}", filter_action: filter_action.titleize) %> +

    + <%= link_to_help "work-filters-#{filter_action}-tags" %> +
    +
    +
    + <% %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 %> +
    + + <% end %> + <% field_name = filter_action == "include" ? "other_tag_names" : "excluded_tag_names" %> + + +
    +
    + <% end %> + +
    +

    <%= ts("More Options") %>

    +
    + +
    +
    +
    <%= ts("Crossovers") %>
    + +
    <%= ts("Completion Status") %>
    + +
    <%= ts("Word Count") %>
    + +
    <%= ts("Date Updated") %>
    + + + + + + <% unless @language %> +
    + <%= f.label :language_id, ts("Language") %> +
    +
    + <%= f.select(:language_id, language_options_for_select(Language.default_order, "short"), include_blank: true) %> +
    + <% end %> +
    +
    +
    <%= ts("Submit") %>
    +
    <%= f.submit ts("Sort and Filter") %>
    +
    + + <% if @owner %> +

    + <%= link_to ts("Clear Filters"), works_original_path %> +

    + <% end %> + +
    + <%= hidden_field_tag("collection_id", @collection.id) if @collection %> + <%= hidden_field_tag("tag_id", @tag.to_param) if @tag %> + <%= hidden_field_tag("fandom_id", @fandom.id) if @fandom %> + <%= hidden_field_tag("pseud_id", @pseud.name) if @pseud %> + <%= hidden_field_tag("user_id", @user.login) if @user %> + <%= hidden_field_tag("language_id", @language.short) if @language %> +
    + + <% end %> + + <% # On narrow screens, link jumps to top of index when JavaScript is disabled and closes filters when JavaScript is enabled %> + + +<% end %> + +<%= content_for :footer_js do %> + <%= javascript_tag do %> + $j(".datepicker").datepicker({ + dateFormat: "yy-mm-dd" + }); + <% end %> +<% end %> diff --git a/app/views/works/_hidden_fields.html.erb b/app/views/works/_hidden_fields.html.erb new file mode 100644 index 0000000..a028c2e --- /dev/null +++ b/app/views/works/_hidden_fields.html.erb @@ -0,0 +1,55 @@ + + + +
    + <%= form.fields_for :author_attributes do |creator_form| %> + <% (@work.current_user_pseuds || []).each do |pseud| %> + <%= creator_form.hidden_field :ids, value: pseud.id, multiple: true %> + <% end %> + + <% @work.creatorships.each do |creatorship| %> + <% if creatorship.new_record? %> + <%= creator_form.hidden_field :coauthors, value: creatorship.pseud_id, multiple: true %> + <% end %> + <% end %> + <% end %> + +<%= form.hidden_field :rating_string, :value => "#{@work.rating_string}" %> +<%= form.hidden_field :archive_warning_string, :value => "#{@work.archive_warning_string}" %> +<%= form.hidden_field :category_string, :value => "#{@work.category_string}" %> +<%= form.hidden_field :fandom_string, :value => "#{@work.fandom_string}" %> +<%= form.hidden_field :relationship_string, :value => "#{@work.relationship_string}" %> +<%= form.hidden_field :character_string, :value => "#{@work.character_string}" %> +<%= form.hidden_field :freeform_string, :value => "#{@work.freeform_string}" %> + +<%= form.hidden_field :title, :value => "#{@work.title}" %> +<%= form.hidden_field :summary, :value => "#{@work.summary}" %> +<%= form.hidden_field :collection_names, :value => "#{@work.collection_names}" %> +<%= form.hidden_field :recipients, :value => "#{@work.recipients}" %> +<%= form.hidden_field :notes, :value => "#{@work.notes}" %> +<%= form.hidden_field :endnotes, :value => "#{@work.endnotes}" %> +<%= fields_for "work[chapter_attributes]", @chapter do |c| %> + <%= c.hidden_field :content, :value => "#{@chapter.content}" %> + <%= c.hidden_field :title, :value => "#{@chapter.title}" %> + <%= c.hidden_field :published_at, :value => "#{@chapter.published_at}" %> +<% end %> +<%= fields_for "work[series_attributes]" do |s| %> + <%= s.hidden_field :id, value: "#{work_series_value(:id)}" %> + <%= s.hidden_field :title, value: "#{work_series_value(:title)}" %> +<% end %> + +<%= form.fields_for :parent_work_relationships, @work.parent_work_relationships.select(&:new_record?) do |p| %> + <%= p.hidden_field :url %> + <%= p.hidden_field :title %> + <%= p.hidden_field :author %> + <%= p.hidden_field :language_id %> + <%= p.hidden_field :translation %> +<% end %> + +<%= form.hidden_field :backdate, :value => "#{@work.backdate}" %> +<%= form.hidden_field :wip_length, :value => "#{@work.wip_length}" %> +<%= form.hidden_field :restricted, :value => "#{@work.restricted}" %> +<%= form.hidden_field :comment_permissions, :value => "#{@work.comment_permissions}" %> +<%= form.hidden_field :language_id, :value => "#{@work.language_id}" %> +<%= form.hidden_field :work_skin_id, :value => "#{@work.work_skin_id}" %> +
    diff --git a/app/views/works/_meta.html.erb b/app/views/works/_meta.html.erb new file mode 100755 index 0000000..a122e89 --- /dev/null +++ b/app/views/works/_meta.html.erb @@ -0,0 +1,117 @@ +<% cache_unless(@preview_mode, "/v4/#{work.cache_key}-#{Work.work_blurb_version(work.id)}-meta-#{hide_warnings?(work) ? 'nowarn' : 'showwarn'}-#{hide_freeform?(work) ? 'nofreeform' : 'showfreeform'}", skip_digest: true) do %> +

    <%= ts("Work Header") %>

    + +
    + +
    + <% tag_group = work.tag_groups + Tag::VISIBLE.each do |type| %> + <% tags = tag_group[type] %> + <% unless tags.blank? %> +
    + + <% if type == "ArchiveWarning" %> + <%= link_to( tags.size == 1 ? type.constantize::NAME : type.constantize::NAME.pluralize, tos_faq_path(:anchor => "tags") ) %>: + <% elsif type.constantize::NAME == ArchiveConfig.FREEFORM_CATEGORY_NAME %> + <%= type.constantize::NAME %>: + <% else %> + <%= tags.size == 1 ? type.constantize::NAME : type.constantize::NAME.pluralize %>: + <% end %> +
    + +
    +
      + <% if (type == "ArchiveWarning" && hide_warnings?(work)) || (type == "Freeform" && hide_freeform?(work)) %> + <%= show_hidden_tag_link_list_item(work, type, { suppress_toggle_class: true }) %> + <% else %> + <%= tag_link_list(tags, link_to_works=true) %> + <% end %> +
    +
    + <% end %> + <% end %> + + <% unless @work.language.blank? %> +
    + <%= ts("Language:") %> +
    +
    + <%= @work.language.name %> +
    + <% end %> + + <% unless @work.serial_works.blank? %> +
    + <%= ts("Series:") %> +
    +
    + <%= show_series_data(@work) %> +
    + <% end %> + + <% unless @work.approved_collections.empty? %> +
    + <%= ts('Collections:') %> +
    +
    + <%= show_collections_data(@work.approved_collections) %> +
    + <% end %> + +
    <%= ts("Stats:") %>
    +
    +<% end %> + + <%= work_meta_list(@work, @chapter) %> +
    + <% if policy(:user_creation).show_original_creators? %> + <% if @work.original_creators.any? %> + <% original_creators = @work.original_creators.map(&:display) %> +
    + <%= t('.original_creators', count: original_creators.count) %> +
    +
    + <%= original_creators.join(", ") %> +
    + <% end %> + <% end %> + <% if policy(:user_creation).show_ip_address? %> +
    + <%= ts('IP Address:') %> +
    +
    + <% if @work.ip_address.nil? %> + <%= ts('No address recorded') %> + <% else %> + <%= @work.ip_address %> + <% end %> +
    + <% end %> + <% if logged_in_as_admin? %> +
    + <%= ts('Akismet Reported As Spam:') %> +
    +
    + <% if !@work.spam_checked? %> + <%= ts('No result recorded') %> + <% else %> + <%= @work.spam? %> + <% end %> +
    +
    + <%= ts("Over Tag Limit:") %> +
    +
    + <%= @work.user_defined_tags_count > ArchiveConfig.USER_DEFINED_TAGS_MAX ? ts("Yes") : ts("No") %> +
    + <% if @work.gifts.are_rejected.exists? %> +
    + <%= ts('Refused As Gift:') %> +
    +
    + <%= @work.gifts.are_rejected.map { |g| g.recipient }.join(", ") %> +
    + <% end %> + <% end %> +
    +
    diff --git a/app/views/works/_mystery_blurb.html.erb b/app/views/works/_mystery_blurb.html.erb new file mode 100644 index 0000000..9b7cb6b --- /dev/null +++ b/app/views/works/_mystery_blurb.html.erb @@ -0,0 +1,11 @@ +
    +

    <%= ts("Mystery Work") %>

    + <% if item.approved_collections.unrevealed.present? %> +
    <%= h(ts("Part of ")) + show_collections_data(item.approved_collections.unrevealed) %>
    + <% end %> +
    +
    +
    <%= ts("Summary") %>
    +
    +

    <%= ts("This is part of an ongoing challenge and will be revealed soon!") %>

    +
    diff --git a/app/views/works/_notes_form.html.erb b/app/views/works/_notes_form.html.erb new file mode 100644 index 0000000..eb39ecc --- /dev/null +++ b/app/views/works/_notes_form.html.erb @@ -0,0 +1,40 @@ +
    <%= type == "chapter" ? ts("Chapter Notes") : ts("Notes") %>
    +
    +
      +
    • + <% if !params[:claim_id].blank? %> + <%= check_box_tag "front-notes-options-show", "1", true, :class => "toggle_formfield" %> + <%= label_tag 'front-notes-options-show', ts("at the beginning") %> + <% else %> + <%= check_box_tag "front-notes-options-show", "1", !f.object.notes.blank?, :class => "toggle_formfield" %> + <%= label_tag 'front-notes-options-show', ts("at the beginning") %> + <% end %> + + <%= ts("Warning: Unchecking this box will delete the existing beginning note.") %> + +
      + <%= f.label :notes, ts("Notes") %> + <% if @posting_claim && @posting_claim.prompt_description.present? %> + <%= f.text_area :notes, :class => "observe_textlength", :value => "Prompt: #{@posting_claim.prompt_description}" %> + <%= generate_countdown_html("#{type}_notes", ArchiveConfig.NOTES_MAX) %> + <% else %> + <%= f.text_area :notes, :class => "observe_textlength" %> + <%= generate_countdown_html("#{type}_notes", ArchiveConfig.NOTES_MAX) %> + <% end %> +
      +
    • + +
    • + <%= check_box_tag "end-notes-options-show", "1", !f.object.endnotes.blank?, :class => "toggle_formfield" %> + <%= label_tag 'end-notes-options-show', ts("at the end") %> + + <%= ts("Warning: Unchecking this box will delete the existing end note.") %> + +
      + <%= f.label :endnotes, ts("End Notes") %> + <%= f.text_area :endnotes, :class => "observe_textlength" %> + <%= generate_countdown_html("#{type}_endnotes", ArchiveConfig.NOTES_MAX) %> +
      +
    • +
    +
    diff --git a/app/views/works/_search_box.html.erb b/app/views/works/_search_box.html.erb new file mode 100644 index 0000000..fb18011 --- /dev/null +++ b/app/views/works/_search_box.html.erb @@ -0,0 +1,10 @@ +<%= form_for WorkSearchForm.new, as: :work_search, url: search_works_path, html: { class: "search", id: "search", role: "search", "aria-label": t(".a11y_label"), method: :get } do |f| %> +
    +

    + <%= label_tag :site_search, t(".label"), class: "landmark" %> + <%= f.text_field :query, class: "text", id: "site_search", "aria-describedby": "site_search_tooltip" %> + <%= t(".tooltip_label") %> <%= random_search_tip %> + <%= submit_tag t(".submit"), class: "button", name: nil %> +

    +
    +<% end %> diff --git a/app/views/works/_search_form.html.erb b/app/views/works/_search_form.html.erb new file mode 100644 index 0000000..6d69d83 --- /dev/null +++ b/app/views/works/_search_form.html.erb @@ -0,0 +1,212 @@ +<%= form_for @search, as: :work_search, url: search_works_path, html: { method: :get, class: "verbose search" } do |f| %> +
    + <%= ts("Work Info") %> +

    <%= ts("Work Info") %>

    +

    <%= f.submit ts("Search") %>

    +
    +
    + <%= f.label :query, ts("Any Field") %> + <%= link_to_help "work-search-text-help" %> +
    +
    + <%= f.text_field :query %> +
    +
    + <%= f.label :title, ts("Title") %> +
    +
    + <%= f.text_field :title %> +
    +
    + <%= f.label :creators, t(".creator") %> +
    +
    + <%= f.text_field :creators %> +
    +
    + <%= f.label :revised_at, ts("Date") %> + <%= link_to_help "work-search-date-help" %> +
    +
    + <%= f.text_field :revised_at %> +
    +
    <%= ts("Completion status") %>
    +
    +
      +
    • + <%= f.radio_button :complete, "" %> + <%= f.label :complete, ts("All works"), value: "" %> +
    • +
    • + <%= f.radio_button :complete, "T" %> + <%= f.label :complete, ts("Complete works only"), value: "T" %> +
    • +
    • + <%= f.radio_button :complete, "F" %> + <%= f.label :complete, ts("Works in progress only"), value: "F" %> +
    • +
    +
    +
    + <%= ts("Crossovers") %> + <%= link_to_help "work-search-crossover-help" %> +
    +
    +
      +
    • + <%= f.radio_button :crossover, "" %> + <%= f.label :crossover, ts("Include crossovers"), value: "" %> +
    • +
    • + <%= f.radio_button :crossover, "F" %> + <%= f.label :crossover, ts("Exclude crossovers"), value: "F" %> +
    • +
    • + <%= f.radio_button :crossover, "T" %> + <%= f.label :crossover, ts("Only crossovers"), value: "T" %> +
    • +
    +
    +
    + <%= f.label :single_chapter, ts("Single Chapter") %> +
    +
    + <%= f.check_box :single_chapter %> +
    +
    + <%= f.label :word_count, ts("Word Count") %> + <%= link_to_help "work-search-numerical-help" %> +
    +
    + <%= f.text_field :word_count %> +
    +
    + <%= f.label :language_id, ts("Language") %> + <%= link_to_help "work-search-language-help" %> +
    +
    + <%= f.select(:language_id, language_options_for_select(@languages, "short"), include_blank: true) %> +
    +
    +
    + +
    + <%= ts("Work Tags") %> <%= link_to_help "work-search-tags-help" %> +

    <%= ts("Work Tags") %>

    +
    +
    + <%= f.label :fandom_names, ts("Fandoms") %> +
    +
    + <%= f.text_field :fandom_names, autocomplete_options("fandom") %> +
    +
    + <%= f.label :rating_ids, ts("Rating") %> +
    +
    + <%= f.collection_select :rating_ids, + Rating.defaults_by_severity, + :id, + :name, + include_blank: true %> +
    +
    + <%= ts("Warnings") %> +
    +
    +
      + <% for tag in ArchiveWarning.canonical.by_name %> +
    • + <%= check_box_tag "work_search[archive_warning_ids][]", tag.id, @search.archive_warning_ids.present? && @search.archive_warning_ids.include?(tag.id.to_s), :id => "warning_#{tag.id}" %> + <%= label_tag "warning_#{tag.id}", warning_display_name(tag.name) %> +
    • + <% end %> +
    +
    +
    + <%= ts("Categories") %> +
    +
    +
      + <% for tag in Category.canonical.by_name.sort %> +
    • + <%= check_box_tag "work_search[category_ids][]", tag.id, @search.category_ids.present? && @search.category_ids.include?(tag.id.to_s), :id => "category_#{tag.id}" %> + <%= label_tag "category_#{tag.id}", tag.name %> +
    • + <% end %> +
    +
    +
    + <%= f.label :character_names, ts("Characters") %> +
    +
    + <%= f.text_field :character_names, autocomplete_options("character") %> +
    +
    + <%= f.label :relationship_names, ts("Relationships") %> +
    +
    + <%= f.text_field :relationship_names, autocomplete_options("relationship") %> +
    +
    + <%= f.label :freeform_names, ts("Additional Tags") %> +
    +
    + <%= f.text_field :freeform_names, autocomplete_options("freeform") %> +
    +
    +
    + +
    + <%= ts("Work Stats") %> <%= link_to_help "work-search-numerical-help" %> +

    <%= ts("Work Stats") %>

    +
    +
    + <%= f.label :hits, ts("Plays") %> +
    +
    + <%= f.text_field :hits %> +
    +
    + <%= f.label :kudos_count, ts("Applause") %> +
    +
    + <%= f.text_field :kudos_count %> +
    +
    + <%= f.label :comments_count, ts("Comments") %> +
    +
    + <%= f.text_field :comments_count %> +
    +
    + <%= f.label :bookmarks_count, ts("Bookmarks") %> +
    +
    + <%= f.text_field :bookmarks_count %> +
    +
    +
    + +
    + <%= ts("Search") %> +

    <%= ts("Search") %>

    +
    +
    + <%= f.label :sort_column, ts("Sort by") %> +
    +
    + <%= f.select :sort_column, options_for_select(@search.sort_options, @search.sort_column) %> +
    +
    + <%= f.label :sort_direction, ts("Sort direction") %> +
    +
    + <%= f.select :sort_direction, + options_for_select([["Ascending", "asc"], ["Descending", "desc"]], @search.sort_direction) %> +
    +
    +

    <%= f.submit ts("Search") %>

    +
    + +<% end %> diff --git a/app/views/works/_standard_form.html.erb b/app/views/works/_standard_form.html.erb new file mode 100644 index 0000000..32ac453 --- /dev/null +++ b/app/views/works/_standard_form.html.erb @@ -0,0 +1,378 @@ + +<%= form_for(@work, html: { id: "work-form", class: "verbose post work" }) do |f| %> + +

    * <%= ts("Required information") %>

    + + <%= render "work_form_tags", include_blank: false %> + +
    + <%= t("works.preface.title") %> +

    <%= t("works.preface.title") %>

    +
    +
    + <%= f.label :title, ts("Work Title") + "*" %> +
    +
    + <%= f.text_field :title, class: "observe_textlength text" %> + <%= live_validation_for_field("work_title", + maximum_length: ArchiveConfig.TITLE_MAX, + minimum_length: ArchiveConfig.TITLE_MIN, + failureMessage: ts("We need a title! (At least %{min_length} character long, please.)", + min_length: ArchiveConfig.TITLE_MIN.to_s)) + %> + <%= generate_countdown_html("work_title", ArchiveConfig.TITLE_MAX) %> +
    + + + <%= render "pseuds/byline", form: f, object: @work %> + +
    + <%= f.label :summary, ts("Summary", max: ArchiveConfig.SUMMARY_MAX.to_s) %> +
    +
    + <%= f.text_area :summary, class: "observe_textlength" %> + <%= live_validation_for_field("work_summary", presence: false, + maximum_length: ArchiveConfig.SUMMARY_MAX) %> + <%= generate_countdown_html("work_summary", ArchiveConfig.SUMMARY_MAX) %> +
    + + + <%= render "notes_form", f: f, type: "work" %> + +
    +
    + +
    + <%= t("works.associations.title") %> +

    <%= t("works.associations.title") %>

    +
    + <% @assignments = get_open_assignments(current_user) %> + <% if @assignments.any? || @work.challenge_assignments.any? %> + +
    + <%= f.label "challenge_assignment_ids[]", ts("Does this fulfill a challenge assignment") %> + <%= link_to_help "add-work-to-assignment" %> +
    +
    +

    <%= ts("Open Assignments") %>

    + <%= checkbox_section f, :challenge_assignment_ids, (@work.challenge_assignments + @assignments).uniq, + :checked_method => "challenge_assignments", :name_method => "title" %> +
    + <% end %> + + <% if !(@claims = current_user.request_claims.unstarted).empty? || !@work.challenge_claims.empty? %> + +
    + <%= f.label "challenge_claim_ids[]", ts("Fulfill a Claim") %> + <%= link_to_help "add-work-to-assignment" %> +
    +
    +

    <%= ts("Open Claims") %>

    + <%= checkbox_section f, :challenge_claim_ids, (@work.challenge_claims + @claims).uniq, + :checked_method => "challenge_claims", :name_helper_method => "claim_title" %> +
    + <% end %> + + +
    "> + <%= f.label :collection_names, ts("Post to Collections / Challenges") %> + <%= link_to_help "add-collectible-to-collection" %> +
    +
    "> + <%= f.text_field :collection_names, autocomplete_options("open_collection_names") %> +
    + +
    "> + <%= f.label :recipients, ts("Gift this work to") %> + <%= link_to_help "recipients" %> +
    +
    "> + <%= f.text_field :recipients, autocomplete_options("pseud", value: @work.recipients(for_form = true)) %> +
    + + +
    + <%= check_box_tag "parent-options-show", "1", check_parent_box(@work), class: "toggle_formfield" %> +
    +
    + <%= 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" %> + + +
    +
    + <% new_parent, existing_parents = @work.parent_work_relationships.partition(&:new_record?) %> + <% new_parent << @work.parent_work_relationships.build if new_parent.empty? %> + + <%= f.fields_for :parent_work_relationships, new_parent do |p| %> +
    <%= p.label :url, ts("URL") %>
    +
    + <%= p.text_field :url, + "aria-describedby" => "parent-attributes-url-field-description" %> +

    + <%= ts("For a work in the Archive, only the URL is required.") %> +

    +
    + +
    <%= p.label :title %>
    +
    <%= p.text_field :title %>
    + +
    <%= p.label :author %>
    +
    <%= p.text_field :author %>
    + +
    + <%= p.label :language_id, ts("Language") %> + <%= link_to_help "languages-help" %> +
    +
    + <%= p.select(:language_id, language_options_for_select(sorted_languages, "id"), prompt: " ") %> +
    + +
    <%= p.check_box :translation %>
    +
    + <%= p.label :translation, ts("This is a translation") %> + <%= link_to_help "translation-link" %> +
    + <% end %> + + <% unless existing_parents.blank? %> +
    <%= ts("Current parent works") %>
    + <% existing_parents.each do |related_work| %> + <% if related_work.parent %> +
    + +
    + <% end %> + <% end %> + <% end %> +
    +
    +
    + + + +
    + <%= check_box_tag "series-options-show", "1", check_series_box(@work), class: "toggle_formfield" %> +
    +
    + <%= label_tag "series-options-show", ts("This work is part of a series") %> + <%= link_to_help "choosing-series" %> + + +
    +
    + <%= fields_for "work[series_attributes]" do |s| %> +
    <%= s.label "id", ts("Choose one of your existing series:") %>
    +
    + <%= s.collection_select(:id, @series, :id, :title, { selected: work_series_value(:id), prompt: true }) %> +
    + +
    <%= s.label :title, ts("Or create and use a new one:") %>
    +
    <%= s.text_field :title, value: work_series_value(:title) %>
    + <% end %> + + <% unless @serial_works.blank? || @work.new_record? %> +
    <%= ts("Current Series") %>
    + <% for serial in @serial_works %> + <% unless serial.new_record? %> +
    + +
    + <% end %> + <% end %> + <% end %> +
    +
    +
    + + <%= fields_for "work[chapter_attributes]", @chapter do |c| %> + + +
    + <%= check_box_tag "chapters-options-show", "1", @work.chaptered?, class: "toggle_formfield" %> +
    +
    + <%= label_tag "chapters-options-show", ts("This work has multiple chapters") %> + +
    +
    +
    <%= f.label :wip_length, ts("Chapter 1 of") %>
    +
    <%= f.text_field :wip_length, class: "number" %>
    +
    <%= c.label :title, ts("Chapter Title:") %>
    +
    <%= c.text_field :title, value: @chapter.title %>
    +
    +
    +
    + + +
    + <%= f.check_box :backdate, { id: "backdate-options-show", class: "toggle_formfield" } %> +
    +
    + <%= label_tag "backdate-options-show", ts("Set a different publication date") %> + <%= link_to_help "backdating-help" %> + +
    +
    +
    <%= label_tag "published_at", ts("Set publication date") %>
    +
    "> + + <%= c.date_select("published_at", :start_year => Date.current.year, :end_year => 1950, :default => Date.current, :value => @chapter.published_at, :order => [:day, :month, :year]) %> +
    +
    +
    +
    + + <%= render "work_form_associations_language", f: f %> + +
    + <%= f.label :work_skin_id, t("works.skin.select") %> + <%= link_to_help "work-skins" %> +
    +
    + <%= f.collection_select :work_skin_id, all_coauthor_skins, :id, :title, + { :selected => (@work.work_skin ? @work.work_skin.id.to_s : nil), include_blank: true } %> + +
    +
    + +
    + + +
    + <%= t("works.permissions.privacy") %> +

    <%= t("works.permissions.privacy") %>

    +
    + +
    + <%= f.check_box :restricted %> +
    + +
    + <%= f.label :restricted, t("works.permissions.visibility.restricted") %> + <%= link_to_help "registered-users" %> +
    + +
    + <%= f.check_box :moderated_commenting_enabled %> +
    +
    + <%= f.label :moderated_commenting_enabled, t("comments.commentable.permissions.moderated_commenting.enable") %> + <%= link_to_help "comments-moderated" %> +
    + +
    + <%= t("comments.commentable.permissions.options.label") %> + <%= link_to_help "who-can-comment-on-this-work" %> +
    +
    +
    + <%= + radio_button_list(f, :comment_permissions, [ + [:enable_all, t("comments.commentable.permissions.options.enable_all")], + [:disable_anon, t("comments.commentable.permissions.options.disable_anon")], + [:disable_all, t("comments.commentable.permissions.options.disable_all")] + ]) + %> +
    +
    + +
    +
    + <% unless @chapters %> + +
    + <%= ts("Work Text") %>* + +

    + <%= allowed_html_instructions %> + +

    +

    + <%= 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 %> + + <% end %> + +
    + <%= ts("Post") %> +

    + <%= t(".post_notice_html", + content_policy_link: link_to(t(".content_policy"), content_path), + tos_faq_link: link_to(t(".tos_faq"), tos_faq_path(anchor: "content_faq"))) %> +

    +
      + <% unless @work.new_record? || @work.posted? %> +
    • + <%= submit_tag ts("Save As Draft"), name: "save_button" %> +
    • + <% end %> +
    • + <%= submit_tag ts("Preview"), name: "preview_button" %> +
    • +
    • + <%= submit_tag ts("Post"), name: "post_button", + data: { disable_with: ts("Please wait...") } %> +
    • +
    • + <%= submit_tag ts("Cancel"), name: "cancel_button" %> +
    • +
    +
    +<% 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 100644 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 work.title, work %> + (<%= tag_groups["Fandom"].collect {|tag| link_to_tag_works(tag) }.join(', ').html_safe %>) +
    +
    +
      + <%= blurb_tag_block(work) %> +
    +
    + <% end %> +
    + diff --git a/app/views/works/_work_approved_children.html.erb b/app/views/works/_work_approved_children.html.erb new file mode 100644 index 0000000..9f7ee8f --- /dev/null +++ b/app/views/works/_work_approved_children.html.erb @@ -0,0 +1,15 @@ + + +
    +

    <%= t(".inspired_by.title") %>:

    +
      + <%# i18n-tasks-use t("works.work_approved_children.inspired_by.restricted_html") %> + <%# i18n-tasks-use t("works.work_approved_children.inspired_by.revealed_html") %> + <%# i18n-tasks-use t("works.work_approved_children.inspired_by.unrevealed") %> + <% for child_work in inspired_by %> +
    • + <%= related_work_note(child_work.work, "inspired_by") %> +
    • + <% end %> +
    +
    diff --git a/app/views/works/_work_blurb.html.erb b/app/views/works/_work_blurb.html.erb new file mode 100644 index 0000000..74ff518 --- /dev/null +++ b/app/views/works/_work_blurb.html.erb @@ -0,0 +1,22 @@ +<% # expects "work" %> +
  • + <%= render "works/work_module", work: work %> + <% if is_author_of?(work) %> +
    <%= ts("Author Actions") %>
    + + <% end %> +
  • diff --git a/app/views/works/_work_endnotes.html.erb b/app/views/works/_work_endnotes.html.erb new file mode 100644 index 0000000..d3cd7fd --- /dev/null +++ b/app/views/works/_work_endnotes.html.erb @@ -0,0 +1,6 @@ + +
    +

    <%= ts("Artist Notes:") %>

    +
    <%=raw sanitize_field(@work, :endnotes) %>
    +
    + 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 100644 index 0000000..ae20bac --- /dev/null +++ b/app/views/works/_work_form_associations_language.html.erb @@ -0,0 +1,11 @@ +<% # expects local variable f %> +
    + <%= f.label :language_id, "#{t('works.associations.language.choose')} *" %> + <%= link_to_help "languages-help" %> +
    +
    + +
    diff --git a/app/views/works/_work_form_pseuds.html.erb b/app/views/works/_work_form_pseuds.html.erb new file mode 100644 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 %> +
    <%= form.label :current_user_pseud_ids, ts("Creator/Pseud(s)") %>
    +
    + <% if has_multiple_pseuds %> + <%= form.collection_select :current_user_pseud_ids, current_user.pseuds, :id, :name, { include_hidden: false }, { multiple: true } %> + <% end %> + <% if has_cocreators %> + <%= label_tag :remove_me, class: "action" do %> + <%= check_box_tag :remove_me, "1", false %> + <%= ts("Remove me as co-creator") %> + <% end %> + <% end %> +
    +<% end %> + +
    <%= form.label :pseuds_to_add, t("works.byline.add_co-creators") %>
    +
    <%= form.text_field :pseuds_to_add, autocomplete_options("pseud", data: { autocomplete_min_chars: 0 }) %>
    diff --git a/app/views/works/_work_form_tags.html.erb b/app/views/works/_work_form_tags.html.erb new file mode 100644 index 0000000..ccf7fe3 --- /dev/null +++ b/app/views/works/_work_form_tags.html.erb @@ -0,0 +1,120 @@ +
    + <%= ts('Tags') %> +

    <%= 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) %> +

    + +
    +
    + + <%= link_to_help "rating-help" %> +
    +
    + <%= collection_select :work, + :rating_string, + Rating.defaults_by_severity, + :name, + :name, + include_blank: include_blank, + selected: include_blank ? nil : + rating_selected(@work) %> +
    + +
    + + <%= link_to_help "warning-help" %> +
    +
    +
    + +
      + <%= collection_check_boxes( + :work, :archive_warning_strings, + ArchiveWarning.canonical.by_name, + :name, :name, + include_hidden: !include_blank + ) do |builder| %> +
    • + <%= builder.check_box %> + <%= builder.label %> +
    • + <% end %> +
    +
    +
    + +
    + + <%= link_to_help "fandom-help" %> +
    +
    + <%= text_field_tag "work[fandom_string]", include_blank ? "" : @work.fandom_string, + autocomplete_options('fandom', + :id => "work_fandom", + :title => ts('fandoms')) %> +

    <%= ts('If this is the first work for a fandom, it may not show up in the fandoms page for a day or two.') %>

    +
    + +
    + + <%= link_to_help "categories-help" %> +
    +
    +
    + +
      + <%= collection_check_boxes( + :work, :category_strings, + Category.canonical.by_name.sort, + :name, :name, + include_hidden: !include_blank + ) do |builder| %> +
    • + <%= builder.check_box %> + <%= builder.label %> +
    • + <% end %> +
    +
    +
    + +
    + + <%= link_to_help "relationships-help" %> +
    +
    + <%= text_field_tag "work[relationship_string]", include_blank ? "" : @work.relationship_string, + autocomplete_options("relationship_in_fandom", + id: "work_relationship", + title: ts('relationships'), + data: { autocomplete_live_params: "fandom=work_fandom" }) %> +
    + +
    + + <%= link_to_help "characters-help" %> +
    +
    + <%= text_field_tag "work[character_string]", include_blank ? "" : @work.character_string, + autocomplete_options('character_in_fandom', + id: "work_character", + title: ts('characters'), + data: { autocomplete_live_params: "fandom=work_fandom" }) %> +
    + +
    + + <%= link_to_help "additional-tags-help" %> +
    +
    + <%= text_field_tag "work[freeform_string]", include_blank ? "" : @work.freeform_string, autocomplete_options('freeform', + :id => "work_freeform", + :title => ts('additional tags')) %> +
    + +
    +
    diff --git a/app/views/works/_work_header.html.erb b/app/views/works/_work_header.html.erb new file mode 100644 index 0000000..1b22556 --- /dev/null +++ b/app/views/works/_work_header.html.erb @@ -0,0 +1,55 @@ +<% unless @preview_mode %> + <%= render "works/work_header_navigation" %> +<% end %> + +<%= render "works/meta", work: @work %> + +<% if @work.work_skin && !Preference.disable_work_skin?(params[:style]) %> + <% cache("#{@work.work_skin.id}-#{@work.work_skin.updated_at}-work-skin", skip_digest: true) do %> + <%= render "skins/skin_style_block", skin: @work.work_skin %> + <% end %> +<% end %> + + +
    +
    +

    + <% if @work.restricted %> + <%= image_tag("lockblue.png", size: "15x15", + alt: ts("(Restricted)"), + title: ts("Restricted"), + skip_pipeline: true) %> + <% end %> + <% if @work.hidden_by_admin %> + <%= image_tag("lockred.png", size: "15x15", + alt: ts("(Hidden by Admin)"), + title: ts("Hidden by Administrator"), + skip_pipeline: true) %> + <% end %> + <%= @work.title %> +

    + + + <% if @chapter == @work.first_chapter %> + + <% unless @work.summary.blank? %> +
    +
    <%= ts("Summary:") %>
    + <% unless @work.summary.blank? %> +
    + <%=raw sanitize_field(@work, :summary) %> +
    + <% end %> +
    + <% 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 100644 index 0000000..7217d15 --- /dev/null +++ b/app/views/works/_work_header_navigation.html.erb @@ -0,0 +1,122 @@ + +

    <%= ts("Actions") %>

    + + diff --git a/app/views/works/_work_header_notes.html.erb b/app/views/works/_work_header_notes.html.erb new file mode 100644 index 0000000..7966bea --- /dev/null +++ b/app/views/works/_work_header_notes.html.erb @@ -0,0 +1,79 @@ +
    +
    <%= ts("Artist 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 %> +
    • + <%= related_work_note(related_work.work, "translated_to") %> +
    • + <% else %> + <% related_works_link ||= link_to t(".inspired_by.other_works_inspired_by_this_one"), get_related_works_url %> + <% end %> + <% end %> + + <%# parent works %> + <%# i18n-tasks-use t("works.work_header_notes.translation_of.restricted_html") %> + <%# i18n-tasks-use t("works.work_header_notes.translation_of.revealed_html") %> + <%# i18n-tasks-use t("works.work_header_notes.translation_of.unrevealed") %> + <%# i18n-tasks-use t("works.work_header_notes.inspired_by.restricted_html") %> + <%# i18n-tasks-use t("works.work_header_notes.inspired_by.revealed_html") %> + <%# i18n-tasks-use t("works.work_header_notes.inspired_by.unrevealed") %> + <% for related_work in @work.parents_after_saving %> + <% if related_work.parent %> +
    • + <% relation = related_work.translation ? "translation_of" : "inspired_by" %> + <%= related_work_note(related_work.parent, relation) %> +
    • + <% end %> + <% end %> + + <%# prompts %> + <% @work.challenge_claims.each do |claim| %> + <% unless claim.request_prompt.nil? %> +
    • <%= ts("In response to a ") %><%= link_to("prompt", collection_prompt_path(claim.collection, claim.request_prompt)) %> <%= ts("by") %> + <% if claim.request_prompt.anonymous? %> + <%= ts("Anonymous in the ") %><%= link_to(claim.collection.title, collection_path(claim.collection)) %> + <% else %> + <%= link_to(claim.request_signup.pseud.byline, user_pseud_path(claim.request_signup.user, claim.request_signup.pseud)) %> <%= ts(" in the ") %> <%= link_to(claim.collection.title, collection_path(claim.collection)) %> + <% end %> + <%= ts("collection.") %> +
    • + <% end %> + <% end %> + <% if show_associations?(@work) %> +
    + <% end %> + + <%# notes %> + <% unless @work.notes.blank? %> +
    + <%=raw sanitize_field(@work, :notes) %> +
    + <% end %> + + <% if @work.endnotes.present? || related_works_link %> + <% endnotes_link = link_to (@work.notes.blank? ? t(".jump.notes") : t(".jump.more_notes")), get_endnotes_link(@work) %> +

    + <% if @work.endnotes.present? && related_works_link %> + <%= t(".jump.endnotes_and_related_works_html", endnotes_link: endnotes_link, related_works_link: related_works_link) %> + <% elsif @work.endnotes.present? %> + <%= t(".jump.endnotes_html", endnotes_link: endnotes_link) %> + <% elsif related_works_link %> + <%= t(".jump.related_works_html", related_works_link: related_works_link) %> + <% end %> +

    + <% end %> +
    diff --git a/app/views/works/_work_module.html.erb b/app/views/works/_work_module.html.erb new file mode 100644 index 0000000..231dbaa --- /dev/null +++ b/app/views/works/_work_module.html.erb @@ -0,0 +1,120 @@ +<% # expects "work" %> +<% if work.unrevealed? && !is_author_of?(work) %> + <%= render "works/mystery_blurb", item: work %> +<% else %> + +
    + +

    + <% if (work.unrevealed? || work.anonymous?) && is_author_of?(work) %> + + <% if work.unrevealed? %> + <%= ts("Unrevealed:") %> + <% elsif work.anonymous? %> + <%= ts("Anonymous:") %> + <% end %> + + <% end %> + <%= link_to work.title, @collection ? collection_work_path(@collection, work) : work %> + <%= ts('by') %> + + + <%= byline(work, visibility: "public") %> + +<% #### CACHE show/hide ####, if you update the key edit lib/tasks/memcached.rake %> +<% cache("#{work.cache_key}-#{Work.work_blurb_version(work.id)}-#{hide_warnings?(work) ? 'nowarn' : 'showwarn'}-#{hide_freeform?(work) ? 'nofreeform' : 'showfreeform'}-v10", skip_digest: true) do %> + + <% tag_groups = work.tag_groups %> + + <% if work.gifts.not_rejected.exists? %> + <%= ts("for") %> <%= recipients_link(work) %> + <% end %> + <% if work.restricted %><%= image_tag("lockblue.png", :size => "15x15", :alt => "(Restricted)", :title => "Restricted", skip_pipeline: true) %><% end %> + <% if work.hidden_by_admin %><%= image_tag("lockred.png", :size => "15x15", :alt => "(Hidden by Admin)", :title => "Hidden by Administrator", skip_pipeline: true) %><% end %> +

    + +
    + <%= ts("Fandoms") %>: + <% fandoms = tag_groups['Fandom'] %> + <%= fandoms.collect{|tag| link_to_tag_works(tag) }.join(', ').html_safe if fandoms %> +   +
    + + + <%= get_symbols_for(work, tag_groups) %> +

    <%= set_format_for_date(work.revised_at) %>

    +
    + + <% if !work.posted? %> +

    <%= t('.draft_deletion_notice_html', deletion_date: date_in_zone(work.created_at + 29.days)) %>

    + <% end %> + +
    <%= ts("Tags") %>
    +
      + <%= blurb_tag_block(work, tag_groups) %> +
    + + + <% unless work.summary.blank? %> +
    <%= ts("Summary") %>
    +
    + <%=raw strip_images(sanitize_field(work, :summary)) %> +
    + <% end %> + + <% unless work.series.empty? %> +
    <%= ts("Series") %>
    +
      + <% work.series.each do |series| %> +
    • + <%= work_series_description(work, series) %> +
    • + <% end %> +
    + <% end %> + + + +
    + <% if work.language.present? %> +
    <%= ts("Language") %>:
    +
    <%= work.language.name %>
    + <% end %> +
    <%= ts('Words') %>:
    +
    <%= number_with_delimiter(work.word_count) %>
    +
    <%= ts('Chapters') %>:
    +
    <%= chapter_total_display_with_link(work) %>
    <% end %> +<% #### END CACHE show/hide %> + +<% #### CACHE stats #### %> +<% cache("#{work.cache_key}/stats-v4", expires_in: 1.hour, skip_digest: true) do %> + <% unless work.approved_collections.empty? %> +
    <%= ts('Collections') %>:
    +
    <%= link_to number_with_delimiter(work.approved_collections.length), work_collections_path(work) %>
    + <% end %> + + <% if work.count_visible_comments > 0 %> +
    <%= ts('Comments') %>:
    +
    <%= link_to number_with_delimiter(work.count_visible_comments), work.number_of_posted_chapters > 1 ? work_path(work, anchor: "comments", show_comments: true, view_full_work: "true") : work_path(work, anchor: "comments", show_comments: true) %>
    + <% end %> + + <% if work.all_kudos_count > 0 %> +
    <%= ts('Applause') %>:
    +
    <%= link_to number_with_delimiter(work.all_kudos_count), work.number_of_posted_chapters > 1 ? work_path(work, anchor: "kudos", view_full_work: "true") : work_path(work, anchor: "kudos") %>
    + + <% end %> + <% if (bookmark_count = work.public_bookmarks_count) > 0 %> +
    <%= ts('Bookmarks') %>:
    +
    <%= link_to_bookmarkable_bookmarks(work, number_with_delimiter(bookmark_count)) %>
    + <% end %> + +
    <%= ts("Plays") %>:
    +
    <%= number_with_delimiter(work.hits) %>
    + + +<% end %> + +<% #### END CACHE stats #### %> +
    + +<% end %> diff --git a/app/views/works/_work_series_links.html.erb b/app/views/works/_work_series_links.html.erb new file mode 100644 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 100644 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 100644 index 0000000..bab2507 --- /dev/null +++ b/app/views/works/collected.html.erb @@ -0,0 +1,43 @@ + +

    + <%= ts("Works in Challenges/Collections") %> +

    + + + + + + +<% if @works.respond_to?(:total_pages) %> + <%= will_paginate @full_results %> +<% end %> + + +

    <%= ts("Listing Works") %>

    +
      + <% for work in @works %> + <% if work %><%= render 'work_blurb', :work => work %><% end %> + <% end %> +
    + + + + +<% if @facets.present? %> + <%= render 'collection_filters' %> +<% end %> + + +<% if @works.respond_to?(:total_pages) %> + <%= will_paginate @full_results %> +<% end %> diff --git a/app/views/works/confirm_delete.html.erb b/app/views/works/confirm_delete.html.erb new file mode 100644 index 0000000..7e6c55b --- /dev/null +++ b/app/views/works/confirm_delete.html.erb @@ -0,0 +1,19 @@ + +

    <%= @work.posted? ? ts("Delete Work") : ts("Delete Draft") %>

    + + +<%= form_for(@work, :html => {:method => :delete, :class => "simple destroy"}) do |f| %> +

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

    +

    + <%= f.submit (@work.posted? ? ts("Yes, Delete Work") : ts("Yes, Delete Draft")) %> + <% if @work.posted? %> + <%= link_to ts("Orphan Work Instead"), new_orphan_path(:work_id => @work.id) %> + <% end %> +

    +<% end %> + diff --git a/app/views/works/confirm_delete_multiple.html.erb b/app/views/works/confirm_delete_multiple.html.erb new file mode 100644 index 0000000..1bddd2c --- /dev/null +++ b/app/views/works/confirm_delete_multiple.html.erb @@ -0,0 +1,26 @@ + +

    <%= ts("Delete Works?") %>

    + +

    + You are about to delete all of the following works PERMANENTLY. This CANNOT BE UNDONE and all bookmarks, comments, and kudos will be lost. +

    + +<%= render "works/work_abbreviated_list", :works => @works %> + +<%= form_tag delete_multiple_user_works_path(@user) do %> + +

    + <%= 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)) %>
    • +
    • <%= link_to ts("Cancel"), show_multiple_user_works_path(@user) %>
    • +
    + +<% end %> + + diff --git a/app/views/works/drafts.html.erb b/app/views/works/drafts.html.erb new file mode 100755 index 0000000..03a650a --- /dev/null +++ b/app/views/works/drafts.html.erb @@ -0,0 +1,24 @@ + +

    <%= search_header @works, nil, ts("Unposted Draft") %>

    + +

    + <%= 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 %> +
    + + + +<%= will_paginate @works %> + diff --git a/app/views/works/edit.html.erb b/app/views/works/edit.html.erb new file mode 100755 index 0000000..1d05276 --- /dev/null +++ b/app/views/works/edit.html.erb @@ -0,0 +1,33 @@ + +

    <%= ts('Edit Work') %>

    + +<%= error_messages_for :work %> + + + + + + + +<%= render :partial => 'standard_form' %> + diff --git a/app/views/works/edit_multiple.html.erb b/app/views/works/edit_multiple.html.erb new file mode 100644 index 0000000..1dca1cd --- /dev/null +++ b/app/views/works/edit_multiple.html.erb @@ -0,0 +1,105 @@ + +

    <%= ts("Edit Multiple Works") %>

    + + + +<%= render "users/header_navigation" %> + + + +

    + <%= ts("Your edits will be applied to") %> <%= ts("all") %> <%= ts("of the following works:") %> +

    + +<%= render "works/work_abbreviated_list", works: @works %> + +<%= form_for :work, url: update_multiple_user_works_path(@user), method: :patch, html: { class: "verbose post" } do |form| %> +

    + <%= ts("* Required information") %> + <% @works.each do |work| %> + <%= hidden_field_tag "work_ids[]", work.id %> + <% end %> +

    + + <%= render "work_form_tags", include_blank: true %> +
    + <%= t("works.preface.title") %> +

    <%= t("works.preface.title") %>

    +
    + <%= render "works/work_form_pseuds", form: form, works: @works %> +
    +
    + +
    + <%= t("works.associations.title") %> +

    <%= t("works.associations.title") %>

    +
    + <%= render "collectibles/collectible_form", form: form, collectibles: @works %> +
    <%= form.label :language_id, t("works.associations.language.choose") %> <%= link_to_help "languages-help" %>
    +
    + <%= form.select :language_id, language_options_for_select(sorted_languages, "id"), { include_blank: true } %> +
    + +
    <%= form.label :work_skin_id, t("works.skin.select") %> <%= link_to_help "work-skins" %>
    +
    + <%= form.collection_select :work_skin_id, WorkSkin.approved_or_owned_by(current_user).order(:title), :id, :title, + { include_blank: true } %> +
    +
    +
    + +
    + <%= t("works.permissions.privacy") %> +

    <%= t("works.permissions.privacy") %>

    +
    + +
    <%= t("works.permissions.visibility.label") %> <%= link_to_help "registered-users" %>
    +
    +
    + <%= radio_button_list(form, :restricted, [ + ["", t("works.permissions.visibility.keep_current")], + ["1", t("works.permissions.visibility.multiple_works_restricted")], + ["0", t("works.permissions.visibility.unrestricted")] + ]) + %> +
    +
    + +
    <%= t("comments.commentable.permissions.moderated_commenting.label") %><%= link_to_help "comments-moderated" %>
    +
    +
    + <%= radio_button_list(form, :moderated_commenting_enabled, [ + ["", t("comments.commentable.permissions.moderated_commenting.keep_current")], + ["1", t("comments.commentable.permissions.moderated_commenting.enable")], + ["0", t("comments.commentable.permissions.moderated_commenting.disable")] + ]) + %> +
    +
    + +
    + <%= t("comments.commentable.permissions.options.multiple_works_label") %> + <%= link_to_help "who-can-comment-on-this-work" %> +
    +
    +
    + <%= radio_button_list(form, :comment_permissions, [ + ["", t("comments.commentable.permissions.options.keep_current")], + [:enable_all, t("comments.commentable.permissions.options.enable_all")], + [:disable_anon, t("comments.commentable.permissions.options.disable_anon")], + [:disable_all, t("comments.commentable.permissions.options.disable_all")] + ]) %> +
    +
    +
    +
    + +

    + <%= 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 100644 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 %> +

    + +<%= error_messages_for :work %> + + + +<%= form_for(@work, html: { id: "work-form", class: "verbose work post" }, url: { action: "update_tags" }) do |f| %> +

    * <%= ts('Required information') %>

    + + <%= render 'work_form_tags', include_blank: false %> + + <% if logged_in_as_admin? %> +
    + <%= t("works.associations.title") %> +

    <%= t("works.associations.title") %>

    +
    + <%= render 'work_form_associations_language', f: f %> +
    +
    + <% end %> + +
    + <%= ts('Post Work') %> +

    + <%= t(".post_notice_html", + content_policy_link: link_to(t(".content_policy"), content_path), + tos_faq_link: link_to(t(".tos_faq"), tos_faq_path(anchor: "content_faq"))) %> +

    +
      + <% unless @work.posted? %> +
    • <%= submit_tag ts('Save As Draft'), name: 'save_button' %>
    • + <% end %> +
    • <%= submit_tag ts('Preview'), name: 'preview_button' %>
    • +
    • <%= submit_tag ts('Post'), name: 'update_button' %>
    • +
    • <%= submit_tag ts('Cancel'), name: 'cancel_button' %>
    • +
    +
    +<% end %> + diff --git a/app/views/works/import.html.erb b/app/views/works/import.html.erb new file mode 100644 index 0000000..5049f3c --- /dev/null +++ b/app/views/works/import.html.erb @@ -0,0 +1,19 @@ +

    <%= t(".page_heading") %>

    + +<% if @works && !@works.empty? %> +

    + <%= t(".success") %> +

    +
      + <% for work in @works %> + <% if work.posted? %> +
    • <%= link_to(work.title, work_path(work)) %>
    • + <% else %> +
    • <%= t(".draft_work_title_html", work_link: link_to(work.title, preview_work_path(work))) %>
    • + <% end %> + <% end %> +
    + +<% end %> + + diff --git a/app/views/works/index.html.erb b/app/views/works/index.html.erb new file mode 100755 index 0000000..db80ac9 --- /dev/null +++ b/app/views/works/index.html.erb @@ -0,0 +1,65 @@ + +<%= render "muted/muted_items_notice" %> + +

    + <%= search_header @works, @search, "Work", @owner %> +

    + + + +<% if @collection || @tag || @user || @language %> + +<% end %> + + +<% unless @owner.present? %> +

    <%= t(".recent_works_html", choose_fandom_link: link_to(t(".choose_fandom"), media_index_path), advanced_search_link: link_to(t(".advanced_search"), search_works_path)) %> +<% end %> + +<%== pagy_nav @pagy %> + + +

    <%= ts("Listing Works") %>

    +
      + <%= render partial: "work_blurb", collection: @works, as: :work %> +
    + + + + +<% if @facets.present? %> + <%= render "filters" %> +<% end %> + + +<%== pagy_nav @pagy %> diff --git a/app/views/works/navigate.html.erb b/app/views/works/navigate.html.erb new file mode 100644 index 0000000..084a790 --- /dev/null +++ b/app/views/works/navigate.html.erb @@ -0,0 +1,8 @@ +

    <%= t(".page_heading_html", work_link: link_to(@work.title, @work), creators: byline(@work)) %>

    + + + diff --git a/app/views/works/new.html.erb b/app/views/works/new.html.erb new file mode 100755 index 0000000..672955f --- /dev/null +++ b/app/views/works/new.html.erb @@ -0,0 +1,24 @@ + +

    <%= ts('Post New Work') %>

    + +<%= error_messages_for @work %> + + + + + + + +<%= render :partial => "standard_form" %> + diff --git a/app/views/works/new_import.html.erb b/app/views/works/new_import.html.erb new file mode 100644 index 0000000..c4b239d --- /dev/null +++ b/app/views/works/new_import.html.erb @@ -0,0 +1,206 @@ + +

    <%= ts("Import New Work") %> <%= link_to_help 'work-import'%>

    +<% import = true %> + +<%= error_messages_for :work %> +
    +

    + <%= ts("Please note! Fanfiction.net, Wattpad.com, and Quotev.com do not allow imports from their sites.") %> +

    +

    <%= ts("You might find the #{ link_to(ts("Import FAQ"), archive_faqs_path + "/posting-and-editing#importwork") } useful.").html_safe %>

    +
    + + + + + + + + + +<%= form_tag url_for(controller: :works, action: :import), :class => "import work verbose post" do %> +
    + <%= ts("Works URLs") %> +
    +
    <%= label_tag "urls", ts("URLs") + "*" %>
    +
    <%= text_area_tag "urls", @urls ? @urls.join("\n") : "", rows: 20, cols: 90, "aria-describedby" => "url-field-description" %> +

    + <%= ts("URLs for existing work(s) or for the chapters of a single work; one URL per line.").html_safe %> +

    +
    +
    + <%= label_tag :language_id, ts("Choose a language") + "*" %> + <%= link_to_help "languages-help" %> +
    +
    + +
    +
    <%= label_tag "encoding", ts("Set custom encoding") %> <%= link_to_help "encoding-help" %>
    +
    <%= select_tag "encoding", options_for_select([''] + Encoding.name_list.sort) %>
    +
    <%= ts("Import as") %>
    +
    +
      +
    • + <%= radio_button_tag "import_multiple", "works", true %> + <%= label_tag "import_multiple_works", + ts("Works (limit of %{max})", + max: current_user.archivist ? ArchiveConfig.IMPORT_MAX_WORKS_BY_ARCHIVIST : ArchiveConfig.IMPORT_MAX_WORKS) %> +
    • +
    • + <%= 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") %>
    +
    <%= label_tag "external_author_name", ts("Author Name") + "*" %>
    +
    <%= text_field_tag_original "external_author_name" %>
    +
    <%= label_tag "external_author_email", ts("Author Email Address") + "*" %>
    +
    <%= text_field_tag_original "external_author_email" %>
    +
    <%= label_tag "external_coauthor_name", ts("Co-Author Name") %>
    +
    <%= text_field_tag_original "external_coauthor_name" %>
    +
    <%= label_tag "external_coauthor_email", ts("Co-Author Email Address") %>
    +
    <%= text_field_tag_original "external_coauthor_email" %>
    +
    +
    +
    + <% end %> + +
    <%= ts("Preferences") %>
    +
    +
      +
    • + <%= check_box_tag "post_without_preview" %> + <%= label_tag "post_without_preview", ts("Post without previewing.") %> +
    • +
    +
    +
    <%= ts("Override tags and notes") %>
    +
    +
      +
    • + <%= 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") %> +
      • +
      +
    • +
    +
    +
    +
    + +
    + <%= t("works.permissions.privacy") %> +

    <%= t("works.permissions.privacy") %>

    +
    +
    + <%= check_box_tag "restricted" %> +
    +
    + <%= label_tag "restricted", t("works.permissions.visibility.import_works_restricted") %> + <%= link_to_help "registered-users" %> +
    + +
    + <%= check_box_tag "moderated_commenting_enabled" %> +
    +
    + <%= label_tag :moderated_commenting_enabled, t("comments.commentable.permissions.moderated_commenting.enable") %> + <%= link_to_help "comments-moderated" %> +
    + +
    + <%= t("comments.commentable.permissions.options.multiple_works_label") %> + <%= link_to_help "who-can-comment-on-this-work" %> +
    +
    +
    +
      +
    • + <%= radio_button_tag "comment_permissions", "enable_all" %> + <%= label_tag "comment_permissions_enable_all", t("comments.commentable.permissions.options.enable_all") %> +
    • +
    • + <%= radio_button_tag "comment_permissions", "disable_anon", true %> + <%= label_tag "comment_permissions_disable_anon", t("comments.commentable.permissions.options.disable_anon") %> +
    • +
    • + <%= radio_button_tag "comment_permissions", "disable_all" %> + <%= label_tag "comment_permissions_disable_all", t("comments.commentable.permissions.options.disable_all") %> +
    • +
    +
    +
    +
    +
    + + <%= render :partial => 'work_form_tags', locals: { include_blank: true, import: true } %> + +
    + <%= label_tag :notes, ts("Notes") %> +
    +
    <%= label_tag :notes, ts("Notes at the beginning") %>
    +
    + <%= text_area_tag :notes, nil, class: "observe_textlength" %> + <%= generate_countdown_html(".notes", ArchiveConfig.NOTES_MAX) %> +
    +
    +
    + +
    + <%= ts("Submit") %> +

    + <%= t(".post_notice_html", + content_policy_link: link_to(t(".content_policy"), content_path), + tos_faq_link: link_to(t(".tos_faq"), tos_faq_path(anchor: "content_faq"))) %> +

    +

    + <%= submit_tag ts("Import") %> +

    +
    + +<% 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/preview.html.erb b/app/views/works/preview.html.erb new file mode 100755 index 0000000..9534b08 --- /dev/null +++ b/app/views/works/preview.html.erb @@ -0,0 +1,73 @@ + +

    <%= ts("Preview") %>

    +<%= error_messages_for :work %> + + + +
    +
    + <%= render "works/work_header" %> +
    + <% if @chapters %> + <% @chapters.each do |chapter| %> + <%= render "chapters/chapter", chapter: chapter %> + <% end %> + <% else %> +
    <%=raw sanitize_field(@chapter, :content) %>
    + <% end %> +
    + + <% inspired_by = get_inspired_by(@work) %> + <% if !@work.endnotes.blank? || !@work.serial_works.blank? || !inspired_by.empty? %> + +
    + <% unless @work.endnotes.blank? %> + <%= render "works/work_endnotes" %> + <% end %> + <% unless @work.serial_works.blank? %> + <%= render "works/work_series_links" %> + <% end %> + <% unless inspired_by.empty? %> + <%= render "works/work_approved_children", inspired_by: inspired_by %> + <% end %> +
    + + <% end %> + +
    +
    + +
    + +<%= form_for(@work) do |f| %> + + <%= render "hidden_fields", form: f %> + +
    + <%= ts("Post Work") %> +

    + <%= t(".post_notice_html", + content_policy_link: link_to(t(".content_policy"), content_path), + tos_faq_link: link_to(t(".tos_faq"), tos_faq_path(anchor: "content_faq"))) %> +

    +
      + <% if @work.posted? %> +
    • <%= submit_tag ts("Update"), name: "update_button" %>
    • + <% else %> +
    • + <%= submit_tag ts("Post"), + name: "post_button", + data: { disable_with: ts("Please wait...") } %> +
    • +
    • + <%= submit_tag ts("Save As Draft"), name: "save_button" %> +
    • + <% end %> +
    • <%= submit_tag ts("Edit"), name: "edit_button" %>
    • +
    • <%= submit_tag ts("Cancel"), name: "cancel_button" %>
    • +
    +
    + +<% end %> +
    + diff --git a/app/views/works/preview_tags.html.erb b/app/views/works/preview_tags.html.erb new file mode 100644 index 0000000..e94257b --- /dev/null +++ b/app/views/works/preview_tags.html.erb @@ -0,0 +1,46 @@ + +

    + <% if logged_in_as_admin? %> + <%= ts("Preview Tags and Language") %> + <% else %> + <%= ts("Preview Tags") %> + <% end %> +

    +<%= error_messages_for :work %> + + + +
    +
    + <%= render 'works/meta', work: @work %> +
    +
    + + +<%= form_for(@work, url: { action: :update_tags }) do |form| %> + <%= form.hidden_field :rating_string, value: "#{@work.rating_string}" %> + <%= form.hidden_field :archive_warning_string, value: "#{@work.archive_warning_string}" %> + <%= form.hidden_field :category_string, value: "#{@work.category_string}" %> + <%= form.hidden_field :fandom_string, value: "#{@work.fandom_string}" %> + <%= form.hidden_field :relationship_string, value: "#{@work.relationship_string}" %> + <%= form.hidden_field :character_string, value: "#{@work.character_string}" %> + <%= form.hidden_field :freeform_string, value: "#{@work.freeform_string}" %> + <% if logged_in_as_admin? %> + <%= form.hidden_field :language_id, value: "#{@work.language.id}" %> + <% end %> + +
    + <%= ts('Post Work') %> +

    + <% if @work.posted? %> + <%= submit_tag ts('Update'), name: 'update_button' %> + <% else %> + <%= submit_tag ts('Post'), name: 'post_button' %> + <%= submit_tag ts('Save As Draft'), name: 'save_button' %> + <% end %> + <%= submit_tag ts('Edit'), name: 'edit_button' %> + <%= submit_tag ts('Cancel'), name: 'cancel_button' %> +

    +
    + +<% end %> diff --git a/app/views/works/search.html.erb b/app/views/works/search.html.erb new file mode 100755 index 0000000..66ab610 --- /dev/null +++ b/app/views/works/search.html.erb @@ -0,0 +1,11 @@ + +

    <%= ts('Work Search') %>

    + + + +<%= render 'shared/search_nav' %> + + + +<%= render 'search_form' %> + \ No newline at end of file diff --git a/app/views/works/search_results.html.erb b/app/views/works/search_results.html.erb new file mode 100644 index 0000000..5330293 --- /dev/null +++ b/app/views/works/search_results.html.erb @@ -0,0 +1,36 @@ + +<%= render "muted/muted_items_notice" %> + +

    <%= ts('Search Results') %>

    + +

    + <%= ts("You searched for:") %> + <%= (sanitize @search.summary, tags: %w[span], attributes: %w[lang]).html_safe %> +

    + + + + + + + + +<% if @works.blank? %> +

    <%= ts("No results found. You may want to edit your search to make it less specific.") %>

    +<% else %> +

    <%= search_results_found(@works) %> <%= link_to_help "work-search-results-help" %>

    + +

    Works List

    +
      + <% @works.each do |work| %> + <% unless work.nil? %> + <%= render :partial => 'work_blurb', :locals => {:work => work} %> + <% end %> + <% end %> +
    + + <%= will_paginate @works %> +<% end %> + diff --git a/app/views/works/share.html.erb b/app/views/works/share.html.erb new file mode 100644 index 0000000..6bc24a2 --- /dev/null +++ b/app/views/works/share.html.erb @@ -0,0 +1 @@ +<%= render 'share/share', shareable: @work %> diff --git a/app/views/works/show.html.erb b/app/views/works/show.html.erb new file mode 100755 index 0000000..acc1fe1 --- /dev/null +++ b/app/views/works/show.html.erb @@ -0,0 +1,76 @@ + + +<% if !@work.posted? %> +

    <%= t(".unposted_deletion_notice_html", deletion_date: date_in_zone(@work.created_at + 29.days)) %>

    +<% 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 %> + + + + +<% 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) %> + + + <%= render partial: 'works/work_header' %> +<%= ts("Estimated reading time") %>: +<%= reading_time_with_title(@work.word_count || @work.revised_total_words) %>
    +
    + <% if @chapters %> + <% for chapter in @chapters %> + <%= render 'chapters/chapter', chapter: chapter %> + <% end %> + <% else %> +

    <%= ts("Work Text:") %>

    + <% cache("#{@chapter.cache_key}-show-content", skip_digest: true) do %> +
    <%=raw sanitize_field(@chapter, :content) %>
    + <% end %> + <% end %> +
    + + + <% inspired_by = get_inspired_by(@work) %> + <% if !@work.endnotes.blank? || !@work.series.blank? || !inspired_by.empty? %> + +
    + <% unless @work.endnotes.blank? %> + <%= render :partial => 'works/work_endnotes' %> + <% end %> + <% unless @work.series.blank? %> + <%= render :partial => 'works/work_series_links' %> + <% end %> + <% unless inspired_by.empty? %> + <%= render :partial => 'works/work_approved_children', :locals => {:inspired_by => inspired_by} %> + <% end %> +
    + + <% end %> + +
    + + + + +<%= render 'comments/commentable', :commentable => @work %> + +<% end %> + + +<%= render "hit_count/include" %> diff --git a/app/views/works/show_multiple.html.erb b/app/views/works/show_multiple.html.erb new file mode 100644 index 0000000..6055eaf --- /dev/null +++ b/app/views/works/show_multiple.html.erb @@ -0,0 +1,67 @@ + +

    Edit Multiple Works

    + + + +<%= render "users/header_navigation" %> + + + +<% if @works.empty? %> +

    + <%= t(".no_works") %> +

    +<% else %> + <%= form_tag edit_multiple_user_works_path(@user), id: "edit-multiple-works" do %> + +
    + <%= check_all_none %> + +
    + Actions +

    + + + +

    +
    + +
      + <% @works_by_fandom.keys.sort.each do |fandom| %> +
    • +
      + Select <%= fandom %> works +
      + <%= fandom %> + <%= check_all_none %> +
      +
        + <% @works_by_fandom[fandom].each do |work| %> +
      • + <% work_title_with_status = work.title + (work.posted? ? "" : t(".draft")) %> + + <%= link_to work_title_with_status, work_path(id: work.id) %> +
      • + <% end %> +
      +
      +
    • + <% end %> +
    + +
    + Actions +

    + + + +

    +
    + +
    + + <% end %> +<% end %> + diff --git a/app/views/wrangling_guidelines/_admin_index.html.erb b/app/views/wrangling_guidelines/_admin_index.html.erb new file mode 100644 index 0000000..cdb309c --- /dev/null +++ b/app/views/wrangling_guidelines/_admin_index.html.erb @@ -0,0 +1,29 @@ +
    + +

    <%= ts('Wrangling Guidelines') %>

    + + + + <%= render 'admin/admin_nav' %> + + + + +

    <%= ts("Manage Wrangling Guidelines") %>

    +
    + <% @wrangling_guidelines.each do |wrangling_guideline| %> +
    <%= link_to wrangling_guideline.title, wrangling_guideline %>
    +
    +

    <%= ts('Created at') %> <%= wrangling_guideline.created_at %> <%= ts('and updated at') %> <%= wrangling_guideline.updated_at %>

    +
    + <% end %> +
    + +
    diff --git a/app/views/wrangling_guidelines/_wrangling_guideline_form.html.erb b/app/views/wrangling_guidelines/_wrangling_guideline_form.html.erb new file mode 100644 index 0000000..f0fa89e --- /dev/null +++ b/app/views/wrangling_guidelines/_wrangling_guideline_form.html.erb @@ -0,0 +1,43 @@ +<%= form_for(@wrangling_guideline, html: { class: "post" }) do |f| %> + + <%= error_messages_for @wrangling_guideline %> + +

    <%= "* #{t('.required_information')}" %>

    +
    + +
    <%= f.label :title, "#{t('.title')}*" %>
    +
    + <%= f.text_field :title %> + <%= live_validation_for_field("wrangling_guideline_title", failureMessage: t(".title_failure")) %> +
    + +
    <%= f.label :content, "#{t('.guideline_text')}*", for: "content" %>
    +
    + +

    + <%= allowed_html_instructions %> + +

    + <% use_tinymce %> +
    + <%= 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: t(".content_too_long", count: ArchiveConfig.CONTENT_MAX), + tooShortMessage: t(".content_too_short", count: ArchiveConfig.CONTENT_MIN), + failureMessage: t(".content_required")) %> + <%= generate_countdown_html("content", ArchiveConfig.CONTENT_MAX) %> +
    +
    + +
    <%= t(".landmark.post") %>
    +
    + <%= submit_tag t(".post"), name: "post_button" %> +
    + +
    +<% end %> diff --git a/app/views/wrangling_guidelines/_wrangling_guideline_order.html.erb b/app/views/wrangling_guidelines/_wrangling_guideline_order.html.erb new file mode 100644 index 0000000..ab2c7ba --- /dev/null +++ b/app/views/wrangling_guidelines/_wrangling_guideline_order.html.erb @@ -0,0 +1,33 @@ +
    + <%= form_tag url_for(:action => 'update_positions') do %> +
      + <% for wrangling_guideline in @wrangling_guidelines %> +
    • + <%= text_field_tag "wrangling_guidelines[]", nil, :size => 3, :maxlength => 3, :id => "wrangling_guidelines_#{wrangling_guideline.id}", :class => "number" %> + <%= wrangling_guideline.position %>. +

      <%=h wrangling_guideline.title %>

      +
    • + <% end %> +
    +

    <%= submit_tag ts('Update Positions') %>

    + <% end %> +
    + +<%= content_for :footer_js do %> + <%= javascript_tag do %> + $j("#sortable_wrangling_guideline_list").sortable({ + delay: 300, + update: function(event, ui) { + $j(".wrangling_guideline-position-list").each(function(index, li){ + var faqId = $j(li).attr("id").replace("wrangling_guideline_",""); + $j("#position-for-"+faqId).html(index+1); + }); + $j.ajax({ + type: 'post', + data: $j("#sortable_wrangling_guideline_list").sortable("serialize"), + dataType: 'script', + url: "<%= url_for(:action => :update_positions) %>"}) + } + }) + <% end %> +<% end %> diff --git a/app/views/wrangling_guidelines/edit.html.erb b/app/views/wrangling_guidelines/edit.html.erb new file mode 100644 index 0000000..8d23d43 --- /dev/null +++ b/app/views/wrangling_guidelines/edit.html.erb @@ -0,0 +1,11 @@ + +

    <%= ts('Edit Wrangling Guideline') %>

    + + + +<%= render 'admin/admin_nav' %> + + + +<%= render 'wrangling_guideline_form' %> + \ No newline at end of file diff --git a/app/views/wrangling_guidelines/index.html.erb b/app/views/wrangling_guidelines/index.html.erb new file mode 100644 index 0000000..0b29d89 --- /dev/null +++ b/app/views/wrangling_guidelines/index.html.erb @@ -0,0 +1,19 @@ + +<% if policy(:wrangling).new? %> + <%= render 'admin_index' %> +<% else %> +

    <%= ts('Wrangling Guidelines') %>

    + + + +
    + <% unless @wrangling_guidelines.blank? %> +

    <%= ts('Wrangling Guideline Sections') %>

    +
      + <% for wrangling_guideline in @wrangling_guidelines %> +
    1. <%= link_to wrangling_guideline.title, wrangling_guideline %>
    2. + <% end %> +
    + <% end %> +
    +<% 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 100644 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 100644 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 100644 index 0000000..8288367 --- /dev/null +++ b/app/views/wrangling_guidelines/show.html.erb @@ -0,0 +1,22 @@ + +

    <%= link_to t(".heading"), wrangling_guidelines_path %> > <%= @wrangling_guideline.title %>

    + + + + + + + +
    + <% if policy(:wrangling).edit? %> +
    +

    + Updated: <%= h @wrangling_guideline.updated_at %> | <%= link_to t(".edit"), edit_wrangling_guideline_path(@wrangling_guideline) %> +

    +
    + <% end %> +
    + <%= 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 100644 index 0000000..c40fa16 --- /dev/null +++ b/bin/reload_elastic @@ -0,0 +1,7 @@ +#!/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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 index 0000000..af06306 --- /dev/null +++ b/config/environments/production.rb @@ -0,0 +1,117 @@ +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 + + # Store uploaded files in S3, proxied (see config/storage.yml for options). + config.active_storage.service = :local + config.active_storage.resolve_model_to_route = :rails_storage_proxy + + # 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 = true + + # 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", "error") + + # 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 + + # 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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) + content.gsub(/\s*(]*>.*?<\/p>)\s*/m, "\n\n" + '\1'). + strip + else + content.gsub(/\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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 index 0000000..753fa15 --- /dev/null +++ b/config/locales/controllers/en.yml @@ -0,0 +1,274 @@ +--- +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 Artist + 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. + related_works: + index: + page_title: "%{login} - Related Works" + series: + index: + page_title: "%{username} - Series" + show: + anonymous: Anonymous Artist + 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: + 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 AO3. 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 100644 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 100644 index 0000000..084f99d --- /dev/null +++ b/config/locales/helpers/en.yml @@ -0,0 +1,51 @@ +--- +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 diff --git a/config/locales/mailers/en.yml b/config/locales/mailers/en.yml new file mode 100644 index 0000000..7d2c896 --- /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 Archive of Our Own is a fan-run and fan-supported archive that relies on %{your_donations_link}. + text: 'The Archive of Our Own 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 the Archive of Our Own! + text: You have been assigned the following request in the "%{collection_title}" challenge (%{collection_url}) at the Archive of Our Own! + 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: Archive of Our Own + 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 the Archive of Our Own! + 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: The Archive of Our Own (AO3) is a free, noncommercial 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 %{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: The Archive of Our Own (AO3) 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/models/en.yml b/config/locales/models/en.yml new file mode 100644 index 0000000..69fd646 --- /dev/null +++ b/config/locales/models/en.yml @@ -0,0 +1,291 @@ +--- +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 + 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 AO3 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 13! + 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 index 0000000..48397be --- /dev/null +++ b/config/locales/views/en.yml @@ -0,0 +1,2543 @@ +--- +en: + a11y: + navigation: Navigation + abuse_reports: + new: + do_not_spam: + delay: it may take several months for us to get to your report + paragraph_html: "%{split_bold} Our volunteer team is small, so %{delay_link}." + 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_link} 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: the %{reported_username_link} + 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 %{tos_link}. For more information, please refer to the %{tos_faq_link}. + contact_support_html: If you need technical support, want to report a work tagged with an incorrect language, or would like to manage your %{fnok_link}, please %{support_link}. + dmca: + abbreviated: DMCA + full: Digital Millennium Copyright Act + dmca_takedown_html: If you would like to file a %{dmca_abbreviation} takedown request, please %{legal_link}. + fnok: Fannish Next-of-Kin + legal: review our DMCA Policy and contact our Legal team + support: contact our Support team + tos: AO3 Terms of Service + tos_faq: Terms of Service FAQ + reportable: + allowed: You would like to know whether specific content is allowed on AO3. + content_policy: Content Policy + email: an email explaining why + hack: You believe that your AO3 account was hacked. + harassment: You have experienced or witnessed harassment taking place on AO3. + intro_html: 'Potential reasons to submit a report to the %{pac_link} team:' + pac: Policy & Abuse + suspended_html: You have been suspended or your work has been removed, and you have not received %{email_link}. + tos: Terms of Service + violation_html: You have encountered content on AO3 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: AO3 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 AO3 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: AO3 News + post_news: Post AO3 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: AO3 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_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 AO3 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 AO3 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 + collections: + form: + icon: + delete: Delete collection icon and revert to our default. This will also remove the icon alt text and comment text. + 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: ao3org Tumblr + twitter: "@AO3_Status Twitter feed" + home: + about: + ao3: + github_repository: GitHub repository + html: The Archive of Our Own offers a noncommercial and nonprofit central hosting place for fanworks using open-source archiving software. We welcome contributions to our %{github_repository_link}, and a list of open tasks is available on our %{jira_project_link}. + jira_project: Jira project + details: We are proactive and innovative in protecting and defending our work from commercial exploitation and legal challenge. We preserve our fannish economy, values, and creative expression by protecting and nurturing our fellow fans, our work, our commentary, our history, and our identity while providing the broadest possible access to fannish activity for all fans. + general: The Organization for Transformative Works (OTW) is a nonprofit organization, established by fans in 2007, to serve the interests of fans by providing access to and preserving the history of fanworks and fan culture in its myriad forms. We believe that fanworks are transformative and that transformative works are legitimate. + heading: + html: About the %{otw} + otw: OTW + otw_long: Organization for Transformative Works + major_projects: + fanlore: Fanlore + fanlore_details_html: "%{fanlore_link}, a fandom wiki devoted to preserving the history of transformative fanworks and the fandoms from which they have arisen." + legal_advocacy: Legal Advocacy + legal_details_html: "%{legal_advocacy_link} committed to protecting and defending fanworks from commercial exploitation and legal challenge." + open_doors: Open Doors + open_doors_details_html: "%{open_doors_link}, which offers shelter to at-risk fannish projects." + title: 'Our other major projects include:' + twc: Transformative Works and Cultures + twc_details_html: "%{twc_link}, a peer-reviewed academic journal that seeks to promote scholarship on fanworks and practices." + more_info: + communications_team: Communications team + faq_page: FAQ page + html: You can find out more about the OTW and its projects at its website, %{transformative_works_link}, and learn about how your financial support is vital to the continuation and expansion of the OTW's work on its %{faq_page_link}. If you have a media or research question, please contact the %{communications_team_link}. + transformative_works: transformativeworks.org + page_title: About the OTW + 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 + 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: report it to us + 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 AO3 must comply with our Content Policy. + archive_description: The Archive of Our Own (AO3) 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: Our goal is %{maximum_inclusiveness_link}. We want to provide a safe and permanent home for fanworks, including works that might be at risk on other sites due to being deemed immoral, explicit, or otherwise objectionable. Users should always observe and heed the ratings and warnings provided before accessing works on AO3. + report_it_to_us: report it to us + 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 AO3. + you_can_report_html: If you encounter content that you believe violates our Content Policy, you can %{report_it_to_us_link}. %{we_do_not_prescreen_bold} + 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}. + mandatory_tags: + any_archive_warning: any Archive Warning + ao3_may_designate_html: AO3 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 AO3'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, AO3 administrators may determine that tags in a mandatory tag field on a Work are inaccurate or insufficient. In such cases, AO3 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 AO3 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 AO3 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: AO3 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: There are two main ways to support the AO3 - donating your time or money. + title: Donations + money: + details_html: The AO3 has ongoing running costs - electricity for the servers and bandwidth so you can reach them - and one-off costs such as buying new servers as the number of users and works increases. Any %{donation_link} is a big help. (Don't worry, we'll never connect your AO3 username and your financial information.) + donation: donation to the OTW + title: Donating Financially + page_title: Donate or Volunteer + 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: ao3org 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 AO3 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 AO3 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 AO3 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 AO3. 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: AO3 News + archive_faq: Archive FAQ + content_policy: Content Policy + header: About the Archive of Our Own + known_issues: Known Issues + privacy_policy: Privacy Policy + project_of_otw_html: The Archive of Our Own 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: OTW + full: Organization for Transformative Works + 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 AO3 administrators. + open_doors_removal_html: The removal of Content imported via Open Doors will not usually result in a penalty for the archivist or creator, except for violations of %{content_policy_ii_k_1_link}. + 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 thirteen (13) 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 thirteen (13), and + individuals_under_16: 'individuals under the age of sixteen (16) 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 Archive of Our Own (AO3) is a home for 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: AO3 is run by the Organization for Transformative Works (OTW). We are committed to %{defending_fanworks_link}. We have legal resources and alliances on which we can draw. However, that is not a guarantee that the OTW can or will fight each battle. The OTW Board will take into account a variety of factors, both legal and otherwise, when responding to a legal challenge. More information is available %{on_the_otw_site_link}. + defending_fanworks: defending fanworks against legal challenges + faq: + abbreviated: FAQ + full: Frequently Asked Questions + header: What We Believe + maximum_inclusiveness: maximum inclusiveness of fanwork content + on_the_otw_site: on the OTW site + our_goal_html: Our goal is %{maximum_inclusiveness_link}. + readability_html: We strive to make our Terms of Service (TOS) readable. We have provided explanations for the more unusual legal terms, and answers to common questions are available in the %{tos_faq_link}. + tos_faq_html: TOS %{faq_abbreviation} + we_do_not_sell_html: We do not sell any data that you post on, submit to, or share on OTW sites (%{ao3_link}, %{fanlore_link}, and %{transformativeworks_link}), and we do not include or accept paid advertisements from third parties. + 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 AO3 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 Archive of Our Own account, you need an Invitation. By submitting your email address to our invitation queue, you confirm that you are at least 13 years old, and if you're in a country whose residents/citizens have to be of an age older than 13 to consent, you are old enough to consent to the processing of your personal data without our obtaining written permission from a parent or legal guardian. 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 %{privacy_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: Content Policy + diversity_statement: Diversity Statement + dmca_policy: DMCA Policy + header: About the Archive + privacy_policy: 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_link}" + view_license: View License + landmark: Footer + otw: + abbreviated: OTW + full: Organization for Transformative Works + 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 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. + learn_more_html: To learn more, check out our %{tos_link}, including the %{content_policy_link} and %{privacy_policy_link}. + page_heading: Archive of Our Own + privacy_policy: Privacy Policy + read_and_understood: I have read & understood the 2024 Terms of Service, including the Content Policy and Privacy Policy. + summary_html: On the Archive of Our Own (AO3), users can create works, bookmarks, comments, tags, and other %{content_link}. Any information you publish on AO3 may be accessible by the public, AO3 users, and/or AO3 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. + tos: Terms of Service + media: + index: + all: All %{media_type}... + page_heading: Fandoms + menu: + menu_about: + about_us: About Us + donate: Donate or Volunteer + 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 + 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}"? + 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 + 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: + browser_title: 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_password: + browser_title: Change Password + change_username: + account_faq: Account FAQ + browser_title: Change Username + 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 AO3 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 AO3 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 AO3, 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 13. + over_thirteen_required: You need to be at least 13 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 an Archive of Our Own 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 AO3 for the first time. For help getting started on AO3, 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 AO3 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 AO3 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 AO3 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 AO3 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 index 0000000..2798273 --- /dev/null +++ b/config/routes.rb @@ -0,0 +1,717 @@ +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/routes.rb.example b/config/routes.rb.example new file mode 100644 index 0000000..42419b1 --- /dev/null +++ b/config/routes.rb.example @@ -0,0 +1,716 @@ +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" + + #### 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 :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 '/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 100644 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 100644 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 100644 index 0000000..503ff5c --- /dev/null +++ b/config/schedule_production.rb @@ -0,0 +1,17 @@ +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: '9:46 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 100644 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 100644 index 0000000..b421de3 --- /dev/null +++ b/config/storage.yml @@ -0,0 +1,22 @@ +test: + service: Disk + root: <%= Rails.root.join("tmp/storage") %> + +local: + service: Disk + root: <%= Rails.root.join("storage") %> + + +production: + 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 100644 index 0000000..02ca593 --- /dev/null +++ b/docker-compose-example.yml @@ -0,0 +1,108 @@ +version: "3" +volumes: + esdata1: + redisdata1: +services: + db: + image: mariadb:10.5.4-focal + environment: + - MYSQL_ROOT_PASSWORD=CHANGE THIS PASSWORD + ports: + - "33062: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: + - "4379:6379" + volumes: + - .:/var/lib/redis:rw + es: + image: docker.elastic.co/elasticsearch/elasticsearch:9.1.3 + ports: + - "9201:9200" + - "9301:9300" + - "9401:9400" + volumes: + - ./elastic-data:/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=-Xms6g -Xmx6g" + - 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: + - "11711: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 3351 -b 0.0.0.0" + volumes: + - ./bundler_gems:/usr/local/bundle/ + - .:/otwa + ports: + - "3351:3351" + 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..22879fe --- /dev/null +++ b/entrypoints/sc-entrypoint.sh @@ -0,0 +1,10 @@ +#!/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..59a9f14 --- /dev/null +++ b/entrypoints/worker-entrypoint.sh @@ -0,0 +1,10 @@ +#!/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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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/favicon.ico b/favicon.ico new file mode 100644 index 0000000..7e6c4fd Binary files /dev/null and b/favicon.ico differ diff --git a/features/admins/admin_email_blacklist.feature b/features/admins/admin_email_blacklist.feature new file mode 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 '<strong>Damp Gravel</strong></a> (8 words)' within "#share textarea" + And I should see 'by <a href="http://www.example.com/users/tess"><strong>tess</strong></a>' within "#share textarea" + And I should see 'Fandom: <a href="http://www.example.com/tags/Stargate%20SG-1">Stargate SG-1</a>' 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 "<dl><dt>What is this thing?</dt><dd>It's a collection</dd></dl>" + 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 100644 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 100644 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 100644 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 100644 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 100644 index 0000000..b9e7c23 --- /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 follow "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 follow "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 \ No newline at end of file diff --git a/features/comments_and_kudos/add_comment.feature b/features/comments_and_kudos/add_comment.feature new file mode 100644 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 100644 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 "<role>" admin + And I view the work "Random Work" with comments + Then I <should_ip> see "IP Address:" within ".work.meta" + And I <should_ip> see "IP Address:" within ".comment.group" + And I <should_email> 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 100644 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 <commentable> + And a comment "OMG!" by "commenter" on <commentable> + And the user "commenter" has blocked the user "pest" + When I am logged in as "pest" + And I view <commentable> 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 <commentable> + And a comment "OMG!" by "commenter" on <commentable> + And a reply "Ugh." by "pest" on <commentable> + And the user "commenter" has blocked the user "pest" + When I am logged in as "pest" + And I view <commentable> 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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 <commentable> + And <commentable> with guest comments enabled + And the user "commenter" turns off guest comment replies + And a comment "OMG!" by "commenter" on <commentable> + When I view <commentable> 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 <commentable> 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 100644 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 <img src='https://example.com/image.jpg' alt='baz'>" + 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 100644 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! <img src= 'https://example.com/image.jpg'>" 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 100644 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 100644 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 <commentable> + And image safety mode is disabled for comments + When I view <commentable> 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 "<parent_type>" + And I view <commentable> 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 <commentable> + And image safety mode is enabled for comments on a "<parent_type>" + When I view <commentable> 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 <commentable> 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 100644 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 100644 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 100644 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 100644 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 100644 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 <commentable> + And <commentable> with guest comments enabled + When I view <commentable> 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 <commentable> + 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 <commentable> with comments + And I post the comment "I don't like spam" on <commentable> + 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 <commentable> + 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 <commentable> + 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 <commentable> with comments + And I post the comment "abcdefghijk" on <commentable> + 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 <commentable> + 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 <commentable> + 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 <commentable> + 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 <commentable> + 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 <commentable> + 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 <commentable> + 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 <commentable> + 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 <commentable> + 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 100644 index 0000000..742d271 Binary files /dev/null and b/features/fixtures/icon.bmp differ diff --git a/features/fixtures/icon.gif b/features/fixtures/icon.gif new file mode 100644 index 0000000..cb4af6b Binary files /dev/null and b/features/fixtures/icon.gif differ diff --git a/features/fixtures/icon.jpg b/features/fixtures/icon.jpg new file mode 100644 index 0000000..8651bd1 Binary files /dev/null and b/features/fixtures/icon.jpg differ diff --git a/features/fixtures/icon.png b/features/fixtures/icon.png new file mode 100644 index 0000000..2d631a2 Binary files /dev/null and b/features/fixtures/icon.png differ diff --git a/features/fixtures/skin_test_preview.png b/features/fixtures/skin_test_preview.png new file mode 100644 index 0000000..2934d88 Binary files /dev/null and b/features/fixtures/skin_test_preview.png differ diff --git a/features/gift_exchanges/challenge_giftexchange.feature b/features/gift_exchanges/challenge_giftexchange.feature new file mode 100644 index 0000000..e905efb --- /dev/null +++ b/features/gift_exchanges/challenge_giftexchange.feature @@ -0,0 +1,718 @@ +@collections +Feature: Gift Exchange Challenge + In order to have more fics for my fandom + As a humble user + I want to run a gift exchange + + Scenario: Create a collection to house a gift exchange + Given I am logged in as "mod1" + And I have standard challenge tags setup + When I set up the collection "My Gift Exchange" + And I select "Gift Exchange" from "challenge_type" + And I submit + Then "My Gift Exchange" gift exchange should be correctly created + + Scenario: Enter settings for a gift exchange + Given I am logged in as "mod1" + And I have set up the gift exchange "My Gift Exchange" + When I fill in gift exchange challenge options + And I submit + Then "My Gift Exchange" gift exchange should be fully created + + Scenario: Open signup in a gift exchange + Given I am logged in as "mod1" + And I have created the gift exchange "My Gift Exchange" + And I am on "My Gift Exchange" gift exchange edit page + When I check "Sign-up open?" + And I submit + Then I should see "Challenge was successfully updated" + And I should see "Sign-up: Open" within ".collection .meta" + And I should see "Sign-up Closes:" + + Scenario: Gift exchange appears in list of open challenges + Given I am logged in as "mod1" + And I have created the gift exchange "My Gift Exchange" + And I am on "My Gift Exchange" gift exchange edit page + When I check "Sign-up open?" + And I submit + When I view open challenges + Then I should see "My Gift Exchange" + + Scenario: Gift exchange also appears in list of open gift exchange challenges + Given I am logged in as "mod1" + And I have created the gift exchange "My Gift Exchange" + And I am on "My Gift Exchange" gift exchange edit page + When I check "Sign-up open?" + And I submit + When I view open challenges + And I follow "Gift Exchange Challenges" + Then I should see "My Gift Exchange" + + Scenario: Change timezone for a gift exchange + Given time is frozen at 1/1/2019 + And the gift exchange "My Gift Exchange" is ready for signups + When I go to "My Gift Exchange" gift exchange edit page + And I select "(GMT-08:00) Pacific Time (US & Canada)" from "Time zone" + And I submit + Then I should see "Challenge was successfully updated" + And I should see the correct time zone for "Pacific Time (US & Canada)" + And I jump in our Delorean and return to the present + + Scenario: Add a co-mod + Given the following activated users exist + | login | + | comod | + And I am logged in as "mod1" + And I have created the gift exchange "Awesome Gift Exchange" + And I open signups for "Awesome Gift Exchange" + When I go to "Awesome Gift Exchange" collection's page + And I follow "Membership" + And I fill in "participants_to_invite" with "comod" + And I press "Submit" + Then I should see "New members invited: comod" + + Scenario: Sign up for a gift exchange + Given the gift exchange "Awesome Gift Exchange" is ready for signups + And I am logged in as "myname1" + When I sign up for "Awesome Gift Exchange" with combination A + Then I should see "Sign-up was successfully created" + # Invalid signup should warn the user + When I create an invalid signup in the gift exchange "Awesome Gift Exchange" + And I reload the page + Then I should see "sign-up is invalid" + + Scenario: I cannot sign up with a pseud that I don't own + Given the gift exchange "Awesome Gift Exchange" is ready for signups + When I attempt to sign up for "Awesome Gift Exchange" with a pseud that is not mine + Then I should not see "Sign-up was successfully created" + And I should see "You can't sign up with that pseud" + + Scenario: I cannot edit in a pseud that I don't own + Given the gift exchange "Awesome Gift Exchange" is ready for signups + When I attempt to update my signup for "Awesome Gift Exchange" with a pseud that is not mine + Then I should not see "Sign-up was successfully updated" + And I should see "You can't sign up with that pseud" + + Scenario: Optional tags should be saved when editing a signup (gcode issue #2729) + Given the gift exchange "Awesome Gift Exchange" is ready for signups + And I edit settings for "Awesome Gift Exchange" challenge + And I check "Optional Tags?" + And I submit + When I am logged in as "myname1" + And I sign up for "Awesome Gift Exchange" with combination A + And I follow "Edit Sign-up" + And I fill in "Optional Tags:" with "My extra tag, Something else weird" + And I submit + Then I should see "Something else weird" + When I follow "Edit Sign-up" + And I submit + Then I should see "Something else weird" + + Scenario: HTTPS URL is saved as HTTPS when editing a signup + Given the gift exchange "Awesome Gift Exchange" is ready for signups + And I edit settings for "Awesome Gift Exchange" challenge + And I check "gift_exchange[request_restriction_attributes][url_allowed]" + And I submit + When I am logged in as "myname1" + And I sign up for "Awesome Gift Exchange" with combination A + And I follow "Edit Sign-up" + And I fill in "Prompt URL:" with "https://example.com/url_for_prompt" + And I submit + Then I should see "URL: https://example.com/url_for_prompt" + + Scenario: Invalid URL is disallowed when editing a request in a signup + Given the gift exchange "Awesome Gift Exchange" is ready for signups + And I edit settings for "Awesome Gift Exchange" challenge + And I check "gift_exchange[request_restriction_attributes][url_allowed]" + And I submit + When I am logged in as "myname1" + And I sign up for "Awesome Gift Exchange" with combination A + And I follow "Edit Sign-up" + And I fill in "Prompt URL:" with "i am broken." + And I submit + Then I should see "Request URL does not appear to be a valid URL." + + Scenario: Invalid URL is disallowed when editing an offer in a signup + Given the gift exchange "Awesome Gift Exchange" is ready for signups + And I edit settings for "Awesome Gift Exchange" challenge + And I check "gift_exchange[offer_restriction_attributes][url_allowed]" + And I submit + When I am logged in as "myname1" + And I sign up for "Awesome Gift Exchange" with combination A + And I follow "Edit Sign-up" + And I fill in "Prompt URL:" with "i hereby offer you a bug." + And I submit + Then I should see "Offer URL does not appear to be a valid URL." + + Scenario: Sign-ups can be seen in the dashboard + Given the gift exchange "Awesome Gift Exchange" is ready for signups + When I am logged in as "myname1" + And I sign up for "Awesome Gift Exchange" with combination A + When I am on myname1's user page + Then I should see "Sign-ups (1)" + When I follow "Sign-ups (1)" + Then I should see "Awesome Gift Exchange" + + Scenario: Ordinary users cannot see other signups + Given the gift exchange "Awesome Gift Exchange" is ready for signups + And I am logged in as "myname1" + When I sign up for "Awesome Gift Exchange" with combination A + And I go to the collections page + And I follow "Awesome Gift Exchange" + Then I should not see "Sign-ups" within "#dashboard" + + Scenario: Mod can view signups + Given the gift exchange "Awesome Gift Exchange" is ready for signups + And everyone has signed up for the gift exchange "Awesome Gift Exchange" + When I am logged in as "mod1" + And I go to "Awesome Gift Exchange" collection's page + And I follow "Sign-ups" + Then I should see all the participants who have signed up + And I should see "Something else weird" + And I should see "Alternate Universe - Historical" + + Scenario: Mod can search signups by pseud + Given the gift exchange "Awesome Gift Exchange" is ready for signups + And everyone has signed up for the gift exchange "Awesome Gift Exchange" + When I am logged in as "mod1" + And I go to "Awesome Gift Exchange" collection's page + And I follow "Sign-ups" + And I fill in "query" with "3" + And I press "Search by Pseud" + Then I should see "myname3" within "#main" + And I should not see "myname4" within "#main" + + Scenario: Cannot generate matches while signup is open + Given the gift exchange "Awesome Gift Exchange" is ready for signups + And everyone has signed up for the gift exchange "Awesome Gift Exchange" + When I am logged in as "mod1" + And I go to "Awesome Gift Exchange" collection's page + And I follow "Matching" + Then I should see "You can't generate matches while sign-up is still open." + And I should not see "Generate Potential Matches" + + Scenario: Matches can be generated and a translated email is sent + Given the gift exchange "Awesome Gift Exchange" is ready for matching + And I have added a co-moderator "mod2" to collection "Awesome Gift Exchange" + And a locale with translated emails + And the user "mod1" enables translated emails + When I close signups for "Awesome Gift Exchange" + And I follow "Matching" + And I follow "Generate Potential Matches" + Then I should see "Beginning generation of potential matches. This may take some time, especially if your challenge is large." + And 1 email should be delivered to "mod1" + And the email to "mod1" should be translated + And the email should contain "finished generating potential assignments" + And the email should contain "you are an owner or moderator of the collection" + And 1 email should be delivered to "mod2" + And the email to "mod2" should be non-translated + And the email should contain "finished generating potential assignments" + And the email should contain "you are an owner or moderator of the collection" + When I reload the page + Then I should see "Reviewing Assignments" + And I should see "Complete" + + Scenario: Invalid signups are caught before generation and a translated email is sent + Given the gift exchange "Awesome Gift Exchange" is ready for matching + And I create an invalid signup in the gift exchange "Awesome Gift Exchange" + And I have added a co-moderator "mod2" to collection "Awesome Gift Exchange" + And a locale with translated emails + And the user "mod1" enables translated emails + When I close signups for "Awesome Gift Exchange" + And I follow "Matching" + And I follow "Generate Potential Matches" + Then 1 email should be delivered to "mod1" + And the email to "mod1" should be translated + And the email should contain "invalid sign-up" + And the email should contain "you are an owner or moderator of the collection" + And 1 email should be delivered to "mod2" + And the email to "mod2" should be non-translated + And the email should contain "invalid sign-up" + And the email should contain "you are an owner or moderator of the collection" + When I go to "Awesome Gift Exchange" gift exchange matching page + Then I should see "Generate Potential Matches" + And I should see "invalid sign-ups" + + Scenario: Assignments can be updated and cannot be sent out until everyone is assigned + Given the gift exchange "Awesome Gift Exchange" is ready for matching + And I have generated matches for "Awesome Gift Exchange" + When I remove a recipient + And I press "Save Assignment Changes" + Then I should see "Assignments updated" + And I should see "No Recipient" + And I should see "No Giver" + When I follow "Send Assignments" + Then I should see "aren't assigned" + When I follow "No Giver" + And I assign a pinch hitter + And I press "Save Assignment Changes" + Then I should see "Assignments updated" + And I should not see "No Giver" + When I follow "No Recipient" + And I assign a pinch recipient + And I press "Save Assignment Changes" + And I should not see "No Recipient" + When I follow "Send Assignments" + Then I should see "Assignments are now being sent out" + + Scenario: Issues with assignments + Given the gift exchange "Awesome Gift Exchange" is ready for matching + And I have generated matches for "Awesome Gift Exchange" + When I assign a recipient to herself + And I press "Save Assignment Changes" + Then I should not see "Assignments updated" + And I should see "do not match" + When I manually destroy the assignments for "Awesome Gift Exchange" + And I go to "Awesome Gift Exchange" gift exchange matching page + Then I should see "Regenerate Assignments" + And I should see "Regenerate All Potential Matches" + And I should see "try regenerating assignments" + When I follow "Regenerate Assignments" + And I reload the page + Then I should see "Reviewing Assignments" + And I should see "Complete" + + Scenario: Matches can be regenerated for a single signup + Given the gift exchange "Awesome Gift Exchange" is ready for matching + And I am logged in as "Mismatch" + And I sign up for "Awesome Gift Exchange" with a mismatched combination + When I am logged in as "mod1" + And I have generated matches for "Awesome Gift Exchange" + Then I should see "No Potential Givers" + And I should see "No Potential Recipients" + When I follow "No Potential Givers" + Then I should see "Regenerate Matches For Mismatch" + When I follow "Edit" + And I check the 1st checkbox with the value "Stargate Atlantis" + And I uncheck the 1st checkbox with the value "Bad Choice" + And I check the 2nd checkbox with the value "Stargate Atlantis" + And I uncheck the 2nd checkbox with the value "Bad Choice" + And I submit + And I follow "Matching" + And I follow "No Potential Recipients" + And I follow "Regenerate Matches For Mismatch" + Then I should see "Matches are being regenerated for Mismatch" + When I reload the page + Then I should not see "No Potential Givers" + And I should not see "No Potential Recipients" + When I follow "Regenerate Assignments" + And I reload the page + Then I should not see "No Potential Givers" + And I should not see "No Potential Recipients" + And I should see "Complete" + + Scenario: Assignments can be sent + Given the gift exchange "Awesome Gift Exchange" is ready for matching + And I have generated matches for "Awesome Gift Exchange" + When I follow "Send Assignments" + Then I should see "Assignments are now being sent out" + When I reload the page + Then I should not see "Assignments are now being sent out" + # 4 users and the mod should get emails :) + And 1 email should be delivered to "mod1" + And the email should have "Assignments sent" in the subject + And the email should contain "You have received a message about your collection" + And the email should not contain "translation missing" + And 1 email should be delivered to "myname1" + And the email should contain "You have been assigned the following request" + And the email should contain "Fandom:" + And the email should contain "Stargate SG-1" + # Ratings and warnings don't show unless they've been selected to be something other than the default + And the email should not contain "Rating:" + And the email should not contain "Choose Not To Use Archive Warnings" + And the email should contain "Additional Tag" + And the email should contain "Something else weird" + And the email should not contain "translation missing" + And 1 email should be delivered to "myname2" + And 1 email should be delivered to "myname3" + And 1 email should be delivered to "myname4" + And the email should link to "Awesome Gift Exchange" collection's url + And the email should link to myname1's user url + And the email html body should link to the works tagged "Stargate Atlantis" + + Scenario: User signs up for two gift exchanges at once and can use the Fulfill + link to fulfill one assignment at a time + Given everyone has their assignments for "Awesome Gift Exchange" + And everyone has their assignments for "Second Challenge" + When I am logged in as "myname1" + And I start to fulfill my assignment + Then the "Awesome Gift Exchange (myname3)" checkbox should be checked + And the "Second Challenge (myname3)" checkbox should not be checked + + Scenario: User has more than one pseud on signup form + Given "myname1" has the pseud "othername" + Given I am logged in as "mod1" + And I have created the gift exchange "Sensitive Gift Exchange" + And I open signups for "Sensitive Gift Exchange" + When I am logged in as "myname1" + When I start to sign up for "Sensitive Gift Exchange" + Then I should see "othername" + + Scenario: User tries to change pseud on a challenge signup and should not be able to, as it would break matching + Given "myname1" has the pseud "othername" + Given I am logged in as "mod1" + And I have created the gift exchange "Sensitive Gift Exchange" + And I open signups for "Sensitive Gift Exchange" + When I am logged in as "myname1" + When I sign up for "Sensitive Gift Exchange" with combination A + Then I should see "Sign-up was successfully created" + And I should see "Sign-up for myname1" + When I edit my signup for "Sensitive Gift Exchange" + Then I should not see "othername" + + Scenario: Mod can see everyone's assignments, includind users' emails + Given I am logged in as "mod1" + And everyone has their assignments for "Awesome Gift Exchange" + When I go to the "Awesome Gift Exchange" assignments page + Then I should see "Assignments for Awesome" + When I follow "Open" + Then I should see "Open Assignments" + And I should see "myname1" + And I should see the image "alt" text "email myname1" + + Scenario: User can see their assignment, but no email links + Given everyone has their assignments for "Awesome Gift Exchange" + When I am logged in as "myname1" + And I go to myname1's user page + And I follow "Assignments" + Then I should see "Awesome Gift Exchange" + When I follow "Awesome Gift Exchange" + Then I should see "Requests by myname3" + But I should not see the image "alt" text "email myname3" + And I should see "Offers by myname1" + But I should not see the image "alt" text "email myname1" + + Scenario: User fulfills their assignment and it shows on their assigments page as fulfilled + + Given everyone has their assignments for "Awesome Gift Exchange" + When I am logged in as "myname1" + And I fulfill my assignment + When I go to myname1's user page + And I follow "Assignments" + Then I should see "Awesome Gift Exchange" + And I should not see "Not yet posted" + And I should see "Fulfilled Story" + When I am logged in as "mod1" + And I go to the "Awesome Gift Exchange" assignments page + And I follow "Complete" + Then I should see "myname1" + And I should see "Fulfilled Story" + + Scenario: A mod can default all incomplete assignments + + Given everyone has their assignments for "Awesome Gift Exchange" + And I am logged in as "myname1" + And I fulfill my assignment + When I am logged in as "mod1" + And I go to the "Awesome Gift Exchange" assignments page + And I follow "Default All Incomplete" + Then I should see "All unfulfilled assignments marked as defaulting." + And I should see "Undefault myname2" + And I should see "Undefault myname3" + And I should see "Undefault myname4" + And I should not see "Undefault myname1" + + Scenario: User can default and a mod can undefault on their assignment + + Given everyone has their assignments for "Awesome Gift Exchange" + When I am logged in as "myname1" + And I go to the assignments page for "myname1" + And I follow "Default" + Then I should see "We have notified the collection maintainers that you had to default on your assignment." + When I am logged in as "mod1" + And I go to the "Awesome Gift Exchange" assignments page + And I check "Undefault myname1" + And I press "Submit" + Then I should see "Assignment updates complete!" + And I should not see "Undefault" + When I am logged in as "myname1" + And I go to the assignments page for "myname1" + And I should see "Default" + + Scenario: User can default and a mod can assign a pinch hitter + + Given everyone has their assignments for "Awesome Gift Exchange" + When I am logged in as "myname1" + And I go to the assignments page for "myname1" + And I follow "Default" + Then I should see "We have notified the collection maintainers that you had to default on your assignment." + When I am logged in as "mod1" + And I go to the "Awesome Gift Exchange" assignments page + And I fill in "Pinch Hitter:" with "nonexistent" + And I press "Submit" + Then I should see "We couldn't find the user nonexistent to assign that to." + When I fill in "Pinch Hitter:" with "myname1" + And I press "Submit" + Then I should see "No assignments to review!" + And I should see "Assignment updates complete!" + + Scenario: Refused story should still fulfill the assignment + + Given an assignment has been fulfilled in a gift exchange + And I reveal works for "Awesome Gift Exchange" + And I refuse my gift story "Fulfilled Story" + And I am logged in as "mod1" + And I go to the "Awesome Gift Exchange" assignments page + And I follow "Complete" + Then I should see "myname1" + And I should see "Fulfilled Story" + + Scenario: Download signups CSV + Given I am logged in as "mod1" + And I have created the gift exchange "My Gift Exchange" + + When I go to the "My Gift Exchange" signups page + And I follow "Download (CSV)" + Then I should download a csv file with the header row "Pseud Email Sign-up URL Request 1 Tags Request 1 Description Offer 1 Tags Offer 1 Description" + + Scenario: View a signup summary with no tags + Given the following activated users exist + | login | password | + | user1 | password | + | user2 | password | + | user3 | password | + | user4 | password | + | user5 | password | + | user6 | password | + When I am logged in as "mod1" + And I have created the tagless gift exchange "My Gift Exchange" + And I open signups for "My Gift Exchange" + When I am logged in as "user1" with password "password" + And I start to sign up for "My Gift Exchange" tagless gift exchange + When I am logged in as "user2" with password "password" + And I start to sign up for "My Gift Exchange" tagless gift exchange + When I am logged in as "user3" with password "password" + And I start to sign up for "My Gift Exchange" tagless gift exchange + When I am logged in as "user4" with password "password" + And I start to sign up for "My Gift Exchange" tagless gift exchange + When I am logged in as "user5" with password "password" + And I start to sign up for "My Gift Exchange" tagless gift exchange + When I am logged in as "user6" with password "password" + And I start to sign up for "My Gift Exchange" tagless gift exchange + When I am logged in as "mod1" + And I go to "My Gift Exchange" collection's page + And I follow "Sign-up Summary" + Then I should not see "Summary does not appear until at least" + And I should see "Tags were not used in this Challenge, so there is no summary to display here." + + Scenario: Sign-up Form link shows up in sidebar of moderated collections + Given I am logged in as "mod1" + And I have created the gift exchange "Cabbot Cove" + And I open signups for "Cabbot Cove" + When I am logged in as "Scott" with password "password" + And I go to "Cabbot Cove" collection's page + And I should see "Unmoderated" + And I should see "Sign-up Form" + Then I am logged in as "mod1" + And I go to "Cabbot Cove" collection's page + And I follow "Collection Settings" + And I check "This collection is moderated" + And I press "Update" + Then I am logged in as "Scott" with password "password" + And I go to "Cabbot Cove" collection's page + And I should see "Moderated" + And I should see "Sign-up Form" + + Scenario: Mod deletes a user's sign-up and a user deletes their own sign-up without JavaScript + Given I am logged in as "mod1" + And I have created the gift exchange "Awesome Gift Exchange" + And I open signups for "Awesome Gift Exchange" + And everyone has signed up for the gift exchange "Awesome Gift Exchange" + When I am logged in as "mod1" + And I go to the "Awesome Gift Exchange" signups page + And I delete the signup by "myname1" + Then I should see "Challenge sign-up was deleted." + When I am logged in as "myname2" + And I delete my signup for the gift exchange "Awesome Gift Exchange" + Then I should see "Challenge sign-up was deleted." + + Scenario: Assignment emails should contain all the information in the request + # Note: tag names are lowercased for the test so we could borrow the potential + # match steps, and due to the HTML, each tag must be looked for separate from + # its label or other tags of its type + Given I create the gift exchange "EmailTest" with the following options + | value | minimum | maximum | match | + | prompts | 1 | 1 | 1 | + | fandoms | 1 | 1 | 0 | + | characters | 1 | 1 | 0 | + | freeforms | 0 | 2 | 0 | + | ratings | 0 | 1 | 0 | + | categories | 0 | 1 | 0 | + And the user "badgirlsdoitwell" signs up for "EmailTest" with the following prompts + | type | characters | fandoms | freeforms | ratings | categories | + | request | any | the show | fic, art | Mature | | + | offer | villain | the show | fic | | | + And the user "sweetiepie" signs up for "EmailTest" with the following prompts + | type | characters | fandoms | freeforms | ratings | categories | + | request | protag | the book | | | any | + | offer | protag | the book | fic | | | + When I have generated matches for "EmailTest" + And I have sent assignments for "EmailTest" + Then 1 email should be delivered to "sweetiepie" + And the email should contain "Fandom:" + And the email should contain "the show" + And the email should contain "Additional Tags:" + And the email should contain "fic" + And the email should contain "art" + And the email should contain "Character:" + And the email should contain "Any" + And the email should contain "Rating:" + And the email should contain "Mature" + And the email should not contain "Relationships:" + And the email should not contain "Warnings:" + And the email should not contain "Category:" + And the email should not contain "Optional Tags:" + Then 1 email should be delivered to "badgirlsdoitwell" + And the email should contain "Fandom:" + And the email should contain "the book" + And the email should contain "Character:" + And the email should contain "protag" + And the email should contain "Category:" + And the email should contain "Any" + And the email should not contain "Additional Tags:" + And the email should not contain "Relationships:" + And the email should not contain "Rating:" + And the email should not contain "Warnings:" + And the email should not contain "Optional Tags:" + + Scenario: A mod can delete a gift exchange without needing Javascript and all the assignments and + sign-ups will be deleted with it, but the collection will remain + Given everyone has their assignments for "Bad Gift Exchange" + And I am logged in as "mod1" + When I delete the challenge "Bad Gift Exchange" + Then I should see "Are you sure you want to delete the challenge from the collection Bad Gift Exchange? All sign-ups, assignments, and settings will be lost. (Works and bookmarks will remain in the collection.)" + When I press "Yes, Delete Challenge" + Then I should see "Challenge settings were deleted." + And I should not see the gift exchange dashboard for "Bad Gift Exchange" + And no one should have an assignment for "Bad Gift Exchange" + And no one should be signed up for "Bad Gift Exchange" + When I am on the collections page + Then I should see "Bad Gift Exchange" + + Scenario: A user can still access their Sign-ups page after a gift exchange + they were signed up for has been deleted + Given I am logged in as "mod1" + And I have created the gift exchange "Bad Gift Exchange" + And I open signups for "Bad Gift Exchange" + And everyone has signed up for the gift exchange "Bad Gift Exchange" + And the challenge "Bad Gift Exchange" is deleted + When I am logged in as "myname1" + And I go to myname1's signups page + Then I should see "Challenge Sign-ups" + And I should not see "Bad Gift Exchange" + + Scenario: A user can still access their Assignments page after a gift exchange + they had an unfulfilled assignment in has been deleted + Given everyone has their assignments for "Bad Gift Exchange" + And the challenge "Bad Gift Exchange" is deleted + When I am logged in as "myname1" + And I go to the assignments page for "myname1" + Then I should see "My Assignments" + And I should not see "Bad Gift Exchange" + + Scenario: A user can still access their Assignments page after a gift exchange + they had a fulfilled assignment in has been deleted + Given an assignment has been fulfilled in a gift exchange + And the challenge "Awesome Gift Exchange" is deleted + When I am logged in as "myname1" + And I go to the assignments page for "myname1" + Then I should see "My Assignments" + And I should not see "Awesome Gift Exchange" + + Scenario: A mod can purge assignments after they have been sent, but must + first confirm the action + Given everyone has their assignments for "Bad Gift Exchange" + And I am logged in as "mod1" + When I go to the "Bad Gift Exchange" assignments page + And I follow "Purge Assignments" + Then I should see "Are you sure you want to purge all assignments for Bad Gift Exchange?" + When I press "Yes, Purge Assignments" + Then I should see "Assignments purged!" + + Scenario: The My Assignments page that a user sees when they have multiple + assignments in a single exchange does not include an email link. + Given everyone has their assignments for "Bad Gift Exchange" + And I am logged in as "write_in_giver" + And "write_in_giver" has two pinchhit assignments in the gift exchange "Bad Gift Exchange" + When I go to "Bad Gift Exchange" collection's page + And I follow "My Assignments" within "#dashboard" + Then I should not see the image "src" text "/images/envelope_icon.gif" + + Scenario: A user who disallows gift works is cautioned about signing up for + an exchange, and a user who allows them is not. + Given the gift exchange "Some Gift Exchange" is ready for signups + And I am logged in as "participant" + And the user "participant" disallows gifts + When I go to "Some Gift Exchange" collection's page + And I follow "Sign-up Form" + Then I should see "assigned users to gift works to you regardless of your preference settings" + When the user "participant" allows gifts + And I go to "Some Gift Exchange" collection's page + And I follow "Sign-up Form" + Then I should not see "assigned users to gift works to you regardless of your preference settings" + + Scenario: If a work is connected to an assignment for a user who disallows + gifts, user is still automatically added as a gift recipient. The recipient + remains attached even if the work is later disconnected from the assignment. + Given basic tags + And the user "recip" exists and is activated + And the user "recip" disallows gifts + And I am logged in as "gifter" + And "gifter" has an assignment for the user "recip" in the collection "exchange_collection" + When I fulfill my assignment + Then I should see "For recip." + When I follow "Edit" + And I uncheck "exchange_collection (recip)" + And I press "Post" + Then I should see "For recip." + + Scenario: A user can explicitly give a gift to a user who disallows gifts if + the work is connected to an assignment. The recipient remains attached even if + the work is later disconnected from the assignment. + Given basic tags + And the user "recip" exists and is activated + And the user "recip" disallows gifts + And I am logged in as "gifter" + And "gifter" has an assignment for the user "recip" in the collection "exchange_collection" + When I start to fulfill my assignment + And I fill in "Gift this work to" with "recip" + And I press "Post" + Then I should see "For recip." + When I follow "Edit" + And I uncheck "exchange_collection (recip)" + And I press "Post" + Then I should see "For recip." + + Scenario: If a work is connected to an assignment for a user who blocked the gifter, + user is still automatically added as a gift recipient. The recipient + remains attached even if the work is later disconnected from the assignment. + Given basic tags + And the user "recip" exists and is activated + And the user "recip" allows gifts + And the user "recip" has blocked the user "gifter" + And I am logged in as "gifter" + And "gifter" has an assignment for the user "recip" in the collection "exchange_collection" + When I fulfill my assignment + Then I should see "For recip." + When I follow "Edit" + And I uncheck "exchange_collection (recip)" + And I press "Post" + Then I should see "For recip." + + Scenario: A user can explicitly give a gift to a user who blocked the gifter if + the work is connected to an assignment. The recipient remains attached even if + the work is later disconnected from the assignment. + Given basic tags + And the user "recip" exists and is activated + And the user "recip" allows gifts + And the user "recip" has blocked the user "gifter" + And I am logged in as "gifter" + And "gifter" has an assignment for the user "recip" in the collection "exchange_collection" + When I start to fulfill my assignment + And I fill in "Gift this work to" with "recip" + And I press "Post" + Then I should see "For recip." + When I follow "Edit" + And I uncheck "exchange_collection (recip)" + And I press "Post" + Then I should see "For recip." diff --git a/features/gift_exchanges/challenge_giftexchange_tagsets.feature b/features/gift_exchanges/challenge_giftexchange_tagsets.feature new file mode 100644 index 0000000..119686e --- /dev/null +++ b/features/gift_exchanges/challenge_giftexchange_tagsets.feature @@ -0,0 +1,69 @@ +@collections @tag_sets +Feature: Gift Exchange Challenge with Tag Sets + In order to have more fics for my fandom + As a humble user + I want to run a gift exchange with tag sets so I can make it single-fandom + + Scenario: Tagsets show up in Challenge metadata + Given I am logged in as "mod1" + And I have created the gift exchange "Cabbot Cove Remixes" + And I go to the tagsets page + And I follow the add new tagset link + And I fill in "Title" with "Angela Lansbury" + And I submit + And I go to "Cabbot Cove Remixes" collection's page + And I follow "Profile" + And I should see "Tag Set:" + And I should see "Standard Challenge Tags" + When I edit settings for "Cabbot Cove Remixes" challenge + And I fill in "Tag Sets To Use:" with "Angela Lansbury" + And I press "Update" + Then I should see "Tag Sets:" + And I should see "Standard Challenge Tags" + And I should see "Angela Lansbury" + When I edit settings for "Cabbot Cove Remixes" challenge + And I check "Standard Challenge Tags" + And I check "Angela Lansbury" + And I press "Update" + Then I should not see "Tag Sets:" + And I should not see "Tag Set:" + And I should not see "Standard Challenge Tags" + And I should not see "Angela Lansbury" + + @javascript + Scenario: Run a single-fandom exchange + + Given basic tags + And I am logged in as "mod1" + And I have a canonical "Celebrities & Real People" fandom tag named "Hockey RPF" + And I have a canonical "Celebrities & Real People" fandom tag named "Bandom" + And a canonical character "Alexander Ovechkin" in fandom "Hockey RPF" + And a canonical character "Gerard Way" in fandom "Bandom" + And I set up the tag set "HockeyExchangeTags" with the fandom tags "Hockey RPF" + When I go to the "HockeyExchangeTags" tag set page + Then I should see "About HockeyExchangeTags" + And I should see "Celebrities & Real People" within ".index" + When I press "↓" within ".index" + Then I should see "Hockey RPF" + When I have set up the gift exchange "My Hockey Exchange" + And I fill in single-fandom gift exchange challenge options + And I fill in "Tag Sets To Use:" with "HockeyExchangeTags" + And I submit + Then I should see "Challenge was successfully created" + When I follow "Challenge Settings" + Then I should see "HockeyExchangeTags" + When I follow "Sign-up Form" + And I check the 1st checkbox with the value "Hockey RPF" + And I check the 2nd checkbox with the value "Hockey RPF" + And I enter "Gerard Way" in the "Characters" autocomplete field + Then I should see "No suggestions found" in the autocomplete + # "Gerard Way" from a different fandom cannot appear in autocomplete; let's force it anyway. + When I fill in the 1st field with id matching "character_tagnames" with "Gerard Way" + And I submit + Then I should see "Sorry! We couldn't save this challenge signup because" + And I should see "These character tags in your request are not in the selected fandom(s), Hockey RPF: Gerard Way (Your moderator may be able to fix this.)" + And I should not see "Sign-up was successfully created." + When I follow "remove Gerard Way" + And I choose "Alexander Ovechkin" from the "Characters" autocomplete + And I submit + Then I should see "Sign-up was successfully created." diff --git a/features/gift_exchanges/challenge_yuletide.feature b/features/gift_exchanges/challenge_yuletide.feature new file mode 100644 index 0000000..dbbe961 --- /dev/null +++ b/features/gift_exchanges/challenge_yuletide.feature @@ -0,0 +1,636 @@ +@collections +Feature: Collection + I want to test Yuletide, because it has several specific settings that are different from an ordinary gift exchange + + # Basic tag set testing is covered in challenge_giftexchange_tagsets.feature. + # Advanced stuff and nominations are covered in tags_and_wrangling/tag_set.feature. + + # uncomment this and the other 'javascript' lines below when testing on local + # in order to test javascript-based features + #@javascript + Scenario: Create a Yuletide gift exchange, sign up for it, run matching, post, fulfil pinch hits + + Given the following activated users exist + | login | password | + | mod1 | password | + | myname1 | password | + | myname2 | password | + | myname3 | password | + | myname4 | password | + | pinchhitter | password | + And I am logged in as "mod1" + And I have no collections + And I have Yuletide challenge tags setup + And I add the fandom "Stargate Atlantis" to the character "John Sheppard" + And I add the fandom "Starsky & Hutch" to the character "John Sheppard" + And I add the fandom "Tiny fandom" to the character "John Sheppard" + And a character exists with name: "Teyla Emmagan", canonical: true + And I add the fandom "Stargate Atlantis" to the character "Teyla Emmagan" + And I add the fandom "Starsky & Hutch" to the character "Teyla Emmagan" + And a character exists with name: "Foo The Wonder Goat", canonical: true + And I add the fandom "Tiny fandom" to the character "Foo The Wonder Goat" + And I add the fandom "Starsky & Hutch" to the character "Foo The Wonder Goat" + And a character exists with name: "Obscure person", canonical: true + And I add the fandom "Tiny fandom" to the character "Obscure person" + When I go to the collections page + Then I should see "Collections in the " + And I should not see "Yuletide" + When I follow "New Collection" + And I fill in "Display title" with "Yuletide" + And I fill in "Collection name" with "yule2011" + And I fill in "Introduction" with "Welcome to the exchange" + And I fill in "FAQ" with "<dl><dt>What is this thing?</dt><dd>It's a gift exchange-y thing</dd></dl>" + And I fill in "Rules" with "Be even nicer to people" + And I select "Gift Exchange" from "challenge_type" + And I check "This collection is unrevealed" + And I check "This collection is anonymous" + And I submit + Then I should see "Collection was successfully created" + And I should see "Setting Up the Yuletide Gift Exchange" + When I fill in "General Sign-up Instructions" with "Here are some general tips" + And I fill in "Request Instructions" with "Please request easy things" + And I fill in "Offer Instructions" with "Please offer lots of stuff" + # for testing convenience while still exercising the options, we are going with + # 2-3 requests, 2-3 offers + # url allowed in request + # description not allowed in offer + # 1 fandom required in offer and request + # 0-2 characters allowed in request + # 2-3 characters required in offer + # unique fandoms required in offers and requests + # "any" option available in character offers + # restrict character to fandom only + # match on 1 fandom and 1 character + And I check "gift_exchange_request_restriction_attributes_url_allowed" + And I uncheck "gift_exchange_offer_restriction_attributes_description_allowed" + And I fill in "gift_exchange_requests_num_required" with "2" + And I fill in "gift_exchange_requests_num_allowed" with "3" + And I fill in "gift_exchange_offers_num_required" with "2" + And I fill in "gift_exchange_offers_num_allowed" with "3" + And I fill in "Tag Sets To Use:" with "Standard Challenge Tags" + And I fill in "gift_exchange_request_restriction_attributes_fandom_num_required" with "1" + And I fill in "gift_exchange_request_restriction_attributes_fandom_num_allowed" with "1" + And I check "gift_exchange_request_restriction_attributes_require_unique_fandom" + And I fill in "gift_exchange_request_restriction_attributes_character_num_allowed" with "2" + And I fill in "gift_exchange_offer_restriction_attributes_fandom_num_required" with "1" + And I fill in "gift_exchange_offer_restriction_attributes_fandom_num_allowed" with "1" + And I fill in "gift_exchange_offer_restriction_attributes_character_num_required" with "2" + And I fill in "gift_exchange_offer_restriction_attributes_character_num_allowed" with "3" + And I check "gift_exchange_offer_restriction_attributes_require_unique_fandom" + And I check "gift_exchange_offer_restriction_attributes_allow_any_character" + And I select "1" from "gift_exchange_potential_match_settings_attributes_num_required_fandoms" + And I select "1" from "gift_exchange_potential_match_settings_attributes_num_required_characters" + And I check "gift_exchange_offer_restriction_attributes_character_restrict_to_fandom" + And I check "Sign-up open?" + And I set up the challenge dates + And I submit + Then I should see "Challenge was successfully created" + When I log out + And I am logged in as "myname1" + When I go to the collections page + Then I should see "Yuletide" + When I follow "Yuletide" + Then I should see "Sign Up" + When I follow "Profile" + Then I should see "About Yuletide (yule2011)" + And I should see "Sign-up:" within ".collection .meta" + And I should see "Open" within ".collection .meta" + And I should see "Sign-up Closes:" within ".collection .meta" + And I should see "Assignments Due:" within ".collection .meta" + And I should see "Works Revealed:" within ".collection .meta" + And I should see "Creators Revealed:" within ".collection .meta" + And I should see "Signed up:" within ".collection .meta" + And I should see "0" within ".collection .meta" + And I should see "Welcome to the exchange" within "#intro" + And I should see "What is this thing?" within "#faq" + And I should see "It's a gift exchange-y thing" within "#faq" + And I should see "Be even nicer to people" within "#rules" + When I follow "Sign Up" + Then I should see "General Sign Up Instructions" + And I should see "Here are some general tips" + And I should see "Requests (2 - 3)" + And I should see "Please request easy things" + And I should see "Request 1" + And I should see "Fandom (1):" + And I should see "Care Bears" + And I should see "Stargate Atlantis" + And I should see "Starsky & Hutch" + And I should see "Tiny fandom" + And I should see "Yuletide Hippos RPF" + And I should see "Characters (0 - 2):" + And I should see "Prompt URL:" + And I should see "Description:" + And I should see "Request 2" + And I should not see "Request 3" + And I should see "Add another request? (Up to 3 allowed.)" + And I should see "Offers (2 - 3)" + And I should see "Please offer lots of stuff" + And I should see "Offer 1" + And I should see "Characters (2 - 3)" + And I should see "Any Character" within "dd.any.option" + And I should see "Offer 2" + And I should not see "Offer 3" + And I should see "Add another offer? (Up to 3 allowed.)" + # we fill in 1 request with 1 fandom, 1 character; 1 offer with 1 fandom and 1 character + When I check the 1st checkbox with the value "Stargate Atlantis" + And I fill in "challenge_signup_requests_attributes_0_tag_set_attributes_character_tagnames" with "John Sheppard" + And I fill in "Prompt URL" with "http://user.dreamwidth.org/123.html" + And I fill in "Description" with "This is my wordy request" + And I check the 3rd checkbox with the value "Care Bears" + And I fill in "challenge_signup_offers_attributes_0_tag_set_attributes_character_tagnames" with "Obscure person" + And I press "Submit" + Then I should see a save error message + # errors for the empty request + And I should see "Request: Your Request must include exactly 1 fandom tags, but you have included 0 fandom tags in your current Request" + # errors for the not-quite-filled offer + And I should see "Offer: Your Offer must include between 2 and 3 character tags, but you have included 1 character tags in your current Offer" + And I should see a not-in-fandom error message + # errors for the empty offer + And I should see "Offer: Your Offer must include exactly 1 fandom tags, but you have included 0 fandom tags in your current Offer" + And I should see "Offer: Your Offer must include between 2 and 3 character tags, but you have included 0 character tags in your current Offer" + # Over-fill the remaining missing fields and duplicate fandoms + When I fill in "challenge_signup_requests_attributes_0_tag_set_attributes_character_tagnames" with "John Sheppard, Teyla Emmagan, Obscure person" + And I check the 2nd checkbox with the value "Tiny fandom" + And I check the 2nd checkbox with the value "Starsky & Hutch" + And I fill in "challenge_signup_requests_attributes_1_tag_set_attributes_character_tagnames" with "Teyla Emmagan" + And I fill in "challenge_signup_offers_attributes_0_tag_set_attributes_character_tagnames" with "Obscure person, John Sheppard" + And I check the 4th checkbox with the value "Care Bears" + And I fill in "challenge_signup_offers_attributes_1_tag_set_attributes_character_tagnames" with "Obscure person, John Sheppard, Teyla Emmagan, Foo The Wonder Goat" + And I press "Submit" + Then I should see a save error message + And I should see "Request: Your Request must include between 0 and 2 character tags, but you have included 3 character tags in your current Request" + And I should see a not-in-fandom error message for "Obscure person" in "Stargate Atlantis" + And I should see "Request: Your Request must include exactly 1 fandom tags, but you have included 2 fandom tags in your current Request" + And I should see a not-in-fandom error message for "Obscure person, John Sheppard" in "Care Bears" + And I should see "Offer: Your Offer must include between 2 and 3 character tags, but you have included 4 character tags in your current Offer" + And I should see a not-in-fandom error message for "Obscure person, John Sheppard, Teyla Emmagan, Foo The Wonder Goat" in "Care Bears" + And I should see "You have submitted more than one offer with the same fandom tags. This challenge requires them all to be unique." + + + # now fill in correctly + # We have six participants who sign up as follows: + + # myname1 requests: SGA (JS, TE), Tiny fandom (Obscure person) + # offers: Tiny fandom (Obscure person, JS), Hippos (Any) + # (is the only person who can write for myname2 and should therefore be assigned to them) + When I fill in "challenge_signup_requests_attributes_0_tag_set_attributes_character_tagnames" with "John Sheppard, Teyla Emmagan" + And I uncheck the 2nd checkbox with the value "Starsky & Hutch" + And I fill in "challenge_signup_requests_attributes_1_tag_set_attributes_character_tagnames" with "Obscure person" + And I uncheck the 3rd checkbox with the value "Care Bears" + And I check the 3rd checkbox with the value "Tiny fandom" + And I uncheck the 4th checkbox with the value "Care Bears" + And I check the 4th checkbox with the value "Yuletide Hippos RPF" + And I fill in "challenge_signup_offers_attributes_1_tag_set_attributes_character_tagnames" with "" + And I check "challenge_signup_offers_attributes_1_any_character" + And I press "Submit" + Then I should see "Sign-up was successfully created" + And I should see "Sign-up for myname1" + And I should see "Requests" + And I should see "This is my wordy request" + And I should see "Offers" + And I should see "Edit" + And I should see "Delete" + + # another person signs up + When I log out + And I am logged in as "myname2" + When I go to the collections page + And I follow "Yuletide" + And I follow "Profile" + # before signing up, you can check who else has already signed up + Then I should see "Signed up:" within ".collection .meta" + And I should see "1" within ".collection .meta" + + # myname2 requests: Unoffered (no chars), Hippos (no chars) + # offers: S&H (JS, TE), SGA (JS, TE) + # can only get from myname1 + When I follow "Sign Up" + And I check the 1st checkbox with value "Unoffered" + And I check the 2nd checkbox with value "Yuletide Hippos RPF" + And I check the 3rd checkbox with value "Starsky & Hutch" + And I check the 4th checkbox with value "Stargate Atlantis" + And I fill in "challenge_signup_requests_attributes_0_tag_set_attributes_character_tagnames" with "Any" + And I fill in "challenge_signup_offers_attributes_0_tag_set_attributes_character_tagnames" with "Teyla Emmagan, John Sheppard" + And I fill in "challenge_signup_offers_attributes_1_tag_set_attributes_character_tagnames" with "Teyla Emmagan, John Sheppard" + And I press "Submit" + Then I should see a save error message + And I should see a not-in-fandom error message for "Any" in "Unoffered" + When I fill in "challenge_signup_requests_attributes_0_tag_set_attributes_character_tagnames" with "" + And I press "Submit" + Then I should see "Sign-up was successfully created" + + # and a third person signs up + # myname3 requests: S&H (JS), Tiny fandom; + # offers: SGA (JS, TE), S&H (JS, TE, Foo) + When I log out + And I am logged in as "myname3" + When I go to the collections page + And I follow "Yuletide" + And I follow "Sign Up" + When I check the 1st checkbox with the value "Starsky & Hutch" + And I fill in "challenge_signup_requests_attributes_0_tag_set_attributes_character_tagnames" with "John Sheppard" + And I check the 2nd checkbox with the value "Tiny fandom" + And I check the 3rd checkbox with the value "Stargate Atlantis" + And I fill in "challenge_signup_offers_attributes_0_tag_set_attributes_character_tagnames" with "John Sheppard, Teyla Emmagan" + And I check the 4th checkbox with the value "Starsky & Hutch" + And I fill in "challenge_signup_offers_attributes_1_tag_set_attributes_character_tagnames" with "John Sheppard, Teyla Emmagan, Foo The Wonder Goat" + # TRICKY note here! the index value for the javascript-added request 3 is actually 3; this is + # a workaround because otherwise it would display a duplicate number + # These three commented out so it can run on the command-line + #And I follow "Add another request? (Up to 3 allowed.)" + #Then I should see "Request 3" + #And I check "challenge_signup_requests_attributes_3_fandom_30" + And I press "Submit" + Then I should see "Sign-up was successfully created" + + # fourth person signs up + # myname4 requests SGA, S&H (JS, TE) + # offers Tiny (Obscure, JS), S&H (Foo, JS) + When I log out + And I am logged in as "myname4" + When I go to the collections page + And I follow "Yuletide" + And I follow "Sign Up" + And I check the 1st checkbox with value "Stargate Atlantis" + And I check the 2nd checkbox with value "Starsky & Hutch" + And I fill in "challenge_signup_requests_attributes_1_tag_set_attributes_character_tagnames" with "John Sheppard, Teyla Emmagan" + And I check the 3rd checkbox with value "Tiny fandom" + And I fill in "challenge_signup_offers_attributes_0_tag_set_attributes_character_tagnames" with "Obscure person, John Sheppard" + And I check the 4th checkbox with value "Starsky & Hutch" + And I fill in "challenge_signup_offers_attributes_1_tag_set_attributes_character_tagnames" with "Foo The Wonder Goat, John Sheppard" + And I press "Submit" + Then I should see "Sign-up was successfully created" + + # ordinary users can't see signups until 5 people have signed up + When I go to the collections page + And I follow "Yuletide" + Then I should not see "Sign-ups" within "#dashboard" + And I should see "Sign-up Summary" + When I follow "Sign-up Summary" + Then I should see "Summary does not appear until at least 5 sign-ups have been made!" + And I should not see "Stargate Atlantis" + + # fifth person signs up + # myname5 requests SGA, S&H + # offers Tiny (Foo, Obscure), SGA (JS, TE) + When I log out + And I am logged in as "myname5" + When I go to the collections page + And I follow "Yuletide" + And I follow "Sign Up" + And I check the 1st checkbox with value "Stargate Atlantis" + And I check the 2nd checkbox with value "Starsky & Hutch" + And I check the 3rd checkbox with value "Tiny fandom" + And I fill in "challenge_signup_offers_attributes_0_tag_set_attributes_character_tagnames" with "Foo The Wonder Goat, Obscure Person" + And I check the 4th checkbox with value "Stargate Atlantis" + And I fill in "challenge_signup_offers_attributes_1_tag_set_attributes_character_tagnames" with "Teyla Emmagan, John Sheppard" + And I press "Submit" + Then I should see "Sign-up was successfully created" + + # ordinary users can't see signups but can see summary + When I go to the collections page + And I follow "Yuletide" + Then I should not see "Sign-ups" within "#dashboard" + And I should see "Sign-up Summary" + When I follow "Sign-up Summary" + Then I should see "Sign-up Summary for Yuletide" + And I should see "Requested Fandoms" + And I should see "Starsky & Hutch 3 3" + And I should see "Stargate Atlantis 3 3" + And I should see "Tiny fandom 2 3" + + # signup summary changes when another person signs up + # myname6 requests: SGA, S&H + # offers: Tiny (Foo, Obscure), SGA (JS, TE) + When I log out + And I am logged in as "myname6" + When I go to the collections page + And I follow "Yuletide" + And I follow "Sign Up" + And I check the 1st checkbox with value "Stargate Atlantis" + And I check the 2nd checkbox with value "Starsky & Hutch" + And I check the 3rd checkbox with value "Tiny fandom" + And I fill in "challenge_signup_offers_attributes_0_tag_set_attributes_character_tagnames" with "Foo The Wonder Goat, Obscure Person" + And I check the 4th checkbox with value "Stargate Atlantis" + And I fill in "challenge_signup_offers_attributes_1_tag_set_attributes_character_tagnames" with "Teyla Emmagan, John Sheppard" + And I press "Submit" + Then I should see "Sign-up was successfully created" + When I go to the collections page + And I follow "Yuletide" + And I follow "Sign-up Summary" + Then I should see "Sign-up Summary for Yuletide" + And I should see "Requested Fandoms" + And I should see "Starsky & Hutch 4 3" + And I should see "Stargate Atlantis 4 4" + And I should see "Tiny fandom 2 4" + + # mod can view signups + When I log out + And I am logged in as "mod1" + And I go to the collections page + And I follow "Yuletide" + And I follow "Sign-ups" + Then I should see "myname4" within "#main" + And I should see "myname3" within "#main" + And I should see "myname2" within "#main" + And I should see "myname1" within "#main" + And I should see "myname5" within "#main" + And I should see "myname6" within "#main" + And I should see "John Sheppard" + And I should see "Obscure person" + And I should see "http://user.dreamwidth.org/123.html" + + # mod runs matching + When I follow "Matching" + Then I should see "You can't generate matches while sign-up is still open." + And I should not see "Generate Potential Matches" + When I follow "Challenge Settings" + And I uncheck "Sign-up open?" + And I press "Update" + Then I should see "Challenge was successfully updated" + When I follow "Matching" + Then I should see "Matching for Yuletide" + And I should see "Generate Potential Matches" + And I should see "No potential matches yet" + When all emails have been delivered + When I follow "Generate Potential Matches" + Then I should see "Beginning generation of potential matches. This may take some time, especially if your challenge is large." + When I reload the page + Then I should see "Reviewing Assignments" + And I should see "Complete" + And I should not see "No Recipient" + And I should not see "No Giver" + And I should see "Regenerate Assignments" + And I should see "Regenerate All Potential Matches" + And I should see "Send Assignments" + And 1 email should be delivered + + # mod regenerates the assignments + When all emails have been delivered + When I follow "Regenerate Assignments" + Then I should see "Beginning regeneration of assignments. This may take some time, especially if your challenge is large." + When I reload the page + Then I should see "Complete" + And I should not see "No Recipient" + And I should not see "No Giver" + And 1 email should be delivered + + # mod sends assignments out + When all emails have been delivered + And I follow "Send Assignments" + Then I should see "Assignments are now being sent out" + And I should see "No assignments to review" + And I should see "Defaulted" + And I should see "Pinch Hits" + And I should see "Open" + And I should see "Complete" + And I should see "Purge Assignments" + And I should see "Default All Incomplete" + When I reload the page + Then I should not see "Assignments are now being sent out" + # 6 users and the mod should get emails :) + And 7 emails should be delivered + + + # Notes for understanding the matching here: + # + # myname1 requests: SGA (JS, TE), Tiny fandom (Obscure person) + # offers: Tiny fandom (Obscure person, JS), Hippos (Any) + # myname2 requests: Unoffered (no chars), Hippos (no chars) + # offers: S&H (JS, TE), SGA (JS, TE) + # myname3 requests: S&H (JS), Tiny fandom; + # offers: SGA (JS, TE), S&H (JS, TE, Foo) + # myname4 requests SGA, S&H (JS, TE) + # offers Tiny (Obscure, JS), S&H (Foo, JS) + # myname5 requests SGA, S&H + # offers Tiny (Foo, Obscure), SGA (JS, TE) + # myname6 requests: SGA, S&H + # offers: Tiny (Foo, Obscure), SGA (JS, TE) + # + # so myname1 is the only person who can write for myname2 and therefore myname2 should be their assignment + # + + # first user starts posting + When I log out + And I am logged in as "myname1" + And I go to myname1's user page + And all emails have been delivered + #' stop annoying syntax highlighting after apostrophe + Then I should see "Assignments (1)" + When I follow "Assignments" + Then I should see "Yuletide for myname2" within "dl" + And I should see "Fulfill" + When I follow "Fulfill" + Then I should see "Post New Work" + When I fill in "Work Title" with "Fulfilling Story 1" + And I fill in "Fandoms" with "Stargate Atlantis" + And I select "Not Rated" from "Rating" + And I check "No Archive Warnings Apply" + And I select "English" from "Choose a language" + And I fill in "content" with "This is an exciting story about Atlantis" + When I press "Preview" + Then I should see "Preview" + And 0 emails should be delivered + + # someone looks while it's still a draft + When I log out + And I am logged in as "myname2" + And I go to myname2's user page + #' stop annoying syntax highlighting after apostrophe + Then I should see "Gifts (0)" + And I should not see "Gifts (1)" + When I follow "Gifts" + Then I should not see "Stargate Atlantis" + And I should not see "myname" within "ul.gift" + When I go to the collections page + And I follow "Yuletide" + Then I should see "Works (0)" + And I should see "Fandoms (0)" + When I follow "Works (0)" + Then I should not see "Stargate" + And I should not see "myname" within "#main" + When I follow "Fandoms (0)" + Then I should not see "Stargate" + And I should not see "myname" within "#main" + When I follow "Random Items" + Then I should not see "Stargate" + And I should not see "myname" within "#main" + + # first user posts the work + When I log out + And I am logged in as "myname1" + And I go to myname1's user page + #' stop annoying syntax highlighting after apostrophe + And I follow "Drafts" + Then I should see "Fulfilling Story 1" + When I follow "Edit" + And I fill in "Fandoms" with "Stargate Atlantis" + And I press "Preview" + Then I should see "Preview" + And I should see "Fulfilling Story" + And I should see "myname" within "#main" + And I should see "Anonymous" + And 0 emails should be delivered + When I press "Post" + And all indexing jobs have been run + Then I should see "Work was successfully posted" + And I should see "For myname" + And I should see "Collections:" + And I should see "Yuletide" within ".meta" + And I should see "Anonymous" + # notification is still not sent, because it's unrevealed + And 0 emails should be delivered + + # someone tries to view it + When I log out + And I go to myname1's user page + #' stop annoying syntax highlighting after apostrophe + Then I should not see "Mystery Work" + And I should not see "Yuletide" + And I should not see "Fulfilling Story 1" + And I should not see "Stargate Atlantis" + When I follow "Works (0)" + Then I should not see "Stargate Atlantis" + + # user edits it to undo fulfilling the assignment + # When I am logged in as "myname1" + # And I go to myname1's user page + # #' stop highlighting + # Then I should see "Fulfilling Story" + # When I follow "Edit" + # When I uncheck "Yuletide (myname3)" + # And I fill in "work_collection_names" with "" + # And I fill in "work_recipients" with "" + # When I press "Preview" + # Then show me the html + # Then I should see "Work was successfully updated" + + # post works for all the assignments + When "myname2" posts the fulfilling story "Fulfilling Story 2" in "Stargate Atlantis" + And "myname3" posts the fulfilling story "Fulfilling Story 3" in "Tiny Fandom" + And "myname4" posts the fulfilling story "Fulfilling Story 4" in "Starsky & Hutch, Tiny Fandom" + And "myname5" posts the fulfilling draft "Fulfilling Story 5" in "Starsky & Hutch" + And 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." + + # Mod checks for unfulfilled assignments, and gets pinch-hitters to do them. + When I am logged in as "mod1" + And I go to the collections page + And I follow "Yuletide" + And I follow "Assignments" + Then I should see "No assignments to review!" + When I follow "Open" + Then I should see "myname5" within "dl.index.group" + And I should see "myname6" within "dl.index.group" + When I follow "Complete" + Then I should see "myname1" within "dl.index.group" + And I should see "myname2" within "dl.index.group" + And I should see "myname3" within "dl.index.group" + And I should see "myname4" within "dl.index.group" + And I should see "Fulfilling Story" + When I follow "Default All Incomplete" + Then I should see "All unfulfilled assignments marked as defaulting." + And I should not see "No assignments to review!" + When I fill in the 1st field with id matching "cover" with "pinchhitter" + And I submit + Then 1 email should be delivered + And the email should contain "You have been assigned the following request" + And I should see "Assignment updates complete!" + And all emails have been delivered + When I follow "Pinch Hits" + Then I should see "pinchhitter" + + # pinch hitter writes story + When "pinchhitter" posts the fulfilling story "Fulfilling Story pinch" in "Starsky & Hutch" + And I am logged in as "mod1" + And I go to "Yuletide" collection's page + And I follow "Assignments" + And I follow "Pinch Hits" + Then I should not see "pinchhitter" + When I follow "Complete" + Then I should see "pinchhitter" + And I should see "Fulfilling Story pinch" + + # mod reveals challenge on Dec 25th + When I am logged in as "mod1" + And 0 emails should be delivered + And all emails have been delivered + And I go to "Yuletide" collection's page + And I follow "Collection Settings" + And I uncheck "This collection is unrevealed" + And I press "Update" + Then I should see "Collection was successfully updated" + When I reload the page + # 5 gift notification emails are delivered for the 5 stories that have been posted so far (4 standard, 1 pinch-hit, 1 still a draft) + Then 5 emails should be delivered + And the email should contain "A gift work has been posted for you in the" + And the email should contain "Yuletide" + And the email should contain "at the Archive of Our Own" + And the email should contain "by Anonymous" + And the email should not contain "by myname1" + And the email should not contain "by myname2" + And the email should not contain "by myname3" + And the email should not contain "by myname4" + And the email should not contain "by myname5" + And the email should not contain "by myname6" + + # someone views their gift and it is anonymous + # Needs everyone to have fulfilled their assignments to be sure of finding a gift + When I am logged in as "myname2" + And I go to myname2's user page + #' + And I follow "Gifts" + Then I should see "Anonymous" + And I should not see "myname1" + And I should not see "myname3" + And I should not see "myname4" + And I should not see "myname5" + And I should not see "myname6" + And I should not see "pinchhitter" + When I follow "Fulfilling Story 1" + Then I should see the page title "Fulfilling Story 1 - Anonymous - Stargate Atlantis [Example Archive]" + Then I should see "Anonymous" + And I should not see "myname1" + And I should not see "myname3" + And I should not see "myname4" + And I should not see "myname5" + And I should not see "myname6" + And I should not see "pinchhitter" within ".byline" + # TODO: Check downloads more thoroughly + # When I follow "MOBI" + # Then I should see "Anonymous" + When I log out + Then I should see "Successfully logged out" + + # mod reveals authors on Jan 1st + When I am logged in as "mod1" + And I go to the collections page + And I follow "Yuletide" + And I follow "Collection Settings" + And I uncheck "This collection is anonymous" + And I press "Update" + Then I should see "Collection was successfully updated" + + # someone can now see their writer + When I log out + And I am logged in as "myname1" + And I go to myname1's user page + #' + Then I should see "Fulfilling Story 1" + And I should not see "Anonymous" + When I follow "Fulfilling Story 1" + Then I should not see "Anonymous" + And I should see "myname" within ".byline" + + When I follow "New Work" + Then I should not see "Does this fulfill a challenge assignment" + When I log out + And I am logged in as "pinchhitter" + And I follow "New Work" + Then I should not see "Does this fulfill a challenge assignment" + When I log out + And I am logged in as "myname6" + And I follow "New Work" + Then I should not see "Does this fulfill a challenge assignment" + + diff --git a/features/gift_exchanges/notification_emails.feature b/features/gift_exchanges/notification_emails.feature new file mode 100644 index 0000000..fd2caa4 --- /dev/null +++ b/features/gift_exchanges/notification_emails.feature @@ -0,0 +1,164 @@ +Feature: Gift Exchange Notification Emails + Make sure that gift exchange notification emails are formatted properly + + Scenario: Assignment sent notification emails should be sent to two owners in their respective locales when assignments are generated + Given I have created the tagless gift exchange "Holiday Swap" + And I open signups for "Holiday Swap" + + When I am logged in as "participant1" + And I start signing up for "Holiday Swap" + And I press "Submit" + Then I should see "Sign-up was successfully created." + + When I am logged in as "participant2" + And I start signing up for "Holiday Swap" + And I press "Submit" + Then I should see "Sign-up was successfully created." + + When I have added a co-moderator "mod2" to collection "Holiday Swap" + And a locale with translated emails + And the user "mod1" enables translated emails + And I close signups for "Holiday Swap" + And I have generated matches for "Holiday Swap" + And I have sent assignments for "Holiday Swap" + + Then 4 emails should be delivered + And "mod1" should receive 1 email + And the email to "mod1" should be translated + And the email should contain "You have received a message about your collection" + And "mod2" should receive 1 email + And the email to "mod2" should be non-translated + And the email should contain "You have received a message about your collection" + And "participant1" should receive 1 email + And "participant2" should receive 1 email + + Scenario: If collection email is set, use the collection email instead of moderator emails + Given I have created the tagless gift exchange "Holiday Swap" + And I open signups for "Holiday Swap" + And I am logged in as "participant1" + And I start signing up for "Holiday Swap" + And I press "Submit" + And I am logged in as "participant2" + And I start signing up for "Holiday Swap" + And I press "Submit" + And I have added a co-moderator "mod2" to collection "Holiday Swap" + And I go to "Holiday Swap" collection's page + And I follow "Collection Settings" + And I fill in "Collection email" with "test@archiveofourown.org" + And I press "Update" + And I close signups for "Holiday Swap" + And I have generated matches for "Holiday Swap" + And I have sent assignments for "Holiday Swap" + Then 3 emails should be delivered + And 1 email should be delivered to test@archiveofourown.org + And the email should contain "You have received a message about your collection" + + Scenario: Default notification emails should be sent to two owners in their respective locales when a user defaults on an assignment + + Given everyone has their assignments for "Holiday Swap" + And I have added a co-moderator "mod2" to collection "Holiday Swap" + And a locale with translated emails + And the user "mod1" enables translated emails + + When I am logged in as "myname1" + And I go to the assignments page for "myname1" + And I follow "Default" + Then I should see "We have notified the collection maintainers that you had to default on your assignment." + And 7 emails should be delivered + And "mod1" should receive 2 emails + And the last email to "mod1" should be translated + And the last email should contain "defaulted on their assignment" + And "mod2" should receive 1 email + And the email to "mod2" should be non-translated + And the email should contain "defaulted on their assignment" + + Scenario: Assignment notifications with linebreaks. + Given I have created the tagless gift exchange "Holiday Swap" + And I open signups for "Holiday Swap" + And I create an assignment notification message with linebreaks for "Holiday Swap" + + When I am logged in as "participant1" + And I start signing up for "Holiday Swap" + And I press "Submit" + Then I should see "Sign-up was successfully created." + + When I am logged in as "participant2" + And I start signing up for "Holiday Swap" + And I press "Submit" + Then I should see "Sign-up was successfully created." + + When I close signups for "Holiday Swap" + And I have generated matches for "Holiday Swap" + And I have sent assignments for "Holiday Swap" + + Then 3 emails should be delivered + And "mod1" should receive 1 email + And "participant1" should receive 1 email + And "participant2" should receive 1 email + And the notification message to "participant1" should contain linebreaks + And the notification message to "participant2" should contain linebreaks + + Scenario: Assignment notifications with ampersands should escape them. + Given I have created the tagless gift exchange "Holiday Swap" + And I open signups for "Holiday Swap" + And I create an assignment notification message with an ampersand for "Holiday Swap" + + When I am logged in as "participant1" + And I start signing up for "Holiday Swap" + And I press "Submit" + Then I should see "Sign-up was successfully created." + + When I am logged in as "participant2" + And I start signing up for "Holiday Swap" + And I press "Submit" + Then I should see "Sign-up was successfully created." + + When I close signups for "Holiday Swap" + And I have generated matches for "Holiday Swap" + And I have sent assignments for "Holiday Swap" + + Then 3 emails should be delivered + And "mod1" should receive 1 email + And "participant1" should receive 1 email + And "participant2" should receive 1 email + And the notification message to "participant1" should escape the ampersand + And the notification message to "participant2" should escape the ampersand + + Scenario: Assignment notifications with warning tags work. + Given I have set up the gift exchange "Dark Fic Exchange" + And I check "Sign-up open?" + And I allow warnings in my gift exchange + And I submit + + When I am logged in as "participant1" + And I start signing up for "Dark Fic Exchange" + And I check "No Archive Warnings Apply" + And I submit + Then I should see "Sign-up was successfully created." + + When I am logged in as "participant2" + And I start signing up for "Dark Fic Exchange" + And I check "No Archive Warnings Apply" + And I submit + Then I should see "Sign-up was successfully created." + + When I close signups for "Dark Fic Exchange" + And I have generated matches for "Dark Fic Exchange" + And I have sent assignments for "Dark Fic Exchange" + + Then "participant1" should receive 1 email + And the notification message to "participant1" should contain the no archive warnings tag + + Scenario: Assignment notifications should be sent to participants in their respective locales + Given the gift exchange "Holiday Swap" is ready for matching + And a locale with translated emails + And the user "myname1" enables translated emails + When I close signups for "Holiday Swap" + And I have generated matches for "Holiday Swap" + And I have sent assignments for "Holiday Swap" + Then "myname1" should receive 1 email + And the email should have "Your assignment!" in the subject + And the email to "myname1" should be translated + And "myname2" should receive 1 email + And the email should have "Your assignment!" in the subject + And the email to "myname2" should be non-translated diff --git a/features/gift_exchanges/potential_matches.feature b/features/gift_exchanges/potential_matches.feature new file mode 100644 index 0000000..e4e4e27 --- /dev/null +++ b/features/gift_exchanges/potential_matches.feature @@ -0,0 +1,360 @@ +Feature: + Testing potential match generation. + + Scenario: Small multi-fandom exchange, only fandom tags. + + Given I create the gift exchange "multifan3" with the following options + | value | minimum | maximum | match | + | prompts | 2 | 2 | 1 | + | fandoms | 1 | 1 | 1 | + And the user "test1" signs up for "multifan3" with the following prompts + | type | fandoms | + | request | Popular Fandom | + | request | Fandom of One | + | offer | Rare Fandom 1 | + | offer | Fandom of One | + And the user "test2" signs up for "multifan3" with the following prompts + | type | fandoms | + | request | Rare Fandom 1 | + | request | Rare Fandom 2 | + | offer | Popular Fandom | + | offer | Rare Fandom 2 | + And the user "test3" signs up for "multifan3" with the following prompts + | type | fandoms | + | request | Rare Fandom 2 | + | request | Rare Fandom 3 | + | offer | Popular Fandom | + | offer | Rare Fandom 2 | + + When potential matches are generated for "multifan3" + Then the potential matches for "multifan3" should be + | offer | request | + | test1 | test2 | + | test2 | test1 | + | test2 | test3 | + | test3 | test1 | + | test3 | test2 | + + Scenario: Unconstrained exchange. + + Given I create the gift exchange "unconstrained3" with the following options + | value | minimum | maximum | match | + | prompts | 1 | 1 | 1 | + | characters | 1 | 1 | 0 | + And the user "test1" signs up for "unconstrained3" with the following prompts + | type | characters | + | request | Evil Villain | + | offer | Evil Villain | + And the user "test2" signs up for "unconstrained3" with the following prompts + | type | characters | + | request | Sweet Protagonist | + | offer | Sweet Protagonist | + And the user "test3" signs up for "unconstrained3" with the following prompts + | type | characters | + | request | Morally Grey | + | offer | Morally Grey | + + When potential matches are generated for "unconstrained3" + Then the potential matches for "unconstrained3" should be + | offer | request | + | test1 | test2 | + | test1 | test3 | + | test2 | test1 | + | test2 | test3 | + | test3 | test1 | + | test3 | test2 | + + Scenario: Unconstrained exchange with no tags. + + Given I create the gift exchange "no_tags3" with the following options + | value | minimum | maximum | match | + | prompts | 1 | 1 | 1 | + And the user "test1" signs up for "no_tags3" with the following prompts + | type | + | request | + | offer | + And the user "test2" signs up for "no_tags3" with the following prompts + | type | + | request | + | offer | + And the user "test3" signs up for "no_tags3" with the following prompts + | type | + | request | + | offer | + + When potential matches are generated for "no_tags3" + Then the potential matches for "no_tags3" should be + | offer | request | + | test1 | test2 | + | test1 | test3 | + | test2 | test1 | + | test2 | test3 | + | test3 | test1 | + | test3 | test2 | + + Scenario: Constrained exchange with no matches. + + Given I create the gift exchange "constrained3" with the following options + | value | minimum | maximum | match | + | prompts | 1 | 1 | 1 | + | characters | 1 | 1 | 1 | + And the user "test1" signs up for "constrained3" with the following prompts + | type | characters | + | request | Evil Villain | + | offer | Evil Villain | + And the user "test2" signs up for "constrained3" with the following prompts + | type | characters | + | request | Sweet Protagonist | + | offer | Sweet Protagonist | + And the user "test3" signs up for "constrained3" with the following prompts + | type | characters | + | request | Morally Grey | + | offer | Morally Grey | + + When potential matches are generated for "constrained3" + Then there should be no potential matches for "constrained3" + + Scenario: Exchange with someone offering Any. + + Given I create the gift exchange "any_offer3" with the following options + | value | minimum | maximum | match | + | prompts | 1 | 1 | 1 | + | characters | 1 | 1 | 1 | + And the user "test1" signs up for "any_offer3" with the following prompts + | type | characters | + | request | Evil Villain | + | offer | any | + And the user "test2" signs up for "any_offer3" with the following prompts + | type | characters | + | request | Sweet Protagonist | + | offer | Sweet Protagonist | + And the user "test3" signs up for "any_offer3" with the following prompts + | type | characters | + | request | Morally Grey | + | offer | Morally Grey | + + When potential matches are generated for "any_offer3" + Then the potential matches for "any_offer3" should be + | offer | request | + | test1 | test2 | + | test1 | test3 | + + Scenario: Exchange with someone requesting Any. + + Given I create the gift exchange "any_request3" with the following options + | value | minimum | maximum | match | + | prompts | 1 | 1 | 1 | + | characters | 1 | 1 | 1 | + And the user "test1" signs up for "any_request3" with the following prompts + | type | characters | + | request | any | + | offer | Evil Villain | + And the user "test2" signs up for "any_request3" with the following prompts + | type | characters | + | request | Sweet Protagonist | + | offer | Sweet Protagonist | + And the user "test3" signs up for "any_request3" with the following prompts + | type | characters | + | request | Morally Grey | + | offer | Morally Grey | + + When potential matches are generated for "any_request3" + Then the potential matches for "any_request3" should be + | offer | request | + | test2 | test1 | + | test3 | test1 | + + Scenario: Exchange with offers and requests for Any. + + Given I create the gift exchange "any_both3" with the following options + | value | minimum | maximum | match | + | prompts | 1 | 1 | 1 | + | characters | 1 | 1 | 1 | + And the user "test1" signs up for "any_both3" with the following prompts + | type | characters | + | request | any | + | offer | Evil Villain | + And the user "test2" signs up for "any_both3" with the following prompts + | type | characters | + | request | Sweet Protagonist | + | offer | any | + And the user "test3" signs up for "any_both3" with the following prompts + | type | characters | + | request | Morally Grey | + | offer | Morally Grey | + + When potential matches are generated for "any_both3" + Then the potential matches for "any_both3" should be + | offer | request | + | test2 | test1 | + | test2 | test3 | + | test3 | test1 | + + Scenario: Exchange with ALL matching. + + Given I create the gift exchange "all_matching3" with the following options + | value | minimum | maximum | match | + | prompts | 1 | 1 | 1 | + | characters | 1 | 4 | -1 | + And the user "test1" signs up for "all_matching3" with the following prompts + | type | characters | + | request | Evil Villain, Shy Friend | + | offer | Evil Villain, Shy Friend, Anti-Villain, Sweet Protagonist | + And the user "test2" signs up for "all_matching3" with the following prompts + | type | characters | + | request | Evil Villain, Comic Relief, Sweet Protagonist | + | offer | Evil Villain, Comic Relief, Sweet Protagonist | + And the user "test3" signs up for "all_matching3" with the following prompts + | type | characters | + | request | Comic Relief | + | offer | Shy Friend, Sweet Protagonist, Comic Relief | + + When potential matches are generated for "all_matching3" + Then the potential matches for "all_matching3" should be + | offer | request | + | test2 | test3 | + + Scenario: Exchange with ALL and Any. + + Given I create the gift exchange "all_and_any4" with the following options + | value | minimum | maximum | match | + | prompts | 1 | 1 | 1 | + | characters | 1 | 4 | -1 | + And the user "test1" signs up for "all_and_any4" with the following prompts + | type | characters | + | request | Evil Villain, Shy Friend | + | offer | any | + And the user "test2" signs up for "all_and_any4" with the following prompts + | type | characters | + | request | Evil Villain, Comic Relief, Sweet Protagonist | + | offer | Evil Villain, Comic Relief, Sweet Protagonist | + And the user "test3" signs up for "all_and_any4" with the following prompts + | type | characters | + | request | Comic Relief | + | offer | Shy Friend, Sweet Protagonist, Comic Relief | + And the user "test4" signs up for "all_and_any4" with the following prompts + | type | characters | + | request | any | + | offer | Shy Friend, Sweet Protagonist, Comic Relief | + + When potential matches are generated for "all_and_any4" + Then the potential matches for "all_and_any4" should be + | offer | request | + | test1 | test2 | + | test1 | test3 | + | test2 | test3 | + | test1 | test4 | + | test2 | test4 | + | test3 | test4 | + | test4 | test3 | + + Scenario: Exchange with freeform restrictions. + + Given I create the gift exchange "freeform4" with the following options + | value | minimum | maximum | match | unique | + | prompts | 1 | 2 | 1 | n/a | + | characters | 1 | 2 | -1 | no | + | freeforms | 1 | 2 | 1 | no | + And the user "test1" signs up for "freeform4" with the following prompts + | type | characters | freeforms | + | request | Evil Villain | Fic | + | request | Evil Villain, Sweet Protagonist | Fic, Art | + | offer | Evil Villain, Sweet Protagonist | Fic | + And the user "test2" signs up for "freeform4" with the following prompts + | type | characters | freeforms | + | request | Comic Relief | Fic | + | request | Woobie of Choice | Art | + | offer | any | Art | + And the user "test3" signs up for "freeform4" with the following prompts + | type | characters | freeforms | + | request | Sweet Protagonist | Art, Fic | + | request | Evil Villain, Anti-Villain | Art | + | offer | Evil Villain, Anti-Villain | Fic | + And the user "test4" signs up for "freeform4" with the following prompts + | type | characters | freeforms | + | request | Sweet Protagonist | Fic | + | offer | Comic Relief, Sweet Protagonist | Fic | + + When potential matches are generated for "freeform4" + Then the potential matches for "freeform4" should be + | offer | request | + | test1 | test3 | + | test1 | test4 | + | test2 | test1 | + | test2 | test3 | + | test3 | test1 | + | test4 | test2 | + | test4 | test3 | + + Scenario: Multi-fandom exchange with optional tags. + + Given I create the gift exchange "optional3" with the following options + | value | minimum | maximum | match | optional | any | + | prompts | 2 | 2 | 1 | n/a | n/a | + | fandoms | 1 | 1 | 1 | yes | no | + | characters | 1 | 1 | 1 | yes | yes | + And the user "bookfan" signs up for "optional3" with the following prompts + | type | fandoms | characters | optional fandoms | + | request | Out-of-Print Book | Rare Woobie | | + | request | Rare Book | Obscure Character | | + | offer | Out-of-Print Book | any | Rare Book | + | offer | Popular Book | any | | + And the user "showfan" signs up for "optional3" with the following prompts + | type | fandoms | characters | optional fandoms | + | request | 80s Cop Show | Badass Partner | | + | request | 90s Fantasy Show | Witty Protagonist | | + | offer | 80s Cop Show | Badass Partner | | + | offer | 90s Fantasy Show | any | 00s SciFi Show | + And the user "mixed" signs up for "optional3" with the following prompts + | type | fandoms | characters | optional fandoms | + | request | Rare Book | Fandom Darling | | + | request | 00s SciFi Show | Bounty Hunter | | + | offer | Popular Book | any | Rare Book | + | offer | 10s Drama Show | any | 90s Fantasy Show | + + When potential matches are generated for "optional3" + Then the potential matches for "optional3" should be + | offer | request | + | mixed | bookfan | + | mixed | showfan | + | bookfan | mixed | + | showfan | mixed | + + Scenario: Exchange with optional tags and restrictions on a different type. + + Given I create the gift exchange "optional4" with the following options + | value | minimum | maximum | match | optional | any | unique | + | prompts | 1 | 3 | 1 | n/a | n/a | n/a | + | characters | 1 | 1 | 1 | yes | no | no | + | freeforms | 1 | 1 | 1 | no | no | yes | + And the user "test1" signs up for "optional4" with the following prompts + | type | character | optional characters | freeform | + | request | Warlock | | Fic | + | request | Warlock | | Art | + | offer | Warlock | Witch, Magician | Fic | + And the user "test2" signs up for "optional4" with the following prompts + | type | character | optional characters | freeform | + | request | Magician | Headologist | Art | + | offer | Magician | Headologist, Warlock | Art | + And the user "test3" signs up for "optional4" with the following prompts + | type | character | optional characters | freeform | + | request | Magician | | Fic | + | request | Magician | | Art | + | request | Magician | | Vid | + | offer | Magician | | Fic | + | offer | Magician | | Art | + | offer | Magician | | Vid | + And the user "test4" signs up for "optional4" with the following prompts + | type | character | optional characters | freeform | + | request | Headologist | Summoner, Witch | Vid | + | offer | Headologist | Summoner, Witch | Art | + | offer | Headologist | Summoner, Witch | Vid | + + When potential matches are generated for "optional4" + Then the potential matches for "optional4" should be + | offer | request | + | test2 | test1 | + | test3 | test2 | + | test1 | test3 | + | test2 | test3 | + | test4 | test2 | diff --git a/features/gift_exchanges/signup_summary.feature b/features/gift_exchanges/signup_summary.feature new file mode 100644 index 0000000..d436d76 --- /dev/null +++ b/features/gift_exchanges/signup_summary.feature @@ -0,0 +1,41 @@ +Feature: Gift Exchange Signup Summary + + Scenario: Updating a live summary + Given signup summaries are always visible + And all signup summaries are live + And I have created the gift exchange "Exchange" + And I open signups for "Exchange" + + When I am logged in as "testuser1" + And I sign up for "Exchange" with combination C + And I view the sign-up summary for "Exchange" + Then I should see "Stargate SG-1 1 1" + + When I am logged in as "testuser2" + And I sign up for "Exchange" with combination D + And I view the sign-up summary for "Exchange" + Then I should see "Stargate SG-1 1 1" + And I should see "Stargate Atlantis 1 1" + + Scenario: Updating a delayed summary + Given signup summaries are always visible + And all signup summaries are delayed + And I have created the gift exchange "Exchange" + And I open signups for "Exchange" + + When I am logged in as "testuser1" + And I sign up for "Exchange" with combination C + And I view the sign-up summary for "Exchange" + Then I should see "Stargate SG-1 1 1" + + When I am logged in as "testuser2" + And I sign up for "Exchange" with combination D + And I view the sign-up summary for "Exchange" + Then I should see "Stargate SG-1 1 1" + But I should not see "Stargate Atlantis" + + When it is currently 61 minutes from now + And I view the sign-up summary for "Exchange" + Then I should see "Stargate SG-1 1 1" + And I should see "Stargate Atlantis 1 1" + And I jump in our Delorean and return to the present diff --git a/features/importing/archivist.feature b/features/importing/archivist.feature new file mode 100644 index 0000000..4879bd6 --- /dev/null +++ b/features/importing/archivist.feature @@ -0,0 +1,392 @@ +@archivist_import +Feature: Archivist bulk imports + + Background: + Given I have an archivist "archivist" + And the default ratings exist + And all warnings exist + And I am logged in as "archivist" + + Scenario: Non-archivist cannot import for others + Given I am logged in as a random user + When I go to the import page + Then I should not see "Import for others ONLY with permission" + + Scenario: Make a user an archivist + Given I have pre-archivist setup for "not_archivist" + And I am logged in as an "open_doors" admin + When I make "not_archivist" an archivist + Then I should see "User was successfully updated" + + Scenario: Archivist can see link to import for others + When I go to the import page + Then I should see "Import for others ONLY with permission" + + Scenario: Importing for others without an email address should give an error + Given I am logged in as "archivist" + When I start importing "http://import-site-with-tags" with a mock website as an archivist + And I check "Import for others ONLY with permission" + And I fill in "Author Name*" with "Name" + And I press "Import" + Then I should see "We couldn't successfully import that work, sorry: No author email specified" + + Scenario: Importing for others without a name should give an error + Given I am logged in as "archivist" + When I start importing "http://import-site-with-tags" with a mock website as an archivist + And I check "Import for others ONLY with permission" + And I fill in "Author Email Address*" with "foo@example.com" + And I press "Import" + Then I should see "We couldn't successfully import that work, sorry: No author name specified" + + Scenario: Importing for others without a name or email address should give an error + Given I am logged in as "archivist" + When I start importing "http://import-site-with-tags" with a mock website as an archivist + And I check "Import for others ONLY with permission" + And I press "Import" + Then I should see "We couldn't successfully import that work, sorry: No external author name or email specified" + + Scenario: Importing for an author without an account should have the correct byline and email + When I import the work "http://rebecca2525.livejournal.com/3562.html" + Then I should see "We have notified the author(s) you imported works for" + And I should see "rebecca2525 [archived by archivist]" + And 1 email should be delivered to "rebecca2525@livejournal.com" + And the email should contain invitation warnings from "archivist" for work "Importing Test" in fandom "Lewis" + + # TODO: Enable after AO3-6353. + @wip + Scenario: Import a work for multiple authors without accounts should display all in the byline + When I go to the import page + And I import the work "http://ao3testing.dreamwidth.org/593.html" by "name1" with email "a@ao3.org" and by "name2" with email "b@ao3.org" + Then I should see "Story" + And I should see "name1 [archived by archivist]" + And I should see "name2 [archived by archivist]" + + # TODO: Enable after AO3-6353. + @wip + Scenario: Import a work for multiple authors without accounts should send emails to all authors + When I go to the import page + And I import the work "http://ao3testing.dreamwidth.org/593.html" by "name1" with email "a@ao3.org" and by "name2" with email "b@ao3.org" + Then 1 email should be delivered to "a@ao3.org" + And 1 email should be delivered to "b@ao3.org" + + # TODO: Enable after AO3-6353. + @wip + Scenario: Import a work for multiple authors with and without accounts should display all in the byline + Given the following activated users exist + | login | email | + | user1 | a@ao3.org | + When I go to the import page + And I import the work "http://ao3testing.dreamwidth.org/593.html" by "name1" with email "a@ao3.org" and by "name2" with email "b@ao3.org" + Then I should see "Story" + And I should see "user1" + And I should see "name2 [archived by archivist]" + + # TODO: Enable after AO3-6353. + @wip + Scenario: Import a work for multiple authors with and without accounts should send emails to all authors + Given the following activated users exist + | login | email | + | user1 | a@ao3.org | + When I go to the import page + And I import the work "http://ao3testing.dreamwidth.org/593.html" by "name1" with email "a@ao3.org" and by "name2" with email "b@ao3.org" + Then 1 email should be delivered to "a@ao3.org" + And 1 email should be delivered to "b@ao3.org" + + # TODO: Enable after AO3-6353. + @wip + Scenario: Import a work for multiple authors with accounts should not display the archivist + Given the following activated users exist + | login | email | + | user1 | a@ao3.org | + | user2 | b@ao3.org | + When I go to the import page + And I import the work "http://ao3testing.dreamwidth.org/593.html" by "name1" with email "a@ao3.org" and by "name2" with email "b@ao3.org" + Then I should see "Story" + And I should see "user1" + And I should see "user2" + But I should not see "archivist" within ".byline" + + # TODO: Enable after AO3-6353. + @wip + Scenario: Import multiple works as an archivist + When I import the works "http://ao3testing.dreamwidth.org/593.html, http://ao3testing.dreamwidth.org/325.html" + Then I should see multi-story import messages + And I should see "Story" + And I should see "Test entry" + And I should see "We have notified the author(s) you imported works for. If any were missed, you can also add co-authors manually." + + # TODO: Enable after AO3-6353. + @wip + Scenario: Importing only sends one email even if there are many works + When I import the works "http://ao3testing.dreamwidth.org/593.html, http://ao3testing.dreamwidth.org/325.html" + Then 1 email should be delivered to "ao3testing@dreamwidth.org" + + # TODO: Enable after AO3-6353. + @wip + Scenario: Importing for an existing Archive author should have correct byline and email + Given the following activated user exists + | login | email | + | ao3 | ao3testing@dreamwidth.org | + When I import the work "http://ao3testing.dreamwidth.org/593.html" + And all indexing jobs have been run + Then I should see import confirmation + And I should see "ao3" + And I should not see "[archived by archivist]" + And 1 email should be delivered to "ao3testing@dreamwidth.org" + And the email should contain claim information + When I go to ao3's works page + Then I should see "Story" + + # TODO: Enable after AO3-6353. + @wip + Scenario: Importing for an email address that's not associated with an existing Archive account, but that does belong to a user, allows the user to claim the works and add them to their account + Given the user "creator" exists and is activated + When I import the work "http://ao3testing.dreamwidth.org/593.html" by "creator" with email "not_creators_account_email@example.com" + And all indexing jobs have been run + Then 1 email should be delivered to "not_creators_account_email@example.com" + When I am logged in as "creator" + # Use the URL because we get logged out if we follow the link in the email + And I go to the claim page for "not_creators_account_email@example.com" + Then I should see "Claim your works with your logged-in account." + When I press "Add these works to my currently-logged-in account" + And all indexing jobs have been run + Then I should see "Author Identities for creator" + And I should see "We have added the stories imported under not_creators_account_email@example.com to your account." + When I go to creator's works page + Then I should see "Story" + + # TODO: Enable after AO3-6353. + @wip + Scenario: Importing sends an email to a guessed address if it can't find the author + When I import the work "http://ao3testing.dreamwidth.org/593.html" + Then I should see import confirmation + And I should see "Story" + # Importer assumes dreamwidth email for works from there + And 1 email should be delivered to "ao3testing@dreamwidth.org" + And the email should contain invitation warnings from "archivist" for work "Story" in fandom "Testing" + + # TODO: Enable after AO3-6353. + @wip + Scenario: Import a single work as an archivist specifying an external author + When I go to the import page + And I import the work "http://ao3testing.dreamwidth.org/593.html" by "randomtestname" with email "random@example.com" + Then I should not see multi-story import messages + And I should see "Story" + And I should see "randomtestname" + And I should see "We have notified the author(s) you imported works for. If any were missed, you can also add co-authors manually." + And 1 email should be delivered to "random@example.com" + + # TODO: Enable after AO3-6353. + @wip + Scenario: Import a single work as an archivist specifying an external author with an invalid name + When I import the work "http://ao3testing.dreamwidth.org/593.html" by "ra_ndo!m-t??est n@me." with email "random@example.com" + Then I should see import confirmation + And I should see "ra_ndom-test n@me." + And 1 email should be delivered to "random@example.com" + + # TODO: Enable after AO3-6353. + @wip + Scenario: Claim a work and create a new account in response to an invite + Given account creation is enabled + When I import the work "http://ao3testing.dreamwidth.org/593.html" by "randomtestname" with email "random@example.com" + Then 1 email should be delivered to "random@example.com" + And the email should contain "Claim or remove your works" + When I am logged out + And I follow "Claim or remove your works" in the email + Then I should see "Claiming Your Imported Works" + And I should see "An archive including some of your work(s) has been moved to the Archive of Our Own." + When I press "Sign me up and give me my works!" + Then I should see "Create Account" + When I fill in the sign up form with valid data + And I press "Create Account" + Then I should see "Almost Done!" + + # TODO: Enable after AO3-6353. + @wip + Scenario: Orphan a work in response to an invite, leaving name on it + Given I have an orphan account + When I import the work "http://ao3testing.dreamwidth.org/593.html" by "randomtestname" with email "random@example.com" + Then 1 email should be delivered to "random@example.com" + And the email should contain "Claim or remove your works" + When I am logged out + And I follow "Claim or remove your works" in the email + Then I should see "Claiming Your Imported Works" + And I should see "An archive including some of your work(s) has been moved to the Archive of Our Own." + When I choose "Orphan my works and take my email address off them, but keep my name." + And I wait 2 seconds + And I press "Update" + Then I should see "Your imported stories have been orphaned. Thank you for leaving them in the archive! Your preferences have been saved." + When I am logged in + And I view the work "Story" + Then I should see "randomtestname (orphan_account)" + + # TODO: Enable after AO3-6353. + @wip + Scenario: Orphan a work in response to an invite, taking name off it + Given I have an orphan account + When I import the work "http://ao3testing.dreamwidth.org/593.html" by "randomtestname" with email "random@example.com" + Then 1 email should be delivered to "random@example.com" + And the email should contain "Claim or remove your works" + When I am logged out + And I follow "Claim or remove your works" in the email + Then I should see "Claiming Your Imported Works" + And I should see "An archive including some of your work(s) has been moved to the Archive of Our Own." + When I choose "Orphan my works and take my email address off them, but keep my name." + And I check "Assign my works to the AO3 orphan_account, removing both my name and email address." + And I wait 2 seconds + And I press "Update" + Then I should see "Your imported stories have been orphaned. Thank you for leaving them in the archive! Your preferences have been saved." + When I am logged in + And I view the work "Story" + Then I should not see "randomtestname" + And I should see "orphan_account" + + # TODO: Enable after AO3-6353. + @wip + Scenario: Delete an imported work and choose not to be notified of future imports of your works + When I import the work "http://ao3testing.dreamwidth.org/593.html" by "randomtestname" with email "random@example.com" + Then 1 email should be delivered to "random@example.com" + And the email should contain "Claim or remove your works" + When I am logged out + And I follow "Claim or remove your works" in the email + Then I should see "Claiming Your Imported Works" + And I should see "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." + When I choose "Please remove my works from the archive entirely." + And I check "Do not email me in the future when works are imported with this email address." + And I press "Update" + Then I should be on the homepage + And I should see "Your imported stories have been deleted. Your preferences have been saved." + When the email queue is clear + And I am logged in as "archivist" + And I import the work "http://ao3testing.dreamwidth.org/325.html" by "randomtestname" with email "random@example.com" + Then 0 emails should be delivered to "random@example.com" + + # TODO: Enable after AO3-6353. + @wip + Scenario: Leave an imported work in the archivist's care + When I import the work "http://ao3testing.dreamwidth.org/593.html" by "randomtestname" with email "random@example.com" + Then 1 email should be delivered to "random@example.com" + And the email should contain "Claim or remove your works" + When I am logged out + And I follow "Claim or remove your works" in the email + Then I should see "Claiming Your Imported Works" + And I should see "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." + When I choose "Leave my works in the care of the archivist." + And I press "Update" + Then I should be on the homepage + And I should see "Okay, we'll leave things the way they are! You can use the email link any time if you change your mind. Your preferences have been saved." + + # TODO: Enable after AO3-6353. + @wip + Scenario: Leave an imported work in the archivist's care and choose not to be notified of future imports of your works + When I import the work "http://ao3testing.dreamwidth.org/593.html" by "randomtestname" with email "random@example.com" + Then 1 email should be delivered to "random@example.com" + And the email should contain "Claim or remove your works" + When I am logged out + And I follow "Claim or remove your works" in the email + Then I should see "Claiming Your Imported Works" + And I should see "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." + When I choose "Leave my works in the care of the archivist." + And I check "Do not email me in the future when works are imported with this email address." + And I press "Update" + Then I should be on the homepage + And I should see "Okay, we'll leave things the way they are! You can use the email link any time if you change your mind. Your preferences have been saved." + When the email queue is clear + And I am logged in as "archivist" + And I import the work "http://ao3testing.dreamwidth.org/325.html" by "randomtestname" with email "random@example.com" + Then 0 emails should be delivered to "random@example.com" + + # TODO: Enable after AO3-6353. + @wip + Scenario: Leave an imported work in the archivist's care and do not allow future imports with your email address + When I import the work "http://ao3testing.dreamwidth.org/593.html" by "randomtestname" with email "random@example.com" + Then 1 email should be delivered to "random@example.com" + And the email should contain "Claim or remove your works" + When I am logged out + And I follow "Claim or remove your works" in the email + Then I should see "Claiming Your Imported Works" + And I should see "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." + When I choose "Leave my works in the care of the archivist." + And I check "From now on, do not import works with this email address." + And I press "Update" + Then I should be on the homepage + And I should see "Okay, we'll leave things the way they are! You can use the email link any time if you change your mind. Your preferences have been saved." + When I am logged in as "archivist" + And I import the work "http://ao3testing.dreamwidth.org/325.html" by "randomtestname" with email "random@example.com" + Then I should see "We couldn't successfully import that work, sorry: Author randomtestname at random@example.com does not allow importing their work to this archive." + + # TODO: Enable after AO3-6353. + @wip + Scenario: Importing straight into a collection + Given I have a collection "Club" + And I am logged in as "archivist" + When I start to import the work "http://ao3testing.dreamwidth.org/593.html" by "randomtestname" with email "random@example.com" + And I press "Import" + Then I should see "We have notified the author(s) you imported works for. If any were missed, you can also add co-authors manually." + When I press "Edit" + And I fill in "work_collection_names" with "Club" + And I press "Post" + Then I should see "Story" + And I should see "randomtestname" + And I should see "Club" + And 1 email should be delivered to "random@example.com" + + # TODO: Enable after AO3-6353. + @wip + Scenario: Should not be able to import for others unless the box is checked + When I go to the import page + And I fill in "URLs*" with "http://ao3testing.dreamwidth.org/593.html" + And I select "English" from "Choose a language" + And I fill in "Author Name*" with "ao3testing" + And I fill in "Author Email Address*" with "ao3testing@example.com" + And I press "Import" + Then I should see /You have entered an external author name or e-mail address but did not select "Import for others."/ + When I check the 1st checkbox with id matching "importing_for_others" + And I press "Import" + Then I should see "We have notified the author(s) you imported works for. If any were missed, you can also add co-authors manually." + + Scenario: Archivist can't see Open Doors tools + When I go to the Open Doors tools page + Then I should see "Sorry, you don't have permission to access the page you were trying to reach." + + Scenario: Open Doors committee members can update the redirect URL of a work + Given the work "My Immortal" + And I have an Open Doors committee member "OpenDoors" + And I am logged in as "OpenDoors" + When I go to the Open Doors tools page + Then I should see "Update Redirect URL" + When I fill in "imported_from_url" with "http://example.com/my-immortal" + And I fill in "work_url" with the path to the "My Immortal" work page + And I submit with the 2nd button + Then I should see "Updated imported-from url for My Immortal to http://example.com/my-immortal" + When I follow "http://example.com/my-immortal" + Then I should be on the "My Immortal" work page + + Scenario: Open Doors committee members can block an email address from having imports + Given I have an Open Doors committee member "OpenDoors" + And I have an archivist "archivist" + And I set up mock websites for importing + And the default ratings exist + And I am logged in as "OpenDoors" + When I go to the Open Doors tools page + And I fill in "external_author_email" with "random@example.com" + And I submit with the 3rd button + Then I should see "We have saved and blocked the email address random@example.com" + When I am logged in as "archivist" + And I import the work "http://example.com/second-import-site-with-tags" by "ao3testing" with email "random@example.com" + Then I should see "Author ao3testing at random@example.com does not allow importing their work to this archive." + + Scenario: Open Doors committee members can supply a new email address for an already imported work. + Given I have an Open Doors committee member "OpenDoors" + And I have an archivist "archivist" + And I set up mock websites for importing + And the default ratings exist + And I am logged in as "archivist" + When I import the work "http://example.com/second-import-site-with-tags" by "randomtestname" with email "random@example.com" + And I am logged in as "OpenDoors" + And I go to the Open Doors external authors page + Then I should see "random@example.com" + When I fill in "email" with "random_person@example.com" + And I submit + Then I should see "Claim invitation for random@example.com has been forwarded to random_person@example.com" + And 1 email should be delivered to "random_person@example.com" diff --git a/features/importing/archivist_creatorships.feature b/features/importing/archivist_creatorships.feature new file mode 100644 index 0000000..a36aca5 --- /dev/null +++ b/features/importing/archivist_creatorships.feature @@ -0,0 +1,83 @@ +Feature: Special co-creator behavior for archivists + + Background: + Given I have an archivist "archivist" + And I am logged in as "archivist" + + Scenario: Archivists can add users who allow co-creators to works + Given the user "allow" exists and is activated + And the user "allow" allows co-creators + When I set up a draft "Imported" + And I try to invite the co-author "allow" + And I press "Post" + Then I should see "allow, archivist" within ".byline" + And 1 email should be delivered to "allow" + And the email should contain "The user archivist has added your pseud allow as a co-creator on the following work:" + And the email should not contain "translation missing" + + Scenario: Archivists can add users who don't allow co-creators to works + Given the user "disallow" exists and is activated + And the user "disallow" disallows co-creators + When I set up a draft "Imported" + And I try to invite the co-author "disallow" + And I press "Post" + Then I should see "archivist, disallow" within ".byline" + And 1 email should be delivered to "disallow" + And the email should contain "The user archivist has added your pseud disallow as a co-creator on the following work:" + And the email should not contain "translation missing" + + Scenario: Archivists can add users who allow co-creators to chapters + Given the user "allow" exists and is activated + And the user "allow" allows co-creators + And I post the work "Imported" + When a chapter is set up for "Imported" + And I try to invite the co-author "allow" + # Expire byline cache + And it is currently 1 second from now + And I press "Post" + Then I should see "allow, archivist" within ".byline" + And 1 email should be delivered to "allow" + And the email should contain "The user archivist has added your pseud allow as a co-creator on the following chapter:" + And the email should not contain "translation missing" + + Scenario: Archivists can add users who don't allow co-creators to chapters + Given the user "disallow" exists and is activated + And the user "disallow" disallows co-creators + And I post the work "Imported" + When a chapter is set up for "Imported" + And I try to invite the co-author "disallow" + # Expire byline cache + And it is currently 1 second from now + And I press "Post" + Then I should see "archivist, disallow" within ".byline" + And 1 email should be delivered to "disallow" + And the email should contain "The user archivist has added your pseud disallow as a co-creator on the following chapter:" + And the email should not contain "translation missing" + + Scenario: Archivists can add users who allow co-creators to series + Given the user "allow" exists and is activated + And the user "allow" allows co-creators + And I post the work "Imported" as part of a series "Imported Series" + When I view the series "Imported Series" + And I follow "Edit Series" + And I try to invite the co-author "allow" + And it is currently 1 second from now + And I press "Update" + Then "allow" should be a co-creator of the series "Imported Series" + And 1 email should be delivered to "allow" + And the email should contain "The user archivist has added your pseud allow as a co-creator on the following series:" + And the email should not contain "translation missing" + + Scenario: Archivists can add users who don't allow co-creators to series + Given the user "disallow" exists and is activated + And the user "disallow" disallows co-creators + And I post the work "Imported" as part of a series "Imported Series" + When I view the series "Imported Series" + And I follow "Edit Series" + And I try to invite the co-author "disallow" + And it is currently 1 second from now + And I press "Update" + Then "disallow" should be a co-creator of the series "Imported Series" + And 1 email should be delivered to "disallow" + And the email should contain "The user archivist has added your pseud disallow as a co-creator on the following series:" + And the email should not contain "translation missing" diff --git a/features/importing/work_import.feature b/features/importing/work_import.feature new file mode 100644 index 0000000..f8b6617 --- /dev/null +++ b/features/importing/work_import.feature @@ -0,0 +1,367 @@ +@import +Feature: Import Works + In order to have an archive full of works + As an author + I want to create new works by importing them + + Scenario: You can't create a work unless you're logged in + When I go to the import page + Then I should see "Please log in" + + Scenario: Creating a new minimally valid work + When I set up importing with a mock website + Then I should see "Import New Work" + When I fill in "urls" with "http://import-site-without-tags" + And I press "Import" + Then I should see "Language cannot be blank." + When I select "Deutsch" from "Choose a language" + And I press "Import" + Then I should see "Preview" + And I should see "Untitled Imported Work" + And I should see "Language: Deutsch" + And I should not see "A work has already been imported from http://import-site-without-tags" + And I should see "No Fandom" + And I should see "Chose Not To" + And I should see "Not Rated" + When I press "Post" + Then I should see "Work was successfully posted." + When I go to the works page + Then I should see "Untitled Imported Work" + + Scenario: With override disabled and tag detection enabled, tags should be detected + When I start importing "http://import-site-with-tags" with a mock website + And I select "Deutsch" from "Choose a language" + And I select "Explicit" from "Rating" + And I check "No Archive Warnings Apply" + And I fill in "Fandoms" with "Idol RPF" + And I check "M/M" + And I fill in "Relationships" with "Adam/Kris" + And I fill in "Characters" with "Adam Lambert, Kris Allen" + And I fill in "Additional Tags" with "kinkmeme" + And I fill in "Notes at the beginning" with "This is a <i>note</i>" + When I press "Import" + Then I should see "Preview" + And I should see "Detected Title" + And I should see "Language: Deutsch" + And I should see "Explicit" + And I should see "Archive Warning: Underage Sex" + And I should see "Fandom: Detected Fandom" + And I should see "Category: M/M" + And I should see "Relationship: Detected 1/Detected 2" + And I should see "Characters: Detected 1Detected 2" + And I should see "Additional Tags: Detected tag 1Detected tag 2" + And I should see "Notes: This is a content note." + When I press "Post" + Then I should see "Work was successfully posted." + + Scenario: With override and tag detection enabled, provided tags should be used when tags are entered + When I start importing "http://import-site-with-tags" with a mock website + And I select "Deutsch" from "Choose a language" + And I check "override_tags" + And I choose "detect_tags_true" + And I select "Mature" from "Rating" + And I check "No Archive Warnings Apply" + And I fill in "Fandoms" with "Idol RPF" + And I check "F/M" + And I fill in "Relationships" with "Adam/Kris" + And I fill in "Characters" with "Adam Lambert, Kris Allen" + And I fill in "Additional Tags" with "kinkmeme" + And I fill in "Notes at the beginning" with "This is a <i>note</i>" + When I press "Import" + Then I should see "Preview" + And I should see "Detected Title" + And I should see "Language: Deutsch" + And I should see "Rating: Mature" + And I should see "Archive Warning: No Archive Warnings" + And I should see "Fandom: Idol RPF" + And I should see "Category: F/M" + And I should see "Relationship: Adam/Kris" + And I should see "Characters: Adam LambertKris Allen" + And I should see "Additional Tags: kinkmeme" + And I should see "Notes: This is a note" + When I press "Post" + Then I should see "Work was successfully posted." + + Scenario: With override and tag detection enabled, both provided and detected tags should be used when not all tags are entered + When I start importing "http://import-site-with-tags" with a mock website + And I select "Deutsch" from "Choose a language" + And I check "override_tags" + And I choose "detect_tags_true" + And I select "Mature" from "Rating" + And I check "No Archive Warnings Apply" + And I fill in "Characters" with "Adam Lambert, Kris Allen" + And I fill in "Additional Tags" with "kinkmeme" + And I fill in "Notes at the beginning" with "This is a <i>note</i>" + And I press "Import" + Then I should see "Preview" + And I should see "Detected Title" + And I should see "Language: Deutsch" + And I should see "Rating: Mature" + And I should see "Archive Warning: No Archive Warnings" + And I should see "Fandom: Detected Fandom" + And I should see "Relationship: Detected 1/Detected 2" + And I should see "Characters: Adam LambertKris Allen" + And I should see "Additional Tags: kinkmeme" + And I should see "Notes: This is a note" + And I should not see "Category: M/M" + When I press "Post" + Then I should see "Work was successfully posted." + + Scenario: Default tags should be used when no tags are entered, and override is enabled and tag detection is disabled + When I start importing "http://import-site-with-tags" with a mock website + And I check "override_tags" + And I choose "detect_tags_false" + When I press "Import" + Then I should see "Detected Title" + And I should see "Rating: Not Rated" + And I should see "Archive Warning: Creator Chose Not To Use Archive Warnings" + And I should see "Fandom: No Fandom" + And I should not see "Relationship:" + And I should not see "Additional Tags:" + And I should not see "Relationship: Detected 1/Detected 2" + + Scenario Outline: Admins see IP address on imported works + Given I import "http://import-site-with-tags" with a mock website + And I press "Post" + When I am logged in as a "<role>" admin + And I go to the "Detected Title" work page + Then I should see "IP Address: 127.0.0.1" + + Examples: + | role | + | legal | + | policy_and_abuse | + + Scenario Outline: Admins see IP address on works imported without preview + Given I start importing "http://import-site-with-tags" with a mock website + And I check "Post without previewing" + And I press "Import" + When I am logged in as a "<role>" admin + And I go to the "Detected Title" work page + Then I should see "IP Address: 127.0.0.1" + + Examples: + | role | + | legal | + | policy_and_abuse | + + Scenario Outline: Admins see IP address on multi-chapter works imported without preview + Given I import the urls with mock websites as chapters without preview + """ + http://import-site-without-tags + http://second-import-site-without-tags + """ + When I am logged in as a "policy_and_abuse" admin + And I go to the "Untitled Imported Work" work page + Then I should see "Chapters:2/2" + And I should see "IP Address: 127.0.0.1" + + Examples: + | role | + | legal | + | policy_and_abuse | + + Scenario: Imported works can be set to restricted + When I start importing "http://import-site-with-tags" with a mock website + And I check "Only show imported works to registered users" + And I press "Import" + And I press "Post" + When I am logged out + And I go to the "Detected Title" work page + Then I should see "This work is only available to registered users of the Archive." + + Scenario: Imported works can have comments enabled to guests + When I start importing "http://import-site-with-tags" with a mock website + And I choose "comment_permissions_enable_all" + And I press "Import" + And I press "Post" + When I am logged out + And I go to the "Detected Title" work page + And I follow "Yes, Continue" + Then I should see "Guest name" + + Scenario: Imported works can have comments disabled to guests + When I start importing "http://import-site-with-tags" with a mock website + And I choose "comment_permissions_disable_anon" + And I press "Import" + And I press "Post" + When I am logged out + And I go to the "Detected Title" work page + And I follow "Yes, Continue" + Then I should see "Sorry, this work doesn't allow non-Archive users to comment." + + Scenario: Imported works can have comments disabled + When I start importing "http://import-site-with-tags" with a mock website + And I choose "comment_permissions_disable_all" + And I press "Import" + And I press "Post" + When I go to the "Detected Title" work page + Then I should see "Sorry, this work doesn't allow comments." + + Scenario: Imported works can have comment moderation off + When I start importing "http://import-site-with-tags" with a mock website + And I uncheck "moderated_commenting_enabled" + And I press "Import" + And I press "Post" + When I am logged out + And I go to the "Detected Title" work page + And I follow "Yes, Continue" + Then I should not see "This work's creator has chosen to moderate comments on the work." + + Scenario: Imported works can have comment moderation on + When I start importing "http://import-site-with-tags" with a mock website + And I choose "comment_permissions_enable_all" + And I check "moderated_commenting_enabled" + And I press "Import" + And I press "Post" + When I am logged out + And I go to the "Detected Title" work page + And I follow "Yes, Continue" + Then I should see "This work's creator has chosen to moderate comments on the work." + + Scenario: Importing multiple works with backdating + When I import the urls with mock websites + """ + http://example.com/second-import-site-with-tags.html + http://example.com/import-site-with-tags + """ + Then I should see "Imported Works" + And I should see "We were able to successfully upload" + And I should see "Huddling" + And I should see "Detected Title" + When I follow "Huddling" + Then I should see "Preview" + And I should see "2010-01-11" + + Scenario: Importing a new multichapter work with backdating should have correct chapter index dates + Given I set up importing with a mock website + When I fill in "urls" with + """ + http://example.com/import-site-with-tags + http://example.com/second-import-site-with-tags.html + """ + And I choose "Chapters in a single work" + And I select "Deutsch" from "Choose a language" + And I press "Import" + Then I should see "Preview" + When I press "Post" + Then I should see "Language: Deutsch" + And I should see "Published:2002-01-12" + And I should see "Completed:2010-01-11" + When I follow "Chapter Index" + Then I should see "1. Chapter 1 (2002-01-12)" + And I should see "2. Huddling (2010-01-11)" + + Scenario: Imported multichapter work should have the correct word count + Given I import the urls with mock websites as chapters without preview + """ + http://import-site-without-tags + http://second-import-site-without-tags + """ + Then I should see "Words:5" + + Scenario: Editing an imported multichapter work should have the correct word count + Given I import the urls with mock websites as chapters without preview + """ + http://import-site-without-tags + http://second-import-site-without-tags + """ + Then I should see "Words:5" + When I follow "Edit" + And I follow "1" + And I fill in "content" with "some extra content that is longer than before" + And I press "Post" + Then I should see "Words:11" + +# Scenario: Import works for others and have them automatically notified + + @work_import_special_characters_auto_utf + Scenario: Import a work with special characters (UTF-8, autodetect from page encoding) + When I import "http://www.rbreu.de/otwtest/utf8_specified.html" + Then I should see "Preview" + And I should see "Das Maß aller Dinge" within "h2.title" + And I should see "Ä Ö Ü é è È É ü ö ä ß ñ" + + @work_import_special_characters_auto_latin + Scenario: Import a work with special characters (latin-1, autodetect from page encoding) + When I import "http://www.rbreu.de/otwtest/latin1_specified.html" + Then I should see "Preview" + And I should see "Das Maß aller Dinge" within "h2.title" + And I should see "Ä Ö Ü é è È É ü ö ä ß ñ" + + @work_import_special_characters_man_latin + Scenario: Import a work with special characters (latin-1, must set manually) + When I start importing "http://www.rbreu.de/otwtest/latin1_notspecified.html" + And I select "ISO-8859-1" from "encoding" + When I press "Import" + Then I should see "Preview" + And I should see "Das Maß aller Dinge" within "h2.title" + And I should see "Ä Ö Ü é è È É ü ö ä ß ñ" + + @work_import_special_characters_man_cp + Scenario: Import a work with special characters (cp-1252, must set manually) + When I start importing "http://rbreu.de/otwtest/cp1252.txt" + And I select "Windows-1252" from "encoding" + When I press "Import" + Then I should see "Preview" + And I should see "‘He hadn’t known.’" + And I should see "So—what’s up?" + And I should see "“Something witty.”" + + @work_import_special_characters_man_utf + Scenario: Import a work with special characters (utf-8, must overwrite wrong page encoding) + When I start importing "http://www.rbreu.de/otwtest/utf8_notspecified.html" + And I select "UTF-8" from "encoding" + When I press "Import" + Then I should see "Preview" + And I should see "Das Maß aller Dinge" within "h2.title" + And I should see "Ä Ö Ü é è È É ü ö ä ß ñ" + + # TODO: scarvesandcoffee.net is 403. + @wip + Scenario: Import a chaptered work from an efiction site + When I import "http://www.scarvesandcoffee.net/viewstory.php?sid=9570" + Then I should see "Preview" + And I should see "Chapters: 4" + When I press "Post" + And I follow "Next Chapter →" + Then I should see "Chapter 2" + + Scenario: Searching for an imported work by URL will redirect you to the work + When I import "http://import-site-with-tags" with a mock website + And I press "Post" + And I go to the redirect page + And I fill in "Original URL of work" with "http://import-site-with-tags" + And I press "Go" + Then I should see "Detected Title" + + Scenario: Import URLs as chapters of a single work and post from drafts page + Given I import the urls with mock websites as chapters + """ + http://import-site-without-tags + http://second-import-site-without-tags + """ + When I follow "My Dashboard" + And I follow "Drafts (" + And I follow "Post Draft" + Then I should see "Your work was successfully posted." + And I should not see "This chapter is a draft and hasn't been posted yet!" + When I follow "Next Chapter" + Then I should not see "This chapter is a draft and hasn't been posted yet!" + + Scenario: Importing as an archivist for an existing Archive author should send translated claim email + Given a locale with translated emails + And the following activated users exist + | login | email | + | sam | sam@example.com | + | notsam | notsam@example.com | + And the user "sam" enables translated emails + And all emails have been delivered + When I import the mock work "http://import-site-without-tags" by "sam" with email "sam@example.com" and by "notsam" with email "notsam@example.com" + Then I should see import confirmation + And 1 email should be delivered to "sam@example.com" + And the email should contain claim information + And the email to "sam" should be translated + And 1 email should be delivered to "notsam@example.com" + And the email should contain claim information + And the email to "notsam" should be non-translated diff --git a/features/importing/work_import_da.feature b/features/importing/work_import_da.feature new file mode 100644 index 0000000..ebc6343 --- /dev/null +++ b/features/importing/work_import_da.feature @@ -0,0 +1,73 @@ +# TODO: Enable tests after AO3-5716. +@wip +@works @import +Feature: Import Works from deviantart + In order to have an archive full of works + As an author + I want to create new works by importing them from deviantart + + @import_da_title_link + Scenario: Creating a new art work from a deviantart title link with automatic metadata + Given basic tags + And I am logged in as "cosomeone" + When I go to the import page + And I fill in "urls" with "http://bingeling.deviantart.com/art/Flooded-45971613" + And I select "English" from "Choose a language" + And I press "Import" + Then I should see "Preview" + # And I should see the image "src" text "https://images-wixmp-ed30a86b8c4ca887773594c2.wixmp.com/intermediary/f/917f216b-24af-41f8-9802-7ab80f56d2f2/drdbx9-adee7105-ed30-4e62-a66d-4f78dfa36879.jpg" + And I should see the image "src" text "https://images-wixmp-ed30a86b8c4ca887773594c2.wixmp.com/f/917f216b-24af-41f8-9802-7ab80f56d2f2/drdbx9-adee7105-ed30-4e62-a66d-4f78dfa36879.jpg?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1cm46YXBwOjdlMGQxODg5ODIyNjQzNzNhNWYwZDQxNWVhMGQyNmUwIiwiaXNzIjoidXJuOmFwcDo3ZTBkMTg4OTgyMjY0MzczYTVmMGQ0MTVlYTBkMjZlMCIsIm9iaiI6W1t7InBhdGgiOiJcL2ZcLzkxN2YyMTZiLTI0YWYtNDFmOC05ODAyLTdhYjgwZjU2ZDJmMlwvZHJkYng5LWFkZWU3MTA1LWVkMzAtNGU2Mi1hNjZkLTRmNzhkZmEzNjg3OS5qcGcifV1dLCJhdWQiOlsidXJuOnNlcnZpY2U6ZmlsZS5kb3dubG9hZCJdfQ.rcJ-cfuf5xgyl97ztUJVOYQ2PmHgc6P_FWCirRiUKFU" + And I should see "Digital Art" within "dd.freeform" + And I should see "People" within "dd.freeform" + And I should see "Vector" within "dd.freeform" + And I should see "Published:2007-01-04" + # Importer picks up artist name as title instead of actual title + And I should not see "Flooded" within "h2.title" + And I should see "bingeling" within "h2.title" + And I should see "done with Photoshop 7" within "div.notes" + And I should see "but they were definitely helpful" within "div.notes" + And I should not see "deviant" + And I should not see "Visit the Artist" + And I should not see "Download Image" + When I press "Post" + Then I should see "Work was successfully posted." + When I am on cosomeone's user page + Then I should see "bingeling" + + @import_da_gallery_link + Scenario: Creating a new art work from a deviantart gallery link fails - it needs the direct link + Given basic tags + And I am logged in as "cosomeone" + When I go to the import page + And I fill in "urls" with "http://bingeling.deviantart.com/gallery/#/drdbx9" + And I select "English" from "Choose a language" + And I press "Import" + Then I should not see "Preview" + And I should see "Chapter 1 of" + And I should see "is blank." + + @import_da_fic + Scenario: Creating a new fic from deviantart import + Given basic tags + And I am logged in as "cosomeone" + When I go to the import page + And I fill in "urls" with "http://cesy12.deviantart.com/art/AO3-testing-text-196158032" + And I select "English" from "Choose a language" + And I press "Import" + Then I should see "Preview" + And I should see "Scraps" + And I should see "Published:2011-02-04" + # Importer picks up artist name as title instead of actual title + And I should not see "AO3 testing text" within "h2.title" + And I should see "cesy12" within "h2.title" + And I should see "This is the description of the story above." within "div.notes" + And I should see "This is a text, like a story or something." + And I should see "Complete with some paragraphs." + And I should not see "deviant" + And I should not see "AO3 testing text" within "#chapters" + And I should not see "Visit the Artist" + And I should not see "Download File" + When I press "Post" + Then I should see "Work was successfully posted." + When I am on cosomeone's user page + Then I should see "cesy12" diff --git a/features/importing/work_import_dw.feature b/features/importing/work_import_dw.feature new file mode 100644 index 0000000..63dee9e --- /dev/null +++ b/features/importing/work_import_dw.feature @@ -0,0 +1,170 @@ +# TODO: Enable tests after AO3-6353. +@wip +@works +Feature: Import Works from DW + In order to have an archive full of works + As an author + I want to create new works by importing them from DW + + @import_dw + Scenario: Importing a new work from an DW story with automatic metadata + Given basic tags + And a fandom exists with name: "Testing", canonical: true + And the following activated user exists + | login | password | + | cosomeone | something | + And I am logged in as "cosomeone" with password "something" + When I go to the import page + And I fill in "urls" with "https://ao3testing.dreamwidth.org/1726.html" + And I select "English" from "Choose a language" + And I press "Import" + Then I should see "Preview" + And I should see "Testing" within "dd.fandom" + And I should see "General Audiences" within "dd.rating" + And I should see "Character A/Character B" within "dd.relationship" + And I should see "Published:2017-07-03" + And I should see "Importing Test" within "h2.title" + And I should not see "Import Test" within "h2.title" + And I should see "Something I made for testing purposes." within "div.summary" + And I should see "THIS IS USED FOR AUTOMATED TESTS" within "div.notes" + And I should see "This is the body of my single-chapter work." + And I should not see the image "alt" text "Add to memories" + And I should not see the image "alt" text "Next Entry" + And I should not see "location" + And I should not see "music" + And I should not see "mood" + And I should not see "Entry tags" + And I should not see "Crossposts" + When I press "Post" + Then I should see "Work was successfully posted." + When I am on cosomeone's user page + Then I should see "Importing Test" + + @import_dw_tables + Scenario: Creating a new work from an DW story that has tables + # This is to make sure that we don't accidentally strip other tables than + # DW metadata tables esp. when there's no DW metadata table + + Given basic tags + And the following activated user exists + | login | password | + | cosomeone | something | + And I am logged in as "cosomeone" with password "something" + When I go to the import page + And I fill in "urls" with "https://ao3testing.dreamwidth.org/1836.html" + And I select "English" from "Choose a language" + And I press "Import" + Then I should see "Preview" + And I should see "Testing" within "dd.fandom" + And I should see "Teen And Up Audiences" within "dd.rating" + And I should see "Character A/Character B" within "dd.relationship" + And I should see "Published:2017-07-03" + And I should see "Single Chapter Fic from DW" within "h2.title" + And I should see "THIS IS USED FOR AUTOMATED TESTS" within "div.notes" + And I should see "This is the body of my single-chapter work." + And I should not see the image "alt" text "Add to memories" + And I should not see the image "alt" text "Next Entry" + And I should see "My location" + And I should see "My music" + When I press "Post" + Then I should see "Work was successfully posted." + When I am on cosomeone's user page + Then I should see "Single Chapter Fic from DW" + + @import_dw_tables_no_backdate + Scenario: Creating a new work from an DW story without backdating it + Given basic tags + And the following activated user exists + | login | password | + | cosomeone | something | + And I am logged in as a random user + When I go to the import page + And I fill in "urls" with "https://ao3testing.dreamwidth.org/1726.html" + And I select "English" from "Choose a language" + And I press "Import" + Then I should see "Preview" + And I should see "Importing Test" + When I press "Edit" + Then I should see "* Required information" + And I should see "Importing Test" + When I set the publication date to today + And I check "No Archive Warnings Apply" + When I press "Preview" + Then I should see "Importing Test" + When I press "Post" + Then I should see "Work was successfully posted." + And I should see "Importing Test" within "h2.title" + And I should not see "Created:2017-08-29" + And I should not see the image "alt" text "Add to memories!" + And I should not see the image "alt" text "Next Entry" + + @import_dw_comm + Scenario: Creating a new work from an DW story that is posted to a community + Given basic tags + And I am logged in as "cosomeone" + When I go to the import page + And I fill in "urls" with "https://ao3testingcomm.dreamwidth.org/702.html" + And I select "English" from "Choose a language" + And I press "Import" + Then I should see "Preview" + And I should see "Testing" within "dd.fandom" + And I should see "Explicit" within "dd.rating" + And I should see "Published:2017-08-29" + And I should see "Rails 5.1 Single Chapter from Comm (DW)" within "h2.title" + And I should see "This is an imported work" within "div.summary" + And I should see "This is a Rails 5.1 importing test." + And I should see the image "src" text "https://ao3testing.dreamwidth.org/file/495.jpg" + And I should not see "ao3testingcomm" + And I should not see "ao3testing" + And I should not see the image "alt" text "Add to memories" + And I should not see the image "alt" text "Next Entry" + And I should not see "mood" + And I should not see "Entry tags" + When I press "Post" + Then I should see "Work was successfully posted." + When I follow "Edit" + Then the "content" field should contain "class" + And the "content" field should contain "entry-content" + When I go to cosomeone's user page + Then I should see "Rails 5.1 Single Chapter from Comm (DW)" + + @import_dw_multi_chapter + Scenario: Creating a new multichapter work from a DW story + Given basic tags + And the following activated user exists + | login | password | + | cosomeone | something | + And I am logged in as "cosomeone" with password "something" + And the user "cosomeone" sets the time zone to "UTC" + When I go to the import page + And I fill in "urls" with + """ + https://ao3testing.dreamwidth.org/2460.html + https://ao3testing.dreamwidth.org/2664.html + https://ao3testing.dreamwidth.org/2968.html + """ + And I select "English" from "Choose a language" + And I choose "import_multiple_chapters" + And I press "Import" + Then I should see "Preview" + And I should see "Testing" within "dd.fandom" + And I should see "General Audiences" within "dd.rating" + And I should see "Rails 5.1 Chaptered (DW)" within "h2.title" + And I should not see "[FIC]" within "h2.title" + And I should see "Something I made for testing purposes." within "div.summary" + And I should see "THIS IS USED FOR AUTOMATED TESTS" within "div.notes" + And I should see "This is a Rails 5.1 importing test, chapter 1." + When I press "Post" + Then I should see "Work was successfully posted." + And I should see "Chapters:3/3" + And I should see "Published:2017-08-29" + And I should see "Completed:2017-08-29" + And I should see "This is a Rails 5.1 importing test, chapter 1." + When I follow "Next Chapter" + Then I should see "This is a Rails 5.1 importing test, chapter 2." + And I should see "This is the summary for chapter 2." within "div.summary" + And I should see "THIS IS USED FOR AUTOMATED TESTS" within "div.notes" + And I should see "My note on chapter 2." within "div.notes" + And I should see "Rails 5.1 Chaptered (DW)" within "h3.title" + When I am on cosomeone's user page + Then I should see "Rails 5.1 Chaptered (DW)" diff --git a/features/importing/work_import_errors.feature b/features/importing/work_import_errors.feature new file mode 100644 index 0000000..036821b --- /dev/null +++ b/features/importing/work_import_errors.feature @@ -0,0 +1,26 @@ +@works +Feature: Import Works + In order to have an archive full of works + As an author + I want to create new works by importing them + + Scenario: Entering a bogus URL + Given basic tags + And I set up importing with a mock website + And I am logged in as a random user + When I go to the import page + And I fill in "urls" with "http://no-content" + And I select "English" from "Choose a language" + And I press "Import" + Then I should see "We couldn't successfully import that work, sorry: We couldn't download anything from http://no-content. Please make sure that the URL is correct and complete, and try again." + When I follow "My Dashboard" + Then I should see "Drafts (0)" + + Scenario: Cannot import works from the current archive + Given I set up importing + And I fill in "urls" with "https://archiveofourown.org/works/54711364" + And I select "English" from "Choose a language" + And I press "Import" + Then I should see "We couldn't successfully import that work, sorry: URL is for a work on the Archive. Please bookmark it directly instead." + When I follow "My Dashboard" + Then I should see "Drafts (0)" diff --git a/features/importing/work_import_ffn.feature b/features/importing/work_import_ffn.feature new file mode 100644 index 0000000..6a68173 --- /dev/null +++ b/features/importing/work_import_ffn.feature @@ -0,0 +1,25 @@ +@works +Feature: Import Works from fanfiction.net + In order to have an archive full of works + As an author + I want to create new works by importing them from fanfiction.net + + @import_ffn + Scenario: Importing a new work from an FFN story + Given I am logged in as "cosomeone" with password "something" + And the default ratings exist + When I go to the import page + And I fill in "urls" with "http://www.fanfiction.net/s/3129674/1/What_More_Than_Usual_Light" + And I select "English" from "Choose a language" + When I press "Import" + Then I should see "Sorry, Fanfiction.net does not allow imports from their site." + + @import_ffn_multi_chapter + Scenario: Importing a new multichapter work from an FFN story + Given I am logged in as "cosomeone" with password "something" + And the default ratings exist + When I go to the import page + And I fill in "urls" with "http://www.fanfiction.net/s/6646765/1/IChing" + And I select "English" from "Choose a language" + When I press "Import" + Then I should see "Sorry, Fanfiction.net does not allow imports from their site." diff --git a/features/importing/work_import_lj.feature b/features/importing/work_import_lj.feature new file mode 100644 index 0000000..3b766e9 --- /dev/null +++ b/features/importing/work_import_lj.feature @@ -0,0 +1,172 @@ +@works +Feature: Import Works from LJ + In order to have an archive full of works + As an author + I want to create new works by importing them from LJ + @import_lj + Scenario: Creating a new work from an LJ story with automatic metadata + Given basic tags + And a fandom exists with name: "Lewis", canonical: true + And I am logged in as "cosomeone" + When I go to the import page + And I fill in "urls" with "http://rebecca2525.livejournal.com/3562.html" + And I select "English" from "Choose a language" + And I press "Import" + Then I should see "Preview" + And I should see "Lewis" within "dd.fandom" + And I should see "General Audiences" within "dd.rating" + And I should see "Lewis/Hathaway" within "dd.relationship" + And I should see "Published:2000-01-10" + And I should see "Importing Test" within "h2.title" + And I should not see "[FIC]" within "h2.title" + And I should see "Something I made for testing purposes." within "div.summary" + And I should see "Yes, this is really only for testing. :)" within "div.notes" + And I should see "My first paragraph." + And I should see "My second paragraph." + And I should not see the image "alt" text "Add to memories!" + And I should not see the image "alt" text "Next Entry" + And I should not see "location" + And I should not see "music" + And I should not see "mood" + And I should not see "Entry tags" + When I press "Post" + Then I should see "Work was successfully posted." + When I am on cosomeone's user page + Then I should see "Importing Test" + + @import_lj_tables + Scenario: Creating a new work from an LJ story that has tables + # This is to make sure that we don't accidentally strip other tables than + # LJ metadata tables esp. when there's no LJ metadata table + + Given basic tags + And a fandom exists with name: "Lewis", canonical: true + And I am logged in as "cosomeone" + When I go to the import page + And I fill in "urls" with "http://rebecca2525.livejournal.com/3591.html" + And I select "English" from "Choose a language" + And I press "Import" + Then I should see "Preview" + And I should see "Lewis" within "dd.fandom" + And I should see "General Audiences" within "dd.rating" + And I should see "Lewis/Hathaway" within "dd.relationship" + And I should see "Published:2000-01-10" + And I should see "Importing Test" within "h2.title" + And I should not see "[FIC]" within "h2.title" + And I should see "Something I made for testing purposes." within "div.summary" + And I should see "Yes, this is really only for testing. :)" within "div.notes" + And I should see "My first paragraph." + And I should see "My second paragraph." + And I should not see the image "alt" text "Add to memories!" + And I should not see the image "alt" text "Next Entry" + And I should see "My location" + And I should see "My music" + And I should see "My mood" + And I should see "My tags" + When I press "Post" + Then I should see "Work was successfully posted." + When I am on cosomeone's user page + Then I should see "Importing Test" + + @import_lj_no_backdate + Scenario: Creating a new work from an LJ story without backdating it + Given basic tags + And I am logged in as a random user + When I go to the import page + And I fill in "urls" with "http://rebecca2525.livejournal.com/3562.html" + And I select "English" from "Choose a language" + And I press "Import" + Then I should see "Preview" + And I should see "Importing Test" + When I press "Edit" + Then I should see "* Required information" + And I should see "Importing Test" + When I set the publication date to today + And I check "No Archive Warnings Apply" + And I press "Preview" + Then I should see "Importing Test" + When I press "Post" + Then I should see "Work was successfully posted." + And I should see "Importing Test" within "h2.title" + And I should not see the image "alt" text "Add to memories!" + And I should not see the image "alt" text "Next Entry" + + @import_lj_comm + Scenario: Creating a new work from an LJ story that is posted to a community + Given basic tags + And I am logged in as "cosomeone" + When "AO3-4179" is fixed + #When I go to the import page + # And I fill in "urls" with "http://community.livejournal.com/rarelitslash/271960.html" + # And I select "English" from "Choose a language" + # And I press "Import" + #Then I should see "Preview" + # And I should see "Poirot - Agatha Christie" within "dd.fandom" + # And I should see "General Audiences" within "dd.rating" + # And I should see "Published:2010-10-23" + # And I should see "Mrs Stanwood's Birthday Party" within "h2.title" + # And I should not see "[Poirot]" within "h2.title" + # And I should see "Mrs Stanwood, famous medical researcher" within "div.summary" + # And I should see "more to their friendship than he'd thought." within "div.summary" + # And I should see "Thanks to Tevildo and phantomphan1990 for beta-reading!" + # And I should see the image "src" text "http://www.rbreu.de/fan/stanwood_title_400.png" + # And I should see "Follow me to AO3" + # And I should not see "rarelitslash" + # And I should not see "rebecca2525" + # And I should not see the image "alt" text "Add to memories!" + # And I should not see the image "alt" text "Next Entry" + # And I should not see "mood" + # And I should not see "Entry tags" + #When I press "Post" + #Then I should see "Work was successfully posted." + #When I am on cosomeone's user page + #Then I should see "Mrs Stanwood's Birthday Party" + + @import_lj_underscores + Scenario: Importing from a journal with underscores in the name + Given basic tags + And I am logged in as "cosomeone" + When I go to the import page + And I fill in "urls" with "http://ao3_testing.livejournal.com/557.html" + And I select "English" from "Choose a language" + And I press "Import" + Then I should see "Preview" + + @import_lj_multi_chapter + Scenario: Creating a new multichapter work from an LJ story + Given basic tags + And I am logged in as "cosomeone" + And the user "cosomeone" sets the time zone to "UTC" + When I go to the import page + And I fill in "urls" with + """ + http://rebecca2525.livejournal.com/3562.html + http://rebecca2525.livejournal.com/4024.html + """ + And I select "English" from "Choose a language" + And I choose "import_multiple_chapters" + And I press "Import" + Then I should see "Preview" + And I should see "Lewis" within "dd.fandom" + And I should see "General Audiences" within "dd.rating" + And I should see "Importing Test" within "h2.title" + And I should not see "[FIC]" within "h2.title" + And I should see "Something I made for testing purposes." within "div.summary" + And I should see "Yes, this is really only for testing. :)" within "div.notes" + And I should see "My first paragraph." + And I should see "My second paragraph." + When I press "Post" + Then I should see "Work was successfully posted." + And I should see "Chapters:2/2" + And I should see "Published:2000-01-10" + And I should see "Completed:2000-01-22" + And I should see "My first paragraph." + And I should see "My second paragraph." + When I follow "Next Chapter" + Then I should see "The long awaited second part." + And I should see "And another paragraph." + And I should see "The plot thickens." within "div.summary" + And I should see "MOAR TESTING! :)" within "div.notes" + And I should see "Importing Test Part 2" within "h3.title" + When I am on cosomeone's user page + Then I should see "Importing Test" diff --git a/features/other_a/abuse_report.feature b/features/other_a/abuse_report.feature new file mode 100644 index 0000000..cdd83ad --- /dev/null +++ b/features/other_a/abuse_report.feature @@ -0,0 +1,77 @@ +Feature: Filing an abuse report + In order to report something + As an annoyed user + I want to file an abuse ticket + + Scenario: File an abuse request with default options + + Given basic languages + When I am logged in as "otheruser" + And I am on the home page + And I follow "Policy Questions & Abuse Reports" + And I should see the text with tags 'value="http://www.example.com/' + When I fill in "Description of the content you are reporting (required)" with "This is wrong" + And I fill in "Brief summary of Terms of Service violation (required)" with "This is a summary of bad things" + And I fill in "Link to the page you are reporting (required)" with "http://www.archiveofourown.org/works" + And I press "Submit" + Then I should see "Your report was submitted to the Policy & Abuse team. A confirmation message has been sent to the email address you provided." + # Receiving a copy of the abuse report is no longer a choice for the user. + # The email is sent automatically. + And 1 email should be delivered + + Scenario: URL is auto-filled on abuse report + + Given I have a work "Illegal thing" + And basic languages + When I am logged in as "otheruser" + And I view the work "Illegal thing" + And I follow "Policy Questions & Abuse Reports" + Then I should see the text with tags 'value="http://www.example.com/works/' + + Scenario: File an abuse request while logged out + + Given basic languages + When I am on the home page + And I follow "Policy Questions & Abuse Reports" + When I fill in "Brief summary of Terms of Service violation (required)" with "This is a summary of bad things" + And I fill in "Description of the content you are reporting (required)" with "This is wrong" + And I fill in "Link to the page you are reporting (required)" with "http://www.archiveofourown.org/works" + And I fill in "Your email (required)" with "otheruser@example.org" + And I press "Submit" + Then I should see "Your report was submitted to the Policy & Abuse team. A confirmation message has been sent to the email address you provided." + And 1 email should be delivered + + Scenario: File a request and enter blank email + + When I am logged in as "otheruser" + And basic languages + And I am on the home page + And I follow "Policy Questions & Abuse Reports" + And I fill in "Brief summary of Terms of Service violation (required)" with "This is a summary of bad things" + And I fill in "Description of the content you are reporting (required)" with "This is wrong" + And I fill in "Link to the page you are reporting (required)" with "http://www.archiveofourown.org/works" + And I fill in "Your email (required)" with "" + And I select "Deutsch" from "abuse_report_language" + And I press "Submit" + And I should see "Email should look like an email address." + And "Deutsch" should be selected within "Select language (required)" + Then I fill in "Your email (required)" with "valid@archiveofourown.org" + And I press "Submit" + And I should see "Your report was submitted to the Policy & Abuse team. A confirmation message has been sent to the email address you provided." + And 1 email should be delivered + + Scenario: File a report containing images + + Given I am logged in as "otheruser" + And basic languages + When I follow "Policy Questions & Abuse Reports" + And I fill in "Brief summary of Terms of Service violation (required)" with '<img src="foo.jpg" />Gross' + And I fill in "Description of the content you are reporting (required)" with "This is wrong <img src='bar.jpeg' />" + And I fill in "Link to the page you are reporting (required)" with "http://www.archiveofourown.org/works" + And I press "Submit" + Then 1 email should be delivered + # The sanitizer adds the domain in front of relative image URLs as of AO3-6571 + And the email should not contain "<img src="http://www.example.org/foo.jpg" />" + And the email should not contain "<img src="http://www.example.org/bar.jpeg" />" + But the email should contain "Gross" + And the email should contain "This is wrong img src="http://www.example.org/bar.jpeg"" diff --git a/features/other_a/accept_header.feature b/features/other_a/accept_header.feature new file mode 100644 index 0000000..4ac4f87 --- /dev/null +++ b/features/other_a/accept_header.feature @@ -0,0 +1,10 @@ +Feature: Browsing from a non-standard user agent +As a Playstation user +In order to browse the AO3 +I want to be able to browse from my PSP + +Scenario: user agent requests a weird header + + Given I use a PSP browser + When I make a request for "/works" + Then the response should be "200" diff --git a/features/other_a/autocomplete.feature b/features/other_a/autocomplete.feature new file mode 100644 index 0000000..596ddd6 --- /dev/null +++ b/features/other_a/autocomplete.feature @@ -0,0 +1,262 @@ +Feature: Display autocomplete for tags + In order to facilitate posting + I should be getting autocompletes for my tags + + @javascript + Scenario: Only matching canonical tags should appear in autocomplete, + and searching for the same data twice should produce same results + Given I am logged in + And a set of tags for testing autocomplete + And I go to the new work page + Then the tag autocomplete fields should list only matching canonical tags + + @javascript + Scenario: If a fandom is entered, only characters/relationships in the fandom + should appear in autocomplete + Given I am logged in + And a set of tags for testing autocomplete + And I go to the new work page + Then the fandom-specific tag autocomplete fields should list only fandom-specific canonical tags + + @javascript + Scenario: Bookmark archive work form autocomplete should work + Given I am logged in + And a set of tags for testing autocomplete + When I start a new bookmark + And I enter text in the "Your tags" autocomplete field + Then I should only see matching canonical tags in the autocomplete + + @javascript + Scenario: Bookmark external work form autocomplete should work + Given I am logged in + And a set of tags for testing autocomplete + And an external work + When I go to the new external work page + Then the tag autocomplete fields should list only matching canonical tags + And the fandom-specific tag autocomplete fields should list only fandom-specific canonical tags + And the external url autocomplete field should list the urls of existing external works + + @javascript + Scenario: Work co-author and association autocompletes should work + Given I am logged in + And a set of collections for testing autocomplete + And a set of users for testing autocomplete + And basic tags + And I go to the new work page + Then the coauthor autocomplete field should list matching users + And the gift recipient autocomplete field should list matching users + And the collection item autocomplete field should list matching collections + + @javascript + Scenario: Work co-author and association autocompletes should work with pseuds containing diacrictics + Given basic tags + And a set of users for testing autocomplete + And "coauthor" has the pseud "çola" + And I am logged in + And I go to the new work page + Then the coauthor autocomplete field should list matching users + When I enter "c" in the "pseud_byline_autocomplete" autocomplete field + Then the pseud autocomplete should contain "çola (coauthor)" + And the pseud autocomplete should contain "coauthor" + When I enter "ç" in the "pseud_byline_autocomplete" autocomplete field + Then the pseud autocomplete should contain "çola (coauthor)" + And the pseud autocomplete should contain "coauthor" + + + @javascript + Scenario: Collection autocomplete shows collection title and name + Given I am logged in as "Scott" with password "password" + And I post the work "All The Nice Things" + And I set my preferences to allow collection invitations + And I have the collection "Issue" with name "jb_fletcher" + And I have the collection "Ïssue" with name "robert_stack" + And I am logged in as "moderator" + When I view the work "All The Nice Things" + And I follow "Invite To Collections" + And I enter "Issue" in the "Collection name(s)" autocomplete field + Then I should see "jb_fletcher" in the autocomplete + And I should see "robert_stack" in the autocomplete + + Scenario: Pseuds should be added and removed from autocomplete as they are changed + Given I am logged in as "new_user" + Then the pseud autocomplete should contain "new_user" + When "new_user" creates the pseud "extra" + Then the pseud autocomplete should contain "extra (new_user)" + When "new_user" changes the pseud "extra" to "funny" + And I go to new_user's pseuds page + Then I should not see "extra" + And I should see "funny" + And the pseud autocomplete should not contain "extra (new_user)" + And the pseud autocomplete should contain "funny (new_user)" + When "new_user" deletes the pseud "funny" + Then the pseud autocomplete should not contain "funny (new_user)" + And the pseud autocomplete should contain "new_user" + + Scenario: Pseuds should be added and removed from autocomplete as usernames change + Given I am logged in as "new_user" + And "new_user" creates the pseud "funny" + When I change my username to "different_user" + Then the pseud autocomplete should not contain "funny (new_user)" + And the pseud autocomplete should not contain "new_user" + And the pseud autocomplete should contain "different_user" + And the pseud autocomplete should contain "funny (different_user)" + When I try to delete my account as different_user + Then a user account should not exist for "funny" + And the pseud autocomplete should not contain "funny" + And the pseud autocomplete should not contain "different_user (funny)" + + @javascript + Scenario: People search autocomplete shows no results when searching for space + Given I go to the search people page + When I enter " " in the "Name" autocomplete field + Then I should see "Searching..." in the autocomplete + When I am logged in as "basic" + And "basic" creates the pseud "one" + And I go to the search people page + When I enter " " in the "Name" autocomplete field + Then I should see "Searching..." in the autocomplete + And I should not see "one (basic)" in the autocomplete + And I should not see "basic" in the autocomplete + + @javascript + Scenario: Characters in a fandom with non-ASCII uppercase letters should appear in the autocomplete. + + Given basic tags + And I am logged in + And a canonical character "Bear" in fandom "Østenfor sol og vestenfor måne" + And a canonical character "Beatrice" in fandom "Much Ado About Nothing" + And I go to the new work page + + When I choose "Østenfor sol og vestenfor måne" from the "Fandoms" autocomplete + And I enter "Bea" in the "Characters" autocomplete field + Then I should see "Bear" in the autocomplete + But I should not see "Beatrice" in the autocomplete + + @javascript + Scenario: Accented uppercase letters should appear in the autocomplete. + + Given basic tags + And I am logged in + And a canonical character "Éowyn (Tolkien)" + And a canonical character "Tybalt (Rómeó és Júlia)" + And I go to the new work page + + When I enter "é" in the "Characters" autocomplete field + Then I should see "Éowyn (Tolkien)" in the autocomplete + And I should see "Tybalt (Rómeó és Júlia)" in the autocomplete + When I enter "e" in the "Characters" autocomplete field + Then I should see "Éowyn (Tolkien)" in the autocomplete + And I should see "Tybalt (Rómeó és Júlia)" in the autocomplete + + + @javascript + Scenario: Other non-ASCII uppercase letters should appear in the autocomplete. + + Given basic tags + And I am logged in + And a canonical fandom "Østenfor sol og vestenfor måne" + And I go to the new work page + + When I enter "ø" in the "Fandoms" autocomplete field + Then I should see "Østenfor sol og vestenfor måne" in the autocomplete + When I enter "ostenfor" in the "Fandoms" autocomplete field + Then I should see "Østenfor sol og vestenfor måne" in the autocomplete + + @javascript + Scenario: Characters with a non-ASCII uppercase letter will appear in fandom-specific autocompletes. + + Given basic tags + And I am logged in + And a canonical character "Éowyn" in fandom "Lord of the Rings" + And I go to the new work page + + When I choose "Lord of the Rings" from the "Fandoms" autocomplete + And I enter "É" in the "Characters" autocomplete field + Then I should see "Éowyn" in the autocomplete + When I enter "e" in the "Characters" autocomplete field + Then I should see "Éowyn" in the autocomplete + + @javascript + Scenario: Search terms are highlighted in autocomplete results + Given I am logged in + And basic tags + And a canonical relationship "Cassian Andor & Jyn Erso" + And a canonical character "Éowyn" + And I go to the new work page + + When I enter "Jyn" in the "Relationships" autocomplete field + Then I should see HTML "Cassian Andor & <b>Jyn</b> Erso" in the autocomplete + + When I enter "Cass" in the "Relationships" autocomplete field + Then I should see HTML "<b>Cass</b>ian Andor & Jyn Erso" in the autocomplete + + When I enter "erso and" in the "Relationships" autocomplete field + Then I should see HTML "Cassian <b>And</b>or & Jyn <b>Erso</b>" in the autocomplete + + When I enter "Cassian Andor & Jyn Erso" in the "Relationships" autocomplete field + Then I should see HTML "<b>Cassian</b> <b>Andor</b> & <b>Jyn</b> <b>Erso</b>" in the autocomplete + + When I enter "é" in the "Characters" autocomplete field + Then I should see HTML "<b>É</b>owyn" in the autocomplete + + # AO3-4976 There should not be stray semicolons if the query has... + # ...trailing spaces + When I enter "Jyn " in the "Relationships" autocomplete field + Then I should see HTML "Cassian Andor & <b>Jyn</b> Erso" in the autocomplete + # ...leading spaces + When I enter " Jyn" in the "Relationships" autocomplete field + Then I should see HTML "Cassian Andor & <b>Jyn</b> Erso" in the autocomplete + # ...consecutive spaces + When I enter "Jyn Erso" in the "Relationships" autocomplete field + Then I should see HTML "Cassian Andor & <b>Jyn</b> <b>Erso</b>" in the autocomplete + + @javascript + Scenario: Tags with symbols shouldn't match all other tags with symbols. + + Given basic tags + And I am logged in + And a canonical freeform "AU - Canon" + And a canonical freeform "AU - Cats" + And a canonical freeform "Science Fiction & Fantasy" + And a canonical freeform "日月" + And a canonical freeform "大小" + And I go to the new work page + + When I enter "AU - Ca" in the "Additional Tags" autocomplete field + Then I should see "AU - Canon" in the autocomplete + And I should see "AU - Cats" in the autocomplete + But I should not see "Science Fiction & Fantasy" in the autocomplete + When I enter "日" in the "Additional Tags" autocomplete field + Then I should see "日月" in the autocomplete + But I should not see "大小" in the autocomplete + + @javascript + Scenario: Zero width space tag doesn't appear in the autocomplete for space + Given a canonical character "Gold" + And a zero width space tag exists + And I am logged in as a tag wrangler + When I go to the "Gold" tag edit page + Then I should see "This is the official name for the Character" + When I enter " " in the "tag_merger_string_autocomplete" autocomplete field + Then I should see "No suggestions found" in the autocomplete + + @javascript + Scenario: Zero width space tag appears in the autocomplete for zero width space + Given a canonical character "Gold" + And a zero width space tag exists + And I am logged in as a tag wrangler + When I go to the "Gold" tag edit page + Then I should see "This is the official name for the Character" + # Zero width space tag + When I enter "​" in the "tag_merger_string_autocomplete" autocomplete field + Then I should not see "No suggestions found" in the autocomplete + + @javascript + Scenario: Vertical bar is treated as a word separator + Given I am logged in + And a canonical character "Taylor Hebert | Skitter | Weaver" + And I go to the new work page + When I enter "|" in the "Characters" autocomplete field + Then I should see "No suggestions found" in the autocomplete + When I enter "Taylor|Skitter" in the "Characters" autocomplete field + Then I should see "Taylor Hebert | Skitter | Weaver" in the autocomplete diff --git a/features/other_a/banner_general.feature b/features/other_a/banner_general.feature new file mode 100644 index 0000000..3aa2228 --- /dev/null +++ b/features/other_a/banner_general.feature @@ -0,0 +1,142 @@ +@users +Feature: General notice banner + +Scenario: Banner is blank until admin sets it + Given there are no banners + Then a logged-in user should not see a banner + And a logged-out user should not see a banner + +Scenario: Admin can set a banner + Given there are no banners + When an admin creates an active banner + Then a logged-in user should see the banner + And a logged-out user should see the banner + +Scenario: Admin can set an alert banner + Given there are no banners + And an admin creates an active "alert" banner + When I am logged in as "whatever" + Then a logged-in user should see the "alert" banner + And a logged-out user should see the "alert" banner + +Scenario: Admin can set an event banner + Given there are no banners + When an admin creates an active "event" banner + Then a logged-in user should see the "event" banner + And a logged-out user should see the "event" banner + +Scenario: Admin can edit an active banner + Given there are no banners + And an admin creates an active banner + When an admin edits the active banner + Then a logged-in user should see the edited active banner + And a logged-out user should see the edited active banner + +Scenario: Admin can deactivate a banner + Given there are no banners + And an admin creates an active banner + When an admin deactivates the banner + Then a logged-in user should not see a banner + And a logged-out user should not see a banner + +Scenario: User can turn off banner using "×" button + Given there are no banners + And an admin creates an active banner + When I turn off the banner + Then the page should not have a banner + +Scenario: Banner stays off when logging out and in again + Given there are no banners + And an admin creates an active banner + And I turn off the banner + When I am logged out + And I am logged in as "newname" + Then the page should not have a banner + +Scenario: Logged out user can turn off banner + Given there are no banners + And an admin creates an active banner + And I am logged out + When I follow "×" + Then the page should not have a banner + +Scenario: User can turn off banner in preferences + Given there are no banners + And an admin creates an active banner + And I am logged in as "banner_tester" + And I set my preferences to turn off the banner showing on every page + When I go to banner_tester's user page + Then the page should not have a banner + +Scenario: User can turn off banner in preferences, but will still see a banner when an admin deactivates the existing banner and sets a new banner + Given there are no banners + And an admin creates an active banner + And I am logged in as "banner_tester_2" + When I set my preferences to turn off the banner showing on every page + And I go to banner_tester_2's user page + Then the page should not have a banner + When an admin deactivates the banner + And an admin creates a different active banner + When I am logged in as "banner_tester_2" + Then the page should have the different banner + +Scenario: Admin can delete a banner and it will no longer be shown to users + Given there are no banners + And an admin creates an active banner + When I am logged in as a "communications" admin + And I am on the admin_banners page + And I follow "Delete" + And I press "Yes, Delete Banner" + Then I should see "Banner successfully deleted." + And a logged-in user should not see a banner + And a logged-out user should not see a banner + +Scenario: Admin should not have option to make minor updates on a new banner + Given there are no banners + And I am logged in as a "communications" admin + When I am on the new_admin_banner page + Then I should not see "This is a minor update (Do not turn the banner back on for users who have dismissed it)" + +Scenario: Admin should not have option to make minor updates on banner that is not active + Given there are no banners + And an admin creates a banner + When I am logged in as a "communications" admin + And I am on the admin_banners page + And I follow "Edit" + Then I should not see "This is a minor update (Do not turn the banner back on for users who have dismissed it)" + +Scenario: Admin can make minor changes to the text of an active banner without turning it back on for users who have already dismissed it + Given there are no banners + And an admin creates an active banner + And I am logged in as "banner_tester_3" + And I set my preferences to turn off the banner showing on every page + And an admin makes a minor edit to the active banner + When I am logged in as "banner_tester_3" + Then I should not see the banner with minor edits + And the page should not have a banner + When I am logged out + Then I should see the banner with minor edits + When I am logged in as "banner_tester_4" + Then I should see the banner with minor edits + +Scenario: Development & Membership admin can see edit options but not delete or create + Given an admin creates a banner + When I am logged in as a "development_and_membership" admin + And I go to the admin_banners page + Then I should see "Banners" within "#header .admin.navigation" + And I should see "Banners" within "#main .navigation.actions" + And I should see "Edit" within "#main ul.banners.index.group" + But I should not see "Delete" within "#main ul.banners.index.group" + And I should not see "New Banner" within "#main .navigation.actions" + When I follow "Edit" + Then I should not see "New Banner" within "#main .navigation.actions" + And I should not see "Delete Banner" within "#main .navigation.actions" + But I should see "Edit Banner" within "#main h2" + And I should see "Edit Banner" within "#main .navigation.actions" + When I fill in "Banner text" with "Some fun new text" + And I press "Update Banner" + Then I should see "Banner successfully updated." + And I should see "Banners" within "#main .navigation.actions" + And I should see "Edit Banner" within "#main .navigation.actions" + But I should not see "Delete Banner" within "#main .navigation.actions" + And I should not see "New Banner" within "#main .navigation.actions" diff --git a/features/other_a/banner_login.feature b/features/other_a/banner_login.feature new file mode 100644 index 0000000..4f431f2 --- /dev/null +++ b/features/other_a/banner_login.feature @@ -0,0 +1,70 @@ +@users +Feature: First login help banner + + Scenario: New user sees the banner + + Given I am logged in as "newname" + When I am on newname's user page + Then I should see the first login banner + And I should see "For help getting started on AO3, check out some useful tips for new users or browse through our FAQs." + And I should see "If you need technical support, contact our Support team. If you experience harassment or have questions about our Terms of Service (including the Content Policy and Privacy Policy), contact our Policy & Abuse team." + When I follow "our FAQs" + Then I should be on the faq page + When I am on newname's user page + And I follow "contact our Support team" + Then I should be on the support page + When I am on newname's user page + And I follow "Terms of Service" + Then I should be on the tos page + When I am on newname's user page + And I follow "Content Policy" + Then I should be on the content page + When I am on newname's user page + And I follow "Privacy Policy" + Then I should be on the privacy page + When I am on newname's user page + And I follow "contact our Policy & Abuse team" + Then I should see "Policy Questions & Abuse Reports" + + Scenario: Popup details can be viewed + + Given I am logged in as "newname" + When I am on newname's user page + When I follow "useful tips for new users" + Then I should see the first login popup + + Scenario: Turn off first login help banner directly + + Given I am logged in as "newname2" + When I am on newname2's user page + When I press "Dismiss permanently" + Then I should not see the first login banner + + Scenario: Banner stays off after logout and login if turned off directly + + Given I am logged in as "newname2" + When I am on newname2's user page + When I press "Dismiss permanently" + When I am logged out + And I am logged in as "newname2" + Then I should not see the first login banner + When I am on newname2's user page + Then I should not see the first login banner + + Scenario: Hide banner using X + + Given I am logged in as "newname2" + When I am on newname2's user page + # Note this is "×" and not a letter "x" + When I follow "×" within "div#main" + + Scenario: Banner comes back if turned off using X + + Given I am logged in as "newname2" + When I go to newname2's user page + # Note this is "×" and not a letter "x" + When I follow "×" within "div#main" + When I am logged out + And I am logged in as "newname2" + And I go to newname2's user page + Then I should see the first login banner diff --git a/features/other_a/gift.feature b/features/other_a/gift.feature new file mode 100644 index 0000000..ca1baa8 --- /dev/null +++ b/features/other_a/gift.feature @@ -0,0 +1,458 @@ +Feature: Create Gifts + In order to make friends and influence people + As an author + I want to create works for other people + + + Background: + Given the following activated users exist + | login | password | email | + | gifter | something | gifter@example.com | + | gifter2 | something | gifter2@example.com | + | giftee1 | something | giftee1@example.com | + | giftee2 | something | giftee2@example.com | + | associate | something | associate@example.com | + And "giftee1" has the pseud "g1" + And the user "giftee1" allows gifts + And the user "giftee2" allows gifts + And the user "associate" allows gifts + And I am logged in as "gifter" with password "something" + And I set up the draft "GiftStory1" + + Scenario: Gifts page for recipient should show recipient's gifts + When I give the work to "giftee1" + And I press "Post" + And I go to the gifts page for the recipient giftee1 + Then I should see "GiftStory1 by gifter for giftee1" + + 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 + When I give the work to "giftee1" + And I press "Post" + And I go to the gifts page for the recipient giftee1 + Then I should see an HTML comment containing the number 1744477200 within "li.work.blurb" + + Scenario: Gifts page for recipient when logged out should show recipient's gifts if visible to all + When I give the work to "giftee1" + And I press "Post" + And I set up the draft "GiftStory2" as a gift to "giftee1" + And I lock the work + And I press "Post" + And I log out + And I go to the gifts page for the recipient giftee1 + Then I should see "GiftStory1 by gifter for giftee1" + And I should not see "GiftStory2 by gifter for giftee1" + + Scenario: Gifts page for user should show gifts given to their pseud + Given I give the work to "g1 (giftee1)" + And I press "Post" + When I go to giftee1's gifts page + Then I should see "GiftStory1 by gifter for g1 (giftee1)" + + Scenario: Gifts page for recipient without account should show their gifts + Given I give the work to "g1" + And I press "Post" + When I go to the gifts page for the recipient g1 + Then I should see "GiftStory1 by gifter for g1" + + Scenario: When logged out, gifts page for recipient without account should show gifts visible to all + When I give the work to "g1" + And I press "Post" + And I set up the draft "GiftStory2" as a gift to "g1" + And I lock the work + And I press "Post" + And I log out + When I go to the gifts page for the recipient g1 + Then I should see "GiftStory1 by gifter for g1" + And I should not see "GiftStory2 by gifter for g1" + + Scenario: Giving a work as a gift when posting directly + Given I give the work to "giftee1" + When I press "Post" + Then I should see "For giftee1" + And "giftee1@example.com" should be notified by email about their gift "GiftStory1" + + Scenario: Giving a work as a gift when posting after previewing + Given I give the work to "giftee1" + And I press "Preview" + And I should see "For giftee1" + And 0 emails should be delivered + When I press "Post" + Then I should see "For giftee1" + And "giftee1@example.com" should be notified by email about their gift "GiftStory1" + + Scenario: Edit a draft to add a recipient, then post after previewing + Given I press "Preview" + And I press "Edit" + And I give the work to "giftee1" + And I press "Preview" + And 0 emails should be delivered + When I press "Post" + Then I should see "For giftee1" + And "giftee1@example.com" should be notified by email about their gift "GiftStory1" + + Scenario: Edit an existing work to add a recipient, then post directly + Given I press "Post" + And I follow "Edit" + And I give the work to "giftee1" + When I press "Post" + Then I should see "For giftee1" + And "giftee1@example.com" should be notified by email about their gift "GiftStory1" + + Scenario: Edit an existing work to add a recipient, then post after previewing + Given I press "Post" + And I follow "Edit" + And I give the work to "giftee1" + When I press "Preview" + # this next thing is broken on beta currently, will settle for not breaking it worse + Then 0 emails should be delivered + When I press "Edit" + Then "giftee1" should be listed as a recipient in the form + When I press "Preview" + Then 0 emails should be delivered + When I press "Update" + Then I should see "For giftee1" + And "giftee1@example.com" should be notified by email about their gift "GiftStory1" + + Scenario: Give two gifts to the same recipient + Given I give the work to "giftee1" + And I press "Post" + And I set up the draft "GiftStory2" + And I give the work to "giftee1" + When I press "Post" + And I follow "giftee1" + Then I should see "Gifts for giftee1" + And I should see "GiftStory1" + And I should see "GiftStory2" + + Scenario: Add another recipient to a posted gift + Given I give the work to "giftee1" + And I press "Post" + And I should see "For giftee1" + And "giftee1@example.com" should be notified by email about their gift "GiftStory1" + And all emails have been delivered + And I follow "Edit" + And I give the work to "giftee1, giftee2" + When I press "Post" + Then I should see "For giftee1, giftee2" + And 0 emails should be delivered to "giftee1@example.com" + And "giftee2@example.com" should be notified by email about their gift "GiftStory1" + + Scenario: Add another recipient to a draft gift + Given I give the work to "giftee1" + And I press "Preview" + And I should see "For giftee1" + And 0 emails should be delivered to "giftee1@example.com" + And I press "Edit" + And I give the work to "giftee1, giftee2" + When I press "Post" + Then I should see "For giftee1, giftee2" + And "giftee1@example.com" should be notified by email about their gift "GiftStory1" + And "giftee2@example.com" should be notified by email about their gift "GiftStory1" + + Scenario: Add two recipients, post, then remove one + Given I give the work to "giftee1, giftee2" + And I press "Post" + And I should see "For giftee1, giftee2" + And "giftee1@example.com" should be notified by email about their gift "GiftStory1" + And "giftee2@example.com" should be notified by email about their gift "GiftStory1" + And all emails have been delivered + And I follow "Edit" + And I give the work to "giftee1" + When I press "Post" + Then I should see "For giftee1" + And I should not see "giftee2" + And 0 emails should be delivered to "giftee1@example.com" + And 0 emails should be delivered to "giftee2@example.com" + + Scenario: Add two recipients, preview, then remove one + Given I give the work to "giftee1, giftee2" + And I press "Preview" + And I should see "For giftee1, giftee2" + And 0 emails should be delivered + And I press "Edit" + And I give the work to "giftee1" + When I press "Post" + Then I should see "For giftee1" + And I should not see "giftee2" + And "giftee1@example.com" should be notified by email about their gift "GiftStory1" + And 0 emails should be delivered to "giftee2@example.com" + + Scenario: Edit a posted work to replace one recipient with another + Given I give the work to "giftee1" + And I press "Post" + And I should see "For giftee1" + And "giftee1@example.com" should be notified by email about their gift "GiftStory1" + And all emails have been delivered + And I follow "Edit" + And I give the work to "giftee2" + When I press "Post" + Then I should see "For giftee2" + And I should not see "giftee1" + And 0 emails should be delivered to "giftee1@example.com" + And "giftee2@example.com" should be notified by email about their gift "GiftStory1" + + Scenario: Edit a draft to replace one recipient with another + Given I give the work to "giftee1" + And I press "Preview" + And I should see "For giftee1" + And 0 emails should be delivered + And I press "Edit" + And I give the work to "giftee2" + When I press "Post" + Then I should see "For giftee2" + And I should not see "giftee1" + And 0 emails should be delivered to "giftee1@example.com" + And "giftee2@example.com" should be notified by email about their gift "GiftStory1" + + Scenario: When a user is notified that a co-authored work has been given to them as a gift, the e-mail should link to each author's URL instead of showing escaped HTML + Given I invite the co-author "gifter2" + And I give the work to "giftee1" + And I preview the work + Then 1 email should be delivered to "gifter2" + And the email should contain "The user gifter has invited your pseud gifter2 to be listed as a co-creator on the following work" + And the email should not contain "translation missing" + When all emails have been delivered + And the user "gifter2" accepts all co-creator requests + And I press "Post" + Then 1 email should be delivered to "giftee1" + And the email should link to gifter's user url + And the email should not contain "<a href="http://archiveofourown.org/users/gifter/pseuds/gifter"" + And the email should link to gifter2's user url + And the email should not contain "<a href="http://archiveofourown.org/users/gifter2/pseuds/gifter2"" + + Scenario: A gift work should have an associations list + Given I give the work to "associate" + When I press "Post" + Then I should find a list for associations + And I should see "For associate" + + Scenario: A user should not be able to gift a work twice to the same person + Given "associate" has the pseud "associate2" + And I am logged in as "troll" + And I set up the draft "Yuck" + And I have given the work to "associate, associate2 (associate)" + Then I should not see "For associate, associate2" + And I should see "For associate" + And 1 email should be delivered to "associate@example.com" + When all emails have been delivered + And I edit the work "Yuck" + And I give the work to "associate, associate2 (associate)" + And I post the work without preview + Then I should see "You seem to already have given this work to that user." + And I should not see "For associate, associate2" + And 0 emails should be delivered to "associate@example.com" + + Scenario: A user should be able to refuse a gift + Given I have given the work to "associate" + And I am logged in as "someone_else" + And I am on associate's gifts page + Then I should not see "Refuse Gift" + And I should not see "Refused Gifts" + When I am logged in as "associate" with password "something" + And I go to associate's gifts page + Then I should see "GiftStory1" + And I should see "Refuse Gift" + And I should see "Refused Gifts" + When I follow "Refuse Gift" + Then I should see "This work will no longer be listed among your gifts." + And I should not see "GiftStory1" + When I follow "Refused Gifts" + Then I should see "GiftStory1" + And I should not see "by gifter for associate" + When I view the work "GiftStory1" + Then I should not see "For associate" + And I should not see "For ." + + Scenario: A user should be able to re-accept a gift + Given I have refused the work + And I am on giftee1's gifts page + And I follow "Refused Gifts" + Then I should see "Accept Gift" + And I should not see "by gifter for giftee1" + # Delay to make sure the cache is expired when re-accepting the gift: + When it is currently 1 second from now + And I follow "Accept Gift" + Then I should see "This work will now be listed among your gifts." + And I should see "GiftStory1" + And I should see "by gifter for giftee1" + When I view the work "GiftStory1" + Then I should see "For giftee1" + + Scenario: An admin should see that a gift has been refused + Given I have refused the work + And I am logged in as an admin + And I view the work "GiftStory1" + Then I should see "Refused As Gift: giftee1" + + Scenario: Can't remove a recipient who has refused the gift + Given I have refused the work + And I am logged in as "gifter" + When I edit the work "GiftStory1" + Then "giftee1" should not be listed as a recipient in the form + And the gift for "giftee1" should still exist on "GiftStory1" + When I have removed the recipients + Then the gift for "giftee1" should still exist on "GiftStory1" + + Scenario: Opt to disable notifications, then receive a gift (with no collection) + Given I am logged in as "giftee1" with password "something" + And I set my preferences to turn off notification emails for gifts + When I am logged in as "gifter" with password "something" + And I post the work "QuietGift" as a gift for "giftee1, giftee2" + Then 0 emails should be delivered to "giftee1@example.com" + And "giftee2@example.com" should be notified by email about their gift "QuietGift" + + Scenario: Opt to disable notifications, then receive a gift posted to a non-hidden collection + Given I am logged in as "giftee1" with password "something" + And I set my preferences to turn off notification emails for gifts + And I have the collection "Open Skies" + When I am logged in as "gifter" with password "something" + And I post the work "QuietGift" in the collection "Open Skies" as a gift for "giftee1, giftee2" + Then 0 emails should be delivered to "giftee1@example.com" + And "giftee2@example.com" should be notified by email about their gift "QuietGift" + + Scenario: Opt to disable notifications, then receive a gift posted to a hidden collection and later revealed + Given I am logged in as "giftee1" with password "something" + And I set my preferences to turn off notification emails for gifts + And I have the hidden collection "Hidden Treasures" + When I am logged in as "gifter" with password "something" + And I post the work "QuietGift" in the collection "Hidden Treasures" as a gift for "giftee1, giftee2" + And I reveal works for "Hidden Treasures" + Then 0 emails should be delivered to "giftee1@example.com" + And "giftee2@example.com" should be notified by email about their gift "QuietGift" + + Scenario: Can't give a gift to a user who disallows them + Given the user "giftee1" disallows gifts + When I am logged in as "gifter" + And I post the work "Rude Gift" as a gift for "giftee1" + Then I should see "Sorry! We couldn't save this work because: giftee1 does not accept gifts." + And 0 emails should be delivered to "giftee1@example.com" + + Scenario: A user who disallows gifts can refuse existing ones + Given I am logged in as "gifter" + And I post the work "Rude Gift" as a gift for "giftee1" + And the user "giftee1" disallows gifts + When I am logged in as "giftee1" + And I go to giftee1's gifts page + # Delay to make sure the cache is expired when the gift is refused: + And it is currently 1 second from now + And I follow "Refuse Gift" + Then I should see "This work will no longer be listed among your gifts." + And I should not see "Rude Gift" + When I follow "Refused Gifts" + Then I should see "Rude Gift" + And I should not see "by gifter for giftee1" + When I view the work "Rude Gift" + Then I should not see "For giftee1." + + Scenario: Can't give a gift to a user who has blocked you + Given the user "giftee1" has blocked the user "gifter" + When I am logged in as "gifter" + And I post the work "Rude Gift" as a gift for "giftee1" + Then I should see "Sorry! We couldn't save this work because: giftee1 does not accept gifts from you." + And 0 emails should be delivered to "giftee1@example.com" + + Scenario: Can't gift an existing work to a user who has blocked you + Given the user "giftee1" has blocked the user "gifter" + And I press "Post" + And I follow "Edit" + And I give the work to "giftee1" + When I press "Post" + Then I should see "Sorry! We couldn't save this work because: giftee1 does not accept gifts from you." + + Scenario: Can't gift a work whose co-creator is blocked by recipient + Given I coauthored the work "Collateral" as "gifter" with "gifter2" + And the user "giftee1" has blocked the user "gifter2" + And I edit the work "Collateral" + And I give the work to "giftee1" + When I press "Post" + Then I should see "Sorry! We couldn't save this work because: giftee1 does not accept gifts." + + Scenario: Only see one error message is shown if gifts are disabled and user is blocked* + Given the user "giftee1" disallows gifts + And the user "giftee1" has blocked the user "gifter" + When I am logged in as "gifter" + And I post the work "Rude Gift" as a gift for "giftee1" + Then I should see "Sorry! We couldn't save this work because:" + And I should see "giftee1 does not accept gifts." + And I should not see "giftee1 does not accept gifts from you." + + Scenario: A user can refuse previous gifts from user after blocking them + Given I am logged in as "gifter" + And I post the work "Rude Gift" as a gift for "giftee1" + When I am logged in as "giftee1" + And I go to giftee1's gifts page + Then I should see "Rude Gift" + When I go to the blocked users page for "giftee1" + And I fill in "blocked_id" with "gifter" + And I press "Block" + And I press "Yes, Block User" + Then I should see "You have blocked the user gifter." + When I go to giftee1's gifts page + And it is currently 1 second from now + And I follow "Refuse Gift" + Then I should see "This work will no longer be listed among your gifts." + And I should not see "Rude Gift" + When I follow "Refused Gifts" + Then I should see "Rude Gift" + And I should not see "by gifter for giftee1" + When I view the work "Rude Gift" + Then I should not see "For giftee1." + + Scenario: Translated email is sent when a regular work is gifted + Given a locale with translated emails + And the user "giftee1" enables translated emails + And all emails have been delivered + When I give the work to "giftee1" + And I press "Post" + And I set up the draft "GiftStory2" as a gift to "giftee2" + And I press "Post" + Then "giftee1" should be emailed + And the email should have "A gift work for you" in the subject + And the email to "giftee1" should be translated + Then "giftee2" should be emailed + And the email should have "A gift work for you" in the subject + And the email to "giftee2" should be non-translated + + Scenario: Translated email is sent when a work in a collection is gifted + Given a locale with translated emails + And the user "giftee1" enables translated emails + And all emails have been delivered + When I have the collection "SomeCollection" + And I am logged in as "gifter" + And I set up the draft "GiftStory2" in the collection "SomeCollection" + And I give the work to "giftee1" + And I press "Post" + And I set up the draft "GiftStory3" in the collection "SomeCollection" + And I give the work to "giftee2" + And I press "Post" + Then "giftee1" should be emailed + And the email should have "\[SomeCollection\] A gift work for you from SomeCollection" in the subject + And the email to "giftee1" should be translated + Then "giftee2" should be emailed + And the email should have "\[SomeCollection\] A gift work for you from SomeCollection" in the subject + And the email to "giftee2" should be non-translated + + Scenario: Translated email is sent when a gift work in a hidden collection is revealed + Given a locale with translated emails + And the user "giftee1" enables translated emails + And all emails have been delivered + When I have the hidden collection "Hidden Treasury" + And I am logged in as "gifter" + And I set up the draft "GiftStory2" in the collection "Hidden Treasury" + And I give the work to "giftee1" + And I press "Post" + And I set up the draft "GiftStory3" in the collection "Hidden Treasury" + And I give the work to "giftee2" + And I press "Post" + Then "giftee1" should not be emailed + And "giftee2" should not be emailed + When I am logged in as "moderator" + And I reveal works for "Hidden Treasury" + Then "giftee1" should be emailed + And the email should have "\[Hidden Treasury\] A gift work for you from Hidden Treasury" in the subject + And the email to "giftee1" should be translated + Then "giftee2" should be emailed + And the email should have "\[Hidden Treasury\] A gift work for you from Hidden Treasury" in the subject + And the email to "giftee2" should be non-translated diff --git a/features/other_a/help.feature b/features/other_a/help.feature new file mode 100644 index 0000000..da05dc6 --- /dev/null +++ b/features/other_a/help.feature @@ -0,0 +1,42 @@ +@help +Feature: Help + In order to get help + As a humble user + I want to read help links + + Scenario: clicking the help popup for moderated collection + + Given I am logged in as "first_user" + When I go to the collections page + When I follow "New Collection" + And I follow "Collection moderated" + Then I should see "By default, collections are not moderated" + + Scenario: view the help popup for chapter title + + Given the following activated user exists + | login | password | + | epicauthor | password | + And basic tags + When I am logged in as "epicauthor" + And I go to epicauthor's user page + And I follow "New Work" + 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 "Work Title" with "New Epic Work" + And I select "English" from "Choose a language" + And I fill in "content" with "Well, maybe not so epic." + And I press "Post" + And I follow "Add Chapter" + And I follow "Chapter title" + Then I should see "You can add a chapter title" + + Scenario: Asked to log in if trying to access the first login page as guest + + When I go to the first login help page + Then I should be on the login page + + Given I am logged in + When I go to the first login help page + Then I should see "Here are some tips to help you get started" diff --git a/features/other_a/hit_counts.feature b/features/other_a/hit_counts.feature new file mode 100644 index 0000000..be99a5e --- /dev/null +++ b/features/other_a/hit_counts.feature @@ -0,0 +1,112 @@ +@javascript +Feature: Hit Counts + # Throughout these tests, we use the "all hit count information is reset" + # step because logging in/logging out may result in the user being redirected + # to the page that the user was just on, i.e. the work whose hit count we're + # trying to test. + + Scenario: When the owner views their own work, it doesn't increment the hit count + Given the work "Hit Count Test" + And I am logged in as the author of "Hit Count Test" + And all hit count information is reset + When I go to the work "Hit Count Test" + And the hit counts for all works are updated + And I go to the work "Hit Count Test" + Then I should see "Hits: 0" + + Scenario: Viewing a work logged-in increments the hit count + Given the work "Hit Count Test" + And I am logged in as "viewer" + And all hit count information is reset + When I go to the work "Hit Count Test" + And the hit counts for all works are updated + And I go to the work "Hit Count Test" + Then I should see "Hits: 1" + + Scenario: Viewing a work logged-out increments the hit count + Given the work "Hit Count Test" + And all hit count information is reset + When I go to the work "Hit Count Test" + And the hit counts for all works are updated + And I go to the work "Hit Count Test" + Then I should see "Hits: 1" + + Scenario: Viewing an unrevealed work doesn't increment the hit count + Given there is a work "Hit Count Test" in an unrevealed collection "Unrevealed" + And I am logged in as the owner of "Unrevealed" + And all hit count information is reset + When I go to the work "Hit Count Test" + And the hit counts for all works are updated + And I go to the work "Hit Count Test" + Then I should see "Hits: 0" + + Scenario: Viewing a work hidden by an admin doesn't increment the hit count + Given the hidden work "Hit Count Test" + And I am logged in as an admin + And all hit count information is reset + When I go to the work "Hit Count Test" + And the hit counts for all works are updated + And I go to the work "Hit Count Test" + Then I should see "Hits: 0" + + Scenario: When an admin views a draft, it doesn't increment the hit count + Given I am logged in as a random user + And the draft "Hit Count Test" + And I am logged in as an admin + And all hit count information is reset + When I go to the work "Hit Count Test" + And the hit counts for all works are updated + And I go to the work "Hit Count Test" + Then I should see "Hits: 0" + + Scenario: Viewing the first chapter logged-in increments the hit count + Given the chaptered work "Hit Count Test" + And all hit count information is reset + And I am logged in as "viewer" + When I go to the 1st chapter of the work "Hit Count Test" + And the hit counts for all works are updated + And I go to the work "Hit Count Test" + Then I should see "Hits: 1" + + Scenario: Viewing the first chapter logged-out increments the hit count + Given the chaptered work "Hit Count Test" + And all hit count information is reset + When I go to the 1st chapter of the work "Hit Count Test" + And the hit counts for all works are updated + And I go to the work "Hit Count Test" + Then I should see "Hits: 1" + + Scenario: Viewing the second chapter logged-in increments the hit count + Given the chaptered work "Hit Count Test" + And I am logged in as "viewer" + And all hit count information is reset + When I go to the 2nd chapter of the work "Hit Count Test" + And the hit counts for all works are updated + And I go to the work "Hit Count Test" + Then I should see "Hits: 1" + + Scenario: Viewing the second chapter logged-out increments the hit count + Given the chaptered work "Hit Count Test" + And all hit count information is reset + When I go to the 2nd chapter of the work "Hit Count Test" + And the hit counts for all works are updated + And I go to the work "Hit Count Test" + Then I should see "Hits: 1" + + Scenario: Viewing multiple chapters in sequence only increments the hit count once + Given the chaptered work "Hit Count Test" + And all hit count information is reset + When I go to the 1st chapter of the work "Hit Count Test" + And all AJAX requests are complete + And I go to the 2nd chapter of the work "Hit Count Test" + And the hit counts for all works are updated + And I go to the work "Hit Count Test" + Then I should see "Hits: 1" + + Scenario: Viewing a full multi-chapter work increments the hit count + Given the chaptered work "Hit Count Test" + And all hit count information is reset + When I go to the work "Hit Count Test" in full mode + And the hit counts for all works are updated + And I go to the work "Hit Count Test" + Then I should see "Hits: 1" diff --git a/features/other_a/homepage.feature b/features/other_a/homepage.feature new file mode 100644 index 0000000..15de4da --- /dev/null +++ b/features/other_a/homepage.feature @@ -0,0 +1,34 @@ +Feature: Various things on the homepage + + Scenario: Logged out + + When I am on the homepage + Then I should see "The Archive of Our Own is a project of the Organization for Transformative Works." + + Scenario: Diversity statement + + Given I am on the homepage + When I follow "Diversity Statement" + Then I should see "You are welcome at the Archive of Our Own." + + Scenario: DMCA + + Given I am on the homepage + When I follow "DMCA Policy" + Then I should see "Filing a DMCA counternotice" + + Scenario: Donate + + Given I am on the homepage + When I follow "Site Map" + And I follow "Donations" + Then I should see "There are two main ways to support the AO3 - donating your time or money" + And I should see the page title "Donate or Volunteer" + And I should see a link "donation to the OTW" to "https://donate.transformativeworks.org/otwgive" + + Scenario: About + + Given I am on the about page + Then I should see the page title "About the OTW" + And I should see a link "GitHub repository" to "https://github.com/otwcode/otwarchive" + And I should see a link "Jira project" to "https://otwarchive.atlassian.net/browse/AO3" diff --git a/features/other_a/invite_queue.feature b/features/other_a/invite_queue.feature new file mode 100644 index 0000000..308b0cd --- /dev/null +++ b/features/other_a/invite_queue.feature @@ -0,0 +1,183 @@ +@admin +Feature: Invite queue management + + Background: + Given I have no users + And the following users exist + | login | password | + | user1 | password | + + Scenario: Can turn queue off in Admin Settings and it displays as off + + Given I am logged in as a "policy_and_abuse" admin + And I go to the admin-settings page + And I uncheck "Invite from queue enabled (People can add themselves to the queue and invitations are sent out automatically)" + And I press "Update" + When I log out + And I am on the homepage + Then I should not see "Get an Invite" + And I should see "Archive of Our Own" + + Scenario: Can turn queue on in Admin Settings and it displays as on + + Given I am logged in as a "policy_and_abuse" admin + And account creation requires an invitation + And I go to the admin-settings page + And I check "Invite from queue enabled (People can add themselves to the queue and invitations are sent out automatically)" + And I press "Update" + When I log out + And I am on the homepage + Then I should see "Get an Invitation" + When I follow "Get an Invitation" + Then I should see "Request an invitation" + + Scenario: An admin can delete people from the queue + + Given an invitation request for "invitee@example.org" + And I am logged in as a "policy_and_abuse" admin + When I go to the manage invite queue page + And I press "Delete" + Then I should see "Request for invitee@example.org was removed from the queue." + And I should be on the manage invite queue page + + Scenario: Visitors can join the queue and check status when invitations are required and the queue is enabled + + # join queue + Given I am a visitor + And account creation requires an invitation + And the invitation queue is enabled + When I am on the homepage + And all emails have been delivered + And I follow "Get an Invitation" + Then I should see "We are sending out 10 invitations every 12 hours." + When I fill in "invite_request_email" with "test@archiveofourown.org" + And I press "Add me to the list" + Then I should see "You've been added to our queue" + + # check your place in the queue - invalid address + When I check how long "testttt@archiveofourown.org" will have to wait in the invite request queue + Then I should see "Invitation Request Status" + And I should see "Sorry, we can't find the email address you entered." + And I should not see "You are currently number" + + # check your place in the queue - correct address + When I check how long "test@archiveofourown.org" will have to wait in the invite request queue + Then I should see "Invitation Status for test@archiveofourown.org" + And I should see "You are currently number 1 on our waiting list! At our current rate, you should receive an invitation on or around" + + Scenario: Can't add yourself to the queue when queue is off + + Given the invitation queue is disabled + When I go to the invite_requests page + Then I should not see "Request an invitation" + And I should not see "invite_request_email" + And I should see "New invitation requests are currently closed." + And I should see "There are 0 people remaining on the waiting list." + And I should not see "Add me to the list" + + Scenario: Can still check status when queue is off + + Given the invitation queue is disabled + When I go to the invite_requests page + And I follow "check your position on the waiting list" + Then I should see the page title "Invitation Request Status" + And I should see "There are currently 0 people on the waiting list." + And I should not see "We are currently sending out" + + Scenario: The queue sends out invites and user can create and activate an account + + Given account creation is enabled + And the invitation queue is enabled + And account creation requires an invitation + And the invite_from_queue_at is yesterday + When I am on the homepage + And all emails have been delivered + And I follow "Get an Invitation" + When I fill in "invite_request_email" with "test@archiveofourown.org" + And I press "Add me to the list" + And the scheduled check_invite_queue job is run + Then 1 email should be delivered to test@archiveofourown.org + When I check how long "test@archiveofourown.org" will have to wait in the invite request queue + Then I should see "Invitation Request Status" + And I should see "If you can't find it, please check your email spam folder as your spam filters may have placed it there." + + # invite can be used + When I am logged in as an admin + And I follow "Invitations" + And I fill in "track_invitation_invitee_email" with "test@archiveofourown.org" + And I press "Search" within "form.invitation.simple.search" + And I follow "Copy and use" + Then I should see "You are already logged in!" + + # user uses email invite + Given I am a visitor + # "You've" removed from test due to escaping on apostrophes + Then the email should contain "been invited to join the Archive of Our Own" + And the email should contain "fanart" + And the email should contain "podfic" + And the email should contain "If you do not receive this email after 48 hours" + And the email should contain "With an account, you can post fanworks" + + When I follow "follow this link to sign up" in the email + And I fill in the sign up form with valid data + And I fill in the following: + | user_registration_login | newuser | + | user_registration_email | test@archiveofourown.org | + | user_registration_password | password1 | + | user_registration_password_confirmation | password1 | + And all emails have been delivered + When I press "Create Account" + Then I should see "Almost Done!" + Then 1 email should be delivered + And the email should contain "Welcome to the Archive of Our Own," + And the email should contain "newuser" + And the email should contain "activate your account" + And the email should not contain "translation missing" + + # user activates account + When all emails have been delivered + And I click the first link in the email + Then I should be on the login page + And I should see "Account activation complete! Please log in." + + When I am logged in as "newuser" with password "password1" + Then I should see "Successfully logged in." + + Scenario: You can't request an invitation with an email address that is + already attached to an account + Given account creation requires an invitation + And the invitation queue is enabled + And the following activated users exist + | login | password | email | + | fred | yabadabadoo | fred@bedrock.com | + When I am on the homepage + And I follow "Get an Invitation" + And I fill in "invite_request_email" with "fred@bedrock.com" + And I press "Add me to the list" + Then I should see "Email is already being used by an account holder." + + Scenario: Users can resend their invitation after enough time has passed + Given account creation is enabled + And the invitation queue is enabled + And account creation requires an invitation + And the invite_from_queue_at is yesterday + And an invitation request for "invitee@example.org" + When the scheduled check_invite_queue job is run + Then 1 email should be delivered to invitee@example.org + + When I check how long "invitee@example.org" will have to wait in the invite request queue + Then I should see "Invitation Request Status" + And I should see "If you can't find it, please check your email spam folder as your spam filters may have placed it there." + And I should not see "Because your invitation was sent more than 24 hours ago, you can have your invitation resent." + And I should not see "Resend Invitation" + + When all emails have been delivered + And it is currently 25 hours from now + And I check how long "invitee@example.org" will have to wait in the invite request queue + Then I should see "Invitation Request Status" + And I should see "If you can't find it, please check your email spam folder as your spam filters may have placed it there." + And I should see "Because your invitation was sent more than 24 hours ago, you can have your invitation resent." + And I should see "Resend Invitation" + + When I press "Resend Invitation" + Then 1 email should be delivered to invitee@example.org diff --git a/features/other_a/invite_request.feature b/features/other_a/invite_request.feature new file mode 100644 index 0000000..e4d5b75 --- /dev/null +++ b/features/other_a/invite_request.feature @@ -0,0 +1,189 @@ +@admin +Feature: Invite requests + + Scenario: Request an invite for a friend + + Given invitations are required + And I am logged in as "user1" + When I try to invite a friend from my user page + And I follow "Request invitations" + When I fill in "How many invitations would you like? (max 10)" with "3" + And I fill in "Please specify why you'd like them:" with "I want them for a friend" + And I press "Send Request" + Then I should see a create confirmation message + + Scenario: Requests are not instantly granted + + Given invitations are required + And I am logged in as "user1" + And I request some invites + When I follow "Invitations" + Then I should see "Sorry, you have no unsent invitations right now." + + Scenario: Admin sees the request + + Given invitations are required + And I am logged in as "user1" + And I request some invites + When I view requests as an admin + Then I should see "user1" + And the "requests[user1]" field should contain "3" + And I should see "I want them for a friend" + + Scenario: Admin can refuse request + + Given invitations are required + And I am logged in as "user1" + And I request some invites + When I view requests as an admin + And I fill in "requests[user1]" with "0" + And I press "Update" + Then I should see "Requests were successfully updated." + And I should not see "user1" + + Scenario: Admin can grant request + + Given invitations are required + And I am logged in as "user1" + And I request some invites + When I view requests as an admin + And I fill in "requests[user1]" with "2" + And I press "Update" + Then I should see "Requests were successfully updated." + + Scenario: User is granted invites + + Given invitations are required + And I am logged in as "user1" + And I request some invites + And an admin grants the request + When I try to invite a friend from my user page + Then I should see "Invite a friend" + And I should not see "Sorry, you have no unsent invitations right now." + And I should see "You have 2 open invitations and 0 that have been sent but not yet used." + + Scenario: User can see an error after trying to invite an invalid email address + + Given I am logged in as "user1" + And "user1" has "1" invitation + And I am on user1's manage invitations page + When I follow the link for "user1" first invite + And I fill in "Enter an email address" with "test@" + And I press "Update Invitation" + Then I should see "Invitee email should look like an email address" + + Scenario: User can send out invites they have been granted, and the recipient can sign up + + Given invitations are required + And I am logged in as "user1" + And I request some invites + And an admin grants the request + And I try to invite a friend from my user page + When all emails have been delivered + And I fill in "Email address" with "test@archiveofourown.org" + And I press "Send Invitation" + Then 1 email should be delivered to test@archiveofourown.org + And the email should contain "has invited you to join the Archive of Our Own!" + And the email should contain "If you do not receive this email after 48 hours" + And the email should contain "With an account, you can post fanworks" + + Given I am a visitor + When I follow "follow this link to sign up" in the email + And I fill in the sign up form with valid data + And I fill in the following: + | user_registration_login | user2 | + | user_registration_password | password1 | + | user_registration_password_confirmation | password1 | + And I press "Create Account" + Then I should see "You should soon receive an activation email at the address you gave us" + And I should see how long I have to activate my account + And I should see "If you haven't received this email within 24 hours" + + Scenario: Banned users cannot access their invitations page + + Given the user "bad_user" is banned + And I am logged in as "bad_user" + When I go to bad_user's invitations page + Then I should be on bad_user's user page + And I should see "Your account has been banned." + + Scenario: A user can manage their invitations + + Given I am logged in as "user1" + And "user1" has "5" invitations + When I go to user1's user page + And I follow "Invitations" + And I follow "Manage Invitations" + Then I should see "Unsent (5)" + When I follow "Unsent (5)" + Then I should see "Unsent (5)" + When I follow the link for "user1" first invite + Then I should see "Enter an email address" + When I fill in "invitation[invitee_email]" with "user6@example.org" + And I press "Update Invitation" + Then I should see "Invitation was successfully sent." + + Scenario: An admin can get to a user's invitations page + Given I am logged in as a "support" admin + And the user "steven" exists and is activated + When I go to the user administration page for "steven" + And I follow "Add Invitations" + Then I should be on steven's invitations page + + Scenario: An admin can get to a user's manage invitations page + Given I am logged in as a "support" admin + And the user "steven" exists and is activated + When I go to the user administration page for "steven" + And I follow "Manage Invitations" + Then I should be on steven's manage invitations page + + Scenario: An admin can create a user's invitations + Given I am logged in as a "support" admin + And the user "steven" exists and is activated + When I go to steven's invitations page + Then I should see "Create more invitations for this user" + When I fill in "invitation[number_of_invites]" with "4" + And press "Create" + Then I should see "Invitations were successfully created." + + Scenario: An admin can delete a user's invitations + Given the user "user1" exists and is activated + And "user1" has "5" invitations + And I am logged in as a "support" admin + When I follow "Invite New Users" + And I fill in "invitation[user_name]" with "user1" + And I press "Search" within "form.invitation.simple.search" + Then I should see "Invite token" + When I follow "Delete" + Then I should see "Invitation successfully destroyed" + And "user1" should have "4" invitations + + Scenario: Translated email is sent when invitation request is declined by admin + Given a locale with translated emails + And invitations are required + And the user "user1" exists and is activated + And the user "notuser1" exists and is activated + And the user "user1" enables translated emails + And all emails have been delivered + When as "user1" I request some invites + And as "notuser1" I request some invites + And I view requests as an admin + And I press "Decline All" + Then "user1" should be emailed + And the email should have "Additional invitation request declined" in the subject + And the email to "user1" should be translated + Then "notuser1" should be emailed + And the email should have "Additional invitation request declined" in the subject + And the email to "notuser1" should be non-translated + + Scenario: Translated email is sent when new invitation is given to registered user + Given a locale with translated emails + And invitations are required + And the user "user1" exists and is activated + And the user "user1" enables translated emails + And all emails have been delivered + When as "user1" I request some invites + And an admin grants the request + Then "user1" should be emailed + And the email should have "New invitations" in the subject + And the email to "user1" should be translated \ No newline at end of file diff --git a/features/other_a/invite_use.feature b/features/other_a/invite_use.feature new file mode 100644 index 0000000..54054d8 --- /dev/null +++ b/features/other_a/invite_use.feature @@ -0,0 +1,50 @@ +Feature: invitations +In order to join the archive +As an unregistered user +I want to use an invitation to create an account + + Scenario: user attempts to use an invitation + + Given account creation is enabled + And account creation requires an invitation + And I am a visitor + When I use an invitation to sign up + Then I should see "Create Account" + + Scenario: user attempts to use an already redeemed invitation + + Given account creation is enabled + And account creation requires an invitation + And I am a visitor + When I use an already used invitation to sign up + Then I should see "This invitation has already been used to create an account" + + Scenario: I visit the invitations page for a non-existent user + + Given I am a visitor + And I go to SOME_USER's invitations page + Then I should be on the login page + And 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 "Scott" with password "password" + And I go to SOME_USER2's invitations page + Then I should be on Scott's user page + And I should see "Sorry, you don't have permission to access the page you were trying to reach." + + Scenario: Viewing invitation details when the invitee has deleted their account + 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 "creator" + 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 am logged in as "invitee" + And "invitee" deletes their account + And I am logged in as "creator" + And I go to creator's manage invitations page + Then I should see "Deleted User" + But I should not see "invitee" + When I view the most recent invitation for "creator" + Then I should see "Deleted User" + But I should not see "invitee" diff --git a/features/other_a/languages.feature b/features/other_a/languages.feature new file mode 100644 index 0000000..8df8d39 --- /dev/null +++ b/features/other_a/languages.feature @@ -0,0 +1,53 @@ +@works +Feature: Languages + + # Scenario: Browse works by language + + # Admin set up the language + + # Given I am logged in as an admin + # And I go to the admin-settings page + # TODO: Then I should be able to add a language in the front end + + Scenario: post a work in another language + + Given the following activated users exist + | login | password | + | englishuser | password | + | germanuser1 | password | + | germanuser2 | password | + | frenchuser | password | + And basic tags + And basic languages + When I am logged in as "germanuser2" with password "password" + And I post the work "Die Rache der Sith" + And I follow "Edit" + And I select "Deutsch" from "Choose a language" + And I press "Preview" + And I press "Update" + Then I should see "Die Rache der Sith" + + # TODO: French including sedilla, other characters not in the ascii set. + + When I log out + And I am logged in as "englishuser" with password "password" + And I post the work "Revenge of the Sith" + Then I should see "Revenge of the Sith" + + # Browse works in a language + + When I am on the languages page + And all indexing jobs have been run + Then I should see "Deutsch" + When I follow "Deutsch" + Then I should see "1 Work in Deutsch" + And I should see "Die Rache der Sith" + And I should not see "Revenge of the Sith" + + # cross-check in English + + When I am on the languages page + Then I should see "English" + And I should see "English" within "dl.language" + # And I should see the text with tags "<a href=\"/works\">1</a>" + # And I should see the text with tags '<a href="/works">1</a>' diff --git a/features/other_a/media.feature b/features/other_a/media.feature new file mode 100644 index 0000000..db64635 --- /dev/null +++ b/features/other_a/media.feature @@ -0,0 +1,131 @@ +Feature: The All Fandoms page. + Users should be able to see a list of the most popular canonical fandoms for + each category, with correct filter counts. + + Scenario: Fandoms with more works should appear before fandoms with fewer. + Given a media exists with name: "Movies", canonical: true + And a canonical "Movies" fandom "Marvel Cinematic Universe" with 3 works + And a canonical "Movies" fandom "Star Wars" with 2 works + + When I go to the media page + Then I should see "Marvel Cinematic Universe (3)" + And I should see "Star Wars (2)" + And "Marvel Cinematic Universe" should appear before "Star Wars" + + Scenario: Only the top 5 fandoms of each type should appear. + Given a media exists with name: "TV Shows", canonical: true + And a canonical "TV Shows" fandom "Doctor Who" with 2 works + And a canonical "TV Shows" fandom "Sherlock" with 2 works + And a canonical "TV Shows" fandom "Star Trek" with 2 works + And a canonical "TV Shows" fandom "Supernatural" with 2 works + And a canonical "TV Shows" fandom "Teen Wolf" with 2 works + And a canonical "TV Shows" fandom "The Forgotten" with 1 works + + When I go to the media page + Then I should see "Doctor Who (2)" + And I should see "Sherlock (2)" + And I should see "Star Trek (2)" + And I should see "Supernatural (2)" + And I should see "Teen Wolf (2)" + But I should not see "The Forgotten" + + When I follow "TV Shows" + Then I should see "The Forgotten (1)" + + Scenario: Adding or removing works in a fandom should change the count. + Given I have a canonical "Books" fandom tag named "Lord of the Rings" + And I am logged in as "Tolkien" + And I post a work "Fellowship of the Ring" with fandom "Lord of the Rings" + And I post a work "The Two Towers" with fandom "Lord of the Rings" + And I post a work "Return of the King" with fandom "Lord of the Rings" + + When I go to the media page + Then I should see "Lord of the Rings (3)" + + When I delete the work "Return of the King" + And the periodic filter count task is run + And I go to the media page + Then I should see "Lord of the Rings (2)" + + When I lock the work "The Two Towers" + And the periodic filter count task is run + And I go to the media page + Then I should see "Lord of the Rings (2)" + + When I am logged out + And I go to the media page + Then I should see "Lord of the Rings (1)" + + When I am logged in as a "policy_and_abuse" admin + And I go to the media page + Then I should see "Lord of the Rings (2)" + + When I view the work "The Two Towers" + And I follow "Hide Work" + And the periodic filter count task is run + And I go to the media page + Then I should see "Lord of the Rings (1)" + + Scenario: Adding or removing a metatag changes the metatag's count. + Given I have a canonical "Books" fandom tag named "Harry Potter" + And I have a canonical "Books" fandom tag named "Wizarding World" + And I am logged in as "Rowling" + And I post a work "Philosopher's Stone" with fandom "Harry Potter" + And I post a work "Fantastic Beasts" with fandom "Wizarding World" + + When I go to the media page + Then I should see "Harry Potter (1)" + And I should see "Wizarding World (1)" + + # Adding a metatag. + When I am logged in as a tag wrangler + And I subtag the tag "Harry Potter" to "Wizarding World" + And the periodic filter count task is run + And I go to the media page + Then I should see "Harry Potter (1)" + And I should see "Wizarding World (2)" + + # Removing the metatag. + When I remove the metatag "Wizarding World" from "Harry Potter" + And the periodic filter count task is run + And I go to the media page + Then I should see "Harry Potter (1)" + And I should see "Wizarding World (1)" + + Scenario: Making a tag canonical and adding synonyms adjusts the counts. + Given a media exists with name: "Books", canonical: true + And I am logged in as "Asimov" + And I post a work "I, Robot" with fandom "Robots" + And I post a work "Caves of Steel" with fandom "R. Daneel Olivaw" + + # Make the tag canonical + When I am logged in as a tag wrangler + And I edit the tag "Robots" + And I fill in "tag_media_string" with "Books" + And I check "Canonical" + And I press "Save changes" + Then I should see "Tag was updated." + + When the periodic filter count task is run + And I go to the media page + Then I should see "Robots (1)" + + # Add the synonym + When I edit the tag "R. Daneel Olivaw" + And I fill in "Synonym" with "Robots" + And I press "Save changes" + Then I should see "Tag was updated." + + When the periodic filter count task is run + And I go to the media page + Then I should see "Robots (2)" + + # Remove the synonym + When I edit the tag "R. Daneel Olivaw" + And I fill in "Synonym" with "" + And I press "Save changes" + Then I should see "Tag was updated." + + When the periodic filter count task is run + And I go to the media page + Then I should see "Robots (1)" diff --git a/features/other_a/orphan_account.feature b/features/other_a/orphan_account.feature new file mode 100644 index 0000000..f1bc794 --- /dev/null +++ b/features/other_a/orphan_account.feature @@ -0,0 +1,69 @@ +@works, @users +Feature: Orphan account + In order to have an archive full of works + As an author + I want to orphan all works in my account + +Scenario: Orphan all works belonging to a user + Given I have an orphan account + And the following activated user exists + | login | password | + | orphaneer | password | + And I am logged in as "orphaneer" with password "password" + When I post the work "Shenanigans" + And I post the work "Shenanigans 2" + And I post the work "Shenanigans - the early years" + When I go to orphaneer's user page + Then I should see "Recent works" + And I should see "Shenanigans" + And I should see "Shenanigans 2" + And I should see "Shenanigans - the early years" + When I go to the orphan all works page + Then I should see "Orphan All Works" + And I should see "Are you really sure you want to" + When I choose "Take my pseud off as well" + # Delay before orphaning to make sure the cache is expired + And it is currently 1 second from now + And I press "Yes, I'm sure" + Then I should see "Orphaning was successful." + When I view the work "Shenanigans" + Then I should see "orphan_account" + And I should not see "orphaneer" within ".userstuff" + When I view the work "Shenanigans 2" + Then I should see "orphan_account" + And I should not see "orphaneer" within ".userstuff" + When I view the work "Shenanigans - the early years" + Then I should see "orphan_account" + And I should not see "orphaneer" within ".userstuff" + +Scenario: Orphan all works belonging to a user, add a copy of the pseud to the orphan_account +Given I have an orphan account + And the following activated user exists + | login | password | + | orphaneer | password | + And I am logged in as "orphaneer" with password "password" + When I post the work "Shenanigans" + When I post the work "Shenanigans 2" + When I post the work "Shenanigans - the early years" + When I go to orphaneer's user page + Then I should see "Recent works" + And I should see "Shenanigans" + And I should see "Shenanigans 2" + And I should see "Shenanigans - the early years" + When I go to the orphan all works page + Then I should see "Orphan All Works" + And I should see "Are you really sure you want to" + When I choose "Leave a copy of my pseud on" + # Delay before orphaning to make sure the cache is expired + And it is currently 1 second from now + And I press "Yes, I'm sure" + Then I should see "Orphaning was successful." + When I view the work "Shenanigans" + Then I should see "orphaneer (orphan_account)" + And I should not see "orphaneer" within ".userstuff" + When I view the work "Shenanigans 2" + Then I should see "orphaneer (orphan_account)" + And I should not see "orphaneer" within ".userstuff" + When I view the work "Shenanigans - the early years" + Then I should see "orphaneer (orphan_account)" + And I should not see "orphaneer" within ".userstuff" diff --git a/features/other_a/orphan_pseud.feature b/features/other_a/orphan_pseud.feature new file mode 100644 index 0000000..5781bdb --- /dev/null +++ b/features/other_a/orphan_pseud.feature @@ -0,0 +1,104 @@ +@works +Feature: Orphan pseud + In order to have an archive full of works + As an author + I want to orphan all works under one pseud + # TODO: Expand this to cover a user who has more than one pseud, and check that works on the other pseud don't get orphaned + + Scenario: Orphan all works belonging to one pseud + Given I have an orphan account + And the following activated user exists + | login | password | + | orphanpseud | password | + And I am logged in as "orphanpseud" with password "password" + When I post the work "Shenanigans" + And I post the work "Shenanigans 2" + When I follow "orphanpseud" within ".byline" + Then I should see "Shenanigans 2 by orphanpseud" + When I follow "Back To Pseuds" + Then I should see "orphanpseud" + And I should see "2 works" + # Delay before orphaning to make sure the cache is expired + And it is currently 1 second from now + When I follow "Orphan Works" + Then I should see "Orphan All Works by orphanpseud" + When I choose "Take my pseud off as well" + And I press "Yes, I'm sure" + Then I should see "Orphaning was successful." + When I view the work "Shenanigans" + Then I should see "orphan_account" + And I should not see "orphanpseud" within ".userstuff" + When I view the work "Shenanigans 2" + Then I should see "orphan_account" + And I should not see "orphanpseud" within ".userstuff" + + Scenario: Orphan all works belonging to one pseud, add a copy of the pseud to the orphan_account + Given I have an orphan account + And the following activated user exists + | login | password | + | orphanpseud | password | + And I am logged in as "orphanpseud" with password "password" + When I post the work "Shenanigans" + When I post the work "Shenanigans 2" + When I follow "orphanpseud" within ".byline" + Then I should see "Shenanigans by orphanpseud" + And I should see "Shenanigans 2 by orphanpseud" + When I follow "Back To Pseuds" + Then I should see "orphanpseud" + And I should see "2 works" + # Delay before orphaning to make sure the cache is expired + And it is currently 1 second from now + When I follow "Orphan Works" + Then I should see "Orphan All Works by orphanpseud" + When I choose "Leave a copy of my pseud on" + And I press "Yes, I'm sure" + Then I should see "Orphaning was successful." + When I view the work "Shenanigans" + Then I should see "orphanpseud (orphan_account)" + And I should not see "orphanpseud" within ".userstuff" + When I view the work "Shenanigans 2" + Then I should see "orphanpseud (orphan_account)" + And I should not see "orphanpseud" within ".userstuff" + + Scenario: Orphan a pseud with works co-created by another pseud + Given I have an orphan account + And I am logged in as "halfandhalf" + And "halfandhalf" creates the pseud "To Be Kept" + And "halfandhalf" creates the pseud "To Be Orphaned" + + When I set up the draft "Treasure" + And I unselect "halfandhalf" from "Creator/Pseud(s)" + And I select "To Be Kept" from "Creator/Pseud(s)" + And I press "Post" + Then I should see "To Be Kept" within ".byline" + When I set up the draft "Trash" + And I unselect "halfandhalf" from "Creator/Pseud(s)" + And I select "To Be Orphaned" from "Creator/Pseud(s)" + And I press "Post" + Then I should see "To Be Orphaned" within ".byline" + When I set up the draft "Half and Half" + And I unselect "halfandhalf" from "Creator/Pseud(s)" + And I select "To Be Kept" from "Creator/Pseud(s)" + And I select "To Be Orphaned" from "Creator/Pseud(s)" + And I press "Post" + Then I should see "To Be Kept" within ".byline" + And I should see "To Be Orphaned" within ".byline" + + When I go to halfandhalf's pseuds page + And I follow "Orphan Works by To Be Orphaned" + Then I should see "Orphan All Works by To Be Orphaned" + When I choose "Take my pseud off as well" + And I wait 2 seconds + And I press "Yes, I'm sure" + Then I should see "Orphaning was successful." + + When I view the work "Trash" + Then I should see "orphan_account" within ".byline" + But I should not see "halfandhalf" within ".byline" + When I view the work "Treasure" + Then I should see "To Be Kept (halfandhalf)" within ".byline" + But I should not see "orphan_account" within ".byline" + When I view the work "Half and Half" + Then I should see "To Be Kept (halfandhalf)" within ".byline" + And I should see "orphan_account" within ".byline" + But I should not see "To Be Orphaned (halfandhalf)" within ".byline" diff --git a/features/other_a/orphan_series.feature b/features/other_a/orphan_series.feature new file mode 100644 index 0000000..eddcf06 --- /dev/null +++ b/features/other_a/orphan_series.feature @@ -0,0 +1,81 @@ +@series +Feature: Orphan series + As an author + I want to orphan a series full of works + + Background: + Given I have an orphan account + And I am logged in as "orphaneer" + + Scenario: Orphaning a series (remove pseud) should remove all references to the user from the series + + Given I add the work "Work to be Rued" to the series "My Biggest Mistakes" + And I add the work "Regrettable Work" to the series "My Biggest Mistakes" + + When I orphan and take my pseud off the series "My Biggest Mistakes" + And I am logged out + And I view the series "My Biggest Mistakes" + + Then I should not see "orphaneer" + + Scenario: Orphaning a series (leave pseud) should change the authorship to the correct pseudonym of orphan_account + + Given I add the work "Work to be Rued" to the series "My Biggest Mistakes" + And I add the work "Regrettable Work" to the series "My Biggest Mistakes" + + When I orphan and leave my pseud on the series "My Biggest Mistakes" + And I view the series "My Biggest Mistakes" + + Then I should see "orphaneer (orphan_account)" within ".series.meta" + And I should not see "Edit" + + Scenario: Orphaning a series should remove it from the user's series page + + Given I add the work "Work to be Rued" to the series "My Biggest Mistakes" + And I add the work "Regrettable Work" to the series "My Biggest Mistakes" + + When I orphan and take my pseud off the series "My Biggest Mistakes" + And I am on orphaneer's series page + + Then I should not see "My Biggest Mistakes" + + Scenario: Orphaning a work in a series with only one work should cause the series to be orphaned + + Given I add the work "Work to be Rued" to the series "My Biggest Mistakes" + + When I orphan the work "Work to be Rued" + And I am logged out + And I view the series "My Biggest Mistakes" + + Then I should see "orphan_account" + And I should not see "orphaneer" + + Scenario: Orphaning one but not all of the works in a series should make the series co-created by orphan_account + + Given I add the work "Work to be Rued" to the series "My Biggest Mistakes" + And I add the work "Work to be Kept" to the series "My Biggest Mistakes" + + When I orphan the work "Work to be Rued" + + Then "orphaneer" should be a co-creator of the series "My Biggest Mistakes" + And "orphan_account" should be a co-creator of the series "My Biggest Mistakes" + + Scenario: When a user orphans a shared series, it should not change the byline for works that they didn't co-create + + # Set up a shared series where orphaneer is not listed as a creator on the second work + Given I am logged in as "keeper" + And I add the work "Shared Beginnings" to the series "Shared Series" + And I add the co-author "orphaneer" to the work "Shared Beginnings" + And I add the work "Keeper's Solo" to the series "Shared Series" + + # Double-check to make sure that we've set up the authorships correctly. + Then "orphaneer" should be a co-creator of the series "Shared Series" + And "orphaneer" should be a co-creator of the work "Shared Beginnings" + But "orphaneer" should not be a co-creator of the work "Keeper's Solo" + + When I am logged in as "orphaneer" + And I orphan the series "Shared Series" + + Then "orphan_account" should be a co-creator of the series "Shared Series" + And "orphan_account" should be a co-creator of the work "Shared Beginnings" + But "orphan_account" should not be a co-creator of the work "Keeper's Solo" diff --git a/features/other_a/orphan_work.feature b/features/other_a/orphan_work.feature new file mode 100644 index 0000000..d9758f5 --- /dev/null +++ b/features/other_a/orphan_work.feature @@ -0,0 +1,162 @@ +@works +Feature: Orphan work + In order to have an archive full of works + As an author + I want to orphan works + + + Background: + Given I have an orphan account + And the following activated users exists + | login | password | email | + | orphaneer | password | orphaneer@foo.com | + | author_subscriber | password | author_subscriber@foo.com | + And "author_subscriber" subscribes to author "orphaneer" + And all emails have been delivered + And I am logged in as "orphaneer" + + + Scenario: Orphan a single work, using the default orphan_account + + Given I post the work "Shenanigans" + And I view the work "Shenanigans" + Then I should see "Edit" + When I follow "Edit" + Then I should see "Edit Work" + And I should see "Orphan Work" + # Delay before orphaning to make sure the cache is expired + And it is currently 1 second from now + When I follow "Orphan Work" + Then I should see "Read More About The Orphaning Process" + When I choose "Take my pseud off as well" + And I press "Yes, I'm sure" + And all indexing jobs have been run + Then I should see "Orphaning was successful." + And I should see "Bookmarks (0)" + When I follow "Works (0)" + Then I should not see "Shenanigans" + When I view the work "Shenanigans" + Then I should see "orphan_account" + And I should not see "Delete" + + + Scenario: Orphan a single work and add a copy of the pseud to the orphan_account + + Given I post the work "Shenanigans2" + When I view the work "Shenanigans2" + Then I should see "Edit" + When I follow "Edit" + Then I should see "Edit Work" + And I should see "Orphan Work" + # Delay before orphaning to make sure the cache is expired + And it is currently 1 second from now + When I follow "Orphan Work" + Then I should see "Read More About The Orphaning Process" + When I choose "Leave a copy of my pseud on" + And I press "Yes, I'm sure" + Then I should see "Orphaning was successful." + When I go to orphaneer's works page + Then I should not see "Shenanigans2" + When I view the work "Shenanigans2" + Then I should see "orphaneer (orphan_account)" + And I should not see "Delete" + + + Scenario: Orphan a work (remove pseud) and don't notify subscribers to my account + + Given I post the work "Doomed Story" + And I follow "Edit" + And I follow "Orphan Work" + And I choose "Take my pseud off as well" + And I press "Yes, I'm sure" + When subscription notifications are sent + Then 0 emails should be delivered + + + Scenario: Orphan a work (leave pseud) and don't notify subscribers to my account + + Given I post the work "Awful Concoction" + And I follow "Edit" + And I follow "Orphan Work" + And I choose "Leave a copy of my pseud on" + And I press "Yes, I'm sure" + When subscription notifications are sent + Then 0 emails should be delivered + + + Scenario: Orphan a work (leave pseud) and don't notify subscribers to the work + + Given the following activated user exists + | login | password | email | + | work_subscriber | password | work_subscriber@foo.com | + And I post the work "Torrid Idfic" + And I am logged in as "work_subscriber" + And I view the work "Torrid Idfic" + And I press "Subscribe" + And a chapter is added to "Torrid Idfic" + And I follow "Edit" + And I follow "Orphan Work" + And I choose "Leave a copy of my pseud on" + And I press "Yes, I'm sure" + When subscription notifications are sent + Then 0 emails should be delivered + + + Scenario: Orphan a work (leave pseud) and don't notify subscribers to the work's series + + Given the following activated user exists + | login | password | email | + | series_subscriber | password | series_subscriber@foo.com | + And I add the work "Lazy Purple Sausage" to series "Shame Series" + And I am logged in as "series_subscriber" + And I view the series "Shame Series" + And I press "Subscribe" + And I am logged in as "orphaneer" + And I view the work "Lazy Purple Sausage" + And I follow "Edit" + And I follow "Orphan Work" + And I choose "Leave a copy of my pseud on" + And I press "Yes, I'm sure" + When subscription notifications are sent + Then 0 emails should be delivered + + Scenario: I can orphan multiple works at once + Given I am logged in as "author" + And I post the work "Glorious" with fandom "SGA" + And I post the work "Excellent" with fandom "Star Trek" + And I post the work "Lovely" with fandom "Steven Universe" + And I go to author's works page + When I follow "Edit Works" + Then I should see "Edit Multiple Works" + When I select "Glorious" for editing + And I select "Excellent" for editing + And I press "Delete" + Then I should see "Are you sure you want to delete these works PERMANENTLY?" + And I should see "Glorious" + And I should see "Excellent" + And I should not see "Lovely" + # Delay before orphaning to make sure the cache is expired + And it is currently 1 second from now + When I follow "Orphan Works Instead" + Then I should see "Orphaning a work removes it from your account and re-attaches it to the specially created orphan_account." + When I press "Yes, I'm sure" + And all indexing jobs have been run + Then I should see "Orphaning was successful." + When I go to author's works page + Then I should not see "Glorious" + And I should not see "Excellent" + And I should see "Lovely" + + Scenario: Orphaning a shared work should not affect chapters created solely by the other creator + + Given I am logged in as "keeper" + And I post the work "Half-Orphaned" + And I add the co-author "orphaneer" to the work "Half-Orphaned" + And I post a chapter for the work "Half-Orphaned" + # Verify that the authorship has been set up properly + Then "orphaneer" should be a co-creator of Chapter 1 of "Half-Orphaned" + But "orphaneer" should not be a co-creator of Chapter 2 of "Half-Orphaned" + When I am logged in as "orphaneer" + And I orphan the work "Half-Orphaned" + Then "orphan_account" should be a co-creator of Chapter 1 of "Half-Orphaned" + But "orphan_account" should not be a co-creator of Chapter 2 of "Half-Orphaned" diff --git a/features/other_a/page_title.feature b/features/other_a/page_title.feature new file mode 100644 index 0000000..dfbd456 --- /dev/null +++ b/features/other_a/page_title.feature @@ -0,0 +1,73 @@ +Feature: Page titles +When I browse the AO3 +I want page titles to be readable + +Background: + + Given the app name is "Example Archive" + +Scenario: An index page uses only the controller name in the default browser page title + + Given a fandom exists with name: "No Fandom", canonical: true + When I go to the tags page + Then I should see the page title "Tags | Example Archive" + +Scenario: A non-index page uses the action and controller names in the default browser page title + + When I am logged in as "user" + And I go to the new work page + Then I should see the page title "New Work | Example Archive" + +Scenario: user reads a TOS or FAQ page + + When I go to the TOS page + Then the page title should include "Terms of Service | Example Archive" + When I go to the FAQ page + Then the page title should include "Archive FAQs | Example Archive" + +Scenario: Work page title should respect user preference + + Given I am logged in as "author" + And I follow "My Preferences" + And I fill in "Browser page title format" with "FANDOM - AUTHOR - TITLE" + And I press "Update" + And I post the work "New Story" with fandom "Stargate" + When I view the work "New Story" + Then the page title should include "Stargate - author - New Story [Example Archive]" + +Scenario: Work page title should change when tags are edited + + Given I am logged in as "author" + And I post the work "New Story" with fandom "Stargate" + When I view the work "New Story" + Then the page title should include "Stargate" + When I edit the work "New Story" + And I fill in "Fandoms" with "Harry Potter" + And I press "Post" + When I view the work "New Story" + Then the page title should include "Harry Potter" + And the page title should not include "Stargate" + +Scenario: Work page title should be informative on the adult content notice page + + Given I am logged in as "author" + And I post the 2 chapter work "New Story" with fandom "Stargate" with rating "Mature" + When I am logged out + And I view the work "New Story" + Then I should see "This work could have adult content" + And the page title should include "New Story - author - Stargate [Example Archive]" + When I follow the recent chapter link for the work "New Story" + Then I should see "This work could have adult content" + And the page title should include "New Story - Chapter 2 - author - Stargate [Example Archive]" + +Scenario: Inbox has the expected browser page title + + When I am logged in as "boxer" + And I go to boxer's inbox page + Then I should see the page title "boxer - Inbox | Example Archive" + +Scenario: New tag set page has the expected browser page title + + When I am logged in as "user" + When I go to the new tag set page + Then I should see the page title "New Owned Tag Set | Example Archive" diff --git a/features/other_a/parser.feature b/features/other_a/parser.feature new file mode 100644 index 0000000..6761f73 --- /dev/null +++ b/features/other_a/parser.feature @@ -0,0 +1,126 @@ +@works +Feature: Parsing HTML + + # tests for parsing only are in spec/lib/html_cleaner_spec.rb + + Scenario: Editing a work and saving it twice without changes should preserve the same content + When I am logged in as "newbie" + And I set up the draft "My Awesome Story" + And I fill in "content" with + """ + This is paragraph 1. + + This is paragraph 2. + + + + This is paragraph 3. + """ + And I press "Preview" + Then I should see "Preview" + # testing the HTML here + And I should see the text with tags + """ + <p>This is paragraph 1.</p> + <p>This is paragraph 2.</p> + <p> </p> + <p>This is paragraph 3.</p> + """ + When I press "Post" + And I follow "Edit" + # testing the textarea content here + Then I should see in the "content" input + """ + <p>This is paragraph 1.</p> + + <p>This is paragraph 2.</p> + + <p> </p> + + <p>This is paragraph 3.</p> + """ + When I press "Post" + And I follow "Edit" + Then I should see in the "content" input + """ + <p>This is paragraph 1.</p> + + <p>This is paragraph 2.</p> + + <p> </p> + + <p>This is paragraph 3.</p> + """ + + 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 + """ + <p>A paragraph</p> + <p>Another paragraph.</p> + """ + + 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 "<p class='myclass'>Text</p>" + And I fill in "Notes" with "<p class='note'>Text</p>" + And I fill in "End Notes" with "<span class='keep-me'>Text</span>" + And I fill in "content" with "<p class='size-10'><img src='britney.gif' alt='Britney Spears' />You better work</p>" + 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 "<p class='summary classes'>Text</p>" + And I fill in "Notes" with "<p class='note'>Text</p>" + And I fill in "End Notes" with "<span class='keep-me'>Text</span>" + And I fill in "content" with "<div class='elaborate formatting'><p>The continuation of my <a href='works/123'>masterpiece</a></p></div>" + 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 '<p>The continuation of my <a href="works/123" rel="nofollow">masterpiece</a></p>' + 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 "<p class='strip me'>Hi there!</p>" + 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 <span='remove-me'>best yet</span>" + 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 100644 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 100644 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 100644 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 100644 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 100644 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 100644 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!<img src='http://example.com/icon.svg'>" + 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 100644 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 100644 index 0000000..afc5172 --- /dev/null +++ b/features/other_a/reading.feature @@ -0,0 +1,298 @@ +@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 "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 follow "Mark for Later" + Then I should see "This work was added to your Marked for Later list." + And I go to reader's reading page + Then 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 follow "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 follow "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 follow "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 100644 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 100644 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 100644 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 "<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 100644 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. + <!-- #exec cmd=\"/bin/echo \" --> + <script src=http://ha.ckers.org/xss.js></script> + """ + And I press "Preview" + Then I should see "Preview" + And I should not see the text with tags '<!-- #exec cmd=' + And I should not see the text with tags '<script src=http://ha.ckers.org/xss.js></script>' + + + Scenario: Sanitizer should leave safe HTML alone + + When I am logged in as "newbie" + And I set up the draft "My Awesome Story" + And I fill in "content" with + """ + 1. Lorem ipsim + + 2. <a href="https://en.wikipedia.org/wiki/The_quick_brown_fox_jumps_over_the_lazy_dog"><em>The quick brown fox jumps over the lazy dog</em></a>. + """ + And I press "Preview" + Then I should see "Preview" + And I should see "The quick brown fox jumps over the lazy dog" within "p a em" + And I should see the text with tags '"https://en.wikipedia.org/wiki/The_quick_brown_fox_jumps_over_the_lazy_dog"' + + + Scenario: XSS hacks in works should be blocked by sanitizing + + # TODO + # + # I moved almost all of these to spec/lib/html_cleaner_spec.rb The + # remaining two below should go there, too, but I'm not quite sure + # what their intention is. The sanitiser doesn't change them, and I + # don't get the reason behind the "should not find XSS" test. -- + # Rebecca + + Given basic tags + And I am logged in as "newbie" with password "password" + When I go to the new work page + Then I should see "Post New Work" + And I select "Not Rated" from "Rating" + And I check "No Archive Warnings Apply" + And I select "English" from "Choose a language" + And I fill in "Fandoms" with "Supernatural" + And I fill in "Work Title" with "All Hell Breaks Loose" + And I fill in "content" with 'BODY{-moz-binding:url("http://ha.ckers.org/xssmoz.xml#xss")}' + And I press "Preview" + Then I should see "Preview" + And I should not see "XSS" + And I should see "BODY{-moz-binding:url(" + When I press "Edit" + And I fill in "content" with "behavior: url(xss.htc);" + And I press "Preview" + Then I should see "Preview" + And I should not see "XSS" + And I should see "behavior: url(xss.htc);" + diff --git a/features/other_b/series.feature b/features/other_b/series.feature new file mode 100644 index 0000000..a33ed93 --- /dev/null +++ b/features/other_b/series.feature @@ -0,0 +1,346 @@ +@series +Feature: Create and Edit Series + In order to view series created by a user + As a reader + The index needs to load properly, even for authors with more than ArchiveConfig.ITEMS_PER_PAGE series + + Scenario: Creator manually enters a series name to add a work to a new series when the work is first posted + Given I am logged in as "author" + And I set up the draft "Sweetie Belle" + When I fill in "Or create and use a new one:" with "Ponies" + When I press "Post" + Then I should see "Part 1 of Ponies" within "div#series" + And I should see "Part 1 of Ponies" within "dd.series" + When I view the series "Ponies" + Then I should see "Sweetie Belle" + + Scenario: Creator selects an existing series name to add a work to an existing series when the work is first posted + Given I am logged in as "author" + And I post the work "Sweetie Belle" as part of a series "Ponies" + And I set up the draft "Starsong" + When I select "Ponies" from "Choose one of your existing series:" + And I press "Post" + Then I should see "Part 2 of Ponies" within "div#series" + And I should see "Part 2 of Ponies" within "dd.series" + When I view the series "Ponies" + Then I should see "Sweetie Belle" + And I should see "Starsong" + + Scenario: Creator adds a work to an existing series by editing the work + Given I am logged in as "author" + And I post the work "Sweetie Belle" as part of a series "Ponies" + And I post the work "Rainbow Dash" + When I view the series "Ponies" + Then I should not see "Rainbow Dash" + When I edit the work "Rainbow Dash" + And I select "Ponies" from "Choose one of your existing series:" + And I press "Post" + Then I should see "Part 2 of Ponies" within "div#series" + And I should see "Part 2 of Ponies" within "dd.series" + When I view the series "Ponies" + Then I should see "Sweetie Belle" + And I should see "Rainbow Dash" + + Scenario: Works in a series have series navigation + Given I am logged in as "author" + And I post the work "Sweetie Belle" as part of a series "Ponies" + And it is currently 1 second from now + And I post the work "Starsong" as part of a series "Ponies" + And it is currently 1 second from now + And I post the work "Rainbow Dash" as part of a series "Ponies" + When I view the series "Ponies" + And I follow "Rainbow Dash" + Then I should see "Part 3 of Ponies" + And I should not see "Next Work →" + When I follow "← Previous Work" + Then I should see "Starsong" + And I should see "Next Work →" within ".work.meta .next" + And I should see "Next Work →" within ".afterword .next" + When I follow "← Previous Work" + And I should see "Next Work →" within ".work.meta .next" + And I should see "Next Work →" within ".afterword .next" + Then I should see "Sweetie Belle" + When I follow "Next Work →" + Then I should see "Starsong" + + Scenario: Creator can add series information + Given I am logged in as "author" + And I post the work "Sweetie Belle" as part of a series "Ponies" + When I view the series "Ponies" + And I follow "Edit Series" + And I fill in "Series Description" with "This is a series about ponies. Of course" + And I fill in "Series Notes" with "I wrote this under the influence of coffee! And pink chocolate." + And I press "Update" + Then I should see "Series was successfully updated." + And I should see "This is a series about ponies. Of course" within "blockquote.userstuff" + And I should see "I wrote this under the influence of coffee! And pink chocolate." within "dl.series" + And I should see "Complete: No" + When I follow "Edit Series" + And I check "This series is complete" + And I press "Update" + Then I should see "Complete: Yes" + + Scenario: A work can be in two series + Given I am logged in as "author" + And I post the work "Sweetie Belle" as part of a series "Ponies" + And I post the work "Rainbow Dash" as part of a series "Ponies" + When I edit the work "Rainbow Dash" + Then the "This work is part of a series" checkbox should be checked + And "Ponies" should be an option within "Choose one of your existing series:" + When I fill in "Or create and use a new one:" with "Black Beauty" + And I press "Preview" + Then I should see "Part 2 of Ponies" within "dd.series" + And I should see "Part 1 of Black Beauty" within "dd.series" + When I press "Update" + And all indexing jobs have been run + Then I should see "Part 1 of Black Beauty" within "dd.series" + And I should see "Part 2 of Ponies" within "dd.series" + And I should see "Part 1 of Black Beauty" within "div#series" + And I should see "Part 2 of Ponies" within "div#series" + + Scenario: Creator with multiple pseuds adds a work to a new series when the work is first posted + Given I am logged in as "author" + And "author" creates the pseud "Pointless Pseud" + And I set up the draft "Sweetie Belle" using the pseud "Pointless Pseud" + When I fill in "Or create and use a new one:" with "Ponies" + And I press "Post" + Then I should see "Pointless Pseud" + And I should see "Part 1 of Ponies" within "div#series" + And I should see "Part 1 of Ponies" within "dd.series" + When I view the series "Ponies" + Then I should see "Sweetie Belle" + + Scenario: Creator with multiple pseuds adds a work to an existing series when the work is first posted + Given I am logged in as "author" + And "author" creates the pseud "Pointless Pseud" + And I post the work "Sweetie Belle" as part of a series "Ponies" using the pseud "Pointless Pseud" + When I set up the draft "Starsong" as part of a series "Ponies" using the pseud "Pointless Pseud" + And I press "Post" + Then I should see "Pointless Pseud" + And I should see "Part 2 of Ponies" + When I view the series "Ponies" + Then I should see "Sweetie Belle" + And I should see "Starsong" + + Scenario: Creator with multiple pseuds adds a work to an existing series by editing the work + Given I am logged in as "author" + And "author" creates the pseud "Pointless Pseud" + And I post the work "Sweetie Belle" as part of a series "Ponies" using the pseud "Pointless Pseud" + And I post the work "Rainbow Dash" using the pseud "Pointless Pseud" + When I view the series "Ponies" + Then I should not see "Rainbow Dash" + When I edit the work "Rainbow Dash" + And I select "Ponies" from "Choose one of your existing series:" + And I press "Post" + Then I should see "Part 2 of Ponies" within "div#series" + And I should see "Part 2 of Ponies" within "dd.series" + When I view the series "Ponies" + Then I should see "Sweetie Belle" + And I should see "Rainbow Dash" + + Scenario: A pseud's series page contains the pseud in the page title + Given I am logged in as "author" + And "author" creates the pseud "Pointless Pseud" + And I post the work "Sweetie Belle" as part of a series "Ponies" using the pseud "Pointless Pseud" + When I follow "Pointless Pseud" + And I follow "Series (1)" + Then the page title should include "Pointless Pseud - Series" + + Scenario: Rename a series + Given I am logged in as a random user + When I add the work "WALL-E" to series "Robots" + Then I should see "Part 1 of Robots" within "div#series" + And I should see "Part 1 of Robots" within "dd.series" + When I view the series "Robots" + And I follow "Edit Series" + And I fill in "Series Title" with "Many a Robot" + And I wait 2 seconds + And I press "Update" + Then I should see "Series was successfully updated." + And I should see "Many a Robot" + # Work blurbs should be updated. + When I follow "My Dashboard" + Then I should see "Part 1 of Many a Robot" within "#user-works" + # Work metas should be updated. + When I view the work "WALL-E" + Then I should see "Part 1 of Many a Robot" within "div#series" + And I should see "Part 1 of Many a Robot" within "dd.series" + + Scenario: Post + Given I am logged in as "whoever" with password "whatever" + And I add the work "public" to series "be_public" + When I follow "be_public" + Then I should not see the image "title" text "Restricted" within "h2" + + Scenario: View user's series index + Given I am logged in as "whoever" with password "whatever" + And I add the work "grumble" to series "polarbears" + When I go to whoever's series page + Then I should see "1 Series by whoever" + And I should see "polarbears" + + Scenario: Series index for maaany series + Given I am logged in as "whoever" with password "whatever" + And I add the work "grumble" to "32" series "penguins" + When I go to whoever's series page + Then I should see "penguins30" + When I follow "Next" + Then I should see "penguins0" + + Scenario: Series show page with many works + Given I am logged in as "author" + And I post the work "Caesar" as part of a series "Salads" + And I post the work "Chicken" as part of a series "Salads" + And I post the work "Pasta" as part of a series "Salads" + And I post the work "Spring" as part of a series "Salads" + And I post the work "Chef" as part of a series "Salads" + And there are 3 works per series page + When I view the series "Salads" + Then I should see "Caesar" + And I should see "Chicken" + And I should see "Pasta" + When I follow "Next" + Then I should see "Spring" + And I should see "Chef" + + Scenario: Removing self as co-creator from co-created series when you are the only creator of a work in the series. + Given I am logged in as "sun" + And the user "moon" allows co-creators + And I post the work "Sweetie Bell" as part of a series "Ponies" + When I view the series "Ponies" + And I follow "Edit Series" + And I try to invite the co-author "moon" + And I press "Update" + Then I should see "Series was successfully updated." + But I should not see "moon" + When the user "moon" accepts all co-creator requests + Then "moon" should be a creator of the series "Ponies" + When I view the series "Ponies" + And I follow "Remove Me As Co-Creator" + Then I should see "Sorry, we can't remove all creators of a work." + + Scenario: Removing self as co-creator from co-created series + Given basic tags + And the user "son" allows co-creators + When I am logged in as "moon" with password "testuser" + And I coauthored the work "Sweetie Bell" as "moon" with "son" + And I edit the work "Sweetie Bell" + And I fill in "work_series_attributes_title" with "Ponies" + And I post the work + Then I should see "Work was successfully updated." + And "moon" should be a creator of the series "Ponies" + And "son" should be a creator on the series "Ponies" + # Delay to make sure the cache is expired + And it is currently 1 second from now + When I follow "Remove Me As Co-Creator" + Then I should see "You have been removed as a creator from the series and its works." + And "moon" should not be the creator of the series "Ponies" + And "son" should be a creator on the series "Ponies" + When I go to moon's works page + Then I should not see "Sweetie Bell" + + Scenario: Delete a series + Given I am logged in as "cereal" with password "yumyummy" + And I add the work "Snap" to series "Krispies" + When I view the series "Krispies" + And I follow "Delete Series" + And I press "Yes, Delete Series" + Then I should see "Series was successfully deleted." + + Scenario: A work's series information is visible and up to date when previewing the work while posting or editing + Given I am logged in as "author" + And "author" creates the pseud "Pointless Pseud" + And I set up the draft "Sweetie Belle" as part of a series "Ponies" + When I press "Preview" + Then I should see "Part 1 of Ponies" + When I press "Post" + And I set up the draft "Rainbow Dash" as part of a series "Ponies" using the pseud "Pointless Pseud" + And I press "Preview" + Then I should see "Pointless Pseud" + And I should see "Part 2 of Ponies" + When I edit the work "Rainbow Dash" + And I fill in "Or create and use a new one:" with "Black Beauty" + And I wait 2 seconds + And I press "Preview" + Then I should see "Part 2 of Ponies" within "dd.series" + And I should see "Part 1 of Black Beauty" within "dd.series" + + Scenario: A series's metadata is visible when viewing the series + Given I am logged in as a random user + And I post the work "Story" as part of a series "Excellent Series" + And I bookmark the series "Excellent Series" + When I view the series "Excellent Series" + Then I should see "Words: 6" within ".series.meta" + And I should see "Bookmarks: 1" within ".series.meta" + And I should see "Works: 1" within ".series.meta" + + Scenario: When editing a series, the title field should not escape HTML + Given I am logged in as "whoever" + And I post the work "whatever" as part of a series "What a title! :< :& :>" + And I go to whoever's series page + And I follow "What a title! :< :& :>" + And I follow "Edit Series" + Then I should see "What a title! :< :& :>" in the "Series Title" input + + Scenario: You can edit a series to add someone as a co-creator if their preferences are set to permit it. + Given I am logged in as "foobar" + And the user "barbaz" exists and is activated + And the user "barbaz" allows co-creators + And I post the work "Behind her back she’s Gentleman Jack" as part of a series "Gentleman Jack" + When I view the series "Gentleman Jack" + And I follow "Edit Series" + And I try to invite the co-author "barbaz" + And I press "Update" + Then I should see "Series was successfully updated." + But I should not see "barbaz" + And 1 email should be delivered to "barbaz" + And the email should contain "The user foobar has invited your pseud barbaz to be listed as a co-creator on the following series" + When I am logged in as "barbaz" + And I follow "Gentleman Jack" in the email + Then I should not see "Edit Series" + When I follow "Co-Creator Requests page" + And I check "selected[]" + And I wait 2 seconds + And I press "Accept" + Then I should see "You are now listed as a co-creator on Gentleman Jack." + When I follow "Gentleman Jack" + Then I should see "Edit Series" + And "barbaz" should be a co-creator of the series "Gentleman Jack" + + Scenario: You cannot edit a series to add someone as a co-creator if their preferences don't permit it. + Given I am logged in as "foobar" + And the user "barbaz" exists and is activated + And the user "barbaz" disallows co-creators + And I post the work "Behind her back she’s Gentleman Jack" as part of a series "Gentleman Jack" + When I view the series "Gentleman Jack" + And I follow "Edit Series" + And I try to invite the co-author "barbaz" + And I press "Update" + Then I should see "Invalid creator: barbaz does not allow others to invite them to be a co-creator." + When I press "Update" + Then I should see "Series was successfully updated." + And "barbaz" should not be the creator of the series "Gentleman Jack" + + Scenario: If you edit a series to add a co-creator with an ambiguous pseud, you will be prompted to clarify which user you mean. + Given "myself" has the pseud "Me" + And "herself" has the pseud "Me" + And the user "myself" allows co-creators + And the user "herself" allows co-creators + When I am logged in as "testuser" with password "testuser" + And I post the work "Behind her back she’s Gentleman Jack" as part of a series "Gentleman Jack" + And I view the series "Gentleman Jack" + And I follow "Edit Series" + And I try to invite the co-author "Me" + And I press "Update" + Then I should see "There's more than one user with the pseud Me." + When I select "myself" from "Please choose the one you want:" + And I press "Update" + Then I should see "Series was successfully updated." + And I should not see "Me (myself)" + And 1 email should be delivered to "myself" + And the email should contain "The user testuser has invited your pseud Me to be listed as a co-creator on the following series" + When the user "myself" accepts all co-creator requests + And I view the series "Gentleman Jack" + Then "testuser" should be the creator of the series "Gentleman Jack" + And "Me (myself)" should be the creator of the series "Gentleman Jack" + And I should see "Me (myself), testuser" diff --git a/features/other_b/series_lock.feature b/features/other_b/series_lock.feature new file mode 100644 index 0000000..1064ad5 --- /dev/null +++ b/features/other_b/series_lock.feature @@ -0,0 +1,67 @@ +@series +Feature: Locked and partially locked series + In order to keep my works under the radar + As a registered archive user + I should be able to make my serial works visible only to other registered users + + Scenario: Post a series with a restricted work, then add a draft, then make the draft public and post it + Given I am logged in as "fandomer" + And I set up the draft "Humbug" as part of a series "Antiholidays" + And I lock the work + And I press "Post" + Then I should see "Part 1 of Antiholidays" + When I go to fandomer's series page + Then I should see "Antiholidays" + When I am logged out + And I go to fandomer's series page + Then I should not see "Antiholidays" + When I am logged in as "reccer" + And I go to fandomer's series page + Then I should see "Antiholidays" + When I view the series "Antiholidays" + Then I should see "Humbug" + When I am logged in as "fandomer" + And I set up the draft "Antivalentine" as part of a series "Antiholidays" + And I check "work_restricted" + And I press "Preview" + Then I should see "Draft was successfully created." + And I should see "Part 2 of Antiholidays" + When I view the series "Antiholidays" + Then I should see "Works: 1" + And I should not see "Antivalentine" + When I view the work "Humbug" + Then I should not see "Next Work →" within "dd" + When I edit the work "Antivalentine" + And I unlock the work + And I press "Preview" + Then I should see "Part 2 of Antiholidays" + When I press "Post" + Then I should see "Part 2 of Antiholidays" + And I should not see the image "title" text "Restricted" + And I should see "← Previous Work" within "dd.series" + When I am logged out + And I view the series "Antiholidays" + Then I should see "Antivalentine" + But I should not see "Humbug" + When I view the work "Antivalentine" + Then I should see "Part 1 of Antiholidays" + And I should not see "Next Work →" within "dd" + When I am logged in as "reccer" + And I go to fandomer's series page + Then I should see "Antiholidays" + When I view the series "Antiholidays" + Then I should see "Works: 2" + And I should see "Humbug" + And I should see "Antivalentine" + + Scenario: edit a locked work to add it to a series + Given I am logged in as "fandomer" + And I post the locked work "Boohoo" + When I edit the work "Boohoo" + And I uncheck "work_restricted" + And I fill in "work_series_attributes_title" with "Antiholidays" + And I press "Preview" + Then I should see "Preview" + And I should see "Part 1 of Antiholidays" + When I press "Update" + Then I should see "Work was successfully updated" diff --git a/features/other_b/series_order.feature b/features/other_b/series_order.feature new file mode 100644 index 0000000..421c0b3 --- /dev/null +++ b/features/other_b/series_order.feature @@ -0,0 +1,70 @@ +@series +Feature: Rearrange works within a series + In order to manage parts of a series + As a humble series writer + I want to be able to reorder the parts of my series + + Scenario: Rearrange parts of a series. + Given I am logged in as "author" + And I post the work "A Bad, Bad Day" as part of a series "Tale of Woe" + Then I should see "Part 1 of Tale of Woe" + When I view the series "Tale of Woe" + Then I should see "A Bad, Bad Day" + When I post the work "A Bad, Bad Night" as part of a series "Tale of Woe" + Then I should see "Part 2 of Tale of Woe" + When I post the work "Things Get Worse" as part of a series "Tale of Woe" + Then I should see "Part 3 of Tale of Woe" + When I view the series "Tale of Woe" + And I follow "Reorder Series" + Then I should see "Manage Series: Tale of Woe" + And I should see "1. A Bad, Bad Day" + And I should see "2. A Bad, Bad Night" + And I should see "3. Things Get Worse" + When I fill in "serial_0" with "3" + And I fill in "serial_1" with "1" + And I fill in "serial_2" with "2" + And I press "Update Positions" + Then I should see "Series order has been successfully updated" + When I follow "Reorder Series" + And I should see "1. A Bad, Bad Night" + And I should see "2. Things Get Worse" + And I should see "3. A Bad, Bad Day" + + @javascript + Scenario: Reordering series by drag and drop updates work blurbs and meta correctly. + Given I am logged in as "author" + And I post the work "A Bad, Bad Day" as part of a series "Tale of Woe" + And I post the work "A Bad, Bad Night" as part of a series "Tale of Woe" + And I post the work "Things Get Worse" as part of a series "Tale of Woe" + # Blurbs + When I view the series "Tale of Woe" + Then I should see "Part 1 of Tale of Woe" within ".work.blurb:first-child" + Then I should see "Part 2 of Tale of Woe" within ".work.blurb:nth-child(2)" + Then I should see "Part 3 of Tale of Woe" within ".work.blurb:nth-child(3)" + # Meta + When I view the work "A Bad, Bad Day" + Then I should see "Part 1 of Tale of Woe" + When I view the work "A Bad, Bad Night" + Then I should see "Part 2 of Tale of Woe" + When I view the work "Things Get Worse" + When I view the series "Tale of Woe" + And I follow "Reorder Series" + And I reorder the 2nd work to be below the 3rd work in the series + And I press "Update Positions" + Then I should see "Series order has been successfully updated" + # Blurbs + And I should see "Part 1 of Tale of Woe" within ".work.blurb:first-child" + And I should see "Part 2 of Tale of Woe" within ".work.blurb:nth-child(2)" + And I should see "Part 3 of Tale of Woe" within ".work.blurb:nth-child(3)" + When I follow "Reorder Series" + Then I should see "1. A Bad, Bad Day" + And I should see "2. Things Get Worse" + And I should see "3. A Bad, Bad Night" + # Meta + When I view the work "A Bad, Bad Day" + When I view the work "A Bad, Bad Day" + Then I should see "Part 1 of Tale of Woe" + When I view the work "Things Get Worse" + Then I should see "Part 2 of Tale of Woe" + When I view the work "A Bad, Bad Night" + Then I should see "Part 3 of Tale of Woe" diff --git a/features/other_b/skin.feature b/features/other_b/skin.feature new file mode 100755 index 0000000..cc5893a --- /dev/null +++ b/features/other_b/skin.feature @@ -0,0 +1,352 @@ +@set-default-skin +Feature: Non-public site and work skins + + Scenario: A user should be able to create a skin with CSS + Given I am logged in as "skinner" + And the app name is "Example Archive" + When I am on the new skin page + And I fill in "Title" with "my blinking & skin" + And I fill in "CSS" with "#title { text-decoration: blink;}" + And I submit + Then I should see "Skin was successfully created" + And I should see "my blinking & skin skin by skinner" + And I should see "text-decoration: blink;" + And I should see "(No Description Provided)" + And I should see "by skinner" + But I should see a button with text "Use" + And I should see "Delete" + And I should see "Edit" + And I should not see "Stop Using" + And I should not see "(Approved)" + And I should not see "(Not yet reviewed)" + And I should see the page title "my blinking & skin | Example Archive" + + Scenario: A logged-out user should not be able to create skins. + Given I am a visitor + When I go to the new skin page + Then I should see "Sorry, you don't have permission" + + Scenario: A user should be able to select one of their own non-public skins to use in + their preferences + Given I am logged in as "skinner" + And I create the skin "my blinking skin" with css "#title { text-decoration: blink;}" + When I am on skinner's preferences page + And I select "my blinking skin" from "preference_skin_id" + And I submit + Then I should see "Your preferences were successfully updated." + And I should see "#title {" in the page style + And I should see "text-decoration: blink;" in the page style + + Scenario: A user should be able to select one of their own non-public skins to use in + their My Skins page + Given I am logged in as "skinner" + And I create the skin "my blinking skin" with css "#title { text-decoration: blink;}" + Then I should see "my blinking skin" + When I press "Use" + Then I should see "#title {" in the page style + And I should see "text-decoration: blink;" in the page style + + Scenario: Skin titles should be unique + Given I am logged in as "skinner" + When I am on the new skin page + And I fill in "Title" with "Default" + And I submit + Then I should see "must be unique" + + Scenario: The user who creates a skin should be able to edit it + Given I am logged in as "skinner" + And I create the skin "my skin" + When I follow "Edit" + And I fill in "CSS" with "#greeting { text-decoration: blink;}" + And I submit + Then I should see an update confirmation message + + Scenario: Users should be able to create and use a work skin + Given I am logged in as "skinner" + And the default ratings exist + When I am on the new skin page + And I select "Work Skin" from "skin_type" + And I fill in "Title" with "Awesome Work Skin" + And I fill in "Description" with "Great work skin" + And I fill in "CSS" with "p {color: purple;}" + And I submit + Then I should see "Skin was successfully created" + And I should see "#workskin p" + When I go to the new work page + Then I should see "Awesome Work Skin" + When I set up the draft "Story With Awesome Skin" + And I select "Awesome Work Skin" from "work_work_skin_id" + And I press "Preview" + Then I should see "Preview" + And I should see "color: purple" in the page style + When I press "Post" + Then I should see "Story With Awesome Skin" + And I should see "color: purple" in the page style + And I should see "Hide Creator's Style" + When I follow "Hide Creator's Style" + Then I should see "Story With Awesome Skin" + And I should not see "color: purple" + And I should not see "Hide Creator's Style" + And I should see "Show Creator's Style" + Then the cache of the skin on "Awesome Work Skin" should expire after I save the skin + + Scenario: log out from my skins page (Issue 2271) + Given I am logged in as "skinner" + And I am on skinner's user page + When I follow "Skins" + And I log out + Then I should be on the login page + + Scenario: Create a complex replacement skin using Archive skin components + Given I have loaded site skins + And I am logged in as "skinner" + When I set up the skin "Complex" + And I select "replace archive skin entirely" from "What it does:" + And I check "Load Archive Skin Components" + And I submit + Then I should see a create confirmation message + And I should see "We've added all the archive skin components as parents. You probably want to remove some of them now!" + When I check "Load Archive Skin Components" + And I submit + Then I should see errors + + Scenario: Vendor-prefixed properties should be allowed + Given basic skins + And I am logged in as "skinner" + When I am on the new skin page + And I fill in "Title" with "skin with prefixed property" + And I fill in "CSS" with ".myclass { -moz-box-sizing: border-box; -webkit-transition: opacity 2s; }" + And I submit + Then I should see "Skin was successfully created" + Then the cache of the skin on "skin with prefixed property" should expire after I save the skin + + Scenario: #workskin selector prefixing + Given basic skins + And I am logged in as "skinner" + When I am on the new skin page + And I select "Work Skin" from "skin_type" + And I fill in "Title" with "#worksin prefixing" + And I fill in "CSS" with "#workskin, #workskin a, #workskin:hover, #workskin *, .prefixme, .prefixme:hover, * .prefixme { color: red; }" + And I submit + Then I should not see "#workskin #workskin," + And I should not see "#workskin #workskin a" + And I should see ", #workskin a," + And I should not see "#workskin #workskin:hover" + And I should see "#workskin .prefixme," + And I should see "#workskin .prefixme:hover" + And I should see "#workskin * .prefixme" + + Scenario: New skin form should have the correct skin type pre-selected + Given I am logged in as "skinner" + When I am on the skins page + And I follow "Create Site Skin" + Then "Site Skin" should be selected within "skin_type" + When I am on the skins page + And I follow "My Work Skins" + And I follow "Create Work Skin" + Then "Work Skin" should be selected within "skin_type" + + Scenario: Skin type should persist and remain selectable if you encounter errors + during creation + Given I am logged in as "skinner" + When I am on the skins page + And I follow "My Work Skins" + And I follow "Create Work Skin" + And I fill in "Title" with "invalid skin" + And I fill in "CSS" with "this is invalid css" + And I submit + Then I should see errors + And "Work Skin" should be selected within "skin_type" + When I select "Site Skin" from "skin_type" + And I fill in "CSS" with "still invalid css" + And I submit + Then I should see errors + And "Site Skin" should be selected within "skin_type" + + Scenario: View toggle buttons on skins (Issue 3197) + Given basic skins + And I am logged in as "skinner" + When I am on skinner's skins page + Then I should see "My Site Skins" + And I should see "My Work Skins" + And I should see "Public Site Skins" + And I should see "Public Work Skins" + + Scenario: Toggle between user's work skins and site skins + Given basic skins + And I am logged in as "skinner" + And I am on skinner's skins page + When I follow "My Work Skins" + Then I should see "My Work Skins" + When I follow "My Site Skins" + Then I should see "My Site Skins" + + Scenario: The cache should be flushed with a parent and not when unrelated + Given I have loaded site skins + And I am logged in as "skinner" + And I have a skin "Child" with a parent "Parent" + When I am on the new skin page + And I fill in "Title" with "Unrelated" + And I fill in "CSS" with "#title { text-decoration: blink;}" + And I submit + Then I should see "Skin was successfully created" + And the cache of the skin on "Unrelated" should not expire after I save "Child" + And the cache of the skin on "Child" should expire after I save a parent skin + + Scenario: Users should be able to create skins using @media queries + Given I am logged in as "skinner" + And I set up the skin "Media Query Test Skin" + And I check "only screen and (max-width: 42em)" + And I check "only screen and (max-width: 62em)" + When I press "Submit" + Then I should see a create confirmation message + And I should see "only screen and (max-width: 42em), only screen and (max-width: 62em)" + When I press "Use" + Then the page should have a skin with the media query "only screen and (max-width: 42em), only screen and (max-width: 62em)" + + Scenario: A user should be able to delete a skin + Given I am logged in as "skinner" + And I create the skin "Ugly Skin" + When I go to "Ugly Skin" skin page + And I follow "Delete" + And I press "Yes, Delete Skin" + Then I should see "The skin was deleted." + And I should be on skinner's skins page + And I should not see "Ugly Skin" + + Scenario: A user's skin should be reset to the default if they delete the skin they + are using + Given I am logged in as "skinner" + And I create the skin "Ugly Skin" + And I change my skin to "Ugly Skin" + When I go to skinner's skins page + And I follow "Delete" + And I press "Yes, Delete Skin" + Then I should see "The skin was deleted." + When I go to skinner's preferences page + Then "Default" should be selected within "preference_skin_id" + + Scenario: A user can't make a skin with "Archive" in the title + Given I am logged in as "skinner" + And I set up the skin "My Archive Skin" with some css + And I press "Submit" + Then I should see "Sorry, titles including the word 'Archive' are reserved for official skins." + + Scenario: A user can't look at another user's skins + Given the user "scully" exists and is activated + And I am logged in as "skinner" + When I go to scully's skins page + Then I should see "You can only browse your own skins and approved public skins." + And I should be on the public skins page + + Scenario: A user can't use fixed positioning in a work skin + Given I am logged in as "skinner" + When I set up the skin "Work Skin" with css ".selector {position: fixed; top: 0;}" + And I select "Work Skin" from "Type" + And I submit + Then I should see "The position property in .selector cannot have the value fixed in work skins, sorry!" + + Scenario: User should be able to access their site and work skins from an + individual skin's show page + Given I am logged in as "skinner" + And I create the skin "my skin" + When I view the skin "my skin" + Then I should see "My Site Skins" + And I should see "My Work Skins" + + Scenario: User should be able to revert to the default skin from an individual + skin's show page + Given basic skins + And I am logged in as "skinner" + And I create the skin "my skin" + When I view the skin "my skin" + Then I should not see a "Revert to Default Skin" button + When I press "Use" + And I view the skin "my skin" + Then I should see a "Revert to Default Skin" button + And I should see "My Work Skins" + + Scenario: User should be able to access their site and work skins from an + individual skin's edit page + Given I am logged in as "skinner" + And I create the skin "my skin" + When I edit the skin "my skin" + Then I should see "My Site Skins" + And I should see "My Work Skins" + + Scenario: User should be able to revert to the default skin from an individual + skin's edit page + Given basic skins + And I am logged in as "skinner" + And I create the skin "my skin" + When I edit the skin "my skin" + Then I should not see a "Revert to Default Skin" button + When I change my skin to "my skin" + And I edit the skin "my skin" + Then I should see a "Revert to Default Skin" button + + Scenario: When a cached skin is the child of a cached skin, and the parent is updated, the child reflects the changes to the parent + Given I am logged in as "skin_maker" + And I have a skin "Child Skin" with a parent "Parent Skin" + And the skin "Child Skin" is cached + And the skin "Parent Skin" is cached + And I change my skin to "Child Skin" + # Only admins can edit cached skins: + When I am logged in as a "superadmin" admin + And I edit the skin "Parent Skin" + And I fill in "CSS" with "body { background: cyan; }" + And I press "Update" + Then the filesystem cache of the skin "Child Skin" should include "background: cyan;" + When I am logged in as "skin_maker" + Then the page should have the cached skin "Child Skin" + + Scenario: When a cached skin is the child of an uncached skin, and the parent is updated, the child reflects the changes to the parent + Given I am logged in as "skin_maker" + And I have a skin "Child Skin" with a parent "Parent Skin" + And the skin "Child Skin" is cached + And I change my skin to "Child Skin" + When I edit the skin "Parent Skin" + And I fill in "CSS" with "body { background: cyan; }" + And I press "Update" + Then the filesystem cache of the skin "Child Skin" should include "background: cyan;" + And the page should have the cached skin "Child Skin" + + Scenario: When an uncached skin is the child of a cached skin, and the parent is updated, the child reflects the changes to the parent + Given I am logged in as "skin_maker" + And I have a skin "Child Skin" with a parent "Parent Skin" + And the skin "Parent Skin" is cached + And I change my skin to "Child Skin" + # Only admins can edit cached skins: + When I am logged in as a "superadmin" admin + And I edit the skin "Parent Skin" + And I fill in "CSS" with "body { background: cyan; }" + And I press "Update" + Then the filesystem cache of the skin "Parent Skin" should include "background: cyan;" + When I am logged in as "skin_maker" + Then the page should have the cached skin "Parent Skin" + + Scenario: When an uncached skin is the child of an uncached skin, and the parent is updated, the child reflects the changes to the parent + Given I am logged in as "skin_maker" + And I have a skin "Child Skin" with a parent "Parent Skin" + And I change my skin to "Child Skin" + When I edit the skin "Parent Skin" + And I fill in "CSS" with "body { background: cyan; }" + And I press "Update" + Then I should see "background: cyan;" + + @javascript + Scenario: User can add a parent skin using the Custom CSS form + Given the skin "Dad" by "skinner" + And I am logged in as "skinner" + When I go to the new skin page + Then I should see "Advanced" + When I follow "Show ↓" + Then I should see "Parent Skins" + When I fill in "Title" with "Child" + And I follow "Add parent skin" + And it is currently 1 second from now + Then I should see a parent skin text field + When I enter "Dad" in the "skin_skin_parents_attributes_1_parent_skin_title_autocomplete" autocomplete field + And I press "Submit" + Then I should see "Parent Skins" + And I should see "Dad" diff --git a/features/other_b/skin_public.feature b/features/other_b/skin_public.feature new file mode 100644 index 0000000..92f6b6f --- /dev/null +++ b/features/other_b/skin_public.feature @@ -0,0 +1,126 @@ +@set-default-skin +Feature: Public skins + + Scenario: A user's initial skin should be set to default + Given basic skins + And I am logged in as "skinner" + When I am on skinner's preferences page + Then "Default" should be selected within "preference_skin_id" + + Scenario: User can set a skin for a session and then unset it + Given basic skins + And the approved public skin "public skin" with css "#title { text-decoration: blink;}" + And the skin "public skin" is cached + And the skin "public skin" is in the chooser + When I am logged in as "skinner" + And I follow "public skin" + Then I should see "The skin public skin has been set. This will last for your current session." + And the page should have the cached skin "public skin" + When I follow "Default" + Then I should see "You are now using the default Archive skin again!" + And the page should not have the cached skin "public skin" + + Scenario: A user can't set an uncached public skin for a session + Given the approved public skin "Uncached Public Skin" + And I am logged in as "skinner" + When I set the skin "Uncached Public Skin" for this session + Then I should see "Sorry, but only certain skins can be used this way (for performance reasons). Please drop a support request if you'd like Uncached Public Skin to be added!" + + Scenario: Only public skins should be on the main skins page + Given basic skins + And I am logged in as "skinner" + And I create the skin "my skin" + When I am on the skins page + Then I should not see "my skin" + And I should see "Default" + + Scenario: Newly created public skins should not appear on the main skins page until + approved and should be marked as not-yet-approved + Given I am logged in as "skinner" + And the unapproved public skin "public skin" + When I am on the skins page + Then I should not see "public skin" + When I am on skinner's skins page + Then I should see "public skin" + And I should see "(Not yet reviewed)" + And I should not see "(Approved)" + + Scenario: Public skins should not be viewable by users until approved + Given the unapproved public skin "public skin" + And I log out + When I go to "public skin" skin page + Then I should see "Sorry, you don't have permission" + When I go to "public skin" edit skin page + Then I should see "Sorry, you don't have permission" + When I go to admin's skins page + Then I should see "I'm sorry, only an admin can" + + Scenario: Users should not be able to edit their public approved skins + Given the approved public skin "public skin" + And I am logged in as "skinner" + When I go to "public skin" edit skin page + Then I should see "Sorry, you don't have permission" + When I am on the skins page + Then I should see "public skin" + When I follow "Site Skins" + Then I should see "public skin" + And I should see "(Approved)" + And I should not see "Edit" + + Scenario: Users should be able to use public approved skins created by others + Given the approved public skin "public skin" with css "#title { text-decoration: blink;}" + And I am logged in as "skinuser" + And I am on skinuser's preferences page + When I select "public skin" from "preference_skin_id" + And I submit + Then I should see "Your preferences were successfully updated." + When I am on skinuser's preferences page + And "public skin" should be selected within "preference_skin_id" + And I should see "#title {" in the page style + And I should see "text-decoration: blink;" in the page style + + Scenario: Toggle between public site skins and public work skins + Given I am logged in as "skinner" + And I am on skinner's skins page + When I follow "Public Work Skins" + Then I should see "Public Work Skins" within "h2" + When I follow "Public Site Skins" + Then I should see "Public Site Skins" within "h2" + + Scenario: Reverting to default skin when a custom skin is selected + Given the approved public skin "public skin" with css "#title { text-decoration: blink;}" + And I am logged in as "skinner" + And I am on skinner's preferences page + And I select "public skin" from "preference_skin_id" + And I submit + When I am on skinner's preferences page + Then "public skin" should be selected within "preference_skin_id" + When I go to skinner's skins page + And I press "Revert to Default Skin" + When I am on skinner's preferences page + Then "Default" should be selected within "preference_skin_id" + + Scenario: A logged out user only sees cached skins on the public skins page + Given the approved public skin "Uncached skin" + And the approved public skin "Cached skin" + And the skin "Cached skin" is cached + When I go to the public skins page + Then I should see "Cached skin" + And I should not see "Uncached skin" + + Scenario: A user can preview a cached public site skin, and it will take the + user to the works page for a canonical tag with between 10 and 20 works + Given the approved public skin "Usable Skin" + And the skin "Usable Skin" is cached + And the canonical fandom "Dallas" with 2 works + And the canonical fandom "Major Crimes" with 11 works + And the canonical fandom "Rizzoli and Isles" with 21 works + And I am logged in as "skinner" + When I go to the public skins page + And I follow "Preview" + Then I should be on the works tagged "Major Crimes" + And I should see "You are previewing the skin Usable Skin. This is a randomly chosen page." + And I should see "Go back or click any link to remove the skin" + And I should see "Tip: You can preview any archive page you want by tacking on '?site_skin=[skin_id]' like you can see in the url above." + When I follow "Return To Skin To Use" + Then I should be on "Usable Skin" skin page diff --git a/features/other_b/skin_wizard.feature b/features/other_b/skin_wizard.feature new file mode 100644 index 0000000..d6ce926 --- /dev/null +++ b/features/other_b/skin_wizard.feature @@ -0,0 +1,105 @@ +@set-default-skin +Feature: Skin wizard + + Scenario: User should be able to toggle between the wizard and the form + Given I am logged in + When I go to the new skin page + Then I should see "CSS" within "form#new_skin" + When I follow "Use Wizard" + Then I should see "Site Skin Wizard" + And I should not see "CSS" within "form" + When I follow "Write Custom CSS" + Then I should see "Create New Skin" + And I should see "CSS" + + @javascript + Scenario: User can add a parent skin using the wizard + Given the skin "Dad" by "mage" + And I am logged in as "mage" + When I go to the new skin page + And I follow "Use Wizard" + Then I should see "Site Skin Wizard" + And I should see "Parent Skins" + When I fill in "Title" with "Child" + And I follow "Add parent skin" + And it is currently 1 second from now + Then I should see a parent skin text field + When I enter "Dad" in the "skin_skin_parents_attributes_1_parent_skin_title_autocomplete" autocomplete field + And I press "Submit" + Then I should see "Parent Skins" + And I should see "Dad" + + Scenario: Users should be able to create and use a wizard skin to adjust work margins, + and they should be able to edit the skin while they are using it + Given I am logged in as "skinner" + And I am on the new wizard skin page + When I fill in "Title" with "Wide margins" + And I fill in "Description" with "Layout skin" + And I fill in "Work margin width" with "text" + And I submit + Then I should see a save error message + And I should see "Margin is not a number" + When I fill in "Work margin width" with "5" + And I submit + Then I should see "Skin was successfully created" + And I should see "Work margin width: 5%" + When I am on skinner's preferences page + And I select "Wide margins" from "preference_skin_id" + And I submit + Then I should see "Your preferences were successfully updated." + And I should see "margin: auto 5%; max-width: 100%" in the page style + # Make sure that the creation/update cache keys are different: + And I wait 1 second + When I edit the skin "Wide margins" with the wizard + And I fill in "Work margin width" with "4.5" + And I submit + # TODO: Think about whether rounding to 4 is actually the right behaviour or not + Then I should see an update confirmation message + And I should see "Work margin width: 4%" + And I should not see "Work margin width: 4.5%" + And I should see "margin: auto 4%;" in the page style + When I am on skinner's preferences page + Then "Wide margins" should be selected within "preference_skin_id" + + Scenario: Users should be able to create and use a wizard skin with multiple wizard + settings + Given I am logged in as "skinner" + And I am on the new wizard skin page + When I fill in "Title" with "Many changes" + And I fill in "Description" with "Layout skin" + And I fill in "Font" with "'Times New Roman', Garamond, serif" + And I fill in "Background color" with "#ccccff" + And I fill in "Text color" with "red" + And I fill in "Percent of browser font size" with "120" + And I fill in "Vertical gap between paragraphs" with "5" + And I submit + Then I should see "Skin was successfully created" + And I should see "Font: 'Times New Roman', Garamond, serif" + And I should see "Background color: #ccccff" + And I should see "Text color: red" + And I should see "Percent of browser font size: 120%" + And I should see "Vertical gap between paragraphs: 5.0em" + When I press "Use" + Then I should see "Your preferences were successfully updated." + And I should see "background: #ccccff;" in the page style + And I should see "color: red;" in the page style + And I should see "font-family: 'Times New Roman', Garamond, serif;" in the page style + And I should see "font-size: 120%;" in the page style + And I should see "margin: 5.0em auto;" in the page style + When I am on skinner's preferences page + Then "Many changes" should be selected within "preference_skin_id" + + Scenario: Users should be able to adjust their wizard skin by adding custom CSS + Given I am logged in as "skinner" + And I create and use a skin to make the header pink + # Make sure that the creation/update cache keys are different: + And I wait 1 second + When I edit my pink header skin to have a purple logo + Then I should see an update confirmation message + And I should see a pink header + And I should see a purple logo + + Scenario: Change the accent color + Given I am logged in as "skinner" + When I create and use a skin to change the accent color + Then I should see a different accent color diff --git a/features/other_b/stats.feature b/features/other_b/stats.feature new file mode 100644 index 0000000..579783c --- /dev/null +++ b/features/other_b/stats.feature @@ -0,0 +1,89 @@ +@stats +Feature: User statistics + In order to know more about my works + As a user + The statistics page needs to show me information about my works + + Scenario: A user with no works should see a message + Given I am logged in as "lurker" + When I go to lurker's stats page + Then I should see "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." + And I should see "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." + + Scenario: Show only posted works on stats page + + Given I am logged in as "NUMB3RSfan" + And I post the work "Don Solves Crime" + And I post the work "Don Solves More Crime" + And I set up the draft "Charlie Helps" + When I am logged in as "reader" + And I view the work "Don Solves Crime" + And I am logged in as "NUMB3RSfan" + And I go to NUMB3RSfan's stats page + Then "Don Solves Crime" should appear before "Don Solves More Crime" + And I should not see "Charlie Helps" + When I follow "Date" + Then "Don Solves More Crime" should appear before "Don Solves Crime" + When I follow "Date" + Then "Don Solves Crime" should appear before "Don Solves More Crime" + + Scenario: Calculate word counts from chapter publication date + + Given I am logged in as "statistician" + And I set up the draft "Multiyear Fic" + And I fill in "content" with "Three words long." + And I set the publication date to 3 March 2023 + And I press "Post" + And I follow "Add Chapter" + And I fill in "content" with "Oh look, four words!" + And I set the publication date to 4 April 2024 + And I press "Post" + When I go to statistician's stats page + Then I should see a link "2023" + And I should see a link "2024" + And I should see "Multiyear Fic (7 words)" + And I should see "Word Count: 7" + When I follow "2023" + Then I should see "Multiyear Fic (3 words)" + And I should see "Word Count: 3" + When I follow "2024" + Then I should see "Multiyear Fic (4 words)" + And I should see "Word Count: 4" + + Scenario: Sort works by chapter publication within year + + Given I am logged in as "statistician" + And I set up the draft "New-Year Celebration" + And I set the publication date to 1 January 2023 + And I press "Post" + And I follow "Add Chapter" + And I set the publication date to 1 January 2024 + And I press "Post" + And I set up the draft "Year-End Party" + And I set the publication date to 9 December 2023 + And I press "Post" + And I set up the draft "Midyear Madness" + And I set the publication date to 2 July 2023 + And I press "Post" + When I go to statistician's stats page + And I follow "2023" + And I follow "Flat View" + And I follow "Date" + Then "New-Year Celebration" should appear before "Midyear Madness" + And "Midyear Madness" should appear before "Year-End Party" + + Scenario: Multifandom works once per fandom in Fandoms View + + Given I am logged in as "statistician" + And I set up the draft "Fandom Alphabet" + And I fill in "Fandoms" with "Fandom A, Fandom B, Fandom C" + And I set the publication date to 1 May 2025 + And I press "Post" + When I go to statistician's stats page + Then I should see "Fandom A" within ".fandom.listbox.group[1]" + And I should see "Fandom B" within ".fandom.listbox.group[2]" + And I should see "Fandom C" within ".fandom.listbox.group[3]" + When I follow "2025" + Then I should see "Fandom A" within ".fandom.listbox.group[1]" + And I should see "Fandom B" within ".fandom.listbox.group[2]" + And I should see "Fandom C" within ".fandom.listbox.group[3]" diff --git a/features/other_b/subscriptions.feature b/features/other_b/subscriptions.feature new file mode 100644 index 0000000..89bfb3c --- /dev/null +++ b/features/other_b/subscriptions.feature @@ -0,0 +1,370 @@ + Feature: Subscriptions + In order to follow an author I like + As a reader + I want to subscribe to them + + Background: + Given the following activated users exist + | login | password | email | + | first_user | password | first_user@foo.com | + | second_user | password | second_user@foo.com | + | third_user | password | third_user@foo.com | + And all emails have been delivered + + Scenario: subscribe to an author + + When "second_user" subscribes to author "first_user" + And I am logged in as "first_user" + And I post the work "Awesome Story" + # make sure no emails go out until notifications are sent + Then 0 emails should be delivered + When subscription notifications are sent + Then 1 email should be delivered to "second_user@foo.com" + And the email should contain "first_user" + And the email should contain "Awesome" + When all emails have been delivered + And I post the work "Yet Another Awesome Story" without preview + And subscription notifications are sent + Then 1 email should be delivered to "second_user@foo.com" + When all emails have been delivered + And a draft chapter is added to "Yet Another Awesome Story" + Then 0 emails should be delivered + When I post the draft chapter + Then 0 emails should be delivered + When subscription notifications are sent + Then 1 email should be delivered to "second_user@foo.com" + # This feels hackish to me (scott s), but I'm going with it for now. I'll investigate reworking our email steps for multipart emails once all our gems are up to date. + And the email should contain "first_user" + And the email should contain "posted" + And the email should contain "Chapter 2" + + Scenario: unsubscribe from an author + + When I am logged in as "second_user" + And I go to first_user's user page + And I press "Subscribe" + And I press "Unsubscribe" + Then I should see "successfully unsubscribed" + And I should be on first_user's user page + When I log out + And I am logged in as "first_user" + And I post the work "Awesome Story 2: The Sequel" + And subscription notifications are sent + Then 0 emails should be delivered + + Scenario: unsubscribe from the subscriptions page + + When I am logged in as "second_user" + And I go to first_user's user page + And I press "Subscribe" + When I go to the subscriptions page for "second_user" + And I press "Unsubscribe from first_user" + Then I should see "successfully unsubscribed" + And I should be on the subscriptions page for "second_user" + + Scenario: subscribe button on profile page + + When I am logged in as "second_user" + And I go to first_user's profile page + And I press "Subscribe" + Then I should see "You are now following first_user. If you'd like to stop receiving email updates, you can unsubscribe from your Subscriptions page." + When I press "Unsubscribe" + Then I should see "successfully unsubscribed" + + Scenario: subscribe to individual work + + When "second_user" subscribes to work "Awesome Story" + And a draft chapter is added to "Awesome Story" + Then 0 emails should be delivered + When I post the draft chapter + Then 0 emails should be delivered + When subscription notifications are sent + Then 1 email should be delivered to "second_user@foo.com" + And the email should contain "wip_author" + And the email should contain "posted" + And the email should contain "Chapter 2" + + Scenario: receive notification of a titled chapter + + When "second_user" subscribes to work "Cake Story" + And I am logged in as "wip_author" + And I view the work "Cake Story" + And I follow "Add Chapter" + And I fill in "Chapter Title" with "ICE CREAM CAKE" + And I fill in "content" with "meltiiiinnngg" + And I press "Post" + And subscription notifications are sent + Then 1 email should be delivered to "second_user@foo.com" + And the email should contain "wip_author" + And the email should contain "posted" + And the email should not contain "Chapter ICE CREAM CAKE" + And the email should contain "Chapter 2: ICE CREAM CAKE" + + Scenario: subscribe to series + + When "second_user" subscribes to series "Awesome Series" + And I am logged in as "series_author" + And I set up the draft "Second Work" + And I check "series-options-show" + And I select "Awesome Series" from "work_series_attributes_id" + And I press "Post" + Then 0 emails should be delivered + When subscription notifications are sent + Then 1 email should be delivered to "second_user@foo.com" + And the email should contain "posted a" + And the email should contain "new work" + + Scenario: batched subscription notifications + + When "second_user" subscribes to author "first_user" + And I am logged in as "first_user" + And I post the work "The First Awesome Story" + # make sure no emails go out until notifications are sent + Then 0 emails should be delivered + When I post the work "Another Awesome Story" + And I post the work "A Third Awesome Story" + And I post the work "A FOURTH Awesome Story" + Then 0 emails should be delivered + When subscription notifications are sent + Then 1 email should be delivered to "second_user@foo.com" + And the email should contain "The First" + And the email should contain "Another" + And the email should contain "A Third" + And the email should contain "A FOURTH" + + Scenario: different types of subscriptions are listed separately on a user's subscription page + + When I am logged in as "second_user" + And "second_user" subscribes to author "third_user" + And "second_user" subscribes to work "Awesome Story" + And "second_user" subscribes to series "Awesome Series" + When I go to the subscriptions page for "second_user" + Then I should see "My Subscriptions" + And I should see "Awesome Series (Series)" + And I should see a link "series_author" + And I should see "third_user" + And I should see "Awesome Story (Work)" + And I should see a link "wip_author" + When I follow "Series Subscriptions" + Then I should see "My Series Subscriptions" + And I should see "Awesome Series" + And I should not see "(Series)" + And I should not see "third_user" + And I should not see "Awesome Story" + When I follow "User Subscriptions" + Then I should see "My User Subscriptions" + And I should see "third_user" + And I should not see "Awesome Series" + And I should not see "Awesome Story" + When I follow "Work Subscriptions" + Then I should see "My Work Subscriptions" + And I should see "Awesome Story" + And I should not see "(Work)" + And I should not see "Awesome Series" + And I should not see "third_user" + + Scenario: Subscribe to a multi-chapter work should redirect you back to the chapter you were viewing + + When I am logged in as "first_user" + And I post the work "Multi Chapter Work" + And a chapter is added to "Multi Chapter Work" + When I am logged in as "second_user" + And I view the work "Multi Chapter Work" + And I view the 2nd chapter + When I press "Subscribe" + Then the page title should include "Chapter 2" + + # There are tests in collections/collection_anonymity.feature that ensure + # a creator's subscribers are not notified when the creator posts a new + # anonymous work. + Scenario: When a chapter is added to an anonymous work, subscription emails + are sent to users who have subscribed to the work, but not to users who have + subscribed to the creator. + + Given the anonymous collection "anonymous_collection" + And I am logged in as "creator" + And I post the work "Multi Chapter Work" to the collection "anonymous_collection" + And "author_subscriber" subscribes to author "creator" + And "work_subscriber" subscribes to work "Multi Chapter Work" + When a chapter is added to "Multi Chapter Work" + And subscription notifications are sent + Then "author_subscriber" should not be emailed + But "work_subscriber" should be emailed + And the email should have "Anonymous posted Chapter 2 of Multi Chapter Work" in the subject + And the email should contain "Multi Chapter Work" + And the email should contain "Anonymous" + And the email should not contain "creator" + + Scenario: When a chapter is added to an anonymous work in an anonymous series, + subscription emails are sent to users who have subscribed to the work or + series, but not to users who have subscribed to the creator. + + Given the anonymous collection "anonymous_collection" + And I am logged in as "creator" + And I post the work "Multi Chapter Work" to the collection "anonymous_collection" as part of a series "Multi Work Series" + And "author_subscriber" subscribes to author "creator" + And "work_subscriber" subscribes to work "Multi Chapter Work" + And "series_subscriber" subscribes to series "Multi Work Series" + When a chapter is added to "Multi Chapter Work" + And subscription notifications are sent + Then "author_subscriber" should not be emailed + But "work_subscriber" should be emailed + And the email should have "Anonymous posted Chapter 2 of Multi Chapter Work" in the subject + And the email should contain "Multi Chapter Work" + And the email should contain "Anonymous" + And the email should not contain "creator" + And "series_subscriber" should be emailed + And the email should have "Anonymous posted Chapter 2 of Multi Chapter Work in the Multi Work Series series" in the subject + And the email should contain "Multi Chapter Work" + And the email should contain "Anonymous" + And the email should not contain "creator" + + Scenario: When a new work is added to an anonymous series, subscription emails + are sent to users who are subscribed to the series, but not users who have + subscribed to the creator. + + Given the anonymous collection "anonymous_collection" + And I am logged in as "creator" + And I post the work "Multi Chapter Work" to the collection "anonymous_collection" as part of a series "Multi Work Series" + And "author_subscriber" subscribes to author "creator" + And "series_subscriber" subscribes to series "Multi Work Series" + When I am logged in as "creator" + And I post the work "Second Work" to the collection "anonymous_collection" as part of a series "Multi Work Series" + And subscription notifications are sent + Then "author_subscriber" should not be emailed + But "series_subscriber" should be emailed + And the email should have "Anonymous posted Second Work in the Multi Work Series series" in the subject + And the email should contain "Multi Work Series" + And the email should contain "Second Work" + And the email should contain "Anonymous" + And the email should not contain "creator" + + Scenario: When new chapter for a hidden work is posted, no subscription notifications are sent + + Given I am logged in as "violator" + And I post the work "TOS Violation" as part of a series "Dont Be So Series" + And "author_subscriber" subscribes to author "violator" + And "work_subscriber" subscribes to work "TOS Violation" + And "series_subscriber" subscribes to series "Dont Be So Series" + When I am logged in as a "policy_and_abuse" admin + And I hide the work "TOS Violation" + And a chapter is added to "TOS Violation" + And subscription notifications are sent + Then "author_subscriber" should not be emailed + And "work_subscriber" should not be emailed + And "series_subscriber" should not be emailed + When I am logged in as a "policy_and_abuse" admin + And I unhide the work "TOS Violation" + And subscription notifications are sent + Then "author_subscriber" should not be emailed + And "work_subscriber" should not be emailed + And "series_subscriber" should not be emailed + When a chapter is added to "TOS Violation" + And subscription notifications are sent + Then "author_subscriber" should be emailed + And "work_subscriber" should be emailed + And "series_subscriber" should be emailed + + Scenario: When a hidden work is unrevealed, no subscription notifications are sent + + Given I am logged in as "violator" + And I post the work "TOS Violation" as part of a series "Dont Be So Series" + And "author_subscriber" subscribes to author "violator" + And "work_subscriber" subscribes to work "TOS Violation" + And "series_subscriber" subscribes to series "Dont Be So Series" + And I have the hidden collection "Secret" + And I am logged in as "violator" + And I edit the work "TOS Violation" to be in the collection "Secret" + And I am logged in as a "policy_and_abuse" admin + And I hide the work "TOS Violation" + When I am logged in as "moderator" + And I go to "Secret" collection's page + And I follow "Collection Settings" + And I uncheck "This collection is unrevealed" + And I press "Update" + And subscription notifications are sent + Then "author_subscriber" should not be emailed + And "work_subscriber" should not be emailed + And "series_subscriber" should not be emailed + When I am logged in as a "policy_and_abuse" admin + And I unhide the work "TOS Violation" + And subscription notifications are sent + Then "author_subscriber" should not be emailed + And "work_subscriber" should not be emailed + And "series_subscriber" should not be emailed + + Scenario: subscribe to an individual work with an the & and < and > characters in the title + + Given the work "I am <strong>er Than Yesterday & Other Lies" by "testuser2" + When I am logged in as "subscriber" with password "password" + And I view the work "I am <strong>er Than Yesterday & Other Lies" + When I press "Subscribe" + Then I should see "You are now following I am <strong>er Than Yesterday & Other Lies. If you'd like to stop receiving email updates, you can unsubscribe from your Subscriptions page." + When I am logged in as "testuser2" with password "testuser2" + And a chapter is added to "I am <strong>er Than Yesterday & Other Lies" + When I view the work "I am <strong>er Than Yesterday & Other Lies" + When subscription notifications are sent + Then 1 email should be delivered to "subscriber" + When "The problem with ampersands and angle brackets in email bodies and subjects" is fixed + #And the email should have "I am <strong>er Than Yesterday & Other Lies" in the subject + #And the email should contain "I am <strong>er Than Yesterday & Other Lies" + When I am logged in as "subscriber" with password "password" + And I go to the subscriptions page for "subscriber" + And I press "Unsubscribe from I am <strong>er Than Yesterday & Other Lies" + Then I should see "You have successfully unsubscribed from I am <strong>er Than Yesterday & Other Lies" + +Scenario: delete all subscriptions + + When I am logged in as "second_user" + And "second_user" subscribes to author "third_user" + And "second_user" subscribes to work "Awesome Story" + And "second_user" subscribes to series "Awesome Series" + When I go to the subscriptions page for "second_user" + Then I should see "My Subscriptions" + And I should see "Awesome Series (Series)" + And I should see "third_user" + And I should see "Awesome Story (Work)" + When I follow "Delete All Subscriptions" + Then I should see "Are you sure you want to delete" + When I press "Yes, Delete All Subscriptions" + Then I should see "My Subscriptions" + And I should see "Your subscriptions have been deleted" + And I should not see "Awesome Series (Series)" + And I should not see "third_user" + And I should not see "Awesome Story (Work)" + +Scenario: delete all subscriptions of a specific type + + When I am logged in as "second_user" + And "second_user" subscribes to author "third_user" + And "second_user" subscribes to work "Awesome Story" + And "second_user" subscribes to series "Awesome Series" + When I go to the subscriptions page for "second_user" + Then I should see "My Subscriptions" + And I should see "Awesome Series (Series)" + And I should see "third_user" + And I should see "Awesome Story (Work)" + When I follow "Work Subscriptions" + Then I should see "My Work Subscriptions" + When I follow "Delete All Work Subscriptions" + Then I should see "Delete All Work Subscriptions" + And I should see "Are you sure you want to delete" + When I press "Yes, Delete All Work Subscriptions" + Then I should see "Your subscriptions have been deleted" + When I go to the subscriptions page for "second_user" + Then I should see "Awesome Series (Series)" + And I should see "third_user" + But I should not see "Awesome Story (Work)" + +Scenario: subscriptions are not deleted without confirmation + + When I am logged in as "second_user" + And "second_user" subscribes to work "Awesome Story" + When I go to the subscriptions page for "second_user" + Then I should see "My Subscriptions" + And I should see "Awesome Story (Work)" + When I follow "Delete All Subscriptions" + Then I should see "Are you sure you want to delete" + When I go to the subscriptions page for "second_user" + Then I should see "My Subscriptions" + And I should see "Awesome Story (Work)" diff --git a/features/other_b/subscriptions_fandoms.feature b/features/other_b/subscriptions_fandoms.feature new file mode 100644 index 0000000..a05479f --- /dev/null +++ b/features/other_b/subscriptions_fandoms.feature @@ -0,0 +1,83 @@ +Feature: Subscriptions + In order to follow a fandom I like + As a reader + I want to subscribe to it + + Scenario: Subscribe to a test fandom when there are no works in it + + When I am logged in as "author" + And I post a work "My Work Title" with category "F/M" + When I am logged in as "reader" + And I view the "F/F" works index + Then I should see "RSS Feed" + When I follow "RSS Feed" + Then I should not see "My Work Title" + And I should not see "Stargate SG-1" + + Scenario: Subscribe to a test fandom when there are works in it + + When I am logged in as "author" + And I post a work "My Work Title" with category "F/F" + When I am logged in as "reader" + And I view the "F/F" works index + Then I should see "RSS Feed" + When I follow "RSS Feed" + Then I should see "My Work Title" + And I should see "Stargate SG-1" + + Scenario: Subscribe to a non-test fandom + + When I am logged in as "author" + And I post a work "My Work Title" with category "Multi" + When I am logged in as "reader" + And I view the "Multi" works index + Then I should not see "RSS Feed" + + Scenario: Mystery work is not shown in feed + + Given basic tags + And I am logged in as "myname2" + Given I have a hidden collection "Hidden Treasury" with name "hidden_treasury" + When I am logged in as "myname1" + And I post the work "Old Snippet" + And I edit the work "Old Snippet" + And I fill in "Post to Collections / Challenges" with "hidden_treasury" + And I check "F/F" + And I press "Post" + Then I should see "This work is part of an ongoing challenge and will be revealed soon! You can find details here: Hidden Treasury" + When I am logged in as "author" + And I post a work "My Work Title" with category "F/F" + When I view the "F/F" works index + When I follow "RSS Feed" + Then I should not see "Old Snippet" + And I should not see "myname1" + And I should see "author" + + Scenario: Author of anonymous work is not shown in feed + + Given basic tags + And I am logged in as "myname2" + Given I have an anonymous collection "Hidden Treasury" with name "hidden_treasury" + When I am logged in as "myname1" + And I post the work "Old Snippet" + And I edit the work "Old Snippet" + And I fill in "Post to Collections / Challenges" with "hidden_treasury" + And I check "F/F" + And I press "Post" + And all indexing jobs have been run + Then I should see "Anonymous" + And I should see "Collections: Hidden Treasury" + When I am logged in as "author" + And I post a work "My Work Title" with category "F/F" + When I view the "F/F" works index + When I follow "RSS Feed" + Then I should see "Old Snippet" + And I should not see "myname1" + And I should see "author" + + Scenario: A user can see a feed for non canonical tags + + Given I am logged in as "author" + And I post the work "Glorious" with fandom "SGA" + When I view the "SGA" works feed + Then I should see "Glorious" diff --git a/features/other_b/support.feature b/features/other_b/support.feature new file mode 100644 index 0000000..4f03c15 --- /dev/null +++ b/features/other_b/support.feature @@ -0,0 +1,87 @@ +Feature: Filing a support request + In order to get help + As a confused user + I want to file a support request + + Scenario: Filing a support request + + Given I am logged in as "puzzled" + And basic languages + When time is frozen at 14/3/2022 + When I follow "Support & Feedback" + When I select "Deutsch" from "feedback_language" + And I fill in "Brief summary" with "Just a brief note" + And I fill in "Your question or problem" with "Men have their old boys' network, but we have the OTW. You guys rock!" + And all emails have been delivered + And I press "Send" + Then I should see "Your message was sent to the Archive team - thank you!" + And 1 email should be delivered + And the email should contain "working hard to reply to everyone" + And the email should contain "respond to you as soon as we can." + And the email should contain "If you have additional questions or information" + And the email should contain "Sent at Mon, 14 Mar 2022 12:00:00 \+0000" + When I follow "Support & Feedback" + And I fill in "Brief summary" with "you suck" + And I fill in "Your question or problem" with "blah blah blah" + And I fill in "Your email (required)" with "test@archiveofourown.org" + And I select "Deutsch" from "feedback_language" + And all emails have been delivered + And I press "Send" + Then I should see "Your message was sent to the Archive team - thank you!" + And 1 email should be delivered + + Scenario: Not logged in, with and without email + + When I am on the home page + And basic languages + And I follow "Support & Feedback" + When I select "Deutsch" from "feedback_language" + And I fill in "Brief summary" with "Just a brief note" + And I fill in "Your question or problem" with "Men have their old boys' network, but we have the OTW. You guys rock!" + And I fill in "Your email (required)" with "" + And all emails have been delivered + And I press "Send" + Then I should see "Email should look like an email address." + And "Deutsch" should be selected within "Select language (required)" + And I fill in "Your email (required)" with "test@archiveofourown.org" + And I press "Send" + Then I should see "Your message was sent to the Archive team - thank you!" + And 1 email should be delivered + + Scenario: Submit a request containing an image + + Given I am logged in as "puzzled" + And basic languages + When I follow "Support & Feedback" + And I fill in "Brief summary" with "Just a brief note" + And I fill in "Your question or problem" with '<img src="foo.jpg" />Hi' + And I press "Send" + Then 1 email should be delivered + # The sanitizer adds the domain in front of relative image URLs as of AO3-6571 + And the email should not contain "<img src="http://www.example.org/foo.jpg" />" + But the email should contain "img src="http://www.example.org/foo.jpg"Hi" + + Scenario: Submit a request with an on-Archive referer + + Given I am logged in as "puzzled" + And basic languages + And Zoho ticket creation is enabled + And "www.example.com" is a permitted Archive host + When I go to the works page + And I follow "Support & Feedback" + And I fill in "Brief summary" with "Just a brief note" + And I fill in "Your question or problem" with "Hi, I came from the Archive" + And I press "Send" + Then a Zoho ticket should be created with referer "http://www.example.com/works" + + Scenario: Submit a request with a referer that is not on-Archive + + Given I am logged in as "puzzled" + And basic languages + And Zoho ticket creation is enabled + When I go to the works page + And I follow "Support & Feedback" + And I fill in "Brief summary" with "Just a brief note" + And I fill in "Your question or problem" with "Hi, I didn't come from the Archive" + And I press "Send" + Then a Zoho ticket should be created with referer "" diff --git a/features/prompt_memes_a/challenge_promptmeme_setup.feature b/features/prompt_memes_a/challenge_promptmeme_setup.feature new file mode 100644 index 0000000..822778b --- /dev/null +++ b/features/prompt_memes_a/challenge_promptmeme_setup.feature @@ -0,0 +1,490 @@ +@collections @challenges @promptmemes +Feature: Prompt Meme Challenge + In order to have an archive full of works + As a humble user + I want to create a prompt meme and post to it + + Scenario: Can create a collection to house a prompt meme + + Given I have standard challenge tags setup + When I set up Battle 12 promptmeme collection + Then I should be editing the challenge settings + + Scenario: Creating a prompt meme has different instructions from a gift exchange + + Given I have standard challenge tags setup + When I set up Battle 12 promptmeme collection + Then I should see prompt meme options + + Scenario: Create a prompt meme + + Given I have standard challenge tags setup + When I create Battle 12 promptmeme + Then Battle 12 prompt meme should be correctly created + + Scenario: User can see a prompt meme + + Given I have Battle 12 prompt meme fully set up + And I am logged in as a random user + When I go to the collections page + Then I should see "Battle 12" + + Scenario: Prompt meme is in list of open challenges + + Given I have Battle 12 prompt meme fully set up + And I am logged in as a random user + When I view open challenges + Then I should see "Battle 12" + + Scenario: Prompt meme is also in list of open prompt meme challenges + + Given I have Battle 12 prompt meme fully set up + And I am logged in as a random user + When I view open challenges + And I follow "Prompt Meme Challenges" + Then I should see "Battle 12" + + Scenario: Past challenge is not in list of open challenges + + Given I am logged in as "mod1" + And I have standard challenge tags setup + When I set up Battle 12 promptmeme collection + And I fill in past challenge options + And I am logged in as "myname1" + When I view open challenges + Then I should not see "Battle 12" + + Scenario: Future challenge is not in list of open challenges + + Given I am logged in as "mod1" + And I have standard challenge tags setup + When I set up Battle 12 promptmeme collection + And I fill in future challenge options + And I am logged in as "myname1" + When I view open challenges + Then I should not see "Battle 12" + + Scenario: Can access settings from profile navigation + + Given I have Battle 12 prompt meme fully set up + When I go to "Battle 12" collection's page + And I follow "Profile" + Then I should see "Challenge Settings" within "div#dashboard" + When I follow "Challenge Settings" within "div#dashboard" + Then I should be editing the challenge settings + + Scenario: Can edit settings for a prompt meme + + Given I have Battle 12 prompt meme fully set up + And I am logged in as "mod1" + When I edit settings for "Battle 12" challenge + Then I should be editing the challenge settings + + Scenario: Entering a greater number for required prompts than allowed prompts + automatically increases the number of allowed promps + + Given I set up Battle 12 promptmeme collection + When I require 3 prompts + And I allow 2 prompts + And I press "Submit" + Then I should see a success message + When I edit settings for "Battle 12" challenge + Then 3 prompts should be required + And 3 prompts should be allowed + + Scenario: Sign-up being open is shown on profile + + Given I have Battle 12 prompt meme fully set up + And I am logged in as a random user + When I go to "Battle 12" collection's page + And I follow "Profile" + Then I should see "Sign-up: Open" + + Scenario: User can see profile descriptions + + Given I have Battle 12 prompt meme fully set up + And I am logged in as "myname1" + When I go to "Battle 12" collection's page + When I follow "Profile" + Then I should see Battle 12 descriptions + + Scenario: Sign up for a prompt meme + + Given I have Battle 12 prompt meme fully set up + And I am logged in as "myname1" + When I go to "Battle 12" collection's page + Then I should see "Sign Up" + When I sign up for Battle 12 with combination A + Then I should see "Sign-up was successfully created" + And I should see "Prompts (2)" + And I should see the whole signup + + Scenario: Sign up for a prompt meme and miss out some fields + + Given I have Battle 12 prompt meme fully set up + And I am logged in as "myname1" + When I sign up for "Battle 12" with missing prompts + Then I should see "Request: Your Request must include exactly 1 fandom tags, but you have included 0 fandom tags in your current Request" + When I fill in the missing prompt + Then I should see "Sign-up was successfully created" + + Scenario: Correct number of signups is shown in user sidebar + + Given I have Battle 12 prompt meme fully set up + And I am logged in as "myname1" + When I sign up for Battle 12 with combination A + When I am on myname1's user page + Then I should see "Sign-ups (1)" + + Scenario: View signups in the dashboard + + Given I have Battle 12 prompt meme fully set up + And I am logged in as "myname1" + When I sign up for Battle 12 with combination A + When I am on myname1's signups page + Then I should see "Battle 12" + + Scenario: Prompt count shows on profile + + Given I have Battle 12 prompt meme fully set up + And I am logged in as "myname1" + When I sign up for Battle 12 with combination A + When I go to "Battle 12" collection's page + And I follow "Profile" + Then I should see "Prompts: 2" + # TODO: Was the claimed prompts count intentionally removed from profile? + # And I should see "Claimed prompts: 0" + + Scenario: Prompt count shows on collections index + + Given I have Battle 12 prompt meme fully set up + And I am logged in as "myname1" + When I sign up for Battle 12 with combination A + When I go to the collections page + Then I should see "Prompts: 2" + + Scenario: Sign-ups in the dashboard have correct controls + + Given I have Battle 12 prompt meme fully set up + And I am logged in as "myname1" + When I sign up for Battle 12 with combination A + When I am on myname1's signups page + Then I should see "Edit" + And I should see "Delete" + + Scenario: Edit individual prompt + + Given I have Battle 12 prompt meme fully set up + And I am logged in as "myname1" + When I sign up for Battle 12 with combination A + When I view my signup for "Battle 12" + When I follow "Edit Prompt" + Then I should see single prompt editing + And I should see "Edit Sign-up" + When I uncheck "Stargate Atlantis" + And I press "Update" + Then I should see "Sorry! We couldn't save this request because:" + And I should see "Your Request must include exactly 1 fandom tags" + + Scenario: Add one new prompt to existing signup + + Given I have Battle 12 prompt meme fully set up + And I am logged in as "myname1" + When I sign up for Battle 12 with combination A + And I add a new prompt to my signup + Then I should see "Prompt was successfully added" + And I should see "Request 3" + And I should see "My extra tag" + + Scenario: Sort prompts by date + + Given I have Battle 12 prompt meme fully set up + When I am logged in as "myname1" + When I sign up for Battle 12 with combination A + And I am logged in as "myname2" + When I sign up for Battle 12 with combination B + When I view prompts for "Battle 12" + And I follow "Date" + Then I should see "Something else weird" + + Scenario: Sort prompts by fandom doesn't give error page + + Given I have Battle 12 prompt meme fully set up + When I am logged in as "myname1" + When I sign up for Battle 12 with combination A + And I am logged in as "myname2" + When I sign up for Battle 12 with combination B + When I view prompts for "Battle 12" + And I follow "Fandom 1" + Then I should see "Something else weird" + + Scenario: Sign up for a prompt meme with no tags + + Given I have no-column prompt meme fully set up + When I am logged in as "myname1" + When I sign up for Battle 12 with combination E + Then I should see "Sign-up was successfully created" + + Scenario: If there are no fandoms, prompt info on claims should show description or URL + + Given I have no-column prompt meme fully set up + When I am logged in as "myname1" + When I sign up for Battle 12 with combination E + When I claim a prompt from "Battle 12" + # TODO: check design: regular user doesn't get link to unposted claims anymore + # When I view unposted claims for "Battle 12" + Then I should see "Weird description" + + Scenario: Sort by fandom shouldn't show when there aren't any fandoms + + Given I have no-column prompt meme fully set up + When I am logged in as "myname1" + And I sign up for Battle 12 with combination E + And I view prompts for "Battle 12" + # TODO: We need to check the display for fandomless memes + Then I should not see "Fandom 1" + + Scenario: Claim a prompt and view claims on main page and user page + + Given I have Battle 12 prompt meme fully set up + When I am logged in as "myname1" + When I sign up for Battle 12 with combination B + And I am logged in as "myname4" + And I claim a prompt from "Battle 12" + Then I should see a prompt is claimed + + Scenario: Claim count shows on profile? + + Given I have Battle 12 prompt meme fully set up + And I am logged in as "myname1" + When I sign up for Battle 12 with combination A + And I claim a prompt from "Battle 12" + When I go to "Battle 12" collection's page + And I follow "Profile" + Then I should see "Prompts: 2" + # TODO: have these been removed by design or by accident? + # And I should see "Claimed prompts: 1" + + Scenario: Mod can view signups + + Given I have Battle 12 prompt meme fully set up + And everyone has signed up for Battle 12 + When I am logged in as "mod1" + And I go to "Battle 12" collection's page + And I follow "Prompts (8)" + Then I should see correct signups for Battle 12 + + Scenario: Mod can delete signups + + Given I have Battle 12 prompt meme fully set up + When I am logged in as "myname1" + And I sign up for Battle 12 with combination B + When I am logged in as "mod1" + And I go to "Battle 12" collection's page + And I follow "Prompts (" + And I should see "Prompts for Battle 12" + When I follow "Delete Sign-up" + Then I should see "Challenge sign-up was deleted." + And I should see "Prompts (0)" + + Scenario: Sign up with both prompts anon + + Given I have Battle 12 prompt meme fully set up + When I am logged in as "myname1" + When I sign up for Battle 12 with combination B + Then I should see "Sign-up was successfully created" + + Scenario: Sign up with neither prompt anon + + Given I have Battle 12 prompt meme fully set up + When I am logged in as "myname1" + When I sign up for Battle 12 with combination A + Then I should see "Sign-up was successfully created" + + Scenario: Sign up with one anon prompt and one not + + Given I have Battle 12 prompt meme fully set up + When I am logged in as "myname1" + When I sign up for Battle 12 with combination C + Then I should see "Sign-up was successfully created" + + Scenario: User has more than one pseud on signup form + + Given "myname1" has the pseud "othername" + Given I have Battle 12 prompt meme fully set up + When I am logged in as "myname1" + When I start to sign up for "Battle 12" + Then I should see "othername" + + Scenario: User changes pseud on a challenge signup + + Given "myname1" has the pseud "othername" + Given I have Battle 12 prompt meme fully set up + When I am logged in as "myname1" + When I sign up for Battle 12 with combination A + Then I should see "Sign-up was successfully created" + And I should see "Sign-up for myname1" + When I edit my signup for "Battle 12" + Then I should see "othername" + When I select "othername" from "challenge_signup_pseud_id" + # two forms in this page, must specify which button to press + And I press "Update" + Then I should see "Sign-up was successfully updated" + Then I should see "Sign-up for othername (myname1)" + + Scenario: Add more requests button disappears correctly from signup show page + + Given I am logged in as "mod1" + And I have standard challenge tags setup + When I set up a basic promptmeme "Battle 12" + And I follow "Challenge Settings" + When I fill in multi-prompt challenge options + When I sign up for Battle 12 with combination D + And I add prompt 3 + Then I should see "Add Prompt" + When I add prompt 4 + Then I should not see "Add Prompt" + + Scenario: Remove prompt button shouldn't show on Sign-ups + + Given I have Battle 12 prompt meme fully set up + When I am logged in as "myname1" + When I sign up for Battle 12 with combination A + When I am on myname1's user page + When I follow "Sign-ups" + Then I should not see "Remove prompt" + + Scenario: Mod can't edit signups + + Given I have Battle 12 prompt meme fully set up + When I am logged in as "myname1" + When I sign up for Battle 12 with combination A + When I am logged in as "mod1" + And I view prompts for "Battle 12" + Then I should not see "Edit Sign-up" + + Scenario: Mod cannot edit someone else's prompt + + Given I have Battle 12 prompt meme fully set up + When I am logged in as "myname1" + When I sign up for Battle 12 with combination C + When I am logged in as "mod1" + # The next step just takes you to the 'Prompts' page + When I edit the first prompt + Then I should not see "Edit Prompt" + + Scenario: Claim an anon prompt + + Given I have Battle 12 prompt meme fully set up + When I am logged in as "myname4" + When I sign up for Battle 12 with combination B + When I go to "Battle 12" collection's page + And I follow "Prompts (" + When I press "Claim" + Then I should see "New claim made." + And I should see "by Anonymous" + And I should not see "myname" within "#main" + + Scenario: Prompts are counted up correctly + + Given I have Battle 12 prompt meme fully set up + When I am logged in as "myname1" + When I sign up for Battle 12 with combination A + Then I should see "Prompts (2)" + When I am logged in as "myname2" + When I sign up for Battle 12 with combination B + Then I should see "Prompts (4)" + + Scenario: Claims are shown to mod + + Given I have Battle 12 prompt meme fully set up + Given everyone has signed up for Battle 12 + When I claim a prompt from "Battle 12" + When I close signups for "Battle 12" + Then claims are shown + + Scenario: Claims are hidden from ordinary user + + Given I have Battle 12 prompt meme fully set up + Given everyone has signed up for Battle 12 + When I claim a prompt from "Battle 12" + When I close signups for "Battle 12" + When I am logged in as "myname4" + Then I should not see "Unposted Claims" + # TODO: they got really hidden, since ordinary user can't get to that page at all + # Then claims are hidden + + Scenario: User cannot see unposted claims to delete + + Given I have Battle 12 prompt meme fully set up + Given everyone has signed up for Battle 12 + When I claim a prompt from "Battle 12" + When I am logged in as "myname1" + Then I should not see "Unposted Claims" + + Scenario: User can delete their own claim + + Given I have Battle 12 prompt meme fully set up + Given everyone has signed up for Battle 12 + When I claim a prompt from "Battle 12" + And I go to "Battle 12" collection's page + And I follow "My Claims" + And I follow "Drop Claim" + Then I should see "Your claim was deleted." + When I go to "Battle 12" collection's page + Then I should not see "My Claims" + + Scenario: User can drop a claim from the prompts page + + Given I have Battle 12 prompt meme fully set up + Given everyone has signed up for Battle 12 + When I claim a prompt from "Battle 12" + And I go to "Battle 12" collection's page + And I follow "Prompts" + Then I should see "Drop Claim" + + Scenario: User can't delete another user's claim + + Given I have Battle 12 prompt meme fully set up + Given everyone has signed up for Battle 12 + When I claim a prompt from "Battle 12" + When I am logged in as "otheruser" + And I go to "Battle 12" collection's page + And I follow "Prompts" + Then I should not see "Drop Claim" + + Scenario: User can delete their own claim from the user claims list + + Given I have Battle 12 prompt meme fully set up + Given everyone has signed up for Battle 12 + When I claim a prompt from "Battle 12" + When I follow "My Dashboard" + And I follow "Claims" + Then I should see "Drop Claim" + When I follow "Drop Claim" + Then I should see "Your claim was deleted." + # confirm claim no longer exists + When I go to "Battle 12" collection's page + Then I should not see "My Claims" + + Scenario: Mod or owner can delete a claim from the user claims list + + Given I have Battle 12 prompt meme fully set up + Given everyone has signed up for Battle 12 + When I claim a prompt from "Battle 12" + When I am logged in as "mod1" + And I view unposted claims for "Battle 12" + Then I should see "Delete" + When I follow "Delete" + Then I should see "The claim was deleted." + + Scenario: User can't claim the same prompt twice + + Given I have Battle 12 prompt meme fully set up + When I am logged in as "myname1" + When I sign up for Battle 12 with combination B + And I am logged in as "myname4" + And I claim two prompts from "Battle 12" + And I view prompts for "Battle 12" + # TODO: Refactor this test once we have a new Capybara version so that we look for .exact(Claim) + Then I should see "Drop Claim" diff --git a/features/prompt_memes_b/challenge_promptmeme_posting_fills.feature b/features/prompt_memes_b/challenge_promptmeme_posting_fills.feature new file mode 100755 index 0000000..2216892 --- /dev/null +++ b/features/prompt_memes_b/challenge_promptmeme_posting_fills.feature @@ -0,0 +1,561 @@ +@collections @challenges @promptmemes +Feature: Prompt Meme Challenge + In order to participate without inhibitions + As a humble user + I want to prompt, post and receive fills anonymously + + Scenario: Prompt anonymously and be notified of the fills without the writer knowing who I am + Given basic tags + And the following activated user exists + | login | password | email | + | myname1 | password | my1@e.org | + And a fandom exists with name: "GhostSoup", canonical: true + And I am logged in as "mod1" + And I set up a basic promptmeme "The Kissing Game" + And I log out + When I am logged in as "myname1" + And I go to "The Kissing Game" collection's page + # And the apostrophe stops getting in the way of highlighting in notepad++ ' + And I follow "Sign Up" + And I fill in "challenge_signup_requests_attributes_0_tag_set_attributes_fandom_tagnames" with "GhostSoup" + And I check "challenge_signup_requests_attributes_0_anonymous" + # there are two forms in this page, can't use I submit + And I press "Submit" + Then I should see "Sign-up was successfully created" + When I log out + And I am logged in as "myname2" + And I go to "The Kissing Game" collection's page + And I follow "Prompts (1)" + And I press "Claim" + Then I should see "New claim made" + And I follow "Fulfill" + And I check "No Archive Warnings Apply" + And I select "English" from "Choose a language" + And I fill in "Fandoms" with "GhostSoup" + And I should see "promptcollection" in the "work_collection_names" input + And the "Untitled Prompt in The Kissing Game (Anonymous)" checkbox should be checked + And the "work_recipients" field should not contain "myname1" + And I fill in "Work Title" with "Kinky Story" + And I fill in "content" with "Story written for your kinks, oh mystery reader!" + Given all emails have been delivered + And I press "Post" + Then I should see "Kinky Story" + And I should find a list for associations + And I should see "In response to a prompt by Anonymous in the The Kissing Game collection" + And I should see a link "prompt" + And 1 email should be delivered to "my1@e.org" +# TODO: when work_anonymous is implemented, test that the prompt filler can be anon too + + Scenario: Fulfilling a claim ticks the right boxes automatically + + Given I have Battle 12 prompt meme fully set up + When I am logged in as "myname1" + When I sign up for Battle 12 with combination B + And I am logged in as "myname4" + And I claim a prompt from "Battle 12" + When I start to fulfill my claim + Then the "Battle 12" checkbox should be checked + And the "Battle 12" checkbox should not be disabled + + Scenario: User can fulfill a claim + + Given I have Battle 12 prompt meme fully set up + When I am logged in as "myname1" + When I sign up for Battle 12 with combination B + And I am logged in as "myname4" + And I claim a prompt from "Battle 12" + When I fulfill my claim + Then my claim should be fulfilled + + Scenario: User can fulfill a claim to their own prompt + + Given I have Battle 12 prompt meme fully set up + When I am logged in as "myname1" + And I sign up for Battle 12 with combination B + And I claim a prompt from "Battle 12" + And I fulfill my claim + Then my claim should be fulfilled + + Scenario: Fulfilled claim shows correctly on my claims + + Given I have Battle 12 prompt meme fully set up + When I am logged in as "myname1" + When I sign up for Battle 12 with combination B + And I am logged in as "myname4" + And I claim a prompt from "Battle 12" + When I fulfill my claim + When I am on myname4's user page + And I follow "Claims" + And I follow "Fulfilled Claims" + Then I should see "Fulfilled Story" + # TODO: should I? It's not there at all + And I should not see "Not yet posted" + + Scenario: Claims count should be correct, shows fulfilled claims as well + + Given I have Battle 12 prompt meme fully set up + When I am logged in as "myname1" + When I sign up for Battle 12 with combination B + And I am logged in as "myname4" + And I claim a prompt from "Battle 12" + When I fulfill my claim + When I am on myname4's user page + # Then show me the sidebar # TODO: it has Claims (0) but why? + Then I should see "Claims (0)" + When I follow "Claims" + And I follow "Fulfilled Claims" + Then I should see "Fulfilled Story" + + Scenario: Claim shows as fulfilled to another user + + Given I have Battle 12 prompt meme fully set up + When I am logged in as "myname1" + When I sign up for Battle 12 with combination B + And I am logged in as "myname4" + And I claim a prompt from "Battle 12" + When I fulfill my claim + When I am logged in as "myname1" + When I go to "Battle 12" collection's page + And I follow "Prompts (" + Then I should see "Fulfilled By" + And I should see "Mystery Work" + + # Scenario: Fulfilled claims are shown to mod + # TODO: We need to figure out if we want to hide claims from mods in 100% anonymous prompt memes +# Given I have Battle 12 prompt meme fully set up +# Given everyone has signed up for Battle 12 +# When I am logged in as "myname4" +# When I claim a prompt from "Battle 12" +# When I close signups for "Battle 12" +# When I am logged in as "myname4" +# When I fulfill my claim +# When mod fulfills claim +# When I am on "Battle 12" collection's page +# When I follow "Prompts" +# And I follow "Show Claims" +# Then I should not see "Claimed by: myname4" +# And I should not see "Claimed by: mod1" +# And I should not see "Claimed by: (Anonymous)" +# When I follow "Show Filled" +# Then I should see "Claimed by: myname4" +# And I should see "Claimed by: mod1" +# And I should not see "Claimed by: (Anonymous)" + + Scenario: Fulfilled claims are hidden from user + + Given I have Battle 12 prompt meme fully set up + Given everyone has signed up for Battle 12 + When I am logged in as "myname4" + When I claim a prompt from "Battle 12" + When I close signups for "Battle 12" + When I am logged in as "myname4" + When I fulfill my claim + When mod fulfills claim + When I am logged in as "myname4" + When I go to "Battle 12" collection's page + And I follow "Prompts (8)" + Then I should not see "myname4" within "h5" + And I should not see "mod1" within "h5" + And I should see "Fulfilled Story by Anonymous" within "div.work h4" + + Scenario: Sign-up can be deleted after response has been posted + + Given I have Battle 12 prompt meme fully set up + When I am logged in as "myname1" + When I sign up for Battle 12 with combination B + And I am logged in as "myname4" + And I claim a prompt from "Battle 12" + When I fulfill my claim + When I am logged in as "myname1" + And I delete my signup for the prompt meme "Battle 12" + Then I should see "Challenge sign-up was deleted." + # work fulfilling is still fine + When I view the work "Fulfilled Story" + Then I should see "This work is part of an ongoing challenge and will be revealed soon! You can find details here: Battle 12" + And I should not see "Stargate Atlantis" + # work is still fine as another user + When I am logged in as "myname4" + And I view the work "Fulfilled Story" + Then I should see "This work is part of an ongoing challenge and will be revealed soon! You can find details here: Battle 12" + And I should see "Stargate Atlantis" + + Scenario: Prompt can be removed after response has been posted and still show properly on the work which fulfilled it + + Given I have Battle 12 prompt meme fully set up + When I am logged in as "myname1" + When I sign up for Battle 12 with combination B + And I am logged in as "myname4" + And I claim a prompt from "Battle 12" + When I fulfill my claim + When I am logged in as "myname1" + And I delete my signup for the prompt meme "Battle 12" + When I view the work "Fulfilled Story" + Then I should see "This work is part of an ongoing challenge and will be revealed soon! You can find details here: Battle 12" + And I should not see "Stargate Atlantis" + When I am logged in as "myname4" + And I view the work "Fulfilled Story" + Then I should see "This work is part of an ongoing challenge and will be revealed soon! You can find details here: Battle 12" + And I should see "Stargate Atlantis" + + Scenario: User can fulfill the same claim twice + + Given I have Battle 12 prompt meme fully set up + When I am logged in as "myname1" + When I sign up for Battle 12 with combination B + And I am logged in as "myname4" + And I claim a prompt from "Battle 12" + When I fulfill my claim + When I fulfill my claim again + Then I should see "Work was successfully posted" + And I should see "Second Story" + And I should see "In response to a prompt by Anonymous" + And I should see "Collections: Battle 12" + + Scenario: User edits existing work to fulfill claim + + Given I have Battle 12 prompt meme fully set up + When I am logged in as "myname1" + When I sign up for Battle 12 with combination B + And I am logged in as "myname4" + And I claim a prompt from "Battle 12" + And I post the work "Existing Story" + And I should not see "Battle 12" + And I edit the work "Existing Story" + And I check "random SGA love in Battle 12 (Anonymous)" + And I press "Post" + Then I should see "Battle 12" + Then I should see "Existing Story" + And I should see "This work is part of an ongoing challenge" + When I reveal works for "Battle 12" + When I view the work "Existing Story" + And I should not see "This work is part of an ongoing challenge" + + Scenario: User edits existing work in another collection to fulfill claim + + Given I have Battle 12 prompt meme fully set up + And I have a collection "Othercoll" + When I am logged in as "myname1" + When I sign up for Battle 12 with combination B + And I am logged in as "myname4" + And I claim a prompt from "Battle 12" + And I post the work "Existing Story" in the collection "Othercoll" + And I edit the work "Existing Story" + And I check "random SGA love in Battle 12 (Anonymous)" + And I press "Post" + Then I should see "Battle 12" + And I should see "Othercoll" + + Scenario: Fulfill a claim by editing an existing work + + Given I have Battle 12 prompt meme fully set up + And everyone has signed up for Battle 12 + When I close signups for "Battle 12" + When I reveal the "Battle 12" challenge + When I reveal the authors of the "Battle 12" challenge + When I am logged in as "myname1" + And I go to "Battle 12" collection's page + And I follow "Prompts (" + When I press "Claim" + Then I should see "New claim made" + When I post the work "Here's one I made earlier" + And I edit the work "Here's one I made earlier" + And I check "Battle 12" + And I press "Preview" + Then I should see "In response to a prompt by" + And I should see "Collections:" + And I should see "Battle 12" + When I press "Update" + Then I should see "Work was successfully updated" + And I should not see "draft" + And I should see "In response to a prompt by" + Then I should see "Collections:" + And I should see "Battle 12" + + # claim is fulfilled on collection page + When I go to "Battle 12" collection's page + And I follow "Prompts" + Then I should see "myname1" within ".prompt .work" + And I should see "Fulfilled By" + + Scenario: When draft is posted, claim is fulfilled and posted to collection + + Given I have Battle 12 prompt meme fully set up + When I am logged in as "myname2" + And I sign up for Battle 12 with combination B + When I am logged in as "myname4" + And I claim a prompt from "Battle 12" + When I close signups for "Battle 12" + When I reveal the "Battle 12" challenge + When I reveal the authors of the "Battle 12" challenge + When I am logged in as "myname4" + And I go to the "Battle 12" requests page + When I press "Claim" + When I follow "Fulfill" + And I fill in the basic work information for "Existing work" + And I check "random SGA love in Battle 12 (Anonymous)" + And I press "Preview" + When I am on myname4's user page + And I follow "Drafts" + And all emails have been delivered + When I follow "Post Draft" + Then 1 email should be delivered + Then I should see "Your work was successfully posted" + And I should see "In response to a prompt by Anonymous" + When I go to "Battle 12" collection's page + And I follow "Prompts (" + Then I should see "myname4" + And I should see "Fulfilled By" + When I follow "Existing work" + Then I should see "Existing work" + And I should see "Battle 12" + And I should not see "draft" + + Scenario: Make another claim and then fulfill from the post new form (New Work) + + Given I have Battle 12 prompt meme fully set up + Given everyone has signed up for Battle 12 + When I close signups for "Battle 12" + When I reveal the "Battle 12" challenge + Given all emails have been delivered + When I reveal the authors of the "Battle 12" challenge + When I go to "Battle 12" collection's page + And I follow "Prompts (8)" + When I press "Claim" + Then I should see "New claim made." + When I am logged in as "myname4" + And I go to the collections page + And I follow "Battle 12" + When I follow "Prompts (" + When I press "Claim" + Then I should see "New claim made" + When I follow "New Work" + When I fill in the basic work information for "Existing work" + And I check "Battle 12 (myname4)" + And I press "Preview" + Then I should see "Draft was successfully created" + And I should see "In response to a prompt by myname4" + And 0 emails should be delivered + And I should see "Collections:" + And I should see "Battle 12" + When I view the work "Existing work" + Then I should see "draft" + + Scenario: Fulfilled claims show as fulfilled to another user + + Given I have Battle 12 prompt meme fully set up + Given everyone has signed up for Battle 12 + When I am logged in as "myname4" + When I claim a prompt from "Battle 12" + When I close signups for "Battle 12" + When I am logged in as "myname4" + When I fulfill my claim + When mod fulfills claim + When I reveal the "Battle 12" challenge + Given all emails have been delivered + When I reveal the authors of the "Battle 12" challenge + When I go to "Battle 12" collection's page + And I follow "Prompts (8)" + When I press "Claim" + Then I should see "New claim made." + When I am logged in as "myname4" + And I go to the "Battle 12" requests page + Then I should see "mod1" within ".prompt .work" + And I should see "myname4" within ".prompt .work" + + Scenario: When a prompt is filled with a co-authored work, the e-mail should link to each author's URL instead of showing escaped HTML + + Given I have Battle 12 prompt meme fully set up + And I am logged in as "myname3" + And I go to myname3's user page + And I follow "Preferences" + And I check "Allow others to invite me to be a co-creator" + And I press "Update" + When I am logged in as "myname1" + And I sign up for Battle 12 with combination A + And I log out + When I am logged in as "myname2" + And I claim a prompt from "Battle 12" + And I start to fulfill my claim with "Co-authored Fill" + And I invite the co-author "myname3" + When I press "Post" + Then 1 email should be delivered to "myname3" + And the email should contain "The user myname2 has invited your pseud myname3 to be listed as a co-creator on the following work" + And the email should not contain "translation missing" + When the user "myname3" accepts all co-creator requests + And I am logged in as "mod1" + And I reveal the authors of the "Battle 12" challenge + And I reveal the "Battle 12" challenge + Then 1 email should be delivered to "myname1" + And the email should link to myname2's user url + And the email should not contain "<a href="http://archiveofourown.org/users/myname2/pseuds/myname2"" + And the email should link to myname3's user url + And the email should not contain "<a href="http://archiveofourown.org/users/myname3/pseuds/myname3"" + + Scenario: check that completed ficlet is unrevealed + + Given I have Battle 12 prompt meme fully set up + Given everyone has signed up for Battle 12 + When mod fulfills claim + When I am logged in as "myname4" + When I view the work "Fulfilled Story-thing" + Then I should not see "In response to a prompt by myname4" + And I should not see "Fandom: Stargate Atlantis" + And I should not see "Anonymous" + And I should not see "mod1" + And I should see "This work is part of an ongoing challenge and will be revealed soon! You can find details here: Battle 12" + + Scenario: Mod can post a fic + + Given I have Battle 12 prompt meme fully set up + Given everyone has signed up for Battle 12 + When I am logged in as "mod1" + When I claim a prompt from "Battle 12" + When I am on mod1's user page + Then I should see "Claims (1)" + When I follow "Claims" + Then I should see "My Claims" + And I should see "canon SGA love by myname4 in Battle 12" within "div#main.challenge_claims-index h4" + When I follow "Fulfill" + And I fill in "Fandoms" with "Stargate Atlantis" + And I fill in "Work Title*" with "Fulfilled Story-thing" + And I select "Not Rated" from "Rating" + And I check "No Archive Warnings Apply" + And I select "English" from "Choose a language" + And I fill in "content" with "This is an exciting story about Atlantis, but in a different universe this time" + When I press "Preview" + And I press "Post" + Then I should see "Work was successfully posted" + + Scenario: Mod can complete a claim + + Given I have Battle 12 prompt meme fully set up + Given everyone has signed up for Battle 12 + When I am logged in as "mod1" + When I claim a prompt from "Battle 12" + When I start to fulfill my claim + And I fill in "Work Title" with "Fulfilled Story-thing" + And I fill in "content" with "This is an exciting story about Atlantis, but in a different universe this time" + When I press "Preview" + And I press "Post" + When I am on mod1's user page + Then I follow "Claims" + And I should not see "mod" within "h4" + Then I follow "Fulfilled Claims" + # On the users' My Claims page, they see their anon works as Anonymous + And I should see "Anonymous" within "div.work h4" + + Scenario: Fic shows what prompt it is fulfilling when mod views it + + Given I have Battle 12 prompt meme fully set up + Given everyone has signed up for Battle 12 + When I am logged in as "mod1" + When I claim a prompt from "Battle 12" + When I start to fulfill my claim + And I fill in "Work Title" with "Fulfilled Story-thing" + And I fill in "content" with "This is an exciting story about Atlantis, but in a different universe this time" + When I press "Preview" + And I press "Post" + When I view the work "Fulfilled Story-thing" + Then I should see "In response to a prompt by myname4" + And I should see "Fandom: Stargate Atlantis" + And I should see "Anonymous" within ".byline" + And I should see a link "prompt" + + Scenario: Work links to the prompt it fulfils, for all users + + Given I have Battle 12 prompt meme fully set up + And I am logged in as "myname1" + And I sign up for Battle 12 with combination B + And I am logged in as "myname4" + And I claim a prompt from "Battle 12" + And I fulfill my claim + And I reveal works for "Battle 12" + And I view the work "Fulfilled Story" + Then I should see "Fulfilled Story" + And I should see "In response to a prompt by Anonymous" + And I should see a link "prompt" + When I follow "prompt" + Then I should see "Request by Anonymous" + When I am logged in as "myname2" + And I view the work "Fulfilled Story" + Then I should see "Fulfilled Story" + And I should see "In response to a prompt by Anonymous" + And I should see a link "prompt" + When I follow "prompt" + Then I should see "Request by Anonymous" + When I am logged in as "mod" + And I view the work "Fulfilled Story" + Then I should see "Fulfilled Story" + And I should see "In response to a prompt by Anonymous" + And I should see a link "prompt" + When I follow "prompt" + Then I should see "Request by Anonymous" + When I log out + And I view the work "Fulfilled Story" + Then I should see "Fulfilled Story" + And I should see "In response to a prompt by Anonymous" + And I should see a link "prompt" + When I follow "prompt" + Then I should see "Request by Anonymous" + + Scenario: A creator can give a gift to a user who disallows gifts if the work is connected to a claim of a non-anonymous prompt belonging to the recipient, and the recipient remains attached even if the work is later disconnected from the claim + + Given I have Battle 12 prompt meme fully set up + And the user "prompter" exists and is activated + And the user "prompter" disallows gifts + And "prompter" has signed up for Battle 12 with combination A + When I am logged in as "gifter" + And I claim a prompt from "Battle 12" + And I start to fulfill my claim + And I fill in "Gift this work to" with "prompter" + And I press "Post" + Then I should see "For prompter." + When I follow "Edit" + And I uncheck "Battle 12 (prompter)" + And I press "Post" + Then I should see "For prompter." + + Scenario: A creator cannot give a gift to a user who disallows gifts if the work is connected to a claim of an anonymous prompt belonging to the recipient + + Given I have Battle 12 prompt meme fully set up + And the user "prompter" exists and is activated + And the user "prompter" disallows gifts + And "prompter" has signed up for Battle 12 with combination B + When I am logged in as "gifter" + And I claim a prompt from "Battle 12" + And I start to fulfill my claim + And I fill in "Gift this work to" with "prompter" + And I press "Post" + Then I should see "prompter does not accept gifts." + + Scenario: A creator cannot give a gift to a user who disallows gifts if the work is connected to a claim of a non-anonymous prompt belonging to a different user + + Given I have Battle 12 prompt meme fully set up + And the user "prompter" exists and is activated + And the user "prompter" disallows gifts + And "prompter" has signed up for Battle 12 with combination A + And the user "bystander" exists and is activated + And the user "bystander" disallows gifts + When I am logged in as "gifter" + And I claim a prompt from "Battle 12" + And I start to fulfill my claim + And I fill in "Gift this work to" with "prompter, bystander" + And I press "Post" + Then I should see "bystander does not accept gifts." + + Scenario: A creator can give a gift to a user who has blocked them if the work is connected to a claim of a non-anonymous prompt belonging to the recipient + + Given I have Battle 12 prompt meme fully set up + And the user "prompter" exists and is activated + And the user "prompter" has blocked the user "gifter" + And "prompter" has signed up for Battle 12 with combination A + When I am logged in as "gifter" + And I claim a prompt from "Battle 12" + And I start to fulfill my claim + And I fill in "Gift this work to" with "prompter" + And I press "Post" + Then I should see "For prompter." + When I follow "Edit" + And I uncheck "Battle 12 (prompter)" + And I press "Post" + Then I should see "For prompter." \ No newline at end of file diff --git a/features/prompt_memes_b/challenge_promptmeme_springkink.feature b/features/prompt_memes_b/challenge_promptmeme_springkink.feature new file mode 100755 index 0000000..2ae763d --- /dev/null +++ b/features/prompt_memes_b/challenge_promptmeme_springkink.feature @@ -0,0 +1,81 @@ +@collections @challenges @promptmemes +Feature: Prompt Meme Challenge + In order to have an archive full of works + As a humble user + I want to create a prompt meme and post to it + + Scenario: Create a prompt meme for a challenge like http://community.livejournal.com/springkink/ + + Given I am logged in as "mod1" + And I have standard challenge tags set up + + # set up the challenge + + When I go to the collections page + When I set up an anon promptmeme "Spring Kink" + And I follow "Challenge Settings" + When I fill in "General Sign-up Instructions" with "Here are some general tips" + And I fill in "Tag Sets To Use:" with "Standard Challenge Tags" + And I fill in "prompt_meme_request_restriction_attributes_fandom_num_required" with "1" + And I fill in "prompt_meme_request_restriction_attributes_fandom_num_allowed" with "1" + And I check "prompt_meme_anonymous" + And I fill in "prompt_meme_requests_num_allowed" with "1" + And I fill in "prompt_meme_requests_num_required" with "1" + And I press "Update" + Then I should see "Challenge was successfully updated" + + # sign up as first prompter + + When I log out + And I am logged in as "prompter1" with password "something" + When I go to "Spring Kink" collection's page + And I follow "Sign Up" + And I check "Stargate Atlantis" + And I submit + Then I should see "Sign-up was successfully created" + And I should see "Prompts (1)" + + # writer 1 replies to prompt, which is anon + When I am logged out + And I am logged in as "writer1" with password "something" + And I go to "Spring Kink" collection's page + And I follow "Prompts (" + Then I should see "Stargate Atlantis" + And I should not see "prompter1" + When I press "Claim" + Then I should see "New claim made" + When I fulfill my claim + Then I should see "Work was successfully posted" + And I should see "This work is part of an ongoing challenge and will be revealed soon!" + + # writer 2 replies to prompt + When I am logged in as "writer2" with password "something" + And I go to "Spring Kink" collection's page + And I follow "Prompts (" + And I press "Claim" + Then I should see "New claim made" + When I fulfill my claim + Then I should see "Work was successfully posted" + + # responses are hidden, e.g. from prompter + When I am logged out + And I am logged in as "prompter1" with password "something" + And I go to "Spring Kink" collection's page + And I follow "Works" + Then I should not see "writer1" + And I should not see "Response Story" + # And I should see "Mystery Work" + + # mod checks that word count is at least 100 and only reveals the right ones + When I am logged out + And I am logged in as "mod1" with password "something" + + # prompter prompts for day 2 + + # writer 1 responds for day 2 + + # writer 3 and writer 4 respond for day 2 + + # writer 4 responds belatedly for day 1 + + # mod can see all that and reveal the right things diff --git a/features/prompt_memes_c/challenge_promptmeme_claims.feature b/features/prompt_memes_c/challenge_promptmeme_claims.feature new file mode 100644 index 0000000..7078d18 --- /dev/null +++ b/features/prompt_memes_c/challenge_promptmeme_claims.feature @@ -0,0 +1,456 @@ +@collections @challenges @promptmemes +Feature: Prompt Meme Challenge + In order to have an archive full of works + As a humble user + I want to create a prompt meme and post to it + + Scenario: Claim two prompts by the same person in one challenge + + Given I have Battle 12 prompt meme fully set up + When I am logged in as "myname2" + When I sign up for Battle 12 with combination B + # 1st prompt SG-1, 2nd prompt SGA, both anon + When I am logged in as "myname1" + And I claim two prompts from "Battle 12" + And I view prompts for "Battle 12" + # all prompts have been claimed - check it worked + # TODO: find a better way to check that it worked, since 'Drop Claim' includes the word 'Claim', and there is no table anymore, so no tbody + # Then I should not see "Claim" within "tbody" + # TODO: check that they are not intermittent anymore + When I start to fulfill my claim + Then I should find a checkbox "High School AU SG1 in Battle 12 (Anonymous)" + And I should find a checkbox "random SGA love in Battle 12 (Anonymous)" + And the "High School AU SG1 in Battle 12 (Anonymous)" checkbox should not be checked + And the "random SGA love in Battle 12 (Anonymous)" checkbox should be checked + + Scenario: Claim two prompts by different people in one challenge + + Given I have single-prompt prompt meme fully set up + When I am logged in as "sgafan" + And I sign up for "Battle 12" with combination SGA + When I am logged in as "sg1fan" + And I sign up for "Battle 12" with combination SG-1 + When I am logged in as "writer" + And I claim two prompts from "Battle 12" + When I start to fulfill my claim + Then I should find a checkbox "SG1 love in Battle 12 (sg1fan)" + And I should find a checkbox "SGA love in Battle 12 (sgafan)" + # TODO: check that they are not intermittent anymore + And the "SGA love in Battle 12 (sgafan)" checkbox should not be checked + And the "SG1 love in Battle 12 (sg1fan)" checkbox should be checked + + Scenario: Claim two prompts by the same person in one challenge, one is anon + + Given I have Battle 12 prompt meme fully set up + When I am logged in as "myname2" + When I sign up for Battle 12 + # 1st prompt "something else weird" and titled "crack", 2nd prompt anon + When I am logged in as "myname1" + And I claim two prompts from "Battle 12" + And I view prompts for "Battle 12" + # anon as claims are in reverse date order + When I start to fulfill my claim + Then I should find a checkbox "Untitled Prompt in Battle 12 (Anonymous)" + And I should find a checkbox "crack in Battle 12 (myname2)" + And the "Untitled Prompt in Battle 12 (Anonymous)" checkbox should be checked + And the "crack in Battle 12 (myname2)" checkbox should not be checked + + Scenario: User claims two prompts in one challenge and fulfills one of them + # TODO: When SPRs get merged, make this check that 'prompt' is a link + # and that it shows the correct prompt, or whatever + Given I have Battle 12 prompt meme fully set up + When I am logged in as "myname2" + When I sign up for Battle 12 with combination B + # 1st prompt SG-1, 2nd prompt SGA, both anon + When I am logged in as "myname1" + And I claim a prompt from "Battle 12" + # SGA as it's in reverse order + And I claim a prompt from "Battle 12" + # SG-1 + # SGA seems to be the first consistently + When I start to fulfill my claim + Then the "High School AU SG1 in Battle 12 (Anonymous)" checkbox should not be checked + And the "random SGA love in Battle 12 (Anonymous)" checkbox should be checked + When I press "Preview" + And I press "Post" + When I view the work "Fulfilled Story" + Then I should see "Stargate Atlantis" + + Scenario: User claims two prompts in one challenge and fufills both of them at once + + Given I have Battle 12 prompt meme fully set up + When I am logged in as "myname2" + When I sign up for Battle 12 + # 1st prompt anon, 2nd prompt non-anon + When I am logged in as "myname1" + And I claim a prompt from "Battle 12" + And I claim a prompt from "Battle 12" + And I view prompts for "Battle 12" + When I start to fulfill my claim + # the anon prompt will already by checked + And I check "crack in Battle 12 (myname2)" + And I press "Preview" + And I press "Post" + When I view the work "Fulfilled Story" + # fandoms are not filled in automatically anymore, so we check that both prompts are marked as filled by having one anon and one non-anon + Then I should see "In response to a prompt by Anonymous" + And I should see "In response to a prompt by myname2" + + # Scenario: User claims two prompts in different challenges and fulfills both of them at once + # TODO + + Scenario: Sign up for several challenges and see Sign-ups are sorted + + Given I have Battle 12 prompt meme fully set up + When I set up a basic promptmeme "Battle 13" + When I set up an anon promptmeme "Battle 14" with name "anonmeme" + When I am logged in as "prolific_writer" + When I sign up for "Battle 12" fixed-fandom prompt meme + When I sign up for "Battle 13" many-fandom prompt meme + When I sign up for "Battle 14" many-fandom prompt meme + When I am on prolific_writer's user page + And I follow "Sign-ups" + # TODO + + Scenario: User is participating in a prompt meme and a gift exchange at once, clicks "Post to fulfill" on the prompt meme and sees the right boxes ticked + + Given I have created the gift exchange "My Gift Exchange" + And I open signups for "My Gift Exchange" + And everyone has signed up for the gift exchange "My Gift Exchange" + And I have generated matches for "My Gift Exchange" + And I have sent assignments for "My Gift Exchange" + Given I have Battle 12 prompt meme fully set up + And everyone has signed up for Battle 12 + When I am logged in as "myname3" + And I claim a prompt from "Battle 12" + When I start to fulfill my claim + Then the "canon SGA love in Battle 12 (myname4)" checkbox should be checked + And the "My Gift Exchange (myname2)" checkbox should not be checked + And the "canon SGA love in Battle 12 (myname4)" checkbox should not be disabled + And the "My Gift Exchange (myname2)" checkbox should not be disabled + + Scenario: User posts to fulfill direct from Post New (New Work) + + Given I have Battle 12 prompt meme fully set up + And everyone has signed up for Battle 12 + When I am logged in as "myname3" + And I claim a prompt from "Battle 12" + And I follow "New Work" + Then the "canon SGA love in Battle 12 (myname4)" checkbox should not be checked + And the "canon SGA love in Battle 12 (myname4)" checkbox should not be disabled + + Scenario: User is participating in a prompt meme and a gift exchange at once, clicks "Post to fulfill" on the prompt meme and then changes their mind and fulfills the gift exchange instead + + Given I have Battle 12 prompt meme fully set up + And everyone has signed up for Battle 12 + Given I have created the gift exchange "My Gift Exchange" + And I open signups for "My Gift Exchange" + And everyone has signed up for the gift exchange "My Gift Exchange" + And I have generated matches for "My Gift Exchange" + And I have sent assignments for "My Gift Exchange" + When I am logged in as "myname3" + And I claim a prompt from "Battle 12" + When I start to fulfill my claim + When I check "My Gift Exchange (myname2)" + And I uncheck "canon SGA love in Battle 12 (myname4)" + And I fill in "Post to Collections / Challenges" with "" + And I press "Post" + Then I should see "My Gift Exchange" + And I should not see "Battle 12" + And I should not see "This work is part of an ongoing challenge and will be revealed soon! You can find details here: My Gift Exchange" + + Scenario: Mod can claim a prompt like an ordinary user + + Given I have Battle 12 prompt meme fully set up + Given everyone has signed up for Battle 12 + When I am logged in as "mod1" + When I claim a prompt from "Battle 12" + Then I should see "New claim made." + + Scenario: Mod can still see anonymous claims after signup is closed + + Given I have Battle 12 prompt meme fully set up + Given everyone has signed up for Battle 12 + When I am logged in as "myname4" + When I claim a prompt from "Battle 12" + When I fulfill my claim + When I am logged in as "mod1" + When I claim a prompt from "Battle 12" + When I close signups for "Battle 12" + When I am logged in as "mod1" + When I am on "Battle 12" collection's page + And I follow "Unposted Claims (" + Then I should see "claimed by mod" + And I should see "by myname4" + And I should see "Stargate Atlantis" + + # Scenario: check that claims can't be viewed even after challenge is revealed + # TODO: Find a way to construct the link to a claim show page for someone who shouldn't be able to see it + + Scenario: Mod can reveal challenge + + Given I have Battle 12 prompt meme fully set up + When I close signups for "Battle 12" + When I go to "Battle 12" collection's page + And I follow "Collection Settings" + And I uncheck "This collection is unrevealed" + And I press "Update" + Then I should see "Collection was successfully updated" + + Scenario: Revealing challenge sends out emails + + Given I have Battle 12 prompt meme fully set up + Given everyone has signed up for Battle 12 + When I am logged in as "myname4" + When I claim a prompt from "Battle 12" + When I close signups for "Battle 12" + When I am logged in as "myname4" + When I fulfill my claim + When mod fulfills claim + When I reveal the "Battle 12" challenge + Then I should see "Collection was successfully updated" + # 2 stories are now revealed, so notify the prompters + And 2 emails should be delivered + + Scenario: Story is anon when challenge is revealed + + Given I have standard challenge tags setup + Given I have Battle 12 prompt meme fully set up + Given everyone has signed up for Battle 12 + When I am logged in as "myname4" + When I claim a prompt from "Battle 12" + When I close signups for "Battle 12" + When I am logged in as "myname4" + When I fulfill my claim + When mod fulfills claim + When I reveal the "Battle 12" challenge + When I am logged in as "myname4" + When I view the work "Fulfilled Story-thing" + Then I should see "In response to a prompt by myname4" + And I should see "Fandom: Stargate Atlantis" + And I should see "Collections: Battle 12" + And I should see "Anonymous" within ".byline" + And I should not see "mod1" within ".byline" + + Scenario: Authors can be revealed + + Given I have Battle 12 prompt meme fully set up + Given everyone has signed up for Battle 12 + When I am logged in as "myname4" + When I claim a prompt from "Battle 12" + When I close signups for "Battle 12" + When I am logged in as "myname4" + When I fulfill my claim + When mod fulfills claim + When I reveal the "Battle 12" challenge + When I reveal the authors of the "Battle 12" challenge + Then I should see "Collection was successfully updated" + + Scenario: Revealing authors doesn't send emails + + Given I have Battle 12 prompt meme fully set up + Given everyone has signed up for Battle 12 + When I am logged in as "myname4" + When I claim a prompt from "Battle 12" + When I close signups for "Battle 12" + When I am logged in as "myname4" + When I fulfill my claim + When mod fulfills claim + When I reveal the "Battle 12" challenge + Given all emails have been delivered + When I reveal the authors of the "Battle 12" challenge + Then I should see "Collection was successfully updated" + Then 0 emails should be delivered + + Scenario: When challenge is revealed-authors, user can see claims + + Given I have Battle 12 prompt meme fully set up + Given everyone has signed up for Battle 12 + When I am logged in as "myname4" + When I claim a prompt from "Battle 12" + When I close signups for "Battle 12" + When I am logged in as "myname4" + When I fulfill my claim + When mod fulfills claim + When I reveal the "Battle 12" challenge + When I reveal the authors of the "Battle 12" challenge + When I am logged in as "myname4" + When I go to "Battle 12" collection's page + And I follow "Prompts (8)" + Then I should see "Fulfilled By" + And I should see "Fulfilled Story by myname4" within "div.work" + And I should see "Fulfilled Story-thing by mod1" within "div.work" + + Scenario: Anon prompts stay anon on claims index even if challenge is revealed + + Given I have Battle 12 prompt meme fully set up + When I am logged in as "myname4" + When I sign up for Battle 12 with combination B + When I close signups for "Battle 12" + When I am logged in as "myname2" + When I claim a prompt from "Battle 12" + When I fulfill my claim + When I reveal the "Battle 12" challenge + When I reveal the authors of the "Battle 12" challenge + When I go to "Battle 12" collection's page + And I follow "Prompts (" + Then I should see "by Anonymous" + And I should not see "by myname4" + + Scenario: Check that anon prompts are still anon on the prompts page after challenge is revealed + + Given I have Battle 12 prompt meme fully set up + When I am logged in as "myname4" + When I sign up for Battle 12 with combination B + When I close signups for "Battle 12" + When I am logged in as "myname2" + When I claim a prompt from "Battle 12" + When I fulfill my claim + When I reveal the "Battle 12" challenge + When I reveal the authors of the "Battle 12" challenge + When I view prompts for "Battle 12" + Then I should see "random SGA love by Anonymous" + Then I should see "Fulfilled Story by myname2" + Then I should see "High School AU SG1 by Anonymous " + + Scenario: Check that anon prompts are still anon on user claims index after challenge is revealed + + Given I have Battle 12 prompt meme fully set up + When I am logged in as "myname4" + When I sign up for Battle 12 with combination B + When I close signups for "Battle 12" + When I am logged in as "myname2" + When I claim a prompt from "Battle 12" + When I reveal the "Battle 12" challenge + When I reveal the authors of the "Battle 12" challenge + When I am logged in as "myname2" + When I am on myname2's user page + And I follow "Claims" + # note that user Claims page currently only shows unfulfilled claims + Then I should not see "myname4" + And I should see "Anonymous" + + Scenario: Check that anon prompts are still anon on claims show after challenge is revealed + # note that only mod can see claims show now - users don't get linked to it + + Given I have Battle 12 prompt meme fully set up + When I am logged in as "myname4" + When I sign up for Battle 12 with combination B + When I close signups for "Battle 12" + When I am logged in as "myname2" + When I claim a prompt from "Battle 12" + When I reveal the "Battle 12" challenge + When I reveal the authors of the "Battle 12" challenge + When I am logged in as "mod1" + When I am on "Battle 12" collection's page + And I follow "Unposted Claims" + And I follow "Anonymous" + Then I should not see "myname4" + And I should see "Anonymous" + + # Scenario: check that anon prompts are still anon on the fulfilling work + # TODO + + Scenario: work left in draft so claim is not yet totally fulfilled + + Given I have Battle 12 prompt meme fully set up + Given an anon has signed up for Battle 12 + When I close signups for "Battle 12" + When I reveal the "Battle 12" challenge + Given all emails have been delivered + When I reveal the authors of the "Battle 12" challenge + When I am logged in as "myname4" + When I claim a prompt from "Battle 12" + When I start to fulfill my claim + And I press "Preview" + When I go to the "Battle 12" requests page + Then I should see "Claimed By" + And I should not see "Fulfilled By" + When I am logged in as "mod1" + And I go to "Battle 12" collection's page + And I follow "Unposted Claims" + Then I should see "myname4" + When I am logged in as "myname4" + And I go to myname4's claims page + # Draft not shown. Instead we see that there is a 'Fulfill' button which + # we can use. Then use the 'Restore From Last Unposted Draft?' button + When I follow "Fulfill" + And I follow "Restore From Last Unposted Draft?" + When I press "Post" + And I should see "Work was successfully posted." + Then I should see "Fulfilled Story" + + Scenario: Maintainers can download CSV from requests or sign-ups page + + Given I am logged in as "mod1" + And I have standard challenge tags setup + And I create Battle 12 promptmeme + When I go to the "Battle 12" signups page + Then I should see "Download (CSV)" + When I go to the "Battle 12" requests page + And I follow "Download (CSV)" + Then I should download a csv file with the header row "Pseud Sign-up URL Tags Title Description" + + Scenario: Users can't download prompt CSV from requests page + + Given I have Battle 12 prompt meme fully set up + And everyone has signed up for Battle 12 + And I am logged in + When I go to the "Battle 12" requests page + Then I should not see "Download (CSV)" + + Scenario: Validation error doesn't cause semi-anon ticky to lose state (Issue 2617) + Given I set up an anon promptmeme "Scotts Prompt" with name "scotts_prompt" + And I am logged in as "Scott" with password "password" + And I go to "Scotts Prompt" collection's page + And I follow "Prompt Form" + And I check "Semi-anonymous Prompt" + And I press "Submit" + Then I should see "There were some problems with this submission. Please correct the mistakes below." + And I should see "Your Request must include between 1 and 2 fandom tags, but you have included 0 fandom tags in your current Request." + And the "Semi-anonymous Prompt" checkbox should be checked + + Scenario: Dates should be correctly set on PromptMemes + Given it is currently 2015-09-21 12:40 AM + And I am logged in as "mod1" + And I have standard challenge tags set up + And I have no prompts + When I set up Battle 12 promptmeme collection + And I check "Sign-up open?" + And I fill in "Sign-up opens:" with "2010-09-20 12:40AM" + And I fill in "Sign-up closes:" with "2010-09-22 12:40AM" + And I submit + Then I should see "If sign-ups are open, sign-up close date cannot be in the past." + When I fill in "Sign-up opens:" with "2022-09-20 12:40AM" + And I fill in "Sign-up closes:" with "2010-09-22 12:40AM" + And I submit + Then I should see "If sign-ups are open, sign-up open date cannot be in the future." + When I fill in "Sign-up opens:" with "2010-09-22 12:40AM" + And I fill in "Sign-up closes:" with "2010-09-20 12:40AM" + And I submit + Then I should see "Close date cannot be before open date." + When I fill in "Sign-up opens:" with "" + And I use tomorrow as the "Sign-up closes" date + And I submit + Then I should see "Challenge was successfully created." + + Scenario: A user who disallows gift works is cautioned about signing up for + a prompt meme, and a user who allows them is not. + Given I have Battle 12 prompt meme fully set up + And I am logged in as "participant" + And the user "participant" disallows gifts + When I go to "Battle 12" collection's page + And I follow "Prompt Form" + Then I should see "any user who claims your prompt to gift you a work in response to your prompt regardless of your preference settings" + When the user "participant" allows gifts + And I go to "Battle 12" collection's page + And I follow "Prompt Form" + Then I should not see "any user who claims your prompt to gift you a work in response to your prompt regardless of your preference settings" + + Scenario: When a work is posted to fulfill a prompt, the notes should contain the collection title + Given I have Battle 12 prompt meme fully set up + And everyone has signed up for Battle 12 + When I am logged in as "myname1" + And I claim a prompt from "Battle 12" + And I fulfill my claim + Then I should see "In response to a prompt by myname4 in the Battle 12 collection" \ No newline at end of file diff --git a/features/prompt_memes_c/challenge_promptmeme_deletion.feature b/features/prompt_memes_c/challenge_promptmeme_deletion.feature new file mode 100644 index 0000000..d7852db --- /dev/null +++ b/features/prompt_memes_c/challenge_promptmeme_deletion.feature @@ -0,0 +1,215 @@ +@collections @challenges @promptmemes +Feature: Prompt Meme Challenge + In order to have an archive full of works + As a humble user + I want to create a prompt meme and post to it + + Background: + Given I have Battle 12 prompt meme fully set up + + Scenario: Mod can delete whole sign-ups + + Given "myname1" has signed up for Battle 12 with combination A + When I am logged in as "mod1" + And I view prompts for "Battle 12" + And I follow "Delete Sign-up" + Then I should see "Challenge sign-up was deleted." + + Scenario: Mod can delete a prompt provided the user's sign-up has more than + the minimum number required for the meme + + Given "myname1" has signed up for Battle 12 with one more prompt than required + When I am logged in as "mod1" + And I view prompts for "Battle 12" + And I follow "Delete Prompt" + Then I should see "Prompt was deleted." + And I should not see "Delete Prompt" + And I should see "Delete Sign-up" + + Scenario: As a co-moderator I can delete whole sign-ups + + Given I have added a co-moderator "mod2" to collection "Battle 12" + And "myname1" has signed up for Battle 12 with combination A + When I am logged in as "mod2" + And I view prompts for "Battle 12" + And I follow "Delete Sign-up" + Then I should see "Challenge sign-up was deleted." + + Scenario: As a co-moderator I can delete prompts provided the user's sign-up + has more than the minimum number required for the meme + + Given I have added a co-moderator "mod2" to collection "Battle 12" + And "myname1" has signed up for Battle 12 with one more prompt than required + When I am logged in as "mod2" + And I view prompts for "Battle 12" + And I follow "Delete Prompt" + Then I should see "Prompt was deleted." + And I should not see "Delete Prompt" + And I should see "Delete Sign-up" + + Scenario: User can't delete prompt if they don't have more than the minimum + number required by the meme + + Given I am logged in as "myname1" + And I sign up for Battle 12 with combination C + When I view prompts for "Battle 12" + Then I should not see "Delete Prompt" + And I should see "Edit Sign-up" + + Scenario: User can delete one prompt provided their sign-up has more than the + minimum number required for the meme + + Given "myname1" has signed up for Battle 12 with one more prompt than required + When I am logged in as "myname1" + And I view prompts for "Battle 12" + And I follow "Delete Prompt" + Then I should see "Prompt was deleted." + And I should see "Sign-up for myname1" + And I should see "Delete Sign-up" + And I should not see "Delete Prompt" + + Scenario: When user deletes signup, its prompts disappear from the collection + + Given I am logged in as "myname1" + And I sign up for Battle 12 with combination A + When I delete my signup for the prompt meme "Battle 12" + And I view prompts for "Battle 12" + Then I should not see "myname1" within "ul.index" + + Scenario: When user deletes sign-up, the sign-up disappears from their + dashboard + + Given I am logged in as "myname1" + And I sign up for Battle 12 with combination A + When I delete my signup for the prompt meme "Battle 12" + And I go to myname1's signups page + Then I should see "Sign-ups (0)" + And I should not see "Battle 12" + + Scenario: When user deletes signup, the work stays part of the collection, + but no longer has the "In response to a prompt by" note + + Given "myname1" has signed up for Battle 12 with combination A + And "myname2" has fulfilled a claim from Battle 12 + And "myname1" has deleted their sign up for the prompt meme "Battle 12" + # Use the work creator account to avoid having to reveal the collection + When I am logged in as "myname2" + And I go to "Battle 12" collection's page + Then I should see "Fulfilled Story" + When I follow "Fulfilled Story" + Then I should not see "In response to a prompt" + And I should see "Battle 12" + + Scenario: When user deletes signup, the work creator can edit the work + normally + + Given "myname1" has signed up for Battle 12 with combination A + And "myname2" has fulfilled a claim from Battle 12 + And "myname1" has deleted their sign up for the prompt meme "Battle 12" + When I am logged in as "myname2" + And I edit the work "Fulfilled Story" + And I fill in "Additional Tags" with "My New Tag" + And I press "Post" + Then I should see "Work was successfully updated." + And I should see "My New Tag" + + Scenario: A mod can delete a prompt meme without needing Javascript and all the + claims and sign-ups will be deleted with it, but the collection will remain + + Given everyone has signed up for Battle 12 + And "myname4" has claimed a prompt from Battle 12 + When I am logged in as "mod1" + And I delete the challenge "Battle 12" + Then I should see "Are you sure you want to delete the challenge from the collection Battle 12? All sign-ups, assignments, and settings will be lost. (Works and bookmarks will remain in the collection.)" + When I press "Yes, Delete Challenge" + Then I should see "Challenge settings were deleted." + And I should not see the prompt meme dashboard for "Battle 12" + And no one should have a claim in "Battle 12" + And no one should be signed up for "Battle 12" + When I go to the collections page + Then I should see "Battle 12" + + Scenario: A user can still access their Sign-ups page after a prompt meme they + were signed up for has been deleted + + Given everyone has signed up for Battle 12 + And the challenge "Battle 12" is deleted + When I am logged in as "myname1" + And I go to myname1's signups page + Then I should see "Challenge Sign-ups for myname1" + And I should not see "Battle 12" + + Scenario: A user can still access their Claims page after a prompt meme they + had an unfulfilled claim in has been deleted + + Given everyone has signed up for Battle 12 + And "myname1" has claimed a prompt from Battle 12 + And the challenge "Battle 12" is deleted + When I am logged in as "myname1" + And I go to myname1's signups page + Then I should see "Challenge Sign-ups for myname1" + And I should not see "Battle 12" + + Scenario: A user can still access their Claims page after a prompt meme they + had a fulfilled claim in has been deleted + + Given everyone has signed up for Battle 12 + And "myname4" has fulfilled a claim from Battle 12 + And the challenge "Battle 12" is deleted + When I am logged in as "myname4" + And I go to myname4's claims page + Then I should see "My Claims" + When I follow "Fulfilled Claims" + Then I should not see "Battle 12" + + Scenario: The prompt line should not show on claim fills after the prompt meme + has been deleted + + Given everyone has signed up for Battle 12 + And "myname1" has fulfilled a claim from Battle 12 + And the challenge "Battle 12" is deleted + When I am logged out + And I view the work "Fulfilled Story" + Then I should not see "In response to a prompt" + + Scenario: A mod can delete a prompt meme collection and all the claims and + sign-ups will be deleted with it + + Given everyone has signed up for Battle 12 + And "myname1" has fulfilled a claim from Battle 12 + And the challenge "Battle 12" is deleted + When I am logged in as "mod1" + And I go to "Battle 12" collection's page + And I follow "Collection Settings" + And I follow "Delete" + Then I should see "Are you sure you want to delete the collection Battle 12?" + When I press "Yes, Delete Collection" + Then I should see "Collection was successfully deleted." + And no one should have a claim in "Battle 12" + And no one should be signed up for "Battle 12" + When I go to the collections page + Then I should not see "Battle 12" + + Scenario: Claim fills should still be accessible even after the prompt meme + collection has been deleted + + Given "AO3-4693" is fixed + # Given I have Battle 12 prompt meme fully set up + # And everyone has signed up for Battle 12 + # And "myname1" has fulfilled a claim from Battle 12 + # And the collection "Battle 12" is deleted + # When I view the work "Fulfilled Story" + # Then I should see "Fulfilled Story" + # TODO: Make an issue + # And I should not see "In response to a prompt" + # And I should not see "Battle 12" + + Scenario: Delete a signup, claims should also be deleted from the prompt + meme's Claims list + + Given "myname1" has signed up for Battle 12 with combination B + And "myname4" has claimed a prompt from Battle 12 + And "myname1" has deleted their sign up for the prompt meme "Battle 12" + When I am logged in as "myname4" + And I go to myname4's claims page + Then I should see "Claims (0)" diff --git a/features/search/exceeding_maximum.feature b/features/search/exceeding_maximum.feature new file mode 100644 index 0000000..c20f328 --- /dev/null +++ b/features/search/exceeding_maximum.feature @@ -0,0 +1,46 @@ +Feature: Large searches + As a user + I want to see how many results my search returns if it's more than the max + + Scenario: Work search should show the correct number of results + Given a set of Kirk/Spock works for searching + And the max search result count is 2 + And 1 item is displayed per page + When I search for works containing "James T. Kirk" + Then I should see "4 Found" + + Scenario: Bookmark search should show the correct number of results + Given I have bookmarks to search + And the max search result count is 2 + And 1 item is displayed per page + When I am on the search bookmarks page + And I select "Work" from "Type" + And I press "Search Bookmarks" + Then I should see "5 Found" + + Scenario: People search should show the correct number of results + Given the following activated users exist + | login | + | test_alice | + | test_betsy | + | test_carol | + | test_diana | + And the max search result count is 2 + And 1 item is displayed per page + And all indexing jobs have been run + When I go to the search people page + And I fill in "Search all fields" with "test" + And I press "Search People" + Then I should see "4 Found" + + Scenario: Tag search should show the correct number of results + Given a canonical freeform "Fluff" + And a canonical freeform "Angst" + And a canonical freeform "Smut" + And a canonical freeform "Alternate Universe" + And the max search result count is 2 + And 1 tag is displayed per search page + When I go to the search tags page + And I choose "Freeform" + And I press "Search Tags" + Then I should see "4 Found" diff --git a/features/search/filters.feature b/features/search/filters.feature new file mode 100644 index 0000000..c0762dc --- /dev/null +++ b/features/search/filters.feature @@ -0,0 +1,354 @@ +@users +Feature: Filters + In order to ensure filtering works on works and bookmarks + As a humble user + I want to filter on a user's works and bookmarks + + Background: + Given a canonical fandom "The Hobbit" + And a canonical fandom "Harry Potter" + And a canonical fandom "Legend of Korra" + And the work "A Hobbit's Meandering" by "meatloaf" with fandom "The Hobbit" + And the work "Bilbo Does the Thing" by "meatloaf" with fandom "The Hobbit, Legend of Korra" + And the work "Roonal Woozlib and the Ferrets of Nimh" by "meatloaf" with fandom "Harry Potter" + And all indexing jobs have been run + And the dashboard counts have expired + And I am logged in as "meatloaf" + + @javascript + Scenario: You can filter through a user's works using inclusion filters + When I go to meatloaf's user page + And I follow "Works (3)" + Then I should see "A Hobbit's Meandering" + And I should see "Bilbo Does the Thing" + And I should see "Roonal Woozlib and the Ferrets of Nimh" + And I should see "Include" + And I should see "Exclude" + When I press "Fandoms" within "dd.include" + Then I should see "The Hobbit (2)" within "#include_fandom_tags" + And I should see "Harry Potter (1)" within "#include_fandom_tags" + And I should see "Legend of Korra (1)" within "#include_fandom_tags" + When I check "The Hobbit (2)" within "#include_fandom_tags" + And I press "Sort and Filter" + Then I should see "A Hobbit's Meandering" + And I should see "Bilbo Does the Thing" + And I should not see "Roonal Woozlib and the Ferrets of Nimh" + When I press "Fandoms" within "dd.include" + Then I should see "The Hobbit (2)" within "#include_fandom_tags" + And I should see "Legend of Korra (1)" within "#include_fandom_tags" + And I should not see "Harry Potter (1)" within "#include_fandom_tags" + When I check "Legend of Korra (1)" within "#include_fandom_tags" + And press "Sort and Filter" + Then I should see "Bilbo Does the Thing" + And I should not see "A Hobbit's Meandering" + And I should not see "Roonal Woozlib and the Ferrets of Nimh" + When I follow "Clear Filters" + Then I should see "3 Works by meatloaf" + And I should see "A Hobbit's Meandering" + And I should see "Bilbo Does the Thing" + And I should see "Roonal Woozlib and the Ferrets of Nimh" + When I press "Fandoms" within "dd.include" + Then the "The Hobbit (2)" checkbox within "#include_fandom_tags" should not be checked + And the "Legend of Korra (1)" checkbox within "#include_fandom_tags" should not be checked + + @javascript + Scenario: You can filter through a user's works using exclusion filters + When I go to meatloaf's user page + And I follow "Works (3)" + When I press "Fandoms" within "dd.exclude" + Then I should see "The Hobbit (2)" within "#exclude_fandom_tags" + And I should see "Harry Potter (1)" within "#exclude_fandom_tags" + And I should see "Legend of Korra (1)" within "#exclude_fandom_tags" + When I check "Harry Potter (1)" within "#exclude_fandom_tags" + And I press "Sort and Filter" + Then I should see "Bilbo Does the Thing" + And I should see "A Hobbit's Meandering" + And I should not see "Roonal Woozlib and the Ferrets of Nimh" + When I press "Fandoms" within "dd.exclude" + Then I should see "The Hobbit (2)" within "#exclude_fandom_tags" + And I should see "Legend of Korra (1)" within "#exclude_fandom_tags" + And I should see "Harry Potter (0)" within "#exclude_fandom_tags" + When I check "Legend of Korra (1)" within "#exclude_fandom_tags" + And I press "Sort and Filter" + Then I should see "A Hobbit's Meandering" + And I should not see "Bilbo Does the Thing" + And I should not see "Roonal Woozlib and the Ferrets of Nimh" + When I follow "Clear Filters" + Then I should see "3 Works by meatloaf" + And I should see "A Hobbit's Meandering" + And I should see "Bilbo Does the Thing" + And I should see "Roonal Woozlib and the Ferrets of Nimh" + When I press "Fandoms" within "dd.exclude" + Then the "Legend of Korra (1)" checkbox within "#exclude_fandom_tags" should not be checked + And the "Harry Potter (1)" checkbox within "#exclude_fandom_tags" should not be checked + + @javascript + Scenario: Filter through a user's works with non-existent tags + Given the tag "legend korra" does not exist + And all indexing jobs have been run + + When I go to meatloaf's works page + And I fill in "Other tags to include" with "legend korra" + And I press "Sort and Filter" + Then I should see "1 Work by meatloaf" + And I should see "Bilbo Does the Thing" + And I should not see "A Hobbit's Meandering" + And I should not see "Roonal Woozlib and the Ferrets of Nimh" + + When I go to meatloaf's works page + And I fill in "Other tags to exclude" with "legend korra" + And I press "Sort and Filter" + Then I should see "2 Works by meatloaf" + And I should not see "Bilbo Does the Thing" + And I should see "A Hobbit's Meandering" + And I should see "Roonal Woozlib and the Ferrets of Nimh" + + @javascript + Scenario: You can filter through a user's bookmarks using inclusion filters + Given I am logged in as "recengine" + And I bookmark the work "Bilbo Does the Thing" + And I bookmark the work "A Hobbit's Meandering" + And I bookmark the work "Roonal Woozlib and the Ferrets of Nimh" + And the dashboard counts have expired + When I go to recengine's user page + And I follow "Bookmarks (3)" + Then I should see "Bilbo Does the Thing" + And I should see "A Hobbit's Meandering" + And I should see "Roonal Woozlib and the Ferrets of Nimh" + And I should see "Include" + And I should see "Exclude" + When I press "Fandoms" within "dd.include" + Then I should see "The Hobbit (2)" within "#include_fandom_tags" + And I should see "Harry Potter (1)" within "#include_fandom_tags" + And I should see "Legend of Korra (1)" within "#include_fandom_tags" + When I check "The Hobbit (2)" within "#include_fandom_tags" + And I press "Sort and Filter" + Then I should see "A Hobbit's Meandering" + And I should see "Bilbo Does the Thing" + And I should not see "Roonal Woozlib and the Ferrets of Nimh" + When I press "Fandoms" within "dd.include" + Then I should see "The Hobbit (2)" within "#include_fandom_tags" + And I should see "Legend of Korra (1)" within "#include_fandom_tags" + And I should not see "Harry Potter (1)" within "#include_fandom_tags" + When I check "Legend of Korra (1)" within "#include_fandom_tags" + And press "Sort and Filter" + Then I should see "Bilbo Does the Thing" + And I should not see "A Hobbit's Meandering" + And I should not see "Roonal Woozlib and the Ferrets of Nimh" + + @javascript + Scenario: You can filter through a user's bookmarks using exclusion filters + Given I am logged in as "recengine" + And I bookmark the work "Bilbo Does the Thing" + And I bookmark the work "A Hobbit's Meandering" + And I bookmark the work "Roonal Woozlib and the Ferrets of Nimh" + And the dashboard counts have expired + When I go to recengine's user page + And I follow "Bookmarks (3)" + When I press "Fandoms" within "dd.exclude" + Then the "The Hobbit (2)" checkbox within "#exclude_fandom_tags" should not be checked + And the "Harry Potter (1)" checkbox within "#exclude_fandom_tags" should not be checked + And the "Legend of Korra (1)" checkbox within "#exclude_fandom_tags" should not be checked + When I check "Harry Potter (1)" within "#exclude_fandom_tags" + And I press "Sort and Filter" + Then I should see "Bilbo Does the Thing" + And I should see "A Hobbit's Meandering" + And I should not see "Roonal Woozlib and the Ferrets of Nimh" + When I press "Fandoms" within "dd.exclude" + Then the "The Hobbit (2)" checkbox within "#exclude_fandom_tags" should not be checked + And the "Legend of Korra (1)" checkbox within "#exclude_fandom_tags" should not be checked + And the "Harry Potter (0)" checkbox within "#exclude_fandom_tags" should be checked + When I check "Legend of Korra (1)" within "#exclude_fandom_tags" + And I press "Sort and Filter" + Then I should see "A Hobbit's Meandering" + And I should not see "Bilbo Does the Thing" + And I should not see "Roonal Woozlib and the Ferrets of Nimh" + + @javascript + Scenario: Filter a user's bookmarks by "Search within results" and "Search bookmarker's tags and notes" + Given I am logged in as "recengine" + And I bookmark the work "Bilbo Does the Thing" with the tags "hobbit" + And I bookmark the work "A Hobbit's Meandering" with the tags "bilbo" + And all indexing jobs have been run + And the dashboard counts have expired + + When I go to recengine's bookmarks page + And I fill in "Search within results" with "bilbo" + And I press "Sort and Filter" + Then I should see "1 Bookmark found by recengine" + And I should see "Bilbo Does the Thing" + And I should not see "A Hobbit's Meandering" + + When I go to recengine's bookmarks page + And I fill in "Search bookmarker's tags and notes" with "bilbo" + And I press "Sort and Filter" + Then I should see "1 Bookmark found by recengine" + And I should see "A Hobbit's Meandering" + And I should not see "Bilbo Does the Thing" + + @javascript + Scenario: Filter a user's bookmarks by bookmarker's tags + Given I am logged in as "recengine" + And I bookmark the work "Bilbo Does the Thing" with the tags "to read,been here" + And I bookmark the work "A Hobbit's Meandering" with the tags "to read" + And I bookmark the work "Roonal Woozlib and the Ferrets of Nimh" with the tags "been here" + And all indexing jobs have been run + And the dashboard counts have expired + + # Use an include checkbox + When I go to recengine's bookmarks page + And I press "Bookmarker's Tags" within "dd.include" + Then the "to read (2)" checkbox within "#include_tag_tags" should not be checked + And the "been here (2)" checkbox within "#include_tag_tags" should not be checked + When I check "to read (2)" within "#include_tag_tags" + And I press "Sort and Filter" + Then I should see "2 Bookmarks by recengine" + And the "to read (2)" checkbox within "#include_tag_tags" should be checked + And I should see "Bilbo Does the Thing" + And I should see "A Hobbit's Meandering" + And I should not see "Roonal Woozlib and the Ferrets of Nimh" + + # Use a second include checkbox for bookmarks with both tags + When I check "been here (1)" within "#include_tag_tags" + And I press "Sort and Filter" + Then I should see "1 Bookmark by recengine" + And I should see "Bilbo Does the Thing" + + # Use an exclude checkbox + When I go to recengine's bookmarks page + And I press "Bookmarker's Tags" within "dd.exclude" + Then the "to read (2)" checkbox within "#exclude_tag_tags" should not be checked + And the "been here (2)" checkbox within "#exclude_tag_tags" should not be checked + When I check "to read (2)" within "#exclude_tag_tags" + And I press "Sort and Filter" + Then I should see "1 Bookmark by recengine" + And the "to read (0)" checkbox within "#exclude_tag_tags" should be checked + And I should not see "Bilbo Does the Thing" + And I should not see "A Hobbit's Meandering" + And I should see "Roonal Woozlib and the Ferrets of Nimh" + + # Use a second exclude checkbox for bookmarks with neither tags + When I check "been here (1)" within "#exclude_tag_tags" + And I press "Sort and Filter" + Then I should see "0 Bookmarks by recengine" + + # Use include field + When I go to recengine's bookmarks page + And I fill in "Other bookmarker's tags to include" with "to read" + And I press "Sort and Filter" + Then I should see "2 Bookmarks by recengine" + And I should see "Bilbo Does the Thing" + And I should see "A Hobbit's Meandering" + And I should not see "Roonal Woozlib and the Ferrets of Nimh" + + # Use exclude field + When I go to recengine's bookmarks page + And I fill in "Other bookmarker's tags to exclude" with "to read" + And I press "Sort and Filter" + Then I should see "1 Bookmark by recengine" + And I should not see "Bilbo Does the Thing" + And I should not see "A Hobbit's Meandering" + And I should see "Roonal Woozlib and the Ferrets of Nimh" + + Scenario: Filter bookmarks by a tag that appears both on bookmarked works and in bookmarker's tags + Given I am logged in as "recengine" + And I bookmark the work "Bilbo Does the Thing" + And I bookmark the work "Roonal Woozlib and the Ferrets of Nimh" with the tags "The Hobbit" + And the dashboard counts have expired + + # Exclude a tag as a work tag but not as a bookmarker's tag + When I go to recengine's bookmarks page + Then the "The Hobbit (1)" checkbox within "#exclude_fandom_tags" should not be checked + And the "The Hobbit (1)" checkbox within "#exclude_tag_tags" should not be checked + + When I check "The Hobbit (1)" within "#exclude_fandom_tags" + And I press "Sort and Filter" + Then I should see "1 Bookmark by recengine" + And I should not see "Bilbo Does the Thing" + And I should see "Roonal Woozlib and the Ferrets of Nimh" + And the "The Hobbit (0)" checkbox within "#exclude_fandom_tags" should be checked + And the "The Hobbit (1)" checkbox within "#exclude_tag_tags" should not be checked + + # Exclude a tag as a bookmarker's tag but not as a work tag + When I go to recengine's bookmarks page + And I check "The Hobbit (1)" within "#exclude_tag_tags" + And I press "Sort and Filter" + Then I should see "1 Bookmark by recengine" + And I should see "Bilbo Does the Thing" + And I should not see "Roonal Woozlib and the Ferrets of Nimh" + And the "The Hobbit (0)" checkbox within "#exclude_tag_tags" should be checked + And the "The Hobbit (1)" checkbox within "#exclude_fandom_tags" should not be checked + + # Exclude a tag as a bookmarker's tag AND as a work tag + When I go to recengine's bookmarks page + And I check "The Hobbit (1)" within "#exclude_fandom_tags" + And I check "The Hobbit (1)" within "#exclude_tag_tags" + And I press "Sort and Filter" + Then I should see "0 Bookmarks by recengine" + And I should not see "Bilbo Does the Thing" + And I should not see "Roonal Woozlib and the Ferrets of Nimh" + And the "The Hobbit (0)" checkbox within "#exclude_fandom_tags" should be checked + And the "The Hobbit (0)" checkbox within "#exclude_tag_tags" should be checked + + @javascript + Scenario: Filter a user's bookmarks by non-existent tags + Given the tag "legend korra" does not exist + And the tag "fun crossover" does not exist + And I am logged in as "recengine" + And I bookmark the work "A Hobbit's Meandering" with the tags "fun" + And I bookmark the work "Bilbo Does the Thing" with the tags "fun little crossover" + And all indexing jobs have been run + And the dashboard counts have expired + + When I go to recengine's bookmarks page + And I fill in "Other work tags to include" with "legend korra" + And I press "Sort and Filter" + Then I should see "1 Bookmark by recengine" + And I should not see "A Hobbit's Meandering" + And I should see "Bilbo Does the Thing" + + When I go to recengine's bookmarks page + And I fill in "Other work tags to exclude" with "legend korra" + And I press "Sort and Filter" + Then I should see "1 Bookmark by recengine" + And I should see "A Hobbit's Meandering" + And I should not see "Bilbo Does the Thing" + + When I go to recengine's bookmarks page + And I fill in "Other bookmarker's tags to include" with "fun crossover" + And I press "Sort and Filter" + Then I should see "1 Bookmark by recengine" + And I should not see "A Hobbit's Meandering" + And I should see "Bilbo Does the Thing" + + When I go to recengine's bookmarks page + And I fill in "Other bookmarker's tags to exclude" with "fun crossover" + And I press "Sort and Filter" + Then I should see "1 Bookmark by recengine" + And I should see "A Hobbit's Meandering" + And I should not see "Bilbo Does the Thing" + + @javascript + Scenario: Tag bookmark pages should display bookmarked items instead of bookmarks, and the sidebar counts should reflect that. + Given I am logged in as "meatloaf" + And I bookmark the work "Bilbo Does the Thing" + And I bookmark the work "A Hobbit's Meandering" + And I am logged out + And I am logged in as "anothermeatloaf" + And I bookmark the work "Bilbo Does the Thing" + And I bookmark the work "A Hobbit's Meandering" + And all indexing jobs have been run + When I go to the bookmarks tagged "The Hobbit" + Then I should see "2 Bookmarked Items in The Hobbit" + When I follow "Fandoms" + Then I should see "The Hobbit (2)" + + Scenario: Filtering with an invalid query while excluding a tag + When I go to meatloaf's user page + And I follow "Works (3)" + When I check "Harry Potter (1)" within "#exclude_fandom_tags" + And I press "Sort and Filter" + Then I should see "Bilbo Does the Thing" + When I fill in "work_search_query" with "bad~query!!!" + And I press "Sort and Filter" + Then I should see "Your search failed because of a syntax error" diff --git a/features/search/people_search.feature b/features/search/people_search.feature new file mode 100644 index 0000000..ab7f851 --- /dev/null +++ b/features/search/people_search.feature @@ -0,0 +1,130 @@ +Feature: Search pseuds + As a user + I want to use search to find other users + + Scenario: Search by name + Given the following activated users exists + | login | + | testuser | + | sad_user_with_no_pseuds | + And "testuser" has the pseud "testy" + And "testuser" has the pseud "tester_pseud" + And "testuser" has the pseud "testymctesty" + And I am logged in as "testuser" + When I go to the search people page + And I fill in "Name" with "testuser" + And I press "Search People" + Then I should see "testy" + And I should not see "sad_user_with_no_pseuds" + + When I fill in "Search all fields" with "test" + And I press "Search People" + Then I should see "0 Found" + + When I fill in "Search all fields" with "test*" + And I press "Search People" + Then I should see "4 Found" + And I should see "testy" + And I should not see "sad_user_with_no_pseuds" + + Scenario: Search by fandom + Given a canonical fandom "Ghost Soup" + And a canonical fandom "Bread" + And the work "soup" by "testuser2" with fandom "Ghost Soup" + And the work "toast" by "testy" with fandom "Bread" + And all indexing jobs have been run + And I am logged in as "testuser" + When I go to the search people page + And I fill in "Fandom" with "Ghost Soup" + And I press "Search People" + Then I should see "testuser2" + And I should not see "testy" + + Scenario: Search by fandom updates when a work is posted. + Given a canonical fandom "Ghost Soup" + And I am logged in as "testuser" + When I post a work "Drabble Collection" with fandom "Ghost Soup" + And all indexing jobs have been run + And I go to the search people page + And I fill in "Fandom" with "Ghost Soup" + And I press "Search People" + Then I should see "testuser" within "ol.pseud.group" + + Scenario: Search by fandom updates when a fandom is added to a work. + Given a canonical fandom "Ghost Soup" + And I am logged in as "testuser" + And I post the work "Drabble Collection" with fandom "MCU" + And all indexing jobs have been run + When I edit the work "Drabble Collection" + And I fill in "Fandom" with "MCU, Ghost Soup" + And I press "Post" + And all indexing jobs have been run + And I go to the search people page + And I fill in "Fandom" with "Ghost Soup" + And I press "Search People" + Then I should see "testuser" within "ol.pseud.group" + + Scenario: Search by fandom updates when a fandom is removed from a work. + Given a canonical fandom "Ghost Soup" + And I am logged in as "testuser" + And I post the work "Drabble Collection" with fandom "MCU, Ghost Soup" + And all indexing jobs have been run + When I edit the work "Drabble Collection" + And I fill in "Fandom" with "MCU" + And I press "Post" + And all indexing jobs have been run + And I go to the search people page + And I fill in "Fandom" with "Ghost Soup" + And I press "Search People" + Then I should not see "testuser" within "ol.pseud.group" + + Scenario: Search by fandom updates when an author is added to a work. + Given a canonical fandom "Ghost Soup" + And I am logged in as "testuser" + And I post the work "Drabble Collection" with fandom "Ghost Soup" + And all indexing jobs have been run + When I edit the work "Drabble Collection" + And I invite the co-author "alice" + And I press "Post" + And all indexing jobs have been run + And I go to the search people page + And I fill in "Fandom" with "Ghost Soup" + And I press "Search People" + Then I should see "testuser" within "ol.pseud.group" + But I should not see "alice" within "ol.pseud.group" + When the user "alice" accepts all co-creator requests + And all indexing jobs have been run + And I go to the search people page + And I fill in "Fandom" with "Ghost Soup" + And I press "Search People" + Then I should see "testuser" within "ol.pseud.group" + And I should see "alice" within "ol.pseud.group" + + Scenario: Search by fandom updates when an author is removed from a work. + Given a canonical fandom "Ghost Soup" + And I am logged in as "testuser" + And I post the work "Drabble Collection" with fandom "Ghost Soup" + And I add the co-author "alice" to the work "Drabble Collection" + And all indexing jobs have been run + When I edit the work "Drabble Collection" + And I follow "Remove Me As Co-Creator" + And all indexing jobs have been run + And I go to the search people page + And I fill in "Fandom" with "Ghost Soup" + And I press "Search People" + Then I should see "alice" within "ol.pseud.group" + But I should not see "testuser" within "ol.pseud.group" + + Scenario: Search by fandom updates when a work is orphaned. + Given a canonical fandom "Ghost Soup" + And I have an orphan account + And I am logged in as "testuser" + And I post the work "Drabble Collection" with fandom "Ghost Soup" + And all indexing jobs have been run + When I orphan the work "Drabble Collection" + And all indexing jobs have been run + And I go to the search people page + And I fill in "Fandom" with "Ghost Soup" + And I press "Search People" + Then I should see "orphan_account" within "ol.pseud.group" + But I should not see "testuser" within "ol.pseud.group" diff --git a/features/search/works_anonymous.feature b/features/search/works_anonymous.feature new file mode 100644 index 0000000..2075aa9 --- /dev/null +++ b/features/search/works_anonymous.feature @@ -0,0 +1,59 @@ +@works @search +Feature: Search anonymous works + As a creator of anonymous works + I do not want searching for my name to ruin my anonymity + But I do want my works to appear in searches + + Scenario: Works that are anonymous do not show up in searches for the + creator's name + Given I have the anonymous collection "Battle 12" + And I am logged in as "moderator" + And I post the work "Fulfilled Story-thing" in the collection "Battle 12" + When I search for works containing "moderator" + Then I should see "You searched for: moderator" + And I should see "No results found" + When I search for works by "moderator" + Then I should see "You searched for: creator: moderator" + And I should see "No results found" + + Scenario: Works that are anonymous should show up in searches for the + creator Anonymous + Given I have the anonymous collection "Battle 12" + And I am logged in as "moderator" + And I post the work "Fulfilled Story-thing" in the collection "Battle 12" + When I search for works containing "Anonymous" + Then I should see "You searched for: Anonymous" + And I should see "1 Found" + And I should see "Fulfilled Story-thing" + When I search for works by "Anonymous" + Then I should see "You searched for: creator: Anonymous" + And I should see "1 Found" + And I should see "Fulfilled Story-thing" + When I go to the search works page + And I fill in "Creator" with "Anonymous" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: Creator: Anonymous" + And I should see "1 Found" + And I should see "Fulfilled Story-thing" + + Scenario: Works that used to be anonymous show up in searches for the + creator's name once the creator is revealed + Given I have the anonymous collection "Battle 12" + And I am logged in as "moderator" + And I post the work "Fulfilled Story-thing" in the collection "Battle 12" + And I reveal authors for "Battle 12" + And all indexing jobs have been run + When I search for works containing "moderator" + Then I should see "You searched for: moderator" + And I should see "1 Found" + And I should see "Fulfilled Story-thing" + When I search for works by "moderator" + Then I should see "You searched for: creator: moderator" + And I should see "1 Found" + And I should see "Fulfilled Story-thing" + When I go to the search works page + And I fill in "Creator" with "moderator" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: Creator: moderator" + And I should see "1 Found" + And I should see "Fulfilled Story-thing" diff --git a/features/search/works_collected.feature b/features/search/works_collected.feature new file mode 100644 index 0000000..3fcd071 --- /dev/null +++ b/features/search/works_collected.feature @@ -0,0 +1,48 @@ +Feature: Search collected works + As a creator of collected works + I want to filter for works across all those collections + + Scenario: Works that are collected show up in the creator's + collected works page + Given I am logged in as "author" + And I create the collection "Author Collection" + And I create the collection "Other Collection" + And I post the work "Old Title" to the collection "Author Collection" + And I wait 1 second + And I post the work "Revised Title" to the collection "Author Collection" + And I wait 1 second + And I post the work "New Title" to the collection "Other Collection" + And I wait 1 second + And I post a chapter for the work "Revised Title" + When I go to author's user page + And I follow "Works (3)" + And I follow "Works in Collections" + Then I should see "Works in Challenges/Collections" + And "Date Updated" should be selected within "Sort by" + And "Revised Title" should appear before "New Title" + And "New Title" should appear before "Old Title" + When I select "Title" from "Sort by" + And I press "Sort and Filter" + Then "Title" should be selected within "Sort by" + And "New Title" should appear before "Old Title" + And "Old Title" should appear before "Revised Title" + When I select "Date Posted" from "Sort by" + And I press "Sort and Filter" + Then "Date Posted" should be selected within "Sort by" + And "New Title" should appear before "Revised Title" + And "Revised Title" should appear before "Old Title" + When I check "Author Collection" + And I check "Other Collection" + And I press "Sort and Filter" + Then the "Author Collection" checkbox should be checked + And the "Other Collection" checkbox should be checked + And I should see "New Title" + And I should see "Revised Title" + And I should see "Old Title" + When I uncheck "Other Collection" + And I press "Sort and Filter" + Then the "Author Collection" checkbox should be checked + And I should not see "Other Collection" + And I should not see "New Title" + And I should see "Revised Title" + And I should see "Old Title" diff --git a/features/search/works_info.feature b/features/search/works_info.feature new file mode 100644 index 0000000..52c9b73 --- /dev/null +++ b/features/search/works_info.feature @@ -0,0 +1,145 @@ +@works @search +Feature: Search works by work info + As a user + I want to search works by work info + + Scenario: Inputting bad queries + When I am on the homepage + When I fill in "site_search" with "bad~query!!!" + And I press "Search" + Then I should see "Your search failed because of a syntax error" + + Scenario: Search by language + Given a set of old multilanguage works for searching + When I am on the search works page + And I select "Deutsch" from "Language" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: Language: Deutsch" + And I should see "1 Found" + And I should see "My <strong>er German Work" + And the 1st result should contain "Language: Deutsch" + When I follow "Edit Your Search" + Then "Deutsch" should be selected within "Language" + + Scenario: Search by date and then refine by word count + Given a set of old multilanguage works for searching + When I am on the search works page + And I fill in "Date" with "> 2 years ago" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: revised at: > 2 years ago" + And I should see "2 Found" + And I should see "My <strong>er German Work" + And I should see "unfinished" + When I follow "Edit Your Search" + Then I should be on the search works page + And the field labeled "Date" should contain "> 2 years ago" + When I fill in "Word Count" with ">15000" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: word count: >15000 revised at: > 2 years ago" + And I should see "No results found" + + Scenario: Search with the header search field and then refine by creator + Given a set of old multilanguage works for searching + And I am logged in as "searcher" + When I fill in "site_search" with "testuser2" + And I press "Search" + Then I should see "You searched for: testuser2" + And I should see "1 Found" + And I should see "My <strong>er German Work" + When I follow "Edit Your Search" + Then I should be on the search works page + And the field labeled "Any Field" should contain "testuser2" + When I fill in "Any Field" with "" + And I fill in "Creator" with "testuser2" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: Creator: testuser2" + And I should see "1 Found" + And I should see "My <strong>er German Work" + + Scenario: Search by status + Given a set of old multilanguage works for searching + When I am on the search works page + And I choose "Complete works only" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: Complete" + And I should see "1 Found" + And I should see "My <strong>er German Work" + When I am on the search works page + And I choose "Works in progress only" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: Incomplete" + And I should see "1 Found" + And I should see "unfinished" + + Scenario: Search by crossovers + Given a set of crossover works for searching + When I am on the search works page + And I choose "Exclude crossovers" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: No Crossovers" + And I should see "5 Found" + But I should not see "Work With Multiple Fandoms" + When I am on the search works page + And I choose "Only crossovers" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: Only Crossovers" + And I should see "3 Found" + And I should see "First Work With Multiple Fandoms" + And I should see "Second Work With Multiple Fandoms" + And I should see "Third Work With Multiple Fandoms" + + Scenario: Search by single chapter + Given a set of old multilanguage works for searching + When I am on the search works page + And I check "Single Chapter" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: Single Chapter" + And I should see "1 Found" + And I should see "My <strong>er German Work" + When I follow "Edit Your Search" + Then the "Single Chapter" checkbox should be checked + + Scenario: Search and sort by title + Given the work "First work" + And the work "second work (2 of 6)" + And the work "third work" + And all indexing jobs have been run + When I am on the search works page + And I fill in "Title" with "work" + And I select "Title" from "Sort by" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: Title: work sort by: title descending" + And I should see "3 Found" + And the 1st result should contain "third work" + And the 2nd result should contain "second work" + And the 3rd result should contain "First work" + When I follow "Edit Your Search" + Then the field labeled "Title" should contain "work" + And "Title" should be selected within "Sort by" + When I select "Ascending" from "Sort direction" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: Title: work sort by: title ascending" + And I should see "3 Found" + And the 1st result should contain "First work" + And the 2nd result should contain "second work" + And the 3rd result should contain "third work" + When I follow "Edit Your Search" + Then the field labeled "Title" should contain "work" + And "Title" should be selected within "Sort by" + And "Ascending" should be selected within "Sort direction" + + Scenario: Search by number in title + Given the work "First work" + And the work "second work (2 of 6)" + And all indexing jobs have been run + When I am on the search works page + And I fill in "Title" with "work 2 6" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: Title: work 2 6" + And I should see "1 Found" + And the 1st result should contain "second work (2 of 6)" + When I am on the search works page + And I fill in "Title" with "work 1" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: Title: work 1" + And I should see "No results found" diff --git a/features/search/works_restricted.feature b/features/search/works_restricted.feature new file mode 100644 index 0000000..cdc3b9d --- /dev/null +++ b/features/search/works_restricted.feature @@ -0,0 +1,47 @@ +@works @search +Feature: Search restricted works + As a user + I want search results to only include works I can access + + Scenario: Search results for logged out users should contain only posted works + that are public; they should not contain works that are drafts, restricted to + registered users, or hidden by an admin + Given a set of works with various access levels for searching + And I am a visitor + When I search for works containing "Work" + Then I should see "You searched for: Work" + And I should see "1 Found" + And I should see "Posted Work" + And I should not see "Restricted Work" + And I should not see "Work Hidden by Admin" + And I should not see "Draft Work" + + Scenario: Search results for logged in users should contain only posted works + that are public or restricted to registered users; they should not contain + drafts or works hidden by an admin + Given a set of works with various access levels for searching + And I am logged in as a random user + When I search for works containing "Work" + Then I should see "You searched for: Work" + And I should see "2 Found" + And I should see "Posted Work" + And I should see "Restricted Work" + And I should not see "Work Hidden by Admin" + And I should not see "Draft Work" + + Scenario: Searching for restricted works only returns results for logged in + users or admins + Given a set of works with various access levels for searching + And I am logged in as a random user + When I search for works containing "restricted: true" + Then I should see "You searched for: restricted: true" + And I should see "1 Found" + And the results should contain only the restricted work + When I log out + And I search for works containing "restricted: true" + Then I should see "You searched for: restricted: true" + And I should see "No results found." + When I am logged in as an admin + And I search for works containing "restricted: true" + Then I should see "1 Found" + And the results should contain only the restricted work diff --git a/features/search/works_stats.feature b/features/search/works_stats.feature new file mode 100644 index 0000000..ed5b9ad --- /dev/null +++ b/features/search/works_stats.feature @@ -0,0 +1,233 @@ +@works @search +Feature: Search works by stats + As a user + I want to search works by hits, kudos, comments, and bookmarks + + Scenario: Search by range of hits + Given a set of works with stats for searching + When I am on the search works page + And I fill in "Hits" with "10000-20000" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: hits: 10000-20000" + And I should see "1 Found" + And the 1st result should contain "Hits: 10,000" + And I should see "many" + When I follow "Edit Your Search" + Then the field labeled "Hits" should contain "10000-20000" + + Scenario: Search by > hits + Given a set of works with stats for searching + When I am on the search works page + And I fill in "Hits" with "> 100" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: hits: > 100" + And I should see "2 Found" + And I should see "many" + And I should see "less" + When I follow "Edit Your Search" + Then the field labeled "Hits" should contain "> 100" + + Scenario: Search and sort by kudos + Given a set of works with stats for searching + When I am on the search works page + And I fill in "Kudos" with ">0" + And I select "Kudos" from "Sort by" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: kudos count: >0 sort by: kudos descending" + And I should see "2 Found" + And the 1st result should contain "Kudos: 4" + And the 2nd result should contain "Kudos: 1" + When I follow "Edit Your Search" + Then the field labeled "Kudos" should contain ">0" + And "Kudos" should be selected within "Sort by" + When I fill in "Kudos" with "5" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: kudos count: 5 sort by: kudos descending" + And I should see "No results found" + When I follow "Edit Your Search" + Then the field labeled "Kudos" should contain "5" + When I fill in "Kudos" with "4" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: kudos count: 4 sort by: kudos descending" + And I should see "1 Found" + And the 1st result should contain "Kudos: 4" + And I should see "many" + When I follow "Edit Your Search" + Then the field labeled "Kudos" should contain "4" + When I fill in "Kudos" with "<2" + And I select "Ascending" from "Sort direction" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: kudos count: <2 sort by: kudos ascending" + And I should see "3 Found" + And I should see "unfinished" + And I should see "none" + And I should see "less" + And the 3rd result should contain "Kudos: 1" + When I follow "Edit Your Search" + Then the field labeled "Kudos" should contain "<2" + And "Kudos" should be selected within "Sort by" + And "Ascending" should be selected within "Sort direction" + When I choose "Complete works only" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: Complete kudos count: <2 sort by: kudos ascending" + And I should see "2 Found" + And I should see "none" + And I should see "less" + And the 2nd result should contain "Kudos: 1" + When I follow "Edit Your Search" + Then the field labeled "Kudos" should contain "<2" + And the "Complete works only" checkbox should be checked + And "Ascending" should be selected within "Sort direction" + + Scenario: Search by exact number of comments + Given a set of works with comments for searching + When I am on the search works page + And I fill in "Comments" with "1" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: comments count: 1" + And I should see "3 Found" + When I follow "Edit Your Search" + Then the field labeled "Comments" should contain "1" + + Scenario: Search by a range of comments + Given a set of works with comments for searching + When I am on the search works page + And I fill in "Comments" with "1-5" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: comments count: 1-5" + And I should see "5 Found" + When I follow "Edit Your Search" + Then the field labeled "Comments" should contain "1-5" + + Scenario: Search by > a number of comments and sort in ascending order by + comments + Given a set of works with comments for searching + When I am on the search works page + And I fill in "Comments" with "> 0" + And I select "Comments" from "Sort by" + And I select "Ascending" from "Sort direction" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: comments count: > 0 sort by: comments ascending" + And I should see "6 Found" + And the 1st result should contain "Comments: 1" + And the 2nd result should contain "Comments: 1" + And the 3rd result should contain "Comments: 1" + And the 4th result should contain "Comments: 3" + And the 5th result should contain "Comments: 3" + And the 6th result should contain "Comments: 10" + When I follow "Edit Your Search" + Then the field labeled "Comments" should contain "> 0" + And "Comments" should be selected within "Sort by" + And "Ascending" should be selected within "Sort direction" + + Scenario: Search by < a number of comments and sort in descending order by + comments + Given a set of works with comments for searching + When I am on the search works page + And I fill in "Comments" with "<20" + And I select "Comments" from "Sort by" + And I select "Descending" from "Sort direction" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: comments count: <20 sort by: comments descending" + And I should see "7 Found" + And the 1st result should contain "Comments: 10" + And the 2nd result should contain "Comments: 3" + And the 3rd result should contain "Comments: 3" + And the 4th result should contain "Comments: 1" + And the 5th result should contain "Comments: 1" + And the 6th result should contain "Comments: 1" + When I follow "Edit Your Search" + Then the field labeled "Comments" should contain "<20" + And "Comments" should be selected within "Sort by" + And "Descending" should be selected within "Sort direction" + + Scenario: Search by > a number of comments and sort in ascending order by + title using the header search + Given a set of works with comments for searching + When I am on the home page + And I fill in "site_search" with "comments: > 2 sort: title ascending" + And I press "Search" + Then I should see "You searched for: comments count: > 2 sort by: title ascending" + And I should see "3 Found" + And the 1st result should contain "Work 5" + And the 2nd result should contain "Work 6" + And the 3rd result should contain "Work 7" + When I follow "Edit Your Search" + Then the field labeled "Comments" should contain "> 2" + And "Title" should be selected within "Sort by" + And "Ascending" should be selected within "Sort direction" + + Scenario: Search by exact number of bookmarks + Given a set of works with bookmarks for searching + When I am on the search works page + And I fill in "Bookmarks" with "1" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: bookmarks count: 1" + And I should see "2 Found" + When I follow "Edit Your Search" + Then the field labeled "Bookmarks" should contain "1" + + Scenario: Search by a range of bookmarks + Given a set of works with bookmarks for searching + When I am on the search works page + And I fill in "Bookmarks" with "2 - 5" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: bookmarks count: 2 - 5" + And I should see "3 Found" + When I follow "Edit Your Search" + Then the field labeled "Bookmarks" should contain "2 - 5" + + Scenario: Search by > a number of bookmarks and sort in ascending order by + bookmarks + Given a set of works with bookmarks for searching + When I am on the search works page + And I fill in "Bookmarks" with ">1" + And I select "Bookmarks" from "Sort by" + And I select "Ascending" from "Sort direction" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: bookmarks count: >1 sort by: bookmarks ascending" + And I should see "4 Found" + And the 1st result should contain "Bookmarks: 2" + And the 2nd result should contain "Bookmarks: 2" + And the 3rd result should contain "Bookmarks: 4" + And the 4th result should contain "Bookmarks: 10" + When I follow "Edit Your Search" + Then the field labeled "Bookmarks" should contain ">1" + And "Bookmarks" should be selected within "Sort by" + And "Ascending" should be selected within "Sort direction" + + Scenario: Search by < a number of bookmarks and sort in descending order by + bookmarks + Given a set of works with bookmarks for searching + When I am on the search works page + And I fill in "Bookmarks" with "< 20" + And I select "Bookmarks" from "Sort by" + And I select "Descending" from "Sort direction" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: bookmarks count: < 20 sort by: bookmarks descending" + And I should see "7 Found" + And the 1st result should contain "Bookmarks: 10" + And the 2nd result should contain "Bookmarks: 4" + And the 3rd result should contain "Bookmarks: 2" + And the 4th result should contain "Bookmarks: 2" + And the 5th result should contain "Bookmarks: 1" + And the 6th result should contain "Bookmarks: 1" + When I follow "Edit Your Search" + Then the field labeled "Bookmarks" should contain "< 20" + And "Bookmarks" should be selected within "Sort by" + And "Descending" should be selected within "Sort direction" + + Scenario: Search by > a number of bookmarks and sort in ascending order by + title using the header search + Given a set of works with bookmarks for searching + When I am on the home page + And I fill in "site_search" with "bookmarks: > 2 sort by: title ascending" + And I press "Search" + Then I should see "You searched for: bookmarks count: > 2 sort by: title ascending" + And I should see "2 Found" + And the 1st result should contain "Work 6" + And the 2nd result should contain "Work 7" + When I follow "Edit Your Search" + Then the field labeled "Bookmarks" should contain "> 2" + And "Title" should be selected within "Sort by" + And "Ascending" should be selected within "Sort direction" diff --git a/features/search/works_tags.feature b/features/search/works_tags.feature new file mode 100644 index 0000000..fb8d3b5 --- /dev/null +++ b/features/search/works_tags.feature @@ -0,0 +1,304 @@ +@works @search +Feature: Search works by tag + As a user + I want to search works by tags + + Scenario: Searching for a fandom in the header search returns works with (a) + the exact tag, (b) the tag's syns, (c) the tag's subtags and _their_ syns, and + (d) any other tags or text matching the search term; refining the search with + the fandom field returns only works with (a), (b), or (c) + Given a set of Star Trek works for searching + When I search for works containing "Star Trek" + Then I should see "You searched for: Star Trek" + And I should see "6 Found" + And the results should contain the fandom tag "Star Trek" + And the results should contain the subtags of "Star Trek" + # A synonym of one of the Star Trek subtags + And the results should contain the fandom tag "ST: TOS" + And the results should contain a freeform mentioning "Star Trek" + When I follow "Edit Your Search" + Then the field labeled "Any Field" should contain "Star Trek" + When I fill in "Fandoms" with "Star Trek" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: Star Trek Tags: Star Trek" + And I should see "5 Found" + And the results should contain the fandom tag "Star Trek" + And the results should contain the subtags of "Star Trek" + # A synonym of one of the Star Trek subtags + And the results should contain the fandom tag "ST: TOS" + And the results should not contain a freeform mentioning "Star Trek" + When I follow "Edit Your Search" + Then the field labeled "Any Field" should contain "Star Trek" + And the field labeled "Fandoms" should contain "Star Trek" + + Scenario: Searching by fandom for a tag that does not exist returns 0 results + Given a set of Star Trek works for searching + When I am on the search works page + And I fill in "Fandoms" with "Star Trek: The Next Generation" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: Tags: Star Trek: The Next Generation" + And I should see "No results found." + And I should see "You may want to edit your search to make it less specific." + + # We use JavaScript here because otherwise there is a minor spacing issue with + # "You searched for" on the results page and the coder who wrote this test was + # offended by it + @javascript + Scenario: Searching by fandom for two fandoms returns only works tagged with + both fandoms (or syns or subtags of those fandoms) + Given a set of Star Trek works for searching + When I am on the search works page + And I fill in "Fandoms" with "Star Trek, Battlestar Galactica (2003)" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: Tags: Star Trek, Battlestar Galactica (2003)" + And I should see "1 Found" + # A synonym of one of the Star Trek subtags + And the results should contain the fandom tag "ST: TOS" + And the results should contain the fandom tag "Battlestar Galactica (2003)" + When I follow "Edit Your Search" + Then "Star Trek" should already be entered in the work search fandom autocomplete field + And "Battlestar Galactica (2003)" should already be entered in the work search fandom autocomplete field + + Scenario: Searching by rating returns only works using that rating + Given a set of works with various ratings for searching + When I am on the search works page + And I select "Teen And Up Audiences" from "Rating" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: Tags: Teen And Up Audiences" + And I should see "1 Found" + And the results should contain the rating tag "Teen And Up Audiences" + When I follow "Edit Your Search" + Then "Teen And Up Audiences" should be selected within "Rating" + + Scenario: Searching for Explicit or Mature in the header returns works with + (a) either rating or (b) other tags or text matching either rating; editing + the search to use the ratings' filter_ids returns only (a) + Given a set of works with various ratings for searching + When I search for works containing "Mature || Explicit" + Then I should see "You searched for: Mature || Explicit" + And I should see "3 Found" + And the results should contain the rating tag "Mature" + And the results should contain the rating tag "Explicit" + And the results should contain a summary mentioning "explicit" + When I follow "Edit Your Search" + Then the field labeled "Any Field" should contain "Mature || Explicit" + When I exclude the tags "Mature" and "Explicit" by filter_id + And I press "Search" within "#new_work_search" + Then the search summary should include the filter_id for "Mature" + And the search summary should include the filter_id for "Explicit" + And the results should not contain the rating tag "Mature" + And the results should not contain the rating tag "Explicit" + + Scenario: Using Any Field to exclude works using (a) one of the two ratings or (b) other tags or text matching either rating + Given a set of works with various ratings for searching + When I am on the search works page + And I fill in "Any Field" with "-Mature -Explicit" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: -Mature -Explicit" + And I should see "3 Found" + And the results should contain the rating tag "General Audiences" + And the results should contain the rating tag "Teen And Up Audiences" + And the results should contain the rating tag "Not Rated" + And the results should not contain a summary mentioning "explicit" + When I follow "Edit Your Search" + Then the field labeled "Any Field" should contain "-Mature -Explicit" + + Scenario: Searching by warning returns all works using that warning tag + Given a set of works with various warnings for searching + When I am on the search works page + And I check "No Archive Warnings Apply" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: Tags: No Archive Warnings Apply" + And I should see "2 Found" + And the 1st result should contain "No Archive Warnings Apply" + And the 2nd result should contain "No Archive Warnings Apply" + When I follow "Edit Your Search" + Then the "No Archive Warnings Apply" checkbox should be checked + + Scenario: Using the header search to exclude works with certain warnings using the warnings' filter_ids + Given a set of works with various warnings for searching + When I search for works without the "Rape/Non-Con" and "Underage Sex" filter_ids + Then the search summary should include the filter_id for "Rape/Non-Con" + And the search summary should include the filter_id for "Underage Sex" + And I should see "5 Found" + And the results should not contain the warning tag "Underage Sex" + And the results should not contain the warning tag "Rape/Non-Con" + + Scenario: Searching by category returns all works using that category; search + can be refined using Any Field to return works using only that category + Given a set of works with various categories for searching + When I am on the search works page + And I check "F/F" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: Tags: F/F" + And I should see "2 Found" + And the results should contain the category tag "F/F" + And the results should contain the category tag "M/M, F/F" + When I follow "Edit Your Search" + Then the "F/F" checkbox should be checked + When I fill in "Any Field" with "-M/M" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: -M/M Tags: F/F" + And I should see "1 Found" + + Scenario: Searching by category for Multi only returns works tagged with the + Multi category, not works tagged with multiple categories + Given a set of works with various categories for searching + When I am on the search works page + And I check "Multi" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: Tags: Multi" + And I should see "1 Found" + And the results should contain the category tag "Multi" + And the results should not contain the category tag "M/M, F/F" + When I follow "Edit Your Search" + Then the "Multi" checkbox should be checked + + Scenario: Searching for a character in the header search returns works with + (a) the exact tag, (b) the tag's syns, or (c) any other tags or text matching + the search term + Given a set of Steve Rogers works for searching + When I search for works containing "Steve Rogers" + Then I should see "You searched for: Steve Rogers" + And I should see "6 Found" + And the results should contain the character tag "Steve Rogers" + And the results should contain a synonym of "Steve Rogers" + And the results should contain a relationship mentioning "Steve Rogers" + And the results should contain a summary mentioning "Steve Rogers" + When I follow "Edit Your Search" + Then the field labeled "Any Field" should contain "Steve Rogers" + + Scenario: Searching by character for a tag with synonyms returns works using + the exact tag or its synonyms + Given a set of Steve Rogers works for searching + When I am on the search works page + And I fill in "Characters" with "Steve Rogers" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: Tags: Steve Rogers" + And I should see "4 Found" + And the results should contain the character tag "Steve Rogers" + And the results should contain a synonym of "Steve Rogers" + And the results should not contain a relationship mentioning "Steve Rogers" + And the results should not contain a summary mentioning "Steve Rogers" + When I follow "Edit Your Search" + Then the field labeled "Characters" should contain "Steve Rogers" + + Scenario: Searching for a relationship in the header search returns works + with (a) the exact tag and (b) the tag's syns, and (c) any other tags or text + matching the search term (e.g. a threesome); refining the search with the + relationship field returns only (a) or (b) + Given a set of Spock/Uhura works for searching + When I search for works containing "Spock/Nyota Uhura" + Then I should see "You searched for: Spock/Nyota Uhura" + And I should see "3 Found" + And the results should contain the relationship tag "Spock/Nyota Uhura" + And the results should contain a synonym of "Spock/Nyota Uhura" + And the results should contain the relationship tag "James T. Kirk/Spock/Nyota Uhura" + When I follow "Edit Your Search" + Then the field labeled "Any Field" should contain "Spock/Nyota Uhura" + When I fill in "Relationships" with "Spock/Nyota Uhura" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: Spock/Nyota Uhura Tags: Spock/Nyota Uhura" + And I should see "2 Found" + And the results should contain the relationship tag "Spock/Nyota Uhura" + And the results should contain a synonym of "Spock/Nyota Uhura" + And the results should not contain the relationship tag "James T. Kirk/Spock/Nyota Uhura" + + Scenario: Searching by relationship returns works using the exact tag or its + synonyms + Given a set of Kirk/Spock works for searching + When I am on the search works page + And I fill in "Relationships" with "James T. Kirk/Spock" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: Tags: James T. Kirk/Spock" + And I should see "4 Found" + And the results should contain the relationship tag "James T. Kirk/Spock" + And the results should contain the synonyms of "James T. Kirk/Spock" + When I follow "Edit Your Search" + Then the field labeled "Relationships" should contain "James T. Kirk/Spock" + + Scenario: Searching by otp: true returns works with one relationship tag or + multiple synonymous relationship tags. + Given a set of Ed Stede works for searching + When I am on the search works page + And I fill in "Any Field" with "otp: true" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: otp: true" + And I should see "3 Found" + But I should not see "The Work Without a Relationship" + And I should not see "The Work With Multiple Ships" + When I follow "Edit Your Search" + Then the field labeled "Any Field" should contain "otp: true" + + Scenario: Searching by relationship and category returns only works using the + category and the exact relationship tag or its synonyms + Given a set of Kirk/Spock works for searching + When I am on the search works page + And I fill in "Relationships" with "James T. Kirk/Spock" + And I check "F/M" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: Tags: F/M, James T. Kirk/Spock" + And I should see "1 Found" + And the results should contain the category tag "F/M" + And the results should contain the relationship tag "Spirk" + When I follow "Edit Your Search" + Then the field labeled "Relationships" should contain "James T. Kirk/Spock" + And the "F/M" checkbox should be checked + + Scenario: Searching by additional tags (freeforms) for a metatag with synonyms + and subtags should return works using (a) the exact tag, (b) its synonyms, (c) + its subtags, and (d) its subtags' synonyms + Given a set of alternate universe works for searching + When I am on the search works page + And I fill in "Additional Tags" with "Alternate Universe" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: Tags: Alternate Universe" + And I should see "4 Found" + And the results should contain the freeform tag "Alternate Universe" + And the results should contain a synonym of "Alternate Universe" + And the results should contain the freeform tag "High School AU" + And the results should contain the freeform tag "Alternate Universe - Coffee Shops & Cafés" + And the results should not contain the freeform tag "Coffee Shop AU" + When I follow "Edit Your Search" + Then the field labeled "Additional Tags" should contain "Alternate Universe" + + Scenario: Searching by additional tags (freeforms) for a synonym of a metatag + returns only works using the exact tag + Given a set of alternate universe works for searching + When I am on the search works page + And I fill in "Additional Tags" with "AU" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: Tags: AU" + And I should see "1 Found" + And the results should contain the freeform tag "AU" + And the results should not contain the freeform tag "High School AU" + And the results should not contain the freeform tag "Coffee Shop AU" + And the results should not contain a character mentioning "AU" + When I follow "Edit Your Search" + Then the field labeled "Additional Tags" should contain "AU" + + Scenario: Searching by additional tags (freeforms) for a tag with no direct + uses returns works using the tag's synonyms + Given a set of alternate universe works for searching + When I am on the search works page + And I fill in "Additional Tags" with "Alternate Universe - High School" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: Tags: Alternate Universe - High School" + And I should see "1 Found" + And the results should contain a synonym of "Alternate Universe - High School" + When I follow "Edit Your Search" + Then the field labeled "Additional Tags" should contain "Alternate Universe - High School" + + Scenario: Searching by additional tags (freeforms) for a tag that has not been + wrangled returns only works using tags containing the search term (not + summaries, titles, etc) + Given a set of alternate universe works for searching + When I am on the search works page + And I fill in "Additional Tags" with "Coffee Shop AU" + And I press "Search" within "#new_work_search" + Then I should see "You searched for: Tags: Coffee Shop AU" + And I should see "1 Found" + And the results should contain the freeform tag "Coffee Shop AU" + And the results should not contain a summary mentioning "Coffee Shop AU" + When I follow "Edit Your Search" + Then the field labeled "Additional Tags" should contain "Coffee Shop AU" diff --git a/features/step_definitions/admin_steps.rb b/features/step_definitions/admin_steps.rb new file mode 100644 index 0000000..8a3ec4b --- /dev/null +++ b/features/step_definitions/admin_steps.rb @@ -0,0 +1,579 @@ +### GIVEN + +Given /^the following admin settings are configured:$/ do |table| + admin_settings = AdminSetting.first + admin_settings.assign_attributes(table.rows_hash.symbolize_keys) + # Skip validations which require setting an admin as the last updater. + admin_settings.save!(validate: false) +end + +Given /the following admins? exists?/ do |table| + table.hashes.each do |hash| + FactoryBot.create(:admin, hash) + end +end + +Given "I am logged in as a super admin" do + step %{I am logged in as a "superadmin" admin} +end + +Given "I am logged in as a(n) {string} admin" do |role| + step "I start a new session" + login = "testadmin-#{role}" + email = "#{login}@example.org" + FactoryBot.create(:admin, login: login, email: email, roles: [role], password: "adminpassword") if Admin.find_by(login: login, email: email).nil? + visit new_admin_session_path + fill_in "Admin username", with: login + fill_in "Admin password", with: "adminpassword" + click_button "Log In as Admin" + step %{I should see "Successfully logged in"} +end + +Given "I am logged in as an admin" do + step "I start a new session" + FactoryBot.create(:admin, login: "testadmin", email: "testadmin@example.org", password: "adminpassword") if Admin.find_by(login: "testadmin").nil? + visit new_admin_session_path + fill_in "Admin username", with: "testadmin" + fill_in "Admin password", with: "adminpassword" + click_button "Log In as Admin" + step %{I should see "Successfully logged in"} +end + +Given /^basic languages$/ do + Language.default + german = Language.find_or_create_by(short: "DE", name: "Deutsch", support_available: true, abuse_support_available: true) + Locale.create(iso: "de", name: "Deutsch", language: german) +end + +Given /^Persian language$/ do + Language.default + persian = Language.find_or_create_by(short: "fa", name: "Persian", support_available: true, abuse_support_available: true) + Locale.create(iso: "fa", name: "Persian", language: persian) +end + +Given /^downloads are off$/ do + step("I am logged in as a super admin") + visit(admin_settings_path) + uncheck("Allow downloads") + click_button("Update") +end + +Given /^tag wrangling is off$/ do + step(%{I am logged in as a "tag_wrangling" admin}) + visit(admin_settings_path) + step(%{I check "Turn off tag wrangling for non-admins"}) + step(%{I press "Update"}) + step("I log out") +end + +Given /^tag wrangling is on$/ do + step(%{I am logged in as a "tag_wrangling" admin}) + visit(admin_settings_path) + step(%{I uncheck "Turn off tag wrangling for non-admins"}) + step(%{I press "Update"}) + step("I log out") +end + +Given /^the support form is disabled and its text field set to "Please don't contact us"$/ do + step(%{I am logged in as a "support" admin}) + visit(admin_settings_path) + check("Turn off support form") + fill_in(:admin_setting_disabled_support_form_text, with: "Please don't contact us") + click_button("Update") +end + +Given /^the support form is enabled$/ do + step(%{I am logged in as a "support" admin}) + visit(admin_settings_path) + uncheck("Turn off support form") + click_button("Update") +end + +Given "guest comments are on" do + step("I am logged in as a super admin") + visit(admin_settings_path) + uncheck("Turn off guest comments across the site") + click_button("Update") +end + +Given "guest comments are off" do + step("I am logged in as a super admin") + visit(admin_settings_path) + check("Turn off guest comments across the site") + click_button("Update") +end + +Given "account age threshold for comment spam check is set to {int} days" do |days| + step("I am logged in as a super admin") + visit(admin_settings_path) + fill_in("admin_setting_account_age_threshold_for_comment_spam_check", with: days) + click_button("Update") +end + +Given "I have posted known issues" do + step %{I am logged in as a super admin} + step %{I follow "Admin Posts"} + step %{I follow "Known Issues" within "#header"} + step %{I follow "make a new known issues post"} + step %{I fill in "known_issue_title" with "First known problem"} + step %{I fill in "content" with "This is a bit of a problem"} + step %{I press "Post"} +end + +Given /^I have posted an admin post$/ do + step(%{I am logged in as a "communications" admin}) + step("I make an admin post") + step("I log out") +end + +Given "the admin post {string}" do |title| + FactoryBot.create(:admin_post, title: title, comment_permissions: :enable_all) +end + +Given "the fannish next of kin {string} for the user {string}" do |kin, user| + user = ensure_user(user) + kin = ensure_user(kin) + user.create_fannish_next_of_kin(kin: kin, kin_email: "fnok@example.com") +end + +Given "the user {string} is suspended" do |user| + step %{the user "#{user}" exists and is activated} + step %{I am logged in as a "policy_and_abuse" admin} + step %{I go to the user administration page for "#{user}"} + choose("admin_action_suspend") + fill_in("suspend_days", with: 30) + fill_in("Notes", with: "Why they are suspended") + click_button("Update") +end + +Given /^the user "([^\"]*)" is banned$/ do |user| + step %{the user "#{user}" exists and is activated} + step(%{I am logged in as a "policy_and_abuse" admin}) + step %{I go to the user administration page for "#{user}"} + choose("admin_action_ban") + fill_in("Notes", with: "Why they are banned") + click_button("Update") +end + +Then /^the user "([^\"]*)" should be permanently banned$/ do |user| + u = User.find_by(login: user) + assert u.banned? +end + +Given /^I have posted an admin post without paragraphs$/ do + step(%{I am logged in as a "communications" admin}) + step("I make an admin post without paragraphs") + step("I log out") +end + +Given /^I have posted an admin post with tags "(.*?)"$/ do |tags| + step(%{I am logged in as a "communications" admin}) + visit new_admin_post_path + fill_in("admin_post_title", with: "Default Admin Post") + fill_in("content", with: "Content of the admin post.") + fill_in("admin_post_tag_list", with: tags) + click_button("Post") +end + +Given(/^the following language exists$/) do |table| + table.hashes.each do |hash| + FactoryBot.create(:language, hash) + end +end + +Given /^I have posted an admin post with guest comments disabled$/ do + step %{I am logged in as a "communications" admin} + step %{I start to make an admin post} + choose("Only registered users can comment") + click_button("Post") + step %{I log out} +end + +Given /^I have posted an admin post with comments disabled$/ do + step %{I am logged in as a "communications" admin} + step %{I start to make an admin post} + choose("No one can comment") + click_button("Post") + step %{I log out} +end + +Given "an abuse ticket ID exists" do + ticket = { + "departmentId" => ArchiveConfig.ABUSE_ZOHO_DEPARTMENT_ID, + "status" => "Open", + "webUrl" => Faker::Internet.url + } + allow_any_instance_of(ZohoResourceClient).to receive(:find_ticket).and_return(ticket) +end + +Given "a work {string} with the original creator {string}" do |title, creator| + step %{the work "#{title}" by "#{creator}"} + step %{I have an orphan account} + step %{"#{creator}" orphans and takes their pseud off the work "#{title}"} +end + +Given "the admin {string} is locked" do |login| + admin = Admin.find_by(login: login) || FactoryBot.create(:admin, login: login) + # Same as script/lock_admin.rb + admin.lock_access!({ send_instructions: false }) +end + +Given "the admin {string} is unlocked" do |login| + admin = Admin.find_by(login: login) || FactoryBot.create(:admin, login: login) + # Same as script/unlock_admin.rb + admin.unlock_access! +end + +Given "there is/are {int} user creation(s) per page" do |amount| + allow(Work).to receive(:per_page).and_return(amount) + allow(Comment).to receive(:per_page).and_return(amount) +end + +Given "an archive FAQ category with the title {string} exists" do |title| + FactoryBot.create(:archive_faq, title: title) +end + +Given "the app name is {string}" do |app_name| + allow(ArchiveConfig).to receive(:APP_NAME).and_return(app_name) +end + +### WHEN + +When /^I visit the last activities item$/ do + visit("/admin/activities/#{AdminActivity.last.id}") +end + +When /^I fill in "([^"]*)" with "([^"]*)'s" invite code$/ do |field, login| + user = User.find_by(login: login) + token = user.invitations.first.token + fill_in(field, with: token) +end + +When "I start to make an admin post" do + visit new_admin_post_path + fill_in("admin_post_title", with: "Default Admin Post") + fill_in("content", with: "Content of the admin post.") +end + +When /^I make an admin post$/ do + step %(I start to make an admin post) + click_button("Post") +end + +When /^I make an admin post without paragraphs$/ do + visit new_admin_post_path + fill_in("admin_post_title", with: "Admin Post Without Paragraphs") + fill_in("content", with: "<ul><li>This post</li><li>is just</li><li>a list</li></ul>") + click_button("Post") +end + +When /^I make a multi-question FAQ post$/ do + visit new_archive_faq_path + fill_in("Question*", with: "Number 1 Question.") + fill_in("Answer*", with: "Number 1 posted FAQ, this is.") + fill_in("Category name*", with: "Standard FAQ Category") + fill_in("Anchor name*", with: "Number1anchor") + click_button("Post") + step %{I follow "Edit"} + step %{I fill in "Questions:" with "3"} + step %{I press "Update Form"} + fill_in("archive_faq_questions_attributes_1_question", with: "Number 2 Question.") + fill_in("archive_faq_questions_attributes_1_content", with: "This is an answer to the second question") + fill_in("archive_faq_questions_attributes_1_anchor", with: "whatisao32") + fill_in("archive_faq_questions_attributes_2_question", with: "Number 3 Question.") + fill_in("archive_faq_questions_attributes_2_content", with: "This is an answer to the third question") + fill_in("archive_faq_questions_attributes_2_anchor", with: "whatisao33") + click_button("Post") +end + +When "{int} Archive FAQ(s) exist(s)" do |n| + (1..n).each do |i| + FactoryBot.create(:archive_faq, id: i, title: "The #{i} FAQ") + end +end + +When "{int} Archive FAQ(s) with {int} question(s) exist(s)" do |faqs, questions| + (1..faqs).each do |i| + archive_faq = FactoryBot.create(:archive_faq, id: i) + (1..questions).each do + FactoryBot.create(:question, archive_faq: archive_faq) + end + end +end + +When "the invite_from_queue_at is yesterday" do + AdminSetting.first.update_attribute(:invite_from_queue_at, Time.current - 1.day) +end + +When "the scheduled check_invite_queue job is run" do + Resque.enqueue(AdminSetting, :check_queue) +end + +When "I edit known issues" do + step %{I follow "Admin Posts"} + step %{I follow "Known Issues" within "#header"} + step %{I follow "Edit"} + step %{I fill in "known_issue_title" with "More known problems"} + step %{I fill in "content" with "This is a bit of a problem, and this is too"} + step %{I press "Post"} +end + +When "I delete known issues" do + step %{I follow "Admin Posts"} + step %{I follow "Known Issues" within "#header"} + step %{I follow "Delete"} +end + +When "I check the {string} role checkbox" do |role| + role_id = Role.find_by(name: role).id + check("user_roles_#{role_id}") +end + +When "I uncheck the {string} role checkbox" do |role| + role_id = Role.find_by(name: role).id + uncheck("user_roles_#{role_id}") +end + +When /^I make a translation of an admin post( with tags "(.*?)")?$/ do |tags| + admin_post = AdminPost.find_by(title: "Default Admin Post") + # If post doesn't exist, assume we want to reference a non-existent post + admin_post_id = !admin_post.nil? ? admin_post.id : 0 + visit new_admin_post_path + fill_in("admin_post_title", with: "Deutsch Ankuendigung") + fill_in("content", with: "Deutsch Woerter") + step %{I select "Deutsch" from "Choose a language"} + fill_in("admin_post_translated_post_id", with: admin_post_id) + fill_in("admin_post_tag_list", with: tags) if tags + click_button("Post") +end + +When /^I hide the work "(.*?)"$/ do |title| + work = Work.find_by(title: title) + visit work_path(work) + step %{I follow "Hide Work"} +end + +When "I unhide the work {string}" do |title| + work = Work.find_by(title: title) + visit work_path(work) + step %{I follow "Make Work Visible"} +end + +When "the search criteria contains the ID for {string}" do |login| + user_id = User.find_by(login: login).id + fill_in("user_id", with: user_id) +end + +When "it is past the admin password reset token's expiration date" do + days = ArchiveConfig.DAYS_UNTIL_ADMIN_RESET_PASSWORD_LINK_EXPIRES + 1 + step "it is currently #{days} days from now" +end + +When "I confirm I want to remove the pseud" do + expect(page.accept_alert).to eq("Are you sure you want to remove the creator's pseud from this work?") if @javascript +end + +When "I follow the first invitation token url" do + first('//td/a[href*="/invitations/"]').click +end + +### THEN + +Then (/^the translation information should still be filled in$/) do + step %{the "admin_post_title" field should contain "Deutsch Ankuendigung"} + step %{the "content" field should contain "Deutsch Woerter"} + step %{"Deutsch" should be selected within "Choose a language"} +end + +Then /^I should see a translated admin post( with tags "(.*?)")?$/ do |tags| + tags = tags.split(/, ?/) if tags + step %{I go to the admin-posts page} + step %{I should see "Default Admin Post"} + step %{I should see "Tags: #{tags.join(' ')}"} if tags + step %{I should see "Translations: Deutsch"} + step %{I follow "Default Admin Post"} + step %{I should see "Deutsch" within "dd.translations"} + step %{I follow "Deutsch"} + step %{I should see "Deutsch Woerter"} + tags&.each do |tag| + step %{I should see "#{tag}" within "dd.tags"} + end +end + +Then (/^I should not see a translated admin post$/) do + step %{I go to the admin-posts page} + step %{I should see "Default Admin Post"} + step %{I should see "Deutsch Ankuendigung"} + step %{I follow "Default Admin Post"} + step %{I should not see "Translations: Deutsch"} +end + +Then "the {string} role checkbox should be checked" do |role| + role_id = Role.find_by(name: role).id + assert has_checked_field?("user_roles_#{role_id}") +end + +Then "the {string} role checkbox should not be checked" do |role| + role_id = Role.find_by(name: role).id + assert has_unchecked_field?("user_roles_#{role_id}") +end + +Then /^the work "([^\"]*)" should be hidden$/ do |work| + w = Work.find_by_title(work) + user = w.pseuds.first.user.login + step %{logged out users should not see the hidden work "#{work}" by "#{user}"} + step %{logged in users should not see the hidden work "#{work}" by "#{user}"} +end + +Then /^the work "([^\"]*)" should not be hidden$/ do |work| + w = Work.find_by_title(work) + user = w.pseuds.first.user.login + step %{logged out users should see the unhidden work "#{work}" by "#{user}"} + step %{logged in users should see the unhidden work "#{work}" by "#{user}"} +end + +Then /^logged out users should not see the hidden work "([^\"]*)" by "([^\"]*)"?/ do |work, user| + step "I am a visitor" + step %{I should not see the hidden work "#{work}" by "#{user}"} +end + +Then /^logged in users should not see the hidden work "([^\"]*)" by "([^\"]*)"?/ do |work, user| + step %{I am logged in as a random user} + step %{I should not see the hidden work "#{work}" by "#{user}"} +end + +Then /^I should not see the hidden work "([^\"]*)" by "([^\"]*)"?/ do |work, user| + step %{I am on #{user}'s works page} + step %{I should not see "#{work}"} + step %{I view the work "#{work}"} + step %{I should see "Sorry, you don't have permission to access the page you were trying to reach."} +end + +Then /^"([^\"]*)" should see their work "([^\"]*)" is hidden?/ do |user, work| + step %{I am logged in as "#{user}"} + step %{I am on #{user}'s works page} + step %{I should not see "#{work}"} + step %{I view the work "#{work}"} + step %{I should see the image "title" text "Hidden by Administrator"} +end + +Then /^logged out users should see the unhidden work "([^\"]*)" by "([^\"]*)"?/ do |work, user| + step "I am a visitor" + step %{I should see the unhidden work "#{work}" by "#{user}"} +end + +Then /^logged in users should see the unhidden work "([^\"]*)" by "([^\"]*)"?/ do |work, user| + step %{I am logged in as a random user} + step %{I should see the unhidden work "#{work}" by "#{user}"} +end + +Then /^I should see the unhidden work "([^\"]*)" by "([^\"]*)"?/ do |work, user| + step %{I am on #{user}'s works page} + step %{I should see "#{work}"} + step %{I view the work "#{work}"} + step %{I should see "#{work}"} +end + +Then(/^the work "(.*?)" should not be deleted$/) do |work| + w = Work.find_by(title: work) + assert w && w.posted? +end + +Then(/^there should be no bookmarks on the work "(.*?)"$/) do |work| + w = Work.find_by(title: work) + assert w.bookmarks.count == 0 +end + +Then(/^there should be no comments on the work "(.*?)"$/) do |work| + w = Work.find_by(title: work) + assert w.comments.count == 0 +end + +When(/^the user "(.*?)" is unbanned in the background/) do |user| + u = User.find_by(login: user) + u.update_attribute(:banned, false) +end + +Given "I have banned the address {string}" do |email| + visit admin_blacklisted_emails_url + fill_in("Email to ban", with: email) + click_button("Ban Email") +end + +Given "I have banned the address for user {string}" do |user| + visit admin_blacklisted_emails_url + u = User.find_by(login: user) + fill_in("admin_blacklisted_email_email", with: u.email) + click_button("Ban Email") +end + +Then "the address {string} should be banned" do |email| + visit admin_blacklisted_emails_url + fill_in("Email to find", with: email) + click_button("Search Banned Emails") + assert page.should have_content(email) +end + +Then "the address {string} should not be banned" do |email| + visit admin_blacklisted_emails_url + fill_in("Email to find", with: email) + click_button("Search Banned Emails") + step %{I should see "0 emails found"} +end + +Then "I should not be able to add the email {string} to the invite queue" do |email| + step %{I am on the homepage} + click_link "Get an Invitation" + fill_in "Email", with: email + click_button "Add me to the list" + expect(page).to have_content("Sorry! We couldn't save this invite request because:") + expect(page).to have_content("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 AO3 Support if you have any questions.") +end + +Then(/^I should not be able to comment with the address "([^"]*)"$/) do |email| + step %{the work "New Work"} + step %{I post the comment "I loved this" on the work "New Work" as a guest with email "#{email}"} + step %{I should see "has been blocked at the owner's request"} + step %{I should not see "Comment created!"} +end + +Then(/^I should be able to comment with the address "([^"]*)"$/) do |email| + step %{the work "New Work"} + step %{I post the comment "I loved this" on the work "New Work" as a guest with email "#{email}"} + step %{I should not see "has been blocked at the owner's request"} + step %{I should see "Comment created!"} +end + +Then /^the work "([^\"]*)" should be marked as spam/ do |work| + w = Work.find_by_title(work) + assert w.spam? +end + +Then /^the work "([^\"]*)" should not be marked as spam/ do |work| + w = Work.find_by_title(work) + assert !w.spam? +end + +Then "I should see {int} admin activity log entry/entries" do |count| + expect(page).to have_css("tr[id^=admin_activity_]", count: count) +end + +Then /^the user content should be shown as right-to-left$/ do + page.should have_xpath("//div[contains(@class, 'userstuff') and @dir='rtl']") +end + +Then "I should see the original creator {string}" do |creator| + user = User.find_by(login: creator) + expect(page).to have_selector(".original_creators", + text: "#{user.id} (#{creator})") +end + +Then "the history table should show that {string} was {word} as next of kin" do |username, action| + user_id = User.find_by(login: username).id + step %{I should see "Fannish Next of Kin #{action.capitalize}: #{user_id}" within "#user_history"} +end + +Then "the history table should show they were {word} as next of kin of {string}" do |action, username| + user_id = User.find_by(login: username).id + step %{I should see "#{action.capitalize} as Fannish Next of Kin for: #{user_id}" within "#user_history"} +end diff --git a/features/step_definitions/archivist_steps.rb b/features/step_definitions/archivist_steps.rb new file mode 100644 index 0000000..2f5fc07 --- /dev/null +++ b/features/step_definitions/archivist_steps.rb @@ -0,0 +1,103 @@ +### GIVEN + +Given /^I have an archivist "([^\"]*)"$/ do |name| + step(%{the user "#{name}" exists and has the role "archivist"}) +end + +Given /^I have pre-archivist setup for "([^\"]*)"$/ do |name| + step(%{I am logged in as "#{name}"}) + step(%{the role "archivist"}) +end + +Given /^I have an Open Doors committee member "([^\"]*)"$/ do |name| + step(%{I have pre-archivist setup for "#{name}"}) + step(%{I make "#{name}" an Open Doors committee member}) +end + +### WHEN + +When /^I make "([^\"]*)" an archivist$/ do |name| + step(%{I go to the manage users page}) + step(%{I fill in "Name" with "#{name}"}) + step(%{I press "Find"}) + step(%{I check the "archivist" role checkbox}) + step(%{I press "Update"}) +end + +When /^I make "([^\"]*)" an Open Doors committee member$/ do |name| + @user = User.find_by(login: name) + @role = Role.find_or_create_by(name: "opendoors") + @user.roles = [@role] +end + +When /^(?:|I )fill in "([^"]*)" with the path to (.+)$/ do |field, page_name| + fill_in(field, with: path_to(page_name)) +end + +When /^I start to import the work "([^\"]*)"(?: by "([^\"]*)" with email "([^\"]*)")?$/ do |url, external_author_name, external_author_email| + step(%{I go to the import page}) + step(%{I check "Import for others ONLY with permission"}) + step(%{I fill in "urls" with "#{url}"}) + step %{I select "English" from "Choose a language"} + if external_author_name.present? + step(%{I fill in "external_author_name" with "#{external_author_name}"}) + step(%{I fill in "external_author_email" with "#{external_author_email}"}) + end +end + +When /^I import the work "(.*?)"(?: by "(.*?)" with email "(.*?)")?(?: and by "(.*?)" with email "(.*?)")?$/ do + |url, creator_name, creator_email, cocreator_name, cocreator_email| + step(%{I go to the import page}) + step(%{I check "Import for others ONLY with permission"}) + step(%{I fill in "urls" with "#{url}"}) + step %{I select "English" from "Choose a language"} + if creator_name.present? + step(%{I fill in "external_author_name" with "#{creator_name}"}) + step(%{I fill in "external_author_email" with "#{creator_email}"}) + end + if cocreator_name.present? + step(%{I fill in "external_coauthor_name" with "#{cocreator_name}"}) + step(%{I fill in "external_coauthor_email" with "#{cocreator_email}"}) + end + step(%{I check "Post without previewing"}) + step(%{I press "Import"}) +end + +When /^I import the works "([^\"]*)"$/ do |urls| + urls = urls.split(/, ?/).join("\n") + step(%{I go to the import page}) + step(%{I check "Import for others ONLY with permission"}) + step(%{I fill in "urls" with "#{urls}"}) + step %{I select "English" from "Choose a language"} + step(%{I check "Post without previewing"}) + step(%{I press "Import"}) +end + +### THEN + +Then /^I should not see multi-story import messages$/ do + step %{I should not see "Importing completed successfully for the following works! (But please check the results over carefully!)"} + step %{I should not see "Imported Works"} + step %{I should not see "We were able to successfully upload the following works."} +end + +Then /^I should see multi-story import messages$/ do + step %{I should see "Importing completed successfully for the following works! (But please check the results over carefully!)"} + step %{I should see "Imported Works"} + step %{I should see "We were able to successfully upload the following works."} +end + +Then /^I should see import confirmation$/ do + step %{I should see "We have notified the author(s) you imported works for. If any were missed, you can also add co-authors manually."} +end + +Then /^the email should contain invitation warnings from "([^\"]*)" for work "([^\"]*)" in fandom "([^\"]*)"$/ do |name, work, fandom| + step %{the email should contain "has recently been imported"} + step %{the email should contain "Open Doors"} + step %{the email should contain "#{work}"} + step %{the email should contain "#{fandom}"} +end + +Then /^the email should contain claim information$/ do + step %{the email should contain "automatically added to your AO3 account"} +end diff --git a/features/step_definitions/autocomplete_steps.rb b/features/step_definitions/autocomplete_steps.rb new file mode 100644 index 0000000..d2220ed --- /dev/null +++ b/features/step_definitions/autocomplete_steps.rb @@ -0,0 +1,256 @@ +Given /^a set of tags for testing autocomplete$/ do + step %{basic tags} + step %{a canonical fandom "Supernatural"} + step %{a canonical fandom "Battlestar Galactica"} + step %{a noncanonical fandom "Super Awesome"} + step %{a canonical character "Ellen Harvelle" in fandom "Supernatural"} + step %{a canonical character "Ellen Tigh" in fandom "Battlestar Galactica"} + step %{a noncanonical character "ellen somebody"} + step %{a canonical relationship "Dean/Castiel" in fandom "Supernatural"} + step %{a canonical relationship "Sam/Dean" in fandom "Supernatural"} + step %{a canonical relationship "Ellen Tigh/Lee Adama" in fandom "Battlestar Galactica"} + step %{a noncanonical relationship "Destiel"} + step %{a canonical freeform "Alternate Universe"} + step %{a canonical freeform "Superduper"} + step %{a noncanonical freeform "alternate sundays"} +end + +Then /^I should see HTML "(.*)?" in the autocomplete$/ do |string| + # There should be only one visible autocomplete dropdown. + within("input + .autocomplete") do + # Wait for results to appear, then check their HTML content + expect(current_scope).to have_selector("li") + expect(current_scope["innerHTML"]).to include(string) + end +end + +Then /^I should see "([^\"]+)" in the autocomplete$/ do |string| + # There should be only one visible autocomplete dropdown. + expect(find("input + .autocomplete")).to have_content(string) +end + +Then /^I should not see "([^\"]+)" in the autocomplete$/ do |string| + # There should be only one visible autocomplete dropdown. + expect(find("input + .autocomplete")).to have_no_content(string) +end + +# Define all values to be entered here depending on the fieldname +When /^I enter text in the "([^\"]+)" autocomplete field$/ do |fieldname| + text = case fieldname + when "Fandoms" + "sup" + when "Additional Tags" + "alt" + when "Characters" + "ellen" + when "Relationships" + "cast" + when "Your tags" + "sup" + else + "" + end + step %{I enter "#{text}" in the "#{fieldname}" autocomplete field} +end + +When /^I enter "([^\"]+)" in the "([^\"]+)" autocomplete field$/ do |text, fieldname| + field = find_field(fieldname) + # Clear the field. + field.set("") + # Simulate keystrokes to make the autocomplete dropdown appear (instead of fill_in). + field.send_keys(text) + # Wait for the autocomplete right after the field to appear, + # so in the Then steps we can look for the only active autocomplete + # without caring where it is. + expect(page).to have_selector("##{field[:id]} + .autocomplete") +end + +When /^I choose "([^\"]+)" from the "([^\"]+)" autocomplete$/ do |text, fieldname| + field = find_field(fieldname) + # Clear the field. + field.set("") + # Simulate keystrokes to make the autocomplete dropdown appear (instead of fill_in) + field.send_keys(text) + # In the autocomplete right after the field... + with_scope("##{field[:id]} + .autocomplete") do + # Wait for the expected result to appear and click to select it + find("li", text: text).click + end +end + +# alias for most common fields +When /^I enter text in the (\w+) autocomplete field$/ do |fieldtype| + fieldname = case fieldtype + when "fandom" + "Fandoms" + when "character" + "Characters" + when "relationship" + "Relationships" + when "freeform" + "Additional Tags" + end + step %{I enter text in the "#{fieldname}" autocomplete field} +end + +When "I remove selected values from the autocomplete field within {string}" do |selector| + within(selector) do + find_all(".autocomplete .delete").each(&:click) + end +end + +When /^I specify a fandom and enter text in the character autocomplete field$/ do + step %{I choose "Supernatural" from the "Fandoms" autocomplete} + step %{I enter text in the character autocomplete field} +end + +When /^I specify a fandom and enter text in the relationship autocomplete field$/ do + step %{I choose "Supernatural" from the "Fandoms" autocomplete} + step %{I enter text in the relationship autocomplete field} +end + +When /^I specify two fandoms and enter text in the character autocomplete field$/ do + step %{I choose "Supernatural" from the "Fandoms" autocomplete} + step %{I choose "Battlestar Galactica" from the "Fandoms" autocomplete} + step %{I enter text in the character autocomplete field} +end + +When /^I choose a previously bookmarked URL from the autocomplete$/ do + url = ExternalWork.first.url + step %{I choose "#{url}" from the "URL" autocomplete} + step %{all AJAX requests are complete} +end + +## Here's where we create the steps defining which tags should appear/not appear +## based on the set of tags and the data entered + +Then /^I should only see matching canonical fandom tags in the autocomplete$/ do + step %{I should see "Supernatural" in the autocomplete} + step %{I should not see "Super Awesome" in the autocomplete} + step %{I should not see "Battlestar Galactica" in the autocomplete} + step %{I should not see "Superduper" in the autocomplete} +end + +Then /^I should only see matching canonical freeform tags in the autocomplete$/ do + step %{I should see "Alternate Universe" in the autocomplete} + step %{I should not see "alternate sundays" in the autocomplete} + step %{I should not see "Superduper" in the autocomplete} +end + +Then /^I should only see matching canonical character tags in the autocomplete$/ do + step %{I should see "Ellen Harvelle" in the autocomplete} + step %{I should see "Ellen Tigh" in the autocomplete} + step %{I should not see "Ellen Somebody" in the autocomplete} +end + +Then /^I should only see matching canonical relationship tags in the autocomplete$/ do + step %{I should see "Dean/Castiel" in the autocomplete} + step %{I should not see "Sam/Dean" in the autocomplete} + step %{I should not see "Destiel" in the autocomplete} +end + +Then /^I should only see matching canonical character tags in the specified fandom in the autocomplete$/ do + step %{I should see "Ellen Harvelle" in the autocomplete} + step %{I should not see "Ellen Tigh" in the autocomplete} + step %{I should not see "Ellen Somebody" in the autocomplete} +end + +Then /^I should see matching canonical character tags from both fandoms in the autocomplete$/ do + step %{I should see "Ellen Harvelle" in the autocomplete} + step %{I should see "Ellen Tigh" in the autocomplete} + step %{I should not see "Ellen Somebody" in the autocomplete} +end + +Then /^I should only see matching canonical relationship tags in the specified fandom in the autocomplete$/ do + step %{I should see "Dean/Castiel" in the autocomplete} + step %{I should not see "Destiel" in the autocomplete} +end + +Then /^I should only see matching canonical tags in the autocomplete$/ do + step %{I should see "Supernatural" in the autocomplete} + step %{I should see "Superduper" in the autocomplete} + step %{I should not see "Dean/Castiel" in the autocomplete} +end + +Then /^I should only see matching noncanonical tags in the autocomplete$/ do + step %{I should see "Super Awesome" in the autocomplete} + step %{I should not see "Supernatural" in the autocomplete} +end + +Then /^the tag autocomplete fields should list only matching canonical tags$/ do + step %{I enter text in the fandom autocomplete field} + step %{I should only see matching canonical fandom tags in the autocomplete} + step %{I enter text in the character autocomplete field} + step %{I should only see matching canonical character tags in the autocomplete} + step %{I enter text in the relationship autocomplete field} + step %{I should only see matching canonical relationship tags in the autocomplete} + if page.body =~ /Additional Tags/ + step %{I enter text in the freeform autocomplete field} + step %{I should only see matching canonical freeform tags in the autocomplete} + end +end + +Then /^the fandom-specific tag autocomplete fields should list only fandom-specific canonical tags$/ do + step %{I specify a fandom and enter text in the character autocomplete field} + step %{I should only see matching canonical character tags in the specified fandom in the autocomplete} + step %{I specify a fandom and enter text in the relationship autocomplete field} + step %{I should only see matching canonical relationship tags in the specified fandom in the autocomplete} + step %{I specify two fandoms and enter text in the character autocomplete field} + step %{I should see matching canonical character tags from both fandoms in the autocomplete} +end + +Then /^the external url autocomplete field should list the urls of existing external works$/ do + step %{I enter "exam" in the "URL" autocomplete field} + step %{I should see "http://example.org/200" in the autocomplete} +end + +Given /^a set of users for testing autocomplete$/ do + %w(myname coauthor giftee).each do |username| + user = FactoryBot.create(:user, login: username) + user.pseuds.first.add_to_autocomplete + end +end + +Then /^the coauthor autocomplete field should list matching users$/ do + check("co-authors-options-show") + step %{I enter "coa" in the "pseud_byline_autocomplete" autocomplete field} + step %{I should see "coauthor" in the autocomplete} + step %{I should not see "giftee" in the autocomplete} +end + +Then /^the gift recipient autocomplete field should list matching users$/ do + step %{I enter "gif" in the "Gift this work to" autocomplete field} + step %{I should see "giftee" in the autocomplete} + step %{I should not see "coauthor" in the autocomplete} +end + +Given /^a set of collections for testing autocomplete$/ do + step %{I create the collection "awesome"} + step %{I create the collection "great"} + step %{I create the collection "really great"} +end + +Then /^the collection item autocomplete field should list matching collections$/ do + step %{I enter "gre" in the "Post to Collections / Challenges" autocomplete field} + step %{I should see "great" in the autocomplete} + step %{I should see "really great" in the autocomplete} + step %{I should not see "awesome" in the autocomplete} +end + +Given /^a gift exchange for testing autocomplete$/ do + step %{I have created the gift exchange "autocomplete"} +end + +When /^I edit the gift exchange for testing autocomplete$/ do + visit(edit_collection_gift_exchange_path(Collection.find_by(name: "autocomplete"))) +end + +Then(/^the pseud autocomplete should contain "([^\"]*)"$/) do |pseud| + results = Pseud.autocomplete_lookup(search_param: pseud, autocomplete_prefix: "autocomplete_pseud").map { |res| Pseud.fullname_from_autocomplete(res) } + assert results.include?(pseud) +end + +Then(/^the pseud autocomplete should not contain "([^\"]*)"$/) do |pseud| + results = Pseud.autocomplete_lookup(search_param: pseud, autocomplete_prefix: "autocomplete_pseud").map { |res| Pseud.fullname_from_autocomplete(res) } + assert !results.include?(pseud) +end diff --git a/features/step_definitions/banner_steps.rb b/features/step_definitions/banner_steps.rb new file mode 100644 index 0000000..b8922d0 --- /dev/null +++ b/features/step_definitions/banner_steps.rb @@ -0,0 +1,158 @@ +# encoding: UTF-8 + +### GIVEN + +Given /^there are no banners$/ do + AdminBanner.delete_all +end + +### WHEN + +When /^an admin creates an?( active)?(?: "([^\"]*)")? banner$/ do |active, banner_type| + step %{I am logged in as a "communications" admin} + visit(new_admin_banner_path) + fill_in("admin_banner_content", with: "This is some banner text") + if banner_type.present? + if banner_type == "alert" + choose("admin_banner_banner_type_alert") + elsif banner_type == "event" + choose("admin_banner_banner_type_event") + else + choose("admin_banner_banner_type_") + end + end + check("admin_banner_active") unless active.blank? + click_button("Create Banner") + step %{I should see "Setting banner back on for all users. This may take some time."} unless active.blank? +end + +When /^an admin deactivates the banner$/ do + step %{I am logged in as a "communications" admin} + visit(admin_banners_path) + step %{I follow "Edit"} + uncheck("admin_banner_active") + click_button("Update Banner") + step %{I should see "Banner successfully updated."} +end + +When /^an admin edits the active banner$/ do + step %{I am logged in as a "communications" admin} + visit(admin_banners_path) + step %{I follow "Edit"} + fill_in("admin_banner_content", with: "This is some edited banner text") + click_button("Update Banner") + step %{I should see "Setting banner back on for all users. This may take some time."} +end + +When /^an admin makes a minor edit to the active banner$/ do + step %{I am logged in as a "communications" admin} + visit(admin_banners_path) + step %{I follow "Edit"} + fill_in("admin_banner_content", with: "This is some banner text!") + check("admin_banner_minor_edit") + click_button("Update Banner") + step %{I should see "Updating banner for users who have not already dismissed it. This may take some time."} +end + +When /^an admin creates a different active banner$/ do + step %{I am logged in as a "communications" admin} + visit(new_admin_banner_path) + fill_in("admin_banner_content", with: "This is new banner text") + check("admin_banner_active") + click_button("Create Banner") + step %{I should see "Setting banner back on for all users. This may take some time."} +end + +When /^I turn off the banner$/ do + step %{I am logged in as "newname"} + step %{I am on newname's user page} + click_button("×") +end + +### THEN + +Then /^a logged-in user should see the(?: "([^\"]*)")? banner$/ do |banner_type| + step %{I am logged in as "ordinaryuser"} + visit(works_path) + if banner_type.present? + if banner_type == "alert" + page.should have_xpath("//div[@class=\"alert announcement group\"]") + elsif banner_type == "event" + page.should have_xpath("//div[@class=\"event announcement group\"]") + else + page.should have_xpath("//div[@class=\"announcement group\"]") + page.should_not have_xpath("//div[@class=\"alert announcement group\"]") + page.should_not have_xpath("//div[@class=\"event\"]") + end + end + step %{I should see "This is some banner text"} +end + +Then /^a logged-out user should see the(?: "([^\"]*)")? banner$/ do |banner_type| + step "I am a visitor" + visit(works_path) + if banner_type.present? + if banner_type == "alert" + page.should have_xpath("//div[@class=\"alert announcement group\"]") + elsif banner_type == "event" + page.should have_xpath("//div[@class=\"event announcement group\"]") + else + page.should have_xpath("//div[@class=\"announcement group\"]") + page.should_not have_xpath("//div[@class=\"alert announcement group\"]") + page.should_not have_xpath("//div[@class=\"event announcement group\"]") + end + end + step %{I should see "This is some banner text"} +end + +Then /^a logged-in user should see the edited active banner$/ do + step %{I am logged in as "ordinaryuser"} + visit(works_path) + step %{I should see "This is some edited banner text"} +end + +Then /^a logged-out user should see the edited active banner$/ do + step "I am a visitor" + visit(works_path) + step %{I should see "This is some edited banner text"} +end + +Then /^a logged-in user should not see a banner$/ do + step %{I am logged in as "ordinaryuser"} + page.should_not have_xpath("//div[@class=\"announcement group\"]") +end + +Then /^a logged-out user should not see a banner$/ do + step "I am a visitor" + page.should_not have_xpath("//div[@class=\"announcement group\"]") +end + +Then "I should see the first login banner" do + step %{I should see "It looks like you've just logged in to AO3 for the first time."} +end + +Then "I should not see the first login banner" do + step %{I should not see "It looks like you've just logged in to AO3 for the first time."} +end + +Then /^I should see the first login popup$/ do + step %{I should see "Here are some tips to help you get started."} + step %{I should see "To log in, locate the login link"} +end + +Then /^I should see the banner with minor edits$/ do + step %{I should see "This is some banner text!"} +end + +Then /^I should not see the banner with minor edits$/ do + step %{I should not see "This is some banner text!"} +end + +Then /^the page should have the different banner$/ do + step %{I should see "This is new banner text"} +end + +Then /^the page should not have a banner$/ do + page.should_not have_xpath("//div[@class=\"announcement group\"]") +end + diff --git a/features/step_definitions/block_steps.rb b/features/step_definitions/block_steps.rb new file mode 100644 index 0000000..2dba821 --- /dev/null +++ b/features/step_definitions/block_steps.rb @@ -0,0 +1,41 @@ +Given "the user {string} has blocked the user {string}" do |blocker, blocked| + blocker = ensure_user(blocker) + blocked = ensure_user(blocked) + Block.create!(blocker: blocker, blocked: blocked) +end + +Given "there are {int} blocked users per page" do |amount| + allow(Block).to receive(:per_page).and_return(amount) +end + +Given "the maximum number of accounts users can block is {int}" do |count| + allow(ArchiveConfig).to receive(:MAX_BLOCKED_USERS).and_return(count) +end + +Then "the user {string} should have a block for {string}" do |blocker, blocked| + blocker = User.find_by(login: blocker) + blocked = User.find_by(login: blocked) + expect(Block.find_by(blocker: blocker, blocked: blocked)).to be_present +end + +Then "the user {string} should not have a block for {string}" do |blocker, blocked| + blocker = User.find_by(login: blocker) + blocked = User.find_by(login: blocked) + expect(Block.find_by(blocker: blocker, blocked: blocked)).to be_blank +end + +Then "the blurb should say when {string} blocked {string}" do |blocker, blocked| + blocker = User.find_by(login: blocker) + blocked = User.find_by(login: blocked) + block = Block.where(blocker: blocker, blocked: blocked).first + # Find the blurb for the specified block using the h4 with the blocked user's name, navigate back up to div, and then down to the datetime p + expect(page).to have_xpath("//li/div/h4/a[text()[contains(., '#{blocked.login}')]]/parent::h4/parent::div/p[text()[contains(., '#{block.created_at}')]]") +end + +Then "the blurb should not say when {string} blocked {string}" do |blocker, blocked| + blocker = User.find_by(login: blocker) + blocked = User.find_by(login: blocked) + block = Block.where(blocker: blocker, blocked: blocked).first + # Find the blurb for the specified block using the h4 with the blocked user's name, navigate back up to div, and then down to where the datetime p would be + expect(page).not_to have_xpath("//li/div/h4/a[text()[contains(., '#{blocked.login}')]]/parent::h4/parent::div/p[text()[contains(., '#{block.created_at}')]]") +end diff --git a/features/step_definitions/bookmark_steps.rb b/features/step_definitions/bookmark_steps.rb new file mode 100644 index 0000000..e6e4681 --- /dev/null +++ b/features/step_definitions/bookmark_steps.rb @@ -0,0 +1,457 @@ +Given /^mock websites with no content$/ do + WebMock.disable_net_connect! + WebMock.stub_request(:head, "http://example.org/200") + WebMock.stub_request(:head, "http://example.org/301").to_return(status: 301) + WebMock.stub_request(:head, "http://example.org/404").to_return(status: 404) +end + +Given "all pages on the host {string} return status 200" do |url| + WebMock.disable_net_connect! + parsed_url = Addressable::URI.parse(url) + WebMock.stub_request(:any, %r[https?://#{parsed_url.host}.*]).to_return(status: 200) +end + +Given /^I have a bookmark for "([^\"]*)"$/ do |title| + step %{I start a new bookmark for "#{title}"} + fill_in("Your tags", with: DEFAULT_BOOKMARK_TAGS) + step %{I press "Create"} + step %{all indexing jobs have been run} +end + +Given /^I have a bookmark of a deleted work$/ do + title = "Deleted Work For Bookmarking" + step %{I start a new bookmark for "#{title}"} + fill_in("bookmark_tag_string", with: DEFAULT_BOOKMARK_TAGS) + step %{I press "Create"} + work = Work.find_by(title: title) + work.destroy + step %{all indexing jobs have been run} +end + +Given /^I have bookmarks to search$/ do + # set up a user + user1 = FactoryBot.create(:user, login: "testuser") + + # set up the pseuds + pseud1 = FactoryBot.create(:pseud, name: "testy", user_id: user1.id) + pseud2 = FactoryBot.create(:pseud, name: "tester_pseud", user_id: user1.id) + + # set up a tag + freeform1 = FactoryBot.create(:freeform, name: "classic") + freeform2 = FactoryBot.create(:freeform, name: "rare") + + # set up some works + work1 = FactoryBot.create(:work, title: "First work", freeform_string: freeform2.name) + work2 = FactoryBot.create(:work, title: "second work") + work3 = FactoryBot.create(:work, title: "third work") + work4 = FactoryBot.create(:work, title: "fourth") + work5 = FactoryBot.create(:work, title: "fifth") + + # set up an external work + external1 = FactoryBot.create(:external_work, title: "Skies Grown Darker") + + # set up some series + series1 = FactoryBot.create(:series, title: "First Series") + series2 = FactoryBot.create(:series_with_a_work, title: "Second Series") + + # add work1 to series1 to ensure the series has tags + FactoryBot.create(:serial_work, work_id: work1.id, series_id: series1.id) + + # set up the bookmarks + FactoryBot.create(:bookmark, + bookmarkable_id: work1.id, + pseud_id: user1.default_pseud.id, + rec: true) + + FactoryBot.create(:bookmark, + bookmarkable_id: work2.id, + pseud_id: user1.default_pseud.id, + tag_string: freeform2.name) + + FactoryBot.create(:bookmark, + bookmarkable_id: work3.id, + pseud_id: user1.default_pseud.id, + tag_string: freeform1.name) + + FactoryBot.create(:bookmark, bookmarkable_id: work4.id, pseud_id: pseud1.id) + + FactoryBot.create(:bookmark, + bookmarkable_id: work5.id, + pseud_id: pseud2.id, + bookmarker_notes: "Left me with a broken heart") + + FactoryBot.create(:bookmark, + bookmarkable_id: external1.id, + bookmarkable_type: "ExternalWork", + pseud_id: pseud2.id, + bookmarker_notes: "I enjoyed this") + + FactoryBot.create(:bookmark, + bookmarkable_id: series1.id, + bookmarkable_type: "Series", + pseud_id: user1.default_pseud.id, + tag_string: freeform1.name) + + FactoryBot.create(:bookmark, + bookmarkable_id: series2.id, + bookmarkable_type: "Series", + pseud_id: pseud2.id, + rec: true, + bookmarker_notes: "A new classic") + + step %{all indexing jobs have been run} +end + +Given /^I have bookmarks to search by any field$/ do + work1 = FactoryBot.create(:work, + title: "Comfort", + freeform_string: "hurt a little comfort but only so much") + work2 = FactoryBot.create(:work, title: "Hurt and that's it") + work3 = FactoryBot.create(:work, title: "Fluff") + + external1 = FactoryBot.create(:external_work, + title: "External Whump", + author: "im hurt") + external2 = FactoryBot.create(:external_work, title: "External Fix-It") + + series1 = FactoryBot.create(:series_with_a_work, + title: "H/C Series", + summary: "Hurt & comfort ficlets") + series2 = FactoryBot.create(:series_with_a_work, title: "Ouchless Series") + + FactoryBot.create(:bookmark, bookmarkable_id: work1.id, bookmarker_notes: "whatever") + FactoryBot.create(:bookmark, bookmarkable_id: work2.id, tag_string: "more please") + FactoryBot.create(:bookmark, bookmarkable_id: work3.id, bookmarker_notes: "more please") + FactoryBot.create(:bookmark, + bookmarkable_id: external1.id, + bookmarkable_type: "ExternalWork", + bookmarker_notes: "please rec me more like this") + FactoryBot.create(:bookmark, + bookmarkable_id: external2.id, + bookmarkable_type: "ExternalWork", + tag_string: "please no more pain") + FactoryBot.create(:bookmark, + bookmarkable_id: series1.id, + bookmarkable_type: "Series", + bookmarker_notes: "needs more comfort please") + FactoryBot.create(:bookmark, + bookmarkable_id: series2.id, + bookmarkable_type: "Series", + pseud_id: FactoryBot.create(:pseud, name: "more please").id) + + step %{all indexing jobs have been run} +end + +Given /^I have bookmarks to search by dates$/ do + work1 = nil + series1 = nil + external1 = nil + Timecop.freeze(901.days.ago) do + work1 = FactoryBot.create(:work, title: "Old work") + FactoryBot.create(:bookmark, + bookmarkable_id: work1.id, + bookmarker_notes: "Old bookmark of old work") + + series1 = FactoryBot.create(:series_with_a_work, title: "Old series") + FactoryBot.create(:bookmark, + bookmarkable_id: series1.id, + bookmarkable_type: "Series", + bookmarker_notes: "Old bookmark of old series") + + external1 = FactoryBot.create(:external_work, title: "Old external") + FactoryBot.create(:bookmark, + bookmarkable_id: external1.id, + bookmarkable_type: "ExternalWork", + bookmarker_notes: "Old bookmark of old external work") + end + FactoryBot.create(:bookmark, + bookmarkable_id: work1.id, + bookmarker_notes: "New bookmark of old work") + FactoryBot.create(:bookmark, + bookmarkable_id: series1.id, + bookmarkable_type: "Series", + bookmarker_notes: "New bookmark of old series") + FactoryBot.create(:bookmark, + bookmarkable_id: external1.id, + bookmarkable_type: "ExternalWork", + bookmarker_notes: "New bookmark of old external work") + + work2 = FactoryBot.create(:work, title: "New work") + FactoryBot.create(:bookmark, + bookmarkable_id: work2.id, + bookmarker_notes: "New bookmark of new work") + + series2 = FactoryBot.create(:series_with_a_work, title: "New series") + FactoryBot.create(:bookmark, + bookmarkable_id: series2.id, + bookmarkable_type: "Series", + bookmarker_notes: "New bookmark of new series") + + external2 = FactoryBot.create(:external_work, title: "New external") + FactoryBot.create(:bookmark, + bookmarkable_id: external2.id, + bookmarkable_type: "ExternalWork", + bookmarker_notes: "New bookmark of new external work") + + step %{all indexing jobs have been run} +end + +Given /^I have bookmarks of various completion statuses to search$/ do + complete_work = FactoryBot.create(:work, title: "Finished Work") + incomplete_work = FactoryBot.create(:work, title: "Incomplete Work", complete: false, expected_number_of_chapters: 2) + + complete_series = FactoryBot.create(:series_with_a_work, title: "Complete Series", complete: true) + incomplete_series = FactoryBot.create(:series_with_a_work, title: "Incomplete Series", complete: false) + + external_work = FactoryBot.create(:external_work, title: "External Work") + + FactoryBot.create(:bookmark, bookmarkable_id: complete_work.id) + FactoryBot.create(:bookmark, bookmarkable_id: incomplete_work.id) + FactoryBot.create(:bookmark, bookmarkable_id: complete_series.id, bookmarkable_type: "Series") + FactoryBot.create(:bookmark, bookmarkable_id: incomplete_series.id, bookmarkable_type: "Series") + FactoryBot.create(:bookmark, bookmarkable_id: external_work.id, bookmarkable_type: "ExternalWork") + + step %{all indexing jobs have been run} +end + +Given /^I have bookmarks of old series to search$/ do + step %{basic tags} + step %{the user "creator" exists and is activated} + creator = User.find_by(login: "creator").default_pseud + + Timecop.freeze(30.days.ago) do + older_work = FactoryBot.create(:work, title: "WIP in a Series", authors: [creator]) + older_series = FactoryBot.create(:series, title: "Older WIP Series", works: [older_work]) + FactoryBot.create(:bookmark, + bookmarkable_id: older_series.id, + bookmarkable_type: "Series") + end + + Timecop.freeze(7.days.ago) do + newer_series = FactoryBot.create(:series_with_a_work, title: "Newer Complete Series") + FactoryBot.create(:bookmark, + bookmarkable_id: newer_series.id, + bookmarkable_type: "Series") + end +end + +# Freeform is omitted because there is no freeform option on the bookmark external work form +Given /^bookmarks of all types tagged with the (character|relationship|fandom) tag "(.*?)"$/ do |tag_type, tag| + work = if tag_type == "character" + FactoryBot.create(:work, + title: "BookmarkedWork", + character_string: tag) + elsif tag_type == "relationship" + FactoryBot.create(:work, + title: "BoomarkedWork", + relationship_string: tag) + elsif tag_type == "fandom" + FactoryBot.create(:work, + title: "BookmarkedWork", + fandom_string: tag) + end + + FactoryBot.create(:bookmark, bookmarkable_id: work.id, bookmarkable_type: "Work") + + step %{bookmarks of external works and series tagged with the #{tag_type} tag "#{tag}"} +end + +# Freeform is omitted because there is no freeform option on the bookmark external work form +Given /^bookmarks of external works and series tagged with the (character|relationship|fandom) tag "(.*?)"$/ do |tag_type, tag| + # Series get their tags from works, so we have to create the work first + work = if tag_type == "character" + FactoryBot.create(:work, character_string: tag) + elsif tag_type == "relationship" + FactoryBot.create(:work, relationship_string: tag) + elsif tag_type == "fandom" + FactoryBot.create(:work, fandom_string: tag) + end + + # We're going to need to use the series ID, so make the series + series = FactoryBot.create(:series, title: "BookmarkedSeries") + + # Now add the work to the series + FactoryBot.create(:serial_work, work_id: work.id, series_id: series.id) + + external_work = if tag_type == "character" + FactoryBot.create(:external_work, title: "BookmarkedExternalWork", character_string: tag) + elsif tag_type == "relationship" + FactoryBot.create(:external_work, title: "BookmarkedExternalWork", relationship_string: tag) + elsif tag_type == "fandom" + FactoryBot.create(:external_work, title: "BookmarkedExternalWork", fandom_string: tag) + end + + FactoryBot.create(:bookmark, + bookmarkable_id: series.id, + bookmarkable_type: "Series") + + FactoryBot.create(:bookmark, + bookmarkable_id: external_work.id, + bookmarkable_type: "ExternalWork") + + step %{all indexing jobs have been run} +end + +Given /^"(.*?)" has bookmarks of works in various languages$/ do |user| + step %{the user "#{user}" exists and is activated} + user_pseud = User.find_by(login: user).default_pseud + + lang_en = Language.find_or_create_by!(name: "English", short: "en") + lang_de = Language.find_or_create_by!(name: "Deutsch", short: "de") + + work1 = FactoryBot.create(:work, title: "english work", language_id: lang_en.id) + work2 = FactoryBot.create(:work, title: "german work", language_id: lang_de.id) + + FactoryBot.create(:bookmark, bookmarkable_id: work1.id, pseud_id: user_pseud.id) + FactoryBot.create(:bookmark, bookmarkable_id: work2.id, pseud_id: user_pseud.id) + + step %{all indexing jobs have been run} +end + +Given "{string} has a bookmark of a work titled {string}" do |user, title| + step %{the user "#{user}" exists and is activated} + + user_pseud = User.find_by(login: user).default_pseud + work1 = FactoryBot.create(:work, title: title) + FactoryBot.create(:bookmark, + bookmarkable: work1, + pseud: user_pseud) + + step %{all indexing jobs have been run} +end + +Given "pseud {string} has a bookmark of a work titled {string} by {string}" do |pseud, title, creator| + pseud = Pseud.find_by(name: pseud) + work = FactoryBot.create(:work, title: title, authors: [ensure_user(creator).default_pseud]) + FactoryBot.create(:bookmark, bookmarkable: work, pseud: pseud) + + step %{all indexing jobs have been run} +end + +def submit_bookmark_form(pseud, note, tags) + select(pseud, from: "bookmark_pseud_id") unless pseud.nil? + fill_in("bookmark_notes", with: note) unless note.nil? + fill_in("Your tags", with: tags) unless tags.nil? + click_button("Create") + step %{all indexing jobs have been run} +end + +When /^I bookmark the work "(.*?)"(?: as "(.*?)")?(?: with the note "(.*?)")?(?: with the tags "(.*?)")?$/ do |title, pseud, note, tags| + step %{I start a new bookmark for "#{title}"} + submit_bookmark_form(pseud, note, tags) +end + +When /^I bookmark the work "(.*?)"(?: as "(.*?)")?(?: with the note "(.*?)")?(?: with the tags "(.*?)")? from new bookmark page$/ do |title, pseud, note, tags| + step %{I go to the new bookmark page for work "#{title}"} + submit_bookmark_form(pseud, note, tags) +end + +When /^I bookmark the series "([^\"]*)"$/ do |series_title| + series = Series.find_by(title: series_title) + visit series_path(series) + click_link("Bookmark Series") + click_button("Create") + step %{all indexing jobs have been run} +end + +When /^I start a new bookmark for "([^\"]*)"$/ do |title| + step %{I open the bookmarkable work "#{title}"} + click_link("Bookmark") +end + +When /^I start a new bookmark$/ do + step %{I start a new bookmark for "#{DEFAULT_TITLE}"} +end + +When /^I bookmark the works "([^\"]*)"$/ do |worklist| + worklist.split(/, ?/).each do |work_title| + step %{I bookmark the work "#{work_title}"} + step %{it is currently 1 second from now} + end +end + +When /^I edit the bookmark for "([^\"]*)"$/ do |title| + step %{I open the bookmarkable work "#{title}"} + click_link("Edit Bookmark") +end + +When /^I open a bookmarkable work$/ do + step %{I open the bookmarkable work "#{DEFAULT_TITLE}"} +end + +When /^I open the bookmarkable work "([^\"]*)"$/ do |title| + work = Work.find_by(title: title) + work ||= FactoryBot.create(:work, title: title) + visit work_path(work) +end + +When /^I add my bookmark to the collection "([^\"]*)"$/ do |collection_name| + step %{I follow "Add To Collection"} + fill_in("collection_names", with: collection_name) + click_button("Add") +end + +When /^I rec the current work$/ do + click_link("Bookmark") + check("bookmark_rec") + click_button("Create") + step %{all indexing jobs have been run} +end + +When(/^I attempt to create a bookmark of "([^"]*)" with a pseud that is not mine$/) do |work| + step %{I am logged in as "commenter"} + step %{I start a new bookmark for "#{work}"} + pseud_id = User.first.pseuds.first.id + find("#bookmark_pseud_id", visible: false).set(pseud_id) + click_button "Create" +end + +When(/^I attempt to transfer my bookmark of "([^"]*)" to a pseud that is not mine$/) do |work| + step %{the user "not_the_bookmarker" exists and is activated} + step %{I edit the bookmark for "#{work}"} + pseud_id = User.find_by(login: "not_the_bookmarker").pseuds.first.id + find("#bookmark_pseud_id", visible: false).set(pseud_id) + click_button "Update" +end + +When(/^I use the bookmarklet on a previously bookmarked URL$/) do + url = ExternalWork.first.url + visit new_external_work_path(params: { url_from_external: url }) + step %{all AJAX requests are complete} +end + +Then /^the bookmark on "([^\"]*)" should have tag "([^\"]*)"$$/ do |title, tag| + work = Work.find_by(title: title) + bookmark = work.bookmarks.first + bookmark.reload + bookmark.tags.collect(&:name).include?(tag) +end +Then /^the ([\d]+)(?:st|nd|rd|th) bookmark result should contain "([^"]*)"$/ do |n, text| + selector = "ol.bookmark > li:nth-of-type(#{n})" + with_scope(selector) do + page.should have_content(text) + end +end + +Then /^the cache of the bookmark on "([^\"]*)" should expire after I edit the bookmark tags$/ do |title| + work = Work.find_by(title: title) + bookmark = work.bookmarks.first + orig_cache_key = bookmark.cache_key + Kernel::sleep 1 + visit edit_bookmark_path(bookmark) + fill_in("bookmark_tag_string", with: "New Tag") + click_button("Update") + bookmark.reload + assert orig_cache_key != bookmark.cache_key, "Cache key #{orig_cache_key} matches #{bookmark.cache_key}." +end + +Then /^the cache of the bookmark on "([^\"]*)" should not expire if I have not edited the bookmark$/ do |title| + work = Work.find_by(title: title) + bookmark = work.bookmarks.first + orig_cache_key = bookmark.cache_key + Kernel::sleep 1 + visit edit_bookmark_path(bookmark) + visit bookmark_path(bookmark) + bookmark.reload + assert orig_cache_key == bookmark.cache_key, "Cache key #{orig_cache_key} does not match #{bookmark.cache_key}." +end diff --git a/features/step_definitions/challege_gift_exchange_steps.rb b/features/step_definitions/challege_gift_exchange_steps.rb new file mode 100644 index 0000000..9a0f328 --- /dev/null +++ b/features/step_definitions/challege_gift_exchange_steps.rb @@ -0,0 +1,405 @@ +# Set up a new/unsaved gift exchange + +Given /^I have set up the gift exchange "([^\"]*)" with name "([^\"]*)"$/ do |challengename, name| + step %{I am logged in as "mod1"} + step "I have standard challenge tags setup" + step %{I set up the collection "#{challengename}" with name "#{name}"} + step %{I select "Gift Exchange" from "challenge_type"} + click_button("Submit") +end + +Given /^I have set up the gift exchange "([^\"]*)"$/ do |challengename| + step %{I have set up the gift exchange "#{challengename}" with name "#{challengename.gsub(/[^\w]/, '_')}"} +end + +Then /^"([^\"]*)" gift exchange should be correctly created$/ do |title| + step %{I should see "Collection was successfully created"} + step %{I should see "Setting Up the #{title} Gift Exchange"} + step %{I should see "Offer Settings"} + step %{I should see "Request Settings"} + step %{I should see "If you plan to use automated matching"} + step %{I should see "Allow Any"} +end + +Then /^I should see gift exchange options$/ do + step %{I should see "Offer Settings"} + step %{I should see "Request Settings"} + step %{I should see "If you plan to use automated matching"} + step %{I should see "Allow Any"} +end + +# Create and save a gift exchange with some common options + +Given /^I have created the gift exchange "([^\"]*)" with name "([^\"]*)"$/ do |challengename, name| + step %{I have set up the gift exchange "#{challengename}" with name "#{name}"} + step "I fill in gift exchange challenge options" + step "I submit" + step %{I should see "Challenge was successfully created"} +end + +Given /^I have created the gift exchange "([^\"]*)"$/ do |challengename| + step %{I have created the gift exchange "#{challengename}" with name "#{challengename.gsub(/[^\w]/, '_')}"} +end + +Given /^I have created the tagless gift exchange "([^\"]*)" with name "([^\"]*)"$/ do |challengename, name| + step %{I have set up the gift exchange "#{challengename}" with name "#{name}"} + step "I submit" + step %{I should see "Challenge was successfully created"} +end + +Given /^I have created the tagless gift exchange "([^\"]*)"$/ do |challengename| + step %{I have created the tagless gift exchange "#{challengename}" with name "#{challengename.gsub(/[^\w]/, '_')}"} +end + +When /^I fill in gift exchange challenge options$/ do + current_date = DateTime.current + fill_in("Sign-up opens", with: "#{current_date.months_ago(2)}") + fill_in("Sign-up closes", with: "#{current_date.years_since(1)}") + select("(GMT-05:00) Eastern Time (US & Canada)", from: "gift_exchange_time_zone") + fill_in("Tag Sets To Use:", with: "Standard Challenge Tags") + fill_in("gift_exchange_request_restriction_attributes_fandom_num_required", with: "1") + fill_in("gift_exchange_request_restriction_attributes_fandom_num_allowed", with: "1") + fill_in("gift_exchange_request_restriction_attributes_freeform_num_allowed", with: "2") + fill_in("gift_exchange_offer_restriction_attributes_fandom_num_required", with: "1") + fill_in("gift_exchange_offer_restriction_attributes_fandom_num_allowed", with: "1") + fill_in("gift_exchange_offer_restriction_attributes_freeform_num_allowed", with: "2") + select("1", from: "gift_exchange_potential_match_settings_attributes_num_required_fandoms") +end + +When /^I fill in single-fandom gift exchange challenge options$/ do + current_date = DateTime.current + fill_in("Sign-up opens", with: current_date.months_ago(2).to_s) + fill_in("Sign-up closes", with: current_date.years_since(1).to_s) + select("(GMT-05:00) Eastern Time (US & Canada)", from: "gift_exchange_time_zone") + fill_in("gift_exchange_request_restriction_attributes_fandom_num_required", with: "1") + fill_in("gift_exchange_request_restriction_attributes_fandom_num_allowed", with: "1") + fill_in("gift_exchange_request_restriction_attributes_character_num_required", with: "1") + fill_in("gift_exchange_request_restriction_attributes_character_num_allowed", with: "3") + fill_in("gift_exchange_request_restriction_attributes_relationship_num_allowed", with: "3") + fill_in("gift_exchange_request_restriction_attributes_rating_num_allowed", with: "5") + fill_in("gift_exchange_request_restriction_attributes_category_num_allowed", with: "5") + fill_in("gift_exchange_request_restriction_attributes_archive_warning_num_allowed", with: "5") + fill_in("gift_exchange_request_restriction_attributes_freeform_num_allowed", with: "2") + fill_in("gift_exchange_offer_restriction_attributes_fandom_num_required", with: "1") + fill_in("gift_exchange_offer_restriction_attributes_fandom_num_allowed", with: "1") + fill_in("gift_exchange_offer_restriction_attributes_character_num_allowed", with: "3") + fill_in("gift_exchange_offer_restriction_attributes_freeform_num_allowed", with: "2") + select("1", from: "gift_exchange_potential_match_settings_attributes_num_required_characters") + check("gift_exchange_offer_restriction_attributes_allow_any_rating") + check("gift_exchange_offer_restriction_attributes_allow_any_category") + check("gift_exchange_offer_restriction_attributes_allow_any_archive_warning") + check("gift_exchange_offer_restriction_attributes_character_restrict_to_fandom") + check("gift_exchange_offer_restriction_attributes_relationship_restrict_to_fandom") +end + +When /^I allow warnings in my gift exchange$/ do + fill_in("gift_exchange_request_restriction_attributes_archive_warning_num_allowed", with: "1") + check("gift_exchange_request_restriction_attributes_allow_any_archive_warning") + fill_in("gift_exchange_offer_restriction_attributes_archive_warning_num_allowed", with: "1") + check("gift_exchange_offer_restriction_attributes_allow_any_archive_warning") +end + +Then /^"([^\"]*)" gift exchange should be fully created$/ do |title| + step %{I should see a create confirmation message} + step %{"#{title}" collection exists} + step %{I should see "(Open, Unmoderated, Gift Exchange Challenge)"} +end + +Given /^the gift exchange "([^\"]*)" is ready for signups$/ do |title| + step %{I am logged in as "mod1"} + step %{I have created the gift exchange "#{title}"} + step %{I open signups for "#{title}"} +end + +# This is going to make broken assignments a la AO3-5748 +Given /^"(.*?)" has two pinchhit assignments in the gift exchange "(.*?)"$/ do |user, collection_title| + collection = Collection.find_by(title: collection_title) + user = User.find_by(login: user) + assignments = ChallengeAssignment.where(collection_id: collection.id).limit(2) + assignments.each do |a| + a.pinch_hitter_id = user.default_pseud_id + a.save + a.reload + end +end + +## Signing up + +When /^I set up a signup for "([^\"]*)" with combination A$/ do |title| + step %{I start signing up for "#{title}"} + step %{I check the 1st checkbox with the value "Stargate Atlantis"} + step %{I check the 2nd checkbox with value "Stargate SG-1"} + step %{I fill in the 1st field with id matching "freeform_tagnames" with "Alternate Universe - Historical"} + step %{I fill in the 2nd field with id matching "freeform_tagnames" with "Alternate Universe - High School"} +end + +When /^I sign up for "([^\"]*)" with combination A$/ do |title| + step %{I set up a signup for "#{title}" with combination A} + click_button "Submit" +end + +When "I sign up for {string} with combination A and my pseud {string}" do |title, pseud_name| + step %{I set up a signup for "#{title}" with combination A} + select pseud_name, from: "challenge_signup[pseud_id]" + click_button "Submit" +end + +When /^I attempt to sign up for "([^\"]*)" with a pseud that is not mine$/ do |title| + step %{the user "gooduser" exists and is activated} + step %{I am logged in as "baduser"} + step %{I set up a signup for "#{title}" with combination A} + pseud_id = Pseud.where(name: "gooduser").first.id + find("#challenge_signup_pseud_id", visible: false).set(pseud_id) + click_button "Submit" +end + +When /^I attempt to update my signup for "([^\"]*)" with a pseud that is not mine$/ do |title| + step %{the user "gooduser" exists and is activated} + step %{I am logged in as "baduser"} + step %{I sign up for "#{title}" with combination A} + step %{I follow "Edit Sign-up"} + pseud_id = Pseud.where(name: "gooduser").first.id + find("#challenge_signup_pseud_id", visible: false).set(pseud_id) + click_button "Update" +end + +When /^I sign up for "([^\"]*)" with combination B$/ do |title| + step %{I start signing up for "#{title}"} + step %{I check the 1st checkbox with value "Stargate SG-1"} + step %{I check the 2nd checkbox with the value "Stargate Atlantis"} + step %{I fill in the 1st field with id matching "freeform_tagnames" with "Alternate Universe - High School, Something else weird"} + step %{I fill in the 2nd field with id matching "freeform_tagnames" with "Alternate Universe - High School"} + click_button "Submit" +end + +When /^I sign up for "([^\"]*)" with combination C$/ do |title| + step %{I start signing up for "#{title}"} + step %{I check the 1st checkbox with the value "Stargate SG-1"} + step %{I check the 2nd checkbox with the value "Stargate SG-1"} + step %{I fill in the 1st field with id matching "freeform_tagnames" with "Something else weird"} + step %{I fill in the 2nd field with id matching "freeform_tagnames" with "Something else weird"} + click_button "Submit" +end + +When /^I sign up for "([^\"]*)" with combination D$/ do |title| + step %{I start signing up for "#{title}"} + step %{I check the 1st checkbox with the value "Stargate Atlantis"} + step %{I check the 2nd checkbox with the value "Stargate Atlantis"} + step %{I fill in the 1st field with id matching "freeform_tagnames" with "Something else weird, Alternate Universe - Historical"} + step %{I fill in the 2nd field with id matching "freeform_tagnames" with "Something else weird, Alternate Universe - Historical"} + click_button "Submit" +end + +When /^I sign up for "([^\"]*)" with a mismatched combination$/ do |title| + step %{I start signing up for "#{title}"} + step %{I check the 1st checkbox with the value "Bad Choice"} + step %{I check the 2nd checkbox with the value "Bad Choice"} + click_button "Submit" +end + + +When /^I sign up for "([^\"]*)" with combination SGA$/ do |title| + step %{I start signing up for "#{title}"} + step %{I fill in "challenge_signup_requests_attributes_0_tag_set_attributes_fandom_tagnames" with "Stargate Atlantis"} + fill_in("challenge_signup_requests_attributes_0_title", with: "SGA love") + click_button "Submit" +end + +When /^I sign up for "([^\"]*)" with combination SG-1$/ do |title| + step %{I start signing up for "#{title}"} + step %{I fill in "challenge_signup_requests_attributes_0_tag_set_attributes_fandom_tagnames" with "Stargate SG-1"} + fill_in("challenge_signup_requests_attributes_0_title", with: "SG1 love") + click_button "Submit" +end + +When /^I sign up for "([^\"]*)" with missing prompts$/ do |title| + step %{I start signing up for "#{title}"} + step %{I check the 1st checkbox with the value "Stargate Atlantis"} + step %{I fill in the 1st field with id matching "freeform_tagnames" with "Something else weird"} + click_button "Submit" +end + +When /^I start to sign up for "([^\"]*)"$/ do |title| + step %{I start signing up for "#{title}"} + step %{I check the 1st checkbox with value "Stargate SG-1"} +end + +When /^I start to sign up for "([^\"]*)" tagless gift exchange$/ do |title| + visit collection_path(Collection.find_by(title: title)) + step %{I follow "Sign Up"} + step %{I fill in "Description" with "random text"} + step %{I press "Submit"} + step %{I should see "Sign-up was successfully created"} +end + +Then "I should see participant number {int} with byline {string}" do |num, byline| + within(:xpath, ".//dt[@class=\"participant\"][#{num}]") { expect(page).to have_content(byline) } +end + +Then "I should see all the participants who have signed up" do + step %{I should see participant number 1 with byline "myname1_pseud (myname1)"} + step %{I should see participant number 2 with byline "myname2"} + step %{I should see participant number 3 with byline "myname3"} + step %{I should see participant number 4 with byline "myname4"} +end + +## Matching + +Given /^the gift exchange "([^\"]*)" is ready for matching$/ do |title| + step %{the gift exchange "#{title}" is ready for signups} + step %{everyone has signed up for the gift exchange "#{title}"} +end + +Given /^I create an invalid signup in the gift exchange "([^\"]*)"$/ do |challengename| + collection = Collection.find_by(title: challengename) + # create an invalid signup by deleting the first one's offers, + # bypassing the validation checks + collection.signups.first.offers.delete_all +end + +When /^I remove a recipient$/ do + step %{I fill in the 1st field with id matching "_request_signup_pseud" with ""} +end + +When /^I assign a recipient to herself$/ do + first_recip_field = page.all("input[type='text']").select {|el| el['id'] && el['id'].match(/_request_signup_pseud/)}[0] + recip = first_recip_field['value'] + id = first_recip_field['id'] + if id.match(/assignments_(\d+)_request/) + num = $1 + fill_in "challenge_assignments_#{num}_offer_signup_pseud", with: recip + end +end + +When /^I manually destroy the assignments for "([^\"]*)"$/ do |title| + collection = Collection.find_by(title: title) + collection.assignments.destroy_all +end + +When /^I assign a pinch hitter$/ do + step %{I fill in the 1st field with id matching "pinch_hitter_byline" with "mod1"} +end + +When /^I assign a pinch recipient$/ do + name = page.all("td").select {|el| el['id'] && el['id'].match(/offer_signup_for/)}[0].text + pseud = Pseud.find_by(name: name) + request_pseud = ChallengeSignup.where(pseud_id: pseud.id).first.offer_potential_matches.first.request_signup.pseud.byline + step %{I fill in the 1st field with id matching "request_signup_pseud" with "#{request_pseud}"} +end + +Given /^everyone has signed up for the gift exchange "([^\"]*)"$/ do |challengename| + step %{I am logged in as "myname1"} + pseud = User.find_by(login: "myname1").pseuds.find_or_create_by(name: "myname1_pseud") + step %{I sign up for "#{challengename}" with combination A and my pseud "#{pseud.name}"} + step %{I am logged in as "myname2"} + step %{I sign up for "#{challengename}" with combination B} + step %{I am logged in as "myname3"} + step %{I sign up for "#{challengename}" with combination C} + step %{I am logged in as "myname4"} + step %{I sign up for "#{challengename}" with combination D} +end + +Given /^I have generated matches for "([^\"]*)"$/ do |challengename| + step %{I close signups for "#{challengename}"} + step %{I follow "Matching"} + step %{I follow "Generate Potential Matches"} + step %{I reload the page} + step %{all emails have been delivered} +end + +Given /^I have sent assignments for "([^\"]*)"$/ do |challengename| + step %{I follow "Send Assignments"} + step %{I reload the page} + step %{I should not see "Assignments are now being sent out"} +end + +Given /^everyone has their assignments for "([^\"]*)"$/ do |challenge_title| + step %{the gift exchange "#{challenge_title}" is ready for matching} + step %{I have generated matches for "#{challenge_title}"} + step %{I have sent assignments for "#{challenge_title}"} +end + +Given "{string} has an assignment for the user {string} in the collection {string}" do |giver_login, recip_login, collection_name| + giver = User.find_by(login: giver_login) + recip = User.find_by(login: recip_login) + collection = FactoryBot.create(:collection, name: collection_name, title: collection_name) + assignment = FactoryBot.create(:challenge_assignment, sent_at: Time.zone.now, collection_id: collection.id) + assignment.offer_signup.update_column(:pseud_id, giver.default_pseud_id) + assignment.request_signup.update_column(:pseud_id, recip.default_pseud_id) + assignment.reload +end + +### Fulfilling assignments + +When /^I start to fulfill my assignment$/ do + step %{I follow "My Dashboard"} + step %{I follow "Assignments ("} + step %{I follow "Fulfill"} + step %{I fill in "Work Title" with "Fulfilled Story"} + step %{I select "Not Rated" from "Rating"} + step %{I check "No Archive Warnings Apply"} + step %{I select "English" from "Choose a language"} + step %{I fill in "Fandom" with "Final Fantasy X"} + step %{I fill in "content" with "This is a really cool story about Final Fantasy X"} +end + +When /^I fulfill my assignment$/ do + step %{I start to fulfill my assignment} + step %{I press "Preview"} + step %{I press "Post"} + step %{I should see "Work was successfully posted"} +end + +When /^an assignment has been fulfilled in a gift exchange$/ do + step %{everyone has their assignments for "Awesome Gift Exchange"} + step %{I am logged in as "myname1"} + step %{I fulfill my assignment} +end + +# we're not testing the process of rejection here, just that +# it doesn't affect the completion status of the challenge assignment +When /^I refuse my gift story "(.*?)"/ do |work| + w = Work.find_by(title: work) + w.gifts.first.toggle!(:rejected) +end + +### WHEN we need the author attribute to be set +When /^I fulfill my assignment and the author is "([^\"]*)"$/ do |new_user| + step %{I start to fulfill my assignment} + step %{I select "#{new_user}" from "Author / Pseud(s)"} + step %{I press "Preview"} + step %{I press "Post"} + step %{I should see "Work was successfully posted"} +end + +When /^I have set up matching for "([^\"]*)" with no required matching$/ do |challengename| + step %{I am logged in as "mod1"} + step %{I have created the gift exchange "Awesome Gift Exchange"} + step %{I open signups for "Awesome Gift Exchange"} + step %{everyone has signed up for the gift exchange "Awesome Gift Exchange"} +end + +### Deleting a gift exchange + +Then /^I should not see the gift exchange dashboard for "([^\"]*)"$/ do |challenge_title| + collection = Collection.find_by(title: challenge_title) + visit collection_path(collection) + step %{I should not see "Gift Exchange" within "#dashboard"} + step %{I should not see "Sign-up Form" within "#dashboard"} + step %{I should not see "My Sign-up" within "#dashboard"} + step %{I should not see "Sign-ups" within "#dashboard"} + step %{I should not see "Challenge Settings" within "#dashboard"} + step %{I should not see "Sign-up Summary" within "#dashboard"} + step %{I should not see "Requests Summary" within "#dashboard"} + step %{I should not see "Matching" within "#dashboard"} + step %{I should not see "Assignments" within "#dashboard"} + step %{I should not see "Challenge Settings" within "#dashboard"} +end + +Then /^no one should have an assignment for "([^\"]*)"$/ do |challenge_title| + collection = Collection.find_by(title: challenge_title) + User.all.each do |user| + user.offer_assignments.in_collection(collection).should be_empty + user.pinch_hit_assignments.in_collection(collection).should be_empty + end +end diff --git a/features/step_definitions/challenge_promptmeme_steps.rb b/features/step_definitions/challenge_promptmeme_steps.rb new file mode 100644 index 0000000..b4582c2 --- /dev/null +++ b/features/step_definitions/challenge_promptmeme_steps.rb @@ -0,0 +1,557 @@ + +Given /^I have Battle 12 prompt meme set up$/ do + step %{I am logged in as "mod1"} + step "I have standard challenge tags setup" + step "I set up Battle 12 promptmeme collection" +end + +Given /^I have Battle 12 prompt meme fully set up$/ do + step %{I am logged in as "mod1"} + step "I have standard challenge tags setup" + step "I set up Battle 12 promptmeme collection" + step "I fill in Battle 12 challenge options" +end + +Given /^I have no-column prompt meme fully set up$/ do + step %{I am logged in as "mod1"} + step "I have standard challenge tags setup" + step "I set up Battle 12 promptmeme collection" + step "I fill in no-column challenge options" +end + +Given /^I have single-prompt prompt meme fully set up$/ do + step %{I am logged in as "mod1"} + step "I have standard challenge tags setup" + step "I set up Battle 12 promptmeme collection" + step "I fill in single-prompt challenge options" +end + +Given /^everyone has signed up for Battle 12$/ do + # no anon + step %{I am logged in as "myname1"} + step %{I sign up for Battle 12 with combination A} + + # both anon + step %{I am logged in as "myname2"} + step %{I sign up for Battle 12 with combination B} + + # one anon + step %{I am logged in as "myname3"} + step %{I sign up for Battle 12} + + # no anon + step %{I am logged in as "myname4"} + step %{I sign up for Battle 12 with combination C} +end + +Given /^an anon has signed up for Battle 12$/ do + # both anon + step %{I am logged in as "myname2"} + step %{I sign up for Battle 12 with combination B} +end + +Given /^"([^\"]*)" has signed up for Battle 12 with combination ([^\"]*)$/ do |username, combo| + step %{I am logged in as "#{username}"} + step %{I sign up for Battle 12 with combination #{combo}} +end + +Given /^"([^\"]*)" has signed up for Battle 12 with one more prompt than required$/ do |username| + step %{I am logged in as "#{username}"} + step %{I sign up for Battle 12 with combination C} + step %{I add a new prompt to my signup for a prompt meme} +end + +Given /^"([^\"]*)" has fulfilled a claim from Battle 12$/ do |username| + step %{"#{username}" has claimed a prompt from Battle 12} + step %{I fulfill my claim} +end + +Given /^"([^\"]*)" has deleted their sign up for the prompt meme "([^\"]*)"$/ do |username, challenge_title| + step %{I am logged in as "#{username}"} + step %{I delete my signup for the prompt meme "#{challenge_title}"} +end + +Given /^"([^\"]*)" has claimed a prompt from Battle 12$/ do |username| + step %{I am logged in as "#{username}"} + step %{I claim a prompt from "Battle 12"} +end + +When /^I set up an?(?: ([^"]*)) promptmeme "([^\"]*)"(?: with name "([^"]*)")?$/ do |type, title, name| + step %{I am logged in as "mod1"} + visit new_collection_path + if name.nil? + fill_in("collection_name", with: "promptcollection") + else + fill_in("collection_name", with: name) + end + fill_in("collection_title", with: title) + if type == "anon" + check("This collection is unrevealed") + check("This collection is anonymous") + end + select("Prompt Meme", from: "challenge_type") + step %{I submit} + step "I should see \"Collection was successfully created\"" + + check("prompt_meme_signup_open") + fill_in("prompt_meme_requests_num_allowed", with: ArchiveConfig.PROMPT_MEME_PROMPTS_MAX) + fill_in("prompt_meme_requests_num_required", with: 1) + fill_in("prompt_meme_request_restriction_attributes_fandom_num_required", with: 1) + fill_in("prompt_meme_request_restriction_attributes_fandom_num_allowed", with: 2) + fill_in("Sign-up opens:", with: Date.yesterday) + fill_in("Sign-up closes:", with: Date.tomorrow) + step %{I submit} + step "I should see \"Challenge was successfully created\"" +end + +When /^I set up Battle 12 promptmeme collection$/ do + step %{I am logged in as "mod1"} + visit new_collection_path + fill_in("collection_name", with: "lotsofprompts") + fill_in("collection_title", with: "Battle 12") + fill_in("Introduction", with: "Welcome to the meme") + fill_in("FAQ", with: "<dl><dt>What is this thing?</dt><dd>It is a comment fic thing</dd></dl>") + fill_in("Rules", with: "Be nicer to people") + check("This collection is unrevealed") + check("This collection is anonymous") + select("Prompt Meme", from: "challenge_type") + step %{I submit} + step "I should see \"Collection was successfully created\"" +end + +When /^I create Battle 12 promptmeme$/ do + step "I set up Battle 12 promptmeme collection" + step "I fill in Battle 12 challenge options" +end + +When /^I fill in Battle 12 challenge options$/ do + step "I fill in prompt meme challenge options" + step %{I fill in "Sign-up Instructions" with "Please request easy things"} + fill_in("Sign-up opens:", with: Date.yesterday) + fill_in("Sign-up closes:", with: Date.tomorrow) + step %{I select "(GMT-05:00) Eastern Time (US & Canada)" from "Time zone"} + step %{I fill in "prompt_meme_requests_num_allowed" with "3"} + check("prompt_meme_request_restriction_attributes_title_allowed") + step %{I submit} +end + +When /^I fill in future challenge options$/ do + step "I fill in prompt meme challenge options" + fill_in("Sign-up opens:", with: Date.yesterday) + fill_in("Sign-up closes:", with: Date.tomorrow) + step %{I fill in "prompt_meme_requests_num_allowed" with "3"} + step %{I uncheck "Sign-up open?"} + step %{I submit} +end + +When /^I fill in past challenge options$/ do + step "I fill in prompt meme challenge options" + step %{I fill in "Sign-up opens" with "2010-09-20 12:40AM"} + step %{I fill in "Sign-up closes" with "2010-09-20 12:40AM"} + step %{I fill in "prompt_meme_requests_num_allowed" with "3"} + step %{I uncheck "Sign-up open?"} + step %{I submit} +end + +When /^I fill in no-column challenge options$/ do + step %{I fill in "prompt_meme_requests_num_required" with "1"} + step %{I fill in "prompt_meme_request_restriction_attributes_fandom_num_allowed" with "0"} + step %{I fill in "prompt_meme_request_restriction_attributes_character_num_allowed" with "0"} + step %{I fill in "prompt_meme_request_restriction_attributes_relationship_num_allowed" with "0"} + step %{I check "Sign-up open?"} + fill_in("Sign-up opens:", with: Date.yesterday) + fill_in("Sign-up closes:", with: Date.tomorrow) + step %{I submit} +end + +When /^I fill in single-prompt challenge options$/ do + step %{I fill in "prompt_meme_requests_num_required" with "1"} + step %{I check "Sign-up open?"} + check("prompt_meme_request_restriction_attributes_title_allowed") + fill_in("Sign-up opens:", with: Date.yesterday) + fill_in("Sign-up closes:", with: Date.tomorrow) + step %{I submit} +end + +When /^I fill in multi-prompt challenge options$/ do + step "I fill in prompt meme challenge options" + step %{I fill in "prompt_meme_requests_num_allowed" with "4"} + step %{I submit} +end + +When /^I fill in prompt meme challenge options$/ do + step %{I fill in "General Sign-up Instructions" with "Here are some general tips"} + fill_in("Tag Sets To Use:", with: "Standard Challenge Tags") + step %{I fill in "prompt_meme_request_restriction_attributes_fandom_num_required" with "1"} + step %{I fill in "prompt_meme_request_restriction_attributes_fandom_num_allowed" with "1"} + step %{I fill in "prompt_meme_request_restriction_attributes_freeform_num_allowed" with "2"} + step %{I fill in "prompt_meme_requests_num_required" with "2"} + step %{I check "Sign-up open?"} + fill_in("Sign-up opens:", with: Date.yesterday) + fill_in("Sign-up closes:", with: Date.tomorrow) +end + +When /^I allow (\d+) prompts$/ do |number| + fill_in("prompt_meme_requests_num_allowed", with: number) +end + +When /^I require (\d+) prompts$/ do |number| + fill_in("prompt_meme_requests_num_required", with: number) +end + +When /^I sign up for Battle 12$/ do + step %{I start signing up for "Battle 12"} + step %{I check the 1st checkbox with the value "Stargate SG-1"} + step %{I check the 2nd checkbox with the value "Stargate SG-1"} + step %{I check the 2nd checkbox with id matching "anonymous"} + step %{I fill in the 1st field with id matching "freeform_tagnames" with "Something else weird"} + step %{I fill in the 1st field with id matching "title" with "crack"} + # We have to use explicit button names because there are two forms on this page - the form to expand prompts + click_button "Submit" +end + +When /^I sign up for Battle 12 with combination A$/ do + step %{I start signing up for "Battle 12"} + step %{I check the 1st checkbox with the value "Stargate Atlantis"} + step %{I check the 2nd checkbox with the value "Stargate Atlantis"} + step %{I fill in the 1st field with id matching "freeform_tagnames" with "Alternate Universe - Historical"} + click_button "Submit" +end + +When /^I sign up for Battle 12 with combination B$/ do + step %{I start signing up for "Battle 12"} + step %{I check the 1st checkbox with the value "Stargate SG-1"} + step %{I check the 2nd checkbox with the value "Stargate Atlantis"} + step %{I check the 1st checkbox with id matching "anonymous"} + step %{I check the 2nd checkbox with id matching "anonymous"} + step %{I fill in the 1st field with id matching "freeform_tagnames" with "Alternate Universe - High School, Something else weird"} + step %{I fill in the 1st field with id matching "title" with "High School AU SG1"} + step %{I fill in the 2nd field with id matching "title" with "random SGA love"} + click_button "Submit" + step %{I should see "Sign-up was successfully created"} +end + +When /^I sign up for Battle 12 with combination C$/ do + step %{I start signing up for "Battle 12"} + step %{I check the 1st checkbox with the value "Stargate Atlantis"} + step %{I check the 2nd checkbox with the value "Stargate Atlantis"} + step %{I fill in the 1st field with id matching "freeform_tagnames" with "Something else weird, Alternate Universe - Historical"} + step %{I fill in the 1st field with id matching "title" with "weird SGA history AU"} + step %{I fill in the 2nd field with id matching "title" with "canon SGA love"} + click_button "Submit" + step %{I should see "Sign-up was successfully created"} + step %{I should see "Stargate Atlantis"} + step %{I should see "Something else weird"} +end + +When /^I sign up for Battle 12 with combination D$/ do + step %{I start signing up for "Battle 12"} + step %{I check the 1st checkbox with the value "Stargate Atlantis"} + step %{I check the 2nd checkbox with the value "Stargate Atlantis"} + click_button "Submit" +end + +When /^I sign up for Battle 12 with combination E$/ do + step "I go to the collections page" + step "I follow \"Battle 12\"" + step "I follow \"Sign Up\"" + step %{I fill in "Description:" with "Weird description"} + step "I press \"Submit\"" +end + +When /^I sign up for "([^\"]*)" fixed-fandom prompt meme$/ do |title| + visit collection_path(Collection.find_by(title: title)) + step %{I follow "Sign Up"} + step %{I check the 1st checkbox with value "Stargate SG-1"} + step %{I check the 2nd checkbox with value "Stargate SG-1"} + step %{I check the 2nd checkbox with id matching "anonymous"} + step %{I fill in the 1st field with id matching "freeform_tagnames" with "Something else weird"} + click_button "Submit" +end + +When /^I sign up for "([^\"]*)" many-fandom prompt meme$/ do |title| + visit collection_path(Collection.find_by(title: title)) + step %{I follow "Sign Up"} + step %{I fill in the 1st field with id matching "fandom_tagnames" with "Stargate Atlantis"} + step %{I check the 1st checkbox with id matching "anonymous"} + click_button "Submit" +end + +When /^I add prompt (\d+)$/ do |number| + step %{I add prompt #{number} with "Stargate Atlantis"} +end + +When /^I add prompt (\d+) with "([^"]+)"$/ do |number, tag| + step %{I follow "Add Prompt"} + step %{I should see "Request #{number}"} + step %{I check the 1st checkbox with the value "#{tag}"} + # there is only one form on the individual prompt page + step %{I submit} + step %{I should see "Prompt was successfully added"} +end + +When /^I add prompts up to (\d+) starting with (\d+)$/ do |final_number_of_prompts, start| + @index = start + while @index <= final_number_of_prompts + step "I add prompt #{@index}" + @index = @index + 1 + end +end + +When /^I fill in the missing prompt$/ do + step %{I check the 2nd checkbox with the value "Stargate Atlantis"} + click_button "Submit" +end + +When /^I add a new prompt to my signup$/ do + step %{I follow "Add Prompt"} + step %{I check "Stargate Atlantis"} + step %{I fill in the 1st field with id matching "freeform_tagnames" with "My extra tag"} + step %{I press "Submit"} +end + +When /^I add a new prompt to my signup for a prompt meme$/ do + step %{I follow "Add Prompt"} + step %{I check "Stargate Atlantis"} + step %{I press "Submit"} +end + +When /^I edit the signup by "([^\"]*)"$/ do |participant| + visit collection_path(Collection.find_by(title: "Battle 12")) + step %{I follow "Prompts ("} + step %{I follow "Edit Sign-up"} +end + +### WHEN viewing after signups + +When /^I view my signup for "([^\"]*)"$/ do |title| + visit collection_path(Collection.find_by(title: title)) + step %{I follow "My Prompts"} +end + +When /^I view unposted claims for "([^\"]*)"$/ do |title| + visit collection_path(Collection.find_by(title: title)) + # step %{show me the sidebar} + step %{I follow "Unposted Claims ("} +end + +When /^I view prompts for "([^\"]*)"$/ do |title| + visit collection_path(Collection.find_by(title: title)) + step %{I follow "Prompts ("} +end + +### WHEN claiming + +When /^I claim a prompt from "([^\"]*)"$/ do |title| + visit collection_path(Collection.find_by(title: title)) + step %{I follow "Prompts ("} + step %{I press "Claim"} +end + +When /^I claim two prompts from "([^\"]*)"$/ do |title| + step %{I claim a prompt from "#{title}"} + step %{I claim a prompt from "#{title}"} +end + +### WHEN fulfilling claims + +When /^I start to fulfill my claim with "([^\"]*)"$/ do |title| + step %{I follow "My Dashboard"} + step %{I follow "Claims ("} + step %{I follow "Fulfill"} + step %{I fill in "Work Title" with "#{title}"} + step %{I select "Not Rated" from "Rating"} + step %{I check "No Archive Warnings Apply"} + step %{I select "English" from "Choose a language"} + step %{I fill in "Fandom" with "Stargate Atlantis"} + step %{I fill in "content" with "This is an exciting story about Atlantis"} +end + +When /^I start to fulfill my claim$/ do + step %{I start to fulfill my claim with "Fulfilled Story"} +end + +When /^I fulfill my claim$/ do + step %{I start to fulfill my claim with "Fulfilled Story"} + step %{I press "Preview"} + step %{I press "Post"} + step %{I should see "Work was successfully posted"} +end + +When /^I fulfill my claim again$/ do + step %{I follow "My Dashboard"} + step %{I follow "Claims ("} + step %{I follow "Fulfilled Claims"} + step %{I follow "Fulfill"} + step %{I fill in "Work Title" with "Second Story"} + step %{I select "Not Rated" from "Rating"} + step %{I check "No Archive Warnings Apply"} + step %{I select "English" from "Choose a language"} + step %{I fill in "Fandom" with "Stargate Atlantis"} + step %{I fill in "content" with "This is an exciting story about Atlantis"} + step %{I press "Preview"} + step %{I press "Post"} + step %{I should see "Work was successfully posted"} +end + +When /^mod fulfills claim$/ do + step %{I am logged in as "mod1"} + step %{I claim a prompt from "Battle 12"} + step %{I start to fulfill my claim} + step %{I fill in "Work Title" with "Fulfilled Story-thing"} + step %{I fill in "content" with "This is an exciting story about Atlantis, but in a different universe this time"} + step %{I press "Preview"} + step %{I press "Post"} +end + +When /^I delete my prompt in "([^\"]*)"$/ do |title| + visit collection_path(Collection.find_by(title: title)) + step %{I follow "Prompts ("} + step %{I press "Delete Prompt"} +end + +When /^I delete the prompt by "([^\"]*)"$/ do |participant| + visit collection_path(Collection.find_by(title: "Battle 12")) + step %{I follow "Prompts ("} + step %{I follow "Delete Prompt"} +end + +When /^I edit the first prompt$/ do + visit collection_path(Collection.find_by(title: "Battle 12")) + step %{I follow "Prompts ("} + # The 'Edit Sign-up' and 'Edit Prompt' buttons were removed for mods in + # Prompt Meme challenges + #step %{I follow "Edit Prompt"} +end + +Then /^I should see prompt meme options$/ do + step %{I should not see "Offer Settings"} + step %{I should see "Request Settings"} + step %{I should not see "If you plan to use automated matching"} + step %{I should not see "Allow Any"} +end + +Then /^I should see a prompt is claimed$/ do + # note, prompts are in reverse date order by default + step %{I should see "New claim made."} + step %{I should see "My Claims in Battle 12"} + step %{I should see "Fulfill"} + step %{I should see "Drop Claim"} + + # Claims in the user page are just the prompts that have been claimed + step %{I follow "My Dashboard"} + step %{I follow "Claims"} + step %{I should see "Fulfill"} + step %{I should see "by Anonymous"} + step %{I should not see "myname" within ".index"} +end + +Then /^I should see correct signups for Battle 12$/ do + step %{I should see "myname4"} + step %{I should see "myname3"} + step %{I should not see "myname2"} + step %{I should see "by Anonymous"} + step %{I should see "myname1"} + step %{I should see "Stargate Atlantis"} + step %{I should see "Stargate SG-1"} + step %{I should see "Something else weird"} + step %{I should see "Alternate Universe - Historical"} + step %{I should not see "Matching"} +end + +Then /^claims are hidden$/ do + step %{I go to "Battle 12" collection's page} + step %{I follow "Unposted Claims"} + step %{I should see "Unposted Claims"} + step %{I should see "Fulfilled Claims"} + step %{I should see "myname" within ".claims"} + step %{I should see "Secret!" within ".claims"} + step %{I should see "Stargate Atlantis"} +end + +Then /^claims are shown$/ do + step %{I go to "Battle 12" collection's page} + step %{I follow "Unposted Claims"} + step %{I should see "myname4" within "h5"} + step %{I should not see "Secret!"} + step %{I should see "Stargate Atlantis"} +end + +Then /^Battle 12 prompt meme should be correctly created$/ do + step %{I should see "Challenge was successfully created"} + step "signup should be open" + step %{"Battle 12" collection exists} + step %{I should see "(Open, Unmoderated, Unrevealed, Anonymous, Prompt Meme Challenge)"} +end + +Then /^my claim should be fulfilled$/ do + step %{I should see "Work was successfully posted"} + step %{I should see "Fandom:"} + step %{I should see "Stargate Atlantis"} + step %{I should not see "Alternate Universe - Historical"} + step %{I should see "In response to a prompt by"} +end + +Then /^I should see the whole signup$/ do + page.should have_content("Sign-up for") + page.should have_content("Requests") + page.should have_content("Request 1") + page.should have_content("Request 2") +end + +Then /^I should see single prompt editing$/ do + page.should have_content("Edit Sign-up") + page.should have_content("Additional Tags") + step %{the field labeled "Additional Tags" should contain "Alternate Universe - Historical"} + page.should have_no_content("Just add one new prompt instead") +end + +Then /^I should see Battle 12 descriptions$/ do + step %{I should see "Welcome to the meme" within "#intro"} + step %{I should see "Sign-up: Open"} + step %{I should see "Sign-up Closes:"} + step %{I should see "#{Time.now.year}" within ".collection .meta"} + step %{I should see "What is this thing?" within "#faq"} + step %{I should see "It is a comment fic thing" within "#faq"} + step %{I should see "Be nicer to people" within "#rules"} +end + +Then /^I should be editing the challenge settings$/ do + step %{I should see "Setting Up the Battle 12 Prompt Meme"} +end + +Then "{int} prompt(s) should be required" do |number| + expect(page).to have_field("prompt_meme_requests_num_required", with: number.to_s) +end + +Then "{int} prompt(s) should be allowed" do |number| + expect(page).to have_field("prompt_meme_requests_num_allowed", with: number.to_s) +end + +Then /^I should not see the prompt meme dashboard for "([^\"]*)"$/ do |challenge_title| + collection = Collection.find_by(title: challenge_title) + visit collection_path(collection) + step %{I should not see "Prompt Meme" within "#dashboard"} + step %{I should not see "Prompts" within "#dashboard"} + step %{I should not see "My Prompts" within "#dashboard"} + step %{I should not see "Prompt Form" within "#dashboard"} + step %{I should not see "My Claims" within "#dashboard"} + step %{I should not see "Unposted Claims" within "#dashboard"} + step %{I should not see "Challenge Settings" within "#dashboard"} +end + +Then /^no one should have a claim in "([^\"]*)"$/ do |challenge_title| + collection = Collection.find_by(title: challenge_title) + if collection.present? + User.all.each do |user| + user.request_claims.in_collection(collection).should be_empty + end + # we don't have a collection id because the collection has been deleted + # so let's make sure any remaining claims are for exisiting collections + else + ChallengeClaim.all.each do |claim| + collection_id = claim.collection_id + Collection.find_by(id: collection_id).should_not be_nil + end + end +end diff --git a/features/step_definitions/challenge_steps.rb b/features/step_definitions/challenge_steps.rb new file mode 100644 index 0000000..36e4da0 --- /dev/null +++ b/features/step_definitions/challenge_steps.rb @@ -0,0 +1,298 @@ +###### ERROR MESSAGES +Then /^I should see a not\-in\-fandom error message for "([^"]+)" in "([^"]+)"$/ do |tag, fandom| + step %{I should see "are not in the selected fandom(s), #{fandom}: #{tag}"} +end + +Then /^I should see a not\-in\-fandom error message$/ do + step %{I should see "are not in the selected fandom(s)"} +end + +### Set up dates correctly ### +Then /^I set up the challenge dates$/ do + fill_in("Sign-up opens:", with: Date.yesterday) + fill_in("Sign-up closes:", with: Date.tomorrow) +end + +### Clear out old data + +Given /^I have no prompts$/ do + Prompt.delete_all +end + +### CHALLENGE TAGS + +Given /^signup summaries are always visible$/ do + stub_const("ArchiveConfig", OpenStruct.new(ArchiveConfig)) + ArchiveConfig.ANONYMOUS_THRESHOLD_COUNT = 0 +end + +Given /^all signup summaries are delayed$/ do + stub_const("ArchiveConfig", OpenStruct.new(ArchiveConfig)) + ArchiveConfig.MAX_SIGNUPS_FOR_LIVE_SUMMARY = 0 +end + +Given /^all signup summaries are live$/ do + stub_const("ArchiveConfig", OpenStruct.new(ArchiveConfig)) + ArchiveConfig.MAX_SIGNUPS_FOR_LIVE_SUMMARY = 1_000_000 +end + +Given /^I have standard challenge tags set ?up$/ do + begin + unless UserSession.find + step %{I am logged in as "mod1"} + end + rescue + step %{I am logged in as "mod1"} + end + step "I have no tags" + step "basic tags" + step %{a canonical fandom "Stargate Atlantis"} + step %{a canonical fandom "Stargate SG-1"} + step %{a canonical fandom "Bad Choice"} + step %{a canonical character "John Sheppard"} + step %{a canonical freeform "Alternate Universe - Historical"} + step %{a canonical freeform "Alternate Universe - High School"} + step %{a canonical freeform "Something else weird"} + step %{a canonical freeform "My extra tag"} + step %{I set up the tag set "Standard Challenge Tags" with the fandom tags "Stargate Atlantis, Stargate SG-1, Bad Choice", the character tag "John Sheppard"} +end + +Given /^I have Yuletide challenge tags set ?up$/ do + step "I have standard challenge tags setup" + step %{I add the fandom tags "Starsky & Hutch, Tiny fandom, Care Bears, Yuletide Hippos RPF, Unoffered, Unrequested" to the tag set "Standard Challenge Tags"} + step %{a canonical fandom "Starsky & Hutch"} + step %{a canonical fandom "Tiny fandom"} + step %{a canonical fandom "Care Bears"} + step %{a canonical fandom "Yuletide Hippos RPF"} + step %{a canonical fandom "Unoffered"} + step %{a canonical fandom "Unrequested"} +end + +### General Challenge Settings + +When /^I edit settings for "([^\"]*)" challenge$/ do |title| + visit collection_path(Collection.find_by(title: title)) + step %{I follow "Challenge Settings"} +end + +# Timezone + +When /^I change the challenge timezone to Alaska$/ do + step %{I follow "Challenge Settings"} + step %{I select "(GMT-09:00) Alaska" from "prompt_meme_time_zone"} + step %{I submit} + step %{I should see "Challenge was successfully updated"} +end + +Then /^I should see both timezones$/ do + step %{I follow "Profile"} + step %{I should see "EST ("} + step %{I should see "AKST)"} +end + +Then /^I should see just one timezone$/ do + step %{I follow "Profile"} + step %{I should see "Sign-up: Open"} + step %{I should not see "EST" within "#main"} + step %{I should see "AKST" within "#main"} +end + +# Open signup + +When /^I open signups for "([^\"]*)"$/ do |title| + step %{I am logged in as "mod1"} + visit collection_path(Collection.find_by(title: title)) + step %{I follow "Challenge Settings"} + step %{I check "Sign-up open?"} + step %{I submit} + step %{I should see "Challenge was successfully updated"} +end + +Then /^signup should be open$/ do + step %{I should see "Profile" within "div#main .collection .navigation"} + step %{I should see "Sign-up: Open" within ".collection .meta"} + step %{I should see "Sign-up Closes:"} +end + +When /^I view open challenges$/ do + step "I go to the collections page" + step %{I follow "Open Challenges"} +end + +### Signup process + +When /^I start signing up for "([^\"]*)"$/ do |title| + visit collection_path(Collection.find_by(title: title)) + step %{I follow "Sign Up"} +end + +When /^I view the sign-up summary for "(.*?)"$/ do |title| + visit collection_path(Collection.find_by(title: title)) + step %(I follow "Sign-up Summary") +end + +### Editing signups + +When /^I edit my signup for "([^\"]*)"$/ do |title| + visit collection_path(Collection.find_by(title: title)) + step %{I follow "Edit Sign-up"} +end + +### WHEN other + +When /^I close signups for "([^\"]*)"$/ do |title| + collection = Collection.find_by(title: title) + user_id = collection.all_owners.first.user_id + mod_login = User.find_by(id: user_id).login + step %{I am logged in as "#{mod_login}"} + visit collection_path(collection) + step %{I follow "Challenge Settings"} + step %{I uncheck "Sign-up open?"} + step %{I press "Update"} + step %{I should see an update confirmation message} +end + +When /^I delete my signup for the prompt meme "([^\"]*)"$/ do |title| + visit collection_path(Collection.find_by(title: title)) + step %{I follow "My Prompts"} + step %{I delete the signup} +end + +When /^I delete my signup for the gift exchange "([^\"]*)"$/ do |title| + visit collection_path(Collection.find_by(title: title)) + step %{I follow "My Sign-up"} + step %{I delete the signup} +end + +When /^I delete the signup by "([^\"]*)"$/ do |participant| + click_link("#{participant}") + step %{I delete the signup} +end + +When /^I delete the signup$/ do + step %{I follow "Delete Sign-up"} + step %{I press "Yes, Delete Sign-up"} + step %{I should see "Challenge sign-up was deleted."} +end + +When /^I reveal the "([^\"]*)" challenge$/ do |title| + step %{I am logged in as "mod1"} + visit collection_path(Collection.find_by(title: title)) + step %{I follow "Collection Settings"} + step %{I uncheck "This collection is unrevealed"} + step %{I press "Update"} +end + +When /^I approve the first item in the collection "([^\"]*)"$/ do |collection| + collection = Collection.find_by(title: collection) + collection_item = collection.collection_items.first.id + visit collection_path(collection) + step %{I follow "Manage Items"} + step %{I select "Approved" from "collection_items_#{collection_item}_collection_approval_status"} + step %{I press "Submit"} +end + + +When /^I reveal the authors of the "([^\"]*)" challenge$/ do |title| + step %{I am logged in as "mod1"} + visit collection_path(Collection.find_by(title: title)) + step %{I follow "Collection Settings"} + step %{I uncheck "This collection is anonymous"} + step %{I press "Update"} + step %{all indexing jobs have been run} +end + +When /^I use tomorrow as the "Sign-up closes" date$/ do + fill_in("Sign-up closes:", with: Date.tomorrow) +end + +# Notification messages + +When /^I create an assignment notification message with (an ampersand|linebreaks) for "([^\"]*)"$/ do |message_content, title| + c = Collection.find_by(title: title) + field = "collection_collection_profile_attributes_assignment_notification" + message = if message_content == "an ampersand" + "The first thing & the second thing." + else + "First Line\nSecond Line" + end + + step %{I am logged in as "#{c.owners.first.name}"} + visit collection_path(c) + + # TODO: Once AO3-3376 is fixed, this will need to change. + step %{I follow "Collection Settings"} + + fill_in(field, with: message) + step %{I press "Update"} +end + +Then /^the notification message to "([^\"]*)" should contain linebreaks$/ do |user| + @user = User.find_by(login: user) + email = emails("to: \"#{email_for(@user.email)}\"").first + email.multipart?.should be == true + + text_lines = email.text_part.body.to_s.split("\n") + html_lines = email.html_part.body.to_s.split(%r{<(?:\/?p|br|div)\b[^>]*>}i) + + (text_lines + html_lines).each do |line| + # We shouldn't see "First Line" and "Second Line" on the same line. + line.should_not =~ /Second Line/ if line =~ /First Line/ + end + + email.text_part.body.should =~ /First Line/ + email.text_part.body.should =~ /Second Line/ + email.html_part.body.should =~ /First Line/ + email.html_part.body.should =~ /Second Line/ +end + +Then /^the notification message to "([^\"]*)" should escape the ampersand$/ do |user| + @user = User.find_by(login: user) + email = emails("to: \"#{email_for(@user.email)}\"").first + email.multipart?.should be == true + + email.html_part.body.should =~ /The first thing & the second thing./ + email.html_part.body.should_not =~ /The first thing & the second thing./ +end + +Then /^the notification message to "([^\"]*)" should contain the no archive warnings tag$/ do |user| + @user = User.find_by(login: user) + email = emails("to: \"#{email_for(@user.email)}\"").first + email.multipart?.should be == true + + email.text_part.body.should =~ /Warning:/ + email.text_part.body.should =~ /No Archive Warnings Apply/ + email.text_part.body.should_not =~ /Name with colon/ + + email.html_part.body.should =~ /Warning:/ + email.html_part.body.should =~ /No Archive Warnings Apply/ + email.html_part.body.should_not =~ /Name with colon/ +end + +# Delete challenge + +Given /^the challenge "([^\"]*)" is deleted$/ do |challenge_title| + collection = Collection.find_by(title: challenge_title) + collection.challenge.destroy +end + +When /^I delete the challenge "([^\"]*)"$/ do |challenge_title| + step %{I edit settings for "#{challenge_title}" challenge} + step %{I follow "Delete Challenge"} +end + +Then /^no one should be signed up for "([^\"]*)"$/ do |challenge_title| + collection = Collection.find_by(title: challenge_title) + if collection.present? + User.all.each do |user| + user.challenge_signups.in_collection(collection).should be_empty + end + # we don't have a collection id because the collection has been deleted + # so let's make sure any remaining sign ups are for exisiting collections + else + ChallengeSignup.all.each do |signup| + collection_id = signup.collection_id + Collection.find_by(id: collection_id).should_not be_nil + end + end +end diff --git a/features/step_definitions/challenge_yuletide_steps.rb b/features/step_definitions/challenge_yuletide_steps.rb new file mode 100644 index 0000000..7f84718 --- /dev/null +++ b/features/step_definitions/challenge_yuletide_steps.rb @@ -0,0 +1,24 @@ + +When /^"([^"]*)" posts the fulfilling draft "([^"]*)" in "([^"]*)"$/ do |name, title, fandom| + step %{I am logged in as "#{name}"} + step %{I go to #{name}'s user page} + step %{I follow "Assignments"} + step %{I follow "Fulfill"} + step %{I fill in "Work Title" with "#{title}"} + step %{I fill in "Fandoms" with "#{fandom}"} + step %{I select "Not Rated" from "Rating"} + step %{I check "No Archive Warnings Apply"} + step %{I select "English" from "Choose a language"} + step %{I fill in "content" with "This is an exciting story about #{fandom}"} + step %{I press "Preview"} +end + +When /^"([^"]*)" posts the fulfilling story "([^"]*)" in "([^"]*)"$/ do |name, title, fandom| + step %{"#{name}" posts the fulfilling draft "#{title}" in "#{fandom}"} + step %{I press "Post"} + step %{I should see "Work was successfully posted"} + step %{I should see "For myname"} + step %{I should see "Collections:"} + step %{I should see "Yuletide" within ".meta"} + step %{I should see "Anonymous"} +end \ No newline at end of file diff --git a/features/step_definitions/collection_steps.rb b/features/step_definitions/collection_steps.rb new file mode 100644 index 0000000..ce4b210 --- /dev/null +++ b/features/step_definitions/collection_steps.rb @@ -0,0 +1,306 @@ +### GIVEN + +Given /^I have no collections$/ do + Collection.delete_all +end + +Given /^the collection "([^\"]*)" is deleted$/ do |collection_title| + step %{I am logged in as the owner of "#{collection_title}"} + visit edit_collection_path(Collection.find_by(title: collection_title)) + click_link "Delete Collection" + click_button "Yes, Delete Collection" + page.should have_content("Collection was successfully deleted.") +end + +When /^I am logged in as the owner of "([^\"]*)"$/ do |collection| + c = Collection.find_by(title: collection) + step %{I am logged in as "#{c.owners.first.user.login}"} +end + +When /^I view the collection "([^\"]*)"$/ do |collection| + visit collection_path(Collection.find_by(title: collection)) +end + +When /^I add my work to the collection$/ do + step %{I follow "Add To Collection"} + fill_in("collection_names", with: "Various_Penguins") + click_button("Add") +end + +When "I invite the work {string} to the collection {string}" do |work_title, collection_title| + work = Work.find_by(title: work_title) + collection = Collection.find_by(title: collection_title) + visit work_path(work) + click_link("Invite To Collections") + fill_in("collection_names", with: collection.name) + click_button("Invite") +end + +When "I edit the work {string} to be in the collection(s) {string}" do |work, collection| + step %{I edit the work "#{work}"} + fill_in("Post to Collections / Challenges", with: collection) + step %{I post the work} +end + +When /^I view the ([^"]*) collection items page for "(.*?)"$/ do |item_status, collection| + c = Collection.find_by(title: collection) + if item_status == "approved" + visit collection_items_path(c, status: "approved") + elsif item_status == "rejected by user" + visit collection_items_path(c, status: "rejected_by_user") + elsif item_status == "rejected by collection" + visit collection_items_path(c, status: "rejected_by_collection") + elsif item_status == "awaiting user approval" + visit collection_items_path(c, status: "unreviewed_by_user") + elsif item_status == "awaiting collection approval" + visit collection_items_path(c) + end +end + +When "the collection counts have expired" do + step "all indexing jobs have been run" + step "it is currently #{ArchiveConfig.SECONDS_UNTIL_COLLECTION_COUNTS_EXPIRE} seconds from now" +end + +When "the collection blurb cache has expired" do + step "it is currently #{ArchiveConfig.MINUTES_UNTIL_COLLECTION_BLURBS_EXPIRE} minutes from now" +end + +Given /^mod1 lives in Alaska$/ do + step %{I am logged in as "mod1"} + step %{I go to mod1 preferences page} + step %{I select "(GMT-09:00) Alaska" from "preference_time_zone"} + step %{I press "Update"} +end + +Given /^(?:I have )?(?:a|an|the) (hidden)?(?: )?(anonymous)?(?: )?(moderated)?(?: )?(closed)?(?: )?collection "([^\"]*)"(?: with name "([^\"]*)")?$/ do |hidden, anon, moderated, closed, title, name| + mod = ensure_user("moderator") + collection = FactoryBot.create(:collection, title: title, name: (name.presence || title.gsub(/[^\w]/, "_")), owner: mod.default_pseud) + collection.collection_preference.update_attribute(:anonymous, true) if anon.present? + collection.collection_preference.update_attribute(:unrevealed, true) if hidden.present? + collection.collection_preference.update_attribute(:moderated, true) if moderated.present? + collection.collection_preference.update_attribute(:closed, true) if closed.present? +end + +Given /^I open the collection with the title "([^\"]*)"$/ do |title| + step %{I am logged in as "moderator"} + visit collection_path(Collection.find_by(title: title)) + step %{I follow "Collection Settings"} + step %{I uncheck "This collection is closed"} + step %{I submit} + step %{I am logged out} +end + +Given /^I close the collection with the title "([^\"]*)"$/ do |title| + step %{I am logged in as "moderator"} + visit collection_path(Collection.find_by(title: title)) + step %{I follow "Collection Settings"} + step %{I check "This collection is closed"} + step %{I submit} + step %{I am logged out} +end + +Given /^I have added (?:a|the) co\-moderator "([^\"]*)" to collection "([^\"]*)"$/ do |name, title| + # create the user + step %{I am logged in as "#{name}"} + step %{I am logged in as "mod1"} + visit collection_path(Collection.find_by(title: title)) + click_link("Membership") + step %{I fill in "participants_to_invite" with "#{name}"} + step %{I press "Submit"} + + step %{I select "Moderator" from "#{name}_role"} + # TODO: fix the form, it is malformed right now + click_button("#{name}_submit") + step %{I should see "Updated #{name}"} +end + +Given "I have joined the collection {string} as {string}" do |title, login| + collection = Collection.find_by(title: title) + user = User.find_by(login: login) + FactoryBot.create(:collection_participant, pseud: user.default_pseud, collection: collection, participant_role: "Member") + visit collections_path +end + +Given "a set of collections for searching" do + profile = CollectionProfile.create!(faq: "<dl><dt>What is this test thing?</dt><dd>It's a test collection</dd></dl>", + intro: "Welcome to the test collection", + rules: "Be nice to testers") + FactoryBot.create(:collection, + name: "sometest", + title: "Some Test Collection", + collection_profile: profile) + FactoryBot.create(:collection, + name: "othertest", + title: "Some Other Collection") + FactoryBot.create(:collection, + :closed, + name: "anothertest", + title: "Another Plain Collection") + FactoryBot.create(:collection, + :moderated, + name: "surprisetest", + title: "Surprise Presents", + challenge: FactoryBot.create(:gift_exchange)) + FactoryBot.create(:collection, + name: "swaptest", + title: "Another Gift Swap", + challenge: FactoryBot.create(:gift_exchange)) + FactoryBot.create(:collection, + :closed, + name: "demandtest", + title: "On Demand", + challenge: FactoryBot.create(:prompt_meme)) + + step %{all indexing jobs have been run} +end + +### WHEN + +When /^I set up (?:a|the) collection "([^"]*)"(?: with name "([^"]*)")?$/ do |title, name| + visit new_collection_path + fill_in("collection_name", with: (name.blank? ? title.gsub(/[^\w]/, '_') : name)) + fill_in("collection_title", with: title) +end + +When /^I create (?:a|the) collection "([^"]*)"(?: with name "([^"]*)")?$/ do |title, name| + name = title.gsub(/[^\w]/, '_') if name.blank? + step %{I set up the collection "#{title}" with name "#{name}"} + step %{I submit} +end + +When /^I add (?:a|the) subcollection "([^"]*)"(?: with name "([^"]*)")? to (?:a|the) parent collection named "([^"]*)"$/ do |title, name, parent_name| + if Collection.find_by_name(parent_name).nil? + step %{I create the collection "#{parent_name}" with name "#{parent_name}"} + end + name = title.gsub(/[^\w]/, '_') if name.blank? + step %{I set up the collection "#{title}" with name "#{name}"} + fill_in("collection_parent_name", with: parent_name) + step %{I submit} +end + +When /^I sort by fandom$/ do + within(:xpath, "//li[a[contains(@title,'Sort')]]") do + step %{I follow "Fandom 1"} + end +end + +When /^I reveal works for "([^\"]*)"$/ do |title| + step %{I am logged in as the owner of "#{title}"} + visit collection_path(Collection.find_by(title: title)) + step %{I follow "Collection Settings"} + uncheck "This collection is unrevealed" + click_button "Update" + page.should have_content("Collection was successfully updated") +end + +When /^I reveal authors for "([^\"]*)"$/ do |title| + step %{I am logged in as the owner of "#{title}"} + visit collection_path(Collection.find_by(title: title)) + step %{I follow "Collection Settings"} + uncheck "This collection is anonymous" + click_button "Update" + page.should have_content("Collection was successfully updated") +end + +When /^I check all the collection settings checkboxes$/ do + check("collection_collection_preference_attributes_moderated") + check("collection_collection_preference_attributes_closed") + check("collection_collection_preference_attributes_unrevealed") + check("collection_collection_preference_attributes_anonymous") + check("collection_collection_preference_attributes_show_random") + check("collection_collection_preference_attributes_email_notify") +end + +When "{string} accepts the invitation for their work in the collection {string}" do |username, collection| + the_collection = Collection.find_by(title: collection) + collection_item_id = the_collection.collection_items.first.id + visit user_collection_items_path(User.find_by(login: username)) + step %{I select "Approved" from "collection_items_#{collection_item_id}_user_approval_status"} +end + +When "I approve the work {string} in the collection {string}" do |work, collection| + work = Work.find_by(title: work) + collection = Collection.find_by(title: collection) + item_id = CollectionItem.find_by(item: work, collection: collection).id + visit collection_items_path(collection) + step %{I select "Approved" from "collection_items_#{item_id}_collection_approval_status"} +end + +### THEN + +Then /^"([^"]*)" collection exists$/ do |title| + assert Collection.where(title: title).exists? +end + +Then /^the name of the collection "([^"]*)" should be "([^"]*)"$/ do |title, name| + assert Collection.find_by(title: title).name == name +end + +Then /^I should see a collection not found message for "([^\"]+)"$/ do |collection_name| + step %{I should see /We couldn't find the collection(?:.+and)? #{collection_name}/} +end + +Then /^the collection "(.*)" should be deleted/ do |collection| + assert Collection.where(title: collection).first.nil? +end + +Then /^the work "([^\"]*)" should be hidden from me$/ do |title| + work = Work.find_by(title: title) + visit work_path(work) + expect(page.title).to include("Mystery Work") + expect(page.title).not_to include(title) + expect(page).not_to have_content(title) + expect(page).to have_content("This work is part of an ongoing challenge and will be revealed soon!") + expect(page).not_to have_content(Sanitize.clean(work.chapters.first.content)) + if work.collections.first + step "all indexing jobs have been run" + visit collection_path(work.collections.first) + expect(page).not_to have_content(title) + expect(page).to have_content("Mystery Work") + end + visit user_path(work.users.first) + expect(page).not_to have_content(title) +end + +Then /^the work "([^\"]*)" should be visible to me$/ do |title| + work = Work.find_by(title: title) + visit work_path(work) + page.should have_content(title) + page.should have_content(Sanitize.clean(work.chapters.first.content)) +end + +Then /^the author of "([^\"]*)" should be visible to me on the work page$/ do |title| + work = Work.find_by(title: title) + visit work_path(work) + authors = work.pseuds.uniq.sort.collect(&:byline).join(", ") + page.should have_content("Anonymous [#{authors}]") +end + +Then /^the author of "([^\"]*)" should be publicly visible$/ do |title| + work = Work.find_by(title: title) + byline = work.users.first.pseuds.first.byline + visit work_path(work) + expect(page.title).to include(byline) + step %{I should see "#{byline}" within ".byline"} + if work.collections.first + step "all indexing jobs have been run" + visit collection_path(work.collections.first) + expect(page).to have_content("#{title} by #{byline}") + end +end + +Then /^the author of "([^\"]*)" should be hidden from me$/ do |title| + step "all indexing jobs have been run" + work = Work.find_by(title: title) + byline = work.users.first.pseuds.first.byline + visit work_path(work) + expect(page).not_to have_content(byline) + expect(page.title).to include("Anonymous") + step %{I should see "Anonymous" within ".byline"} + visit collection_path(work.collections.first) + expect(page).not_to have_content("#{title} by #{byline}") + expect(page).to have_content("#{title} by Anonymous") + visit user_path(work.users.first) + expect(page).not_to have_content(title) +end diff --git a/features/step_definitions/comment_steps.rb b/features/step_definitions/comment_steps.rb new file mode 100644 index 0000000..7ce8828 --- /dev/null +++ b/features/step_definitions/comment_steps.rb @@ -0,0 +1,406 @@ +# GIVEN + +Given /^I have the default comment notifications setup$/ do +end + +Given /^I have the receive all comment notifications setup$/ do + step %{I set my preferences to turn on copies of my own comments} +end + +ParameterType( + name: "commentable", + regexp: /the (work|admin post|tag) "([^"]*)"/, + type: ActsAsCommentable::Commentable, + transformer: lambda { |type, title| + case type + when "work" + Work.find_by(title: title) + when "admin post" + AdminPost.find_by(title: title) + when "tag" + Tag.find_by(name: title) + end + } +) + +Given "{commentable} with guest comments enabled" do |commentable| + assert !commentable.is_a?(Tag) + commentable.update_attribute(:comment_permissions, :enable_all) +end + +Given "a guest comment on {commentable}" do |commentable| + commentable = Comment.commentable_object(commentable) + FactoryBot.create(:comment, :by_guest, commentable: commentable) +end + +Given "a comment {string} by {string} on {commentable}" do |text, user, commentable| + user = ensure_user(user) + commentable = Comment.commentable_object(commentable) + FactoryBot.create(:comment, + pseud: user.default_pseud, + commentable: commentable, + comment_content: text) +end + +Given "a reply {string} by {string} on {commentable}" do |text, user, commentable| + user = ensure_user(user) + comment = commentable.comments.first + FactoryBot.create(:comment, + pseud: user.default_pseud, + commentable: comment, + comment_content: text) +end + +Given "image safety mode is enabled for comments on a {string}" do |parent_type| + allow(ArchiveConfig).to receive(:PARENTS_WITH_IMAGE_SAFETY_MODE).and_return(parent_type) +end + +Given "image safety mode is disabled for comments" do + allow(ArchiveConfig).to receive(:PARENTS_WITH_IMAGE_SAFETY_MODE).and_return([]) +end + +Given "the setup for testing image safety mode on the admin post {string}" do |title| + step %{the admin post "#{title}"} + step %{a comment "plain text" by "commentrecip" on the admin post "#{title}"} + step %{I am logged in as "commenter"} + visit comment_path(Comment.last) + step %{I follow "Reply"} + with_scope(".odd") do + # Use HTML that will get cleaned up by the sanitizer so we're sure it runs. + fill_in("comment[comment_content]", with: 'OMG! <img src= "https://example.com/image.jpg">') + click_button("Comment") + end + step %{I am logged in as "commentrecip"} +end + +Given "the setup for testing image safety mode on the tag {string}" do |name| + step %{the tag wrangler "commentrecip" with password "password" is wrangler of "#{name}"} + step %{the tag wrangler "commenter" with password "password" is wrangler of "Some Fandom"} + step %{I am logged in as "commenter"} + visit tag_comments_path(Tag.find_by_name(name)) + # Use HTML that will get cleaned up by the sanitizer so we're sure it runs. + fill_in("comment[comment_content]", with: 'OMG! <img src= "https://example.com/image.jpg">') + click_button("Comment") + step %{I am logged in as "commentrecip"} +end + +Given "the setup for testing image safety mode on the work {string}" do |title| + step %{the work "#{title}" by "commentrecip"} + step %{I am logged in as "commenter"} + visit work_path(Work.find_by(title: title)) + # Use HTML that will get cleaned up by the sanitizer so we're sure it runs. + fill_in("comment[comment_content]", with: 'OMG! <img src= "https://example.com/image.jpg">') + click_button("Comment") + step %{I am logged in as "commentrecip"} +end + +# THEN + +Then /^the comment's posted date should be nowish$/ do + nowish = Time.zone.now.strftime('%a %d %b %Y %I:%M%p') + step %{I should see "#{nowish}" within ".posted.datetime"} +end + +Then /^I should see Last Edited nowish$/ do + nowish = Time.zone.now.strftime('%a %d %b %Y %I:%M%p') + step "I should see \"Last Edited #{nowish}\"" +end + +Then /^I should see the comment form$/ do + step %{I should see "New comment on"} +end + +Then /^I should see the reply to comment form$/ do + step %{I should see "Comment as" within ".odd"} +end + +Then /^I should see Last Edited in the right timezone$/ do + zone = Time.current.in_time_zone(Time.zone).zone + step %{I should see "#{zone}" within ".comment .posted"} + step %{I should see "Last Edited"} +end + +# WHEN + +When /^I set up the comment "([^"]*)" on the work "([^"]*)"$/ do |comment_text, work| + work = Work.find_by(title: work) + visit work_path(work) + fill_in("comment[comment_content]", with: comment_text) +end + +When "I set up the comment {string} on the admin post {string}" do |comment_text, admin_post| + admin_post = AdminPost.find_by(title: admin_post) + visit admin_post_path(admin_post) + fill_in("comment[comment_content]", with: comment_text) +end + +When "I set up the comment {string} on the work {string} with guest comments enabled" do |comment_text, work| + work = Work.find_by(title: work) + work.update_attribute(:comment_permissions, :enable_all) + visit work_path(work) + fill_in("comment[comment_content]", with: comment_text) +end + +When /^I attempt to comment on "([^"]*)" with a pseud that is not mine$/ do |work| + step %{I am logged in as "commenter"} + step %{I set up the comment "This is a test" on the work "#{work}"} + work_id = Work.find_by(title: work).id + pseud_id = User.first.pseuds.first.id + find("#comment_pseud_id_for_#{work_id}", visible: false).set(pseud_id) + click_button "Comment" +end + +When /^I attempt to update a comment on "([^"]*)" with a pseud that is not mine$/ do |work| + step %{I am logged in as "commenter"} + step %{I post the comment "blah blah blah" on the work "#{work}"} + step %{I follow "Edit"} + pseud_id = User.first.pseuds.first.id + find(:xpath, "//input[@name='comment[pseud_id]']", visible: false).set(pseud_id) + click_button "Update" +end + +When /^I post the comment "([^"]*)" on the work "([^"]*)"$/ do |comment_text, work| + step "I set up the comment \"#{comment_text}\" on the work \"#{work}\"" + click_button("Comment") +end + +When "I post the comment {string} on the admin post {string}" do |comment_text, work| + step "I set up the comment \"#{comment_text}\" on the admin post \"#{work}\"" + click_button("Comment") +end + +When /^I post the comment "([^"]*)" on the work "([^"]*)" as a guest(?: with email "([^"]*)")?$/ do |comment_text, work, email| + step "I start a new session" + step %{I set up the comment "#{comment_text}" on the work "#{work}" with guest comments enabled} + fill_in("Guest name", with: "guest") + fill_in("Guest email", with: (email || "guest@foo.com")) + click_button "Comment" +end + +When /^I edit a comment$/ do + step %{I follow "Edit"} + fill_in("comment[comment_content]", with: "Edited comment") + click_button "Update" +end + +# this step assumes we are on a page with a comment form +When /^I post a comment "([^"]*)"$/ do |comment_text| + fill_in("comment[comment_content]", with: comment_text) + click_button("Comment") +end + +# this step assumes that the reply-to-comment form can be opened +When /^I reply to a comment with "([^"]*)"$/ do |comment_text| + step %{I follow "Reply"} + step %{I should see the reply to comment form} + with_scope(".odd") do + fill_in("comment[comment_content]", with: comment_text) + click_button("Comment") + end +end + +When /^I visit the new comment page for the work "([^"]+)"$/ do |work| + work = Work.find_by(title: work) + visit new_work_comment_path(work, only_path: false) +end + +When /^I comment on an admin post$/ do + step "I go to the admin-posts page" + step %{I follow "Default Admin Post"} + step %{I fill in "comment[comment_content]" with "Excellent, my dear!"} + step %{I press "Comment"} +end + +When "I try to post a spam comment" do + fill_in("comment[name]", with: "spammer") + fill_in("comment[email]", with: "spammer@example.org") + fill_in("comment[comment_content]", with: "Buy my product! http://spam.org") + click_button("Comment") +end + +When "I post a spam comment" do + step "I try to post a spam comment" + step %{I should see "Comment created!"} +end + +When "Akismet will flag any comment by {string}" do |username| + allow_any_instance_of(Comment).to receive(:spam?) do |comment| + comment.name == username || comment.user.login == username + end +end + +When "Akismet will flag any comment containing {string}" do |spam_text| + allow_any_instance_of(Comment).to receive(:spam?) do |comment| + comment.comment_content.include?(spam_text) + end +end + +When "I post a guest comment {string}" do |comment_content| + fill_in("comment[name]", with: "guest") + fill_in("comment[email]", with: "guest@example.org") + fill_in("comment[comment_content]", with: comment_content) + click_button("Comment") + step %{I should see "Comment created!"} +end + +When "I post a guest comment" do + step "I post a guest comment \"This was really lovely!\"" +end + +When /^all comments by "([^"]*)" are marked as spam$/ do |name| + Comment.where(name: name).find_each(&:mark_as_spam!) +end + +When /^I compose an invalid comment(?: within "([^"]*)")?$/ do |selector| + with_scope(selector) do + fill_in("Comment", with: "Now, we can devour the gods, together! " * 270) + end +end + +When /^I delete the comment$/ do + step %{I follow "Delete" within ".odd"} + step %{I follow "Yes, delete!"} +end + +When /^I delete the reply comment$/ do + step %{I follow "Delete" within ".even"} + step %{I follow "Yes, delete!"} +end + +When /^I view the latest comment$/ do + visit comment_path(Comment.last) +end + +Given "the moderated work {string} by {string}" do |title, login| + user = ensure_user(login) + w = FactoryBot.create(:work, title: title, authors: [user.default_pseud]) + w.update_attribute(:moderated_commenting_enabled, true) +end + +Then /^comment moderation should be enabled on "([^\"]*?)"/ do |work| + w = Work.find_by(title: work) + assert w.moderated_commenting_enabled? +end + +Then /^comment moderation should not be enabled on "([^\"]*?)"/ do |work| + w = Work.find_by(title: work) + assert !w.moderated_commenting_enabled? +end + +Then /^the comment on "([^\"]*?)" should be marked as unreviewed/ do |work| + w = Work.find_by(title: work) + assert w.comments.first.unreviewed? +end + +Then /^the comment on "([^\"]*?)" should not be marked as unreviewed/ do |work| + w = Work.find_by(title: work) + assert !w.comments.first.unreviewed? +end + +When "I view {commentable} with comments" do |commentable| + if commentable.is_a?(Tag) + visit tag_comments_path(commentable) + else + visit polymorphic_path(commentable, show_comments: true) + end +end + +When /^I view the unreviewed comments page for "([^\"]*?)"/ do |work| + w = Work.find_by(title: work) + visit unreviewed_work_comments_path(w) +end + +When /^I visit the thread for the comment on "([^\"]*?)"/ do |work| + w = Work.find_by(title: work) + visit comment_path(w.comments.first) +end + +Then /^there should be (\d+) comments on "([^\"]*?)"/ do |num, work| + w = Work.find_by(title: work) + assert w.find_all_comments.count == num.to_i +end + +Given /^the moderated work "([^\"]*)" by "([^\"]*)" with the approved comment "([^\"]*)" by "([^\"]*)"/ do |work, author, comment, commenter| + step %{the moderated work "#{work}" by "#{author}"} + step %{I am logged in as "#{commenter}"} + step %{I post the comment "#{comment}" on the work "#{work}"} + step %{I am logged in as "#{author}"} + step %{I view the unreviewed comments page for "#{work}"} + step %{I press "Approve"} +end + +When /^I reload the comments on "([^\"]*?)"/ do |work| + w = Work.find_by(title: work) + w.find_all_comments.each { |c| c.reload } +end + +When "{string} posts a deeply nested comment thread on {string}" do |username, work| + work = Work.find_by(title: work) + chapter = work.chapters[0] + user = User.find_by(login: username) + + commentable = chapter + + count = ArchiveConfig.COMMENT_THREAD_MAX_DEPTH + 1 + + count.times do |i| + commentable = Comment.create( + commentable: commentable, + parent: chapter, + comment_content: "This is a comment at depth #{i}.", + pseud: user.default_pseud + ) + end + + # As long as there's only one child comment, it'll keep displaying the child. + # So we need two comments at the final depth: + 2.times do |i| + ordinal = i.zero? ? "first" : "second" + Comment.create( + commentable: commentable, + parent: chapter, + comment_content: "This is the #{ordinal} hidden comment.", + pseud: user.default_pseud + ) + end +end + +Then /^I (should|should not) see the deeply nested comments$/ do |should_or_should_not| + step %{I #{should_or_should_not} see "This is the first hidden comment."} + step %{I #{should_or_should_not} see "This is the second hidden comment."} +end + +When /^I delete all visible comments on "([^\"]*?)"$/ do |work| + work = Work.find_by(title: work) + + loop do + visit work_url(work, show_comments: true) + break unless page.has_content? "Delete" + click_link("Delete") + click_link("Yes, delete!") # TODO: Fix along with comment deletion. + end +end + +When "I mark the comment as spam" do + click_link("Spam") +end + +When "I confirm I want to mark the comment as spam" do + expect(page.accept_alert).to eq("Are you sure you want to mark this as spam?") if @javascript +end + +When "I display comments" do + click_link("Comments") +end + +When "I open the reply box" do + click_link("Reply") +end + +When "I cancel the reply box" do + click_link("Cancel") +end + +When "I reply on a new page" do + visit find(:link, "Reply")["href"] +end diff --git a/features/step_definitions/email_custom_steps.rb b/features/step_definitions/email_custom_steps.rb new file mode 100644 index 0000000..ea97134 --- /dev/null +++ b/features/step_definitions/email_custom_steps.rb @@ -0,0 +1,139 @@ +Given "the email queue is clear" do + reset_mailer +end + +Given "a locale with translated emails" do + FactoryBot.create(:locale, iso: "new", email_enabled: true) + # The footer keys are used in most emails + I18n.backend.store_translations(:new, { mailer: { general: { footer: { about: { html: "Translated footer", text: "Translated footer" } } } } }) + I18n.backend.store_translations(:new, { kudo_mailer: { batch_kudo_notification: { subject: "Translated subject" } } }) + I18n.backend.store_translations(:new, { users: { mailer: { reset_password_instructions: { subject: "Translated subject" } } } }) +end + +Given "the user {string} enables translated emails" do |user| + user = User.find_by(login: user) + $rollout.activate_user(:set_locale_preference, user) + user.preference.update!(locale: Locale.find_by(iso: "new")) +end + +When "the locale preference feature flag is disabled for user {string}" do |user| + user = User.find_by(login: user) + $rollout.deactivate_user(:set_locale_preference, user) +end + +When "translated emails are disabled for the locale" do + Locale.find_by(iso: "new").update_attribute(:email_enabled, false) +end + +Then "the email to {string} should be translated" do |user| + step(%{the email to "#{user}" should contain "Translated footer"}) + step(%{the email to "#{user}" should not contain "fan-run and fan-supported archive"}) # untranslated English text + step(%{the email to "#{user}" should not contain "translation missing"}) # missing translations in the target language fall back to English +end + +Then "the email to email address {string} should be translated" do |email_address| + step(%{the email to email address "#{email_address}" should contain "Translated footer"}) + step(%{the email to email address "#{email_address}" should not contain "fan-run and fan-supported archive"}) # untranslated English text + step(%{the email to email address "#{email_address}" should not contain "translation missing"}) # missing translations in the target language fall back to English +end + +Then "the last email to {string} should be translated" do |user| + step(%{the last email to "#{user}" should contain "Translated footer"}) + step(%{the last email to "#{user}" should not contain "fan-run and fan-supported archive"}) # untranslated English text + step(%{the last email to "#{user}" should not contain "translation missing"}) # missing translations in the target language fall back to English +end + +Then "the email to {string} should be non-translated" do |user| + step(%{the email to "#{user}" should not contain "Translated footer"}) + step(%{the email to "#{user}" should contain "fan-run and fan-supported archive"}) + step(%{the email to "#{user}" should not contain "translation missing"}) +end + +Then "the last email to {string} should be non-translated" do |user| + step(%{the last email to "#{user}" should not contain "Translated footer"}) + step(%{the last email to "#{user}" should contain "fan-run and fan-supported archive"}) + step(%{the last email to "#{user}" should not contain "translation missing"}) +end + +Then "{string} should be emailed" do |user| + @user = User.find_by(login: user) + expect(emails("to: \"#{email_for(@user.email)}\"")).not_to be_empty +end + +Then "the email address {string} should be emailed" do |email_address| + expect(emails("to: \"#{email_for(email_address)}\"")).not_to be_empty +end + +Then "{string} should not be emailed" do |user| + @user = User.find_by(login: user) + expect(emails("to: \"#{email_for(@user.email)}\"")).to be_empty +end + +Then "the email to {string} should contain {string}" do |user, text| + @user = User.find_by(login: user) + email = emails("to: \"#{email_for(@user.email)}\"").first + if email.multipart? + expect(email.text_part.body).to match(text) + expect(email.html_part.body).to match(text) + else + expect(email.body).to match(text) + end +end + +Then "the email to email address {string} should contain {string}" do |email_address, text| + email = emails("to: \"#{email_for(email_address)}\"").first + if email.multipart? + expect(email.text_part.body).to match(text) + expect(email.html_part.body).to match(text) + else + expect(email.body).to match(text) + end +end + +Then "the last email to {string} should contain {string}" do |user, text| + @user = User.find_by(login: user) + email = emails("to: \"#{email_for(@user.email)}\"").last + if email.multipart? + expect(email.text_part.body).to match(text) + expect(email.html_part.body).to match(text) + else + expect(email.body).to match(text) + end +end + +Then "the email to {string} should not contain {string}" do |user, text| + @user = User.find_by(login: user) + email = emails("to: \"#{email_for(@user.email)}\"").first + if email.multipart? + expect(email.text_part.body).not_to match(text) + expect(email.html_part.body).not_to match(text) + else + expect(email.body).not_to match(text) + end +end + +Then "the email to email address {string} should not contain {string}" do |email_address, text| + email = emails("to: \"#{email_for(email_address)}\"").first + if email.multipart? + expect(email.text_part.body).not_to match(text) + expect(email.html_part.body).not_to match(text) + else + expect(email.body).not_to match(text) + end +end + +Then "the last email to {string} should not contain {string}" do |user, text| + @user = User.find_by(login: user) + email = emails("to: \"#{email_for(@user.email)}\"").last + if email.multipart? + expect(email.text_part.body).not_to match(text) + expect(email.html_part.body).not_to match(text) + else + expect(email.body).not_to match(text) + end +end + +Then "{string} should receive {int} email(s)" do |user, count| + @user = User.find_by(login: user) + expect(emails("to: \"#{email_for(@user.email)}\"").size).to eq(count.to_i) +end diff --git a/features/step_definitions/email_steps.rb b/features/step_definitions/email_steps.rb new file mode 100644 index 0000000..4c9dafd --- /dev/null +++ b/features/step_definitions/email_steps.rb @@ -0,0 +1,105 @@ +# this file generated by script/generate pickle email +# +# add email mappings in features/support/email.rb + +ActionMailer::Base.delivery_method = :test +ActionMailer::Base.perform_deliveries = true + +Before do + ActionMailer::Base.deliveries.clear +end + +# Clear the deliveries array, useful if your background sends email that you want to ignore +Given(/^all emails? (?:have|has) been delivered$/) do + ActionMailer::Base.deliveries.clear + ActionMailer::Base.deliveries.should be_empty # Sanity check, ftw +end + +Given(/^confirmation emails have been delivered$/) do + ActionMailer::Base.deliveries.reject! do |delivery| + delivery.subject == "Confirmation instructions" + end + + ActionMailer::Base.deliveries.select do |delivery| + delivery.subject == "Confirmation instructions" + end.should be_empty +end + +Given(/^(\d)+ emails? should be delivered$/) do |count| + emails.size.should == count.to_i +end + +When(/^(?:I|they) follow "([^"]*?)" in #{capture_email}$/) do |link, email_ref| + address = email(email_ref).to.first + visit_in_email(link, address) +end + +When(/^(?:I|they) click the first link in #{capture_email}$/) do |email_ref| + click_first_link_in_email(email(email_ref).text_part) +end + +Then(/^(\d)+ emails? should be delivered to (.*)$/) do |count, to| + emails("to: \"#{email_for(to)}\"").size.should == count.to_i +end + +Then(/^(\d)+ emails? should be delivered with #{capture_fields}$/) do |count, fields| + emails(fields).size.should == count.to_i +end + +Then(/^#{capture_email} should be delivered to (.+)$/) do |email_ref, to| + email(email_ref, "to: \"#{email_for(to)}\"").should_not be_nil +end + +Then(/^#{capture_email} should not be delivered to (.+)$/) do |email_ref, to| + email(email_ref, "to: \"#{email_for(to)}\"").should be_nil +end + +Then(/^#{capture_email} should have #{capture_fields}$/) do |email_ref, fields| + email(email_ref, fields).should_not be_nil +end + +Then(/^#{capture_email} should have "(.*?)" in the subject$/) do |email_ref, text| + email(email_ref).subject.should =~ /#{text}/ +end + +Then(/^#{capture_email} should contain "(.*)"$/) do |email_ref, text| + if email(email_ref).multipart? + email(email_ref).text_part.body.should =~ /#{text}/ + email(email_ref).html_part.body.should =~ /#{text}/ + else + email(email_ref).body.should =~ /#{text}/ + end +end + +Then(/^#{capture_email} should not contain "(.*)"$/) do |email_ref, text| + if email(email_ref).multipart? + email(email_ref).text_part.body.should_not =~ /#{text}/ + email(email_ref).html_part.body.should_not =~ /#{text}/ + else + email(email_ref).body.should_not =~ /#{text}/ + end +end + +Then(/^#{capture_email} should link to (.+)$/) do |email_ref, page| + expected_url = Regexp.escape(path_to(page)).sub(/^http:/, "https:") + if email(email_ref).multipart? + email(email_ref).text_part.body.should =~ /#{expected_url}/ + email(email_ref).html_part.body.should =~ /#{expected_url}/ + else + email(email_ref).body.should =~ /#{expected_url}/ + end +end + +Then (/^#{capture_email} html body should link to (.+)$/) do |email_ref, page| + email(email_ref).html_part.body.should =~ /#{path_to(page)}/ +end + +Then(/^show me the emails?$/) do + ActionMailer::Base.deliveries.each do |email| + puts "From: #{email.from}" + puts "To: #{email.to}" + puts "Subject: #{email.subject}" + puts email.text_part.body.to_s + puts "" + end +end diff --git a/features/step_definitions/external_work_steps.rb b/features/step_definitions/external_work_steps.rb new file mode 100644 index 0000000..e562a1f --- /dev/null +++ b/features/step_definitions/external_work_steps.rb @@ -0,0 +1,101 @@ +DEFAULT_EXTERNAL_URL = "http://example.org/200" +DEFAULT_EXTERNAL_CREATOR = "Zooey Glass" +DEFAULT_EXTERNAL_TITLE = "A Work Not Posted To The AO3" +DEFAULT_EXTERNAL_SUMMARY = "This is my story, I am its author." +DEFAULT_EXTERNAL_FANDOM = "External Fandom" +DEFAULT_EXTERNAL_RELATIONSHIP = "Charater A & Character B" +DEFAULT_EXTERNAL_CATEGORY = "F/M" +DEFAULT_EXTERNAL_CHARACTERS = "Character A, Character B" +DEFAULT_BOOKMARK_NOTES = "I liked this story." +DEFAULT_BOOKMARK_TAGS = "Awesome" + +Given /^an external work$/ do + step %{I set up an external work} + click_button("Create") +end + +Given /^I set up an external work$/ do + step %{mock websites with no content} + visit new_external_work_path + fill_in("URL", with: DEFAULT_EXTERNAL_URL) + fill_in("external_work_author", with: DEFAULT_EXTERNAL_CREATOR) + fill_in("external_work_title", with: DEFAULT_EXTERNAL_TITLE) + step %{I fill in basic external work tags} + fill_in("bookmark_notes", with: DEFAULT_BOOKMARK_NOTES) + fill_in("Your tags", with: DEFAULT_BOOKMARK_TAGS) +end + +Given /^I bookmark the external work "([^\"]*)"(?: with fandom "([^"]*)")?(?: with character "([^"]*)")?$/ do |title, fandom, character| + step %{I set up an external work} + fill_in("external_work_title", with: title) + fill_in("Fandoms", with: fandom) if fandom.present? + fill_in("Characters", with: character) if character.present? + click_button("Create") +end + +Given "{string} has bookmarked an external work" do |user| + step %{mock websites with no content} + step %{basic tags} + step %{I am logged in as "#{user}"} + visit new_external_work_path + # Typically, when we write step definitions, we prefer to use a + # field's id attribute over its label. But in this case, + # we have to use the labels for some fields because the ids + # change when JavaScript is enabled. + fill_in("URL", with: DEFAULT_EXTERNAL_URL) + fill_in("external_work_author", with: DEFAULT_EXTERNAL_CREATOR) + fill_in("external_work_title", with: DEFAULT_EXTERNAL_TITLE) + fill_in("external_work_summary", with: DEFAULT_EXTERNAL_SUMMARY) + fill_in("Fandoms", with: DEFAULT_EXTERNAL_FANDOM) + select(ArchiveConfig.RATING_TEEN_TAG_NAME, from: "external_work_rating_string") + check(DEFAULT_EXTERNAL_CATEGORY) + fill_in("Relationships", with: DEFAULT_EXTERNAL_RELATIONSHIP) + fill_in("Characters", with: DEFAULT_EXTERNAL_CHARACTERS) + click_button("Create") +end + +Given "the external work {string} has {int} {word} tag(s)" do |title, count, type| + work = ExternalWork.find_by(title: title) + work.send("#{type.pluralize}=", FactoryBot.create_list(type.to_sym, count)) +end + +When /^I view the external work "([^\"]*)"$/ do |external_work| + external_work = ExternalWork.find_by_title(external_work) + visit external_work_url(external_work) +end + +When /^the (character|fandom|relationship) "(.*?)" is removed from the external work "(.*?)"$/ do |tag_type, tag, title| + external_work = ExternalWork.find_by(title: title) + tags = external_work.tags.where(type: tag_type).pluck(:name) - [tag] + tag_string = tags.join(", ") + step %{I am logged in as a "policy_and_abuse" admin} + visit edit_external_work_path(external_work) + fill_in("work_#{tag_type}", with: tag_string) + click_button("Update External work") + step %{all indexing jobs have been run} +end + +Then /^the work info for my new bookmark should match the original$/ do + works = ExternalWork.where(url: "http://example.org/200").order("created_at ASC") + original_work = works[0] + new_work = works[1] + expect(new_work.author).to eq(original_work.author) + expect(new_work.title).to eq(original_work.title) + step %{the summary and tag info for my new bookmark should match the original} +end + +Then /^the title and creator info for my new bookmark should vary from the original$/ do + works = ExternalWork.where(url: "http://example.org/200").order("created_at ASC") + original_work = works[0] + new_work = works[1] + expect(new_work.author).not_to eq(original_work.author) + expect(new_work.title).not_to eq(original_work.title) +end + +Then /^the summary and tag info for my new bookmark should match the original$/ do + works = ExternalWork.where(url: "http://example.org/200").order("created_at ASC") + original_work = works[0] + new_work = works[1] + expect(new_work.summary).to eq(original_work.summary) + expect(new_work.tags).to eq(original_work.tags) +end diff --git a/features/step_definitions/generic_steps.rb b/features/step_definitions/generic_steps.rb new file mode 100644 index 0000000..499f2b1 --- /dev/null +++ b/features/step_definitions/generic_steps.rb @@ -0,0 +1,306 @@ +When /^(?:|I )unselect "([^"]+)" from "([^"]+)"$/ do |item, selector| + unselect(item, from: selector) +end + +Then /^debug$/ do + binding.pry +end + +Then /^tell me I got (.*)$/ do |spot| + puts "got #{spot}" +end + +Then /^show me the response$/ do + puts page.body +end + +Then /^show me the html$/ do + puts page.body +end + +Then /^show me the main content$/ do + puts "\n" + find("#main").native.inner_html +end + +Then /^show me the errors$/ do + puts "\n" + find("div.error").native.inner_html +end + +Then /^show me the sidebar$/ do + puts "\n" + find("#dashboard").native.inner_html +end + +Then "the page should have a dashboard sidebar" do + expect(page).to have_css("#dashboard") +end + +Then /^I should see errors/ do + assert find("div.error") +end + +Then /^show me the form$/ do + step %{show me the 1st form} +end + +Then /^show me the (\d+)(?:st|nd|rd|th) form$/ do |index| + puts "\n" + page.all("#main form")[(index.to_i-1)].native.inner_html +end + +Then "I should see the {string} form" do |form_id| + expect(page).to have_css("form##{form_id}") +end + +Then "I should not see the {string} form" do |form_id| + expect(page).not_to have_css("form##{form_id}") +end + +Given /^I wait (\d+) seconds?$/ do |number| + Kernel::sleep number.to_i +end + +When "all AJAX requests are complete" do + wait_for_ajax if @javascript +end + +When 'I reload the page' do + visit current_url +end + +Then /^I should see Posted now$/ do + now = Time.zone.now.to_s + step "I should see \"Posted #{now}\"" +end + +When /^I fill in "([^\"]*)" with$/ do |field, value| + fill_in(field, with: value) +end + +When /^I fill in "([^\"]*)" with `([^\`]*)`$/ do |field, value| + fill_in(field, with: value) +end + +When /^I fill in "([^\"]*)" with '([^\']*)'$/ do |field, value| + fill_in(field, with: value) +end + +Then /^I should see a create confirmation message$/ do + page.should have_content('was successfully created') +end + +Then /^I should see an update confirmation message$/ do + page.should have_content('was successfully updated') +end + +Then /^I should see a save error message$/ do + step %{I should see "We couldn't save"} +end + +Then /^I should see a success message$/ do + step %{I should see "success"} +end + +def assure_xpath_present(tag, attribute, value, selector) + with_scope(selector) do + page.should have_xpath("//#{tag}[@#{attribute}='#{value}']") + end +end + +def assure_xpath_not_present(tag, attribute, value, selector) + with_scope(selector) do + page.should_not have_xpath("//#{tag}[@#{attribute}='#{value}']") + end +end + +# img attributes +Then /^I should see the image "([^"]*)" text "([^"]*)"(?: within "([^"]*)")?$/ do |attribute, text, selector| + assure_xpath_present("img", attribute, text, selector) +end + +Then /^I should not see the image "([^"]*)" text "([^"]*)"(?: within "([^"]*)")?$/ do |attribute, text, selector| + assure_xpath_not_present("img", attribute, text, selector) +end + +Then /^"([^"]*)" should be selected within "([^"]*)"$/ do |value, field| + page.has_select?(field, selected: value).should == true +end + +Then /^"(.*)?" should( not)? be an option within "(.*)?"$/ do |value, negation, field| + expect(page.has_select?(field, with_options: [value])).to be !negation +end + +Then /^I should see "([^"]*)" in the "([^"]*)" input/ do |content, labeltext| + find_field("#{labeltext}").value.should == content +end + +Then "I should see in the {string} input" do |labeltext, content| + find_field(labeltext).value.should == content +end + +Then /^I should see a button with text "(.*?)"(?: within "(.*?)")?$/ do |text, selector| + assure_xpath_present("input", "value", text, selector) +end + +Then /^I should not see a button with text "(.*?)"(?: within "(.*?)")?$/ do |text, selector| + assure_xpath_not_present("input", "value", text, selector) +end + +Then "I should see a link to {string} within {string}" do |url, selector| + assure_xpath_present("a", "href", url, selector) +end + +Then /^I should see a page link to (.+) within "(.*?)"$/ do |page_name, selector| # rubocop:disable Cucumber/RegexStepName + expect(page.find(selector)).to have_link("", href: path_to(page_name)) +end + +Then /^I should not see a page link to (.+) within "(.*?)"$/ do |page_name, selector| # rubocop:disable Cucumber/RegexStepName + expect(page.find(selector)).to_not have_link("", href: path_to(page_name)) +end + +Then "I should see a link {string} within {string}" do |text, selector| + expect(page.find(selector)).to have_link(text) +end + +Then "I should not see a link {string} within {string}" do |text, selector| + expect(page.find(selector)).to_not have_link(text) +end + +Then "the {string} input should be blank" do |label| + expect(find_field(label).value).to be_blank +end + +Then /^I should see (a|an) "([^"]*)" button(?: within "([^"]*)")?$/ do |_article, text, selector| + assure_xpath_present("input", "value", text, selector) +end + +Then /^I should not see (a|an) "([^"]*)" button(?: within "([^"]*)")?$/ do |_article, text, selector| + assure_xpath_not_present("input", "value", text, selector) +end + +When /^"([^\"]*)" is fixed$/ do |what| + puts "\nDEFERRED (#{what})" +end + +Then /^the "([^"]*)" checkbox(?: within "([^"]*)")? should be disabled$/ do |label, selector| + with_scope(selector) do + field = find_field(label, disabled: true) + expect(field).to be_present + expect(field.disabled?).to be_truthy + end +end + +Then /^the "([^"]*)" checkbox(?: within "([^"]*)")? should not be disabled$/ do |label, selector| + with_scope(selector) do + field = find_field(label) + expect(field).to be_present + expect(field.disabled?).to be_falsey + end +end + +Then /^I should see the input with id "([^"]*)"(?: within "([^"]*)")?$/ do |id, selector| + assure_xpath_present("input", "id", id, selector) +end + +Then /^I should not see the input with id "([^"]*)"(?: within "([^"]*)")?$/ do |id, selector| + assure_xpath_not_present("input", "id", id, selector) +end + +When /^I check the (\d+)(?:st|nd|rd|th) checkbox with the value "([^"]*)"$/ do |index, value| + check(page.all("input[type='checkbox']").select {|el| el['value'] == value}[(index.to_i-1)]['id']) +end + +When /^I check the (\d+)(st|nd|rd|th) checkbox with value "([^"]*)"$/ do |index, suffix, value| + step %{I check the #{index}#{suffix} checkbox with the value "#{value}"} +end + +When /^I uncheck the (\d+)(?:st|nd|rd|th) checkbox with the value "([^"]*)"$/ do |index, value| + uncheck(page.all("input[type='checkbox']").select {|el| el['value'] == value}[(index.to_i-1)]['id']) +end + +When /^I check the (\d+)(?:st|nd|rd|th) checkbox with id matching "([^"]*)"$/ do |index, id_string| + check(page.all("input[type='checkbox']").select {|el| el['id'] && el['id'].match(/#{id_string}/)}[(index.to_i-1)]['id']) +end + +When /^I uncheck the (\d+)(?:st|nd|rd|th) checkbox with id matching "([^"]*)"$/ do |index, id_string| + uncheck(page.all("input[type='checkbox']").select {|el| el['id'] && el['id'].match(/#{id_string}/)}[(index.to_i-1)]['id']) +end + +When /^I fill in the (\d+)(?:st|nd|rd|th) field with id matching "([^"]*)" with "([^"]*)"$/ do |index, id_string, value| + fill_in(page.all("input[type='text']").select {|el| el['id'] && el['id'].match(/#{id_string}/)}[(index.to_i-1)]['id'], with: value) +end + + +# If you have multiple forms on a page you will need to specify which one you want to submit with, eg, +# "I submit with the 2nd button", but in those cases you probably want to make sure that +# the different forms have different button text anyway, and submit them using +# When I press "Button Text" +When /^I submit with the (\d+)(?:st|nd|rd|th) button$/ do |index| # rubocop:disable Cucumber/RegexStepName + page.all("input[type='submit']")[(index.to_i - 1)].click +end + +# This is for buttons generated with the button_to helper method. They use a different HTML element, +# <button> instead of <input type="submit">. +When /^I click the (\d+)(?:st|nd|rd|th) button$/ do |index| # rubocop:disable Cucumber/RegexStepName + page.all("button")[index.to_i - 1].click +end + +# This will submit the first submit button inside a <p class="submit"> by default +# That wrapping paragraph tag will be generated automatically if you use +# the submit_button or submit_fieldset helpers in application_helper.rb +# The text on the button will not matter and can be changed without breaking tests. +When /^I submit$/ do + page.all("p.submit input[type='submit']")[0].click +end + +Then "I should see the text with tags {string}" do |text| + page.body.should =~ /#{Regexp.escape(text)}/m +end + +Then "I should see the text with tags" do |text| + page.body.should =~ /#{Regexp.escape(text)}/m +end + +Then /^I should not see the text with tags '(.*)'$/ do |text| + page.body.should_not =~ /#{Regexp.escape(text)}/m +end + +Then /^I should find a checkbox "([^\"]*)"$/ do |name| + field = find_field(name) + field['checked'].respond_to? :should +end + +Then /^I should see a link "([^\"]*)"$/ do |name| + text = name + "</a>" + page.body.should =~ /#{Regexp.escape(text)}/m +end + +Then "I should see a link {string} to {string}" do |text, href| + expect(page).to have_link(text, href: href) +end + +Then /^I should not see a link "([^\"]*)"$/ do |name| + text = name + "</a>" + page.body.should_not =~ /#{Regexp.escape(text)}/m +end + +Then "the page should be hidden from search engines" do + expect(page).to have_css("meta[name=robots][content=noindex]", visible: false) + expect(page).to have_css("meta[name=googlebot][content=noindex]", visible: false) +end + +Then "the page should not be hidden from search engines" do + expect(page).not_to have_css("meta[name=robots][content=noindex]", visible: false) + expect(page).not_to have_css("meta[name=googlebot][content=noindex]", visible: false) +end + +When /^I want to search for exactly one term$/ do + Capybara.exact = true +end + +When /^I should see the correct time zone for "(.*)"$/ do |zone| + Time.zone = zone + page.body.should =~ /#{Regexp.escape(Time.zone.now.zone)}/ +end + +Then "I should see {string} exactly {int} time(s)" do |string, int| + expect(page).to have_content(string).exactly(int) +end diff --git a/features/step_definitions/gift_steps.rb b/features/step_definitions/gift_steps.rb new file mode 100644 index 0000000..4ed4cc5 --- /dev/null +++ b/features/step_definitions/gift_steps.rb @@ -0,0 +1,43 @@ +### NOTE: many of these steps rely on the background in gift.feature + +Then /^"(.+)" should be notified by email about their gift "(.+)"$/ do |recipient, title| + step %{1 email should be delivered to "#{recipient}"} + step %{the email should contain "A gift work has been posted for you"} + step %{the email should link to the "#{title}" work page} +end + +When /^I have given the work to "(.*?)"/ do |recipient| + step %{I give the work to "#{recipient}"} + step %{I post the work without preview} +end + +Given(/^I have refused the work/) do + step %{I have given the work to "giftee1"} + + # Delay to force the cache to expire when the gift is refused: + step "it is currently 1 second from now" + + step %{I am logged in as "giftee1" with password "something"} + step %{I go to giftee1's gifts page} + step %{I follow "Refuse Gift"} +end + +When /^I have removed the recipients/ do + fill_in("work_recipients", with: "") + step %{I post the work without preview} +end + +Then /^"(.*?)" should be listed as a recipient in the form/ do |recipient| + recipients = page.find("input#work_recipients")['value'] + assert recipients =~ /#{recipient}/ +end + +Then /^"(.*?)" should not be listed as a recipient in the form/ do |recipient| + recipients = page.find("input#work_recipients")['value'] + assert recipients !~ /#{recipient}/ +end + +Then(/^the gift for "(.*?)" should still exist on "(.*?)"$/) do |recipient, work| + w = Work.find_by(title: work) + assert w.gifts.map(&:recipient).include?(recipient) +end diff --git a/features/step_definitions/icon_steps.rb b/features/step_definitions/icon_steps.rb new file mode 100644 index 0000000..7ae339a --- /dev/null +++ b/features/step_definitions/icon_steps.rb @@ -0,0 +1,61 @@ +### GIVEN + +Given /^I am editing a pseud$/ do + username = "myname" + step %{I am logged in as "#{username}"} + user = User.find_by(login: username) + visit edit_user_pseud_path(user, user.default_pseud) +end + +#' cancelling highlighting + +Given /^I have an icon uploaded$/ do + step "I am editing a pseud" + step %{I attach the file "features/fixtures/icon.gif" to "icon"} + step %{I press "Update"} +end + +### WHEN +When "I attach an icon with the extension {string}" do |extension| + step %{I attach the file "features/fixtures/icon.#{extension}" to "icon"} +end + +When /^I add an icon to the collection "([^"]*)"$/ do |title| + step %{I am logged in as "moderator"} + step %{I am on "#{title}" collection's page} + step %{I follow "Settings"} + step %{I attach the file "features/fixtures/icon.gif" to "collection_icon"} + step %{I press "Update"} +end + +When /^I delete the icon from the collection "([^"]*)"$/ do |title| + step %{I am logged in as "moderator"} + step %{I am on "#{title}" collection's page} + step %{I follow "Settings"} + check("collection_delete_icon") + step %{I press "Update"} +end + +When "I delete the icon from my pseud" do + user = User.find_by(login: "myname") + visit edit_user_pseud_path(user, user.default_pseud) + check("pseud_delete_icon") + step %{I press "Update"} +end + +Then /^the "([^"]*)" collection should have an icon$/ do |title| + collection = Collection.find_by(title: title) + assert collection.icon.attached? +end + +Then /^the "([^"]*)" collection should not have an icon$/ do |title| + collection = Collection.find_by(title: title) + assert !collection.icon.attached? +end + +### THEN + +Then "I should see the icon and alt text boxes are blank" do + expect(find("#pseud_icon").value).to be_blank + expect(find("#pseud_icon_alt_text").value).to be_nil +end diff --git a/features/step_definitions/invite_steps.rb b/features/step_definitions/invite_steps.rb new file mode 100644 index 0000000..85c26c0 --- /dev/null +++ b/features/step_definitions/invite_steps.rb @@ -0,0 +1,192 @@ +### GIVEN + +Given /^an invitation(?: for "([^\"]+)") exists$/ do |invitee_email| + invite = Invitation.new + invite.invitee_email = (invitee_email ? invitee_email : "default@example.org") + invite.save +end + +def invite(attributes = {}) + @invite ||= Invitation.new + @invite.invitee_email = "default@example.org" + @invite.save + @invite +end + +Given /^account creation is disabled$/ do + steps %Q{ + Given the following admin settings are configured: + | account_creation_enabled | 0 | + | creation_requires_invite | 0 | + | invite_from_queue_enabled | 0 | + | request_invite_enabled | 0 | + } +end + +Given /^account creation is enabled$/ do + steps %Q{ + Given the following admin settings are configured: + | account_creation_enabled | 1 | + } +end + +Given /^invitations are required$/ do + steps %{ + Given I have no users + And account creation requires an invitation + And users can request invitations + } +end + +Given /^account creation requires an invitation$/ do + steps %Q{ + Given the following admin settings are configured: + | account_creation_enabled | 1 | + | creation_requires_invite | 1 | + } +end + +Given /^account creation does not require an invitation$/ do + steps %Q{ + Given the following admin settings are configured: + | account_creation_enabled | 1 | + | creation_requires_invite | 0 | + } +end + +Given /^users can request invitations$/ do + steps %Q{ + Given the following admin settings are configured: + | request_invite_enabled | 1 | + } +end + +Given /^the invitation queue is enabled$/ do + steps %Q{ + Given the following admin settings are configured: + | invite_from_queue_enabled | 1 | + } +end + +Given /^the invitation queue is disabled$/ do + steps %Q{ + Given the following admin settings are configured: + | invite_from_queue_enabled | 0 | + } +end + +Given /^"([^\"]*)" has "([^"]*)" invitations?$/ do |login, invitation_count| + user = User.find_by(login: login) + # If there are more invitations than we want, first destroy them + if invitation_count.to_i < user.invitations.count + user.invitations.destroy_all + end + # Now create the number of invitations we want + (invitation_count.to_i - user.invitations.count).times { user.invitations.create } +end + +Given /^an invitation request for "([^"]*)"$/ do |email| + visit invite_requests_path + fill_in("invite_request[email]", with: email) + click_button("Add me to the list") +end + +Given "there are {int} invite request(s) per page" do |amount| + allow(InviteRequest).to receive(:per_page).and_return(amount) +end + +Given "an invitation created by {string} and used by {string}" do |creator, invitee| + creator = User.find_by(login: creator) + invitee = User.find_by(login: invitee) + FactoryBot.create(:invitation, creator: creator, invitee: invitee) +end + +### WHEN + +When /^I use an invitation to sign up$/ do + visit signup_path(invite.token) +end + +When /^I use an already used invitation to sign up$/ do + steps %Q{ + Given the following activated user exists + | login | password | + | invited | password | + } + user = User.find_by(login: "invited") + invite.redeemed_at = Time.now + invite.mark_as_redeemed(user) + invite.save + visit signup_path(invite.token) +end + +When /^I try to invite a friend from my user page$/ do + step %{I am logged in as "user1"} + step %{I go to user1's user page} + step %{I follow "Invitations"} +end + +When /^I request some invites$/ do + step %{I try to invite a friend from my user page} + step %{I follow "Request invitations"} + step %{I fill in "How many invitations would you like? (max 10)" with "3"} + step %{I fill in "Please specify why you'd like them:" with "I want them for a friend"} + step %{I press "Send Request"} +end + +When "as {string} I request some invites" do |user| + step %{I am logged in as "#{user}"} + step %{I go to #{user}'s user page} + step %{I follow "Invitations"} + step %{I follow "Request invitations"} + step %{I fill in "How many invitations would you like? (max 10)" with "3"} + step %{I fill in "Please specify why you'd like them:" with "I want them for a friend"} + step %{I press "Send Request"} +end + +When /^I view requests as an admin$/ do + step %{I am logged in as an admin} + step %{I follow "Invitations"} + step %{I follow "Manage Requests"} +end + +When /^an admin grants the request$/ do + step %{I view requests as an admin} + step %{I fill in "requests[user1]" with "2"} + step %{I press "Update"} +end + +When /^I check how long "(.*?)" will have to wait in the invite request queue$/ do |email| + visit(status_invite_requests_path) + fill_in("email", with: "#{email}") + click_button("Look me up") +end + +When "I view the most recent invitation for {string}" do |creator| + user = User.find_by(login: creator) + invitation = user.invitations.last + visit user_invitation_path(creator, invitation) +end + +### Then + +Then /^I should see how long I have to activate my account$/ do + days_to_activate = AdminSetting.first.days_to_purge_unactivated * 7 + step %{I should see "You must activate your account within #{days_to_activate} days"} +end + +Then /^"([^"]*)" should have "([^"]*)" invitations$/ do |login, invitation_count| + user = User.find_by(login: login) + assert user.invitations.count == invitation_count.to_i +end + +Then "the invite queue should list the following:" do |desired| + actual = all("table tbody tr").map do |row| + { + "position" => row.find("td:nth-child(1)").text, + "email" => row.find("td:nth-child(2)").text + } + end + + expect(actual).to eq(desired.hashes) +end diff --git a/features/step_definitions/kudos_steps.rb b/features/step_definitions/kudos_steps.rb new file mode 100644 index 0000000..c7049a2 --- /dev/null +++ b/features/step_definitions/kudos_steps.rb @@ -0,0 +1,48 @@ +### GIVEN + +Given "a work {string} with {int} kudo(s)" do |title, count| + step "basic tags" + + work = FactoryBot.create(:work, title: title) + + count.times do |i| + user = User.find_by(login: "fan#{i + 1}") || + FactoryBot.create(:user, login: "fan#{i + 1}") + work.kudos.create(user: user) + end +end + +Given "the maximum number of kudos to show is {int}" do |count| + allow(ArchiveConfig).to receive(:MAX_KUDOS_TO_SHOW).and_return(count) +end + +### WHEN + +When /^I leave kudos on "([^\"]*)"$/ do |work_title| + step %{I view the work "#{work_title}"} + click_button("kudo_submit") +end + +When "the kudos cache has expired" do + step "it is currently #{ArchiveConfig.MINUTES_UNTIL_COMMENTABLE_KUDOS_LISTS_EXPIRE} minutes from now" +end + +### THEN + +Then /^I should see kudos on every chapter$/ do + step %{I should see "myname3 left kudos on this work!"} + step %{I follow "Next Chapter"} + step %{I should see "myname3 left kudos on this work!"} + step %{I follow "Entire Work"} + step %{I should see "myname3 left kudos on this work!"} +end + +Then /^I should see kudos on every chapter but the draft$/ do + step %{I should see "myname3 left kudos on this work!"} + step %{I follow "Next Chapter"} + step %{I should see "myname3 left kudos on this work!"} + step %{I follow "Next Chapter"} + step %{I should not see "myname3 left kudos on this work!"} + step %{I follow "Entire Work"} + step %{I should see "myname3 left kudos on this work!"} +end diff --git a/features/step_definitions/mute_steps.rb b/features/step_definitions/mute_steps.rb new file mode 100644 index 0000000..154738b --- /dev/null +++ b/features/step_definitions/mute_steps.rb @@ -0,0 +1,41 @@ +Given "the user {string} has muted the user {string}" do |muter, muted| + muter = ensure_user(muter) + muted = ensure_user(muted) + Mute.create!(muter: muter, muted: muted) +end + +Given "there are {int} muted users per page" do |amount| + allow(Mute).to receive(:per_page).and_return(amount) +end + +Given "the maximum number of accounts users can mute is {int}" do |count| + allow(ArchiveConfig).to receive(:MAX_MUTED_USERS).and_return(count) +end + +Then "the user {string} should have a mute for {string}" do |muter, muted| + muter = User.find_by(login: muter) + muted = User.find_by(login: muted) + expect(Mute.find_by(muter: muter, muted: muted)).to be_present +end + +Then "the user {string} should not have a mute for {string}" do |muter, muted| + muter = User.find_by(login: muter) + muted = User.find_by(login: muted) + expect(Mute.find_by(muter: muter, muted: muted)).to be_blank +end + +Then "the blurb should say when {string} muted {string}" do |muter, muted| + muter = User.find_by(login: muter) + muted = User.find_by(login: muted) + mute = Mute.where(muter: muter, muted: muted).first + # Find the blurb for the specified mute using the h4 with the muted user's name, navigate back up to div, and then down to the datetime p + expect(page).to have_xpath("//li/div/h4/a[text()[contains(., '#{muted.login}')]]/parent::h4/parent::div/p[text()[contains(., '#{mute.created_at}')]]") +end + +Then "the blurb should not say when {string} muted {string}" do |muter, muted| + muter = User.find_by(login: muter) + muted = User.find_by(login: muted) + mute = Mute.where(muter: muter, muted: muted).first + # Find the blurb for the specified mute using the h4 with the muted user's name, navigate back up to div, and then down to where the datetime p would be + expect(page).not_to have_xpath("//li/div/h4/a[text()[contains(., '#{muted.login}')]]/parent::h4/parent::div/p[text()[contains(., '#{mute.created_at}')]]") +end diff --git a/features/step_definitions/orphan_steps.rb b/features/step_definitions/orphan_steps.rb new file mode 100644 index 0000000..6eddcf5 --- /dev/null +++ b/features/step_definitions/orphan_steps.rb @@ -0,0 +1,70 @@ +When /^I choose to take my pseud off$/ do + step %{I choose "Take my pseud off as well"} + step %{I press "Yes, I'm sure"} + step %{I should see "Orphaning was successful."} +end + +When /^I choose to (?:keep|leave) my pseud on$/ do + step %{I choose "Leave a copy of my pseud on"} + step %{I press "Yes, I'm sure"} + step %{I should see "Orphaning was successful."} +end + +When /^I begin orphaning the work "([^"]*)"$/ do |name| + step %{I wait 1 second} + step %{I edit the work "#{name}"} + step %{I follow "Orphan Work"} + step %{I should see "Orphan Works"} +end + +When /^I begin orphaning the series "([^"]*)"$/ do |name| + step %{I wait 1 second} + step %{I view the series "#{name}"} + step %{I follow "Orphan Series"} + step %{I should see "Orphan Series"} +end + +When /^I orphan(?:| and take my pseud off) the (work|series) "([^"]*)"$/ do |type, name| + step %{I begin orphaning the #{type} "#{name}"} + step %{I choose to take my pseud off} +end + +When "I orphan and leave my pseud on the series {string}" do |name| + step %{I begin orphaning the series "#{name}"} + step %{I choose to keep my pseud on} +end + +When "{string} orphans and takes their pseud off the work {string}" do |author, work| + u = User.find_by(login: author) + w = Work.find_by(title: work) + Creatorship.orphan(u.pseuds, [w], true) +end + +When "{string} orphans and keeps their pseud on the work {string}" do |author, work| + u = User.find_by(login: author) + w = Work.find_by(title: work) + Creatorship.orphan(u.pseuds, [w], false) +end + +Then /^"([^"]*)" (should|should not) be (?:a|the) (?:|co-)creator (?:of|on) the work "([^"]*)"$/ do |user, should_or_should_not, work| + step %{I view the work "#{work}"} + step %{I #{should_or_should_not} see "#{user}" within ".byline"} +end + +Then /^"([^"]*)" (should|should not) be (?:a|the) (?:|co-)creator (?:of|on) Chapter (\d+) of "([^"]*)"$/ do |user, should_or_should_not, chapter, work| + step %{I view the work "#{work}"} + step %{I view the #{chapter}th chapter} + + if page.has_css? ".chapter .byline" + # the chapter has different co-authors from the full work + step %{I #{should_or_should_not} see "#{user}" within ".chapter .byline"} + else + # the chapter's co-authors are the same as the full work's + step %{I #{should_or_should_not} see "#{user}" within ".byline"} + end +end + +Then /^"([^"]*)" (should|should not) be (?:a|the) (?:|co-)creator (?:of|on) the series "([^"]*)"$/ do |user, should_or_should_not, series| + step %{I view the series "#{series}"} + step %{I #{should_or_should_not} see "#{user}" within ".series.meta"} +end diff --git a/features/step_definitions/page_title_steps.rb b/features/step_definitions/page_title_steps.rb new file mode 100644 index 0000000..3d41329 --- /dev/null +++ b/features/step_definitions/page_title_steps.rb @@ -0,0 +1,11 @@ +Then "I should see the page title {string}" do |text| + expect(page.title).to include(text) +end + +Then "the page title should include {string}" do |text| + expect(page.title).to include(text) +end + +Then "the page title should not include {string}" do |text| + expect(page.title).not_to include(text) +end diff --git a/features/step_definitions/pickle_steps.rb b/features/step_definitions/pickle_steps.rb new file mode 100644 index 0000000..756c073 --- /dev/null +++ b/features/step_definitions/pickle_steps.rb @@ -0,0 +1,100 @@ +# this file generated by script/generate pickle + +# create a model +Given(/^#{capture_model} exists?(?: with #{capture_fields})?$/) do |name, fields| + create_model(name, fields) +end + +# create n models +Given(/^(\d+) #{capture_plural_factory} exist(?: with #{capture_fields})?$/) do |count, plural_factory, fields| + count.to_i.times { create_model(plural_factory.singularize, fields) } +end + +# create models from a table +Given(/^the following #{capture_plural_factory} exists?:?$/) do |plural_factory, table| + create_models_from_table(plural_factory, table) +end + +# find a model +Then(/^#{capture_model} should exist(?: with #{capture_fields})?$/) do |name, fields| + find_model!(name, fields) +end + +# not find a model +Then(/^#{capture_model} should not exist(?: with #{capture_fields})?$/) do |name, fields| + find_model(name, fields).should be_nil +end + +# find models with a table +Then(/^the following #{capture_plural_factory} should exists?:?$/) do |plural_factory, table| + find_models_from_table(plural_factory, table).should_not be_any(&:nil?) +end + +# find exactly n models +Then(/^(\d+) #{capture_plural_factory} should exist(?: with #{capture_fields})?$/) do |count, plural_factory, fields| + find_models(plural_factory.singularize, fields).size.should == count.to_i +end + +# assert equality of models +Then(/^#{capture_model} should be #{capture_model}$/) do |a, b| + model!(a).should == model!(b) +end + +# assert model is in another model's has_many assoc +Then(/^#{capture_model} should be (?:in|one of|amongst) #{capture_model}(?:'s)? (\w+)$/) do |target, owner, association| + model!(owner).send(association).should include(model!(target)) +end + +# assert model is not in another model's has_many assoc +Then(/^#{capture_model} should not be (?:in|one of|amongst) #{capture_model}(?:'s)? (\w+)$/) do |target, owner, association| + model!(owner).send(association).should_not include(model!(target)) +end + +# assert model is another model's has_one/belongs_to assoc +Then(/^#{capture_model} should be #{capture_model}(?:'s)? (\w+)$/) do |target, owner, association| + model!(owner).send(association).should == model!(target) +end + +# assert model is not another model's has_one/belongs_to assoc +Then(/^#{capture_model} should not be #{capture_model}(?:'s)? (\w+)$/) do |target, owner, association| + model!(owner).send(association).should_not == model!(target) +end + +# assert model.predicate? +Then(/^#{capture_model} should (?:be|have) (?:an? )?#{capture_predicate}$/) do |name, predicate| + if model!(name).respond_to?("has_#{predicate.gsub(' ', '_')}") + model!(name).should send("have_#{predicate.gsub(' ', '_')}") + else + model!(name).should send("be_#{predicate.gsub(' ', '_')}") + end +end + +# assert not model.predicate? +Then(/^#{capture_model} should not (?:be|have) (?:an? )?#{capture_predicate}$/) do |name, predicate| + if model!(name).respond_to?("has_#{predicate.gsub(' ', '_')}") + model!(name).should_not send("have_#{predicate.gsub(' ', '_')}") + else + model!(name).should_not send("be_#{predicate.gsub(' ', '_')}") + end +end + +# model.attribute.should eql(value) +# model.attribute.should_not eql(value) +Then(/^#{capture_model}'s (\w+) (should(?: not)?) be #{capture_value}$/) do |name, attribute, expectation, expected| + actual_value = model(name).send(attribute) + expectation = expectation.gsub(' ', '_') + + case expected + when 'nil', 'true', 'false' + actual_value.send(expectation, send("be_#{expected}")) + when /^[+-]?[0-9_]+(\.\d+)?$/ + actual_value.send(expectation, eql(expected.to_f)) + else + actual_value.to_s.send(expectation, eql(eval(expected))) + end +end + +# assert size of association +Then /^#{capture_model} should have (\d+) (\w+)$/ do |name, size, association| + model!(name).send(association).size.should == size.to_i +end diff --git a/features/step_definitions/potential_match_steps.rb b/features/step_definitions/potential_match_steps.rb new file mode 100644 index 0000000..3b2e8d9 --- /dev/null +++ b/features/step_definitions/potential_match_steps.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +Given /^I create the gift exchange "([^\"]*)" with the following options$/ do |name, table| + # Set up the gift exchange with the correct owner. + step %{the user "moderator" exists and is activated} + user = User.find_by(login: "moderator") + collection = Collection.new( + name: name, + title: name, + challenge_type: "GiftExchange" + ) + + collection.collection_participants.build( + pseud_id: user.default_pseud.id, + participant_role: "Owner" + ) + + # Create the new collection. + collection.challenge = GiftExchange.new + collection.challenge.request_restriction = PromptRestriction.new + collection.challenge.offer_restriction = PromptRestriction.new + collection.challenge.potential_match_settings = PotentialMatchSettings.new + + potential_match_settings = collection.challenge.potential_match_settings + + # Go through each of the types, and set up the request/offer/matching + # requirements accordingly. + table.hashes.each do |hash| + # Get the type (prompt/fandom/character/etc.) that we're restricting. + type = hash["value"].downcase.singularize + + # Constraints on the prompts. + required = (hash["minimum"] || "0").to_i + allowed = (hash["maximum"] || "0").to_i + any = %w(y t yes true).include?(hash["any"] || "yes") + unique = %w(y t yes true).include?(hash["unique"] || "yes") + optional = %w(y t yes true).include?(hash["optional"] || "no") + + # Constraints on the matching. + match = (hash["match"] || hash["minimum"] || "0").to_i + + if type == "prompt" + # Prompts aren't a type of tag, so we have to set them up differently. + collection.challenge.requests_num_required = required + collection.challenge.requests_num_allowed = allowed + collection.challenge.offers_num_required = required + collection.challenge.offers_num_allowed = allowed + potential_match_settings.num_required_prompts = match + else + attributes = { + "#{type}_num_required" => required, + "#{type}_num_allowed" => allowed, + "allow_any_#{type}" => any, + "require_unique_#{type}" => unique + } + + attributes["optional_tags_allowed"] = true if optional + + # We treat requests and offers identically, in order to make the + # constraints simpler to express. (The tests are designed to verify + # potential match generation, not challenge signups, so we don't need + # that kind of fine-grained control.) + collection.challenge.request_restriction.update!(attributes) + collection.challenge.offer_restriction.update!(attributes) + + potential_match_settings.update_attribute( + "num_required_#{type.pluralize}", match + ) + + potential_match_settings.update_attribute( + "include_optional_#{type.pluralize}", optional + ) + end + end + + # Save all the things! + collection.save! + collection.challenge.save! + collection.challenge.request_restriction.save! + collection.challenge.offer_restriction.save! + collection.challenge.potential_match_settings.save! +end + +Given /^the user "([^\"]*)" signs up for "([^\"]*)" with the following prompts$/ do |user_name, collection_name, table| + # Set up the username. + step %{the user "#{user_name}" exists and is activated} + user = User.find_by(login: user_name).default_pseud + + # Set up the basics of the signup. + collection = Collection.find_by(name: collection_name) + signup = ChallengeSignup.new(pseud: user, collection: collection) + offers = [] + requests = [] + + table.hashes.each do |hash| + # Each row of the table is a prompt -- either a request, or an offer. + prompt_type = hash["type"].downcase + prompt = prompt_type.classify.constantize.new( + collection: collection, + pseud: user + ) + + tagset = TagSet.new + optional_tag_set = nil + TagSet::TAG_TYPES.each do |type| + optional = hash["optional #{type}"] || hash["optional #{type.pluralize}"] + unless optional.nil? + optional_tag_set ||= TagSet.new + + tag_names = optional.split(/ *, */) + tag_names.each do |tag_name| + tag = type.classify.constantize.create_canonical(tag_name) + optional_tag_set.tags << tag + end + end + + value = hash[type] || hash[type.pluralize] + next if value.nil? + value = value.downcase + + # Allow the test to specify "any" for the type. + if value == "any" + prompt.update_attribute("any_#{type}", true) + else + tag_names = value.split(/ *, */) + tag_names.each do |tag_name| + tag = type.classify.constantize.create_canonical(tag_name) + tagset.tags << tag + end + end + end + + tagset.save! + prompt.tag_set = tagset + + optional_tag_set.save! if optional_tag_set + prompt.optional_tag_set = optional_tag_set + + requests << prompt if prompt_type == "request" + offers << prompt if prompt_type == "offer" + end + + # Save the final signup. + signup.requests = requests + signup.offers = offers + signup.save! +end + +When /^potential matches are generated for "([^\"]*)"$/ do |name| + collection = Collection.find_by(name: name) + PotentialMatch.generate_in_background collection.id +end + +Then /^there should be no potential matches for "([^\"]*)"$/ do |name| + collection = Collection.find_by(name: name) + collection.potential_matches.count.should == 0 +end + +Then /^the potential matches for "([^\"]*)" should be$/ do |name, table| + # First extract the set of potential matches for the given challenge. + collection = Collection.find_by(name: name) + matches = collection.potential_matches.includes( + request_signup: :pseud, + offer_signup: :pseud + ) + + match_names = matches.map do |pm| + [pm.request_signup.pseud.name, pm.offer_signup.pseud.name] + end + + match_names.sort! + + # Next extract the set of potential matches from the table. + desired_names = [] + table.hashes.each do |hash| + desired_names << [hash["request"], hash["offer"]] + end + + desired_names.sort! + + match_names.should == desired_names +end diff --git a/features/step_definitions/preferences_steps.rb b/features/step_definitions/preferences_steps.rb new file mode 100644 index 0000000..9be516b --- /dev/null +++ b/features/step_definitions/preferences_steps.rb @@ -0,0 +1,147 @@ +Given /^I set my preferences to View Full Work mode by default$/ do + step %{I follow "My Preferences"} + check("preference_view_full_works") + click_button("Update") +end + +Given(/^the user "(.*?)" disallows co-creators$/) do |login| + user = User.where(login: login).first + user = find_or_create_new_user(login, DEFAULT_PASSWORD) if user.nil? + user.preference.allow_cocreator = false + user.preference.save +end + +Given(/^the user "(.*?)" allows co-creators$/) do |login| + user = User.where(login: login).first + user = find_or_create_new_user(login, DEFAULT_PASSWORD) if user.nil? + user.preference.allow_cocreator = true + user.preference.save +end + +Given "the user {string} disallows gifts" do |login| + user = User.where(login: login).first + user = find_or_create_new_user(login, DEFAULT_PASSWORD) if user.nil? + user.preference.allow_gifts = false + user.preference.save +end + +Given "the user {string} allows gifts" do |login| + user = User.where(login: login).first + user = find_or_create_new_user(login, DEFAULT_PASSWORD) if user.nil? + user.preference.allow_gifts = true + user.preference.save +end + +When "the user {string} turns off guest comment replies" do |login| + user = User.where(login: login).first + user = find_or_create_new_user(login, DEFAULT_PASSWORD) if user.nil? + user.preference.update!(guest_replies_off: true) +end + +Given "the user {string} is hidden from search engines" do |login| + user = User.find_by(login: login) + user.preference.update!(minimize_search_engines: true) +end + +When /^I set my preferences to turn off notification emails for comments$/ do + step %{I follow "My Preferences"} + check("preference_comment_emails_off") + click_button("Update") +end + +When /^I set my preferences to turn off notification emails for kudos$/ do + step %{I follow "My Preferences"} + check("preference_kudos_emails_off") + click_button("Update") +end + +When /^I set my preferences to turn off notification emails for gifts$/ do + step %{I follow "My Preferences"} + check("preference_recipient_emails_off") + click_button("Update") +end + +When /^I set my preferences to hide warnings$/ do + step %{I follow "My Preferences"} + check("preference_hide_warnings") + click_button("Update") +end + +When /^I set my preferences to hide freeform$/ do + step %{I follow "My Preferences"} + check("preference_hide_freeform") + click_button("Update") +end + +When /^I set my preferences to hide the share buttons on my work$/ do + step %{I follow "My Preferences"} + check("preference_disable_share_links") + click_button("Update") +end + +When /^I set my preferences to turn off messages to my inbox about comments$/ do + step %{I follow "My Preferences"} + check("preference_comment_inbox_off") + click_button("Update") +end + +When /^I set my preferences to turn on messages to my inbox about comments$/ do + step %{I follow "My Preferences"} + uncheck("preference_comment_inbox_off") + click_button("Update") +end + +When /^I set my preferences to turn off copies of my own comments$/ do + step %{I follow "My Preferences"} + check("preference_comment_copy_to_self_off") + click_button("Update") +end + +When /^I set my preferences to turn on copies of my own comments$/ do + step %{I follow "My Preferences"} + uncheck("preference_comment_copy_to_self_off") + click_button("Update") +end + +When /^I set my preferences to turn off the banner showing on every page$/ do + step %{I follow "My Preferences"} + check("preference_banner_seen") + click_button("Update") +end + +When /^I set my preferences to turn off history$/ do + step %{I follow "My Preferences"} + uncheck("preference_history_enabled") + click_button("Update") +end + +When "the user {string} sets the time zone to {string}" do |username, time_zone| + user = User.find_by(login: username) + user.preference.time_zone = time_zone + user.preference.save +end + +When "I set my preferences to allow collection invitations" do + step %{I follow "My Preferences"} + check("preference_allow_collection_invitation") + click_button("Update") +end + +When /^I set my preferences to hide both warnings and freeforms$/ do + step %{I follow "My Preferences"} + check("preference_hide_warnings") + check("preference_hide_freeform") + click_button("Update") +end + +When /^I set my preferences to show adult content without warning$/ do + step %{I follow "My Preferences"} + check("preference_adult") + click_button("Update") +end + +When /^I set my preferences to warn before showing adult content$/ do + step %{I follow "My Preferences"} + uncheck("preference_adult") + click_button("Update") +end diff --git a/features/step_definitions/profile_steps.rb b/features/step_definitions/profile_steps.rb new file mode 100644 index 0000000..3f7b56b --- /dev/null +++ b/features/step_definitions/profile_steps.rb @@ -0,0 +1,85 @@ +Given /^I want to edit my profile$/ do + step "I view my profile" + click_link("Edit My Profile") + step %{I should see "Edit My Profile"} +end + +When /^I fill in the details of my profile$/ do + fill_in("Title", with: "Test title thingy") + fill_in("About Me", with: "This is some text about me.") + click_button("Update") +end + +When /^I change the details in my profile$/ do + fill_in("Title", with: "Alternative title thingy") + fill_in("About Me", with: "This is some different text about me.") + click_button("Update") +end + +When /^I remove details from my profile$/ do + fill_in("Title", with: "") + fill_in("About Me", with: "") + click_button("Update") +end + +When "the email address change confirmation period is set to {int} days" do |amount| + allow(Devise).to receive(:confirm_within).and_return(amount.days) +end + +When "I start to change my email to {string}" do |email| + step %{I fill in "New email" with "#{email}"} + step %{I fill in "Enter new email again" with "#{email}"} + step %{I fill in "Password" with "password"} + step %{I press "Confirm New Email"} +end + +When "I confirm my email change request to {string}" do |email| + step %{I should see "Are you sure you want to change your email address to #{email}?"} + step %{I press "Yes, Change Email"} +end + +When "I request to change my email to {string}" do |email| + step %{I start to change my email to "#{email}"} + step %{I confirm my email change request to "#{email}"} +end + +When "I change my email to {string}" do |email| + step %{I follow "My Preferences"} + step %{I follow "Change Email"} + step %{I request to change my email to "#{email}"} + step %{1 email should be delivered to "#{email}"} + step %{I follow "confirm your email change" in the email} + step %{I should see "Your email has been successfully updated."} +end + +When /^I view my profile$/ do + step %{I follow "My Dashboard"} + step %{I should see "Dashboard"} + click_link("Profile") +end + +When /^I make a mistake typing my old password$/ do + click_link("Password") + fill_in("password", with: "newpass1") + fill_in("password_confirmation", with: "newpass1") + fill_in("password_check", with: "wrong") + click_button("Change Password") +end + + +When /^I make a typing mistake confirming my new password$/ do + click_link("Password") + fill_in("password", with: "newpass1") + fill_in("password_confirmation", with: "newpass2") + fill_in("password_check", with: "password") + click_button("Change Password") +end + + +When /^I change my password$/ do + click_link("Password") + fill_in("password", with: "newpass1") + fill_in("password_confirmation", with: "newpass1") + fill_in("password_check", with: "password") + click_button("Change Password") +end diff --git a/features/step_definitions/pseud_steps.rb b/features/step_definitions/pseud_steps.rb new file mode 100644 index 0000000..0b8d069 --- /dev/null +++ b/features/step_definitions/pseud_steps.rb @@ -0,0 +1,39 @@ +Given /^"([^"]*)" has the pseud "([^"]*)"$/ do |username, pseud| + u = ensure_user(username) + u.pseuds.create!(name: pseud) +end + +Given "there are {int} pseuds per page" do |amount| + stub_const("ArchiveConfig", OpenStruct.new(ArchiveConfig)) + ArchiveConfig.ITEMS_PER_PAGE = amount.to_i + allow(Pseud).to receive(:per_page).and_return(amount) +end + +When "{string} changes the pseud {string} to {string}" do |username, old_pseud, new_pseud| + step %{"#{username}" edits the pseud "#{old_pseud}"} + fill_in("Name", with: new_pseud) + click_button("Update") +end + +When "{string} edits the pseud {string}" do |username, pseud| + p = Pseud.where(name: pseud, user_id: User.find_by(login: username)).first + visit edit_user_pseud_path(User.find_by(login: username), p) +end + +When "{string} deletes the pseud {string}" do |username, pseud| + visit user_pseuds_path(User.find_by(login: username)) + click_link("delete_#{pseud}") +end + +When /^"([^\"]*)" creates the default pseud "([^"]*)"$/ do |username, newpseud| + visit new_user_pseud_path(username) + fill_in "Name", with: newpseud + check("pseud_is_default") + click_button "Create" +end + +When /^"([^"]*)" creates the pseud "([^"]*)"$/ do |username, newpseud| + visit new_user_pseud_path(username) + fill_in "Name", with: newpseud + click_button "Create" +end diff --git a/features/step_definitions/rake_steps.rb b/features/step_definitions/rake_steps.rb new file mode 100644 index 0000000..d9b3710 --- /dev/null +++ b/features/step_definitions/rake_steps.rb @@ -0,0 +1,14 @@ +require 'rake' + +When /^I run the rake task "(.*?)"$/ do |name| + Rails.application.load_tasks unless Rake::Task.task_defined?(name) + task = Rake::Task[name] + task.invoke + + # As in spec/support/task_example_group.rb, use "invoke" (and re-enable the + # task and its prerequisites) over "execute" (which doesn't require + # re-enabling, but doesn't run prerequisites) because it more closely matches + # the behavior of rake itself. + task.all_prerequisite_tasks.each { |prerequisite| Rake::Task[prerequisite].reenable } + task.reenable +end diff --git a/features/step_definitions/reading_steps.rb b/features/step_definitions/reading_steps.rb new file mode 100644 index 0000000..7f1ecc1 --- /dev/null +++ b/features/step_definitions/reading_steps.rb @@ -0,0 +1,13 @@ +Given /^(.*) first read "([^"]*)" on "([^"]*)"$/ do |login, title, date| + user = User.find_by(login: login) + work = Work.find_by(title: title) + time = date.to_time.in_time_zone("UTC") + # create the reading + reading_json = [user.id, time, work.id, work.major_version, work.minor_version, false].to_json + REDIS_GENERAL.sadd("Reading:new", reading_json) + step "the readings are saved to the database" +end + +When "the readings are saved to the database" do + RedisJobSpawner.perform_now("ReadingsJob") +end diff --git a/features/step_definitions/redis_mail_queue_steps.rb b/features/step_definitions/redis_mail_queue_steps.rb new file mode 100644 index 0000000..d91fe8e --- /dev/null +++ b/features/step_definitions/redis_mail_queue_steps.rb @@ -0,0 +1,15 @@ +When /^subscription notifications are sent$/ do + RedisMailQueue.deliver_subscriptions +end + +When /^the subscription queue is cleared$/ do + RedisMailQueue.clear_queue("subscription") +end + +When /^kudos are sent$/ do + RedisMailQueue.deliver_kudos +end + +When /^the kudos queue is cleared$/ do + RedisMailQueue.clear_queue("kudos") +end diff --git a/features/step_definitions/request_header_steps.rb b/features/step_definitions/request_header_steps.rb new file mode 100644 index 0000000..52a5314 --- /dev/null +++ b/features/step_definitions/request_header_steps.rb @@ -0,0 +1,14 @@ +World(Rack::Test::Methods) +# thanks to these guys: http://www.anthonyeden.com/2010/11/testing-rest-apis-with-cucumber-and-rack-test/ + +Given /^I use a PSP browser$/ do + header 'Accept', '*/*;q=0.01' +end + +Given /^I make a request for "([^\"]*)"$/ do |path| + get path +end + +Then /^the response should be "([^\"]*)"$/ do |status| + last_response.status.should == status.to_i +end diff --git a/features/step_definitions/search_steps.rb b/features/step_definitions/search_steps.rb new file mode 100644 index 0000000..43c2b83 --- /dev/null +++ b/features/step_definitions/search_steps.rb @@ -0,0 +1,31 @@ +Given /^all indexing jobs have been run$/ do + %w[main background stats].each do |reindex_type| + ScheduledReindexJob.perform(reindex_type) + end + Indexer.all.map(&:refresh_index) +end + +Given /^the max search result count is (\d+)$/ do |max| + stub_const("ArchiveConfig", OpenStruct.new(ArchiveConfig)) + ArchiveConfig.MAX_SEARCH_RESULTS = max.to_i +end + +Given /^(\d+) item(?:s)? (?:is|are) displayed per page$/ do |per_page| + stub_const("ArchiveConfig", OpenStruct.new(ArchiveConfig)) + ArchiveConfig.ITEMS_PER_PAGE = per_page.to_i +end + +Given /^(\d+) tag(?:s)? (?:is|are) displayed per search page$/ do |per_page| + stub_const("ArchiveConfig", OpenStruct.new(ArchiveConfig)) + ArchiveConfig.TAGS_PER_SEARCH_PAGE = per_page.to_i +end + +Given /^dashboard counts expire after (\d+) seconds?$/ do |seconds| + stub_const("ArchiveConfig", OpenStruct.new(ArchiveConfig)) + ArchiveConfig.SECONDS_UNTIL_DASHBOARD_COUNTS_EXPIRE = seconds.to_i +end + +When "the dashboard counts have expired" do + step "all indexing jobs have been run" + step "it is currently #{ArchiveConfig.SECONDS_UNTIL_DASHBOARD_COUNTS_EXPIRE} seconds from now" +end diff --git a/features/step_definitions/series_steps.rb b/features/step_definitions/series_steps.rb new file mode 100644 index 0000000..4b98d3d --- /dev/null +++ b/features/step_definitions/series_steps.rb @@ -0,0 +1,156 @@ +When /^I view the series "([^\"]*)"$/ do |series| + visit series_path(Series.find_by(title: series)) +end + +Given "there are {int} works per series page" do |amount| + allow(WillPaginate).to receive(:per_page).and_return(amount) +end + +When /^I add the series "([^\"]*)"$/ do |series_title| + check("series-options-show") + if Series.find_by(title: series_title) + step %{I select "#{series_title}" from "work_series_attributes_id"} + else + fill_in("work_series_attributes_title", with: series_title) + end +end + +When /^I add the work "([^\"]*)" to (?:the )?series "([^\"]*)"(?: as "([^"]*)")?$/ do |work_title, series_title, pseud| + if Work.where(title: work_title).exists? + # an existing work + step %{I edit the work "#{work_title}"} + else + # a new work + step "I set up the draft \"#{work_title}\"" + end + if pseud + select(pseud, from: "work_author_attributes_ids") + end + step %{I add the series "#{series_title}"} + click_button("Post") +end + +When /^I add the draft "([^\"]*)" to series "([^\"]*)"$/ do |work_title, series_title| + step %{I edit the work "#{work_title}"} + step %{I add the series "#{series_title}"} + click_button("Save As Draft") +end + +When /^I add the work "([^\"]*)" to "(\d+)" series "([^\"]*)"$/ do |work_title, count, series_title| + work = Work.find_by(title: work_title) + if work.blank? + step "the draft \"#{work_title}\"" + work = Work.find_by(title: work_title) + visit preview_work_path(work) + click_button("Post") + step "I should see \"Work was successfully posted.\"" + step %{all indexing jobs have been run} + step "the periodic tag count task is run" + end + + count.to_i.times do |i| + step "I edit the work \"#{work_title}\"" + check("series-options-show") + fill_in("work_series_attributes_title", with: series_title + i.to_s) + click_button("Post") + end +end + +When /^I reorder the (\d+)(?:st|nd|rd|th) work to be below the (\d+)(?:st|nd|rd|th) work in the series$/ do |n1, n2| + # Step only accounts for downward changes through a downward offset. + assert n1 < n2 + + draggable = find(".serial-position-list:nth-child(#{n1})") + droppable = find(".serial-position-list:nth-child(#{n2})") + + # Capybara's drag_to method doesn't work well with this jQuery sortable list that has a default tolerance of "intersect". + # Using another way to simulate dragging. Credit to https://stackoverflow.com/questions/72369314/ + webdriver = page.driver.browser + webdriver.action.click_and_hold(draggable.native).perform + step "I wait 1 second" # a delay is necessary. + # Add downward offset to make the rearrangement register. + webdriver.action.move_to(droppable.native, 0, 10).release.perform + + step "all AJAX requests are complete" +end + +When /^I delete the series "([^"]*)"$/ do |series| + step %{I view the series "#{series}"} + step %{I follow "Delete Series"} + step %{I press "Yes, Delete Series"} + step %{I should see "Series was successfully deleted."} + step %{all indexing jobs have been run} +end + +Then /^the series "(.*)" should be deleted/ do |series| + assert Series.where(title: series).first.nil? +end + +Then /^the work "([^\"]*)" should be part of the "([^\"]*)" series in the database$/ do |work_title, series_title| + work = Work.find_by(title: work_title) + series = Series.find_by(title: series_title) + assert SerialWork.where(series_id: series, work_id: work).exists? +end + +Then /^the work "([^\"]*)" should not be visible on the "([^\"]*)" series page$/ do |work_title, series_title| + series = Series.find_by(title: series_title) + visit series_path(series) + page.should_not have_content(work_title) +end + +Then /^the series "([^\"]*)" should not be visible on the "([^\"]*)" work page$/ do |series_title, work_title| + work = Work.find_by(title: work_title) + visit work_path(work) + page.should_not have_content(series_title) +end + +Then /^the work "([^\"]*)" should be visible on the "([^\"]*)" series page$/ do |work_title, series_title| + series = Series.find_by(title: series_title) + visit series_path(series) + page.should have_content(work_title) +end + +Then /^the series "([^\"]*)" should be visible on the "([^\"]*)" work page$/ do |series_title, work_title| + work = Work.find_by(title: work_title) + series = Series.find_by(title: series_title) + visit work_path(work) + page.should have_content(series_title) + page.should have_content("Part #{SerialWork.where(series_id: series, work_id: work).first.position}") +end + +Then /^the neighbors of "([^\"]*)" in the "([^\"]*)" series should link over it$/ do |work_title, series_title| + work = Work.find_by(title: work_title) + series = Series.find_by(title: series_title) + position = SerialWork.where(series_id: series, work_id: work).first.position + neighbors = SerialWork.where(series_id: series, position: [position - 1, position + 1]) + neighbors.each_with_index do |neighbor, index| + visit work_path(neighbor.work) + # the neighbors should link to each other if they both exist + if neighbors.count > 1 && index == 0 + click_link("Next Work →") + page.should_not have_content(work_title) + page.should have_content(neighbors[1].work.title) + elsif neighbors.count > 1 && index == 1 + click_link("← Previous Work") + page.should_not have_content(work_title) + page.should have_content(neighbors[0].work.title) + end + end +end + +Then /^the neighbors of "([^\"]*)" in the "([^\"]*)" series should link to it$/ do |work_title, series_title| + work = Work.find_by(title: work_title) + series = Series.find_by(title: series_title) + position = SerialWork.where(series_id: series, work_id: work).first.position + neighbors = SerialWork.where(series_id: series, position: [position - 1, position + 1]) + neighbors.each do |neighbor| + visit work_path(neighbor.work) + if neighbor.position > position + click_link("← Previous Work") + page.should have_content(work_title) + else + click_link("Next Work →") + page.should have_content(work_title) + end + end +end diff --git a/features/step_definitions/share_steps.rb b/features/step_definitions/share_steps.rb new file mode 100644 index 0000000..f636a55 --- /dev/null +++ b/features/step_definitions/share_steps.rb @@ -0,0 +1,6 @@ +Then /^the share modal should contain social share buttons$/ do + with_scope("#share") do + expect(page).to have_css("li.twitter", text: "Twitter") + expect(page).to have_css("li.tumblr", text: "Tumblr") + end +end diff --git a/features/step_definitions/skin_steps.rb b/features/step_definitions/skin_steps.rb new file mode 100644 index 0000000..b9dcf91 --- /dev/null +++ b/features/step_definitions/skin_steps.rb @@ -0,0 +1,287 @@ +# Make sure that the methods in SkinCacheHelper are available to steps in this +# file (specifically, the steps checking cache expiration): +World(SkinCacheHelper) + +DEFAULT_CSS = "\"#title { text-decoration: blink;}\"" + +Given /^basic skins$/ do + assert Skin.default + assert WorkSkin.basic_formatting +end + +Given "the skin {string} by {string}" do |skin_name, login| + user = ensure_user(login) + FactoryBot.create(:skin, title: skin_name, author_id: user.id) +end + +Given /^I set up the skin "([^"]*)"$/ do |skin_name| + visit new_skin_path + fill_in("Title", with: skin_name) + fill_in("Description", with: "Random description") + fill_in("CSS", with: "#title { text-decoration: blink;}") +end + +Given /^I set up the skin "([^"]*)" with some css$/ do |skin_name| + step %{I set up the skin "#{skin_name}" with css #{DEFAULT_CSS}} +end + +Given /^I set up the skin "([^"]*)" with css "([^"]*)"$/ do |skin_name, css| + step "I set up the skin \"#{skin_name}\"" + fill_in("CSS", with: css) +end + +Given /^I create the skin "([^"]*)" with css "([^"]*)"$/ do |skin_name, css| + step "I set up the skin \"#{skin_name}\" with css \"#{css}\"" + step %{I submit} +end + +Given /^I create the skin "([^"]*)" with some css$/ do |skin_name, css| + step "I set up the skin \"#{skin_name}\" with css \"#{css}\"" + step %{I submit} +end + +Given /^I create the skin "([^"]*)"$/ do |skin_name| + step "I create the skin \"#{skin_name}\" with css #{DEFAULT_CSS}" +end + +Given /^I edit the skin "([^"]*)"$/ do |skin_name| + skin = Skin.find_by(title: skin_name) + visit edit_skin_path(skin) +end + +Given /^the unapproved public skin "([^"]*)" with css "([^"]*)"$/ do |skin_name, css| + # Delay to make sure all skins have at least 1 second of separation in their + # creation dates, so that they will be listed in the right order: + step "it is currently 1 second from now" + + step %{I am logged in as "skinner"} + step %{I set up the skin "#{skin_name}" with css "#{css}"} + attach_file("skin_icon", "features/fixtures/skin_test_preview.png") + check("skin_public") + step %{I submit} + step %{I should see "Skin was successfully created"} +end + +Given /^the unapproved public skin "([^"]*)"$/ do |skin_name| + step "the unapproved public skin \"#{skin_name}\" with css #{DEFAULT_CSS}" +end + +Given /^I approve the skin "([^"]*)"$/ do |skin_name| + step %{I am logged in as a "superadmin" admin} + visit admin_skins_url + check("make_official_#{skin_name.downcase.gsub(/\s/, '_')}") + step %{I submit} +end + +Given /^I unapprove the skin "([^"]*)"$/ do |skin_name| + step %{I am logged in as a "superadmin" admin} + visit admin_skins_url + step "I follow \"Approved Skins\"" + check("make_unofficial_#{skin_name.downcase.gsub(/\s/, '_')}") + step %{I submit} +end + +Given /^I have loaded site skins$/ do + Skin.load_site_css +end + +Given "the approved public skin {string} with css {string}" do |skin_name, css| + step %{the unapproved public skin "#{skin_name}" with css "#{css}"} + step %{I approve the skin "#{skin_name}"} + step "I am logged out" +end + +Given /^the approved public skin "([^"]*)"$/ do |skin_name| + step "the approved public skin \"#{skin_name}\" with css #{DEFAULT_CSS}" +end + +Given "the approved public skin {string} has reserved words in the title" do |skin_name| + # Admins can't create skins, so we have to create it this way. + FactoryBot.build(:skin, title: skin_name, public: true).save!(validate: false) + + step %{I approve the skin "#{skin_name}"} + step "I am logged out" +end + +Given /^"([^"]*)" is using the approved public skin "([^"]*)" with css "([^"]*)"$/ do |login, skin_name, css| + step "the approved public skin \"public skin\" with css \"#{css}\"" + step "I am logged in as \"#{login}\"" + step "I am on #{login}'s preferences page" + select(skin_name, from: "preference_skin_id") + step %{I submit} +end + +Given /^"([^"]*)" is using the approved public skin "([^"]*)"$/ do |login, skin_name| + step "\"#{login}\" is using the approved public skin with css #{DEFAULT_CSS}" +end + +Given /^I have a skin "(.*?)" with a parent "(.*?)"$/ do |child_title, parent_title| + step %{I set up the skin "#{parent_title}"} + click_button("Submit") + step %{I set up the skin "#{child_title}"} + click_button("Submit") + + child = Skin.find_by(title: child_title) + parent = Skin.find_by(title: parent_title) + child.skin_parents.create(position: 1, parent_skin: parent) +end + +### WHEN + +When /^I change my skin to "([^\"]*)"$/ do |skin_name| + step %{I follow "My Dashboard"} + step %{I follow "Preferences"} + step %{I select "#{skin_name}" from "preference_skin_id"} + step %{I submit} + step %{I should see "Your preferences were successfully updated."} +end + +When /^I create and use a skin to make the header pink$/ do + visit new_skin_url(wizard: true) + fill_in("skin_title", with: "Pink header") + fill_in("skin_headercolor", with: "pink") + click_button("Submit") + click_button("Use") +end + +When /^I create and use a skin to change the accent color$/ do + visit new_skin_url(wizard: true) + fill_in("skin_title", with: "Blue accent") + fill_in("skin_accent_color", with: "blue") + click_button("Submit") + click_button("Use") +end + +When /^I edit the skin "([^\"]*)" with the wizard$/ do |skin_name| + skin = Skin.find_by(title: skin_name) + visit edit_skin_path(skin, wizard: true) +end + +When /^I edit my pink header skin to have a purple logo$/ do + skin = Skin.find_by(title: "Pink header") + visit edit_skin_path(skin) + fill_in("CSS", with: "#header .heading a {color: purple;}") + click_button("Update") +end + +When /^I view the skin "([^\"]*)"$/ do |skin| + skin = Skin.find_by(title: skin) + visit skin_url(skin) +end + +When /^the skin "([^\"]*)" is in the chooser$/ do |skin_name| + skin = Skin.find_by(title: skin_name) + skin.in_chooser = true + skin.save! +end + +When /^the skin "([^\"]*)" is cached$/ do |skin_name| + skin = Skin.find_by(title: skin_name) + skin.cached = true + skin.save! + skin.cache! +end + +When /^I preview the skin "([^\"]*)"$/ do |skin_name| + skin = Skin.find_by(title: skin_name) + visit preview_skin_path(skin) +end + +When /^I set the skin "([^\"]*)" for this session$/ do |skin_name| + skin = Skin.find_by(title: skin_name) + visit set_skin_path(skin) +end + +### THEN + +Then "I should see a parent skin text field" do + step %{I should see "Parent #"} +end + +Then "I should see {string} in the page style" do |css| + expect(page).to have_css("style", text: css, visible: false) +end + +Then "I should not see {string} in the page style" do |css| + expect(page).not_to have_css("style", text: css, visible: false) +end + +Then "the page should have the cached skin {string}" do |skin_name| + skin = Skin.find_by(title: skin_name) + expect(page).to have_css("link[href*='#{skin.skin_dirname}']", visible: false) +end + +Then "the page should not have the cached skin {string}" do |skin_name| + skin = Skin.find_by(title: skin_name) + expect(page).not_to have_css("link[href*='#{skin.skin_dirname}']", visible: false) +end + +Then "I should see a pink header" do + step %{I should see "#header .primary" in the page style} + step %{I should see "background-image: none; background-color: pink;" in the page style} +end + +Then "I should see a different accent color" do + step %{I should see "fieldset, form dl, fieldset dl dl" in the page style} + step %{I should see "background: blue; border-color: blue;" in the page style} +end + +Then "the page should have a skin with the media query {string}" do |query| + expect(page).to have_css("style[media='#{query}']", visible: false) +end + +Then /^the cache of the skin on "([^\"]*)" should expire after I save the skin$/ do |title| + skin = Skin.find_by(title: title) + orig_cache_version = skin_cache_version(skin) + visit edit_skin_path(skin) + fill_in("CSS", with: "#random { text-decoration: blink;}") + click_button("Update") + assert orig_cache_version != skin_cache_version(skin), "Cache version #{orig_cache_version} matches #{skin_cache_version(skin)}." +end + +Then(/^the cache of the skin on "(.*?)" should not expire after I save "(.*?)"$/) do |arg1, arg2| + skin = Skin.find_by(title: arg1) + save_me = Skin.find_by(title: arg2) + orig_skin_version = skin_cache_version(skin) + orig_save_me_version = skin_cache_version(save_me) + visit edit_skin_path(save_me) + fill_in("CSS", with: "#random { text-decoration: blink;}") + click_button("Update") + assert orig_save_me_version != skin_cache_version(save_me), "Cache version #{orig_save_me_version} matches #{skin_cache_version(save_me)}" + assert orig_skin_version == skin_cache_version(skin), "Cache version #{orig_skin_version} does not match #{skin_cache_version(skin)}" +end + +Then(/^the cache of the skin on "(.*?)" should expire after I save a parent skin$/) do |arg1| + skin = Skin.find_by(title: arg1) + orig_skin_version = skin_cache_version(skin) + parent_id = SkinParent.where(child_skin_id: skin.id).last.parent_skin_id + parent = Skin.find(parent_id) + parent.save! + assert orig_skin_version != skin_cache_version(skin), "Cache version #{orig_skin_version} matches #{skin_cache_version(skin)}" +end + +Then "I should see a purple logo" do + step %|I should see "#header .heading a { color: purple; }" in the page style| +end + +Then /^I should see the skin "(.*?)" in the skin chooser$/ do |skin| + with_scope("#footer .menu") do + expect(page).to have_content(skin) + end +end + +Then /^I should not see the skin chooser$/ do + expect(page).not_to have_content("Customize") +end + +Then /^the filesystem cache of the skin "(.*?)" should include "(.*?)"$/ do |title, contents| + skin = Skin.find_by(title: title) + expect(skin.cached?).to be_truthy + + directory = Skin.skins_dir + skin.skin_dirname + style = Skin.skin_dir_entries(directory, /.css$/).map do |filename| + File.read(directory + filename) + end.join("\n") + + expect(style).to include(contents) +end diff --git a/features/step_definitions/subscription_steps.rb b/features/step_definitions/subscription_steps.rb new file mode 100644 index 0000000..265c60f --- /dev/null +++ b/features/step_definitions/subscription_steps.rb @@ -0,0 +1,34 @@ +When /^I view the "([^\"]*)" works index$/ do |tag| + visit works_path(tag_id: tag.to_param) +end + +When /^I view the "([^"]*)" works feed$/ do |tag| + visit "/tags/#{Tag.find_by_name(tag).id}/feed.atom" +end + +When /^"([^\"]*)" subscribes to (author|work|series) "([^\"]*)"$/ do |user, type, name| + case type + when "author" + step %{the user "#{name}" exists and is activated} + step %{I am logged in as "#{user}"} + step %{I go to #{name}'s user page} + when "work" + unless Work.find_by(title: name) + step %{I am logged in as "wip_author"} + step %{I post the work "#{name}"} + end + step %{I am logged in as "#{user}"} + visit work_url(Work.find_by(title: name)) + when "series" + unless Series.find_by(title: name) + step %{I am logged in as "series_author"} + step %{I add the work "Blah" to series "#{name}"} + end + step %{I am logged in as "#{user}"} + step %{I view the series "#{name}"} + end + step %{I press "Subscribe"} + step %{I should see "You are now following #{name}. If you'd like to stop receiving email updates, you can unsubscribe from your Subscriptions page."} + step %{I go to the subscriptions page for "#{user}"} + step %{I should see an "Unsubscribe from #{name}" button} +end diff --git a/features/step_definitions/support_steps.rb b/features/step_definitions/support_steps.rb new file mode 100644 index 0000000..4e63bef --- /dev/null +++ b/features/step_definitions/support_steps.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +Given "Zoho ticket creation is enabled" do + allow_any_instance_of(Feedback).to receive(:zoho_enabled?).and_return(true) + WebMock.stub_request(:get, %r{/contacts/search}) + .to_return(headers: { content_type: "application/json" }, body: '{"data":[{"id":"1"}]}') + WebMock.stub_request(:post, %r{/tickets}) + .to_return(headers: { content_type: "application/json" }, body: '{"id":"3"}') +end + +Given "{string} is a permitted Archive host" do |host| + allow(ArchiveConfig).to receive(:PERMITTED_HOSTS).and_return([host]) +end + +Then "a Zoho ticket should be created with referer {string}" do |referer| + expect(WebMock).to have_requested(:post, "https://desk.zoho.com/api/v1/tickets") + .with { |req| JSON.parse(req.body)["cf"]["cf_ticket_url"] == referer } +end diff --git a/features/step_definitions/tag_set_steps.rb b/features/step_definitions/tag_set_steps.rb new file mode 100644 index 0000000..1119a20 --- /dev/null +++ b/features/step_definitions/tag_set_steps.rb @@ -0,0 +1,241 @@ +Given "a nominated tag set {string} with a tag nomination in the wrong category" do |tag_set_name| + pseud = FactoryBot.create(:pseud, user: FactoryBot.create(:user, login: "tagsetter")) + owned_tag_set = FactoryBot.create(:owned_tag_set, title: tag_set_name, owner: pseud) + tag_set_nomination = FactoryBot.create(:tag_set_nomination, pseud: pseud, owned_tag_set: owned_tag_set) + FactoryBot.create(:relationship, name: "rel tag") + invalid_nom = tag_set_nomination.fandom_nominations.build(tagname: "rel tag") # intentional mismatch in tag category + invalid_nom.save(validate: false) +end + +When /^I follow the add new tag ?set link$/ do + step %{I follow "New Tag Set"} +end + +# This takes strings like: +# ...the fandom tags "x, y, z" +# ...the category tags "a, b, c" +# If you want ratings, warnings, or categories, first load basic or default tags for those types +When /^I add (.*) to the tag ?set$/ do |tags| + tags.scan(/the (\w+) tags "([^\"]*)"/).each do |type, scanned_tags| + if type == "category" || type == "rating" || type == "warning" + tags = scanned_tags.split(/, ?/) + tags.each { |tag| check(tag) } + else + field_name = "owned_tag_set_tag_set_attributes_#{type}_tagnames_to_add" + field_name += "_autocomplete" if @javascript + fill_in(field_name, with: scanned_tags) + end + end +end + +# This takes strings like: +# ...with a visible tag list +# ...with the fandom tags "x, y, z" and the character tags "a, b, c" +# ...with an invisible tag list and the freeform tags "m, n, o" +When /^I set up the tag ?set "([^\"]*)" with(?: (?:an? )(visible|invisible) tag list and)? (.*)$/ do |title, visibility, tags| + unless OwnedTagSet.find_by(title: title).present? + visit new_tag_set_path + fill_in("owned_tag_set_title", with: title) + fill_in("owned_tag_set_description", with: "Here's my tagset") + visibility ||= "invisible" + check("owned_tag_set_visible") if visibility == "visible" + uncheck("owned_tag_set_visible") if visibility == "invisible" + step %{I add #{tags} to the tag set} + step %{I submit} + step %{I should see a create confirmation message} + end +end + +# Takes things like When I add the fandom tags "Bandom" to the tag set "MoreJoyDay". +# Don't forget the extra s, even if it's singular. +When /^I add (.*) to the tag ?set "([^\"]*)"$/ do |tags, title| + step %{I go to the "#{title}" tag set edit page} + step %{I add #{tags} to the tag set} + step %{I submit} + step %{I should see an update confirmation message} +end + +# Takes things like When I remove the fandom tags "Bandom" to the tag set "MoreJoyDay". +# Don't forget the extra s, even if it's singular. +When /^I remove (.*) from the tag ?set "([^\"]*)"$/ do |tags, title| + step %{I go to the "#{title}" tag set edit page} + tags.scan(/the (\w+) tags "([^\"]*)"/).each do |type, scanned_tags| + tags = scanned_tags.split(/, ?/) + + if type == "category" || type == "rating" || type == "warning" + tags.each { |tag| uncheck(tag) } + else + tags.each { |tag| check(tag) } + end + end + step %{I submit} + step %{I should see an update confirmation message} +end + +When /^I set up the nominated tag ?set "([^\"]*)" with (\d*) fandom noms? and (\d*) (character|relationship) noms?$/ do |title, fandom_count, nested_count, nested_type| + unless OwnedTagSet.find_by(title: "#{title}").present? + step %{I go to the new tag set page} + fill_in("owned_tag_set_title", with: title) + fill_in("owned_tag_set_description", with: "Here's my tagset") + check("Currently taking nominations?") + fill_in("Fandom nomination limit", with: fandom_count) + fill_in("#{nested_type.titleize} nomination limit", with: nested_count) + step %{I submit} + step %{I should see a create confirmation message} + end +end + +When /^I nominate (.*) fandoms and (.*) characters in the "([^\"]*)" tag ?set as "([^\"]*)"/ do |fandom_count, char_count, title, login| + step %{I am logged in as "#{login}"} + step %{I go to the "#{title}" tag set page} + step %{I follow "Nominate"} + 1.upto(fandom_count.to_i) do |i| + fill_in("Fandom #{i}", with: "Blah #{i}") + 0.upto(char_count.to_i - 1) do |j| + fill_in("tag_set_nomination_fandom_nominations_attributes_#{i - 1}_character_nominations_attributes_#{j}_tagname", with: "Foobar #{i} #{j}") + end + end +end + +When /^I have (?:a|the) nominated tag ?set "([^\"]*)"/ do |title| + step %{I am logged in as "tagsetter"} + step %{I set up the nominated tag set "#{title}" with 3 fandom noms and 3 character noms} + step %{I nominate 3 fandoms and 3 characters in the "#{title}" tagset as "nominator"} + step %{I submit} + step %{I should see a success message} +end + +When /^I start to nominate fandoms? "([^\"]*)" and characters? "([^\"]*)" in "([^\"]*)"(?: as "([^"]*)")?$/ do |fandom, char, title, user| + user ||= "nominator" + step %{I am logged in as "#{user}"} + step %{I go to the "#{title}" tag set page} + step %{I follow "Nominate"} + @fandoms = fandom.split(/, ?/) + @chars = char.split(/, ?/) + char_index = 0 + chars_per_fandom = @chars.size/@fandoms.size + 1.upto(@fandoms.size) do |i| + fill_in("Fandom #{i}", with: @fandoms[i - 1]) + 0.upto(chars_per_fandom - 1) do |j| + fill_in("tag_set_nomination_fandom_nominations_attributes_#{i - 1}_character_nominations_attributes_#{j}_tagname", with: @chars[char_index]) + char_index += 1 + end + end +end + +When /^I nominate fandoms? "([^\"]*)" and characters? "([^\"]*)" in "([^\"]*)"(?: as "([^"]*)")?$/ do |fandom, char, title, user| + user ||= "nominator" + step %{I start to nominate fandoms "#{fandom}" and characters "#{char}" in "#{title}" as "#{user}"} + step %{I submit} + step %{I should see a success message} +end + +When "I edit nominations for {string} in {string} to include character(s) {string} under fandom {string}" do |user, title, characters, fandom| + step %{I am logged in as "#{user}"} + step %{I go to the "#{title}" tag set page} + step %{I follow "My Nominations"} + step %{I follow "Edit"} + character_inputs = find("dd", text: fandom).find_all(:xpath, "..//input[contains(@id, 'character')]") + characters.split(/, ?/).each.with_index do |character, index| + character_inputs[index].fill_in(with: character) + end + step %{I submit} + step %{I should see a success message} +end + +When /^there are (\d+) unreviewed nominations$/ do |n| + (1..n.to_i).each do |i| + step %{I am logged in as \"nominator#{i}\"} + step %{I nominate 6 fandoms and 6 characters in the "Nominated Tags" tag set as \"nominator#{i}\"} + step %{I press "Submit"} + end +end + +When /^I review nominations for "([^\"]*)"/ do |title| + step %{I am logged in as "tagsetter"} + step %{I go to the "#{title}" tag set page} + step %{I follow "Review Nominations"} +end + +When /^I review associations for "([^\"]*)"/ do |title| + step %{I am logged in as "tagsetter"} + step %{I go to the "#{title}" tag set page} + step %{I follow "Review Associations"} +end + +When /^I nominate and approve fandom "([^\"]*)" and character "([^\"]*)" in "([^\"]*)"/ do |fandom, char, title| + step %{I am logged in as "tagsetter"} + step %{I set up the nominated tag set "#{title}" with 3 fandom noms and 3 character noms} + step %{I nominate fandom "#{fandom}" and character "#{char}" in "#{title}"} + step %{I review nominations for "#{title}"} + step %{I check "fandom_approve_#{fandom}"} + step %{I check "character_approve_#{char}"} + step %{I submit} + step %{I should see "Successfully added to set: #{fandom}"} + step %{I should see "Successfully added to set: #{char}"} +end + +When /^I nominate and approve tags with Unicode characters in "([^\"]*)"/ do |title| + tags = "The Hobbit - All Media Types, Dís, Éowyn, Kíli, Bifur/Óin, スマイルプリキュア, 新白雪姫伝説プリーティア".split(', ') + step %{I am logged in as "tagsetter"} + step %{I set up the nominated tag set "#{title}" with 7 fandom noms and 0 character noms} + step %{I am logged in as "nominator"} + step %{I go to the "#{title}" tag set page} + step %{I follow "Nominate"} + tags.each_with_index do |tag, i| + fill_in("Fandom #{i + 1}", with: tag) + end + step %{I submit} + step %{I should see a success message} + step %{I review nominations for "#{title}"} + tags.each do |tag| + step %{I check "fandom_approve_#{tag}"} + end + step %{I submit} + step %{I should see "Successfully added to set"} +end + +When "I approve the nominated {word} tag {string}" do |tag_type, tag_name| + check("#{tag_type.downcase}_approve_#{tag_name.tr(' ', '_')}") +end + +When /^I should see the tags with Unicode characters/ do + tags = "The Hobbit - All Media Types, Dís, Éowyn, Kíli, Bifur/Óin, スマイルプリキュア, 新白雪姫伝説プリーティア".split(', ') + tags.each do |tag| + step %{I should see "#{tag}"} + end +end + +When /^I view the tag set "([^\"]*)"/ do |tagset| + tagset = OwnedTagSet.find_by(title: tagset) + visit tag_set_path(tagset) +end + +When "the cache for the tag set {string} is expired" do |tagset| + tag_set_id = OwnedTagSet.find_by(title: tagset).tag_set_id + ActionController::Base.new.expire_fragment("tag_set_show_#{tag_set_id}") + TagSet::TAG_TYPES.each do |type| + ActionController::Base.new.expire_fragment("tag_set_show_#{tag_set_id}_#{type}") + end +end + +When /^I view associations for a tag set that does not exist/ do + id = 1 + tagset = OwnedTagSet.find_by(id: id) + tagset.destroy if tagset + visit tag_set_associations_path(id) +end + +When /^I expand the unassociated characters and relationships$/ do + find('button[data-action-target="#list_for_unassociated_char_and_rel"]').click +end + +Then /^"([^\"]*)" should be associated with the "([^\"]*)" fandom "([^\"]*)"$/ do |tag, fandom_type, fandom_name| + name = fandom_name.tr(" ", "_") + type = fandom_type.tr(" ", "_") + step %{I should see "#{tag}" within "ol#list_for_fandom_#{name}_in_#{type}_Fandoms li"} +end + +Then /^"([^\"]*)" should be an unassociated tag$/ do |tag| + step %{I should see "#{tag}" within "ol#list_for_unassociated_char_and_rel"} +end diff --git a/features/step_definitions/tag_steps.rb b/features/step_definitions/tag_steps.rb new file mode 100644 index 0000000..7bf1578 --- /dev/null +++ b/features/step_definitions/tag_steps.rb @@ -0,0 +1,492 @@ +### GIVEN + +Given /^I have no tags$/ do + # Tag.delete_all if Tag.count > 1 + # silence_warnings {load "#{Rails.root}/app/models/fandom.rb"} +end + +Given /^basic tags$/ do + step %{the default ratings exist} + step %{the basic warnings exist} + Fandom.where(name: "No Fandom", canonical: true).first_or_create + step %{the basic categories exist} + step %{all indexing jobs have been run} +end + +Given /^the default ratings exist$/ do + # TODO: "Not Rated" should be adult, to match the behavior in production, but + # there are many tests that rely on being able to view a "Not Rated" work + # without clicking through the adult content warning. So until those tests + # are fixed, we leave "Not Rated" as a non-adult rating. + [ + ArchiveConfig.RATING_DEFAULT_TAG_NAME, + ArchiveConfig.RATING_GENERAL_TAG_NAME, + ArchiveConfig.RATING_TEEN_TAG_NAME + ].each do |rating| + Rating.find_or_create_by!(name: rating, canonical: true) + end + + [ + ArchiveConfig.RATING_MATURE_TAG_NAME, + ArchiveConfig.RATING_EXPLICIT_TAG_NAME + ].each do |rating| + Rating.find_or_create_by!(name: rating, canonical: true, adult: true) + end +end + +Given /^the basic warnings exist$/ do + warnings = [ArchiveConfig.WARNING_DEFAULT_TAG_NAME, + ArchiveConfig.WARNING_NONE_TAG_NAME] + warnings.each do |warning| + ArchiveWarning.find_or_create_by!(name: warning, canonical: true) + end +end + +Given /^all warnings exist$/ do + step %{the basic warnings exist} + warnings = [ArchiveConfig.WARNING_VIOLENCE_TAG_NAME, + ArchiveConfig.WARNING_DEATH_TAG_NAME, + ArchiveConfig.WARNING_NONCON_TAG_NAME, + ArchiveConfig.WARNING_CHAN_TAG_NAME] + warnings.each do |warning| + ArchiveWarning.find_or_create_by!(name: warning, canonical: true) + end +end + +Given /^the basic categories exist$/ do + %w(Gen Other F/F Multi F/M M/M).each do |category| + Category.find_or_create_by!(name: category, canonical: true) + end +end + +Given "a set of tags for tag sort by use exists" do + { + "10 uses" => 10, + "8 uses" => 8, + "also 8 uses" => 8, + "5 uses" => 5, + "2 uses" => 2, + "0 uses" => 0 + }.each do |freeform, uses| + tag = Freeform.find_or_create_by_name(freeform.dup) + tag.taggings_count = uses + end + + step "all indexing jobs have been run" + step "the periodic tag count task is run" +end + +Given /^I have a canonical "([^\"]*)" fandom tag named "([^\"]*)"$/ do |media, fandom| + fandom = Fandom.find_or_create_by_name(fandom) + fandom.update!(canonical: true) + media = Media.find_or_create_by_name(media) + media.update!(canonical: true) + fandom.add_association media +end + +Given "I add the fandom {string} to the tag/character {string}" do |fandom, tag| + tag = Tag.find_or_create_by(name: tag) + fand = Fandom.find_or_create_by_name(fandom) + tag.add_association(fand) +end + +Given /^a canonical character "([^\"]*)" in fandom "([^\"]*)"$/ do |character, fandom| + char = Character.where(name: character, canonical: true).first_or_create + fand = Fandom.where(name: fandom, canonical: true).first_or_create + char.add_association(fand) +end + +Given /^a canonical relationship "([^\"]*)" in fandom "([^\"]*)"$/ do |relationship, fandom| + rel = Relationship.where(name: relationship, canonical: true).first_or_create + fand = Fandom.where(name: fandom, canonical: true).first_or_create + rel.add_association(fand) +end + +Given /^a (non-?canonical|canonical) (\w+) "([^\"]*)"$/ do |canonical_status, tag_type, tag_name| + t = tag_type.classify.constantize.find_or_create_by_name(tag_name) + t.canonical = canonical_status == "canonical" + t.save +end + +Given "a non-canonical character {string} in fandom {string}" do |character_name, fandom_name| + character = Character.where(name: character_name).first_or_create + character.update!(canonical: false) + fandom = Fandom.where(name: fandom_name).first_or_create + character.add_association(fandom) +end + +Given /^a synonym "([^\"]*)" of the tag "([^\"]*)"$/ do |synonym, merger| + merger = Tag.find_by_name(merger) + merger_type = merger.type + + synonym = merger_type.classify.constantize.find_or_create_by(name: synonym) + synonym.reload.merger = merger + synonym.save +end + +Given /^"([^\"]*)" is a metatag of the (\w+) "([^\"]*)"$/ do |metatag, tag_type, tag| + tag = tag_type.classify.constantize.find_or_create_by_name(tag) + metatag = tag_type.classify.constantize.find_or_create_by_name(metatag) + tag.meta_tags << metatag + tag.save +end + +Given /^I am logged in as a tag wrangler$/ do + username = "wrangler" + step %{I am logged in as "#{username}"} + user = User.find_by(login: username) + role = Role.find_or_create_by(name: "tag_wrangler") + user.roles = [role] +end + +Given /^the tag wrangler "([^\"]*)" with password "([^\"]*)" is wrangler of "([^\"]*)"$/ do |user, password, fandomname| + tw = User.find_by(login: user) + + if tw.blank? + tw = FactoryBot.create(:user, login: user, password: password) + else + tw.skip_password_change_notification! + tw.password = password + tw.password_confirmation = password + tw.save + end + + role = Role.find_or_create_by(name: "tag_wrangler") + tw.roles = [role] + + step %{I am logged in as "#{user}" with password "#{password}"} + + fandom = Fandom.where(name: fandomname, canonical: true).first_or_create + visit tag_wranglers_url + fill_in "tag_fandom_string", with: fandomname + click_button "Assign" +end + +Given /^a tag "([^\"]*)" with(?: (\d+))? comments$/ do |tagname, n_comments| + tag = Fandom.find_or_create_by_name(tagname) + + n_comments = 3 if n_comments.blank? || n_comments.zero? + FactoryBot.create_list(:comment, n_comments.to_i, :on_tag, commentable: tag) +end + +Given /^(?:a|the) canonical(?: "([^"]*)")? fandom "([^"]*)" with (\d+) works$/ do |media, tag_name, number_of_works| + fandom = FactoryBot.create(:fandom, name: tag_name, canonical: true) + fandom.add_association(Media.find_by(name: media)) if media.present? + number_of_works.to_i.times do + FactoryBot.create(:work, fandom_string: tag_name) + end + step %(the periodic filter count task is run) +end + +Given /^a period-containing tag "([^\"]*)" with(?: (\d+))? comments$/ do |tagname, n_comments| + tag = Fandom.find_or_create_by_name(tagname) + + n_comments = 3 if n_comments.blank? || n_comments.zero? + FactoryBot.create_list(:comment, n_comments.to_i, :on_tag, commentable: tag) +end + +Given /^the unsorted tags setup$/ do + 30.times do |i| + UnsortedTag.find_or_create_by_name("unsorted tag #{i}") + end +end + +Given /^the tag wrangling setup$/ do + step %{basic tags} + step %{a media exists with name: "TV Shows", canonical: true} + step %{I am logged in as a random user} + step %{I post the work "Revenge of the Sith 2" with fandom "Star Wars, Stargate SG-1" with character "Daniel Jackson" with second character "Jack O'Neil" with rating "Not Rated" with relationship "JackDaniel"} + step %{The periodic tag count task is run} + step %{all indexing jobs have been run} + step %{I flush the wrangling sidebar caches} +end + +Given /^I have posted a Wrangling Guideline?(?: titled "([^\"]*)")?$/ do |title| + step %{I am logged in as a "tag_wrangling" admin} + visit new_wrangling_guideline_path + if title + fill_in("Guideline text", with: "This is a page about how we wrangle things.") + fill_in("Title", with: title) + click_button("Post") + else + step %{I make a 1st Wrangling Guideline} + end +end + +Given(/^the following typed tags exists$/) do |table| + table.hashes.each do |hash| + type = hash["type"].downcase.to_sym + hash.delete("type") + FactoryBot.create(type, hash) + end +end + +Given /^the tag "([^"]*)" does not exist$/ do |tag_name| + tag = Tag.find_by_name(tag_name) + tag.destroy if tag.present? +end + +Given "a zero width space tag exists" do + blank_tag = FactoryBot.build(:character, name: ["200B".hex].pack("U")) + blank_tag.save!(validate: false) +end + +Given "I create the canonical media tag {string}" do |name| + step %{I am logged in as a "tag_wrangling" admin} + visit(new_tag_path) + fill_in("Name", with: name) + choose("Media") + check("Canonical") + click_button("Create Tag") +end + +Given "I create the non-canonical media tag {string}" do |name| + step %{I am logged in as a "tag_wrangling" admin} + visit(new_tag_path) + fill_in("Name", with: name) + choose("Media") + click_button("Create Tag") +end + +Given "I recategorize the {string} fandom as a {string} tag" do |name, tag_type| + step %{I am logged in as a "tag_wrangling" admin} + visit(edit_tag_path(Fandom.create(name: name))) + select(tag_type, from: "tag_type") + check("Canonical") + click_button("Save changes") +end + +### WHEN + +When /^the periodic tag count task is run$/i do + RedisJobSpawner.perform_now("TagCountUpdateJob") +end + +When /^the periodic filter count task is run$/i do + FilterCount.update_counts_for_small_queue + FilterCount.update_counts_for_large_queue +end + +When /^I check the canonical option for the tag "([^"]*)"$/ do |tagname| + tag = Tag.find_by(name: tagname) + check("canonicals_#{tag.id}") +end + +When /^I select "([^"]*)" for the unsorted tag "([^"]*)"$/ do |type, tagname| + tag = Tag.find_by(name: tagname) + select(type, from: "tags[#{tag.id}]") +end + +When /^I check the (?:mass )?wrangling option for "([^"]*)"$/ do |tagname| + tag = Tag.find_by(name: tagname) + check("selected_tags_#{tag.id}") +end + +When "I edit the tag {string}" do |tag| + tag = Tag.find_by!(name: tag) + visit edit_tag_path(tag) +end + +When /^I view the tag "([^\"]*)"$/ do |tag| + tag = Tag.find_by!(name: tag) + visit tag_path(tag) +end + +When /^I create the fandom "([^\"]*)" with id (\d+)$/ do |name, id| + tag = Fandom.new(name: name) + tag.id = id.to_i + tag.canonical = true + tag.save +end + +When /^I set up the comment "([^"]*)" on the tag "([^"]*)"$/ do |comment_text, tag| + tag = Tag.find_by!(name: tag) + visit tag_url(tag) + click_link(" comment") + fill_in("Comment", with: comment_text) +end + +When /^I post the comment "([^"]*)" on the tag "([^"]*)"$/ do |comment_text, tag| + step "I set up the comment \"#{comment_text}\" on the tag \"#{tag}\"" + click_button("Comment") +end + +When /^I post the comment "([^"]*)" on the period-containing tag "([^"]*)"$/ do |comment_text, tag| + step "I am on the search tags page" + fill_in("tag_search_name", with: tag) + click_button "Search tags" + click_link(tag) + click_link(" comment") + fill_in("Comment", with: comment_text) + click_button("Comment") +end + +When /^I post the comment "([^"]*)" on the tag "([^"]*)" via web$/ do |comment_text, tag| + step %{I view the tag "#{tag}"} + step %{I follow " comments"} + step %{I fill in "Comment" with "#{comment_text}"} + step %{I press "Comment"} + step %{I should see "Comment created!"} +end + +When /^I add "([^\"]*)" to my favorite tags$/ do |tag| + step %{I view the "#{tag}" works index} + step %{I press "Favorite Tag"} +end + +When /^I remove "([^\"]*)" from my favorite tags$/ do |tag| + step %{I view the "#{tag}" works index} + step %{I press "Unfavorite Tag"} +end + +When /^the tag "([^\"]*)" is decanonized$/ do |tag| + tag = Tag.find_by!(name: tag) + tag.canonical = false + tag.save +end + +When /^the tag "([^"]*)" is canonized$/ do |tag| + tag = Tag.find_by!(name: tag) + tag.canonical = true + tag.save +end + +When /^I make a(?: (\d+)(?:st|nd|rd|th)?)? Wrangling Guideline$/ do |n| + n = 1 if n.zero? + visit new_wrangling_guideline_path + fill_in("Guideline text", with: "Number #{n} posted Wrangling Guideline, this is.") + fill_in("Title", with: "Number #{n} Wrangling Guideline") + click_button("Post") +end + +When /^(\d+) Wrangling Guidelines? exists?$/ do |n| + (1..n).each do |i| + FactoryBot.create(:wrangling_guideline, id: i) + end +end + +When /^I flush the wrangling sidebar caches$/ do + [Fandom, Character, Relationship, Freeform].each do |klass| + Rails.cache.delete("/wrangler/counts/sidebar/#{klass}") + end +end + +When /^I syn the tag "([^"]*)" to "([^"]*)"$/ do |syn, merger| + syn = Tag.find_by(name: syn) + visit edit_tag_path(syn) + fill_in("Synonym of", with: merger) + click_button("Save changes") +end + +When /^I de-syn the tag "([^"]*)" from "([^"]*)"$/ do |syn, merger| + merger = Tag.find_by(name: merger) + syn_id = Tag.find_by(name: syn).id + visit edit_tag_path(merger) + check("child_Merger_associations_to_remove_#{syn_id}") + click_button("Save changes") +end + +When /^I subtag the tag "([^"]*)" to "([^"]*)"$/ do |subtag, metatag| + subtag = Tag.find_by(name: subtag) + visit edit_tag_path(subtag) + fill_in("Add MetaTags:", with: metatag) + click_button("Save changes") +end + +When /^I remove the metatag "([^"]*)" from "([^"]*)"$/ do |metatag, subtag| + subtag = Tag.find_by(name: subtag) + metatag_id = Tag.find_by(name: metatag).id + visit edit_tag_path(subtag) + check("parent_MetaTag_associations_to_remove_#{metatag_id}") + click_button("Save changes") +end + +When /^I view the (canonical|synonymous|unfilterable|unwrangled|unwrangleable) (character|relationship|freeform) bin for "(.*?)"$/ do |status, type, tag| + visit wrangle_tag_path(Tag.find_by(name: tag), show: type.pluralize, status: status) +end + +When "I select {string} from the {string} wrangling assigment dropdown" do |login, tagname| + tag = Tag.find_by(name: tagname) + select(login, from: "assignments_#{tag.id}_") +end + +### THEN + +Then /^I should see the tag wrangler listed as an editor of the tag$/ do + step %{I should see "wrangler" within "fieldset dl"} +end + +Then /^I should see the tag search result "([^\"]*)"(?: within "([^"]*)")?$/ do |result, selector| + with_scope(selector) do + page.has_text?(result) + end +end + +Then /^I should not see the tag search result "([^\"]*)"(?: within "([^"]*)")?$/ do |result, selector| + with_scope(selector) do + page.has_no_text?(result) + end +end + +Then /^the ([\d]+)(?:st|nd|rd|th) tag result should contain "(.*?)"$/ do |n, text| + selector = "ol.tag > li:nth-of-type(#{n})" + with_scope(selector) do + expect(page).to have_content(text) + end +end + +Then /^"([^\"]*)" should not be a tag wrangler$/ do |username| + user = User.find_by(login: username) + user.tag_wrangler.should be_falsey +end + +Then /^"([^\"]*)" should be assigned to the wrangler "([^\"]*)"$/ do |fandom, username| + user = User.find_by(login: username) + fandom = Fandom.find_by(name: fandom) + assignment = WranglingAssignment.where(user_id: user.id, fandom_id: fandom.id ).first + assignment.should_not be_nil +end + +Then /^"([^\"]*)" should not be assigned to the wrangler "([^\"]*)"$/ do |fandom, username| + user = User.find_by(login: username) + fandom = Fandom.find_by(name: fandom) + assignment = WranglingAssignment.where(user_id: user.id, fandom_id: fandom.id ).first + assignment.should be_nil +end + +Then(/^the "([^"]*)" tag should be a "([^"]*)" tag$/) do |tagname, tag_type| + tag = Tag.find_by(name: tagname) + assert tag.type == tag_type +end + +Then "the {string} tag should be an unsorted tag" do |tagname| + tag = Tag.find_by(name: tagname) + expect(tag).to be_a(UnsortedTag) +end + +Then(/^the "([^"]*)" tag should (be|not be) canonical$/) do |tagname, canonical| + tag = Tag.find_by(name: tagname) + expected = canonical == "be" + assert tag.canonical == expected +end + +Then(/^the "([^"]*)" tag should (be|not be) unwrangleable$/) do |tagname, unwrangleable| + tag = Tag.find_by(name: tagname) + expected = unwrangleable == "be" + assert tag.unwrangleable == expected +end + +Then(/^the "([^"]*)" tag should be in the "([^"]*)" fandom$/) do |tagname, fandom_name| + tag = Tag.find_by(name: tagname) + fandom = Fandom.find_by(name: fandom_name) + assert tag.has_parent?(fandom) +end + +Then(/^show me what the tag "([^"]*)" is like$/) do |tagname| + tag = Tag.find_by(name: tagname) + puts tag.inspect +end + +Then "no tag is scheduled for count update from now on" do + expect_any_instance_of(Tag).not_to receive(:update_filters_for_filterables) +end diff --git a/features/step_definitions/user_steps.rb b/features/step_definitions/user_steps.rb new file mode 100644 index 0000000..cd0916d --- /dev/null +++ b/features/step_definitions/user_steps.rb @@ -0,0 +1,351 @@ +DEFAULT_USER = "testuser" +DEFAULT_PASSWORD = "password" +NEW_USER = "newuser" + +# GIVEN + +Given /^I have no users$/ do + User.delete_all +end + +Given /I have an orphan account/ do + user = FactoryBot.create(:user, login: 'orphan_account') +end + +Given /the following activated users? exists?/ do |table| + table.hashes.each do |hash| + user = FactoryBot.create(:user, hash) + user.pseuds.first.add_to_autocomplete + step %{confirmation emails have been delivered} + end +end + +Given /the following users exist with BCrypt encrypted passwords/ do |table| + table.hashes.each do |hash| + user = FactoryBot.create(:user, hash) + user.pseuds.first.add_to_autocomplete + + # salt = Authlogic::Random.friendly_token + # same as + salt = SecureRandom.urlsafe_base64(15) + # encrypted_password = Authlogic::CryptoProviders::BCrypt.encrypt(hash[:password], salt) + # same as + encrypted_password = BCrypt::Password.create( + [hash[:password], salt].flatten.join, + cost: ArchiveConfig.BCRYPT_COST || 14) + + user.update!( + password_salt: salt, + encrypted_password: encrypted_password + ) + end +end + +Given /the following users exist with SHA-512 encrypted passwords/ do |table| + table.hashes.each do |hash| + user = FactoryBot.create(:user, hash) + user.pseuds.first.add_to_autocomplete + + # salt = Authlogic::Random.friendly_token + # same as + salt = SecureRandom.urlsafe_base64(15) + # encrypted_password = Authlogic::CryptoProviders::Sha512.encrypt(hash[:password], salt) + # same as + encrypted_password = [hash[:password], salt].flatten.join + 20.times { encrypted_password = Digest::SHA512.hexdigest(encrypted_password) } + + user.update!( + password_salt: salt, + encrypted_password: encrypted_password + ) + end +end + +Given /the following activated users with private work skins/ do |table| + table.hashes.each do |hash| + user = FactoryBot.create(:user, hash) + FactoryBot.create(:work_skin, :private, author: user, title: "#{user.login.titleize}'s Work Skin") + step %{confirmation emails have been delivered} + end +end + +Given /the following activated tag wranglers? exists?/ do |table| + table.hashes.each do |hash| + user = FactoryBot.create(:user, hash) + role = Role.find_or_create_by(name: "tag_wrangler") + user.roles = [role] + user.pseuds.first.add_to_autocomplete + end +end + +Given /^the user "([^"]*)" exists and is activated$/ do |login| + find_or_create_new_user(login, DEFAULT_PASSWORD) + step %{confirmation emails have been delivered} +end + +Given /^the user "([^"]*)" exists and is not activated$/ do |login| + find_or_create_new_user(login, DEFAULT_PASSWORD, activate: false) +end + +Given /^the user "([^"]*)" exists and has the role "([^"]*)"/ do |login, role| + user = find_or_create_new_user(login, DEFAULT_PASSWORD) + role = Role.find_or_create_by(name: role) + user.roles = [role] +end + +Given "the role {string}" do |role| + FactoryBot.create(:role, name: role) +end + +Given /^I am logged in as "([^"]*)" with password "([^"]*)"$/ do |login, password| + user = find_or_create_new_user(login, password) + step("I start a new session") + step %{I am on the homepage} + find_link('login-dropdown').click + + fill_in "Username or email:", with: login + fill_in "Password:", with: password + check "Remember Me" + click_button "Log In" + step %{I should see "Hi, #{login}!" within "#greeting"} + step %{confirmation emails have been delivered} +end + +Given /^I am logged in as "([^"]*)"$/ do |login| + step(%{I am logged in as "#{login}" with password "#{DEFAULT_PASSWORD}"}) +end + +Given "I am logged in as a new user {string}" do |login| + step(%{I am logged in as "#{login}"}) + user = User.find_by(login: login) + user.created_at = Time.current + user.confirmed_at = Time.current + user.save! +end + +Given /^I am logged in$/ do + step(%{I am logged in as "#{DEFAULT_USER}"}) +end + +Given /^I am logged in as a random user$/ do + name = "testuser#{User.count + 1}" + step(%{I am logged in as "#{name}" with password "#{DEFAULT_PASSWORD}"}) + step(%{confirmation emails have been delivered}) +end + +Given /^user "([^"]*)" is banned$/ do |login| + user = find_or_create_new_user(login, DEFAULT_PASSWORD) + user.banned = true + user.save +end + +Given /^I start a new session$/ do + page.driver.reset! +end + +Given "the username {string} is on the forbidden list" do |username| + allow(ArchiveConfig).to receive(:FORBIDDEN_USERNAMES).and_return([username]) +end + +# TODO: This should eventually be removed in favor of the "I log out" step, +# which does the same thing (but has a shorter and less passive name). +Given /^I am logged out$/ do + step(%{I follow "Log Out"}) +end + +Given /^I log out$/ do + step(%{I follow "Log Out"}) +end + +Given /^"([^"]*)" deletes their account/ do |username| + visit user_path(username) + step(%{I follow "Profile"}) + step(%{I follow "Delete My Account"}) +end + +Given /^I am a visitor$/ do + step "I start a new session" +end + +Given(/^I coauthored the work "(.*?)" as "(.*?)" with "(.*?)"$/) do |title, login, coauthor| + step %{basic tags} + author1 = User.find_by(login: login).default_pseud + author1.user.preference.update!(allow_cocreator: true) + author2 = User.find_by(login: coauthor).default_pseud + author2.user.preference.update!(allow_cocreator: true) + work = FactoryBot.create(:work, authors: [author1, author2], title: title) + work.creatorships.unapproved.each(&:accept!) +end + +Given /^"(.*?)" has an empty series "(.*?)"$/ do |login, title| + series = Series.new(title: title) + series.creatorships.build(pseud: User.find_by(login: login).default_pseud) + series.save +end + +Given "the user {string} is a protected user" do |login| + user = User.find_by(login: login) + user.roles = [Role.find_or_create_by(name: "protected_user")] +end + +Given "the user {string} has the no resets role" do |login| + user = User.find_by(login: login) + user.roles = [Role.find_or_create_by(name: "no_resets")] +end + +Given "the user {string} with the email {string} exists" do |login, email| + FactoryBot.create(:user, login: login, email: email) +end + +Given "the user {string} was created using an invitation" do |login| + invitation = FactoryBot.create(:invitation) + FactoryBot.create(:user, login: login, invitation: invitation) +end + +# WHEN + +When /^I follow the link for "([^"]*)" first invite$/ do |login| + user = User.find_by(login: login) + invite = user.invitations.first + step(%{I follow "#{invite.token}"}) +end + +When /^the user "([^\"]*)" has failed to log in (\d+) times$/ do |login, count| + user = User.find_by(login: login) + user.update!(failed_attempts: count.to_i) +end + +When "I fill in the sign up form with valid data" do + step(%{I fill in "user_registration_login" with "#{NEW_USER}"}) + step(%{I fill in "user_registration_email" with "test@archiveofourown.org"}) + step(%{I fill in "user_registration_password" with "password1"}) + step(%{I fill in "user_registration_password_confirmation" with "password1"}) + step(%{I check "user_registration_age_over_13"}) + step(%{I check "user_registration_data_processing"}) + step(%{I check "user_registration_terms_of_service"}) +end + +When /^I try to delete my account as (.*)$/ do |login| + step(%{I go to #{login}\'s user page}) + step(%{I follow "Profile"}) + step(%{I follow "Delete My Account"}) +end + +When /^I try to delete my account$/ do + step(%{I try to delete my account as #{DEFAULT_USER}}) +end + +When /^I visit the change username page for (.*)$/ do |login| + user = User.find_by(login: login) + visit change_username_user_path(user) +end + +When "I visit the change email page for {word}" do |login| + user = User.find_by(login: login) + visit change_email_user_path(user) +end + +When /^the user "(.*?)" accepts all co-creator requests$/ do |login| + # To make sure that we don't have caching issues with the byline: + step %{I wait 1 second} + user = User.find_by(login: login) + user.creatorships.unapproved.each(&:accept!) +end + +When "I request a password reset for {string}" do |login| + step(%{I am on the login page}) + step(%{I follow "Reset password"}) + step(%{I fill in "Email address or username" with "#{login}"}) + step(%{I press "Reset Password"}) +end + +# THEN + +Then "I should get the error message for wrong username or password" do + step(%{I should see "The password or username you entered doesn't match our records. Please try again"}) +end + +Then /^I should get an activation email for "(.*?)"$/ do |login| + step(%{1 email should be delivered}) + step(%{the email should contain "Welcome to the Archive of Our Own,"}) + step(%{the email should contain "#{login}"}) + step(%{the email should contain "activate your account"}) +end + +Then /^I should get a new user activation email$/ do + step(%{I should get an activation email for "#{NEW_USER}"}) +end + +Then /^a user account should exist for "(.*?)"$/ do |login| + user = User.find_by(login: login) + expect(user).to be_present +end + +Then /^a user account should not exist for "(.*)"$/ do |login| + user = User.find_by(login: login) + expect(user).to be_blank +end + +Then /^a new user account should exist$/ do + step %{a user account should exist for "#{NEW_USER}"} +end + +Then /^I should be logged out$/ do + step %{I should not see "Log Out"} + step %{I should see "Log In"} +end + +def get_work_name(age, classname, name) + klass = classname.classify.constantize + owner = (classname == "user") ? klass.find_by(login: name) : klass.find_by(name: name) + if age == "most recent" + owner.works.order("revised_at DESC").first.title + elsif age == "oldest" + owner.works.order("revised_at DESC").last.title + end +end + +def get_series_name(age, classname, name) + klass = classname.classify.constantize + owner = (classname == "user") ? klass.find_by(login: name) : klass.find_by(name: name) + if age == "most recent" + owner.series.order("updated_at DESC").first.title + elsif age == "oldest" + owner.series.order("updated_at DESC").last.title + end +end + +Then /^I should see the (most recent|oldest) (work|series) for (pseud|user) "([^"]*)"/ do |age, type, classname, name| + title = (type == "work" ? get_work_name(age, classname, name) : get_series_name(age, classname, name)) + step %{I should see "#{title}"} +end + +Then /^I should not see the (most recent|oldest) (work|series) for (pseud|user) "([^"]*)"/ do |age, type, classname, name| + title = (type == "work" ? get_work_name(age, classname, name) : get_series_name(age, classname, name)) + step %{I should not see "#{title}"} +end + +When /^I change my username to "([^"]*)"/ do |new_name| + step %{I follow "My Preferences"} + step %{I follow "Change Username"} + fill_in("New username", with: new_name) + fill_in("Password", with: "password") + click_button("Change Username") + step %{I should get confirmation that I changed my username} +end + +Then /^I should get confirmation that I changed my username$/ do + step(%{I should see "Your username has been successfully updated."}) + step(%{1 email should be delivered}) + step(%{the email should contain "The username for your .* has been changed to"}) +end + +Then /^the user "([^"]*)" should be activated$/ do |login| + user = User.find_by(login: login) + expect(user).to be_active +end + +Then "I should see the invitation id for the user {string}" do |login| + invitation_id = User.find_by(login: login).invitation.id + step %{I should see "Invitation: #{invitation_id}"} +end diff --git a/features/step_definitions/web_steps.rb b/features/step_definitions/web_steps.rb new file mode 100644 index 0000000..65b27cd --- /dev/null +++ b/features/step_definitions/web_steps.rb @@ -0,0 +1,289 @@ +require 'uri' +require 'cgi' + +module WithinHelpers + def with_scope(locator) + locator ? within(locator) { yield } : yield + end +end +World(WithinHelpers) + +When /^I am in (.*) browser$/ do |name| + Capybara.session_name = name +end + +Given /^(?:|I )am on (.+)$/ do |page_name| + visit path_to(page_name) +end + +When /^I take a screenshot$/ do + screenshot_and_save_page +end + +When /^I clear the network traffic$/ do + page.driver.clear_network_traffic +end + +When /^(?:|I )go to (.+)$/ do |page_name| + visit path_to(page_name) +end + +When /^(?:|I )press "([^"]*)"(?: within "([^"]*)")?$/ do |button, selector| + with_scope(selector) do + click_button(button) + end +end + +When /^(?:|I )follow "([^"]*)"(?: within "([^"]*)")?$/ do |link, selector| + with_scope(selector) do + click_link(link) + end +end + +When /^(?:|I )follow '([^']*)'(?: within "([^"]*)")?$/ do |link, selector| + with_scope(selector) do + click_link(link) + end +end + +When /^(?:|I )fill in "([^"]*)" with "([^"]*)"(?: within "([^"]*)")?$/ do |field, value, selector| + with_scope(selector) do + fill_in(field, with: value) + end +end + +When /^(?:|I )fill in "([^"]*)" for "([^"]*)"(?: within "([^"]*)")?$/ do |value, field, selector| + with_scope(selector) do + fill_in(field, with: value) + end +end + +# Use this to fill in an entire form with data from a table. Example: +# +# When I fill in the following: +# | Account Number | 5002 | +# | Expiry date | 2009-11-01 | +# | Note | Nice guy | +# | Wants Email? | | +# +# TODO: Add support for checkbox, select og option +# based on naming conventions. +# +When /^(?:|I )fill in the following(?: within "([^"]*)")?:$/ do |selector, fields| + with_scope(selector) do + fields.rows_hash.each do |name, value| + step %{I fill in "#{name}" with "#{value}"} + end + end +end + +When /^(?:|I )select "([^"]*)" from "([^"]*)"(?: within "([^"]*)")?$/ do |value, field, selector| + with_scope(selector) do + select(value, from: field) + end +end + +When /^(?:|I )check "([^"]*)"(?: within "([^"]*)")?$/ do |field, selector| + with_scope(selector) do + check(field) + end +end + +When /^(?:|I )uncheck "([^"]*)"(?: within "([^"]*)")?$/ do |field, selector| + with_scope(selector) do + uncheck(field) + end +end + +When /^(?:|I )choose "(.*)"$/ do |field| + choose(field) +end + +When /^(?:|I )attach the file "([^"]*)" to "([^"]*)"(?: within "([^"]*)")?$/ do |path, field, selector| + with_scope(selector) do + attach_file(field, path) + end +end + +Then /^(?:|I )should see JSON:$/ do |expected_json| + require 'json' + expected = JSON.pretty_generate(JSON.parse(expected_json)) + actual = JSON.pretty_generate(JSON.parse(response.body)) + expected.should == actual +end + +Then /^(?:|I )should see "([^"]*)"(?: within "([^"]*)")?$/ do |text, selector| + with_scope(selector) do + page.should have_content(text) + end +end + +Then /^(?:|I )should see the raw text "([^"]*)"(?: within "([^"]*)")?$/ do |text, selector| + with_scope(selector) do + page.body.should =~ /#{Regexp.escape(text)}/m + end +end + +Then /^(?:|I )should see '([^']*)'(?: within "([^"]*)")?$/ do |text, selector| + with_scope(selector) do + if page.respond_to? :should + page.should have_content(text) + else + assert page.has_content?(text) + end + end +end + +Then /^(?:|I )should see \/([^\/]*)\/(?: within "([^"]*)")?$/ do |regexp, selector| + regexp = Regexp.new(regexp) + with_scope(selector) do + if page.respond_to? :should + page.should have_xpath('//*', text: regexp) + else + assert page.has_xpath?('//*', text: regexp) + end + end +end + +Then /^(?:|I )should not see "([^"]*)"(?: within "([^"]*)")?$/ do |text, selector| + with_scope(selector) do + if page.respond_to? :should + page.should have_no_content(text) + else + assert page.has_no_content?(text) + end + end +end + +Then /^(?:|I )should not see '([^']*)'(?: within "([^"]*)")?$/ do |text, selector| + with_scope(selector) do + if page.respond_to? :should + page.should have_no_content(text) + else + assert page.has_no_content?(text) + end + end +end + +Then /^(?:|I )should not see \/([^\/]*)\/(?: within "([^"]*)")?$/ do |regexp, selector| + regexp = Regexp.new(regexp) + with_scope(selector) do + if page.respond_to? :should + page.should have_no_xpath('//*', text: regexp) + else + assert page.has_no_xpath?('//*', text: regexp) + end + end +end + +Then /"(.*)" should appear before "(.*)"/ do |first_example, second_example| + page.body.should =~ /#{first_example}.*#{second_example}/m +end + +Then /^the "([^"]*)" field(?: within "([^"]*)")? should contain "([^"]*)"$/ do |field, selector, value| + with_scope(selector) do + field = find_field(field) + field_value = (field.tag_name == 'textarea') ? field.text : field.value + if field_value.respond_to? :should + field_value.should =~ /#{value}/ + else + assert_match(/#{value}/, field_value) + end + end +end + +Then /^the field labeled "([^"]*)" should contain "([^"]*)"$/ do |label, value| + field = find_field(label) + field_value = (field.tag_name == 'textarea') ? field.text : field.value + if field_value.respond_to? :should + field_value.should =~ /#{value}/ + else + assert_match(/#{value}/, field_value) + end +end + +Then /^the "([^"]*)" field(?: within "([^"]*)")? should not contain "([^"]*)"$/ do |field, selector, value| + with_scope(selector) do + field = find_field(field) + field_value = (field.tag_name == 'textarea') ? field.text : field.value + if field_value.respond_to? :should_not + field_value.should_not =~ /#{value}/ + else + assert_no_match(/#{value}/, field_value) + end + end +end + +Then /^the "(.*?)" (checkbox|radio button)(?: within "(.*?)")? should be checked( and disabled)?$/ do |label, _input_type, selector, disabled| + with_scope(selector) do + assert has_checked_field?(label, disabled: disabled.present?) + end +end + +Then /^the "(.*?)" checkbox(?: within "(.*?)")? should not be checked$/ do |label, selector| + with_scope(selector) do + assert has_unchecked_field?(label) + end +end + +Then /^(?:|I )should be on (.+)$/ do |page_name| + current_path = URI.parse(current_url).path + if current_path.respond_to? :should + current_path.should == path_to(page_name) + else + assert_equal path_to(page_name), current_path + end +end + +Then /^(?:|The )url should include (.+)$/ do |url| + current_url.should include(url) +end + +Then "the url should not include {string}" do |url| + expect(current_url).not_to include(url) +end + +Then /^(?:|I )should have the following query string:$/ do |expected_pairs| + query = URI.parse(current_url).query + actual_params = query ? CGI.parse(query) : {} + expected_params = {} + expected_pairs.rows_hash.each_pair{|k,v| expected_params[k] = v.split(',')} + + if actual_params.respond_to? :should + actual_params.should == expected_params + else + assert_equal expected_params, actual_params + end +end + +Then /^I should download a ([^"]*) file with(?: (\d+) rows and)? the header row "(.*?)"$/ do |type, rows, header| + page.response_headers['Content-Disposition'].should =~ /attachment; filename=.*?\.#{type}/i + page.response_headers['Content-Type'].should =~ /\/#{type}/i + body_without_bom = page.body.encode("UTF-8").delete!("\xEF\xBB\xBF") + csv = CSV.parse(body_without_bom, col_sep: "\t") # array of arrays + expect(csv.first.join(" ")).to eq(header) + expect(csv.size).to eq(rows) unless rows.blank? || rows.zero? +end + +Then /^show me the page$/ do + save_and_open_page + sleep 120 +end + +Then /^show me the network traffic$/ do + puts page.driver.network_traffic.to_yaml +end + +Then /^cookie "([^\"]*)" should be like "([^\"]*)"$/ do |cookie, value| + cookie_value = Capybara.current_session.driver.request.cookies.[](cookie) + if cookie_value.respond_to? :should + cookie_value.should =~ /#{value}/ + else + assert cookie_value =~ /#{value}/ + end +end + +Then /^cookie "([^"]*)" should be deleted$/ do |cookie| + cookie_value = Capybara.current_session.driver.request.cookies.[](cookie) + assert cookie_value.nil? +end diff --git a/features/step_definitions/work_deletion_steps.rb b/features/step_definitions/work_deletion_steps.rb new file mode 100644 index 0000000..538fb54 --- /dev/null +++ b/features/step_definitions/work_deletion_steps.rb @@ -0,0 +1,11 @@ +### THEN + +Then /^"(.+)" should be notified by email about the deletion of "(.+)"$/ do |user, title| + step %{1 email should be delivered to "#{user}"} + step %{the email should contain "Your work"} + step %{the email should contain "#{title}"} + step %{the email should contain "was deleted at your request"} + step %{the email should contain "If you have questions, please"} + step %{the email should link to the support page} + step %{the email should contain "Attached is a copy of your work for your reference."} +end diff --git a/features/step_definitions/work_download_steps.rb b/features/step_definitions/work_download_steps.rb new file mode 100644 index 0000000..27c7696 --- /dev/null +++ b/features/step_definitions/work_download_steps.rb @@ -0,0 +1,37 @@ +Then /^I should see the inspiring parent work link$/ do + parent = Work.find_by(title: "Worldbuilding") + inspired_link = "<a href=\"#{work_url(parent)}\">#{parent.title}</a>" + page.body.should =~ /Inspired by #{Regexp.escape(inspired_link)}/m +end + +Then /^I should see the external inspiring work link$/ do + parent = ExternalWork.find_by(title: "Example External") + inspired_link = "<a href=\"#{external_work_url(parent)}\">#{parent.title}</a>" + page.body.should =~ /Inspired by #{Regexp.escape(inspired_link)}/m +end + +Then /^I should receive a file of type "(.*?)"$/ do |filetype| + mime_type = Marcel::MimeType.for(name: "foo.#{filetype}").to_s + expect(page.response_headers['Content-Disposition']).to match(/filename=.+\.#{filetype}/) + expect(page.response_headers['Content-Length'].to_i).to be_positive + expect(page.response_headers['Content-Type']).to eq(mime_type) +end + +Then /^I should be able to download all versions of "(.*?)"$/ do |title| + (ArchiveConfig.DOWNLOAD_FORMATS - ['html']).each do |filetype| + step %{I should be able to download the #{filetype} version of "#{title}"} + end +end + +Then /^I should be able to download the (\w+) version of "(.*?)"$/ do |filetype, title| + work = Work.find_by_title(title) + visit work_url(work) + step %{I follow "#{filetype.upcase}"} + + download = Download.new(work, format: filetype) + filename = "#{download.file_name}.#{download.file_type}" + mime_type = Marcel::MimeType.for(name: filename).to_s + expect(page.response_headers['Content-Disposition']).to match(/filename="#{filename}"/) + expect(page.response_headers['Content-Length'].to_i).to be_positive + expect(page.response_headers['Content-Type']).to eq(mime_type) +end diff --git a/features/step_definitions/work_import_steps.rb b/features/step_definitions/work_import_steps.rb new file mode 100644 index 0000000..757ea32 --- /dev/null +++ b/features/step_definitions/work_import_steps.rb @@ -0,0 +1,136 @@ +require "webmock/cucumber" + +def content_fields + { + title: "Detected Title", summary: "Detected summary", fandoms: "Detected Fandom", warnings: "Underage Sex", + characters: "Detected 1, Detected 2", rating: "Explicit", relationships: "Detected 1/Detected 2", + categories: "F/F", freeform: "Detected tag 1, Detected tag 2", external_author_name: "Detected Author", + external_author_email: "detected@foo.com", notes: "This is a <i>content note</i>.", + date: "2002-01-12", chapter_title: "Detected chapter title" + } +end + +# Let the test get at external sites, but stub out anything containing certain keywords +def mock_external + WebMock.allow_net_connect! + + WebMock.stub_request(:any, /import-site-with-tags/). + to_return(status: 200, + body: + "Title: #{content_fields[:title]} +Summary: #{content_fields[:summary]} +Date: #{content_fields[:date]} +Fandom: #{content_fields[:fandoms]} +Rating: #{content_fields[:rating]} +Warnings: #{content_fields[:warnings]} +Characters: #{content_fields[:characters]} +Pairings: #{content_fields[:relationships]} +Category: #{content_fields[:categories]} +Tags: #{content_fields[:freeform]} +Author's notes: #{content_fields[:notes]} + +stubbed response", headers: {}) + + WebMock.stub_request(:any, /import-site-without-tags/). + to_return(status: 200, + body: "stubbed response", + headers: {}) + + WebMock.stub_request(:any, /second-import-site-without-tags/). + to_return(status: 200, + body: "second stubbed response", + headers: {}) + + WebMock.stub_request(:any, /no-content/). + to_return(status: 200, + body: "", + headers: {}) + + WebMock.stub_request(:any, /bar/) + .to_return(status: 404, headers: {}) + + WebMock.stub_request(:any, /second-import-site-with-tags/) + .to_return(status: 200, + headers: {}, + body: <<~BODY + <html><head> + <title>Huddling + + + + + +

    Huddling

    +

    by an_author for the otwarchive testing meme.

    + +

    "What is this place?" orphan_account asked.

    + +

    "I—don't know," Tester said.

    + +

    = End =

    + + + BODY + ) +end + +Given "I set up mock websites for importing" do + mock_external +end + +Given /^I set up importing( with a mock website)?( as an archivist)?$/ do |mock, is_archivist| + unless mock.blank? + mock_external + end + step %{basic languages} + step %{basic tags} + step %{all warnings exist} + if is_archivist.blank? + step %{I am logged in as a random user} + else + step %{I have an archivist "archivist"} + step %{I am logged in as "archivist"} + end + step %{I go to the import page} +end + +When /^I start importing "(.*)"( with a mock website)?( as an archivist)?$/ do |url, mock, is_archivist| + step %{I set up importing#{mock}#{is_archivist}} + step %{I fill in "urls" with "#{url}"} + step %{I select "English" from "Choose a language"} +end + +When "I import the mock work {string} by {string} with email {string} and by {string} with email {string}" do |url, creator_name, creator_email, cocreator_name, cocreator_email| + step(%{I start importing "#{url}" with a mock website as an archivist}) + step(%{I check "Import for others ONLY with permission"}) + step(%{I fill in "external_author_name" with "#{creator_name}"}) + step(%{I fill in "external_author_email" with "#{creator_email}"}) + step(%{I fill in "external_coauthor_name" with "#{cocreator_name}"}) + step(%{I fill in "external_coauthor_email" with "#{cocreator_email}"}) + step(%{I check "Post without previewing"}) + step(%{I press "Import"}) +end + +When /^I import "(.*)"( with a mock website)?$/ do |url, mock| + step %{I start importing "#{url}"#{mock}} + step %{I press "Import"} +end + +When /^I import the urls with mock websites( as chapters)?( without preview)?$/ do |chapters, no_preview, urls| + step %{I set up importing with a mock website} + step %{I fill in "urls" with "#{urls}"} + step %{I select "English" from "Choose a language"} + if chapters + step %{I choose "import_multiple_chapters"} + end + if no_preview + step %{I check "post_without_preview"} + end + step %{I press "Import"} +end diff --git a/features/step_definitions/work_related_steps.rb b/features/step_definitions/work_related_steps.rb new file mode 100644 index 0000000..0285042 --- /dev/null +++ b/features/step_definitions/work_related_steps.rb @@ -0,0 +1,206 @@ +### GIVEN + +Given /^I have related works setup$/ do + step "basic tags" + step "all emails have been delivered" + + inspiration = FactoryBot.create(:user, login: "inspiration", confirmed_at: Time.now.utc) + FactoryBot.create(:user, login: "translator", confirmed_at: Time.now.utc) + FactoryBot.create(:user, login: "remixer", confirmed_at: Time.now.utc) + + FactoryBot.create(:work, title: "Worldbuilding", authors: inspiration.pseuds) + FactoryBot.create(:work, title: "Worldbuilding Two", authors: inspiration.pseuds) +end + +Given /^an inspiring parent work has been posted$/ do + step "I post an inspiring parent work as testuser" +end + +# given for remixes / related works + +Given /^a related work has been posted$/ do + step %{I post a related work as remixer} +end + +Given /^a related work has been posted and approved$/ do + step %{I post a related work as remixer} + step %{I approve a related work} +end + +# given for translations + +Given /^a translation has been posted$/ do + step %{I post a translation as translator} +end + +Given /^a translation has been posted and approved$/ do + step %{I post a translation as translator} + step %{I approve a related work} +end + +### WHEN + +When "I post an inspiring parent work as testuser" do + step %{I am logged in as "testuser"} + step %{I post the work "Parent Work"} +end + +When /^I approve a related work$/ do + step %{I am logged in as "inspiration"} + step %{I follow "My Dashboard"} + step %{I follow "Related Works ("} + step %{I follow "Approve"} + step %{I press "Yes, link me!"} +end + +When /^I view my related works$/ do + step %{I follow "My Dashboard"} + step %{I follow "Related Works ("} +end + +# when for remixes / related works + +When /^I post a related work as remixer$/ do + step %{I am logged in as "remixer"} + step %{I go to the new work page} + step %{I select "Not Rated" from "Rating"} + step %{I check "No Archive Warnings Apply"} + step %{I select "English" from "Choose a language"} + step %{I fill in "Fandoms" with "Stargate"} + step %{I fill in "Work Title" with "Followup"} + step %{I fill in "content" with "That could be an amusing crossover."} + step %{I list the work "Worldbuilding" as inspiration} + step %{I press "Preview"} + step %{I press "Post"} +end + +When /^I post a related work as remixer for an external work$/ do + step %{I am logged in as "remixer"} + step %{I go to the new work page} + step %{I select "Not Rated" from "Rating"} + step %{I check "No Archive Warnings Apply"} + step %{I select "English" from "Choose a language"} + step %{I fill in "Fandoms" with "Stargate"} + step %{I fill in "Work Title" with "Followup"} + step %{I fill in "content" with "That could be an amusing crossover."} + step %{I list an external work as inspiration} + step %{I press "Preview"} + step %{I press "Post"} +end + +# when for translations + +When /^I post a translation as translator$/ do + step %{I am logged in as "translator"} + step %{I draft a translation} + step %{I press "Post"} +end + +When /^I post a translation of my own work$/ do + step %{I am logged in as "inspiration"} + step %{I draft a translation} + step %{I press "Post"} +end + +When /^I draft a translation$/ do + FactoryBot.create(:language, name: "Deutsch", short: "de") + + step %{I go to the new work page} + step %{I check "No Archive Warnings Apply"} + step %{I fill in "Fandoms" with "Stargate"} + step %{I fill in "Work Title" with "Worldbuilding Translated"} + step %{I fill in "content" with "That could be an amusing crossover."} + step %{I list the work "Worldbuilding" as inspiration} + step %{I check "This is a translation"} + step %{I select "Deutsch" from "Choose a language"} + step %{I press "Preview"} +end + +When /^I list a series as inspiration$/ do + with_scope("#parent-options") do + fill_in("URL", with: "#{ArchiveConfig.APP_HOST}/series/123") + end +end + +When /^I list a nonexistent work as inspiration$/ do + work = Work.find_by_id(123) + work.destroy unless work.nil? + with_scope("#parent-options") do + fill_in("URL", with: "#{ArchiveConfig.APP_HOST}/works/123") + end +end + +### THEN + +Then /^the original author should be emailed$/ do + step "1 email should be delivered" +end + +Then /^approving the related work should succeed$/ do + step %{I should see "Link was successfully approved"} +end + +# then for remixes / related works + +Then /^a parent related work should be seen$/ do + step %{I should see "Work was successfully posted"} + step %{I should find a list for associations} + step %{I should see "Inspired by Worldbuilding by inspiration" within ".preface .notes"} +end + +Then /^I should see the inspiring parent work in the beginning notes$/ do + step %{I should see "Inspired by Parent Work by testuser" within ".preface .notes"} +end + +Then /^I should see a beginning note about related works$/ do + step %{I should see "See the end of the work for other works inspired by this one" within ".preface .notes"} +end + +Then /^I should see the related work in the end notes$/ do + step %{I should see "Works inspired by this one:" within ".afterword .children"} + step %{I should see "Followup by remixer" within ".afterword .children"} +end + +Then "I should see the related work listed on the original work" do + step %{I should see "See the end of the work for other works inspired by this one"} + step %{I should see "Works inspired by this one:"} + step %{I should see "Followup by remixer"} +end + +Then /^I should not see the related work listed on the original work$/ do + step %{I should not see "See the end of the work for other works inspired by this one"} + step %{I should not see "Works inspired by this one:"} + step %{I should not see "Followup by remixer"} +end + +Then "I should not see the inspiring parent work in the beginning notes" do + step %{I should not see "Inspired by Parent Work by testuser" within ".preface .notes"} +end + +# then for translations + +Then /^a parent translated work should be seen$/ do + step %{I should see "Work was successfully posted"} + step %{I should find a list for associations} + step %{I should see "A translation of Worldbuilding by inspiration" within ".preface .notes"} +end + +Then "I should see the translation in the beginning notes" do + step %{I should see "Translation into Deutsch available:" within ".preface .notes"} + step %{I should see "Worldbuilding Translated by translator" within ".preface .notes"} +end + +Then "I should see the translation listed on the original work" do + step %{I should see "Translation into Deutsch available:"} + step %{I should see "Worldbuilding Translated by translator"} +end + +Then /^I should not see the translation listed on the original work$/ do + step %{I should not see "Translation into Deutsch available:"} + step %{I should not see "Worldbuilding Translated by translator"} +end + +Then /^I should not see the translation in the end notes$/ do + step %{I should not see "Translation into Deutsch available:" within ".afterword"} + step %{I should not see "Worldbuilding Translated by translator" within ".afterword"} +end diff --git a/features/step_definitions/work_search_steps.rb b/features/step_definitions/work_search_steps.rb new file mode 100644 index 0000000..f1436c5 --- /dev/null +++ b/features/step_definitions/work_search_steps.rb @@ -0,0 +1,514 @@ +### GIVEN + +Given /^a set of alternate universe works for searching$/ do + step %{basic tags} + + # Create a metatag with a syn + step %{a canonical freeform "Alternate Universe"} + step %{a synonym "AU" of the tag "Alternate Universe"} + + # Create a subtag with a syn + step %{a canonical freeform "Alternate Universe - High School"} + step %{a synonym "High School AU" of the tag "Alternate Universe - High School"} + + # Create another subtag + step %{a canonical freeform "Alternate Universe - Coffee Shops & Cafés"} + + # Set up the tree + step %{"Alternate Universe" is a metatag of the freeform "Alternate Universe - High School"} + step %{"Alternate Universe" is a metatag of the freeform "Alternate Universe - Coffee Shops & Cafés"} + + # Create a work with every tag except Alternate Universe - High School, and a + # work with the unwrangled tag Coffee Shop AU + ["Alternate Universe", + "AU", + "High School AU", + "Alternate Universe - Coffee Shops & Cafés", + "Coffee Shop AU"].each do |freeform| + FactoryBot.create(:work, freeform_string: freeform) + end + + # Create a work with a summary that is a text match for both the unwrangled + # tag (Coffee Shop AU) and the metatag's syn (AU) + FactoryBot.create(:work, summary: "A humble Coffee Shop AU") + + # Create a work with a character tag that is a text match for the metatag's + # syn (AU) + FactoryBot.create(:work, character_string: "AU Character") + + step %{all indexing jobs have been run} +end + +Given /^a set of Steve Rogers works for searching$/ do + step %{basic tags} + + # Create two fandoms + step %{a canonical fandom "Marvel Cinematic Universe"} + step %{a canonical fandom "The Avengers (Marvel Movies)"} + + # Create a character with a syn + step %{a canonical character "Steve Rogers"} + step %{a synonym "Captain America" of the tag "Steve Rogers"} + + # Create a meta tag for that character + step %{a canonical character "Steve"} + step %{"Steve" is a metatag of the character "Steve Rogers"} + + # Create a work for each character tag in each fandom + ["Marvel Cinematic Universe", "The Avengers (Marvel Movies)"].each do |fandom| + ["Steve Rogers", "Captain America"].each do |character| + FactoryBot.create(:work, + fandom_string: fandom, + character_string: character) + end + end + + # Create a work without Steve as a character but with him in a relationship + FactoryBot.create(:work, + relationship_string: "Steve Rogers/Tony Stark") + + # Create a work that only mentions Steve in the summary + FactoryBot.create(:work, + summary: "Bucky thinks about his pal Steve Rogers.") + + step %{all indexing jobs have been run} +end + +Given /^a set of Kirk\/Spock works for searching$/ do + step %{basic tags} + + # Create a relationship with two syns + step %{a canonical relationship "James T. Kirk/Spock"} + step %{a synonym "K/S" of the tag "James T. Kirk/Spock"} + step %{a synonym "Spirk" of the tag "James T. Kirk/Spock"} + + # Create a work for each tag + ["James T. Kirk/Spock", "K/S", "Spirk"].each do |relationship| + FactoryBot.create(:work, relationship_string: relationship) + end + + # Create a F/M work using one of the synonyms + FactoryBot.create(:work, + title: "The Genderswap K/S Work That Uses a Synonym", + relationship_string: "Spirk", + category_string: "F/M") + + step %{all indexing jobs have been run} +end + +Given /^a set of Spock\/Uhura works for searching$/ do + step %{basic tags} + + # Create a canonical two-character relationship with a syn + step %{a canonical relationship "Spock/Nyota Uhura"} + step %{a synonym "Uhura/Spock" of the tag "Spock/Nyota Uhura"} + + # Create a threesome with a name that is a partial match for the relationship + step %{a canonical relationship "James T. Kirk/Spock/Nyota Uhura"} + + # Create a work for each tag + ["Spock/Nyota Uhura", + "Uhura/Spock", + "James T. Kirk/Spock/Nyota Uhura"].each do |relationship| + FactoryBot.create(:work, + relationship_string: relationship) + end + + step %{all indexing jobs have been run} +end + +Given "a set of Ed Stede works for searching" do + step %{basic tags} + + # Create a relationship with a syn + step %{a canonical relationship "Blackbeard | Edward Teach/Stede Bonnet"} + step %{a synonym "Ed/Stede" of the tag "Blackbeard | Edward Teach/Stede Bonnet"} + + # Create a work for each tag or set of tags (all are otp: true) + ["Ed/Stede", + "Blackbeard | Edward Teach/Stede Bonnet", + "Blackbeard | Edward Teach/Stede Bonnet, Ed/Stede"].each do |relationship| + FactoryBot.create(:work, relationship_string: relationship) + end + + # Create a work with no relationship tag (an otp: false work) + FactoryBot.create(:work, title: "The Work Without a Relationship") + + # Create a work with two unconnected relationship tags (an otp: false work) + FactoryBot.create(:work, + title: "The Work With Multiple Ships", + relationship_string: "Ed/Stede, Ed/Izzy") + + step %{all indexing jobs have been run} +end + +Given "a set of crossover works for searching" do + step %{basic tags} + + # Create two unrelated fandoms, one with a syn + step %{a canonical fandom "Unrelated Fandom"} + step %{a canonical fandom "Hermitcraft SMP"} + step %{a synonym "Hermitcraft" of the tag "Hermitcraft SMP"} + + # Create a fandom and make it the metatag of one of the fandoms + step %{a canonical fandom "Video Blogging RPF"} + step %{"Video Blogging RPF" is a metatag of the fandom "Hermitcraft SMP"} + + # Create a work for each tag or set of tags (none are crossovers) + ["Video Blogging RPF", + "Hermitcraft SMP", + "Hermitcraft SMP, Hermitcraft", + "Hermitcraft SMP, Video Blogging RPF", + "Hermitcraft, Video Blogging RPF"].each do |fandom| + FactoryBot.create(:work, fandom_string: fandom) + end + + # Create three works with two unconnected fandom tags (crossover works) + FactoryBot.create(:work, + title: "First Work With Multiple Fandoms", + fandom_string: "Hermitcraft SMP, Unrelated Fandom") + FactoryBot.create(:work, + title: "Second Work With Multiple Fandoms", + fandom_string: "Hermitcraft, Unrelated Fandom") + FactoryBot.create(:work, + title: "Third Work With Multiple Fandoms", + fandom_string: "Video Blogging RPF, Unrelated Fandom") + + step %{all indexing jobs have been run} +end + +Given /^a set of works with various categories for searching$/ do + step %{basic tags} + + # Create one work with each category + %w(Gen Other F/F Multi F/M M/M).each do |category| + FactoryBot.create(:work, category_string: category) + end + + # Create one work using multiple categories + FactoryBot.create(:work, category_string: "M/M, F/F") + + step %{all indexing jobs have been run} +end + +Given /^a set of works with comments for searching$/ do + step %{basic tags} + + counts = { + "Work 1" => 0, + "Work 2" => 1, + "Work 3" => 1, + "Work 4" => 1, + "Work 5" => 3, + "Work 6" => 3, + "Work 7" => 10 + } + + counts.each_pair do |title, comment_count| + work = FactoryBot.create(:work, title: title) + FactoryBot.create_list(:comment, comment_count, :by_guest, + commentable: work.last_posted_chapter) + end + + step %{the statistics for all works are updated} + step %{all indexing jobs have been run} +end + +Given /^a set of Star Trek works for searching$/ do + step %{basic tags} + + # Create three related canonical fandoms + step %{a canonical fandom "Star Trek"} + step %{a canonical fandom "Star Trek: The Original Series"} + step %{a canonical fandom "Star Trek: The Original Series (Movies)"} + + # Create a syn for one of the fandoms + step %{a synonym "ST: TOS" of the tag "Star Trek: The Original Series"} + + # Create an unrelated fourth fandom we'll use for a crossover + step %{a canonical fandom "Battlestar Galactica (2003)"} + + # Set up the tree for the related fandoms + step %{"Star Trek" is a metatag of the fandom "Star Trek: The Original Series"} + step %{"Star Trek: The Original Series" is a metatag of the fandom "Star Trek: The Original Series (Movies)"} + + # Create a work using each of the related fandoms + ["Star Trek", "Star Trek: The Original Series", + "Star Trek: The Original Series (Movies)", "ST: TOS"].each do |fandom| + FactoryBot.create(:work, fandom_string: fandom) + end + + # Create a work with two fandoms (e.g. a crossover) + FactoryBot.create(:work, + fandom_string: "ST: TOS, + Battlestar Galactica (2003)") + + # Create a work with an additional tag (freeform) that references the fandom + FactoryBot.create(:work, + fandom_string: "Battlestar Galactica (2003)", + freeform_string: "Star Trek Fusion") + + step %{all indexing jobs have been run} +end + +Given /^a set of works with bookmarks for searching$/ do + step %{basic tags} + + counts = { + "Work 1" => 0, + "Work 2" => 1, + "Work 3" => 1, + "Work 4" => 2, + "Work 5" => 2, + "Work 6" => 4, + "Work 7" => 10 + } + + counts.each_pair do |title, bookmark_count| + work = FactoryBot.create(:work, title: title) + FactoryBot.create_list(:bookmark, bookmark_count, bookmarkable: work) + end + + step %{the statistics for all works are updated} + step %{all indexing jobs have been run} +end + +Given /^a set of works with various ratings for searching$/ do + step %{basic tags} + + 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.each do |rating| + FactoryBot.create(:work, rating_string: rating) + end + + FactoryBot.create(:work, + rating_string: ArchiveConfig.RATING_DEFAULT_TAG_NAME, + summary: "Nothing explicit here.") + + step %{all indexing jobs have been run} +end + +Given /^a set of works with various warnings for searching$/ do + step %{basic tags} + step %{all warnings exist} + + warnings = [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] + + # Create a work for each warning + warnings.each do |warning| + FactoryBot.create(:work, archive_warning_string: warning) + end + + # Create a work that uses multiple warnings + FactoryBot.create(:work, + archive_warning_string: "#{ArchiveConfig.WARNING_DEFAULT_TAG_NAME}, + #{ArchiveConfig.WARNING_NONE_TAG_NAME}") + + step %{all indexing jobs have been run} +end + +Given /^a set of works with various access levels for searching$/ do + # Create a draft + FactoryBot.create(:draft, title: "Draft Work") + + # Create a work + FactoryBot.create(:work, title: "Posted Work") + + # Create a work restricted to registered users + FactoryBot.create(:work, restricted: true, title: "Restricted Work") + + # Create a work hidden by an admin + FactoryBot.create(:work, + hidden_by_admin: true, + title: "Work Hidden by Admin") + + step %{all indexing jobs have been run} +end + +Given "a set of old multilanguage works for searching" do + german = Language.find_or_create_by!(short: "de", name: "Deutsch") + + FactoryBot.create(:work, + title: "My <strong>er German Work", + language: german, + authors: [ensure_user("testuser2").default_pseud]) + + FactoryBot.create(:work, + title: "unfinished", + complete: false, + expected_number_of_chapters: 2, + authors: [ensure_user("testuser").default_pseud]) + + step %{all indexing jobs have been run} + step %{it is currently 3 years from now} +end + +Given "a set of works with stats for searching" do + many = FactoryBot.create(:work, title: "many") + FactoryBot.create_list(:kudo, 4, commentable: many) + many.stat_counter.update_attribute(:hit_count, 10_000) + + less = FactoryBot.create(:work, title: "less") + FactoryBot.create(:kudo, commentable: less) + less.stat_counter.update_attribute(:hit_count, 500) + + FactoryBot.create(:work, title: "none") + FactoryBot.create(:work, title: "unfinished", complete: false, expected_number_of_chapters: 2) + + step %{the statistics for all works are updated} + step %{all indexing jobs have been run} +end + +### WHEN + +When /^I search for a simple term from the search box$/ do + step %{I am on the homepage} + step %{I fill in "site_search" with "first"} + step %{I press "Search"} +end + +When /^I search for works containing "([^"]*)"$/ do |term| + step %{I am on the homepage} + step %{I fill in "site_search" with "#{term}"} + step %{I press "Search"} +end + +When /^I search for works by "([^"]*)"$/ do |creator| + step %{I am on the homepage} + step %{I fill in "site_search" with "creator: #{creator}"} + step %{I press "Search"} +end + +When /^I search for works without the "([^"]*)"(?: and "([^"]*)")? filter_ids?$/ do |tag1, tag2| + filter_id1 = Tag.find_by_name(tag1).filter_taggings.first.filter_id + filter_id2 = Tag.find_by_name(tag2).filter_taggings.first.filter_id if tag2 + step %{I am on the homepage} + if tag2 + fill_in("site_search", with: "-filter_ids: #{filter_id1} -filter_ids: #{filter_id2}") + else + fill_in("site_search", with: "-filter_ids: #{filter_id1}") + end + step %{I press "Search"} +end + +When /^I exclude the tags? "([^"]*)"(?: and "([^"]*)")? by filter_id$/ do |tag1, tag2| + filter_id1 = Tag.find_by_name(tag1).filter_taggings.first.filter_id + filter_id2 = Tag.find_by_name(tag2).filter_taggings.first.filter_id if tag2 + if tag2 + fill_in('work_search_query', with: "-filter_ids: #{filter_id1} -filter_ids: #{filter_id2}") + else + fill_in('work_search_query', with: "-filter_ids: #{filter_id1}") + end +end + +### THEN + +Then /^the results should contain the ([^"]*) tag "([^"]*)"$/ do |type, tag| + selector = if type == "fandom" + "ol.work .fandoms" + elsif %w(rating category).include?(type) + "ol.work .required-tags .#{type}" + else + "ol.work .tags .#{type.pluralize}" + end + expect(page).to have_css(selector, text: tag) +end + +Then /^the results should not contain the ([^"]*) tag "([^"]*)"$/ do |type, tag| + selector = if type == "fandom" + "ol.work .fandoms" + elsif %w(rating category).include?(type) + "ol.work .required-tags .#{type}" + else + "ol.work .tags .#{type.pluralize}" + end + expect(page).not_to have_css(selector, text: tag) +end + +Then /^the results should contain (?:a|the) synonyms? of "([^"]*)"$/ do |tag| + tag = Tag.find_by_name(tag) + type = tag.type.downcase.pluralize + synonyms = tag.synonyms.map(&:name) + selector = if type == "fandoms" + "ol.work .fandoms" + else + "ol.work .tags .#{type}" + end + synonyms.each do |synonym| + expect(page).to have_css(selector, text: synonym) + end +end + +Then /^the results should contain (?:a|the) subtags? of "([^"]*)"$/ do |tag| + tag = Tag.find_by_name(tag) + type = tag.type.downcase.pluralize + subtags = tag.sub_tags.map(&:name) + selector = if type == "fandoms" + "ol.work .fandoms" + else + "ol.work .tags .#{type}" + end + subtags.each do |subtag| + expect(page).to have_css(selector, text: subtag) + end +end + +Then /^the results should contain a ([^"]*) mentioning "([^"]*)"$/ do |item, term| + selector = if item == "fandom" + "ol.work .fandoms" + elsif item == "summary" + "ol.work .summary" + else + "ol.work .tags .#{item.pluralize}" + end + expect(page).to have_css(selector, text: term) +end + +Then /^the results should not contain a ([^"]*) mentioning "([^"]*)"$/ do |item, term| + selector = if item == "fandom" + "ol.work .fandoms" + elsif item == "summary" + "ol.work .summary" + else + "ol.work .tags .#{item.pluralize}" + end + expect(page).not_to have_css(selector, text: term) +end + +Then /^the ([\d]+)(?:st|nd|rd|th) result should contain "([^"]*)"$/ do |n, text| + selector = "ol.work > li:nth-of-type(#{n})" + with_scope(selector) do + page.should have_content(text) + end +end + +# If JavaScript is enabled and we want to check that information is retained +# when editing a search, we can't look at what is in the input -- we have to +# look at the contents of the ul that contains both the field and the added tags +Then /^"([^"]*)" should already be entered in the work search ([^"]*) autocomplete field$/ do |tag, field| + within(:xpath, "//input[@id=\"work_search_#{field.singularize}_names_autocomplete\"]/parent::li/parent::ul") do + page.should have_content(tag) + end +end + +Then /^the search summary should include the filter_id for "([^"]*)"$/ do |tag| + filter_id = Tag.find_by_name(tag).filter_taggings.first.filter_id + step %{I should see "filter_ids: #{filter_id}" within "#main h4.heading"} +end + +Then /^the results should contain only the restricted work$/ do + step %{I should see "Restricted Work"} + step %{I should not see "Posted Work"} + step %{I should not see "Work Hidden by Admin"} + step %{I should not see "Draft Work"} +end diff --git a/features/step_definitions/work_steps.rb b/features/step_definitions/work_steps.rb new file mode 100644 index 0000000..27d6ad2 --- /dev/null +++ b/features/step_definitions/work_steps.rb @@ -0,0 +1,813 @@ +require "cgi" + +DEFAULT_TITLE = "My Work Title" +DEFAULT_FANDOM = "Stargate SG-1" +DEFAULT_RATING = "Not Rated" +DEFAULT_WARNING = "No Archive Warnings Apply" +DEFAULT_FREEFORM = "Scary tag" +DEFAULT_CONTENT = "That could be an amusing crossover." +DEFAULT_CATEGORY = "Other" + +### Setting up a work +# These steps get used a lot by many other steps and tests to create works in the archive to test with + +When /^I fill in the basic work information for "([^"]*)"$/ do |title| + step %{I fill in basic work tags} + check(DEFAULT_WARNING) + fill_in("Work Title", with: title) + select("English", from: "work_language_id") + fill_in("content", with: DEFAULT_CONTENT) +end +# Here we set up a draft and can then post it as a draft, preview and post, post, +# or fill in additional information on the work form. +# Example: I set up the draft "Foo" +# Example: I set up the draft "Foo" with fandom "Captain America" in the collection "MCU Stories" as a gift to "Bob" +# +# This is a complex regexp because it attempts to be flexible and match a lot of options (including using a/the, in/to etc) +# the (?: ) construct means: do not use the stuff in () as a capture/match +# the ()? construct means: the stuff in () is optional +# This can handle any number of the options being omitted, but you DO have to match in order +# if you are using more than one of the options. That is, if you are specifying fandom AND freeform AND collection, +# it has to be: +# with fandom "X" with freeform "Y" in collection "Z" +# and NOT: +# with freeform "Y" in collection "Z" with fandom "X" +# +# If you add to this regexp, you probably want to update all the +# similar regexps in the I post/Given the draft/the work steps below. +When /^I set up (?:a|the) draft "([^"]*)"(?: with fandom "([^"]*)")?(?: with character "([^"]*)")?(?: with second character "([^"]*)")?(?: with freeform "([^"]*)")?(?: with second freeform "([^"]*)")?(?: with category "([^"]*)")?(?: with rating "([^\"]*)")?(?: (?:in|to) (?:the )?collection "([^"]*)")?(?: as a gift (?:for|to) "([^"]*)")?(?: as part of a series "([^"]*)")?(?: with relationship "([^"]*)")?(?: using the pseud "([^"]*)")?$/ do |title, fandom, character, character2, freeform, freeform2, category, rating, collection, recipient, series, relationship, pseud| + step %{basic tags} + visit new_work_path + step %{I fill in the basic work information for "#{title}"} + select(rating.blank? ? DEFAULT_RATING : rating, from: "Rating") + check(category.blank? ? DEFAULT_CATEGORY : category) + fill_in("Fandoms", with: (fandom.blank? ? DEFAULT_FANDOM : fandom)) + fill_in("Additional Tags", with: (freeform.blank? ? DEFAULT_FREEFORM : freeform)+(freeform2.blank? ? '' : ','+freeform2)) + unless character.blank? + fill_in("work[character_string]", with: character + ( character2.blank? ? '' : ','+character2 ) ) + end + unless collection.blank? + c = Collection.find_by(title: collection) + fill_in("Collections", with: c.name) + end + unless series.blank? + if page.has_select?("work[series_attributes][id]", with_options: [series]) + select(series, from: "work[series_attributes][id]") + else + fill_in("work[series_attributes][title]", with: series) + end + end + unless relationship.blank? + fill_in("work[relationship_string]", with: relationship) + end + select(pseud, from: "work[author_attributes][ids][]") unless pseud.blank? + fill_in("work_recipients", with: "#{recipient}") unless recipient.blank? +end + +# This is the same regexp as above +When /^I post (?:a|the) (?:(\d+) chapter )?work "([^"]*)"(?: with fandom "([^"]*)")?(?: with character "([^"]*)")?(?: with second character "([^"]*)")?(?: with freeform "([^"]*)")?(?: with second freeform "([^"]*)")?(?: with category "([^"]*)")?(?: with rating "([^\"]*)")?(?: (?:in|to) (?:the )?collection "([^"]*)")?(?: as a gift (?:for|to) "([^"]*)")?(?: as part of a series "([^"]*)")?(?: with relationship "([^"]*)")?(?: using the pseud "([^"]*)")?$/ do |number_of_chapters, title, fandom, character, character2, freeform, freeform2, category, rating, collection, recipient, series, relationship, pseud| + # If the work is already a draft then visit the preview page and post it + work = Work.find_by(title: title) + if work + visit preview_work_path(work) + click_button("Post") + else + # Note: this will match the above regexp and work just fine even if all the options are blank! + step %{I set up the draft "#{title}" with fandom "#{fandom}" with character "#{character}" with second character "#{character2}" with freeform "#{freeform}" with second freeform "#{freeform2}" with category "#{category}" with rating "#{rating}" in collection "#{collection}" as a gift to "#{recipient}" as part of a series "#{series}" with relationship "#{relationship}" using the pseud "#{pseud}"} + click_button("Post") + end + # Now add the chapters + if number_of_chapters.present? && number_of_chapters.to_i > 1 + work = Work.find_by_title(title) + visit work_path(work) + (number_of_chapters.to_i - 1).times do + step %{I follow "Add Chapter"} + fill_in("content", with: "Yet another chapter.") + click_button("Post") + end + end + step %{all indexing jobs have been run} + step "the periodic tag count task is run" + step %(the periodic filter count task is run) +end + +# Again, same regexp, it just creates a draft and not a posted +# To test posting after preview, use: Given the draft "Foo" +# Then use: When I post the work "Foo" +# and the above step +Given /^the draft "([^"]*)"(?: with fandom "([^"]*)")?(?: with character "([^"]*)")?(?: with second character "([^"]*)")?(?: with freeform "([^"]*)")?(?: with second freeform "([^"]*)")?(?: with category "([^"]*)")?(?: (?:in|to) (?:the )?collection "([^"]*)")?(?: as a gift (?:for|to) "([^"]*)")?(?: as part of a series "([^"]*)")?(?: with relationship "([^"]*)")?(?: using the pseud "([^"]*)")?$/ do |title, fandom, character, character2, freeform, freeform2, category, collection, recipient, series, relationship, pseud| + step %{I set up the draft "#{title}" with fandom "#{fandom}" with character "#{character}" with second character "#{character2}" with freeform "#{freeform}" with second freeform "#{freeform2}" with category "#{category}" in collection "#{collection}" as a gift to "#{recipient}" as part of a series "#{series}" with relationship "#{relationship}" using the pseud "#{pseud}"} + click_button("Preview") +end + +When /^I post the works "([^"]*)"$/ do |worklist| + worklist.split(/, ?/).each do |work_title| + step %{I post the work "#{work_title}"} + # Ensure all works are created with different timestamps to avoid flakiness + step %{it is currently 1 second from now} + end +end + +### GIVEN + +Given /^I have no works or comments$/ do + Work.delete_all + Comment.delete_all +end + +Given /^the chaptered work(?: with ([\d]+) chapters)?(?: with ([\d]+) comments?)? "([^"]*)"$/ do |n_chapters, n_comments, title| + step %{basic tags} + + title ||= "Blabla" + n_chapters ||= 2 + + work = FactoryBot.create(:work, title: title, expected_number_of_chapters: n_chapters.to_i) + + # In order to make sure that the chapter positions are valid, we have to set + # them manually. So we can't use create_list, and have to loop instead: + (n_chapters.to_i - 1).times do |index| + FactoryBot.create(:chapter, work: work, position: index + 2) + end + + # Make sure that the word count is set properly: + work.save + + n_comments ||= 0 + FactoryBot.create_list(:comment, n_comments.to_i, :by_guest, + commentable: work.first_chapter, + comment_content: "Bla bla") +end + +Given /^I have a work "([^"]*)"$/ do |work| + step %{the work "#{work}"} +end + +Given /^I have a multi-chapter draft$/ do + step %{I am logged in as a random user} + step %{I post the chaptered draft "Multi-chapter Draft"} +end + +Given /^the work(?: "([^"]*)")? with(?: (\d+))? comments setup$/ do |title, n_comments| + step %{basic tags} + + title ||= "Blabla" + work = FactoryBot.create(:work, title: title) + + n_comments = 3 if n_comments.blank? || n_comments.zero? + FactoryBot.create_list(:comment, n_comments.to_i, :by_guest, + commentable: work.last_posted_chapter) +end + +Given /^the work(?: "([^"]*)")? with(?: (\d+))? bookmarks? setup$/ do |title, n_bookmarks| + step %{basic tags} + + title ||= "Blabla" + work = FactoryBot.create(:work, title: title) + + n_bookmarks = 3 if n_bookmarks.blank? || n_bookmarks.zero? + FactoryBot.create_list(:bookmark, n_bookmarks.to_i, bookmarkable: work) +end + +Given /^the chaptered work setup$/ do + step %{the chaptered work with 3 chapters "BigBang"} +end + +Given /^the chaptered work with comments setup$/ do + step %{the chaptered work with 3 chapters "BigBang"} + step "I am logged in as a random user" + step %{I view the work "BigBang"} + step %{I post a comment "Woohoo"} + (2..3).each do |i| + step %{I view the work "BigBang"} + step %{I view the #{i.to_s}th chapter} + step %{I post a comment "Woohoo"} + end + step "I log out" +end + +Given "the work {string}" do |title| + FactoryBot.create(:work, title: title) +end + +Given "the work {string} by {string}" do |title, login| + user = ensure_user(login) + FactoryBot.create(:work, title: title, authors: [user.default_pseud]) +end + +Given "the work {string} by {string} with fandom {string}" do |title, login, fandom| + user = ensure_user(login) + FactoryBot.create(:work, title: title, authors: [user.default_pseud], fandom_string: fandom) +end + +Given "the work {string} by {string} with guest comments enabled" do |title, login| + user = ensure_user(login) + FactoryBot.create(:work, :guest_comments_on, title: title, authors: [user.default_pseud]) +end + +Given "the work {string} by {string} and {string}" do |title, login1, login2| + user1 = ensure_user(login1) + user2 = ensure_user(login2) + FactoryBot.create(:work, title: title, authors: [user1.default_pseud, user2.default_pseud]) +end + +Given "the work {string} by {string}, {string} and {string}" do |title, login1, login2, login3| + user1 = ensure_user(login1) + user2 = ensure_user(login2) + user3 = ensure_user(login3) + FactoryBot.create(:work, title: title, authors: [user1.default_pseud, user2.default_pseud, user3.default_pseud]) +end + +Given "the work {string} by {string} and {string} with guest comments enabled" do |title, login1, login2| + user1 = ensure_user(login1) + user2 = ensure_user(login2) + FactoryBot.create(:work, :guest_comments_on, title: title, authors: [user1.default_pseud, user2.default_pseud]) +end + +Given /^the work "([^\"]*)" by "([^\"]*)" with chapter two co-authored with "([^\"]*)"$/ do |work, author, coauthor| + step %{I am logged in as "#{author}"} + step %{I post the work "#{work}"} + step %{a chapter with the co-author "#{coauthor}" is added to "#{work}"} +end + +Given /^there is a work "([^"]*)" in an unrevealed collection "([^"]*)"$/ do |work, collection| + step %{I have the hidden collection "#{collection}"} + step %{I am logged in as a random user} + step %{I post the work "#{work}" to the collection "#{collection}"} + step %{I log out} +end + +Given /^there is a work "([^"]*)" in an anonymous collection "([^"]*)"$/ do |work, collection| + step %{I have the anonymous collection "#{collection}"} + step %{I am logged in as a random user} + step %{I post the work "#{work}" to the collection "#{collection}"} + step %{I log out} +end + +Given /^I am logged in as the author of "([^"]*)"$/ do |work| + work = Work.find_by_title(work) + step %{I am logged in as "#{work.users.first.login}"} +end + +Given "the spam work {string}" do |work| + FactoryBot.create(:work, title: work).update_attribute(:spam, true) +end + +Given "the hidden work {string}" do |work| + FactoryBot.create(:work, title: work).update_attribute(:hidden_by_admin, true) +end + +Given "the work {string} is marked as spam" do |work| + w = Work.find_by(title: work) + w.update_attribute(:spam, true) +end + +Given "the user-defined tag limit is {int}" do |count| + allow(ArchiveConfig).to receive(:USER_DEFINED_TAGS_MAX).and_return(count) +end + +Given "the work {string} has {int} {word} tag(s)" do |title, count, type| + work = Work.find_by(title: title) + work.send("#{type.pluralize}=", FactoryBot.create_list(type.to_sym, count)) +end + +### WHEN + +When /^I view the ([\d]+)(?:st|nd|rd|th) chapter$/ do |chapter_no| + (chapter_no.to_i - 1).times do |i| + step %{I follow "Next Chapter"} + end +end + +When /^I view the work "([^"]*)"(?: in (full|chapter-by-chapter) mode)?$/ do |work, mode| + work = Work.find_by_title(work) + visit work_path(work) + step %{I follow "Entire Work"} if mode == "full" + step %{I follow "Chapter by Chapter"} if mode == "chapter-by-chapter" +end + +When /^I view a deleted work$/ do + visit "/works/12345/chapters/12345" +end + +When /^I view a deleted chapter$/ do + step "the draft \"DeletedChapterWork\"" + work = Work.find_by(title: "DeletedChapterWork") + visit "/works/#{work.id}/chapters/12345" +end + +When /^I edit the work "([^"]*)"$/ do |work| + work = Work.find_by(title: work) + visit edit_work_path(work) +end +When /^I edit the draft "([^"]*)"$/ do |draft| + step %{I edit the work "#{draft}"} +end + +When /^I post the chaptered work "([^"]*)"(?: in the collection "([^"]*)")?$/ do |title, collection| + step %{I post the work "#{title}" in the collection "#{collection}"} + step %{I follow "Add Chapter"} + fill_in("content", with: "Another Chapter.") + click_button("Preview") + step %{I press "Post"} + step %{all indexing jobs have been run} + step "the periodic tag count task is run" +end + +When /^I post the chaptered draft "([^"]*)"$/ do |title| + step %{the draft "#{title}"} + step %{a draft chapter is added to "#{title}"} +end + +When /^I post the work "([^"]*)" without preview$/ do |title| + # we now post as our default test case + step %{I post the work "#{title}"} +end + +When "I post the work {string} with guest comments enabled" do |title| + step %{I set up the draft "#{title}"} + choose("Registered users and guests can comment") + step "I post the work without preview" +end + +When /^a chapter is added to "([^"]*)"$/ do |work_title| + step %{a draft chapter is added to "#{work_title}"} + click_button("Post") + step %{all indexing jobs have been run} + step "the periodic tag count task is run" +end + +When /^a chapter with the co-author "([^\"]*)" is added to "([^\"]*)"$/ do |coauthor, work_title| + step %{a chapter is set up for "#{work_title}"} + step %{I invite the co-author "#{coauthor}"} + click_button("Post") + step %{the user "#{coauthor}" accepts all co-creator requests} + step %{all indexing jobs have been run} + step "the periodic tag count task is run" +end + +When /^a draft chapter is added to "([^"]*)"$/ do |work_title| + step %{a chapter is set up for "#{work_title}"} + step %{I press "Preview"} + step %{all indexing jobs have been run} + + step "the periodic tag count task is run" +end + +When /^I delete chapter ([\d]+) of "([^"]*)"$/ do |chapter, title| + step %{I edit the work "#{title}"} + step %{I follow "#{chapter}"} + step %{I follow "Delete Chapter"} + step %{I press "Yes, Delete Chapter"} + step %{all indexing jobs have been run} +end + +# Posts a chapter for the current user +When /^I post a chapter for the work "([^"]*)"(?: as "(.*?)")?$/ do |work_title, pseud| + work = Work.find_by(title: work_title) + visit work_url(work) + step %{I follow "Add Chapter"} + step %{I fill in "content" with "la la la la la la la la la la la"} + select(pseud, from: "chapter_author_attributes_ids") if pseud.present? + step %{I post the chapter} +end + +When /^a chapter is set up for "([^"]*)"$/ do |work_title| + work = Work.find_by(title: work_title) + user = work.users.first + step %{I am logged in as "#{user.login}"} + visit work_url(work) + step %{I follow "Add Chapter"} + step %{I fill in "content" with "la la la la la la la la la la la"} +end + +# meant to be used in conjunction with above step +When /^I post the(?: draft)? chapter$/ do + click_button("Post") + step %{all indexing jobs have been run} + + step "the periodic tag count task is run" +end + +Then /^I should see the default work content$/ do + page.should have_content(DEFAULT_CONTENT) +end + +Then /^I should not see the default work content$/ do + page.should_not have_content(DEFAULT_CONTENT) +end + +When /^I fill in basic work tags$/ do + select(DEFAULT_RATING, from: "Rating") + fill_in("Fandoms", with: DEFAULT_FANDOM) + fill_in("Additional Tags", with: DEFAULT_FREEFORM) +end + +When /^I fill in basic external work tags$/ do + select(DEFAULT_RATING, from: "Rating") + fill_in("Fandoms", with: DEFAULT_FANDOM) + fill_in("Your tags", with: DEFAULT_FREEFORM) +end + +When /^I set the fandom to "([^"]*)"$/ do |fandom| + fill_in("Fandoms", with: fandom) +end +# on the edit multiple works page +When /^I select "([^"]*)" for editing$/ do |title| + id = Work.find_by(title: title).id + check("work_ids_#{id}") +end + +When /^I edit the multiple works "([^"]*)" and "([^"]*)"/ do |title1, title2| + # check if the works have been posted yet + unless Work.where(title: title1).exists? + step %{I post the work "#{title1}"} + end + unless Work.where(title: title2).exists? + step %{I post the work "#{title2}"} + end + step %{I follow "My Dashboard"} + step %{I follow "Works ("} + step %{I follow "Edit Works"} + step %{I select "#{title1}" for editing} + step %{I select "#{title2}" for editing} + step %{I press "Edit"} +end + +When /^I edit multiple works with different comment moderation settings$/ do + step %{I set up the draft "Work with Comment Moderation Enabled"} + check("work_moderated_commenting_enabled") + choose("Registered users and guests can comment") + step %{I post the work without preview} + step %{I post the work "Work with Comment Moderation Disabled"} + step %{I follow "My Dashboard"} + step %{I follow "Works ("} + step %{I follow "Edit Works"} + step %{I select "Work with Comment Moderation Enabled" for editing} + step %{I select "Work with Comment Moderation Disabled" for editing} + step %{I press "Edit"} +end + +When /^I edit multiple works with different commenting settings$/ do + step %{I set up the draft "Work with All Commenting Enabled"} + choose("Registered users and guests can comment") + step %{I post the work without preview} + + step %{I set up the draft "Work with Anonymous Commenting Disabled"} + choose("Only registered users can comment") + step %{I post the work without preview} + + step %{I set up the draft "Work with All Commenting Disabled"} + choose("No one can comment") + step %{I post the work without preview} + + step %{I follow "My Dashboard"} + step %{I follow "Works ("} + step %{I follow "Edit Works"} + step %{I select "Work with All Commenting Enabled" for editing} + step %{I select "Work with Anonymous Commenting Disabled" for editing} + step %{I select "Work with All Commenting Disabled" for editing} + step %{I press "Edit"} +end + +When /^I edit multiple works coauthored as "(.*)" with "(.*)"$/ do |author, coauthor| + step %{I coauthored the work "Shared Work 1" as "#{author}" with "#{coauthor}"} + step %{I coauthored the work "Shared Work 2" as "#{author}" with "#{coauthor}"} + step %{I follow "My Dashboard"} + step %{I follow "Works ("} + step %{I follow "Edit Works"} + step %{I select "Shared Work 1" for editing} + step %{I select "Shared Work 2" for editing} + step %{I press "Edit"} +end + +When /^the purge_old_drafts rake task is run$/ do + step %{I run the rake task "work:purge_old_drafts"} +end + +When /^the work "([^"]*)" was created (\d+) days ago$/ do |title, number| + step "the draft \"#{title}\"" + work = Work.find_by(title: title) + work.update_attribute(:created_at, number.to_i.days.ago) + step %{all indexing jobs have been run} + + step "the periodic tag count task is run" +end + +When /^I post the locked work "([^"]*)"$/ do |title| + work = Work.find_by(title: work) + if work.blank? + step "the locked draft \"#{title}\"" + work = Work.find_by(title: title) + end + visit preview_work_url(work) + click_button("Post") + step %{all indexing jobs have been run} + + step "the periodic tag count task is run" +end + +When /^the locked draft "([^"]*)"$/ do |title| + step "basic tags" + visit new_work_url + step %{I fill in the basic work information for "#{title}"} + check("work_restricted") + click_button("Preview") +end + +When /^I lock the work$/ do + check("work_restricted") +end + +When /^I lock the work "([^"]*)"$/ do |work| + step %{I edit the work "#{work}"} + step %{I lock the work} + step %{I post the work} +end + +When /^I unlock the work$/ do + uncheck("work_restricted") +end + +When /^I unlock the work "([^"]*)"$/ do |work| + step %{I edit the work "#{work}"} + step %{I unlock the work} + step %{I post the work} +end + +When /^I list the work "([^"]*)" as inspiration$/ do |title| + work = Work.find_by(title: title) + check("parent-options-show") + url_of_work = work_url(work).sub("www.example.com", ArchiveConfig.APP_HOST) + with_scope("#parent-options") do + fill_in("URL", with: url_of_work) + end +end + +When /^I list an external work as inspiration$/ do + check("parent-options-show") + with_scope("#parent-options") do + fill_in("URL", with: "https://example.com") + fill_in("Title", with: "Example External") + fill_in("Author", with: "External Author") + select("English", from: "Language") + end +end + +When /^I set the publication date to (\d+) (.*) (\d+)$/ do |day, month, year| + if page.has_selector?("#backdate-options-show") + check("backdate-options-show") if page.find("#backdate-options-show") + select(day.to_s, from: "work[chapter_attributes][published_at(3i)]") + select(month, from: "work[chapter_attributes][published_at(2i)]") + select(year.to_s, from: "work[chapter_attributes][published_at(1i)]") + else + select(day.to_s, from: "chapter[published_at(3i)]") + select(month, from: "chapter[published_at(2i)]") + select(year.to_s, from: "chapter[published_at(1i)]") + end +end + +When /^I set the publication date to today$/ do + today = Date.current + month = today.strftime("%B") + step %{I set the publication date to #{today.day} #{month} #{today.year}} +end + +When /^I browse the "(.*?)" works$/ do |tagname| + tag = Tag.find_by_name(tagname) + visit tag_works_path(tag) + step %{all indexing jobs have been run} + + step "the periodic tag count task is run" +end + +When /^I browse the "(.*?)" works with page parameter "(.*?)"$/ do |tagname, page| + tag = Tag.find_by_name(tagname) + visit tag_works_path(tag, page: page) + step %{all indexing jobs have been run} + + step "the periodic tag count task is run" +end + +When "I browse works in language {string}" do |language_name| + step %{all indexing jobs have been run} + step "the periodic tag count task is run" + + language = Language.find_by(name: language_name) + visit language_works_path(language) +end + +When /^I delete the work "([^"]*)"$/ do |work| + work = Work.find_by(title: CGI.escapeHTML(work)) + visit edit_work_path(work) + step %{I follow "Delete Work"} + + # If JavaScript is enabled, window.confirm will be used and we'll have to accept + if @javascript + expect(page.accept_alert).to eq("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!") + else + click_button("Yes, Delete Work") + end + + step %{all indexing jobs have been run} + + step "the periodic tag count task is run" +end + +When /^I preview the work$/ do + click_button("Preview") + step %{all indexing jobs have been run} + + step "the periodic tag count task is run" +end +When /^I update the work$/ do + click_button("Update") + step %{all indexing jobs have been run} + + step "the periodic tag count task is run" +end +When /^I post the work without preview$/ do + click_button "Post" + step %{all indexing jobs have been run} + + step "the periodic tag count task is run" +end +When /^I post the work$/ do + click_button "Post" + step %{all indexing jobs have been run} +end + +When /^the statistics for all works are updated$/ do + RedisJobSpawner.perform_now("StatCounterJob") + step %{the hit counts for all works are updated} +end + +When /^I add the co-author "([^"]*)" to the work "([^"]*)"$/ do |coauthor, work| + step %{I edit the work "#{work}"} + step %{I invite the co-author "#{coauthor}"} + step %{I post the work without preview} + step %{the user "#{coauthor}" accepts the creator invite for the work "#{work}"} +end + +When /^the user "([^"]*)" accepts the creator invite for the work "([^"]*)"/ do |user, work| + # Make sure that we don't have caching issues with the byline: + step %{I wait 1 second} + u = User.find_by(login: user) + w = Work.find_by(title: work) + w.creatorships.unapproved.for_user(u).each(&:accept!) +end + +When(/^I try to invite the co-authors? "([^"]*)"$/) do |coauthor| + check("co-authors-options-show") + fill_in("pseud_byline", with: "#{coauthor}") +end + +When /^I invite the co-authors? "([^"]*)"$/ do |coauthor| + coauthor.split(",").map(&:strip).reject(&:blank?).each do |user| + step %{the user "#{user}" allows co-creators} + end + step %{I try to invite the co-authors "#{coauthor}"} +end + +When "I give the work to {string}" do |recipient| + fill_in("Gift this work to", with: recipient) +end + +When /^I give the work "([^"]*)" to the user "([^"]*)"$/ do |work_title, recipient| + step %{the user "#{recipient}" exists and is activated} + visit edit_work_path(Work.find_by(title: work_title)) + fill_in("work_recipients", with: "#{recipient}") + click_button("Post") +end + +When /^I add the beginning notes "([^"]*)"$/ do |notes| + check("at the beginning") + fill_in("work_notes", with: "#{notes}") +end + +When /^I add the end notes "([^"]*)"$/ do |notes| + check("at the end") + fill_in("work_endnotes", with: "#{notes}") +end + +When "I add the beginning notes {string} to the work {string}" do |notes, work| + step %{I am logged in as the author of "#{work}"} + step %{I edit the work "#{work}"} + step %{I add the beginning notes "#{notes}"} + step %{I post the work} +end + +When "I add the end notes {string} to the work {string}" do |notes, work| + step %{I am logged in as the author of "#{work}"} + step %{I edit the work "#{work}"} + step %{I add the end notes "#{notes}"} + step %{I post the work} +end + +When /^I mark the work "([^"]*)" for later$/ do |work| + work = Work.find_by(title: work) + visit work_url(work) + step %{I follow "Mark for Later"} + step "the readings are saved to the database" +end + +When /^I follow the recent chapter link for the work "([^\"]*)"$/ do |work| + work = Work.find_by_title(work) + work_id = work.id.to_s + find("#work_#{work_id} dd.chapters a").click +end + +When "I follow the kudos link for the work {string}" do |work| + work = Work.find_by(title: work) + find("#work_#{work.id} dd.kudos a").click +end + +When "I follow the comments link for the work {string}" do |work| + work = Work.find_by(title: work) + find("#work_#{work.id} dd.comments a").click +end + +When "the cache for the work {string} is cleared" do |title| + work = Work.find_by(title: title) + + # Delay to force the updated_at that gets set by .touch to be new + step "it is currently 1 second from now" + # Touch the work to actually expire the cache + work.touch +end + +When "the statistics for the work {string} are updated" do |title| + step %{the statistics for all works are updated} + step %{all indexing jobs have been run} + step %{the cache for the work "#{title}" is cleared} +end + +When /^the hit counts for all works are updated$/ do + step "all AJAX requests are complete" + RedisJobSpawner.perform_now("HitCountUpdateJob") +end + +When /^all hit count information is reset$/ do + redis = RedisHitCounter.redis + redis.keys.each do |key| + redis.del(key) + end +end + +### THEN +Then /^I should see Updated today$/ do + today = Date.current.to_s + step "I should see \"Updated:#{today}\"" +end + +Then /^I should not see Updated today$/ do + today = Date.current.to_s + step "I should not see \"Updated:#{today}\"" +end + +Then /^I should see Completed today$/ do + today = Date.current.to_s + step "I should see \"Completed:#{today}\"" +end + +Then /^I should not see Completed today$/ do + today = Date.current.to_s + step "I should not see \"Completed:#{today}\"" +end + +Then /^I should find a list for associations$/ do + page.should have_xpath("//ul[@class=\"associations\"]") +end + +Then /^I should not find a list for associations$/ do + page.should_not have_xpath("//ul[@class=\"associations\"]") +end + +Then /^the work "([^"]*)" should be deleted$/ do |work| + assert !Work.where(title: work).exists? +end + +Then /^the Remove Me As Chapter Co-Creator option should be on the ([\d]+)(?:st|nd|rd|th) chapter$/ do |chapter_number| + step %{I should see "Remove Me As Chapter Co-Creator" within "ul#sortable_chapter_list > li:nth-of-type(#{chapter_number})"} +end + +Then /^the Remove Me As Chapter Co-Creator option should not be on the ([\d]+)(?:st|nd|rd|th) chapter$/ do |chapter_number| + step %{I should not see "Remove Me As Chapter Co-Creator" within "ul#sortable_chapter_list > li:nth-of-type(#{chapter_number})"} +end + +Then "I should see {string} within the work blurb of {string}" do |content, work| + work = Work.find_by(title: work) + step %{I should see "#{content}" within "li#work_#{work.id}"} +end + +Then "I should not see {string} within the work blurb of {string}" do |content, work| + work = Work.find_by(title: work) + step %{I should not see "#{content}" within "li#work_#{work.id}"} +end + +Then "I should see an HTML comment containing the number {int} within {string}" do |expected_number, selector| + html = page.find(selector).native.inner_html + comment_match = html.match(/.*.*/) + expect(comment_match).not_to be_nil + number = comment_match[1].to_i + expect(number).to eq(expected_number) +end diff --git a/features/support/capybara.rb b/features/support/capybara.rb new file mode 100644 index 0000000..422b0c4 --- /dev/null +++ b/features/support/capybara.rb @@ -0,0 +1,78 @@ +# Produce a screenshot for each failure. +require 'capybara-screenshot/cucumber' + +# Use environment variables to set the host and the port used for tests: +CAPYBARA_HOST = ENV["DOCKER"] ? `hostname`.strip : "localhost" +CAPYBARA_PORT = ENV["CAPYBARA_PORT"] || 5100 +CAPYBARA_URL = "http://#{CAPYBARA_HOST}:#{CAPYBARA_PORT}".freeze + +Capybara.configure do |config| + # Capybara 1.x behavior. + config.match = :prefer_exact + + # Increased timeout to minimise failures on CI servers. + config.default_max_wait_time = 25 + + # Capybara 2.x behavior: match rendered text, squish whitespace by default. + config.default_normalize_ws = true + + # Capybara 3.x changes the default server to Puma; we have WEBRick + # (a dependency of Mechanize, used for importing; also used for the + # Rails development server), so we'll stick with that for now. + config.server = :webrick + + # Make server accessible from the outside world. Note that we don't use + # CAPYBARA_HOST here because this is the IP that the server binds to, not the + # host that we want to use for tests: + config.server_host = "0.0.0.0" if ENV["CHROME_URL"] + + # Specify the port used for tests: + config.server_port = CAPYBARA_PORT +end + +# Modified from https://github.com/teamcapybara/capybara/blob/49cf69c40f4b25931aecab162fb3285d8fe5bff7/lib/capybara/registrations/drivers.rb#L31-L42 +Capybara.register_driver :selenium_chrome_headless do |app| + browser_options = ::Selenium::WebDriver::Chrome::Options.new.tap do |opts| + opts.add_argument("--headless") + + # Workaround from https://bugs.chromium.org/p/chromedriver/issues/detail?id=2660#c13 + opts.add_argument("--disable-site-isolation-trials") + end + + options = if ENV["CHROME_URL"] + # Special handling for Docker, modified from the instructions at + # https://evilmartians.com/chronicles/system-of-a-test-setting-up-end-to-end-rails-testing + { browser: :remote, options: browser_options, url: ENV["CHROME_URL"] } + else + { browser: :chrome, options: browser_options } + end + + Capybara::Selenium::Driver.new(app, **options) +end + +# Make sure we get full-page screenshots on failure: +Capybara::Screenshot.register_driver :selenium_chrome_headless do |driver, path| + # From https://github.com/madebylotus/capybara-full_screenshot/blob/bf1c3ede89e01b847f7b0dc7d71cd73b25175cd5/lib/capybara/full_screenshot/rspec_helpers.rb#L5-L8 + width = Capybara.page.execute_script(<<~WIDTH_SCRIPT.squish) + return Math.max(document.body.scrollWidth, + document.body.offsetWidth, + document.documentElement.clientWidth, + document.documentElement.scrollWidth, + document.documentElement.offsetWidth); + WIDTH_SCRIPT + + height = Capybara.page.execute_script(<<~HEIGHT_SCRIPT.squish) + return Math.max(document.body.scrollHeight, + document.body.offsetHeight, + document.documentElement.clientHeight, + document.documentElement.scrollHeight, + document.documentElement.offsetHeight); + HEIGHT_SCRIPT + + Capybara.current_session.current_window.resize_to(width + 100, height + 100) + + driver.browser.save_screenshot(path) +end + +Capybara.default_driver = :rack_test +Capybara.javascript_driver = :selenium_chrome_headless diff --git a/features/support/elapsed_time.rb b/features/support/elapsed_time.rb new file mode 100644 index 0000000..2040ce2 --- /dev/null +++ b/features/support/elapsed_time.rb @@ -0,0 +1,6 @@ +module ElapsedTime + def print_elapsed_time(io, start_time) + elapsed_time = (((Time.now - start_time) * 100).to_i / 100.0) + io.print " (#{elapsed_time}s)" + end +end diff --git a/features/support/email.rb b/features/support/email.rb new file mode 100644 index 0000000..f8e0b2f --- /dev/null +++ b/features/support/email.rb @@ -0,0 +1,25 @@ +module EmailHelpers + # Maps a name to an email address. Used by email_steps + + def email_for(to) + case to + + # add your own name => email address mappings here + + when /^#{capture_model}$/ + model($1).email + + when /^"([^@]*)"$/ + user = User.find_by(login: $1) + user.email + + when /^"(.*)"$/ + $1 + + else + to + end + end +end + +World(EmailHelpers) diff --git a/features/support/env.rb b/features/support/env.rb new file mode 100644 index 0000000..0208ec3 --- /dev/null +++ b/features/support/env.rb @@ -0,0 +1,76 @@ +# The only manually edited section of this file. +# SimpleCov must be required before any application code. + +require "simplecov" + +# Make sure that we define the hook for creating all ratings, warnings, and +# categories before we define the database cleaner hooks, so that we aren't +# constantly erasing and recreating all ratings/warnings/categories: +Before do + # TODO: Combine these three tag steps and remove them from all scenarios, + # since they're already run at the start of every scenario: + step %{the default ratings exist} + step %{the basic warnings exist} + step %{the basic categories exist} +end + +# IMPORTANT: This file is generated by cucumber-rails - edit at your own peril. +# It is recommended to regenerate this file in the future when you upgrade to a +# newer version of cucumber-rails. Consider adding your own code to a new file +# instead of editing this one. Cucumber will automatically load all features/**/*.rb +# files. + +require 'cucumber/rails' + +# frozen_string_literal: true + +# Capybara defaults to CSS3 selectors rather than XPath. +# If you'd prefer to use XPath, just uncomment this line and adjust any +# selectors in your step definitions to use the XPath syntax. +# Capybara.default_selector = :xpath + +# By default, any exception happening in your Rails application will bubble up +# to Cucumber so that your scenario will fail. This is a different from how +# your application behaves in the production environment, where an error page will +# be rendered instead. +# +# Sometimes we want to override this default behaviour and allow Rails to rescue +# exceptions and display an error page (just like when the app is running in production). +# Typical scenarios where you want to do this is when you test your error pages. +# There are two ways to allow Rails to rescue exceptions: +# +# 1) Tag your scenario (or feature) with @allow-rescue +# +# 2) Set the value below to true. Beware that doing this globally is not +# recommended as it will mask a lot of errors for you! +# +ActionController::Base.allow_rescue = false + +# Remove/comment out the lines below if your app doesn't have a database. +# For some databases (like MongoDB and CouchDB) you may need to use :truncation instead. +begin + DatabaseCleaner.strategy = :transaction +rescue NameError + raise "You need to add database_cleaner to your Gemfile (in the :test group) if you wish to use it." +end + +# You may also want to configure DatabaseCleaner to use different strategies for certain features and scenarios. +# See the DatabaseCleaner documentation for details. Example: +# +# Before('@no-txn,@selenium,@culerity,@celerity,@javascript') do +# # { except: [:widgets] } may not do what you expect here +# # as Cucumber::Rails::Database.javascript_strategy overrides +# # this setting. +# DatabaseCleaner.strategy = :truncation +# end +# +# Before('not @no-txn', 'not @selenium', 'not @culerity', 'not @celerity', 'not @javascript') do +# DatabaseCleaner.strategy = :transaction +# end +# + +# Possible values are :truncation and :transaction +# The :transaction strategy is faster, but might give you threading problems. +# See https://github.com/cucumber/cucumber-rails/blob/master/features/choose_javascript_database_strategy.feature +Cucumber::Rails::Database.javascript_strategy = :truncation + diff --git a/features/support/formatter.rb b/features/support/formatter.rb new file mode 100644 index 0000000..5bcfa91 --- /dev/null +++ b/features/support/formatter.rb @@ -0,0 +1,51 @@ +# Adapted from https://github.com/tpope/fivemat +require "cucumber/formatter/progress" +require_relative "./elapsed_time" + +module Ao3Cucumber + class Formatter < ::Cucumber::Formatter::Progress + include ::ElapsedTime + + def on_test_case_started(event) + super + feature = gherkin_document.feature + + return if same_feature_as_previous_test_case?(feature) + + after_feature unless @current_feature.nil? + before_feature(feature) + end + + def on_test_run_finished(_event) + after_feature + super + end + + private + + def before_feature(feature) + # Print the feature's file name. + @io.puts current_feature_uri + @io.flush + @current_feature = feature + @start_time = Time.current + end + + def after_feature + print_elapsed_time @io, @start_time + @io.puts + @io.puts + @io.flush + + print_elements(@pending_step_matches, :pending, "steps") + print_elements(@failed_results, :failed, "steps") + + @pending_step_matches = [] + @failed_results = [] + end + + def same_feature_as_previous_test_case?(feature) + @current_feature && @current_feature.location == feature.location + end + end +end diff --git a/features/support/hooks.rb b/features/support/hooks.rb new file mode 100644 index 0000000..e6b830b --- /dev/null +++ b/features/support/hooks.rb @@ -0,0 +1,79 @@ +require "cucumber/rspec/doubles" +require "cucumber/timecop" +require "email_spec/cucumber" + +Before do + # Create default settings if necessary, since the database is truncated + # after every test. + # + # Enable our experimental caching, skipping validations which require + # setting an admin as the last updater. + AdminSetting.default.update_attribute(:enable_test_caching, true) + + # Create default language and locale. + Locale.default + + # Clears used values for all generators. + Faker::UniqueGenerator.clear + + # Reset global locale setting. + I18n.locale = I18n.default_locale + + # Assume all spam checks pass by default. + allow(Akismetor).to receive(:spam?).and_return(false) + + # Don't authenticate for Zoho. + allow_any_instance_of(ZohoAuthClient).to receive(:access_token) + + # Clear Memcached + Rails.cache.clear + + # Remove old tag feeds + page_cache_dir = Rails.root.join("public/test_cache") + FileUtils.remove_dir(page_cache_dir, true) if Dir.exist?(page_cache_dir) + + # Clear Redis + REDIS_AUTOCOMPLETE.flushall + REDIS_GENERAL.flushall + REDIS_HITS.flushall + REDIS_KUDOS.flushall + REDIS_RESQUE.flushall + REDIS_ROLLOUT.flushall + + Indexer.all.map(&:prepare_for_testing) +end + +After do + Indexer.all.map(&:delete_index) +end + +@javascript = false +Before "@javascript" do + @javascript = true + + Capybara.app_host = CAPYBARA_URL +end + +Before "not @javascript" do + Capybara.app_host = "http://www.example.com" +end + +Before "@disable_caching" do + ActionController::Base.perform_caching = false +end + +After "@disable_caching" do + ActionController::Base.perform_caching = true +end + +Before "@set-default-skin" do + # Create a default skin: + AdminSetting.current.update_attribute(:default_skin, Skin.default) +end + +Before "@load-default-skin" do + # Load the site skin and make it the default: + Skin.load_site_css + Skin.set_default_to_current_version + AdminSetting.current.update_attribute(:default_skin, Skin.default) +end diff --git a/features/support/minitest.rb b/features/support/minitest.rb new file mode 100644 index 0000000..fe896e7 --- /dev/null +++ b/features/support/minitest.rb @@ -0,0 +1,3 @@ +# https://github.com/cucumber/cucumber-ruby/issues/751 + +World(MultiTest::MinitestWorld) diff --git a/features/support/paths.rb b/features/support/paths.rb new file mode 100644 index 0000000..9f7751f --- /dev/null +++ b/features/support/paths.rb @@ -0,0 +1,311 @@ +module NavigationHelpers + # Maps a name to a path. Used by the + # + # When /^I go to (.+)$/ do |page_name| + # + # step definition in web_steps.rb + # + def path_to(page_name) + case page_name + + when /the home\s?page/ + '/' + when /the media page/ + media_index_path + when /^the search bookmarks page$/i + step %{all indexing jobs have been run} + search_bookmarks_path + when /^the search tags page$/i + step %{all indexing jobs have been run} + search_tags_path + when /^the search works page$/i + step %{all indexing jobs have been run} + search_works_path + when /^the search people page$/i + step %{all indexing jobs have been run} + search_people_path + when /^the bookmarks page$/i + # This cached page only expires by time, not by any user action; + # just clear it every time. + Rails.cache.delete "bookmarks/index/latest/v2_true" + bookmarks_path + when /^the works page$/i + # This cached page only expires by time, not by any user action; + # just clear it every time. + Rails.cache.delete "works/index/latest/v1" + works_path + when /^the admin login page$/i + new_admin_session_path + when /^the redirect page$/i + redirect_path + + # the following are examples using path_to_pickle + + when /^#{capture_model}(?:'s)? page$/ # eg. the forum's page + path_to_pickle $1 + + when /^#{capture_model}(?:'s)? #{capture_model}(?:'s)? page$/ # eg. the forum's post's page + path_to_pickle $1, $2 + + when /^#{capture_model}(?:'s)? #{capture_model}'s (.+?) page$/ # eg. the forum's post's comments page + path_to_pickle $1, $2, extra: $3 # or the forum's post's edit page + + when /^#{capture_model}(?:'s)? (.+?) page$/ # eg. the forum's posts page + path_to_pickle $1, extra: $2 # or the forum's edit page + + # Add more mappings here. + + when /^the tagsets page$/i + tag_sets_path + when /^the login page$/i + new_user_session_path + when /^account creation page$/i + signup_path + when /^invite requests page$/i + invite_requests_path + when /^the manage invite queue page$/i + manage_invite_requests_path + when /the blocked users page for "([^"]*)"/ + user_blocked_users_path(Regexp.last_match(1)) + when /the muted users page for "([^"]*)"/ + user_muted_users_path(Regexp.last_match(1)) + when /^(.*)'s claims page$/ + user_claims_path(Regexp.last_match(1)) + when /^(.*)'s signups page$/ + user_signups_path(Regexp.last_match(1)) + when /^(.*)'s inbox page$/ + user_inbox_path(Regexp.last_match(1)) + when /^(.*)'s co-creator requests page$/ + user_creatorships_path(Regexp.last_match(1)) + when /the gifts page$/ + gifts_path + when /the gifts page for the recipient (.*)$/ + gifts_path(recipient: $1) + when /^the assignments page for "(.*)"$/ + user_assignments_path(Regexp.last_match(1)) + when /^(.*)'s collection items page$/ + user_collection_items_path(Regexp.last_match(1)) + when /^(.*)'s gifts page/ + user_gifts_path(user_id: $1) + when /the import page/ + new_work_path(import: 'true') + when /the public skins page/ + skins_path + when /the work-skins page/ + skins_path(skin_type: "WorkSkin") + when /^(.*?)(?:'s)? user page$/i + user_path(id: $1) + when /^the (user|dashboard) page for user "(.*?)" with pseud "(.*?)"$/i + user_pseud_path(user_id: Regexp.last_match(2), id: Regexp.last_match(3)) + when /^(.*?)(?:'s)? user url$/i + user_url(id: $1) + when /^([^ ]*?)(?:'s)? works page$/i + step %{all indexing jobs have been run} + user_works_path(user_id: $1) + when /^the works page for user "(.*?)" with pseud "(.*?)"$/i + step %{all indexing jobs have been run} + user_pseud_works_path(user_id: Regexp.last_match(1), pseud_id: Regexp.last_match(2)) + when /^the "(.*)" work page/ + # TODO: Avoid this in favor of 'the work "title"', and eventually remove. + work_path(Work.find_by(title: $1)) + when /^the work page with title (.*)/ + # TODO: Avoid this in favor of 'the work "title"', and eventually remove. + work_path(Work.find_by(title: $1)) + when /^the work "(.*?)"$/ + work_path(Work.find_by(title: $1)) + when /^the work "(.*?)" in full mode$/ + work_path(Work.find_by(title: $1), view_full_work: true) + when /^the ([\d]+)(?:st|nd|rd|th) chapter of the work "(.*?)"$/ + work = Work.find_by(title: $2) + chapter = work.chapters_in_order(include_content: false)[$1.to_i - 1] + work_chapter_path(work, chapter) + when /^the bookmarks page for user "(.*)" with pseud "(.*)"$/i + step %{all indexing jobs have been run} + user_pseud_bookmarks_path(user_id: $1, pseud_id: $2) + when /^(.*?)(?:'s)? bookmarks page$/i + step %{all indexing jobs have been run} + user_bookmarks_path(user_id: $1) + when /^(.*?)(?:'s)? pseuds page$/i + user_pseuds_path(user_id: $1) + when /^(.*?)(?:'s)? manage invitations page$/i + manage_user_invitations_path(user_id: $1) + when /^(.*?)(?:'s)? invitations page$/i + user_invitations_path(user_id: $1) + when /^(.*?)(?:'s)? reading page$/i + user_readings_path(user_id: $1) + when /^(.*?)(?:'s)? series page$/i + user_series_index_path(user_id: $1) + when /^the series page for user "(.*?)" with pseud "(.*?)"$/i + step %{all indexing jobs have been run} + user_pseud_series_index_path(user_id: Regexp.last_match(1), pseud_id: Regexp.last_match(2)) + when /^(.*?)(?:'s)? stats page$/i + user_stats_path(user_id: $1) + when /^(.*?)(?:'s)? preferences page$/i + user_preferences_path(user_id: $1) + when /^(.*?)(?:'s)? related works page$/i + user_related_works_path(user_id: $1) + when /^the subscriptions page for "(.*)"$/i + user_subscriptions_path(user_id: $1) + when /^(.*?)(?:'s)? profile page$/i + user_profile_path(user_id: $1) + when /^(.*)'s skins page/ + user_skins_path(user_id: $1) + when /^"(.*)" skin page/ + skin_path(Skin.find_by(title: $1)) + when /^the new skin page/ + new_skin_path + when /^the new wizard skin page/ + new_skin_path(wizard: true) + when /^"(.*)" edit skin page/ + edit_skin_path(Skin.find_by(title: $1)) + when /^"(.*)" edit wizard skin page/ + edit_skin_path(Skin.find_by(title: $1), wizard: true) + when /^the new collection page/ + new_collection_path + when /^"(.*)" collection's page$/i # e.g. when I go to "Collection name" collection's page + step %{all indexing jobs have been run} # reindex to show recent works/bookmarks + collection_path(Collection.find_by(title: $1)) + when /^"(.*)" collection edit page$/i + edit_collection_path(Collection.find_by(title: $1)) + when /^the "(.*)" signups page$/i # e.g. when I go to the "Collection name" signup page + collection_signups_path(Collection.find_by(title: $1)) + when /^the "(.*)" requests page$/i # e.g. when I go to the "Collection name" signup page + collection_requests_path(Collection.find_by(title: $1)) + when /^the "(.*)" assignments page$/i # e.g. when I go to the "Collection name" assignments page + collection_assignments_path(Collection.find_by(title: $1)) + when /^the "(.*)" participants page$/i # e.g. when I go to the "Collection name" participants page + collection_participants_path(Collection.find_by(title: $1)) + when /^"(.*)" collection's url$/i # e.g. when I go to "Collection name" collection's url + collection_url(Collection.find_by(title: $1)) + when /^"(.*)" gift exchange edit page$/i + edit_collection_gift_exchange_path(Collection.find_by(title: $1)) + when /^"(.*)" gift exchange matching page$/i + collection_potential_matches_path(Collection.find_by(title: $1)) + when /^the works tagged "(.*?)" in collection "(.*?)"$/i + step %{all indexing jobs have been run} + collection_tag_works_path(Collection.find_by(title: $2), Tag.find_by_name($1)) + when /^the works tagged "(.*)"$/i + step %{all indexing jobs have been run} + tag_works_path(Tag.find_by_name($1)) + when /^the bookmarks tagged "(.*)"$/i + step %{all indexing jobs have been run} + tag_bookmarks_path(Tag.find_by_name($1)) + when /^the bookmarks in collection "(.*)"$/i + step %{all indexing jobs have been run} + collection_bookmarks_path(Collection.find_by(title: $1)) + when /^the first bookmark for the work "(.*?)"$/i + work = Work.find_by(title: Regexp.last_match(1)) + bookmark_path(work.bookmarks.first) + when /^the new bookmark page for work "(.*?)"$/i + new_work_bookmark_path(Work.find_by(title: Regexp.last_match(1))) + when /^the tag comments? page for "(.*)"$/i + tag_comments_path(Tag.find_by_name($1)) + when /^the work comments? page for "(.*?)"$/i + work_comments_path(Work.find_by(title: $1), show_comments: true) + when /^the work kudos page for "(.*?)"$/i + work_kudos_path(Work.find_by(title: $1)) + when /^the FAQ reorder page$/i + manage_archive_faqs_path + when /^the Wrangling Guidelines reorder page$/i + manage_wrangling_guidelines_path + when /^the tos page$/i + tos_path + when /^the faq page$/i + archive_faqs_path + when /^the wrangling guidelines page$/i + wrangling_guidelines_path + when /^the support page$/i + new_feedback_report_path + when /^the new tag ?set page$/i + new_tag_set_path + when /^the "(.*)" tag ?set edit page$/i + edit_tag_set_path(OwnedTagSet.find_by(title: $1)) + when /^the "(.*)" tag ?set page$/i + tag_set_path(OwnedTagSet.find_by(title: $1)) + when /^the Open Doors tools page$/i + opendoors_tools_path + when /^the Open Doors external authors page$/i + opendoors_external_authors_path + when /^the claim page for "(.*)"$/i + claim_path(invitation_token: Invitation.find_by(invitee_email: $1).token) + when /^the languages page$/i + languages_path + when /^the wranglers page$/i + tag_wranglers_path + when /^the wrangling page for "(.*)"$/i + tag_wrangler_path(User.find_by(login: Regexp.last_match(1))) + when /^the unassigned fandoms page $/i + unassigned_fandoms_path + when /^the "(.*)" fandoms page$/i + media_fandoms_path(Media.find_by(name: Regexp.last_match(1))) + when /^the "(.*)" tag page$/i + tag_path(Tag.find_by_name($1)) + when /^the '(.*)' tag edit page$/i + edit_tag_path(Tag.find_by(name: Regexp.last_match(1))) + when /^the "(.*)" tag edit page$/i + edit_tag_path(Tag.find_by(name: Regexp.last_match(1))) + when /^the new tag page$/i + new_tag_path + when /^the wrangling tools page$/ + tag_wranglings_path + when /^the new external work page$/i + new_external_work_path + when /^the external works page$/i + external_works_path + when /^the external works page with only duplicates$/i + external_works_path(show: :duplicates) + when /^the new user password page$/i + new_user_password_path + when /^the edit user password page$/i + edit_user_password_path + when /^the (.*) mass bin$/i + tag_wranglings_path(show: Regexp.last_match(1).pluralize) + when /^the tags page$/i + tags_path + when /^the orphan all works page$/i + new_orphan_path + + # Admin Pages + when /^the admin-posts page$/i + admin_posts_path + when /^the "(.*)" admin post page$/i + admin_post_path(AdminPost.find_by(title: Regexp.last_match(1))) + when /^the unreviewed comments page for the admin post "(.*)"$/i + unreviewed_admin_post_comments_path(AdminPost.find_by(title: Regexp.last_match(1))) + when /^the admin-settings page$/i + admin_settings_path + when /^the admin-activities page$/i + admin_activities_path + when /^the admin-blacklist page$/i + admin_blacklisted_emails_path + when /^the manage users page$/ + step "all indexing jobs have been run" + admin_users_path + when /^the bulk email search page$/i + bulk_search_admin_users_path + when /^the user administration page for "(.*)"$/i + admin_user_path(User.find_by(login: $1)) + when /^the new admin password page$/i + new_admin_password_path + when /^the edit admin password page$/i + edit_admin_password_path + + # Here is an example that pulls values out of the Regexp: + # + # when /^(.*)'s profile page$/i + # user_profile_path(User.find_by(login: $1)) + + else + begin + page_name =~ /the (.*) page/ + path_components = $1.split(/\s+/) + self.send(path_components.push('path').join('_').to_sym) + rescue Object => e + raise "Can't find mapping from \"#{page_name}\" to a path.\n" + + "Now, go and add a mapping in #{__FILE__}" + end + end + end +end + +World(NavigationHelpers) diff --git a/features/support/pickle.rb b/features/support/pickle.rb new file mode 100644 index 0000000..3ec15d1 --- /dev/null +++ b/features/support/pickle.rb @@ -0,0 +1,26 @@ +# this file generated by script/generate pickle [paths] [email] +# +# Make sure that you are loading your factory of choice in your cucumber environment +# +# For machinist add: features/support/machinist.rb +# +# require 'machinist/active_record' # or your chosen adaptor +# require File.dirname(__FILE__) + '/../../spec/blueprints' # or wherever your blueprints are +# Before { Sham.reset } # to reset Sham's seed between scenarios so each run has same random sequences +# +# For FactoryBot add: features/support/factory_bot.rb +# +# require 'factory_bot' +# require File.dirname(__FILE__) + '/../../spec/factories' # or wherever your factories are +# +# You may also need to add gem dependencies on your factory of choice in config/environments/cucumber.rb + +require 'pickle/world' +# Example of configuring pickle: +# +# Pickle.configure do |config| +# config.adapters = [:machinist] +# config.map 'I', 'myself', 'me', 'my', to: 'user: "me"' +# end +require 'pickle/path/world' +require 'pickle/email/world' diff --git a/features/support/user.rb b/features/support/user.rb new file mode 100644 index 0000000..36dd65d --- /dev/null +++ b/features/support/user.rb @@ -0,0 +1,33 @@ +module UserHelpers + def find_or_create_new_user(login, password, activate: true) + user = User.find_by(login: login) + if user.blank? + params = { login: login, password: password } + params[:confirmed_at] = nil unless activate + user = FactoryBot.create(:user, params) + # Explicitly add pseud to autocomplete in test env as FactoryBot is not + # triggering Sweeper hooks + user.pseuds.first.add_to_autocomplete + else + user.skip_password_change_notification! + user.password = password + user.password_confirmation = password + user.save + end + user + end + + # Like find_or_create_new_user above, but with fewer options, and it doesn't + # invalidate the session for any pre-existing users (because it's not setting + # the password). + def ensure_user(login) + user = User.find_by(login: login) + return user unless user.nil? + + FactoryBot.create(:user, login: login).tap do |u| + u.default_pseud.add_to_autocomplete + end + end +end + +World(UserHelpers) diff --git a/features/support/vcr.rb b/features/support/vcr.rb new file mode 100644 index 0000000..bad5b54 --- /dev/null +++ b/features/support/vcr.rb @@ -0,0 +1,46 @@ +require 'vcr' + +VCR.configure do |c| + c.ignore_localhost = true + c.cassette_library_dir = 'features/cassette_library' + c.hook_into :webmock + c.allow_http_connections_when_no_cassette = true + + # Cassettes are now deleted and re-recorded after 30 days. This will ensure + # that LJ/DW/DA don't update their HTML and break our story parser without us + # knowing about it. + c.default_cassette_options = { + record: :once, + re_record_interval: 30.days + } +end + +VCR.cucumber_tags do |t| + t.tags "@archivist_import", use_scenario_name: true + + t.tags "@work_import_special_characters_auto_utf" + t.tags "@work_import_special_characters_auto_latin" + t.tags "@work_import_special_characters_man_latin" + t.tags "@work_import_special_characters_man_cp" + t.tags "@work_import_special_characters_man_utf" + + t.tags "@import_da_title_link" + t.tags "@import_da_gallery_link" + t.tags "@import_da_fic" + + t.tags "@import_dw" + t.tags "@import_dw_tables" + t.tags "@import_dw_tables_no_backdate" + t.tags "@import_dw_comm" + t.tags "@import_dw_multi_chapter" + + t.tags "@import_ffn" + t.tags "@import_ffn_multi_chapter" + + t.tags "@import_lj" + t.tags "@import_lj_tables" + t.tags "@import_lj_no_backdate" + t.tags "@import_lj_comm" + t.tags "@import_lj_multi_chapter" + t.tags "@import_lj_underscores" +end diff --git a/features/support/wait_for_ajax.rb b/features/support/wait_for_ajax.rb new file mode 100644 index 0000000..1679ef1 --- /dev/null +++ b/features/support/wait_for_ajax.rb @@ -0,0 +1,11 @@ +# from https://thoughtbot.com/blog/automatically-wait-for-ajax-with-capybara + +def wait_for_ajax + Timeout.timeout(Capybara.default_max_wait_time) do + loop until finished_all_ajax_requests? + end +end + +def finished_all_ajax_requests? + page.evaluate_script('jQuery.active').zero? +end diff --git a/features/support/webmock.rb b/features/support/webmock.rb new file mode 100644 index 0000000..93de180 --- /dev/null +++ b/features/support/webmock.rb @@ -0,0 +1 @@ +require "webmock/cucumber" diff --git a/features/tag_sets/tag_set.feature b/features/tag_sets/tag_set.feature new file mode 100644 index 0000000..d3fbeb6 --- /dev/null +++ b/features/tag_sets/tag_set.feature @@ -0,0 +1,175 @@ +# encoding: utf-8 +@tag_sets +Feature: Creating and editing tag sets + + Scenario: A user should be able to create a tag set with a title + Given I am logged in as "tagsetter" + And I go to the tagsets page + And I follow the add new tagset link + And I fill in "Title" with "Empty Tag Set" + And I submit + Then I should see a create confirmation message + And I should see "About Empty Tag Set" + And I should see "tagsetter" within ".meta" + + Scenario: A user should be able to create a tag set with noncanonical tags + Given I am logged in as "tagsetter" + And I set up the tag set "Noncanonical Tags" with the fandom tags "Ywerwe, Blah di blah, Foooo" + Then I should see a create confirmation message + And I should see "Ywerwe" + + Scenario: A user should be able to add additional tags to an existing set + Given I am logged in as "tagsetter" + And I set up the tag set "Noncanonical Tags" with the fandom tags "Ywerwe, Blah di blah, Foooo" + When I add the character tags "Bababa, Lalala" and the freeform tags "wheee, gloopy" to the tag set "Noncanonical Tags" + Then I should see an update confirmation message + And I should see "wheee" + + Scenario: A user should be able to add and remove fandom tags for a tag set they own + Given I am logged in + And I set up the tag set "Fandoms" with the fandom tags "One, Two" + When I add the fandom tags "Three, Four" to the tag set "Fandoms" + Then I should see "One" + And I should see "Two" + And I should see "Three" + And I should see "Four" + When I remove the fandom tags "One, Three" from the tag set "Fandoms" + Then I should see "Two" + And I should see "Four" + And I should not see "One" + And I should not see "Three" + + Scenario: A user should be able to add and remove character tags for a tag set they own + Given I am logged in + And I set up the tag set "Characters" with the character tags "Character 1, Character 2" + When I add the character tags "Character 3, Character 4" to the tag set "Characters" + Then I should see "Character 1" + And I should see "Character 2" + And I should see "Character 3" + And I should see "Character 4" + When I remove the character tags "Character 2, Character 4" from the tag set "Characters" + Then I should see "Character 1" + And I should see "Character 3" + And I should not see "Character 2" + And I should not see "Character 4" + + Scenario: A user should be able to add and remove relationship tags for a tag set they own + Given I am logged in + And I set up the tag set "Relationships" with the relationship tags "One/Two, 1 & 2" + When I add the relationship tags "3/4, Three & Four" to the tag set "Relationships" + Then I should see "One/Two" + And I should see "1 & 2" + And I should see "3/4" + And I should see "Three & Four" + When I remove the relationship tags "One/Two, Three & Four" from the tag set "Relationships" + Then I should see "1 & 2" + And I should see "3/4" + And I should not see "One/Two" + And I should not see "Three & Four" + + Scenario: A user should be able to add and remove rating tags for a tag set they own + Given the default ratings exist + And I am logged in + And I set up the tag set "Ratings" with the rating tags "Explicit, Mature" + When I add the rating tags "Teen And Up Audiences, General Audiences" to the tag set "Ratings" + Then I should see "Explicit" + And I should see "Mature" + And I should see "Teen And Up Audiences" + And I should see "General Audiences" + When I remove the rating tags "Explicit, Teen And Up Audiences" from the tag set "Ratings" + Then I should see "Mature" + And I should see "General Audiences" + And I should not see "Explicit" + And I should not see "Teen And Up Audiences" + + Scenario: A user should be able to add and remove category tags for a tag set they own + Given the basic categories exist + And I am logged in + And I set up the tag set "Categories" with the category tags "Other, F/M" + When I add the category tags "F/F, M/M" to the tag set "Categories" + Then I should see "Other" + And I should see "F/M" + And I should see "M/M" + And I should see "F/F" + When I remove the category tags "F/F, Other" from the tag set "Categories" + Then I should see "M/M" + And I should see "F/M" + And I should not see "F/F" + And I should not see "Other" + + Scenario: A user should be able to add and remove warning tags for a tag set they own + Given the basic warnings exist + And I am logged in + And I set up the tag set "Archive Warnings" with the warning tags "Choose Not To Use Archive Warnings" + When I add the warning tags "No Archive Warnings Apply" to the tag set "Archive Warnings" + Then I should see "Choose Not To Use Archive Warnings" + And I should see "No Archive Warnings Apply" + When I remove the warning tags "Choose Not To Use Archive Warnings" from the tag set "Archive Warnings" + Then I should see "No Archive Warnings Apply" + And I should not see "Choose Not To Use Archive Warnings" + + Scenario: If a tag set does not have a visible tag list, only a moderator should be able to see the tags in the set, but everyone should be able to see the tag set + Given I am logged in as "tagsetter" + And I set up the tag set "Tag Set with Non-visible Tag List" with an invisible tag list and the fandom tags "Dallas, Knots Landing, Models Inc" + Then I should see "Dallas" + And I should see "Knots Landing" + And I should see "Models Inc" + When I go to the tagsets page + Then I should see "Tag Set with Non-visible Tag List" + When I log out + And I go to the tagsets page + Then I should see "Tag Set with Non-visible Tag List" + When I follow "Tag Set with Non-visible Tag List" + Then I should not see "Dallas" + And I should not see "Knots Landing" + And I should not see "Models Inc" + And I should see "The moderators have chosen not to make the tags in this set visible to the public (possibly while nominations are underway)." + + Scenario: If a tag set has a visible tag list, everyone should be able to see the tags in the set + Given I am logged in as "tagsetter" + And I set up the tag set "Tag Set with Visible Tag List" with a visible tag list and the fandom tags "Dallas, Knots Landing, Models Inc" + Then I should see "Dallas" + And I should see "Knots Landing" + And I should see "Models Inc" + When I log out + And I view the tag set "Tag Set with Visible Tag List" + Then I should see "Dallas" + And I should see "Knots Landing" + And I should see "Models Inc" + + @javascript + Scenario: A moderator should be able to manually set up and remove associations between tags in their set on the main tag set edit page + Given I am logged in + And I set up the tag set "Associations" with the fandom tags "Major Crimes, The Closer" and the character tags "Brenda Leigh Johnson, Sharon Raydor" + When I go to the "Associations" tag set edit page + And I follow "Add Association" + And I select "Sharon Raydor" from "Tag" + And I select "The Closer" from "Parent tag" + And I press "Update" + Then I should see an update confirmation message + And I should see "Uncategorized Fandoms (2)" + And I should see "Unassociated Characters & Relationships (1)" + When I press "Expand All" + Then I should see "The Closer (1)" + And I should see "Major Crimes (0)" + And "Sharon Raydor" should be associated with the "Uncategorized" fandom "The Closer" + When I go to the "Associations" tag set edit page + And I check "Sharon Raydor (The Closer)" + And I press "Update" + Then I should see an update confirmation message + And I should see "Unassociated Characters & Relationships (2)" + When I press "Expand All" + Then I should see "The Closer (0)" + And I should see "Major Crimes (0)" + When I expand the unassociated characters and relationships + Then "Sharon Raydor" should be an unassociated tag + + Scenario: A moderator can't change the tag set's nomination settings if there + are already nominations + Given I have the nominated tag set "Nominated Tags" + And I am logged in as "tagsetter" + When I go to the "Nominated Tags" tag set edit page + And I fill in "Fandom nomination limit" with "1" + And I fill in "Character nomination limit" with "1" + And I submit + Then I should see "You cannot make changes to nomination settings when nominations already exist. Please review and delete existing nominations first." diff --git a/features/tag_sets/tag_set_associations.feature b/features/tag_sets/tag_set_associations.feature new file mode 100644 index 0000000..bcb3e90 --- /dev/null +++ b/features/tag_sets/tag_set_associations.feature @@ -0,0 +1,123 @@ +# encoding: utf-8 +@tag_sets +Feature: Reviewing tag set associations + + Scenario: If a nominated tag and its parent are approved they should appear on the associations page + Given I nominate and approve fandom "Floobry" and character "Zarrr" in "Nominated Tags" + And I am logged in as "tagsetter" + And I go to the "Nominated Tags" tag set page + Then I should see "don't seem to be associated" + When I follow "Review Associations" + Then I should see "Zarrr → Floobry" + When I check "Zarrr → Floobry" + And I submit + Then I should see "Nominated associations were added" + And I should not see "don't seem to be associated" + + Scenario: If a nominated tag is wrangled into its nominated parent after approval, it should be automatically associated with the parent + Given I nominate and approve fandom "Floobry" and character "Zarrr" in "Nominated Tags" + And the tag "Floobry" is canonized + And I add the fandom "Floobry" to the character "Zarrr" + And the tag "Zarrr" is canonized + When I review associations for "Nominated Tags" + Then I should not see "Zarrr → Floobry" + # TODO: Remove this step when AO3-3757 is fixed: + When the cache for the tag set "Nominated Tags" is expired + And I view the tag set "Nominated Tags" + Then I should not see "Unassociated Characters & Relationships" + And I should not see "don't seem to be associated" + And "Zarrr" should be associated with the "Uncategorized" fandom "Floobry" + + Scenario: If a nominated tag is wrangled to a different fandom after approval, it should still be possible to associate them + Given I nominate and approve fandom "Floobry" and character "Zarrr" in "Nominated Tags" + And a canonical fandom "Barbar" + And I add the fandom "Barbar" to the character "Zarrr" + And the tag "Zarrr" is canonized + When I review associations for "Nominated Tags" + Then I should see "Zarrr → Floobry" + When I check "Zarrr → Floobry" + And I submit + Then I should see "Nominated associations were added" + And I should not see "Unassociated Characters & Relationships" + And I should not see "don't seem to be associated" + And "Zarrr" should be associated with the "Uncategorized" fandom "Floobry" + + Scenario: If a tag set does not exist, no one should be able to see its associations + Given I am logged in as "tagsetter" + When I view associations for a tag set that does not exist + Then I should see "What tag set did you want to look at?" + And I should be on the tagsets page + When I log out + And I view associations for a tag set that does not exist + Then I should see "What tag set did you want to look at?" + And I should be on the tagsets page + + Scenario: Nominating a canonical tag in its fandom does not generate associations for review + Given a canonical character "Jack Carter" in fandom "Eureka" + And I nominate and approve fandom "Eureka" and character "Jack Carter" in "Canonical Associations" + When I review associations for "Canonical Associations" + Then I should not see "Jack Carter" + And I should not see "Eureka" + + Scenario: Nominating a canonical tag in another fandom generates associations for review + Given a canonical character "Nathan Stark" in fandom "Eureka" + And I nominate and approve fandom "Iron Man" and character "Nathan Stark" in "Canonical Associations" + When I review associations for "Canonical Associations" + Then I should see "Nathan Stark → Iron Man" + But I should not see "Eureka" + + Scenario: Nominating a non-canonical tag in its own fandom generates associations for review + Given a canonical character "Jack Carter" in fandom "Eureka" + And a non-canonical character "Nathan Carter" in fandom "Eureka" + And I am logged in as "tagsetter" + And I set up the nominated tag set "Non-canonical Associations" with 1 fandom nom and 2 character noms + And I nominate fandom "Eureka" and characters "Jack Carter,Nathan Carter" in "Non-canonical Associations" as "tagsetter" + When I review nominations for "Non-canonical Associations" + And I approve the nominated fandom tag "Eureka" + And I approve the nominated character tag "Jack Carter" + And I approve the nominated character tag "Nathan Carter" + And I press "Submit" + And I review associations for "Non-canonical Associations" + Then I should see "Nathan Carter → Eureka" + + Scenario: When a nominated non-canonical is renamed, its associations remain for review + Given a canonical character "Jack Carter" in fandom "Eureka" + And a non-canonical character "nathan carter" in fandom "Nathan Stark" + And I am logged in as "tagsetter" + And I set up the nominated tag set "Non-canonical Associations" with 1 fandom nom and 2 character noms + And I nominate fandom "Eureka" and characters "Jack Carter,nathan carter" in "Non-canonical Associations" as "tagsetter" + When I am logged in as a tag wrangler + And I edit the tag "nathan carter" + And I fill in "Name" with "Nathan Carter" + And I press "Save changes" + Then I should see "Tag was updated" + When I am logged in as "tagsetter" + And I review nominations for "Non-canonical Associations" + And I approve the nominated fandom tag "Eureka" + And I approve the nominated character tag "Jack Carter" + And I approve the nominated character tag "nathan carter" + And I press "Submit" + And I review associations for "Non-canonical Associations" + Then I should see "Nathan Carter → Eureka" + + Scenario: Nominating a new tag in an approved fandom generates associations for review + Given a canonical fandom "Eureka" + And I am logged in as "tagsetter" + And I set up the nominated tag set "New Associations" with 1 fandom nom and 2 character noms + And I nominate fandom "Eureka" and character "Jack Stark" in "New Associations" as "tagsetter" + When I review nominations for "New Associations" + And I approve the nominated fandom tag "Eureka" + And I press "Submit" + And I edit nominations for "tagsetter" in "New Associations" to include characters "Jack Stark,Nathan Stark" under fandom "Eureka" + And I review nominations for "New Associations" + Then I should see "Jack Stark" + And I should see "Nathan Stark" + When I approve the nominated character tag "Jack Stark" + And I approve the nominated character tag "Nathan Stark" + And I press "Submit" + And I review associations for "New Associations" + Then I should see "Jack Stark → Eureka" + And I should see "Nathan Stark → Eureka" + + # TODO + # Scenario: Tags with brackets should work in associations diff --git a/features/tag_sets/tag_set_batch_load.feature b/features/tag_sets/tag_set_batch_load.feature new file mode 100644 index 0000000..d425457 --- /dev/null +++ b/features/tag_sets/tag_set_batch_load.feature @@ -0,0 +1,67 @@ +# encoding: utf-8 +@tag_sets +Feature: Batch loading tags and associations for a tag set + + Scenario: Batch load character tags should successfully load characters that are canonical and return characters that are not + Given a fandom exists with name: "MASH (TV)", canonical: true + And a fandom exists with name: "Dallas (TV)", canonical: true + And a character exists with name: "Hawkeye Pierce", canonical: true + And a character exists with name: "Maxwell Klinger", canonical: true + And a character exists with name: "Henry Blake", canonical: true + And a character exists with name: "J. R. Ewing", canonical: true + And a character exists with name: "Sue Ellen Ewing", canonical: true + When I am logged in as "tagsetter" + And I set up the tag set "Batch Loading Characters" with a visible tag list + And I follow "Batch Load" + When I fill in "Batch Load Tag Associations" with + """ + MASH (TV),Hawkeye Pierce,Maxwell Klinger,Henry Blake + Dallas (TV), J. R. Ewing, Sue Ellen Ewing, Pam Ewing + """ + And I press "Submit" + Then I should see "We couldn't add all the tags and associations you wanted -- the ones left below didn't work. See the help for suggestions!" + And I should see "Dallas (TV),Pam Ewing" + And I should not see "MASH (TV),Hawkeye Pierce,Maxwell Klinger,Henry Blake" + And I should not see "Dallas (TV), J. R. Ewing, Sue Ellen Ewing, Pam Ewing" + When I go to the "Batch Loading Characters" tag set page + Then I should see "MASH (TV)" + And I should see "Hawkeye Pierce" + And I should see "Maxwell Klinger" + And I should see "Henry Blake" + And I should see "Dallas (TV)" + And I should see "J. R. Ewing" + And I should see "Sue Ellen Ewing" + And I should not see "Pam Ewing" + + Scenario: Batch load relationship tags should successfully load relationships that are canonical and return characters that are not + Given a fandom exists with name: "MASH (TV)", canonical: true + And a fandom exists with name: "Dallas (TV)", canonical: true + And a relationship exists with name: "BJ/Hawkeye", canonical: true + And a relationship exists with name: "Hawkeye/Margaret Houlihan", canonical: true + And a relationship exists with name: "Hawkeye & Radar", canonical: true + And a relationship exists with name: "J. R. Ewing/Sue Ellen Ewing", canonical: true + And a relationship exists with name: "Ann Ewing/Bobby Ewing", canonical: true + When I am logged in as "tagsetter" + And I set up the tag set "Batch Loading Relationships" with a visible tag list + And I follow "Batch Load" + When I fill in "Batch Load Tag Associations" with + """ + MASH (TV), BJ/Hawkeye, Hawkeye/Margaret Houlihan, Hawkeye & Radar + Dallas (TV),J. R. Ewing/Sue Ellen Ewing,Ann Ewing/Sue Ellen Ewing,Ann Ewing/Bobby Ewing + """ + And I check "Relationships instead?" + And I press "Submit" + Then I should see "We couldn't add all the tags and associations you wanted -- the ones left below didn't work. See the help for suggestions!" + And I should see "Dallas (TV),Ann Ewing/Sue Ellen Ewing" + And I should not see "MASH (TV), BJ/Hawkeye, Hawkeye/Margaret Houlihan, Hawkeye & Radar" + And I should not see "Dallas (TV),J. R. Ewing/Sue Ellen Ewing,Ann Ewing/Sue Ellen Ewing,Ann Ewing/Bobby Ewing" + When I go to the "Batch Loading Relationships" tag set page + Then I should see "MASH (TV)" + And I should see "BJ/Hawkeye" + And I should see "Hawkeye/Margaret Houlihan" + And I should see "Hawkeye & Radar" + And I should see "Dallas (TV)" + And I should see "J. R. Ewing/Sue Ellen Ewing" + And I should see "Ann Ewing/Bobby Ewing" + And I should not see "Ann Ewing/Sue Ellen Ewing" + diff --git a/features/tag_sets/tag_set_delete.feature b/features/tag_sets/tag_set_delete.feature new file mode 100644 index 0000000..8cb973e --- /dev/null +++ b/features/tag_sets/tag_set_delete.feature @@ -0,0 +1,17 @@ +@tag_sets +Feature: deleting tag sets + + # Note that this test is now testing for non-JS enabled browsers. + Scenario: A user should be able to delete a tag set + Given I am logged in as "tagsetter" + And I go to the tagsets page + And I follow the add new tagset link + And I fill in "Title" with "murder_mystery_tags" + And I submit + And I should see a create confirmation message + And I should see "tagsetter" within ".meta" + When I follow "Delete" + Then I should see "Delete Tag Set?" + And I should see "Are you certain you want to delete the murder_mystery_tags Tag Set?" + And I press "Yes, Delete Tag Set" + Then I should see "Your Tag Set murder_mystery_tags was deleted." diff --git a/features/tag_sets/tag_set_nominations.feature b/features/tag_sets/tag_set_nominations.feature new file mode 100644 index 0000000..aee7353 --- /dev/null +++ b/features/tag_sets/tag_set_nominations.feature @@ -0,0 +1,340 @@ +# encoding: utf-8 +@tag_sets +Feature: Nominating and reviewing nominations for a tag set + + Scenario: A tag set should take nominations within the nomination limits + Given I am logged in as "tagsetter" + And I set up the nominated tag set "Nominated Tags" with 0 fandom noms and 3 character noms + Then I should see "Nominate" + When I follow "Nominate" + Then I should see "You can nominate up to 3 characters" + + Scenario: Tag set nominations should nest characters under fandoms if fandoms are being nominated + Given I am logged in as "tagsetter" + And I set up the nominated tag set "Nominated Tags" with 3 fandom noms and 3 character noms + Then I should see "Nominate" + When I follow "Nominate" + Then I should see "You can nominate up to 3 fandoms and up to 3 characters for each one" + + Scenario: You should be able to nominate tags + Given I am logged in as "tagsetter" + And I set up the nominated tag set "Nominated Tags" with 3 fandom noms and 3 character noms + Given I nominate 3 fandoms and 3 characters in the "Nominated Tags" tag set as "nominator" + And I submit + Then I should see "Your nominations were successfully submitted" + + Scenario: You should be able to nominate characters when the tagset doesn't allow fandom nominations + Given a canonical character "Common Character" in fandom "Canon" + And I am logged in as "tagsetter" + And I set up the nominated tag set "Nominated Tags" with 0 fandom noms and 3 character noms + When I follow "Nominate" + And I fill in "Character 1" with "Obscure Character" + And I fill in "Character 2" with "Common Character" + And I press "Submit" + Then I should see "Sorry! We couldn't save this tag set nomination" + And I should see "We need to know what fandom Obscure Character belongs in." + But I should not see "We need to know what fandom Common Character belongs in." + When I fill in "Fandom?" with "Canon" + And I press "Submit" + Then I should see "Your nominations were successfully submitted." + And I should see "Obscure Character" + And I should see "Common Character" + + Scenario: You should be able to nominate relationships when the tagset doesn't allow fandom nominations + Given a canonical relationship "Common Pairing" in fandom "Canon" + And I am logged in as "tagsetter" + And I set up the nominated tag set "Nominated Tags" with 0 fandom noms and 3 relationship noms + When I follow "Nominate" + And I fill in "Relationship 1" with "Rare Pairing" + And I fill in "Relationship 2" with "Common Pairing" + And I press "Submit" + Then I should see "Sorry! We couldn't save this tag set nomination" + And I should see "We need to know what fandom Rare Pairing belongs in." + But I should not see "We need to know what fandom Common Pairing belongs in." + When I fill in "Fandom?" with "Canon" + And I press "Submit" + Then I should see "Your nominations were successfully submitted." + And I should see "Rare Pairing" + And I should see "Common Pairing" + + Scenario: You should be able to edit your nominated tag sets, but cannot delete them once they've been reviewed + Given I am logged in as "tagsetter" + And I set up the nominated tag set "Mayfly" with 3 fandom noms and 3 character noms + When I nominate fandom "Floobry" and character "Barblah" in "Mayfly" + Then I should see "Not Yet Reviewed (may be edited or deleted)" + And I should see "Floobry" + When I follow "Edit" + Then I should see the input with id "tag_set_nomination_fandom_nominations_attributes_0_tagname" within "div#main" + When I fill in "tag_set_nomination_fandom_nominations_attributes_0_tagname" with "Bloob" + When I press "Submit" + Then I should see "Your nominations were successfully updated" + Given I am logged in as "tagsetter" + When I review nominations for "Mayfly" + Then I should see "Bloob" within ".tagset" + When I check "fandom_approve_Bloob" + And I press "Submit" + Then I should see "Successfully added to set: Bloob" + Given I am logged in as "nominator" + And I go to the tagsets page + And I follow "Mayfly" + And I follow "My Nominations" + Then I should see "Partially Reviewed (unreviewed nominations may be edited)" + When I follow "Edit" + Then I should not see the input with id "tag_set_nomination_fandom_nominations_attributes_0_tagname" within "div#main" + + Scenario: You should be able to edit your nominated characters when the tagset doesn't allow fandom nominations + Given I am logged in as "tagsetter" + And I set up the nominated tag set "Nominated Tags" with 0 fandom noms and 3 character noms + When I follow "Nominate" + And I fill in "Character 1" with "My Aforvite Character" + And I fill in "Fandom?" with "My Favorite Fandom" + And I press "Submit" + Then I should see "Your nominations were successfully submitted." + And I should see "My Aforvite Character" + When I follow "Edit" + And I fill in "Character 1" with "My Favorite Character" + And I press "Submit" + Then I should see "Your nominations were successfully updated." + And I should see "My Favorite Character" + But I should not see "My Aforvite Character" + + Scenario: You should be able to edit your nominated relationships when the tagset doesn't allow fandom nominations + Given I am logged in as "tagsetter" + And I set up the nominated tag set "Nominated Tags" with 0 fandom noms and 3 relationship noms + When I follow "Nominate" + And I fill in "Relationship 1" with "My Favorite Character & Their Best Friend" + And I fill in "Fandom?" with "My Favorite Fandom" + And I press "Submit" + Then I should see "Your nominations were successfully submitted." + And I should see "My Favorite Character & Their Best Friend" + When I follow "Edit" + And I fill in "Relationship 1" with "My Favorite Character/Their Worst Enemy" + And I press "Submit" + Then I should see "Your nominations were successfully updated." + And I should see "My Favorite Character/Their Worst Enemy" + But I should not see "Their Best Friend" + + Scenario: You should be able to delete a nominated character and its fandom at once + when the tagset doesn't allow fandom nominations + Given a canonical character "Common Character" in fandom "Canon" + And I am logged in as "tagsetter" + And I set up the nominated tag set "Nominated Tags" with 0 fandom noms and 3 character noms + When I follow "Nominate" + And I fill in "Character 1" with "Obscure Character" + And I fill in "Fandom?" with "Canon" + And I press "Submit" + Then I should see "Your nominations were successfully submitted." + And I should see "Obscure Character" + When I follow "Edit" + And I fill in "Character 1" with "" + And I fill in "Fandom?" with "" + And I press "Submit" + Then I should see "Your nominations were successfully updated." + And I should see "None nominated in this category." + But I should not see "We need to know what fandom belongs in." + + Scenario: Owner of a tag set can clear all nominations + Given I am logged in as "tagsetter" + And I set up the nominated tag set "Nominated Tags" with 3 fandom noms and 3 character noms + Given I nominate 3 fandoms and 3 characters in the "Nominated Tags" tag set as "nominator" + And I submit + Then I should see "Your nominations were successfully submitted" + Given I am logged in as "tagsetter" + When I review nominations for "Nominated Tags" + And I follow "Clear Nominations" + And I press "Yes, Clear Tag Set Nominations" + Then I should see "All nominations for this Tag Set have been cleared" + + Scenario: Owner of a tag set with over 30 nominations sees a message that they can't all be displayed on one page + Given I am logged in as "tagsetter" + And I set up the nominated tag set "Nominated Tags" with 6 fandom noms and 6 character noms + When there are 36 unreviewed nominations + Given I am logged in as "tagsetter" + And I review nominations for "Nominated Tags" + Then I should see "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" + + Scenario: If a set has received nominations, a moderator should be able to review nominated tags + Given I have the nominated tag set "Nominated Tags" + And I am logged in as "tagsetter" + When I go to the "Nominated Tags" tag set page + And I follow "Review Nominations" + Then I should see "left to review" + + Scenario: If a moderator approves a nominated tag it should no longer appear on the review page and should appear on the tag set page + Given I am logged in as "tagsetter" + And I set up the nominated tag set "Nominated Tags" with 3 fandom noms and 3 character noms + And I nominate fandom "Floobry" and character "Barblah" in "Nominated Tags" + And I review nominations for "Nominated Tags" + Then I should see "Floobry" within ".tagset" + When I check "fandom_approve_Floobry" + And I check "character_approve_Barblah" + And I submit + Then I should see "Successfully added to set: Floobry" + And I should see "Successfully added to set: Barblah" + When I follow "Review Nominations" + Then I should not see "Floobry" + And I should not see "Barblah" + + Scenario: If a moderator rejects a nominated tag or its fandom it should no longer appear on the review page + Given I am logged in as "tagsetter" + And I set up the nominated tag set "Nominated Tags" with 3 fandom noms and 3 character noms + And I nominate fandom "Floobry" and character "Barblah" in "Nominated Tags" + And I review nominations for "Nominated Tags" + When I check "fandom_reject_Floobry" + And I submit + Then I should see "Successfully rejected: Floobry" + And I should not see "Floobry" within ".tagset" + And I should not see "Barblah" + + Scenario: Tags with brackets should work with replacement + Given I am logged in as "tagsetter" + And I set up the nominated tag set "Nominated Tags" with 3 fandom noms and 3 character noms + And I nominate fandoms "Foo [Bar], Bar [Foo]" and characters "Yar [Bar], Bat [Bar]" in "Nominated Tags" + And I review nominations for "Nominated Tags" + When I check "fandom_approve_Foo__LBRACKETBar_RBRACKET" + And I check "character_reject_Yar__LBRACKETBar_RBRACKET" + And I check "fandom_approve_Bar__LBRACKETFoo_RBRACKET" + And I check "character_approve_Bat__LBRACKETBar_RBRACKET" + And I submit + Then I should see "Successfully added to set: Bar [Foo], Foo [Bar]" + And I should see "Successfully added to set: Bat [Bar]" + And I should see "Successfully rejected: Yar [Bar]" + When I go to the "Nominated Tags" tag set page + Then I should see "Foo [Bar]" + And I should see "Bar [Foo]" + And I should not see "Yar [Bar]" + When I go to the "Nominated Tags" tag set page + And I follow "Review Associations" + Then I should see "Bat [Bar] → Bar [Foo]" + When I check "Bat [Bar] → Bar [Foo]" + And I submit + Then I should see "Nominated associations were added" + And I should not see "don't seem to be associated" + + Scenario: Tags with Unicode characters should work + Given I nominate and approve tags with Unicode characters in "Nominated Tags" + And I am logged in as "tagsetter" + And I go to the "Nominated Tags" tag set page + Then I should see the tags with Unicode characters + + # Note this is now testing the non-JS method for deleting your own nominations + Scenario: You should be able to delete your nominations + Given I am logged in as "tagsetter" + And I set up the nominated tag set "Nominated Tags" with 3 fandom noms and 3 character noms + Given I nominate 3 fandoms and 3 characters in the "Nominated Tags" tag set as "nominator" + And I submit + When I should see "Your nominations were successfully submitted" + And I go to the "Nominated Tags" tag set page + And I follow "My Nominations" + And I should see "My Nominations for Nominated Tags" + And I follow "Delete" + And I should see "Delete Tag Set Nomination?" + When I press "Yes, Delete Tag Set Nominations" + Then I should see "Your nominations were deleted." + + Scenario: Two users cannot nominate the same tag for different parents (e.g. + the same character in two fandoms) + Given I am logged in as "tagsetter" + And I set up the nominated tag set "Nominated Tags" with 1 fandom nom and 1 character nom + And I nominate fandom "The Closer" and character "Sharon Raydor" in "Nominated Tags" as "nominator1" + When I start to nominate fandom "Major Crimes" and character "Sharon Raydor" in "Nominated Tags" as "nominator2" + And I submit + Then I should see "Someone else has already nominated the tag Sharon Raydor for this set but in fandom The Closer. (All nominations have to be unique for the approval process to work.) Try making your nomination more specific, for instance tacking on (Major Crimes)." + + Scenario: If a tag already exists in the archive as one type of tag, it can't be + nominated as another type of tag (e.g. Veronica Mars can't be nominated as a fandom if + it exists as a character) + Given a noncanonical character "Veronica Mars" + And I am logged in as "tagsetter" + And I set up the nominated tag set "Nominated Tags" with 1 fandom nom and 1 character nom + When I start to nominate fandom "Veronica Mars" and character "Keith Mars" in "Nominated Tags" + And I submit + Then I should see "The tag Veronica Mars is already in the archive as a Character tag. (All tags have to be unique.) Try being more specific, for instance tacking on the medium or the fandom." + + Scenario: If a tag was nominated as another type of tag, "My Nominations" and "Review Nominations" can still be accessed + Given a nominated tag set "bad" with a tag nomination in the wrong category + And I am logged in as "tagsetter" + And I go to the "bad" tag set page + # preexisting non-canonical tag + When I follow "My Nominations" + Then I should see "My Nominations for bad" + And I should see "rel tag" + When I review nominations for "bad" + Then I should see "Fandoms (1 left to review)" + And I should see "rel tag" + # canonical tag + When the tag "rel tag" is canonized + And I go to the "bad" tag set page + When I follow "My Nominations" + Then I should see "My Nominations for bad" + And I should see "rel tag" + When I review nominations for "bad" + Then I should see "Fandoms (1 left to review)" + And I should see "rel tag" + # synonym tag + When the tag "rel tag" is decanonized + And a canonical relationship "canon tag" + And a synonym "rel tag" of the tag "canon tag" + And I go to the "bad" tag set page + When I follow "My Nominations" + Then I should see "My Nominations for bad" + And I should see "rel tag" + When I review nominations for "bad" + Then I should see "Fandoms (1 left to review)" + And I should see "rel tag (canon tag)" + + Scenario: The zero width space tag doesn't replace deleted tag nominations + Given a canonical fandom "Treasure Chest" + And a zero width space tag exists + And I am logged in as "tagsetter" + And I set up the nominated tag set "Nominated Tags" with 2 fandom noms and 3 character noms + And I nominate fandom "Treasure Chest" and character "Gold" in "Nominated Tags" + And I go to the "Nominated Tags" tag set page + And I follow "My Nominations" + Then I should see "Not Yet Reviewed (may be edited or deleted)" + When I follow "Edit" + And I fill in "Character" with "" + And I press "Submit" + Then I should see "Your nominations were successfully updated." + And I should see "Treasure Chest" + But I should not see "Gold" + And I should see "None nominated in this fandom." + + Scenario: A tag nomination with the zero width space tag doesn't prevent removing other tag nominations + Given a canonical fandom "First" + And a canonical fandom "Treasure Chest" + And a zero width space tag exists + And I am logged in as "tagsetter" + And I set up the nominated tag set "Nominated Tags" with 2 fandom noms and 1 character noms + # Zero width space character tag in first fandom + And I nominate fandom "First,Treasure Chest" and character "​,Gold" in "Nominated Tags" + And I go to the "Nominated Tags" tag set page + And I follow "My Nominations" + Then I should see "First" + And I should not see "None nominated in this fandom." + And I should see "Treasure Chest" + And I should see "Gold" + When I follow "Edit" + # Empty second fandom character field + And I fill in "tag_set_nomination_fandom_nominations_attributes_1_character_nominations_attributes_0_tagname" with "" + And I press "Submit" + Then I should see "Your nominations were successfully updated." + And I should not see "Someone else has already nominated the tag for this set but in fandom First." + And I should not see "Gold" + And I should see "None nominated in this fandom." + + Scenario: A nominated canonical tag can be renamed by a tag wrangling admin without affecting the nomination + Given a canonical character "Before" in fandom "Treasure Chest" + And I am logged in as "tagsetter" + And I set up the nominated tag set "Nominated Tags" with 1 fandom nom and 1 character nom + And I nominate fandom "Treasure Chest" and character "Before" in "Nominated Tags" as "tagsetter" + When I am logged in as an "tag_wrangling" admin + And I edit the tag "Before" + And I fill in "Name" with "After" + And I press "Save changes" + Then I should see "Tag was updated." + When I am logged in as "tagsetter" + And I go to the "Nominated Tags" tag set page + And I follow "My Nominations" + Then I should see "Treasure Chest" + And I should see "Before" diff --git a/features/tags_and_wrangling/brand_new_fandoms.feature b/features/tags_and_wrangling/brand_new_fandoms.feature new file mode 100644 index 0000000..501d022 --- /dev/null +++ b/features/tags_and_wrangling/brand_new_fandoms.feature @@ -0,0 +1,114 @@ +@user @tag_wrangling +Feature: Brand new fandoms + + Background: + # The external works form will error if we don't have basic tags, and since + # we're trying to create a brand new tag, we just want to double-check that + # it doesn't exist. + Given basic tags + And the tag "My Brand New Fandom" does not exist + + Scenario: Brand new fandoms should be visible on the Uncategorized Fandoms page. + Given I am logged in as a random user + And I post a work "My New Work" with fandom "My Brand New Fandom" + And the periodic tag count task is run + And all indexing jobs have been run + When I follow "Uncategorized Fandoms" within "#header" + Then I should see "My Brand New Fandom" + + Scenario: Fandoms used only on external works should be visible on the Uncategorized Fandoms page. + Given I am logged in as a random user + And I set up an external work + And I fill in "Fandoms" with "My Brand New Fandom" + And I submit + And the periodic tag count task is run + And all indexing jobs have been run + When I follow "Uncategorized Fandoms" within "#header" + Then I should see "My Brand New Fandom" + + Scenario: When the only work with a brand new fandom is destroyed, the fandom should not be visible on the Uncategorized Fandoms page. + Given I am logged in as a random user + And I post a work "My New Work" with fandom "My Brand New Fandom" + And the periodic tag count task is run + And all indexing jobs have been run + When I follow "Edit" + And I follow "Delete Work" + And I press "Yes" + Then I should see "Your work My New Work was deleted." + When the periodic tag count task is run + And I follow "Uncategorized Fandoms" within "#header" + Then I should not see "My Brand New Fandom" + + Scenario: When the only external work with a brand new fandom is destroyed, the fandom should not be visible on the Uncategorized Fandoms page. + Given I am logged in as a random user + And I set up an external work + And I fill in "Title" with "External Work To Be Deleted" + And I fill in "Fandoms" with "My Brand New Fandom" + And I submit + And the periodic tag count task is run + And all indexing jobs have been run + When I am logged in as a "policy_and_abuse" admin + And I view the external work "External Work To Be Deleted" + And I follow "Delete External Work" + Then I should see "Item was successfully deleted." + When the periodic tag count task is run + And I follow "Uncategorized Fandoms" within "#header" + Then I should not see "My Brand New Fandom" + + Scenario: Brand new fandoms should be visible to wranglers. + Given I am logged in as a tag wrangler + And I post a work "My New Work" with fandom "My Brand New Fandom" + And the periodic tag count task is run + And all indexing jobs have been run + When I go to the fandom mass bin + Then I should see "My Brand New Fandom" + + Scenario: When the only work with a brand new fandom is destroyed, the fandom should not be visible to tag wranglers. + Given I am logged in as a tag wrangler + And I post a work "My New Work" with fandom "My Brand New Fandom" + And the periodic tag count task is run + And all indexing jobs have been run + When I follow "Edit" + And I follow "Delete Work" + And I press "Yes" + Then I should see "Your work My New Work was deleted." + When the periodic tag count task is run + And all indexing jobs have been run + And I go to the fandom mass bin + Then I should not see "My Brand New Fandom" + + Scenario: Fandoms used only on external works should not be visible to wranglers. + Given I am logged in as a tag wrangler + And I set up an external work + And I fill in "Fandoms" with "My Brand New Fandom" + And I submit + And the periodic tag count task is run + And all indexing jobs have been run + When I go to the fandom mass bin + Then I should not see "My Brand New Fandom" + + Scenario: When a brand new fandom used only on external works is tagged on a work, the fandom should be visible to tag wranglers. + Given I am logged in as a tag wrangler + And I set up an external work + And I fill in "Fandoms" with "My Brand New Fandom" + And I submit + When I post the work "Great work" with fandom "My Brand New Fandom" + And the periodic tag count task is run + And all indexing jobs have been run + And I am logged in as a tag wrangler + And I go to the fandom mass bin + Then I should see "My Brand New Fandom" + + Scenario: When the only draft using a brand new fandom is published, the fandom should be visible to tag wranglers. + Given I am logged in as a tag wrangler + And I set up the draft "Generic Work" with fandom "My Brand New Fandom" + And I press "Preview" + And the periodic tag count task is run + And all indexing jobs have been run + When I go to the fandom mass bin + Then I should not see "My Brand New Fandom" + When I post the work "Generic Work" + And the periodic tag count task is run + And all indexing jobs have been run + And I go to the fandom mass bin + Then I should see "My Brand New Fandom" diff --git a/features/tags_and_wrangling/favorite_tags.feature b/features/tags_and_wrangling/favorite_tags.feature new file mode 100644 index 0000000..4926734 --- /dev/null +++ b/features/tags_and_wrangling/favorite_tags.feature @@ -0,0 +1,39 @@ +Feature: Favorite Tags + In order to browse more efficiently + As an archive user + I should be able to list my favorite tags on my homepage + + Scenario: A user can add a canonical tag to their favorite tags + Given a canonical fandom "Dallas (TV 2012)" + When I am logged in as "bourbon" with password "andbranch" + And I go to the homepage + Then I should see "Find your favorites" + And I should see "Browse fandoms by media or favorite up to 20 tags to have them listed here!" + When I view the "Dallas (TV 2012)" works index + Then I should see a "Favorite Tag" button + When I press "Favorite Tag" + Then I should see "You have successfully added Dallas (TV 2012) to your favorite tags." + When I go to the homepage + Then I should see "Dallas (TV 2012)" + + Scenario: A user can remove a tag from their favorite tags + Given a canonical relationship "John Ross Ewing/Elena Ramos" + When I am logged in as "bourbon" with password "andbranch" + And I add "John Ross Ewing/Elena Ramos" to my favorite tags + When I view the "John Ross Ewing/Elena Ramos" works index + Then I should see a "Unfavorite Tag" button + When I press "Unfavorite Tag" + Then I should see "You have successfully removed John Ross Ewing/Elena Ramos from your favorite tags." + When I go to the homepage + Then I should not see "John Ross Ewing/Elena Ramos" + And I should see "Browse fandoms by media or favorite up to 20 tags to have them listed here!" + + Scenario: A tag that is decanonized should be removed from users' favorite tags + Given a canonical character "Rebecca Sutter" + When I am logged in as "bourbon" with password "andbranch" + And I add "Rebecca Sutter" to my favorite tags + When I go to the homepage + Then I should see "Rebecca Sutter" + When the tag "Rebecca Sutter" is decanonized + And I go to the homepage + Then I should not see "Rebecca Sutter" diff --git a/features/tags_and_wrangling/tag_cloud.feature b/features/tags_and_wrangling/tag_cloud.feature new file mode 100644 index 0000000..1bfa0ba --- /dev/null +++ b/features/tags_and_wrangling/tag_cloud.feature @@ -0,0 +1,110 @@ +@tags + +Feature: Tag Cloud + In order to browse by the most used additional tags + As an archive user or visitor + I should be able to view some tags in a tag cloud + +Scenario: tag cloud should only contain top-level canonical freeforms in "No Fandom" + I want to check that: + non-canonical used freeforms do not show up in cloud, whether unwrangled, fandomish or no fandomish + canonical freeforms within No Fandom but with no uses at all do not show + canonical freeforms that are fandomish or unwrangled do not show up, even used + canonical freeforms within No Fandom, with no uses but with used mergers show up TODO + canonical freeforms within No Fandom, with uses, show up TODO + metatag freeforms with uses show up and their subtags do not anymore TODO + metatag freeforms with no uses do not show and neither do their subtags (which I think is bad) TODO + + Given the following activated tag wrangler exists + | login | password | + | Enigel | wrangulate! | + And basic tags + And a fandom exists with name: "Firefly", canonical: true + And a freeform exists with name: "Non-canonical NoFandom", canonical: false + And a freeform exists with name: "Non-canonical Fandomish", canonical: false + And a freeform exists with name: "Non-canonical unwrangled", canonical: false + And a freeform exists with name: "Canonical unused NoFandom", canonical: true + And a freeform exists with name: "Canonical Fandomish", canonical: true + And a freeform exists with name: "Canonical unwrangled", canonical: true + + # post a work with some fun tags and some boring tags + + When I am logged in as "author" with password "password" + And I go to the new work page + And I select "Not Rated" from "Rating" + And I check "No Archive Warnings Apply" + And I select "English" from "Choose a language" + And I fill in "Fandoms" with "Firefly" + And I fill in "Work Title" with "Silliness" + And I fill in "Additional Tags" with "100 words, five things, objects in space, Sentient Serenity, Episode Tag, Non-canonical NoFandom, Non-canonical Fandomish, Non-canonical unwrangled, Canonical Fandomish, Canonical unwrangled" + And I fill in "content" with "And then everyone was kidnapped by an alien bus." + And I press "Preview" + And I press "Post" + Then I should see "Work was successfully posted." + + # test the tags with fun names + + When I am logged out + And I follow "Tags" within "ul.navigation" + Then I should not see "100 words" + And I should not see "five things" + + When I am logged in as "Enigel" with password "wrangulate!" + And I edit the tag "five things" + And I fill in "Fandoms" with "No Fandom" + And I press "Save changes" + And I follow "New Tag" + And I fill in "Name" with "5 Things" + And I choose "Additional Tag" + And I check "Canonical" + And I press "Create Tag" + And I follow "New Tag" + And I fill in "Name" with "N Things" + And I choose "Additional Tag" + And I check "Canonical" + And I press "Create Tag" + And I follow "Tags" + Then I should not see "Five Things" + And I should not see "5 Things" + And I should not see "N Things" + When I post the work "Test" + And I edit the work "Test" + And I fill in "Additional Tags" with "Five Things" + And I press "Preview" + And I press "Update" + And I follow "Tags" + Then I should not see "Five Things" + + # set up boringly-named tags + + When I edit the tag "Non-canonical NoFandom" + And I fill in "Fandoms" with "No Fandom" + And I press "Save changes" + And I edit the tag "Non-canonical Fandomish" + And I fill in "Fandoms" with "Firefly" + And I press "Save changes" + And I edit the tag "Canonical unused NoFandom" + And I fill in "Fandoms" with "No Fandom" + And I press "Save changes" + And I edit the tag "Canonical Fandomish" + And I fill in "Fandoms" with "Firefly" + And I press "Save changes" + Then I should see "Tag was updated" + + # check the cloud for the boring tags + + When I follow "Tags" + Then I should not see "Non-canonical NoFandom" + And I should not see "Non-canonical Fandomish" + And I should not see "Non-canonical unwrangled" + And I should not see "Canonical unused NoFandom" + And I should not see "Canonical Fandomish" + And I should not see "Canonical unwrangled" + + When I follow "Random" + Then I should not see "Non-canonical NoFandom" + And I should not see "Non-canonical Fandomish" + And I should not see "Non-canonical unwrangled" + And I should not see "Canonical unused NoFandom" + And I should not see "Canonical Fandomish" + And I should not see "Canonical unwrangled" diff --git a/features/tags_and_wrangling/tag_comment.feature b/features/tags_and_wrangling/tag_comment.feature new file mode 100644 index 0000000..c52a277 --- /dev/null +++ b/features/tags_and_wrangling/tag_comment.feature @@ -0,0 +1,216 @@ +@tags @tag_wrangling @comments +Feature: Comment on tag +As a tag wrangler +I'd like to comment on a tag' + + @disable_caching + Scenario: Comment on a tag and get taken to right page and see right date + + Given the following activated tag wranglers exist + | login | + | dizmo | + And a fandom exists with name: "Stargate Atlantis", canonical: true + And it is currently Mon Mar 27 22:00:00 UTC 2017 + When I am logged in as "dizmo" + When I view the tag "Stargate Atlantis" + Then I should see "0 comments" + When I post the comment "Shouldn't this be a metatag with Stargate?" on the tag "Stargate Atlantis" via web + Then I should see "Shouldn't this be a metatag with Stargate?" + And the comment's posted date should be nowish + And I jump in our Delorean and return to the present + + Scenario: Edit a comment on a tag + + Given the following activated tag wranglers exist + | login | + | dizmo | + And a fandom exists with name: "Stargate Atlantis", canonical: true + And it is currently Mon Mar 27 22:00:00 UTC 2017 + When I am logged in as "dizmo" + When I post the comment "Shouldn't this be a metatag with Stargate?" on the tag "Stargate Atlantis" + When it is currently 1 second from now + And I follow "Edit" + Then the "Comment" field should contain "Shouldn't this be a metatag with Stargate?" + And I should see "Cancel" + When I fill in "Comment" with "Yep, we should have a Stargate franchise metatag." + And I press "Update" + Then I should see "Comment was successfully updated." + And I should see "Yep, we should have a Stargate franchise metatag." + And I should not see "Shouldn't this be a metatag with Stargate?" + And I should see Last Edited nowish + When I jump in our Delorean and return to the present + + Scenario: Multiple comments on a tag increment correctly + + Given the following activated tag wranglers exist + | login | + | dizmo | + | someone_else | + And a fandom exists with name: "Stargate Atlantis", canonical: true + When I am logged in as "dizmo" + When I post the comment "Yep, we should have a Stargate franchise metatag." on the tag "Stargate Atlantis" + When I am logged in as "someone_else" + When I post the comment "Important policy decision" on the tag "Stargate Atlantis" + When I view the tag "Stargate Atlantis" + Then I should see "2 comments" + + Scenario: Issue 2185: email notifications for tag commenting; TO DO: replies to comments + + Given the following activated tag wranglers exist + | login | password | email | + | dizmo | wrangulator | dizmo@example.org | + | Enigel | wrangulator | enigel@example.org | + | Cesy | wrangulator | cesy@example.org | + And a canonical fandom "Eroica" + And a canonical fandom "Doctor Who" + And the tag wrangler "Enigel" with password "wrangulator" is wrangler of "Eroica" + And the tag wrangler "Cesy" with password "wrangulator" is wrangler of "Doctor Who" + And the tag wrangler "dizmo" with password "wrangulator" is wrangler of "Doctor Who" + + # receive copies of own comments + When I am logged in as "Enigel" with password "wrangulator" + And I go to Enigel's user page + And I follow "Preferences" + And I uncheck "Turn off copies of your own comments" + And I press "Update" + And I log out + + # fellow wrangler leaves a comment on a wrangler's fandom + When I am logged in as "Cesy" with password "wrangulator" + And I go to Cesy's user page + And I follow "Preferences" + And I check "Turn off copies of your own comments" + And I press "Update" + And all emails have been delivered + And I view the tag "Eroica" + And I follow "0 comments" + And I fill in "Comment" with "really clever stuff" + And I press "Comment" + Then I should see "Comment created" + And 1 email should be delivered to "enigel@example.org" + And the email should contain "really clever stuff" + And the email should contain "Cesy" + And the email should contain "left the following comment on" + And the email should contain "the tag" + + When I am logged in as "Enigel" with password "wrangulator" + And I follow "Go to the thread starting from this comment" in the email + Then I should see "Comment on Eroica" + And I should see "really clever stuff" + When I follow "Read all comments on Eroica" in the email + Then I should see "Reading Comments on Eroica" + And I should see "really clever stuff" + When I follow "Reply to this comment" in the email + Then I should see "Comment on Eroica" + And I should see "really clever stuff" + And all emails have been delivered + + When I view the tag "Doctor Who" + And I follow "0 comments" + And I fill in "Comment" with "really clever stuff" + And I press "Comment" + Then I should see "Comment created" + And 1 email should be delivered to "cesy@example.org" + And 1 email should be delivered to "dizmo@example.org" + And 1 email should be delivered to "enigel@example.org" + When I follow "Edit" + And all emails have been delivered + And I press "Update" + Then I should see "Comment was successfully updated" + And 3 emails should be delivered + + Scenario: Email notifications for tag comments should ignore work comments settings + + Given the following activated tag wranglers exist + | login | password | email | + | dizmo | wrangulator | dizmo@example.org | + | Enigel | wrangulator | enigel@example.org| + And a fandom exists with name: "Doctor Who", canonical: true + And the tag wrangler "Enigel" with password "wrangulator" is wrangler of "Doctor Who" + And I am logged in as "Enigel" + And I set my preferences to turn off notification emails for comments + When I am logged in as "dizmo" with password "wrangulator" + And I post the comment "Heads up" on the tag "Doctor Who" + Then 1 email should be delivered to "enigel@example.org" + + Scenario: comments on synonym fandoms should be received by the wrangler of the canonical merger + + Given the following activated tag wranglers exist + | login | password | email | + | dizmo | wrangulator | dizmo@example.org | + | Enigel | wrangulator | enigel@example.org| + And a canonical fandom "Doctor Who" + And the tag wrangler "Enigel" with password "wrangulator" is wrangler of "Doctor Who" + And a synonym "Dr Who" of the tag "Doctor Who" + When I am logged in as "dizmo" with password "wrangulator" + And I post the comment "Heads up" on the tag "Dr Who" + Then 1 email should be delivered to "enigel@example.org" + + Scenario: Comments pagination for a regular tag + + Given a tag "No Punctuation Here" with 34 comments + And I am logged in as a tag wrangler + When I view the tag "No Punctuation Here" + # link to comments should exist + Then I should see "34 comments" + When I follow "34 comments" + # link to the next page of comments should exist + Then I should see "Next" within ".pagination" + When I follow "Next" within ".pagination" + And I post a comment "Checking redirect after commenting on a tag" + # should redirect to the same page you were on before commenting + Then I should see "Comment created" + And I should see "Checking redirect after commenting on a tag" + + Scenario: Comments pagination for a tag with slashes in the name + + Given a tag "hack/sign" with 34 comments + And I am logged in as a tag wrangler + When I post the comment "And now things should not break!" on the tag "hack/sign" + Then I should see "Comment created" + # all it checks is that the pagination links aren't broken + When I follow "Next" within ".pagination" + Then I should see "And now things should not break!" + + Scenario: Comments pagination for a tag with periods in the name + + Given a period-containing tag "sign.me" with 34 comments + And I am logged in as a tag wrangler + When I post the comment "And now things should not break!" on the tag "sign.me" + Then I should see "Comment created" + # all it checks is that the pagination links aren't broken + When I follow "Next" within ".pagination" + Then I should see "And now things should not break!" + + Scenario: Comments pagination for a tag with slashes and periods in the name + + Given a tag "hack/sign.me" with 34 comments + And I am logged in as a tag wrangler + When I post the comment "And now things should not break!" on the tag "hack/sign.me" + Then I should see "Comment created" + # all it checks is that the pagination links aren't broken + When I follow "Next" within ".pagination" + Then I should see "And now things should not break!" + + Scenario: Comments on a tag should not be visible to non-wranglers. + + Given a canonical fandom "World Domination" + And I am logged in as a tag wrangler + And I post the comment "Top-secret plans." on the tag "World Domination" + And I am logged out + + When I view the latest comment + + Then I should not see "Top-secret plans." + + Scenario: Comments replying to a comment on a tag should not be visible to non-wranglers. + + Given a canonical fandom "World Domination" + And I am logged in as a tag wrangler + And I post the comment "Anyone have a plan?" on the tag "World Domination" + And I reply to a comment with "Top-secret plans." + And I am logged out + + When I view the latest comment + + Then I should not see "Top-secret plans." diff --git a/features/tags_and_wrangling/tag_search.feature b/features/tags_and_wrangling/tag_search.feature new file mode 100644 index 0000000..cbcbb63 --- /dev/null +++ b/features/tags_and_wrangling/tag_search.feature @@ -0,0 +1,250 @@ +@tags @tag_wrangling @search +Feature: Search Tags + In order to find tags + As a user + I want to use tag search + + Scenario: Search tags + Given I have no tags + And a fandom exists with name: "first fandom", canonical: false + And a character exists with name: "first last", canonical: true + And a relationship exists with name: "first last/someone else", canonical: false + And all indexing jobs have been run + When I am on the search tags page + And I fill in "Tag name" with "first" + And I press "Search Tags" + Then I should see "3 Found" + And I should see the tag search result "Fandom: first fandom (0)" + And I should not see the tag search result "Fandom: first fandom (0)" within ".canonical" + And I should see the tag search result "Character: first last (0)" within ".canonical" + And I should see the tag search result "Relationship: first last/someone else (0)" + # test search with slash + When I am on the search tags page + And I fill in "Tag name" with "first last\/someone else" + And I press "Search Tags" + Then I should see "1 Found" + And I should see the tag search result "first last/someone else (0)" + + Scenario: Search for fandom with slash in name + Given I have no tags + And a fandom exists with name: "first/fandom", canonical: false + And all indexing jobs have been run + When I am on the search tags page + And I fill in "Tag name" with "first" + And I press "Search Tags" + Then I should see "1 Found" + And I should see the tag search result "Fandom: first/fandom (0)" + + Scenario: Search for fandom with period in name + Given I have no tags + And a fandom exists with name: "first.fandom", canonical: false + And all indexing jobs have been run + When I am on the search tags page + And I fill in "Tag name" with "first.fandom" + And I press "Search Tags" + Then I should see "1 Found" + And I should see the tag search result "Fandom: first.fandom (0)" + When I follow "first.fandom" + Then I should see "This tag belongs to the Fandom Category" + + When I am on the search tags page + # possibly a bug rather than desired behaviour, to be discussed later + And I fill in "Tag name" with "first" + And I press "Search Tags" + Then I should see "0 Found" + And I should not see "Fandom: first.fandom (0)" + + Scenario: Search for tag in canonical fandom(s) + Given a canonical character "Anna Anderson" in fandom "Fandom A" + And a canonical character "Abby Anderson" in fandom "Fandom B" + And I add the fandom "Fandom A" to the character "Abby Anderson" + And a character exists with name: "Null Anderson" + And a fandom exists with name: "Not Canon Fandom", canonical: false + And all indexing jobs have been run + # Tag in one canonical fandom + When I am on the search tags page + And I fill in "Tag name" with "Anderson" + And I fill in "Fandom" with "Fandom A" + And I press "Search Tags" + Then I should see "2 Found" + And I should see the tag search result "Anna Anderson" + And I should see the tag search result "Abby Anderson" + And I should not see the tag search result "Null Anderson" + # Tag in multiple canonical fandoms + When I am on the search tags page + And I fill in "Tag name" with "Anderson" + And I fill in "Fandom" with "Fandom A, Fandom B" + And I press "Search Tags" + Then I should see "1 Found" + And I should see the tag search result "Abby Anderson" + And I should not see the tag search result "Anna Anderson" + And I should not see the tag search result "Null Anderson" + # Search with non-canonical fandom will yield no results + When I am on the search tags page + And I fill in "Tag name" with "Anderson" + And I fill in "Fandom" with "Not Canon Fandom" + And I press "Search Tags" + Then I should see "0 Found" + # Search with non-existent fandom ignores non-existent fandom (like People Search) + When I am on the search tags page + And I fill in "Tag name" with "Anderson" + And I fill in "Fandom" with "non-existent fandom" + And I press "Search Tags" + Then I should see "3 Found" + And I should see the tag search result "Abby Anderson" + And I should see the tag search result "Anna Anderson" + And I should see the tag search result "Null Anderson" + # Search with non-existent fandom and canonical fandom filters by canonical fandom + When I am on the search tags page + And I fill in "Tag name" with "Anderson" + And I fill in "Fandom" with "non-existent fandom, Fandom A" + And I press "Search Tags" + Then I should see "2 Found" + And I should see the tag search result "Anna Anderson" + And I should see the tag search result "Abby Anderson" + And I should not see the tag search result "Null Anderson" + + Scenario: Search by Type of tags + Given a fandom exists with name: "first fandom" + And a character exists with name: "first character" + And a relationship exists with name: "first last/someone else" + And a freeform exists with name: "first fic please be nice" + And all indexing jobs have been run + When I am on the search tags page + And I fill in "Tag name" with "first" + And I choose "Fandom" + And I press "Search Tags" + Then I should see "1 Found" + And I should see the tag search result "Fandom: first fandom" + And I should not see the tag search result "Character: first character" + And I should not see the tag search result "Relationship: first last/somone else" + And I should not see the tag search result "Freeform: first fic please be nice" + When I am on the search tags page + And I fill in "Tag name" with "first" + And I choose "Character" + And I press "Search Tags" + Then I should see "1 Found" + And I should see the tag search result "Character: first character" + And I should not see the tag search result "Fandom: first fandom" + And I should not see the tag search result "Relationship: first last/somone else" + And I should not see the tag search result "Freeform: first fic please be nice" + When I am on the search tags page + And I fill in "Tag name" with "first" + And I choose "Relationship" + And I press "Search Tags" + Then I should see "1 Found" + And I should see the tag search result "Relationship: first last/somone else" + And I should not see the tag search result "Fandom: first fandom" + And I should not see the tag search result "Character: first character" + And I should not see the tag search result "Freeform: first fic please be nice" + When I am on the search tags page + And I fill in "Tag name" with "first" + And I choose "Freeform" + And I press "Search Tags" + Then I should see "1 Found" + And I should see the tag search result "Freeform: first fic please be nice" + And I should not see the tag search result "Fandom: first fandom" + And I should not see the tag search result "Character: first character" + And I should not see the tag search result "Relationship: first last/somone else" + + Scenario: Search by wrangling status + Given a fandom exists with name: "Not Canon Fandom", canonical: false + And a character exists with name: "Canon Character", canonical: true + And a synonym "Same Canon Character" of the tag "Canon Character" + And all indexing jobs have been run + When I am on the search tags page + And I fill in "Tag name" with "Canon" + And I choose "Canonical" + And I press "Search Tags" + Then I should see "1 Found" + And I should not see the tag search result "Fandom: Not Canon Fandom (0)" + And I should see the tag search result "Character: Canon Character (0)" + And I should not see the tag search result "Character: Same Canon Character (0)" + When I am on the search tags page + And I fill in "Tag name" with "Canon" + And I choose "Non-canonical" + And I press "Search Tags" + Then I should see "2 Found" + And I should see the tag search result "Fandom: Not Canon Fandom (0)" + And I should not see the tag search result "Character: Canon Character (0)" + And I should see the tag search result "Character: Same Canon Character (0)" + When I am on the search tags page + And I fill in "Tag name" with "Canon" + And I choose "Synonymous" + And I press "Search Tags" + Then I should see "1 Found" + And I should not see the tag search result "Fandom: Not Canon Fandom (0)" + And I should not see the tag search result "Character: Canon Character (0)" + And I should see the tag search result "Character: Same Canon Character (0)" + When I am on the search tags page + And I fill in "Tag name" with "Canon" + And I choose "Canonical or synonymous" + And I press "Search Tags" + Then I should see "2 Found" + And I should not see the tag search result "Fandom: Not Canon Fandom (0)" + And I should see the tag search result "Character: Canon Character (0)" + And I should see the tag search result "Character: Same Canon Character (0)" + When I am on the search tags page + And I fill in "Tag name" with "Canon" + And I choose "Non-canonical and non-synonymous" + And I press "Search Tags" + Then I should see "1 Found" + And I should see the tag search result "Fandom: Not Canon Fandom (0)" + And I should not see the tag search result "Character: Canon Character (0)" + And I should not see the tag search result "Character: Same Canon Character (0)" + When I am on the search tags page + And I fill in "Tag name" with "Canon" + And I choose "Any status" + And I press "Search Tags" + Then I should see "3 Found" + And I should see the tag search result "Fandom: Not Canon Fandom (0)" + And I should see the tag search result "Character: Canon Character (0)" + And I should see the tag search result "Character: Same Canon Character (0)" + + Scenario: Search and sort by Date Created in descending and ascending order + Given a freeform exists with name: "created first", created_at: "2008-01-01 20:00:00 Z" + And a freeform exists with name: "created second", created_at: "2009-01-01 20:00:00 Z" + And a freeform exists with name: "created third", created_at: "2010-01-01 20:00:00 Z" + And a freeform exists with name: "created fourth", created_at: "2011-01-01 20:00:00 Z" + And all indexing jobs have been run + When I am on the search tags page + And I fill in "Tag name" with "created" + And I select "Date Created" from "Sort by" + And I select "Descending" from "Sort direction" + And I press "Search Tags" + Then I should see "4 Found" + And the 1st tag result should contain "created fourth" + And the 2nd tag result should contain "created third" + And the 3rd tag result should contain "created second" + And the 4th tag result should contain "created first" + When I select "Ascending" from "Sort direction" + And I press "Search Tags" + Then I should see "4 Found" + And the 1st tag result should contain "created first" + And the 2nd tag result should contain "created second" + And the 3rd tag result should contain "created third" + And the 4th tag result should contain "created fourth" + + Scenario: Search and sort by Uses in descending and ascending order + Given a set of tags for tag sort by use exists + When I am on the search tags page + And I fill in "Tag name" with "uses" + And I select "Uses" from "Sort by" + And I select "Descending" from "Sort direction" + And I press "Search Tags" + Then I should see "6 Found" + And the 1st tag result should contain "10 uses" + And the 2nd tag result should contain "8 uses" + And the 3rd tag result should contain "8 uses" + And the 4th tag result should contain "5 uses" + And the 5th tag result should contain "2 uses" + And the 6th tag result should contain "0 uses" + When I select "Ascending" from "Sort direction" + And I press "Search Tags" + Then I should see "6 Found" + And the 1st tag result should contain "0 uses" + And the 2nd tag result should contain "2 uses" + And the 3rd tag result should contain "5 uses" + And the 4th tag result should contain "8 uses" + And the 5th tag result should contain "8 uses" + And the 6th tag result should contain "10 uses" diff --git a/features/tags_and_wrangling/tag_wrangling.feature b/features/tags_and_wrangling/tag_wrangling.feature new file mode 100644 index 0000000..fc6565a --- /dev/null +++ b/features/tags_and_wrangling/tag_wrangling.feature @@ -0,0 +1,383 @@ +@users @tag_wrangling +Feature: Tag wrangling + + Scenario: Admin can create a tag wrangler using the interface + + Given the role "tag_wrangler" + When I am logged in as "dizmo" + Then I should not see "Tag Wrangling" within "#header" + When I am logged in as a "tag_wrangling" 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" + # admin making user tag wrangler + When I check the "tag_wrangler" role checkbox + And I press "Update" + Then I should see "User was successfully updated" + # accessing wrangling pages + When I am logged in as "dizmo" + And I follow "Tag Wrangling" within "#header" + Then I should see "Wrangling Home" + # no access otherwise + When I log out + Then I should see "Sorry, you don't have permission" + + Scenario Outline: Tag wrangler navigation/sidebar + Given the tag wrangling setup + And I am logged in as a tag wrangler + When I go to the wrangling page for "wrangler" + Then I should see "Wrangling Home" + And I should see "Fandoms by media (2)" + And I should see "Characters by fandom (2)" + And I should see "Relationships by fandom (1)" + When I follow + Then I should see + + Examples: + | link_text | heading | + | "Wranglers" | "Tag Wrangling Assignments" | + | "Wrangling Tools" | "Tag Wrangling" | + | "Characters by fandom (2)" | "Mass Wrangle New/Unwrangled Tags" | + | "Relationships by fandom (1)" | "Mass Wrangle New/Unwrangled Tags" | + | "Fandoms by media (2)" | "Mass Wrangle New/Unwrangled Tags" | + + Scenario: Edit tag page + Given the tag wrangling setup + And I am logged in as a tag wrangler + When I go to the "Daniel Jackson" tag page + And I follow "Edit" within ".header" + Then I should see "Edit Daniel Jackson Tag" + And I should see "Name" + And I should see "Category" + And I should see "Canonical" + And I should see "Synonym" + And I should see "Unwrangleable" + And I should see "Fandom" + And I should see "Meta" + + Scenario: Making a fandom canonical and assigning media to it + Given the tag wrangling setup + And I am logged in as a tag wrangler + When I go to the "Stargate SG-1" tag edit page + When I check "tag_canonical" + And I fill in "tag_media_string" with "TV Shows" + And I press "Save changes" + Then I should see "Tag was updated" + And the "Stargate SG-1" tag should be canonical + + Scenario: Assign wrangler to a fandom + Given the tag wrangling setup + And I have a canonical "TV Shows" fandom tag named "Stargate SG-1" + And I am logged in as a tag wrangler + When I go to the wrangling page for "wrangler" + And I follow "Wranglers" + And I fill in "tag_fandom_string" with "Stargate SG-1" + And I press "Assign" + Then "Stargate SG-1" should be assigned to the wrangler "wrangler" + When I follow "Wrangling Home" + Then I should see "Stargate SG-1" + When I follow "Wranglers" + Then I should see "Stargate SG-1" + And I should see "wrangler" within "ul.wranglers" + + Scenario: Making a character canonical and assigning it to a fandom + Given the tag wrangling setup + And I have a canonical "TV Shows" fandom tag named "Stargate SG-1" + And I am logged in as a tag wrangler + When I go to the "Daniel Jackson" tag edit page + And I fill in "Fandoms" with "Stargate SG-1" + And I check "tag_canonical" + And I press "Save changes" + Then I should see "Tag was updated" + And the "Daniel Jackson" tag should be canonical + And the "Daniel Jackson" tag should be in the "Stargate SG-1" fandom + + Scenario: Assigning a fandom to a non-canonical character + Given the tag wrangling setup + And I have a canonical "TV Shows" fandom tag named "Stargate SG-1" + And I am logged in as a tag wrangler + When I go to the "Daniel Jackson" tag edit page + And I fill in "Fandoms" with "Stargate SG-1" + And I press "Save changes" + Then I should see "Tag was updated" + And the "Daniel Jackson" tag should not be canonical + And the "Daniel Jackson" tag should be in the "Stargate SG-1" fandom + + Scenario: Merging canonical and non-canonical character tags + Given the tag wrangling setup + And I have a canonical "TV Shows" fandom tag named "Stargate SG-1" + And I add the fandom "Stargate SG-1" to the character "Jack O'Neil" + And I am logged in as a tag wrangler + When I go to the "Jack O'Neil" tag edit page + And I fill in "Synonym of" with "Jack O'Neill" + And I press "Save changes" + And I follow "Jack O'Neill" + Then I should see "Stargate SG-1" + When I view the tag "Stargate SG-1" + Then I should see "Jack O'Neil" + And I should see "Jack O'Neill" + + Scenario Outline: Creating new non-canonical tags + Given I am logged in as a tag wrangler + And I go to the wrangling page for "wrangler" + When I follow "New Tag" + And I fill in "Name" with "MyNewTag" + And I choose + And I press "Create Tag" + Then I should see "Tag was successfully created" + And the "MyNewTag" tag should be a tag + And the "MyNewTag" tag should not be canonical + + Examples: + | type | + | "Fandom" | + | "Character" | + + Scenario Outline: Creating new canonical tags + Given I am logged in as a tag wrangler + And I go to the wrangling page for "wrangler" + When I follow "New Tag" + And I fill in "Name" with "MyNewTag" + And I choose + And I check "Canonical" + And I press "Create Tag" + Then I should see "Tag was successfully created" + And the "MyNewTag" tag should be a tag + And the "MyNewTag" tag should be canonical + + Examples: + | type | + | "Fandom" | + | "Character" | + + Scenario: Trying to assign a non-canonical fandom to a character + Given the tag wrangling setup + And a non-canonical fandom "Stargate Atlantis" + And I have a canonical "TV Shows" fandom tag named "Stargate SG-1" + And I am logged in as a tag wrangler + When I go to the "Jack O'Neil" tag edit page + And I fill in "Fandoms" with "Stargate Atlantis" + And I press "Save changes" + Then I should see "Cannot add association to 'Stargate Atlantis':" + And I should see "Parent tag is not canonical." + And I should not see "Stargate Atlantis" within "form" + + Scenario: Assigning a fandom to a non-canonical relationship tag + Given the tag wrangling setup + And I have a canonical "TV Shows" fandom tag named "Stargate Atlantis" + And I am logged in as a tag wrangler + When I go to the "JackDaniel" tag edit page + And I fill in "Fandoms" with "Stargate Atlantis" + And I press "Save changes" + Then I should see "Tag was updated" + When I follow "JackDaniel" + Then I should see "Stargate Atlantis" + + Scenario: Creating a canonical merger and adding characters to a non-canonical relationship + Given I have a canonical "TV Shows" fandom tag named "RWBY" + And a canonical character "Blake Belladonna" in fandom "RWBY" + And a canonical character "Yang Xiao Long" in fandom "RWBY" + And a non-canonical relationship "Bumbleby" + And I am logged in as a tag wrangler + When I go to the "Bumbleby" tag edit page + And I fill in "Synonym of" with "Blake/Yang" + And I fill in "Fandoms" with "RWBY" + And I fill in "Characters" with "Blake Belladonna, Yang Xiao Long" + And I press "Save changes" + Then I should see "Tag was updated" + And I should see "RWBY" + And I should see "Blake/Yang" + And I should see "Blake Belladonna" + And I should see "Yang Xiao Long" + And the "Blake/Yang" tag should be canonical + + Scenario: Check sidebar links and pages for wrangling within a fandom + Given I have a canonical "TV Shows" fandom tag named "Stargate SG-1" + And a canonical character "Samantha Carter" in fandom "Stargate SG-1" + And a canonical character "Teal'c" in fandom "Stargate SG-1" + And a synonym "Tealc" of the tag "Teal'c" + And the tag wrangler "wrangler" with password "password" is wrangler of "Stargate SG-1" + And I post the work "Test Work" with fandom "Stargate SG-1" with character "Janet Fraiser" with second character "Apophis" + When I go to the "Apophis" tag edit page + And I check "Unwrangleable" + And I fill in "Fandoms" with "Stargate SG-1" + And I press "Save changes" + Then I should see "Tag was updated" + And the "Apophis" tag should be unwrangleable + When I go to the wrangling page for "wrangler" + And I follow "Stargate SG-1" + Then I should see "Wrangle Tags for Stargate SG-1" + When I follow "Characters (4)" + Then I should see "Wrangle Tags for Stargate SG-1" + And I should see "Showing All Character Tags" + And I should see "Apophis" + And I should see "Samantha Carter" + And I should see "Teal'c" + And I should see "Tealc" + When I follow "Canonical" + Then I should see "Showing Canonical Character Tags" + And I should see "Samantha Carter" + And I should see "Teal'c" + But I should not see "Tealc" + When I follow "Synonymous" + Then I should see "Showing Synonymous Character Tags" + And I should see "Teal'c" + And I should see "Tealc" within "tbody th" + And I should not see "Teal'c" within "tbody td" + And I should not see "Samantha Carter" + When I follow "Unwrangleable" + Then I should see "Showing Unwrangleable Character Tags" + And I should see "Apophis" + And I should not see "Samantha Carter" + When I follow "Unwrangled" + Then I should see "Showing Unwrangled Character Tags" + And I should see "Janet Fraiser" + And I should not see "Samantha Carter" + When I follow "Relationships (0)" + Then I should see "Wrangle Tags for Stargate SG-1" + And I should see "Showing All Relationship Tags" + When I follow "Freeforms (0)" + Then I should see "Wrangle Tags for Stargate SG-1" + And I should see "Showing All Freeform Tags" + When I follow "SubTags (0)" + Then I should see "Wrangle Tags for Stargate SG-1" + And I should see "Showing All Sub Tag Tags" + When I follow "Mergers (0)" + Then I should see "Wrangle Tags for Stargate SG-1" + And I should see "Showing All Merger Tags" + + Scenario: Wrangler has option to troubleshoot a work + + Given the work "Indexing Issues" + And I am logged in as a tag wrangler + When I view the work "Indexing Issues" + Then I should see "Troubleshoot" + + @javascript + Scenario: AO3-1698 Sign up for a fandom from the edit fandom page, + then from editing a child tag of a fandom + + Given a canonical fandom "'Allo 'Allo" + And a canonical fandom "From Eroica with Love" + And a canonical fandom "Cabin Pressure" + And a noncanonical relationship "Dorian/Martin" + + # I want to sign up from the edit page of an unassigned fandom + When I am logged in as a tag wrangler + And I edit the tag "'Allo 'Allo" + Then I should see "Sign Up" + When I follow "Sign Up" + Then I should see "Assign fandoms to yourself" + And I should see "'Allo 'Allo" within ".autocomplete .added" + When I press "Assign" + Then I should see "Wranglers were successfully assigned" + When I edit the tag "'Allo 'Allo" + Then I should not see "Sign Up" + And I should see the tag wrangler listed as an editor of the tag + + # I want to sign up from the edit page of a relationship that belongs to two unassigned fandoms + When I edit the tag "Dorian/Martin" + Then I should not see "Sign Up" + When I fill in "Fandoms" with "From Eroica with Love, Cabin Pressure" + And I press "Save changes" + Then I should see "Tag was updated" + When I follow "Sign Up" + And I choose "Cabin Pressure" from the "Enter as many fandoms as you like." autocomplete + And I choose "From Eroica with Love" from the "Enter as many fandoms as you like." autocomplete + And I press "Assign" + Then I should see "Wranglers were successfully assigned" + When I edit the tag "From Eroica with Love" + Then I should not see "Sign Up" + And I should see the tag wrangler listed as an editor of the tag + When I edit the tag "Cabin Pressure" + Then I should not see "Sign Up" + And I should see the tag wrangler listed as an editor of the tag + + Scenario: A user can not see the troubleshoot button on a tag page + + Given a canonical fandom "Cowboy Bebop" + And I am logged in as a random user + When I view the tag "Cowboy Bebop" + Then I should not see "Troubleshoot" + + Scenario: A tag wrangler can see the troubleshoot button on a tag page + + Given a canonical fandom "Cowboy Bebop" + And the tag wrangler "lain" with password "lainnial" is wrangler of "Cowboy Bebop" + When I view the tag "Cowboy Bebop" + Then I should see "Troubleshoot" + + Scenario: An admin can see the troubleshoot button on a tag page + + Given a canonical fandom "Cowboy Bebop" + And I am logged in as a "tag_wrangling" admin + When I view the tag "Cowboy Bebop" + Then I should see "Troubleshoot" + + Scenario: Can simultaneously add a grandparent metatag as a direct metatag and remove the parent metatag + Given a canonical fandom "Grandparent" + And a canonical fandom "Parent" + And a canonical fandom "Child" + And "Grandparent" is a metatag of the fandom "Parent" + And "Parent" is a metatag of the fandom "Child" + And I am logged in as a random user + And I post the work "Oldest" with fandom "Grandparent" + And I post the work "Middle" with fandom "Parent" + And I post the work "Youngest" with fandom "Child" + And I am logged in as a tag wrangler + + When I edit the tag "Child" + And I check the 1st checkbox with id matching "MetaTag" + And I fill in "tag_meta_tag_string" with "Grandparent" + # Ensure a new cache key will be used + And it is currently 1 second from now + And I press "Save changes" + Then I should see "Tag was updated" + And I should see "Grandparent" within "#parent_MetaTag_associations_to_remove_checkboxes" + But I should not see "Parent" within "#parent_MetaTag_associations_to_remove_checkboxes" + + When I view the tag "Child" + Then I should see "Grandparent" within ".meta" + But I should not see "Parent" within ".meta" + + When I go to the works tagged "Grandparent" + Then I should see "Oldest" + And I should see "Middle" + And I should see "Youngest" + + When I go to the works tagged "Parent" + Then I should see "Middle" + But I should not see "Oldest" + And I should not see "Youngest" + + When I go to the works tagged "Child" + Then I should see "Youngest" + But I should not see "Oldest" + And I should not see "Middle" + + Scenario: No call to Redis when no action is taken + Given the tag wrangling setup + And I am logged in as a tag wrangler + Then no tag is scheduled for count update from now on + When I go to the wrangling page for "wrangler" + Then I should see "Wrangling Home" + And I should see "Characters by fandom (2)" + When I follow "Characters by fandom (2)" + Then I should see "Mass Wrangle New/Unwrangled Tags" + + Scenario: Subtags are listed in alphabetical order + Given a canonical freeform "Angst" + And a canonical freeform "Angstc" + And it is currently 1 second from now + And a canonical freeform "Angstb" + And it is currently 1 second from now + And a canonical freeform "Angsta" + And "Angst" is a metatag of the freeform "Angstc" + And it is currently 1 second from now + And "Angst" is a metatag of the freeform "Angstb" + And it is currently 1 second from now + And "Angst" is a metatag of the freeform "Angsta" + When I view the tag "Angst" + Then "Angsta" should appear before "Angstb" + And "Angstb" should appear before "Angstc" diff --git a/features/tags_and_wrangling/tag_wrangling_admin.feature b/features/tags_and_wrangling/tag_wrangling_admin.feature new file mode 100644 index 0000000..a7d9151 --- /dev/null +++ b/features/tags_and_wrangling/tag_wrangling_admin.feature @@ -0,0 +1,172 @@ +@users @tag_wrangling @admin +Feature: Tag wrangling + + Scenario: Admin can rename a tag and it updates works and bookmarks. + + Given I am logged in as "audrey" with password "password" + And I post the work "Renoir's Boating Party" + And I bookmark the work "Renoir's Boating Party" with the tags "Amelie" + And I post the work "Luncheon" with fandom "Amelie" + # Visit the relevant pages to make sure the data gets cached. + And I go to audrey's bookmarks page + And I go to audrey's works page + And I go to the work "Luncheon" + When I am logged in as a "tag_wrangling" admin + And I edit the tag "Amelie" + And I fill in "Synonym of" with "Amélie" + And I press "Save changes" + Then I should see "Amélie is considered the same as Amelie by the database" + And I should not see "Tag was successfully updated." + When I fill in "Name" with "Amélie" + And I press "Save changes" + Then I should see "Tag was updated" + And I should see "Amélie" + And I should not see "Amelie" + When I go to audrey's works page + Then I should not see "Amelie" + And I should see "Amélie" + When I go to the work "Luncheon" + Then I should not see "Amelie" + And I should see "Amélie" + When I go to audrey's bookmarks page + Then I should not see "Amelie" + And I should see "Amélie" + + Scenario: Admin can rename a tag using Eastern characters + + Given I am logged in as a "tag_wrangling" admin + And a fandom exists with name: "先生", canonical: false + When I edit the tag "先生" + And I fill in "Name" with "てりやき" + And I press "Save changes" + Then I should see "Tag was updated" + And I should see "てりやき" + And I should not see "先生" + + Scenario: Tag wrangler cannot rename a tag using Eastern characters + + Given I am logged in as a tag wrangler + And a fandom exists with name: "先生", canonical: false + When I edit the tag "先生" + And I fill in "Name" with "てりやき" + And I press "Save changes" + Then I should not see "Tag was updated" + And I should see "Only changes to capitalization and diacritic marks are permitted" + + Scenario: Admin can remove a user's wrangling privileges from the manage users page (this will leave assignments intact) + + Given the tag wrangler "tangler" with password "wr@ngl3r" is wrangler of "Testing" + When I am logged in as a "tag_wrangling" admin + And I am on the manage users page + When I fill in "Name" with "tangler" + And I press "Find" + Then I should see "tangler" within "#admin_users_table" + When I uncheck the "tag_wrangler" role checkbox + And I press "Update" + Then I should see "User was successfully updated." + And "tangler" should not be a tag wrangler + And "Testing" should be assigned to the wrangler "tangler" + + Scenario: Admin can remove a user's wrangling assignments + + Given the tag wrangler "tangler" with password "wr@ngl3r" is wrangler of "Testing" + When I am logged in as a "tag_wrangling" admin + And I am on the wranglers page + And I follow "x" + Then I should see "Wranglers were successfully unassigned!" + And "Testing" should not be assigned to the wrangler "tangler" + When I edit the tag "Testing" + Then I should see "Sign Up" + + Scenario: Tag wrangling admins can download a wrangler's wrangled tags report CSV + + Given the tag wrangler "tangler" with password "wr@ngl3r" is wrangler of "Testing" + And I am logged in as a "tag_wrangling" admin + When I go to the wrangling page for "tangler" + Then I should see "Tags Wrangled (CSV)" + When I follow "Tags Wrangled (CSV)" + Then I should download a csv file with the header row "Name Last Updated Type Merger Fandoms Unwrangleable" + + Scenario Outline: Authorized admins have the tag wrangling item in the admin navbar + + Given I am logged in as a "" admin + Then I should see "Tag Wrangling" within "ul.admin.primary.navigation" + + Examples: + | role | + | superadmin | + | tag_wrangling | + + Scenario Outline: Unauthorized admins do not have the tag wrangling item in the admin navbar + + Given I am logged in as a "" admin + Then I should not see "Tag Wrangling" within "ul.admin.primary.navigation" + + Examples: + | role | + | board | + | board_assistants_team | + | communications | + | development_and_membership | + | docs | + | elections | + | legal | + | translation | + | support | + | policy_and_abuse | + | open_doors | + + Scenario Outline: Fully-authorized admins get the wrangling dashboard sidebar + + Given I am logged in as a "" admin + And basic tags + When I go to the tags page + Then I should see "Wrangling Tools" within "div#dashboard" + And I should see "Wranglers" within "div#dashboard" + And I should see "Search Tags" within "div#dashboard" + And I should see "New Tag" within "div#dashboard" + But I should not see "Wrangling Home" within "div#dashboard" + + Examples: + | role | + | superadmin | + | tag_wrangling | + + Scenario Outline: Read-authorized admins get a partial wrangling dashboard sidebar + + Given I am logged in as a "" admin + And basic tags + When I go to the tags page + Then I should see "Wrangling Tools" within "div#dashboard" + And I should see "Search Tags" within "div#dashboard" + But I should not see "Wranglers" within "div#dashboard" + And I should not see "New Tag" within "div#dashboard" + And I should not see "Wrangling Home" within "div#dashboard" + + Examples: + | role | + | policy_and_abuse | + + Scenario Outline: Unauthorized admins do not get the wrangling dashboard sidebar + + Given I am logged in as a "" admin + And basic tags + When I go to the tags page + Then I should not see "Wrangling Tools" + And I should not see "Wranglers" + And I should not see "Search Tags" + And I should not see "New Tag" + And I should not see "Wrangling Home" + + Examples: + | role | + | board | + | board_assistants_team | + | communications | + | development_and_membership | + | docs | + | elections | + | legal | + | translation | + | support | + | open_doors | diff --git a/features/tags_and_wrangling/tag_wrangling_characters.feature b/features/tags_and_wrangling/tag_wrangling_characters.feature new file mode 100644 index 0000000..6c9c2e3 --- /dev/null +++ b/features/tags_and_wrangling/tag_wrangling_characters.feature @@ -0,0 +1,168 @@ +@tags @users @tag_wrangling @search + +Feature: Tag Wrangling - Characters + +@javascript +Scenario: character wrangling - syns, mergers, characters, autocompletes + + Given the following activated tag wrangler exists + | login | password | + | Enigel | wrangulate! | + And basic tags + And a fandom exists with name: "Doctor Who", canonical: true + And a relationship exists with name: "First Doctor/TARDIS", canonical: true + And I am logged in as "Enigel" with password "wrangulate!" + + # create a new canonical character from tag wrangling interface + When I follow "Tag Wrangling" + And I follow "New Tag" + And I fill in "Name" with "The First Doctor" + And I choose "Character" + And I check "Canonical" + And I press "Create Tag" + Then I should see "Tag was successfully created" + And the "Canonical" checkbox should be checked + And the "Canonical" checkbox should not be disabled + + # create a new non-canonical character from tag wrangling interface + When I follow "New Tag" + And I fill in "Name" with "The Doctor (1st)" + And I choose "Character" + And I press "Create Tag" + Then I should see "Tag was successfully created" + And the "Canonical" checkbox should not be checked + And the "Canonical" checkbox should not be disabled + + # check those two created properly + When I am on the search tags page + And all indexing jobs have been run + And I fill in "Tag name" with "Doctor" + And I press "Search Tags" + # This part of the code is a hot mess. Capybara is returning the first instance of .canonical which contains + # 'First Doctor/TARDIS', which then leaves us unable to check for 'The First Doctor' as being canonical. + # I've changed the code for now to just check that 'The Doctor (1st) as being NON-Canonical + Then I should see "The First Doctor" + And I should see "The Doctor (1st)" + And I should not see "The Doctor (1st)" within "span.canonical" + + # assigning an existing merger to a non-canonical character + When I edit the tag "The Doctor (1st)" + And I fill in "Synonym of" with "The First Doctor" + And I press "Save changes" + Then I should see "Tag was updated" + When I follow "Edit The First Doctor" + Then I should not see "Make tag non-canonical and unhook all associations" + + Given I am logged in as a "tag_wrangling" admin + When I edit the tag "The First Doctor" + Then I should see "Make tag non-canonical and unhook all associations" + And I should see "The Doctor (1st)" + And the "Canonical" checkbox should be checked and disabled + + # creating a new canonical character by renaming + When I fill in "Synonym of" with "First Doctor" + And I press "Save changes" + Then I should see "Tag was updated" + And I should not see "Synonyms" + When I follow "Edit First Doctor" + + # creating non-canonical characters from work posting + When I am logged in as "Enigel" with password "wrangulate!" + And I go to the new work page + And I fill in the basic work information for "Silliness" + And I fill in "Fandoms" with "Doctor Who" + And I fill in "Characters" with "1st Doctor, One" + And I press "Post" + Then I should see "Work was successfully posted." + + # editing non-canonical character in order to syn it to existing canonical merger + When I follow "1st Doctor" + And I follow "Edit" + And I enter "First" in the "Synonym of" autocomplete field + Then I should see "First Doctor" in the autocomplete + But I should not see "The First Doctor" in the autocomplete + When I choose "First Doctor" from the "Synonym of" autocomplete + And I choose "Doctor Who" from the "Fandoms" autocomplete + And I press "Save changes" + Then I should see "Tag was updated" + + # adding a non-canonical synonym to a canonical, fandom should be copied + When I follow "Edit First Doctor" + Then I should see "Doctor Who" + And the "Canonical" checkbox should be disabled + And all indexing jobs have been run + When I choose "One" from the "tag_merger_string_autocomplete" autocomplete + And I fill in "Relationships" with "First Doctor/TARDIS" + And I press "Save changes" + Then I should see "Tag was updated" + And I should see "One" + And I should see "First Doctor/TARDIS" + When I follow "One" + Then I should see "Doctor Who" + But I should not see "First Doctor/TARDIS" within ".tags" + + # metatags and subtags, transference thereof to a new canonical by an admin + When I follow "Edit First Doctor" + And I fill in "MetaTags" with "The Doctor (DW)" + And I press "Save changes" + Then I should see "Invalid metatag 'The Doctor (DW)':" + And I should see "Metatag does not exist." + And I should not see "The Doctor (DW)" within "form" + When I follow "New Tag" + And I fill in "Name" with "The Doctor (DW)" + And I check "Canonical" + And I choose "Character" + And I press "Create Tag" + And I choose "First Doctor" from the "SubTags" autocomplete + And I press "Save changes" + Then I should see "Tag was updated" + When I follow "First Doctor" + Then I should see "The Doctor (DW)" + When I follow "New Tag" + And I fill in "Name" with "John Smith" + And I choose "Character" + And I check "Canonical" + And I press "Create Tag" + And I fill in "MetaTags" with "First Doctor" + And I press "Save changes" + Then I should see "Tag was updated" + When I follow "First Doctor" + Then I should see "John Smith" + And I should see "The Doctor" + When I am logged in as a "tag_wrangling" admin + And I edit the tag "First Doctor" + And I fill in "Synonym of" with "First Doctor (DW)" + And I press "Save changes" + Then I should see "Tag was updated" + And I should not see "John Smith" + And I should not see "The Doctor (1st)" + And I should not see "1st Doctor" + And I should not see "One" + And I should not see "The Doctor (DW)" + When I follow "Edit First Doctor (DW)" + Then I should see "John Smith" + And I should see "First Doctor" within "div#child_Merger_associations_to_remove_checkboxes" + And I should see "The Doctor (1st)" + And I should see "1st Doctor" + And I should see "One" within "div#child_Merger_associations_to_remove_checkboxes" + And I should see "The Doctor (DW)" + + # trying to syn a non-canonical to another non-canonical + When I am logged in as "Enigel" with password "wrangulate!" + And I edit the tag "First Doctor" + And I follow "New Tag" + And I fill in "Name" with "Eleventh Doctor" + And I choose "Character" + And I press "Create Tag" + And I follow "New Tag" + And I fill in "Name" with "Eleven" + And I choose "Character" + And I press "Create Tag" + And I fill in "Synonym of" with "Eleventh Doctor" + And I press "Save changes" + Then I should see "Eleventh Doctor is not a canonical tag. Please make it canonical before adding synonyms to it." + + # trying to syn a non-canonical to a canonical of a different category + When I fill in "Synonym of" with "Doctor Who" + And I press "Save changes" + Then I should see "Doctor Who is a fandom. Synonyms must belong to the same category." diff --git a/features/tags_and_wrangling/tag_wrangling_fandoms.feature b/features/tags_and_wrangling/tag_wrangling_fandoms.feature new file mode 100644 index 0000000..a440ccf --- /dev/null +++ b/features/tags_and_wrangling/tag_wrangling_fandoms.feature @@ -0,0 +1,215 @@ +@tags @users @tag_wrangling + +Feature: Tag Wrangling - Fandoms + +@javascript +Scenario: fandoms wrangling - syns, mergers, autocompletes, metatags + + Given the following activated tag wrangler exists + | login | password | + | Enigel | wrangulate! | + And basic tags + And a media exists with name: "TV Shows", canonical: true + And a character exists with name: "Neal Caffrey", canonical: true + And I am logged in as "Enigel" with password "wrangulate!" + + # create a new canonical fandom from tag wrangling interface + When I follow "Tag Wrangling" + And I follow "New Tag" + And I fill in "Name" with "Stargate SG-1" + And I choose "Fandom" + And I check "Canonical" + And I press "Create Tag" + Then I should see "Tag was successfully created" + And the "Canonical" checkbox should be checked + And the "Canonical" checkbox should not be disabled + + # create a new non-canonical fandom from tag wrangling interface + When I follow "New Tag" + And I fill in "Name" with "SGA" + And I choose "Fandom" + And I press "Create Tag" + Then I should see "Tag was successfully created" + And the "Canonical" checkbox should not be checked + And the "Canonical" checkbox should not be disabled + + # creating a new canonical fandom by synning + When I fill in "Synonym of" with "Stargate Atlantis" + And I press "Save changes" + Then I should see "Tag was updated" + And I should not see "Synonyms" + When I follow "Edit Stargate Atlantis" + And I should see "SGA" + Then the "Canonical" checkbox should be checked and disabled + + # creating non-canonical fandoms from work posting + When I go to the new work page + And I fill in the basic work information for "Silliness" + And I fill in "Fandoms" with "SG1, the whole Stargate franchise, Stargates SG-1" + And I press "Post" + Then I should see "Work was successfully posted." + + # editing non-canonical fandom in order to syn it to existing canonical merger + When I follow "SG1" + And I follow "Edit" + And I enter "Stargate" in the "Synonym of" autocomplete field + Then I should see "Stargate Atlantis" in the autocomplete + And I should see "Stargate SG-1" in the autocomplete + When I choose "Stargate SG-1" from the "Synonym of" autocomplete + And I choose "TV Shows" from the "tag_media_string_autocomplete" autocomplete + And I press "Save changes" + Then I should see "Tag was updated" + + # adding a non-canonical synonym to a canonical, fandom should be copied + When I follow "Edit Stargate SG-1" + Then I should see "TV Shows" + And I should see "SG1" + And the "Canonical" checkbox should be disabled + And all indexing jobs have been run + When I enter "The whole sta" in the "tag_merger_string_autocomplete" autocomplete field + And I should see "the whole Stargate franchise" in the autocomplete + And I should not see "Stargate SG-1" in the autocomplete + And I should not see "Stargates SG-1" in the autocomplete + When I enter "Stargate" in the "tag_merger_string_autocomplete" autocomplete field + Then I should see "Stargates SG-1" in the autocomplete + And I should see "the whole Stargate franchise" in the autocomplete + And I should not see "Stargate SG-1" in the autocomplete + When I choose "Stargates SG-1" from the "tag_merger_string_autocomplete" autocomplete + And I press "Save changes" + Then I should see "Tag was updated" + And I should see "Stargates SG-1" + When I follow "Stargates SG-1" + Then I should see "TV Shows" + + # metatags and subtags, transference thereof to a new canonical by an admin + When I edit the tag "Stargate Atlantis" + And I fill in "MetaTags" with "Stargate Franchise" + And I press "Save changes" + Then I should see "Invalid metatag 'Stargate Franchise':" + And I should see "Metatag does not exist." + And I should not see "Stargate Franchise" within "form" + When I follow "New Tag" + And I fill in "Name" with "Stargate Franchise" + And I check "Canonical" + And I choose "Fandom" + And I press "Create Tag" + And I choose "TV Shows" from the "Add:" autocomplete + And I choose "Stargate Atlantis" from the "Add SubTags" autocomplete + And I press "Save changes" + Then I should see "Tag was updated" + And I should see "TV Shows" + And the "Canonical" checkbox should be checked + When I follow "Stargate Atlantis" + Then I should see "Stargate Franchise" within "div#parent_MetaTag_associations_to_remove_checkboxes" + When I edit the tag "Stargate SG-1" + And I choose "Stargate Franchise" from the "Add MetaTags" autocomplete + And I press "Save changes" + Then I should see "Tag was updated" + When I follow "New Tag" + And I fill in "Name" with "Stargate SG-1: Ark of Truth" + And I check "Canonical" + And I choose "Fandom" + And I press "Create Tag" + And I fill in "MetaTags" with "Stargate SG-1" + And I press "Save changes" + Then I should see "Tag was updated" + When I edit the tag "Stargate SG-1" + Then I should see "Stargate SG-1: Ark of Truth" within "div#child_SubTag_associations_to_remove_checkboxes" + And I should see "Stargate Franchise" within "div#parent_MetaTag_associations_to_remove_checkboxes" + When I am logged in as a "tag_wrangling" admin + And I edit the tag "Stargate SG-1" + And I fill in "Synonym of" with "Stargate SG-1: Greatest Show in the Universe" + And I press "Save changes" + Then I should see "Tag was updated" + And I should not see "Stargate SG-1: Ark of Truth" + And I should not see "Stargates SG-1" + And I should not see "SG1" + And I should not see "Stargate Franchise" + When I follow "Edit Stargate SG-1: Greatest Show in the Universe" + Then I should see "Stargate SG-1: Ark of Truth" + And I should see "Stargates SG-1" + And I should see "SG1" + And I should see "Stargate Franchise" + + # trying to syn a non-canonical to another non-canonical + When I am logged in as "Enigel" with password "wrangulate!" + And I edit the tag "Stargate SG-1: Greatest Show in the Universe" + And I follow "New Tag" + And I fill in "Name" with "White Collar" + And I choose "Fandom" + And I press "Create Tag" + And I follow "New Tag" + And I fill in "Name" with "WhiCo" + And I choose "Fandom" + And I press "Create Tag" + And I fill in "Synonym of" with "White Collar" + And I press "Save changes" + Then I should see "White Collar is not a canonical tag. Please make it canonical before adding synonyms to it." + + # trying to syn a non-canonical to a canonical of a different category + When I fill in "Synonym of" with "Neal Caffrey" + And I press "Save changes" + Then I should see "Neal Caffrey is a character. Synonyms must belong to the same category." + +Scenario: Checking the media pages + + Given basic tags + And a media exists with name: "TV Shows", canonical: true + And a media exists with name: "Video Games", canonical: true + And a media exists with name: "Books", canonical: true + And a canonical fandom "Stargate" + And a canonical fandom "Lord of the Rings" + And a canonical fandom "Final Fantasy" + And a canonical fandom "Yuletide RPF" + And a canonical fandom "A weird thing" + And a canonical fandom "Be another thing" + And a canonical fandom "Be a second B fandom" + And a canonical fandom "Can sort alphabetically" + And the following activated tag wrangler exists + | login | password | + | Enigel | wrangulate! | + When I am logged in as "Enigel" with password "wrangulate!" + And I edit the tag "Stargate" + And I fill in "tag_media_string" with "TV Shows" + And I press "Save changes" + Then I should see "Tag was updated" + When I edit the tag "Lord of the Rings" + And I fill in "tag_media_string" with "Books" + And I press "Save changes" + Then I should see "Tag was updated" + When I edit the tag "Final Fantasy" + And I fill in "tag_media_string" with "Video Games" + And I press "Save changes" + Then I should see "Tag was updated" + + Given I post the work "Test" with fandom "Yuletide RPF" + And I post the work "Test Ring" with fandom "Lord of the Rings" + When I go to the fandoms page + Then I should see "Fandoms" within "h2" + And I should see "Books" within "div#main .media" + And I should see "TV Shows" within "div#main .media" + And I should see "Video Games" within "div#main .media" + And I should see "Uncategorized Fandoms" within "div#main .media" + And I should see "Yuletide RPF" within "div#main .media" + + # Tags will show up if there's at least a work + When I follow "Books" + Then I should see "Fandoms > Books" + And I should not see "No fandoms found" + And I should see "Lord of the Rings" + And I should not see "Stargate" + And I should not see "Yuletide RPF" + + # Tags will not show up if there are no works + When I go to the fandoms page + And I follow "Video Games" + Then I should see "No fandoms found" + And I should not see "Final Fantasy" + + When I go to the media page + Then I should see "Fandoms" within "h2" + When I follow "Uncategorized Fandoms" + Then I should see "B C N W Y" within ".alphabet" + And I should see "A weird thing" within "#letter-W .tags" + And I should see "Be a second B fandom" within "#letter-B .tags" + And I should see "Be another thing" within "#letter-B .tags" diff --git a/features/tags_and_wrangling/tag_wrangling_freeforms.feature b/features/tags_and_wrangling/tag_wrangling_freeforms.feature new file mode 100644 index 0000000..f1a8786 --- /dev/null +++ b/features/tags_and_wrangling/tag_wrangling_freeforms.feature @@ -0,0 +1,130 @@ +@tags @users @tag_wrangling + +Feature: Tag Wrangling - Freeforms + +@javascript +Scenario: freeforms wrangling - syns, mergers, autocompletes, metatags + + Given the following activated tag wrangler exists + | login | password | + | Enigel | wrangulate! | + And basic tags + And I am logged in as "Enigel" with password "wrangulate!" + And I follow "Tag Wrangling" + + # create a new canonical freeform from tag wrangling interface + When I follow "New Tag" + And I fill in "Name" with "Alternate Universe Pirates" + And I choose "Additional Tag" + And I check "Canonical" + And I press "Create Tag" + Then I should see "Tag was successfully created" + And the "Canonical" checkbox should be checked + And the "Canonical" checkbox should not be disabled + + # create a new non-canonical freeform from tag wrangling interface + When I follow "New Tag" + And I fill in "Name" with "Pirates! in Spaaaaace! AU" + And I choose "Additional Tag" + And I press "Create Tag" + Then I should see "Tag was successfully created" + And the "Canonical" checkbox should not be checked + And the "Canonical" checkbox should not be disabled + + # creating a new canonical freeform by synning + When I fill in "Synonym of" with "Alternate Universe Space Pirates" + And I press "Save changes" + Then I should see "Tag was updated" + And I should not see "Synonyms" + When I follow "Edit Alternate Universe Space Pirates" + And I should see "Pirates! in Spaaaaace! AU" + And the "Canonical" checkbox should be checked and disabled + + # creating non-canonical freeforms from work posting + When I go to the new work page + And I fill in the basic work information for "Silliness" + And I fill in "Fandoms" with "Torchwood" + And I fill in "Additional Tags" with "Pirate AU, Arrr-verse" + And I press "Post" + Then I should see "Work was successfully posted." + + # editing non-canonical freeform in order to syn it to existing canonical merger + When I follow "Pirate AU" + And I follow "Edit" + And I choose "Alternate Universe Pirates" from the "Synonym of" autocomplete + And I choose "No Fandom" from the "tag_fandom_string_autocomplete" autocomplete + And I press "Save changes" + Then I should see "Tag was updated" + + # adding a non-canonical synonym to a canonical, fandom should be copied + When I follow "Edit Alternate Universe Pirates" + Then I should see "No Fandom" + And I should see "Pirate AU" + And the "Canonical" checkbox should be disabled + And all indexing jobs have been run + When I choose "Arrr-verse" from the "tag_merger_string_autocomplete" autocomplete + And I press "Save changes" + Then I should see "Tag was updated" + And I should see "Arrr-verse" + When I follow "Arrr-verse" + Then I should see "No Fandom" + + # metatags and subtags, transference thereof to a new canonical by an admin + When I follow "Edit Alternate Universe Pirates" + And I fill in "MetaTags" with "Alternate Universe" + And I press "Save changes" + Then I should see "Invalid metatag 'Alternate Universe':" + And I should see "Metatag does not exist." + And I should not see "Alternate Universe" within "form" + When I follow "New Tag" + And I fill in "Name" with "Alternate Universe" + And I check "Canonical" + And I choose "Additional Tag" + And I press "Create Tag" + And I fill in "Fandoms" with "No Fandom" + And I choose "Alternate Universe Pirates" from the "SubTags" autocomplete + And I press "Save changes" + Then I should see "Tag was updated" + And I should see "No Fandom" + And the "Canonical" checkbox should be checked + When I follow "Alternate Universe Pirates" + Then I should see "Alternate Universe" within "div#parent_MetaTag_associations_to_remove_checkboxes" + When I edit the tag "Alternate Universe Space Pirates" + And I choose "Alternate Universe Pirates" from the "MetaTags" autocomplete + And I press "Save changes" + Then I should see "Tag was updated" + When I follow "Alternate Universe Pirates" + Then I should see "Alternate Universe Space Pirates" + When I am logged in as a "tag_wrangling" admin + And I edit the tag "Alternate Universe Pirates" + And I fill in "Synonym of" with "Alternate Universe Pirrrates" + And I press "Save changes" + Then I should see "Tag was updated" + And I should not see "Alternate Universe Space Pirates" + And I should not see "Pirate AU" + And I should not see "Arrr-verse" + When I follow "Edit Alternate Universe Pirrrates" + Then I should see "Alternate Universe Space Pirates" + And I should see "Pirate AU" + And I should see "Arrr-verse" + And I should see "Alternate Universe Pirates" + + # trying to syn a non-canonical to another non-canonical + When I am logged in as "Enigel" with password "wrangulate!" + And I edit the tag "Alternate Universe Pirates" + And I follow "New Tag" + And I fill in "Name" with "Drabble" + And I choose "Additional Tag" + And I press "Create Tag" + And I follow "New Tag" + And I fill in "Name" with "100 words" + And I choose "Additional Tag" + And I press "Create Tag" + And I fill in "Synonym of" with "Drabble" + And I press "Save changes" + Then I should see "Drabble is not a canonical tag. Please make it canonical before adding synonyms to it." + + # trying to syn a non-canonical to a canonical of a different category + When I fill in "Synonym of" with "No Fandom" + And I press "Save changes" + Then I should see "No Fandom is a fandom. Synonyms must belong to the same category." diff --git a/features/tags_and_wrangling/tag_wrangling_media.feature b/features/tags_and_wrangling/tag_wrangling_media.feature new file mode 100644 index 0000000..cd3580c --- /dev/null +++ b/features/tags_and_wrangling/tag_wrangling_media.feature @@ -0,0 +1,208 @@ +@tags @tag_wrangling +Feature: Media tags + + Scenario: Wranglers do not have the option to create media tags or change + other tags to media tags + Given I am logged in as a tag wrangler + When I go to the new tag page + Then I should not see "Media" within "#new_tag" + When I fill in "Name" with "Not A Media Tag" + And I choose "Fandom" + And I press "Create Tag" + Then I should see "Tag was successfully created." + And "Fandom" should be selected within "tag_type" + # Make sure we can't see admin-only option + And "Media" should not be an option within "tag_type" + + Scenario: Admins can create media tags and then make them canonical + Given I am logged in as a "tag_wrangling" admin + When I go to the new tag page + And I fill in "Name" with "New Media 1" + And I choose "Media" + And I press "Create Tag" + Then I should see "Tag was successfully created." + And "Media" should be selected within "tag_type" + # Make sure we can see regular wrangling option + And "Fandom" should be an option within "tag_type" + When I check "Canonical" + And I press "Save changes" + Then I should see "Tag was updated." + And the "New Media 1" tag should be canonical + And the "New Media 1" tag should be a "Media" tag + + Scenario: Admins can create canonical media tags + Given I am logged in as a "tag_wrangling" admin + When I go to the new tag page + And I fill in "Name" with "New Media 2" + And I check "Canonical" + And I choose "Media" + And I press "Create Tag" + Then I should see "Tag was successfully created." + And the "New Media 2" tag should be canonical + And the "New Media 2" tag should be a "Media" tag + + Scenario: Admins can recategorize tags into media tags + Given a non-canonical fandom "Ambiguous Tag" + And I am logged in as a "tag_wrangling" admin + When I go to the "Ambiguous Tag" tag edit page + And I select "Media" from "tag_type" + And I press "Save changes" + Then I should see "Tag was updated." + And the "Ambiguous Tag" tag should be a "Media" tag + + Scenario: Admins can recategorize media tags into other types + Given a non-canonical media "Not A Media Anymore" + And I am logged in as a "tag_wrangling" admin + When I go to the "Not A Media Anymore" tag edit page + And I select "Relationship" from "tag_type" + And I press "Save changes" + Then I should see "Tag was updated." + And the "Not A Media Anymore" tag should be a "Relationship" tag + + Scenario: New canonical media tags are added to the Fandoms header menu + # Make sure the old state gets cached + When I go to the homepage + Then I should not see "New Media 3" within "#header .primary .dropdown .menu" + When I create the canonical media tag "New Media 3" + And I am logged out + When I go to the homepage + Then I should see "New Media 3" within "#header .primary .dropdown .menu" + When I follow "New Media 3" within "#header .primary .dropdown .menu" + Then I should see "Fandoms > New Media 3" + And I should see "No fandoms found" + + Scenario: New canonical media tags are added to the Fandoms list on the homepage + # Make sure the old state gets cached + Given I go to the homepage + Then I should not see "New Media 3b" within "#main .splash .browse" + When I create the canonical media tag "New Media 3b" + And I am logged out + When I go to the homepage + Then I should see "New Media 3b" within "#main .splash .browse" + When I follow "New Media 3b" within "#main .splash .browse" + Then I should see "Fandoms > New Media 3b" + And I should see "No fandoms found" + + Scenario: New canonical media tags are added to the Fandoms page + Given I create the canonical media tag "New Media 4" + When I go to the fandoms page + Then I should see "New Media 4" within "#main .media" + When I follow "New Media 4" within "#main .media" + Then I should see "Fandoms > New Media 4" + And I should see "No fandoms found" + + Scenario: New non-canonical media tags are not added to the Fandoms header menu and the Fandoms list on the homepage + # Make sure the old state gets cached + When I go to the homepage + Then I should not see "MCYT" within "#header .primary .dropdown .menu" + And I should not see "MCYT" within "#main .splash .browse" + When I create the non-canonical media tag "MCYT" + And I am logged out + When I go to the homepage + Then I should not see "MCYT" within "#header .primary .dropdown .menu" + And I should not see "MCYT" within "#main .splash .browse" + + Scenario: New non-canonical media tags are not added to the Fandoms page + Given I create the non-canonical media tag "MCYT" + When I go to the fandoms page + Then I should not see "MCYT" within "#main .media" + + Scenario: Canonizing a media tag adds it to the Fandoms header menu and the Fandoms list on the homepage + # Make sure the old state gets cached + Given a non-canonical media "MCYT" + When I go to the homepage + Then I should not see "MCYT" within "#header .primary .dropdown .menu" + And I should not see "MCYT" within "#main .splash .browse" + When I am logged in as a "tag_wrangling" admin + And I edit the tag "MCYT" + And I check "Canonical" + And I press "Save changes" + Then I should see "Tag was updated." + When I am logged out + And I go to the homepage + Then I should see "MCYT" within "#header .primary .dropdown .menu" + And I should see "MCYT" within "#main .splash .browse" + + Scenario: Decanonizing a media tag removes it from to the Fandoms header menu and the Fandoms list on the homepage + Given a canonical media "MCYEET" + # Make sure the old state gets cached + And I go to the homepage + Then I should see "MCYEET" within "#header .primary .dropdown .menu" + And I should see "MCYEET" within "#main .splash .browse" + When I am logged in as a "tag_wrangling" admin + And I edit the tag "MCYEET" + And I uncheck "Canonical" + And I press "Save changes" + Then I should see "Tag was updated." + When I am logged out + And I go to the homepage + Then I should not see "MCYEET" within "#header .primary .dropdown .menu" + And I should not see "MCYEET" within "#main .splash .browse" + + Scenario: Recategorizing a tag as media tag adds it to the Fandoms header menu and the Fandoms list on the homepage + # Make sure the old state gets cached + When I go to the homepage + Then I should not see "Yet Another Media" within "#header .primary .dropdown .menu" + And I should not see "Yet Another Media" within "#main .splash .browse" + When I recategorize the "Yet Another Media" fandom as a "Media" tag + And I am logged out + When I go to the homepage + Then I should see "Yet Another Media" within "#header .primary .dropdown .menu" + And I should see "Yet Another Media" within "#main .splash .browse" + + Scenario: Recategorizing a media tag removes it from to the Fandoms header menu and the Fandoms list on the homepage + Given a canonical media "Not a medium" + # Make sure the old state gets cached + And I go to the homepage + Then I should see "Not a medium" within "#header .primary .dropdown .menu" + And I should see "Not a medium" within "#main .splash .browse" + When I am logged in as a "tag_wrangling" admin + And I edit the tag "Not a medium" + And I uncheck "Canonical" + And I press "Save changes" + Then I should see "Tag was updated." + When I edit the tag "Not a medium" + And I select "Character" from "tag_type" + And I press "Save changes" + Then I should see "Tag was updated." + And the "Not a medium" tag should be a "Character" tag + When I am logged out + And I go to the homepage + Then I should not see "Not a medium" within "#header .primary .dropdown .menu" + And I should not see "Not a medium" within "#main .splash .browse" + + Scenario: Renaming a media tag as admin changes it in the Fandoms header menu and the Fandoms list on the homepage + Given a canonical media "New Mediia Tag" + # Make sure the old state gets cached + And I go to the homepage + Then I should see "New Mediia Tag" within "#header .primary .dropdown .menu" + And I should see "New Mediia Tag" within "#main .splash .browse" + When I am logged in as a "tag_wrangling" admin + And I edit the tag "New Mediia Tag" + And I fill in "Name" with "New Media Tag" + And I press "Save changes" + Then I should see "Tag was updated." + When I am logged out + And I go to the homepage + Then I should see "New Media Tag" within "#header .primary .dropdown .menu" + And I should see "New Media Tag" within "#main .splash .browse" + + @javascript + Scenario: Wranglers can add media tags to fandoms and fandoms to media tags + Given a canonical media "Big Media" + And a canonical fandom "Great Fandom" + And a canonical fandom "Greater Fandom" + And I am logged in as a tag wrangler + And I post the work "Some work" with fandom "Great Fandom" + And I post the work "Some other work" with fandom "Greater Fandom" + When I edit the tag "Great Fandom" + And I choose "Big Media" from the "tag_media_string_autocomplete" autocomplete + And I press "Save changes" + Then I should see "Tag was updated." + When I edit the tag "Big Media" + And I choose "Greater Fandom" from the "tag_fandom_string_autocomplete" autocomplete + And I press "Save changes" + Then I should see "Tag was updated." + When I go to the "Big Media" fandoms page + Then I should see "Great Fandom" + And I should see "Greater Fandom" diff --git a/features/tags_and_wrangling/tag_wrangling_more.feature b/features/tags_and_wrangling/tag_wrangling_more.feature new file mode 100644 index 0000000..242e2ff --- /dev/null +++ b/features/tags_and_wrangling/tag_wrangling_more.feature @@ -0,0 +1,396 @@ +@tags @tag_wrangling +Feature: Tag wrangling: assigning wranglers, using the filters on the Wranglers page + + Scenario: Log in as a tag wrangler and see wrangler pages. + View new tags in your fandoms + Given a media exists with name: "TV Shows", canonical: true + And a canonical fandom "first fandom" + And a canonical character "Person A" + And a canonical fandom "Ghost Soup" + And a non-canonical fandom "second fandom" + And the following activated tag wranglers exist + | login | password | + | Enigel | wrangulator | + | dizmo | wrangulator | + + # accessing tag wrangling pages + When I am logged in as "dizmo" with password "wrangulator" + And I follow "Tag Wrangling" + Then I should see "Wrangling Home" + And I should not see "first fandom" + When I follow "Wranglers" + Then I should see "Tag Wrangling Assignments" + And I should see "first fandom" + When I view the tag "first fandom" + Then I should see "Edit" + When I follow "Edit" within ".header" + Then I should see "Edit first fandom Tag" + + # assigning media to a fandom + When I fill in "tag[media_string]" with "TV Shows" + And I press "Save changes" + Then I should see "Tag was updated" + When I follow "Tag Wrangling" + Then I should see "Wrangling Home" + And I should not see "first fandom" + When I follow "Wranglers" + Then I should see "Tag Wrangling Assignments" + And I should see "first fandom" + + # assigning a fandom to oneself + When I fill in "tag_fandom_string" with "first fandom" + And I press "Assign" + And I follow "Wrangling Home" + And I follow "Wranglers" + Then I should see "first fandom" + And I should see "dizmo" within "ul.wranglers" + Given I add the fandom "first fandom" to the character "Person A" + + # checking that wrangling home shows unfilterables + When I follow "Wrangling Home" + Then I should see "first fandom" + And I should see "Unfilterable" + When I follow "first fandom" + Then I should see "Wrangle Tags for first fandom" + And I should see "Characters (1)" + + When I log out + And I am logged in as "Enigel" with password "wrangulator" + And I follow "Tag Wrangling" + + # assigning another wrangler to a fandom + When I follow "Wranglers" + And I fill in "fandom_string" with "Ghost" + And I press "Filter" + Then I should see "Ghost Soup" + And I should not see "first fandom" + When I select "dizmo" from the "Ghost Soup" wrangling assigment dropdown + And I press "Assign" + Then I should see "Wranglers were successfully assigned" + + # the filters on the Wranglers page + When I select "TV Shows" from "media_id" + And I fill in "fandom_string" with "" + And I press "Filter" + Then "TV Shows" should be selected within "media_id" + And I should see "first fandom" + And I should not see "second fandom" + When I select "dizmo" from "wrangler_id" + And I press "Filter" + Then I should see "first fandom" + And I should not see "Ghost Soup" + When I select "" from "media_id" + And I press "Filter" + Then "dizmo" should be selected within "wrangler_id" + And I should see "Ghost Soup" + And I should see "first fandom" + + Scenario: Wrangler can remove self from a fandom + + Given the tag wrangler "tangler" with password "wr@ngl3r" is wrangler of "Testing" + And I am logged in as "tangler" with password "wr@ngl3r" + When I am on the wranglers page + And I follow "x" + Then I should see "Wranglers were successfully unassigned!" + And "Testing" should not be assigned to the wrangler "tangler" + When I edit the tag "Testing" + Then I should see "Sign Up" + + Scenario: Wrangler can remove another wrangler from a fandom + + Given the tag wrangler "tangler" with password "wr@ngl3r" is wrangler of "Testing" + And the following activated tag wrangler exists + | login | + | wranglerette | + When I am logged in as "wranglerette" + And I am on the wranglers page + And I follow "x" + Then I should see "Wranglers were successfully unassigned!" + And "Testing" should not be assigned to the wrangler "tangler" + When I edit the tag "Testing" + Then I should see "Sign Up" + + Scenario: Updating multiple tags works. + Given a canonical fandom "Cowboy Bebop" + And a noncanonical freeform "Spike Spiegel is a sweetie" + And a noncanonical freeform "Jet Black is a sweetie" + And I am logged in as a random user + And I post the work "Brain Scratch" with fandom "Cowboy Bebop" with freeform "Spike Spiegel is a sweetie" + And I post the work "Asteroid Blues" with fandom "Cowboy Bebop" with freeform "Jet Black is a sweetie" + When the tag wrangler "lain" with password "lainnial" is wrangler of "Cowboy Bebop" + And I follow "Tag Wrangling" + And I follow "2" + And I fill in "Wrangle to Fandom(s)" with "Cowboy Bebop" + And I check the mass wrangling option for "Spike Spiegel is a sweetie" + And I check the mass wrangling option for "Jet Black is a sweetie" + And I press "Wrangle" + Then I should see "The following tags were successfully wrangled to Cowboy Bebop: Spike Spiegel is a sweetie, Jet Black is a sweetie" + + Scenario: Updating multiple tags works and set them as canonical + Given the following typed tags exists + | name | type | canonical | + | Cowboy Bebop | Fandom | true | + | Faye Valentine is a sweetie | Freeform | false | + | Ed is a sweetie | Freeform | false | + And I am logged in as a random user + And I post the work "Asteroid Blues" with fandom "Cowboy Bebop" with freeform "Ed is a sweetie" + And I post the work "Honky Tonk Women" with fandom "Cowboy Bebop" with freeform "Faye Valentine is a sweetie" + When the tag wrangler "lain" with password "lainnial" is wrangler of "Cowboy Bebop" + And I follow "Tag Wrangling" + And I follow "2" + And I fill in "fandom_string" with "Cowboy Bebop" + And I check the mass wrangling option for "Faye Valentine is a sweetie" + And I check the mass wrangling option for "Ed is a sweetie" + And I check the canonical option for the tag "Faye Valentine is a sweetie" + And I check the canonical option for the tag "Ed is a sweetie" + And I press "Wrangle" + Then I should see "The following tags were successfully wrangled to Cowboy Bebop: Faye Valentine is a sweetie, Ed is a sweetie" + And the "Faye Valentine is a sweetie" tag should be canonical + And the "Ed is a sweetie" tag should be canonical + + Scenario: Mass wrangling in the fandoms bins + Given I am logged in as a tag wrangler + And a media exists with name: "Anime & Manga", canonical: true + And the following typed tags exists + | name | type | canonical | + | Cowboy Bebop | Fandom | true | + And I post the work "Honky Tonk Women" with fandom "Cowboy Bebop" + And all indexing jobs have been run + When I go to the fandom mass bin + And I check the wrangling option for "Cowboy Bebop" + And I select "Anime & Manga" from "Wrangle to Media" + And I press "Wrangle" + Then I should not see "Cowboy Bebop" + + Scenario: A relationship can't be mass wrangled into a fandom that isn't a + canonical tag + Given I am logged in as a tag wrangler + And the following typed tags exists + | name | type | canonical | + | Toby Daye/Tybalt | Relationship | true | + | October Daye Series - Seanan McGuire | Fandom | false | + And I post the work "Honky Tonk Women" with fandom "October Daye Series - Seanan McGuire" with relationship "Toby Daye/Tybalt" + And all indexing jobs have been run + When I go to the relationship mass bin + And I check the wrangling option for "Toby Daye/Tybalt" + And I fill in "Wrangle to Fandom(s)" with "October Daye Series - Seanan McGuire" + And I press "Wrangle" + Then I should see "The following names are not canonical fandoms: October Daye Series - Seanan McGuire." + + Scenario: A relationship can be mass wrangled into a fandom that is a + canonical tag + Given I am logged in as a tag wrangler + And the following typed tags exists + | name | type | canonical | + | Toby Daye/Tybalt | Relationship | true | + | October Daye Series - Seanan McGuire | Fandom | true | + And I post the work "Honky Tonk Women" with fandom "October Daye Series - Seanan McGuire" with relationship "Toby Daye/Tybalt" + And all indexing jobs have been run + When I go to the relationship mass bin + And I check the wrangling option for "Toby Daye/Tybalt" + And I fill in "Wrangle to Fandom(s)" with "October Daye Series - Seanan McGuire" + And I press "Wrangle" + Then I should see "The following tags were successfully wrangled to October Daye Series - Seanan McGuire: Toby Daye/Tybalt" + + Scenario: A wrangler can make tags canonical while mass wrangling + Given I am logged in as a tag wrangler + And the following typed tags exists + | name | type | canonical | + | Cowboy Bebop | Fandom | true | + | Faye Valentine | Character | false | + | Ed | Character | false | + And I post the work "Honky Tonk Women" with fandom "Cowboy Bebop" with character "Faye Valentine" with second character "Ed" + And all indexing jobs have been run + When I go to the character mass bin + And I fill in "Wrangle to Fandom(s)" with "Cowboy Bebop" + And I check the canonical option for the tag "Faye Valentine" + And I check the canonical option for the tag "Ed" + And I press "Wrangle" + Then I should see "The following tags were successfully made canonical: Faye Valentine, Ed" + + Scenario: Banned tags can only be viewed by an admin + Given the following typed tags exists + | name | type | + | Cowboy Bebop | Banned | + When I am logged in as a random user + And I view the tag "Cowboy Bebop" + 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 a "tag_wrangling" admin + And I view the tag "Cowboy Bebop" + Then I should not see "Please log in as an admin" + And I should see "Cowboy Bebop" + + Scenario: Synning a fandom to a canonical fandom moves its unwrangled tags to the canonical's unwrangled bins; de-synning takes them out. + Given the tag wrangler "krebbs" with password "southfork" is wrangler of "Canonical Fandom" + And I post the work "Populating My Syn Fandom" with fandom "Syn Fandom" with character "Syn Fandom Character" with freeform "Syn Fandom Freeform" with relationship "Syn Fandom Relationship" + When I syn the tag "Syn Fandom" to "Canonical Fandom" + And all indexing jobs have been run + And I view the unwrangled character bin for "Canonical Fandom" + Then I should see "Syn Fandom Character" + When I view the unwrangled freeform bin for "Canonical Fandom" + Then I should see "Syn Fandom Freeform" + When I view the unwrangled relationship bin for "Canonical Fandom" + Then I should see "Syn Fandom Relationship" + When I de-syn the tag "Syn Fandom" from "Canonical Fandom" + And all indexing jobs have been run + And I view the unwrangled character bin for "Canonical Fandom" + Then I should not see "Syn Fandom Character" + When I view the unwrangled freeform bin for "Canonical Fandom" + Then I should not see "Syn Fandom Freeform" + When I view the unwrangled relationship bin for "Canonical Fandom" + Then I should not see "Syn Fandom Relationship" + + Scenario: Synning a character to a canonical character moves its unwrangled relationships to the canonical's unwrangled bin; de-synning takes them out. + Given a canonical character "Canonical Character" + And I am logged in as a tag wrangler + And I post the work "Populating My Syn Character" with character "Syn Character" with relationship "Syn Character/OC" + When I syn the tag "Syn Character" to "Canonical Character" + And all indexing jobs have been run + And I view the unwrangled relationship bin for "Canonical Character" + Then I should see "Syn Character/OC" + When I de-syn the tag "Syn Character" from "Canonical Character" + And all indexing jobs have been run + And I view the unwrangled relationship bin for "Canonical Character" + Then I should not see "Syn Character/OC" + + Scenario: Tags from draft works don't show in unwrangled bins + Given a canonical fandom "Testing" + And I am logged in as a tag wrangler + And I set up the draft "Generic Work" with fandom "Testing" with character "draft char" with freeform "draft freeform" with relationship "draft rel" + And I press "Preview" + And the periodic tag count task is run + And all indexing jobs have been run + And I flush the wrangling sidebar caches + When I view the unwrangled character bin for "Testing" + Then I should not see "draft char" + When I view the unwrangled freeform bin for "Testing" + Then I should not see "draft freeform" + When I view the unwrangled relationship bin for "Testing" + Then I should not see "draft rel" + When I go to the wrangling tools page + And I follow "Characters by fandom (0)" + Then I should not see "draft char" + When I follow "Freeforms by fandom (0)" + Then I should not see "draft freeform" + When I follow "Relationships by fandom (0)" + Then I should not see "draft rel" + + Scenario: When the only draft using a tag is posted, the tag shows up in unwrangled bins + Given a canonical fandom "Testing" + And I am logged in as a tag wrangler + And I set up the draft "Generic Work" with fandom "Testing" with character "draft char" + And I press "Preview" + And the periodic tag count task is run + And all indexing jobs have been run + When I view the unwrangled character bin for "Testing" + Then I should not see "draft char" + When I post the work "Generic Work" + And the periodic tag count task is run + And all indexing jobs have been run + And I flush the wrangling sidebar caches + And I view the unwrangled character bin for "Testing" + Then I should see "draft char" + When I go to the wrangling tools page + And I follow "Characters by fandom (1)" + Then I should see "draft char" + + Scenario: Tags from bookmarks don't show up in unwrangled bins after being sorted and assigned to a fandom + Given a canonical fandom "Testing" + And I am logged in as a tag wrangler + And I post the work "Generic Work" + And I bookmark the work "Generic Work" with the tags "bookmark rel tag, bookmark char tag" + When I go to the unsorted_tags page + And I select "Relationship" for the unsorted tag "bookmark rel tag" + And I select "Character" for the unsorted tag "bookmark char tag" + And I press "Update" + Then I should see "Tags were successfully sorted" + And the "bookmark rel tag" tag should be a "Relationship" tag + And the "bookmark char tag" tag should be a "Character" tag + When the periodic tag count task is run + And all indexing jobs have been run + And I flush the wrangling sidebar caches + And I go to the wrangling tools page + And I follow "Characters by fandom (0)" + Then I should not see "bookmark char tag" + When I follow "Relationships by fandom (0)" + Then I should not see "bookmark rel tag" + When I add the fandom "Testing" to the tag "bookmark char tag" + And I add the fandom "Testing" to the tag "bookmark rel tag" + Then the "bookmark char tag" tag should be in the "Testing" fandom + And the "bookmark rel tag" tag should be in the "Testing" fandom + And the periodic tag count task is run + And all indexing jobs have been run + And I flush the wrangling sidebar caches + When I view the unwrangled character bin for "Testing" + Then I should not see "bookmark char tag" + When I view the unwrangled relationship bin for "Testing" + Then I should not see "bookmark rel tag" + When I go to the wrangling tools page + And I follow "Characters by fandom (0)" + Then I should not see "bookmark char tag" + When I follow "Relationships by fandom (0)" + Then I should not see "bookmark rel tag" + + Scenario: Tags from unrevealed works don't show in unwrangled bins + Given a canonical fandom "Testing" + And I have the hidden collection "Unrevealed Tags" + And I am logged in as a tag wrangler + When I post the work "Hello There" with fandom "Testing" with character "unrevealed char" in the collection "Unrevealed Tags" + Given the periodic tag count task is run + And all indexing jobs have been run + And I flush the wrangling sidebar caches + When I view the unwrangled character bin for "Testing" + Then I should not see "unrevealed char" + + Scenario: Tags from unrevealed works appear in unwrangled bins when the work is revealed + Given a canonical fandom "Testing" + And I have the hidden collection "Unrevealed Tags" + And I am logged in as a tag wrangler + When I post the work "Hello There" with fandom "Testing" with character "unrevealed char" in the collection "Unrevealed Tags" + Given the periodic tag count task is run + And all indexing jobs have been run + And I flush the wrangling sidebar caches + When I view the unwrangled character bin for "Testing" + Then I should not see "unrevealed char" + When I reveal works for "Unrevealed Tags" + Given the periodic tag count task is run + And all indexing jobs have been run + And I flush the wrangling sidebar caches + And I am logged in as a tag wrangler + When I view the unwrangled character bin for "Testing" + Then I should see "unrevealed char" + + Scenario: Tags from hidden works don't appear in unwrangled bins + Given a canonical fandom "Testing" + And I am logged in as a tag wrangler + When I post the work "Hello There" with fandom "Testing" with character "hidden char" + When I am logged in as a super admin + And I hide the work "Hello There" + Given the periodic tag count task is run + And all indexing jobs have been run + And I flush the wrangling sidebar caches + And I am logged in as a tag wrangler + When I view the unwrangled character bin for "Testing" + Then I should not see "hidden char" + + Scenario: Tags from hidden works appear in unwrangled bins when the work is un-hidden + Given a canonical fandom "Testing" + And I am logged in as a tag wrangler + When I post the work "Hello There" with fandom "Testing" with character "hidden char" + When I am logged in as a super admin + And I hide the work "Hello There" + Given the periodic tag count task is run + And all indexing jobs have been run + And I flush the wrangling sidebar caches + And I am logged in as a tag wrangler + When I view the unwrangled character bin for "Testing" + Then I should not see "hidden char" + When I am logged in as a super admin + And I view the work "Hello There" + And I follow "Make Work Visible" + Given the periodic tag count task is run + And all indexing jobs have been run + And I flush the wrangling sidebar caches + And I am logged in as a tag wrangler + When I view the unwrangled character bin for "Testing" + Then I should see "hidden char" diff --git a/features/tags_and_wrangling/tag_wrangling_relationships.feature b/features/tags_and_wrangling/tag_wrangling_relationships.feature new file mode 100644 index 0000000..f997799 --- /dev/null +++ b/features/tags_and_wrangling/tag_wrangling_relationships.feature @@ -0,0 +1,277 @@ +@tags @users @tag_wrangling + +Feature: Tag Wrangling - Relationships + +@javascript +Scenario: relationship wrangling - syns, mergers, characters, autocompletes + + Given the following activated tag wrangler exists + | login | password | + | Enigel | wrangulate! | + And basic tags + And a canonical fandom "Torchwood" + And a canonical character "Hoban Washburne" + And a canonical character "Zoe Washburne" + And a canonical character "Jack Harkness" + And a canonical character "Ianto Jones" + And I am logged in as a "tag_wrangling" admin + And I follow "Tag Wrangling" + + # create a new canonical relationship from tag wrangling interface + When I follow "New Tag" + And I fill in "Name" with "Jack Harkness/Ianto Jones" + And I choose "Relationship" + And I check "Canonical" + And I press "Create Tag" + Then I should see "Tag was successfully created" + And the "Canonical" checkbox should be checked + And the "Canonical" checkbox should not be disabled + + # create a new non-canonical relationship from tag wrangling interface + When I follow "New Tag" + And I fill in "Name" with "Wash/Zoe" + And I choose "Relationship" + And I press "Create Tag" + Then I should see "Tag was successfully created" + And the "Canonical" checkbox should not be checked + And the "Canonical" checkbox should not be disabled + + # assigning characters AND a new merger to a non-canonical relationship + When I fill in "Characters" with "Hoban Washburne, Zoe Washburne" + And I fill in "Synonym of" with "Hoban Washburne/Zoe Washburne" + And I press "Save changes" + Then I should see "Tag was updated" + And I should see "Hoban Washburne" within "div#parent_Character_associations_to_remove_checkboxes" + And I should see "Zoe Washburne" within "div#parent_Character_associations_to_remove_checkboxes" + When I follow "Edit Hoban Washburne/Zoe Washburne" + Then I should see "Hoban Washburne" within "div#parent_Character_associations_to_remove_checkboxes" + And I should see "Zoe Washburne" within "div#parent_Character_associations_to_remove_checkboxes" + And I should see "Wash/Zoe" + And the "Canonical" checkbox should be checked and disabled + + # creating a new canonical relationship by renaming + When I fill in "Synonym of" with "Hoban 'Wash' Washburne/Zoe Washburne" + And I press "Save changes" + Then I should see "Tag was updated" + And I should not see "Synonyms" + When I follow "Edit Hoban 'Wash' Washburne/Zoe Washburne" + Then I should see "Make tag non-canonical and unhook all associations" + And I should see "Wash/Zoe" + And I should see "Hoban Washburne/Zoe Washburne" + And I should see "Hoban Washburne" within "div#parent_Character_associations_to_remove_checkboxes" + And I should see "Zoe Washburne" within "div#parent_Character_associations_to_remove_checkboxes" + And the "Canonical" checkbox should be checked and disabled + + # creating non-canonical relationships from work posting + When I am logged in as "Enigel" with password "wrangulate!" + And I go to the new work page + And I fill in the basic work information for "Silliness" + And I fill in "Fandoms" with "Torchwood" + And I fill in "Relationships" with "Janto, Jack/Ianto" + And I press "Post" + Then I should see "Work was successfully posted." + + # editing non-canonical relationship in order to syn it to existing canonical merger AND add characters + When I follow "Jack/Ianto" + And I follow "Edit" + And I choose "Jack Harkness/Ianto Jones" from the "Synonym of" autocomplete + And I choose "Jack Harkness" from the "Characters" autocomplete + And I choose "Ianto Jones" from the "Characters" autocomplete + And I choose "Torchwood" from the "Fandoms" autocomplete + And I press "Save changes" + Then I should see "Tag was updated" + + # adding a non-canonical synonym to a canonical, fandom should be copied + When I follow "Edit Jack Harkness/Ianto Jones" + Then I should see "Jack Harkness" within "div#parent_Character_associations_to_remove_checkboxes" + And I should see "Ianto Jones" within "div#parent_Character_associations_to_remove_checkboxes" + And I should see "Torchwood" + And I should see "Jack/Ianto" + And all indexing jobs have been run + And the "Canonical" checkbox should be disabled + And I choose "Janto" from the "tag_merger_string_autocomplete" autocomplete + And I press "Save changes" + Then I should see "Tag was updated" + And I should see "Janto" + When I follow "Janto" + Then I should see "Torchwood" + But I should not see "Jack Harkness" within ".tags" + And I should not see "Ianto Jones" within ".tags" + + # metatags and subtags, transference thereof to a new canonical by an admin + When I follow "Edit Jack Harkness/Ianto Jones" + And I fill in "MetaTags" with "Jack Harkness/Male Character" + And I press "Save changes" + Then I should see "Invalid metatag 'Jack Harkness/Male Character':" + And I should see "Metatag does not exist." + And I should not see "Jack Harkness/Male Character" within "form" + When I follow "New Tag" + And I fill in "Name" with "Jack Harkness/Male Character" + And I check "Canonical" + And I choose "Relationship" + And I press "Create Tag" + And I choose "Jack Harkness/Ianto Jones" from the "SubTags" autocomplete + And I press "Save changes" + Then I should see "Tag was updated" + When I follow "Jack Harkness/Ianto Jones" + Then I should see "Jack Harkness/Male Character" + When I follow "New Tag" + And I fill in "Name" with "Jack Harkness/Robot Ianto Jones" + And I choose "Relationship" + And I check "Canonical" + And I press "Create Tag" + And I fill in "MetaTags" with "Jack Harkness/Ianto Jones" + And I press "Save changes" + Then I should see "Tag was updated" + When I follow "Jack Harkness/Ianto Jones" + Then I should see "Jack Harkness/Robot Ianto Jones" + And I should see "Jack Harkness/Male Character" + When I am logged in as a "tag_wrangling" admin + And I edit the tag "Jack Harkness/Ianto Jones" + And I fill in "Synonym of" with "Captain Jack Harkness/Ianto Jones" + And I press "Save changes" + Then I should see "Tag was updated" + And I should not see "Jack Harkness/Robot Ianto Jones" + And I should not see "Jack Harkness/Male Character" + And I should not see "Janto" + And I should not see "Jack/Ianto" + When I follow "Edit Captain Jack Harkness/Ianto Jones" + Then I should see "Jack Harkness/Robot Ianto Jones" + And I should see "Jack Harkness/Male Character" + And I should see "Janto" + And I should see "Jack/Ianto" + And I should see "Jack Harkness/Ianto Jones" within "div#child_Merger_associations_to_remove_checkboxes" + + # trying to syn a non-canonical to another non-canonical + When I am logged in as "Enigel" with password "wrangulate!" + And I edit the tag "Jack Harkness/Ianto Jones" + And I follow "New Tag" + And I fill in "Name" with "James Norrington/Jack Sparrow" + And I choose "Relationship" + And I press "Create Tag" + And I follow "New Tag" + And I fill in "Name" with "Sparrington" + And I choose "Relationship" + And I press "Create Tag" + And I fill in "Synonym of" with "James Norrington/Jack Sparrow" + And I press "Save changes" + Then I should see "James Norrington/Jack Sparrow is not a canonical tag. Please make it canonical before adding synonyms to it." + + # trying to syn a non-canonical to a canonical of a different category + When I fill in "Synonym of" with "Torchwood" + And I press "Save changes" + Then I should see "Torchwood is a fandom. Synonyms must belong to the same category." + +Scenario: AO3-959 Non-canonical merger pairings + + Given the following activated tag wrangler exists + | login | password | + | Enigel | wrangulate! | + And basic tags + And a canonical fandom "Testing" + And a canonical relationship "Testing McTestypants/Testing McTestySkirt" + And a noncanonical relationship "Testypants/Testyskirt" + And I am logged in as "Enigel" with password "wrangulate!" + And I follow "Tag Wrangling" + + When I edit the tag "Testing McTestypants/Testing McTestySkirt" + And I fill in "Fandoms" with "Testing" + And I press "Save changes" + Then I should see "Tag was updated" + + When I edit the tag "Testypants/Testyskirt" + And I fill in "Synonym of" with "Testing McTestypants/Testing McTestySkirt" + And I press "Save changes" + Then I should see "Tag was updated" + + When I edit the tag "Testing McTestypants/Testing McTestySkirt" + # I'm not sure how the line below was ever passing? The checkbox is disabled, and from what I can gather from + # some wranglers, it is expected behavior. + # And I uncheck "Canonical" + And I press "Save changes" + Then I should see "Tag was updated" + + When I post the work "Pants and skirts" + And I edit the work "Pants and skirts" + And I fill in "Relationships" with "Testypants/Testyskirt" + And I press "Preview" + And I press "Update" + Then I should see "Work was successfully updated" + + When all indexing jobs have been run + And I go to Enigel's works page + Then I should see "Testypants/Testyskirt" + And I should see "Testing McTestypants/Testing McTestySkirt" + When I view the tag "Testing" + Then I should see "Testing McTestypants/Testing McTestySkirt" + And I should see "Testypants/Testyskirt" + When I go to Enigel's user page + Then I should see "Testypants/Testyskirt" + And I should not see "Testing McTestypants/Testing McTestySkirt" + +Scenario: AO3-2147 Creating a new merger to a non-can tag while adding characters which belong to a fandom + + Given the following activated tag wrangler exists + | login | password | + | Enigel | wrangulate | + And the following activated user exists + | login | password | + | writer | password | + And basic tags + And a canonical fandom "Up with Testing" + And a canonical fandom "Coding" + And a canonical character "Testing McTestypants" + And a canonical character "Testing McTestySkirt" + + # create a relationship from posting a work as a regular user, just in case + Given I am logged in as "writer" with password "password" + And I follow "New Work" + And I select "English" from "Choose a language" + And I fill in "Fandoms" with "Up with Testing" + And I fill in "Work Title" with "whatever" + And I fill in "Relationships" with "Testypants/Testyskirt" + And I fill in "content" with "a long story about nothing" + And I press "Preview" + And I press "Post" + And I log out + + # wrangle the tags to be as close of those that have errored on beta and test + When I am logged in as "Enigel" with password "wrangulate" + And I edit the tag "Coding" + And I fill in "MetaTags" with "Up with Testing" + And I press "Save changes" + Then I should see "Tag was updated" + And I should see "Up with Testing" + + When I edit the tag "Testing McTestypants" + And I fill in "Fandoms" with "Up with Testing, Coding" + And I press "Save changes" + Then I should see "Tag was updated" + + When I edit the tag "Testing McTestySkirt" + And I fill in "Fandoms" with "Up with Testing, Coding" + And I press "Save changes" + Then I should see "Tag was updated" + + When I edit the tag "Testypants/Testyskirt" + And I fill in "Synonym of" with "Testing McTestypants/Testing McTestySkirt" + And I fill in "Characters" with "Testing McTestypants, Testing McTestySkirt" + And I press "Save changes" + Then I should see "Tag was updated" + And I should see "Testing McTestypants" within "div#parent_Character_associations_to_remove_checkboxes" + And I should see "Testing McTestySkirt" within "div#parent_Character_associations_to_remove_checkboxes" + And I should see "Up with Testing" within "div#parent_Fandom_associations_to_remove_checkboxes" + And I should see "Coding" within "div#parent_Fandom_associations_to_remove_checkboxes" + When I follow "Testing McTestypants/Testing McTestySkirt" + Then I should see "Testing McTestypants" within "div#parent_Character_associations_to_remove_checkboxes" + And I should see "Testing McTestySkirt" within "div#parent_Character_associations_to_remove_checkboxes" + And I should see "Up with Testing" within "div#parent_Fandom_associations_to_remove_checkboxes" + And I should see "Coding" within "div#parent_Fandom_associations_to_remove_checkboxes" + And I should see "Testypants/Testyskirt" + And the "Canonical" checkbox should be checked and disabled + + When I am logged in as a "tag_wrangling" admin + And I edit the tag "Testing McTestypants/Testing McTestySkirt" + And I fill in "Synonym of" with "Dame Tester/Sir Tester" + And I press "Save changes" + Then I should see "Tag was updated" diff --git a/features/tags_and_wrangling/tag_wrangling_special.feature b/features/tags_and_wrangling/tag_wrangling_special.feature new file mode 100644 index 0000000..99ce3e1 --- /dev/null +++ b/features/tags_and_wrangling/tag_wrangling_special.feature @@ -0,0 +1,182 @@ +@tags @tag_wrangling +Feature: Tag Wrangling - special cases + + Scenario: Create a new tag that differs from an existing tag by accents or other markers + Rename a tag to change accents + + Given the following activated tag wrangler exists + | login | + | wranglerette | + And I am logged in as "wranglerette" + And a fandom exists with name: "Amelie", canonical: false + And a character exists with name: "Romania", canonical: true + When I edit the tag "Amelie" + And I fill in "Synonym of" with "Amélie" + And I press "Save changes" + Then I should see "Amélie is considered the same as Amelie by the database" + And I should not see "Tag was successfully updated." + When I fill in "Name" with "Amélie" + And I press "Save changes" + Then I should see "Tag was updated" + And I should see "Amélie" + And I should not see "Amelie" + When I follow "New Tag" + And I fill in "Name" with "România" + And I check "Canonical" + And I choose "Additional Tag" + And I press "Create Tag" + Then I should see "Tag was successfully created." + But I should see "România - Freeform" + + Scenario: Create a new tag that differs by more than just accents - user cannot change name + + Given the following activated tag wrangler exists + | login | + | wranglerette | + And I am logged in as "wranglerette" + And a fandom exists with name: "Amelie", canonical: false + When I edit the tag "Amelie" + When I fill in "Name" with "Amelia" + And I press "Save changes" + Then I should see "Name can only be changed by an admin." + + Scenario: Change capitalisation of a tag + + Given the following activated tag wrangler exists + | login | + | wranglerette | + And I am logged in as "wranglerette" + And a fandom exists with name: "amelie", canonical: false + When I edit the tag "amelie" + And I fill in "Synonym of" with "Amelie" + And I press "Save changes" + Then I should see "Amelie is considered the same as amelie by the database" + And I should not see "Tag was successfully updated." + When I fill in "Name" with "Amelie" + And I press "Save changes" + Then I should see "Tag was updated" + + Scenario: Works should be updated when capitalisation is changed + See AO3-4230 for a bug with the caching of this + + Given the following activated tag wrangler exists + | login | + | wranglerette | + And a fandom exists with name: "amelie", canonical: false + And I am logged in as "author" + And I post the work "wrong" with fandom "amelie" + When I am logged in as "wranglerette" + And I edit the tag "amelie" + And I fill in "Name" with "Amelie" + And I press "Save changes" + Then I should see "Tag was updated" + When I view the work "wrong" + Then I should see "Amelie" + And I should not see "amelie" + When I am on the works page + Then I should see "Amelie" + And I should not see "amelie" + + Scenario: Works should be updated when accents are changed + See AO3-4230 for a bug with the caching of this + + Given the following activated tag wrangler exists + | login | + | wranglerette | + And a fandom exists with name: "Amelie", canonical: false + And I am logged in as "author" + And I post the work "wrong" with fandom "Amelie" + When I am logged in as "wranglerette" + And I edit the tag "Amelie" + And I fill in "Name" with "Amélie" + And I press "Save changes" + Then I should see "Tag was updated" + When I view the work "wrong" + Then I should see "Amélie" + And I should not see "Amelie" + When I am on the works page + Then I should see "Amélie" + And I should not see "Amelie" + + Scenario: Tags with non-standard characters in them - question mark and period + + Given basic tags + And the following activated tag wrangler exists + | login | + | workauthor | + And a character exists with name: "Evan ?", canonical: true + And a character exists with name: "James T. Kirk", canonical: true + When I am logged in as "workauthor" + When I post the work "Epic sci-fi" + And I follow "Edit" + And I fill in "Characters" with "Evan ?, James T. Kirk" + And I press "Preview" + And I press "Update" + Then I should see "Work was successfully updated" + And all indexing jobs have been run + When I view the tag "Evan ?" + And I follow "filter works" + Then I should see "1 Work in Evan ?" + When I view the tag "James T. Kirk" + And I follow "filter works" + Then I should see "1 Work in James T. Kirk" + + Scenario: Adding a noncanonical tag with "a.k.a.", and viewing works for that tag. + + Given basic tags + And I am logged in as a random user + + When I post the work "Escape Attempt" with fandom "a.k.a. Jessica Jones" + And I follow "a.k.a. Jessica Jones" + + Then I should see "This tag belongs to the Fandom Category" + And I should see "a.k.a. Jessica Jones" within "h2.heading" + And I should see "Escape Attempt" + + Scenario: Wranglers can edit a tag with "a.k.a." in the name. + + Given a noncanonical fandom "a.k.a. Jessica Jones" + And a canonical fandom "Jessica Jones (TV)" + + When I am logged in as a tag wrangler + And I edit the tag "a.k.a. Jessica Jones" + Then I should see "Edit a.k.a. Jessica Jones Tag" + + When I fill in "Synonym of" with "Jessica Jones (TV)" + And I press "Save changes" + Then I should see "Tag was updated" + + Scenario Outline: Tag with a, d, h, q, or s between special characters. + + Given a canonical fandom "adhqs" + + When I am logged in as a tag wrangler + And I view the tag "adhqs" + Then I should see "This tag belongs to the Fandom Category" + And I should see "Edit" + + When I follow "Edit" + Then I should see "Edit adhqs Tag" + + Examples: + | char | + | / | + | # | + | . | + | & | + | ? | + + Scenario: Error messages show correct links even if tags contain special characters + + Given the following activated tag wrangler exists + | login | password | + | Enigel | wrangulate! | + And a character exists with name: "Evelyn \"Evie\" Carnahan", canonical: false + And a character exists with name: "Evelyn \"Evy\" Carnahan", canonical: false + And I am logged in as "Enigel" with password "wrangulate!" + When I edit the tag 'Evelyn "Evy" Carnahan' + And I fill in "Synonym of" with 'Evelyn "Evie" Carnahan' + And I press "Save changes" + Then I should see "is not a canonical tag. Please make it canonical before adding synonyms to it." + When I follow 'Evelyn "Evie" Carnahan' + Then I should be on the 'Evelyn "Evie" Carnahan' tag edit page diff --git a/features/tags_and_wrangling/tag_wrangling_unsorted.feature b/features/tags_and_wrangling/tag_wrangling_unsorted.feature new file mode 100644 index 0000000..b95e004 --- /dev/null +++ b/features/tags_and_wrangling/tag_wrangling_unsorted.feature @@ -0,0 +1,106 @@ +@tags @users @tag_wrangling +Feature: Tag Wrangling - Unsorted Tags + + Scenario: Editing an unsorted tag should not allow making it unwrangleable + Given the following activated tag wrangler exists + | login | + | Enigel | + And basic tags + And I am logged in as "Enigel" + And I follow "Tag Wrangling" + And a unsorted_tag exists with name: "author regrets nothing" + # editing unsorted tag + When I edit the tag "author regrets nothing" + Then the "tag_unwrangleable" checkbox should be disabled + + Scenario: Sorting tags should keep you on the same page + Given the following activated tag wrangler exists + | login | + | dizmo | + And a fandom exists with name: "No Fandom", canonical: true + And the unsorted tags setup + When I am logged in as "dizmo" + And I go to the unsorted_tags page + And I follow "2" + And I press "Update" + Then I should see "2" within ".pagination .current" + + Scenario: Updating multiple tags works. + Given I am logged in as a tag wrangler + And the following typed tags exists + | name | type | + | Cowboy Bebop | Unsorted_tag | + | Serial experiments lain | Unsorted_tag | + | Spike Spiegel | Unsorted_tag | + | Annalise Keating & Bonnie Winterbottom | Unsorted_tag | + | i love good omens | Unsorted_tag | + When I go to the unsorted_tags page + And I select "Fandom" for the unsorted tag "Cowboy Bebop" + And I select "Fandom" for the unsorted tag "Serial experiments lain" + And I select "Character" for the unsorted tag "Spike Spiegel" + And I select "Relationship" for the unsorted tag "Annalise Keating & Bonnie Winterbottom" + And I select "Freeform" for the unsorted tag "i love good omens" + And I press "Update" + Then I should see "Tags were successfully sorted" + And the "Cowboy Bebop" tag should be a "Fandom" tag + And the "Serial experiments lain" tag should be a "Fandom" tag + And the "Spike Spiegel" tag should be a "Character" tag + And the "Annalise Keating & Bonnie Winterbottom" tag should be a "Relationship" tag + And the "i love good omens" tag should be a "Freeform" tag + + Scenario: Can return a tag to Unsorted after a different category has been set + Given I am logged in as a tag wrangler + And a unsorted_tag exists with name: "Unsorted Tag" + When I edit the tag "Unsorted Tag" + And I select "Fandom" from "tag_type" + And I press "Save changes" + Then I should see "Tag was updated." + When I select "UnsortedTag" from "tag_type" + And I press "Save changes" + Then I should see "Tag was updated." + + Scenario Outline: Editing unsorted tags as a fully authorized admin + Given an unsorted_tag exists with name: "Admin unsorted tag" + And I am logged in as a "" admin + When I go to the unsorted_tags page + And I select "Freeform" for the unsorted tag "Admin unsorted tag" + And I press "Update" + Then I should see "Tags were successfully sorted" + And the "Admin unsorted tag" tag should be a "Freeform" tag + + Examples: + | role | + | superadmin | + | tag_wrangling | + + Scenario Outline: Editing unsorted tags as a view-only admin + Given an unsorted_tag exists with name: "Admin unsorted tag" + And I am logged in as a "" admin + When I go to the unsorted_tags page + And I select "Freeform" for the unsorted tag "Admin unsorted tag" + And I press "Update" + Then I should see "Sorry, only an authorized admin can access the page you were trying to reach." + And the "Admin unsorted tag" tag should be an unsorted tag + + Examples: + | role | + | policy_and_abuse | + + Scenario Outline: Editing unsorted tags as an unauthorized admin + Given an unsorted_tag exists with name: "Admin unsorted tag" + And I am logged in as a "" admin + When I go to the unsorted_tags page + Then I should see "Sorry, only an authorized admin can access the page you were trying to reach." + + Examples: + | role | + | board | + | board_assistants_team | + | communications | + | development_and_membership | + | docs | + | elections | + | legal | + | translation | + | support | + | open_doors | diff --git a/features/tags_and_wrangling/wrangling_guidelines.feature b/features/tags_and_wrangling/wrangling_guidelines.feature new file mode 100644 index 0000000..837b070 --- /dev/null +++ b/features/tags_and_wrangling/wrangling_guidelines.feature @@ -0,0 +1,52 @@ +@admin @wrangling_guidelines +Feature: Wrangling Guidelines + In order to help people understand the wrangling system + As an admin + I want to be able to maintain wrangling guidelines + + Scenario: Post a Wrangling Guideline + + Given I am logged in as a "tag_wrangling" admin + And I am on the wrangling guidelines page + And I follow "New Wrangling Guideline" + And I fill in "Guideline text" with "This series of documents (Wrangling Guidelines) are intended to help tag wranglers remain consistent as they go about the business of wrangling tags by providing a set of formatting guidelines." + And I fill in "Title" with "Intro and General Concepts" + When I press "Post" + Then I should see "Wrangling Guideline was successfully created" + When I go to the wrangling_guidelines page + And I follow "Intro and General Concepts" + Then I should see "This series of documents (Wrangling Guidelines) are intended to help tag wranglers remain consistent as they go about the business of wrangling tags by providing a set of formatting guidelines." within ".userstuff" + + Scenario: Edit Wrangling Guideline + + Given I have posted a Wrangling Guideline + And I am on the wrangling guidelines page + When I follow "Edit" + And I fill in "Guideline text" with "These guidelines are an in-progress affair, subject to change." + When I press "Post" + Then I should see "Wrangling Guideline was successfully updated" + And I should see "These guidelines are an in-progress affair, subject to change." + + Scenario: Reorder Wrangling Guidelines + + Given I am logged in as a "tag_wrangling" admin + And 3 Wrangling Guidelines exist + When I go to the Wrangling Guidelines reorder page + And I fill in "wrangling_guidelines_1" with "3" + And I fill in "wrangling_guidelines_2" with "1" + And I fill in "wrangling_guidelines_3" with "2" + When I press "Update Positions" + Then I should see "Wrangling Guidelines order was successfully updated" + When I follow "Reorder Wrangling Guidelines" + Then I should see "1. The 2 Wrangling Guideline" + And I should see "2. The 3 Wrangling Guideline" + And I should see "3. The 1 Wrangling Guideline" + + Scenario: Delete Wrangling Guideline + + Given I am logged in as a "tag_wrangling" admin + And I have posted a Wrangling Guideline titled "Relationship Tags" + When I go to the Wrangling Guidelines page + And I follow "Delete" + Then I should see "Wrangling Guideline was successfully deleted" + And I should not see "Relationship Tags" diff --git a/features/users/authenticate_users.feature b/features/users/authenticate_users.feature new file mode 100644 index 0000000..13f9537 --- /dev/null +++ b/features/users/authenticate_users.feature @@ -0,0 +1,337 @@ +@users +@admin +Feature: User Authentication + + Scenario: Forgot password + Given I have no users + And the following activated user exists + | login | password | + | sam | secret | + And all emails have been delivered + When I am on the home page + And I fill in "Username or email:" with "sam" + And I fill in "Password:" with "test" + And I press "Log In" + Then I should see "The password or username you entered doesn't match our records" + And I should see "Forgot your password or username?" + When I follow "Reset password" + Then I should see "Please tell us the username or email address you used when you signed up for your Archive account" + When I fill in "Email address or username" with "sam" + And I press "Reset Password" + Then I should see "Check your email for instructions on how to reset your password." + And 1 email should be delivered + And the email should contain "sam" + And the email should contain "Someone has requested a password reset for your account" + And the email should not contain "translation missing" + + # existing password should still work + When I am on the homepage + And I fill in "Username or email:" with "sam" + And I fill in "Password:" with "secret" + And I press "Log In" + Then I should see "Hi, sam" + + # link from the email should not work when logged in + When I follow "Change my password." in the email + Then I should see "You are already signed in." + And I should not see "Change My Password" + + # link from the email should work + When I log out + And I follow "Change my password." in the email + Then I should see "Change My Password" + + # entering mismatched passwords should produce an error message + When I fill in "New password" with "secret" + And I fill in "Confirm new password" with "newpass" + And I press "Change Password" + Then I should see "We couldn't save this user because:" + And I should see "Password confirmation doesn't match new password." + + # and I should be able to change the password + When I fill in "New password" with "new + When I am on the home page + And I follow "Forgot password?" + And I fill in "Email address or username" with "target" + And I press "Reset Password" + Then I should be on the home page + And I should see "Password resets are disabled for that user." + And 0 emails should be delivered + When I follow "Forgot password?" + And I fill in "Email address or username" with "user@example.com" + And I press "Reset Password" + Then I should be on the home page + And I should see "Password resets are disabled for that user." + And 0 emails should be delivered + + Examples: + | role | + | is a protected user | + | has the no resets role | + + Scenario: Admin cannot log in or reset password as ordinary user. + Given the following admin exists + | login | password | + | admin | adminpassword | + When I go to the login page + And I fill in "Username or email" with "admin" + And I fill in "Password" with "adminpassword" + And I press "Log In" + Then I should not see "Successfully logged in" + And I should see "The password or username you entered doesn't match our records." + When I am logged in as an admin + And I go to the new user password page + Then I should be on the homepage + And I should see "Please log out of your admin account first!" + When I go to the edit user password page + Then I should be on the homepage + And I should see "Please log out of your admin account first!" diff --git a/features/users/blocking.feature b/features/users/blocking.feature new file mode 100644 index 0000000..76391be --- /dev/null +++ b/features/users/blocking.feature @@ -0,0 +1,137 @@ +Feature: Blocking + Scenario: Users can block from my blocked users page + Given the user "pest" exists and is activated + And I am logged in as "blocker" + When I go to the blocked users page for "blocker" + And I fill in "blocked_id" with "pest" + And I press "Block" + And I press "Yes, Block User" + Then I should see "You have blocked the user pest." + And the user "blocker" should have a block for "pest" + + Scenario Outline: Users can block from various user-related pages + Given the user "pest" exists and is activated + And I am logged in as "blocker" + When I go to + And I follow "Block" + And I press "Yes, Block User" + Then I should see "You have blocked the user pest." + And the user "blocker" should have a block for "pest" + And the blurb should not say when "blocker" blocked "pest" + + Examples: + | page | + | pest's user page | + | pest's profile page | + | the dashboard page for user "pest" with pseud "pest" | + + Scenario: Users can block from the comments + Given the work "Aftermath" + And a comment "Ugh." by "pest" on the work "Aftermath" + And I am logged in as "blocker" + When I view the work "Aftermath" with comments + And I follow "Block" + And I press "Yes, Block User" + Then I should see "You have blocked the user pest." + And the user "blocker" should have a block for "pest" + + Scenario: Users cannot block official users + Given the user "pest" exists and has the role "official" + And I am logged in as "blocker" + When I go to pest's user page + And I follow "Block" + Then I should see "Sorry, you can't block an official user." + And I should not see a "Yes, Block User" button + + Scenario: Users cannot block themselves + Given the user "pest" exists and is activated + And I am logged in as "pest" + When I go to pest's user page + Then I should not see a link "Block" + When I go to the blocked users page for "pest" + And I fill in "blocked_id" with "pest" + And I press "Block" + Then I should see "Sorry, you can't block yourself." + And I should not see a "Yes, Block User" button + + Scenario: Users can unblock from the blocked users page + Given the user "unblocker" has blocked the user "improving" + And I am logged in as "unblocker" + When I go to the blocked users page for "unblocker" + And I follow "Unblock" + And I press "Yes, Unblock User" + Then I should see "You have unblocked the user improving." + And the user "unblocker" should not have a block for "improving" + + Scenario Outline: Users can unblock from various user-related pages + Given the user "unblocker" has blocked the user "improving" + And I am logged in as "unblocker" + When I go to + And I follow "Unblock" + And I press "Yes, Unblock User" + Then I should see "You have unblocked the user improving." + And the user "unblocker" should not have a block for "improving" + + Examples: + | page | + | improving's user page | + | improving's profile page | + | the dashboard page for user "improving" with pseud "improving" | + + Scenario: Users can unblock from the comments + Given the user "unblocker" has blocked the user "improving" + And the work "Aftermath" + And a comment "Wonderful!" by "improving" on the work "Aftermath" + And I am logged in as "unblocker" + When I view the work "Aftermath" with comments + And I follow "Unblock" + And I press "Yes, Unblock User" + Then I should see "You have unblocked the user improving." + And the user "unblocker" should not have a block for "improving" + + Scenario: The blocked users page is paginated + Given there are 2 blocked users per page + And the user "blocker" has blocked the user "pest1" + And the user "blocker" has blocked the user "pest2" + And the user "blocker" has blocked the user "pest3" + And the user "blocker" has blocked the user "pest4" + When I am logged in as "blocker" + And I go to the blocked users page for "blocker" + Then I should see "pest4" within "ul.pseud li:nth-child(1)" + And I should see "pest3" within "ul.pseud li:nth-child(2)" + And I should not see "pest2" + And I should not see "pest1" + When I follow "2" within ".pagination" + Then I should see "pest2" within "ul.pseud li:nth-child(1)" + And I should see "pest1" within "ul.pseud li:nth-child(2)" + + Scenario Outline: Authorized admins can see the blocked users page + Given the user "blocker" has blocked the user "pest" + When I am logged in as a "" admin + And I go to the blocked users page for "blocker" + Then I should see "pest" + And the blurb should say when "blocker" blocked "pest" + And I should see a link "Unblock" + When I follow "Unblock" + Then I should see "Sorry, you don't have permission to access the page you were trying to reach." + And the user "blocker" should have a block for "pest" + + Examples: + | role | + | superadmin | + | policy_and_abuse | + | support | + + Scenario: Users are told about blocking effects on gift-giving + Given the user "pest" exists and is activated + And I am logged in as "blocker" + When I go to the blocked users page for "blocker" + Then I should see "giving you gift works" + Given the user "unblocker" has blocked the user "improving" + And I am logged in as "unblocker" + When I go to the blocked users page for "unblocker" + Then I should see "improving" + And I should see "giving you gift works" + When I follow "Unblock" + Then I should see a "Yes, Unblock User" button + And I should see "giving you gift works" diff --git a/features/users/muting.feature b/features/users/muting.feature new file mode 100644 index 0000000..97b011f --- /dev/null +++ b/features/users/muting.feature @@ -0,0 +1,154 @@ +Feature: Muting + Scenario: Users can mute from my muted users page + Given the user "pest" exists and is activated + And I am logged in as "muter" + When I go to the muted users page for "muter" + And I fill in "muted_id" with "pest" + And I press "Mute" + And I press "Yes, Mute User" + Then I should see "You have muted the user pest." + And the user "muter" should have a mute for "pest" + And the blurb should not say when "muter" muted "pest" + + Scenario Outline: Users can mute from various user-related pages + Given the user "pest" exists and is activated + And I am logged in as "muter" + When I go to + And I follow "Mute" + And I press "Yes, Mute User" + Then I should see "You have muted the user pest." + And the user "muter" should have a mute for "pest" + + Examples: + | page | + | pest's user page | + | pest's profile page | + | the dashboard page for user "pest" with pseud "pest" | + + Scenario: Users cannot mute official users + Given the user "pest" exists and has the role "official" + And I am logged in as "muter" + When I go to pest's user page + And I follow "Mute" + Then I should see "Sorry, you can't mute an official user." + And I should not see a "Yes, Mute User" button + + Scenario: Users cannot mute themselves + Given the user "pest" exists and is activated + And I am logged in as "pest" + When I go to pest's user page + Then I should not see a link "Mute" + When I go to the muted users page for "pest" + And I fill in "muted_id" with "pest" + And I press "Mute" + Then I should see "Sorry, you can't mute yourself." + And I should not see a "Yes, Mute User" button + + Scenario: Users can unmute from the muted users page + Given the user "unmuter" has muted the user "improving" + And I am logged in as "unmuter" + When I go to the muted users page for "unmuter" + And I follow "Unmute" + And I press "Yes, Unmute User" + Then I should see "You have unmuted the user improving." + And the user "unmuter" should not have a mute for "improving" + + Scenario Outline: Users can unmute from various user-related pages + Given the user "unmuter" has muted the user "improving" + And I am logged in as "unmuter" + When I go to + And I follow "Unmute" + And I press "Yes, Unmute User" + Then I should see "You have unmuted the user improving." + And the user "unmuter" should not have a mute for "improving" + + Examples: + | page | + | improving's user page | + | improving's profile page | + | the dashboard page for user "improving" with pseud "improving" | + + Scenario: The muted users page is paginated + Given there are 2 muted users per page + And the user "muter" has muted the user "pest1" + And the user "muter" has muted the user "pest2" + And the user "muter" has muted the user "pest3" + And the user "muter" has muted the user "pest4" + When I am logged in as "muter" + And I go to the muted users page for "muter" + Then I should see "pest4" within "ul.pseud li:nth-child(1)" + And I should see "pest3" within "ul.pseud li:nth-child(2)" + And I should not see "pest2" + And I should not see "pest1" + When I follow "2" within ".pagination" + Then I should see "pest2" within "ul.pseud li:nth-child(1)" + And I should see "pest1" within "ul.pseud li:nth-child(2)" + + Scenario Outline: Authorized admins can see the muted users page + Given the user "muter" has muted the user "pest" + When I am logged in as a "" admin + And I go to the muted users page for "muter" + Then I should see "pest" + And the blurb should say when "muter" muted "pest" + And I should see a link "Unmute" + When I follow "Unmute" + Then I should see "Sorry, you don't have permission to access the page you were trying to reach." + And the user "muter" should have a mute for "pest" + + Examples: + | role | + | superadmin | + | policy_and_abuse | + | support | + + @javascript + Scenario: Users cannot see works by a muted user + Given the user "muter" has muted the user "pest" + And the work "Annoying Work" by "pest" + When I am logged in as "muter" + And I go to pest's works page + Then I should not see "Annoying Work" + When I am logged out + And I go to pest's works page + Then I should see "Annoying Work" + + @javascript + Scenario: Users cannot see series by a muted user + Given the user "muter" has muted the user "pest" + And the work "Annoying Work" by "pest" + And I am logged in as "pest" + And I edit the work "Annoying Work" + And I add the series "Annoying Series" + And I press "Post" + When I am logged in as "muter" + And I go to pest's series page + Then I should not see "Annoying Series" + When I am logged out + And I go to pest's series page + Then I should see "Annoying Series" + + @javascript + Scenario: Users cannot see bookmarks by a muted user + Given the user "muter" has muted the user "pest" + And the work "Good Work" by "muter" + And I am logged in as "pest" + And I have a bookmark for "Good Work" + When I am logged in as "muter" + And I go to pest's bookmarks page + Then I should not see "Good Work" + When I am logged out + And I go to pest's bookmarks page + Then I should see "Good Work" + + @javascript + Scenario: Users cannot see comments by a muted user + Given the user "muter" has muted the user "pest" + And the work "Good Work" by "muter" + And I am logged in as "pest" + And I post the comment "fxxk you" on the work "Good Work" + When I am logged in as "muter" + And I view the work "Good Work" + Then I should not see "fxxk you" + When I am logged out + And I view the work "Good Work" + Then I should see "Good Work" diff --git a/features/users/password_compatibility.feature b/features/users/password_compatibility.feature new file mode 100644 index 0000000..4c121bb --- /dev/null +++ b/features/users/password_compatibility.feature @@ -0,0 +1,39 @@ +@users +Feature: + In order to ensure all users can login + No matter what strategy was originally used to encrypt their password + As a registered user + I should be able to login + + Scenario: Using Devise's default encryption strategy + Given the following activated users exist + | login | password | + | user1 | password | + When I am on the homepage + And I fill in "Username or email:" with "user1" + And I fill in "Password:" with "password" + And I press "Log In" + Then I should see "Successfully logged in." + And I should see "Hi, user1!" + + Scenario: Using Authlogic's BCrypt encryption + Given the following users exist with BCrypt encrypted passwords + | login | password | + | user1 | password | + When I am on the homepage + And I fill in "Username or email:" with "user1" + And I fill in "Password:" with "password" + And I press "Log In" + Then I should see "Successfully logged in." + And I should see "Hi, user1!" + + Scenario: Using Authlogic's SHA-512 encryption + Given the following users exist with SHA-512 encrypted passwords + | login | password | + | user1 | password | + When I am on the homepage + And I fill in "Username or email:" with "user1" + And I fill in "Password:" with "password" + And I press "Log In" + Then I should see "Successfully logged in." + And I should see "Hi, user1!" diff --git a/features/users/suspensions.feature b/features/users/suspensions.feature new file mode 100644 index 0000000..2ef5b37 --- /dev/null +++ b/features/users/suspensions.feature @@ -0,0 +1,17 @@ +Feature: Suspensions + + Scenario: Users suspended on 2024-01-11 before the unban threshold can see they will be unbanned on 2024-02-10 + Given the user "mrparis" exists and is activated + And it is currently 2024-01-11 01:00 AM + And the user "mrparis" is suspended + And I am logged in as "mrparis" + And I go to the new work page + Then I should see "suspended until Sat 10 Feb 2024" + + Scenario: Users suspended on 2024-01-11 after the unban threshold can see they will be unbanned on 2024-02-11 + Given the user "mrparis" exists and is activated + And it is currently 2024-01-11 08:00 PM + And the user "mrparis" is suspended + And I am logged in as "mrparis" + And I go to the new work page + Then I should see "suspended until Sun 11 Feb 2024" diff --git a/features/users/user_create.feature b/features/users/user_create.feature new file mode 100644 index 0000000..767ead1 --- /dev/null +++ b/features/users/user_create.feature @@ -0,0 +1,86 @@ +Feature: Sign Up for a new account + In order to add works to the Archive. + As an unregistered user. + I want to be able to create a new account. + + Background: + Given account creation is enabled + And account creation requires an invitation + And I am a visitor + And I use an invitation to sign up + + Scenario Outline: The user should see validation errors when signing up with invalid data. + When I fill in the sign up form with valid data + And I fill in "" with "" + And I press "Create Account" + Then I should see "" + And I should not see "Almost Done!" + Examples: + | field | value | error | + | user_registration_login | xx | Username is too short (minimum is 3 characters)| + | user_registration_login | 87151d8ae964d55515cb986d40394f79ca5c8329c07a8e59f2f783cbfbe401f69a780f27277275b7b2 | Username is too long (maximum is 40 characters) | + | user_registration_password | pass | Password is too short (minimum is 6 characters) | + | user_registration_password | 87151d8ae964d55515cb986d40394f79ca5c8329c07a8e59f2f783cbfbe401f69a780f27277275b7b2 | Password is too long (maximum is 40 characters) | + | user_registration_password_confirmation | password2 | Password confirmation doesn't match | + | user_registration_email | | Email should look like an email address | + | user_registration_email | fake@fake@fake | Email should look like an email address | + + Scenario Outline: The user should see validation errors when signing up without filling in required fields. + When I press "Create Account" + Then I should see "" + And I should not see "Almost Done!" + Examples: + | field | error | + | user_registration_age_over_13 | Sorry, you have to be over 13! | + | user_registration_terms_of_service | Sorry, you need to accept the Terms of Service in order to sign up. | + | user_registration_data_processing | Sorry, you need to consent to the processing of your personal data in order to sign up. | + + Scenario: The user should be able to sign up after fixing form errors. + When I fill in the sign up form with valid data + And I fill in "Valid email" with "lyingrobot@example.com" + And I uncheck "Yes, I have read the Terms of Service, including the Content Policy and Privacy Policy, and agree to them." + And I press "Create Account" + Then I should see "Sorry, you need to accept the Terms of Service in order to sign up." + And I should not see "Sorry, you have to be over 13!" + # Email should be what the user filled in, not the invitee email on the invitation + And I should see "lyingrobot@example.com" in the "Valid email" input + + When I check "Yes, I have read the Terms of Service, including the Content Policy and Privacy Policy, and agree to them." + And I fill in "Password" with "password" + And I fill in "Confirm password" with "password" + And all emails have been delivered + And I press "Create Account" + Then I should see "Almost Done!" + And 1 email should be delivered to "lyingrobot@example.com" + And I should get a new user activation email + And a new user account should exist + + Scenario: The user should not be able to sign up with a login that is already in use + Given the following users exist + | login | password | + | user1 | password | + When I fill in the sign up form with valid data + And I fill in "user_registration_login" with "user1" + And I press "Create Account" + Then I should see "Username has already been taken" + And I should not see "Almost Done!" + + Scenario: The user should not be able to sign up with a login that is already in use, no matter the case + Given the following users exist + | login | password | + | user1 | password | + When I fill in the sign up form with valid data + And I fill in "user_registration_login" with "USER1" + And I press "Create Account" + Then I should see "Username has already been taken" + And I should not see "Almost Done!" + + Scenario: The user should be able to create a new account with a valid email and password + When I fill in the sign up form with valid data + Then I should see the page title "Create Account" + When all emails have been delivered + And I press "Create Account" + Then I should see the page title "Account Created" + And I should see "Almost Done!" + And I should get a new user activation email + And a new user account should exist diff --git a/features/users/user_dashboard.feature b/features/users/user_dashboard.feature new file mode 100644 index 0000000..57e572f --- /dev/null +++ b/features/users/user_dashboard.feature @@ -0,0 +1,275 @@ +@users +Feature: User 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: If I have no creations my dashboard is empty + Given I am logged in as "first_user" + And I follow "My Dashboard" + Then I should see "You don't have anything posted under this name yet" + Given I am logged in as "second_user" + And I go to first_user's user page + Then I should see "There are no works or bookmarks under this name yet" + When I am logged in as "first_user" + And I post the work "First Work" + And I follow "My Dashboard" + Then I should not see "You don't have anything posted under this name yet" + And I should see "First Work" + When I am logged in as "second_user" + And I go to first_user's user page + Then I should not see "There are no works or bookmarks under this name yet" + And I should see "First Work" + + Scenario: Canonical synonym fandoms should be used in the fandoms listing but actual tags should be displayed on the work blurb + Given I am logged in as a tag wrangler + # set up metatag and synonym + 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" + And I fill in "MetaTags" with "Stargate Franchise" + And I press "Save changes" + And I edit the tag "Stargatte SG-oops" + And I fill in "Synonym" with "Stargate SG-1" + And I press "Save changes" + # view user dashboard - when posting a work with the canonical, metatag and synonym should not be seen + When I am logged in as "first_user" + And I post the work "Revenge of the Sith" with fandom "Stargate SG-1" + And I follow "My Dashboard" + Then I should see "Stargate SG-1" within "#user-fandoms" + 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 "My Dashboard" + Then I should see "Stargate 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" within "#user-works" + + Scenario: The user dashboard should list up to five of the user's works and link to more + Given dashboard counts expire after 10 seconds + And I am logged in as "meatloaf" + And I post the works "Oldest Work, Work 2, Work 3, Work 4, Work 5" + When I go to meatloaf's user page + Then I should see "Recent works" + And I should see "Oldest Work" + And I should see "Work 5" + And I should see "Works (5)" within "#dashboard" + And I should not see "Works (" within "#user-works" + When I post the work "Newest Work" + And all indexing jobs have been run + And I go to meatloaf's user page + Then I should see "Newest Work" + And I should not see "Oldest Work" + And I should see "Works (5)" within "#dashboard" + And I should see "Works (5)" within "#user-works" + When I wait 11 seconds + And I reload the page + Then I should see "Works (6)" within "#dashboard" + When I follow "Works (6)" within "#user-works" + Then I should see "6 Works by meatloaf" + And I should see "Oldest Work" + And I should see "Newest Work" + + Scenario: The user dashboard should not list anonymous works by the user + Given I have the anonymous collection "Anon Treasury" + And I am logged in as "meatloaf" + And I post the work "Anon Work" to the collection "Anon Treasury" + When I go to meatloaf's user page + Then I should not see "Recent Works" + When I post the work "New Work" + And I go to meatloaf's user page + Then I should see "Recent works" + And I should not see "Anon Work" within "#user-works" + + Scenario: The user dashboard should list up to five of the user's series and link to more + Given I am logged in as "meatloaf" + And I post the work "My Work" + When I add the work "My Work" to the series "Oldest Series" + # Make sure all other series are more recent + And it is currently 1 second from now + And I add the work "My Work" to the series "Series 2" + And I add the work "My Work" to the series "Series 3" + And I add the work "My Work" to the series "Series 4" + And I add the work "My Work" to the series "Series 5" + When I go to meatloaf's user page + Then I should see "Recent series" + And I should see "Oldest Series" within "#user-series" + When I add the work "My Work" to the series "Newest Series" + And I go to meatloaf's user page + Then I should see "Newest Series" within "#user-series" + And I should not see "Oldest Series" within "#user-series" + When I follow "Series (6)" within "#user-series" + Then I should see "6 Series by meatloaf" + And I should see "Oldest Series" + And I should see "Newest Series" + + Scenario: The user dashboard should not list anonymous series by the user + Given I have the anonymous collection "Anon Treasury" + And I am logged in as "meatloaf" + And I post the work "Anon Work" to the collection "Anon Treasury" + And I add the work "Anon Work" to series "Anon Series" + When I go to meatloaf's user page + Then I should not see "Recent Series" + When I add the work "New Work" to series "Cool Series" + And I go to meatloaf's user page + Then I should see "Recent series" + And I should not see "Anon Series" within "#user-series" + + Scenario: The user dashboard should list up to five of the user's bookmarks and link to more + Given dashboard counts expire after 10 seconds + And I am logged in as "fruitpie" + And I post the works "Work One, Work Two, Work Three, Work Four, Work Five, Work Six" + When I am logged in as "meatloaf" + And I bookmark the works "Work One, Work Two, Work Three, Work Four, Work Five" + When I go to meatloaf's user page + Then I should see "Recent bookmarks" + And I should see "Work One" within "#user-bookmarks" + When I bookmark the work "Work Six" + And I go to meatloaf's user page + Then I should see "Work Six" within "#user-bookmarks" + And I should not see "Work One" within "#user-bookmarks" + And I should see "Bookmarks (5)" within "#dashboard" + When I wait 11 seconds + And I reload the page + Then I should see "Bookmarks (6)" within "#dashboard" + When I follow "Bookmarks (6)" within "#user-bookmarks" + Then I should see "6 Bookmarks by meatloaf" + And I should see "Work One" + And I should see "Work Six" + + Scenario Outline: The dashboard/works/bookmarks pages for a non-default pseud should display both pseud and username + Given "meatloaf" has the pseud "gravy" + When I go to meatloaf's page + Then I should not see "(meatloaf)" within "" + When I go to the page for user "meatloaf" with pseud "gravy" + Then I should see "gravy (meatloaf)" within "" + Examples: + | page_name | selector | + | user | #main .primary h2 | + | works | .works-index .heading | + | bookmarks | .bookmarks-index .heading | + | series | .series-index .heading | + + Scenario: The dashboard for a specific pseud should only list the creations owned by that pseud + Given dashboard counts expire after 10 seconds + And I am logged in as "meatloaf" + And I post the works "Oldest Work, Work 2, Work 3, Work 4, Work 5" + And I add the work "Oldest Work" to series "Oldest Series" + And I bookmark the work "Oldest Work" + And "meatloaf" creates the pseud "gravy" + When I add the work "Pseud's Work 1" to series "Pseud Series A" as "gravy" + And I bookmark the work "Work 5" as "gravy" + And I go to meatloaf's user page + And I follow "gravy" within ".pseud .expandable li" + Then I should see "Works (0)" within "#dashboard" + And I should see "Bookmarks (0)" within "#dashboard" + When I wait 11 seconds + And I reload the page + Then I should see "Recent works" + And I should see "Pseud's Work 1" + And I should see "Works (1)" within "#dashboard" + And I should not see "Works (" within "#user-works" + And I should not see "Oldest Work" within "#user-works" + And I should see "Recent series" + And I should see "Pseud Series A" within "#user-series" + And I should not see "Oldest Series" + And I should see "Recent bookmarks" + And I should see "Work 5" within "#user-bookmarks" + And I should see "Bookmarks (1)" within "#dashboard" + + Scenario: You can follow a fandom link from a user's dashboard and then use the filters to sort within that fandom. + Given a media exists with name: "TV Shows", canonical: true + And a fandom exists with name: "Star Trek", canonical: true + And a fandom exists with name: "Star Trek: Discovery", canonical: true + And I am logged in as "meatloaf" + And I post the work "Excellent" with fandom "Star Trek" + And I post the work "Even more Excellent" with fandom "Star Trek" + And I post the work "Exciting" with fandom "Star Trek: Discovery" + When I go to meatloaf's user page + And I follow "Star Trek" + Then I should see "2 Works by meatloaf in Star Trek" + When I press "Sort and Filter" + Then I should see "2 Works by meatloaf in Star Trek" + + Scenario: The dashboard sidebar series count should exclude restricted series when logged out + Given I have the anonymous collection "Anon works" + And I am logged in as "Accumulator" + And "Accumulator" creates the pseud "Battery" + And "Accumulator" creates the pseud "Centrifuge" + And I post the work "Normal work" as part of a series "Mine" using the pseud "Battery" + And I post the work "Normal work 2" as part of a series "Mine" using the pseud "Battery" + And I post the work "Restricted work" as part of a series "Restricted" using the pseud "Battery" + And I lock the work "Restricted work" + And I post the work "Another restricted work" as part of a series "Restricted" using the pseud "Battery" + And I lock the work "Another restricted work" + When I go to Accumulator's user page + Then I should see "Series (2)" within "#dashboard" + When I go to the dashboard page for user "Accumulator" with pseud "Battery" + Then I should see "Series (2)" within "#dashboard" + When I go to the dashboard page for user "Accumulator" with pseud "Centrifuge" + Then I should see "Series (0)" within "#dashboard" + When I am logged out + And I go to Accumulator's user page + Then I should see "Series (1)" within "#dashboard" + When I go to the dashboard page for user "Accumulator" with pseud "Battery" + Then I should see "Series (1)" within "#dashboard" + When I go to the dashboard page for user "Accumulator" with pseud "Centrifuge" + Then I should see "Series (0)" within "#dashboard" + # Series with anon works are never counted + When I am logged in as "Accumulator" + And I post the work "Another normal work" as part of a series "Anon" using the pseud "Battery" + And I post the work "Anon work" in the collection "Anon works" as part of a series "Anon" using the pseud "Battery" + And I go to Accumulator's user page + Then I should see "Series (2)" within "#dashboard" + When I go to the dashboard page for user "Accumulator" with pseud "Battery" + Then I should see "Series (2)" within "#dashboard" + When I go to the dashboard page for user "Accumulator" with pseud "Centrifuge" + Then I should see "Series (0)" within "#dashboard" + When I am logged out + And I go to Accumulator's user page + Then I should see "Series (1)" within "#dashboard" + When I go to the dashboard page for user "Accumulator" with pseud "Battery" + Then I should see "Series (1)" within "#dashboard" + When I go to the dashboard page for user "Accumulator" with pseud "Centrifuge" + Then I should see "Series (0)" within "#dashboard" + + Scenario Outline: User and pseud dashboards, and user profiles, contain links to the user's administration page only for authorized admins + Given "a_user" has the pseud "a_pseud" + When I am + And I go to a_user's user page + Then I should "User Administration" within ".user .primary" + When I go to a_user's profile page + Then I should "User Administration" within ".user .primary" + When I go to the user page for user "a_user" with pseud "a_pseud" + Then I should "User Administration" within ".user .primary" + Examples: + | logged_in_status | action | + | logged in as a "superadmin" admin | see | + | logged in as a "communications" admin | not see | + | logged in as a random user | not see | + | logged in as "a_user" | not see | + | a visitor | not see | + + Scenario Outline: User and pseud dashboards, and user profiles, contain links to the user's invitations page only for the user and authorized admins + Given "a_user" has the pseud "a_pseud" + When I am + And I go to a_user's user page + Then I should "Invitations" within ".user .primary" + When I go to a_user's profile page + Then I should "Invitations" within ".user .primary" + When I go to the user page for user "a_user" with pseud "a_pseud" + Then I should "Invitations" within ".user .primary" + Examples: + | logged_in_status | action | + | logged in as a "superadmin" admin | see | + | logged in as a "communications" admin | not see | + | logged in as a random user | not see | + | logged in as "a_user" | see | + | a visitor | not see | diff --git a/features/users/user_delete.feature b/features/users/user_delete.feature new file mode 100644 index 0000000..e47f9a1 --- /dev/null +++ b/features/users/user_delete.feature @@ -0,0 +1,119 @@ +@users +Feature: + In order to correct mistakes or reflect my evolving personality + As a registered user + I should be able to delete my account + +Scenario: The Delete My Account link should exist on the Profile page + Given I am logged in as "downthemall" + When I go to downthemall's user page + And I follow "Profile" + Then I should see "Delete My Account" + +Scenario: If I delete a user with no works, the user should be deleted without any prompting + Given I am logged in as "downthemall" + And I have no works or comments + When I try to delete my account as downthemall + Then I should see "You have successfully deleted your account." + And a user account should not exist for "downthemall" + And I should be logged out + +Scenario: If a user chooses "Delete Completely" when removing their account, delete the works associated with that user + Given I am logged in as "otheruser" with password "secret" + And all emails have been delivered + And I post the work "To be deleted" + When I try to delete my account as otheruser + Then I should see "What do you want to do with your works?" + And a user account should exist for "otheruser" + When I choose "Delete completely" + And I press "Save" + Then I should see "You have successfully deleted your account." + And a user account should not exist for "otheruser" + And 1 email should be delivered + And I should be logged out + When I go to the works page + Then I should not see "To be deleted" + +Scenario: Allow a user to orphan their works when deleting their account + Given I have an orphan account + When I am logged in as "orphaner" with password "secret" + And all emails have been delivered + And I post the work "To be orphaned" + And I go to the works page + Then I should see "To be orphaned" + And I should see "orphaner" within "#main" + When I try to delete my account as orphaner + Then I should see "What do you want to do with your works?" + When I choose "Change my pseud to "orphan" and attach to the orphan account" + # Delay before orphaning to make sure the cache is expired + And it is currently 1 second from now + And I press "Save" + Then I should see "You have successfully deleted your account." + And 0 emails should be delivered + And I should be logged out + And a user account should not exist for "orphaner" + When I go to the works page + Then I should see "To be orphaned" + And I should see "orphan_account" + And I should not see "orphaner" + +Scenario: Delete a user with a collection + Given I have an orphan account + When I am logged in as "moderator" with password "password" + And all emails have been delivered + And I create the collection "fake" + And I go to the collections page + Then I should see "fake" + And I should see "moderator" within "#main" + When I try to delete my account as moderator + Then I should see "You have 1 collection(s) under the following pseuds: moderator." + When I choose "Change my pseud to "orphan" and attach to the orphan account" + # Delay before orphaning to make sure the cache is expired + And it is currently 1 second from now + And I press "Save" + Then I should see "You have successfully deleted your account." + And 0 emails should be delivered + And I should be logged out + And a user account should not exist for "moderator" + When I go to the collections page + Then I should see "fake" + And I should see "orphan_account" + And I should not see "moderator" + +Scenario: Delete a user who has coauthored a work + Given the following activated users exist + | login | password | + | otheruser | password | + And I am logged in as "testuser" + And I coauthored the work "Shared" as "testuser" with "otheruser" + And I wait 1 second + When I try to delete my account + Then I should see "What do you want to do with your works?" + When I choose "Remove me completely as co-creator" + And I press "Save" + Then I should see "You have successfully deleted your account" + And a user account should not exist for "testuser" + And I should be logged out + When I go to the works page + Then I should see "otheruser" + And I should not see "testuser" + + Scenario: Can delete a user who has an empty series + Given I am logged in as "testuser" + And "testuser" has an empty series "Empty" + When I try to delete my account + Then I should see "You have successfully deleted your account." + And a user account should not exist for "testuser" + + Scenario: Can orphan a series when deleting + Given I have an orphan account + And I am logged in as "testuser" + And I post a work "Masterpiece" as part of a series "Epic" + When I try to delete my account + Then I should see "What do you want to do with your works?" + When I choose "Change my pseud to "orphan" and attach to the orphan account" + And I press "Save" + Then I should see "You have successfully deleted your account." + And a user account should not exist for "testuser" + When I go to orphan_account's series page + Then I should see "Epic" diff --git a/features/users/user_rename.feature b/features/users/user_rename.feature new file mode 100644 index 0000000..6219175 --- /dev/null +++ b/features/users/user_rename.feature @@ -0,0 +1,293 @@ +@users +Feature: + In order to correct mistakes or reflect my evolving personality + As a registered user + I should be able to change my username + + Scenario: The user should not be able to change username without a password + Given I am logged in as "testuser" with password "password" + When I visit the change username page for testuser + And I fill in "New username" with "anothertestuser" + And I press "Change Username" + Then I should see "Your password was incorrect" + + Scenario: The user should not be able to change their username with an incorrect password + Given I am logged in as "testuser" with password "password" + When I visit the change username page for testuser + And I fill in "New username" with "anothertestuser" + And I fill in "Password" with "wrongpwd" + And I press "Change Username" + Then I should see "Your password was incorrect" + + Scenario: The user should not be able to change their username to their current username + Given I am logged in as "testuser" with password "password" + When I visit the change username page for testuser + And I fill in "New username" with "testuser" + And I fill in "Password" with "password" + And I press "Change Username" + Then I should see "Your new username must be different from your current username" + + Scenario: The user should be able to change only the capitalization of their username + Given I am logged in as "testy" with password "password" + When I visit the change username page for testy + And I fill in "New username" with "teSty" + 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, teSty!" + + Scenario: The user should not be able to change their username to another user's name + Given I have no users + And the following activated user exists + | login | password | + | otheruser | secret | + And I am logged in as "downthemall" with password "password" + When I visit the change username page for downthemall + And I fill in "New username" with "otheruser" + And I fill in "Password" with "password" + When I press "Change" + Then I should see "Username has already been taken" + + Scenario: The user should not be able to change their username to another user's name even if the capitalization is different + Given I have no users + And the following activated user exists + | login | password | + | otheruser | secret | + And I am logged in as "downthemall" with password "password" + When I visit the change username page for downthemall + And I fill in "New username" with "OtherUser" + And I fill in "Password" with "password" + And I press "Change Username" + Then I should see "Username has already been taken" + + Scenario: The user should be able to change their username if username and password are valid + Given I am logged in as "downthemall" with password "password" + When I visit the change username page for downthemall + And I fill in "New username" with "DownThemAll" + And I fill in "Password" with "password" + And I press "Change" + Then I should get confirmation that I changed my username + And I should see "Hi, DownThemAll!" + + Scenario: The user should receive an email notification after they change their username + Given I am logged in as "before" with password "password" + And a locale with translated emails + And the user "before" enables translated emails + And it is currently 2025-01-01 00:00 AM + When I change my username to "after" + Then "after" should receive 1 email + And the email should contain "account .*before.* has been changed to .*after" + And the email should contain "usernames can only be changed once every 7 days" + And the email should contain "You will be able to change your username again on Wed, 08 Jan 2025 00:00:00 \+0000" + And the email to "after" should be translated + + Scenario: The user should be able to change their username to a similar version with underscores + Given I am logged in as "downthemall" with password "password" + When I visit the change username page for downthemall + And I fill in "New username" with "Down_Them_All" + 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, Down_Them_All!" + + Scenario: Changing my username with one pseud changes that pseud + Given I have no users + And I am logged in as "oldusername" with password "password" + When I visit the change username page for oldusername + And I fill in "New username" with "newusername" + 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, newusername" + When I go to newusername's pseuds page + Then I should not see "oldusername" + When I follow "Edit" + Then I should see "You cannot change the pseud that matches your username" + Then the "pseud_is_default" checkbox should be checked and disabled + + Scenario: Changing only the capitalization of my username with one pseud changes that pseud's capitalization + Given I have no users + And I am logged in as "uppercrust" with password "password" + When I visit the change username page for uppercrust + And I fill in "New username" with "Uppercrust" + 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, Uppercrust" + When I go to Uppercrust's pseuds page + Then I should not see "uppercrust" + When I follow "Edit" + Then I should see "You cannot change the pseud that matches your username" + Then the "pseud_is_default" checkbox should be checked and disabled + + Scenario: Changing my username with two pseuds, one same as new, doesn't change old + Given I have no users + And the following activated user exists + | login | password | id | + | oldusername | secret | 1 | + And a pseud exists with name: "newusername", user_id: 1 + And I am logged in as "oldusername" with password "secret" + When I visit the change username page for oldusername + And I fill in "New username" with "newusername" + And I fill in "Password" with "secret" + And I press "Change Username" + Then I should get confirmation that I changed my username + And I should see "Hi, newusername" + When I follow "Pseuds (2)" + Then I should see "Edit oldusername" + And I should see "Edit newusername" + + Scenario: Changing username updates search results (bug AO3-3468) + Given I have no users + And I am logged in as "oldusername" with password "password" + And I post a work "Epic story" + And I wait 1 second + When I visit the change username page for oldusername + And I fill in "New username" with "newusername" + And I fill in "Password" with "password" + And I press "Change Username" + And all indexing jobs have been run + Then I should get confirmation that I changed my username + When I am on the works page + Then I should see "newusername" + And I should see "Epic story" + And I should not see "oldusername" + When I search for works containing "oldusername" + Then I should see "No results found" + And I should not see "Epic story" + When I search for works containing "newusername" + Then I should see "Epic story" + + Scenario: Comments reflect username changes immediately + Given the work "Interesting" + And I am logged in as "before" with password "password" + And "before" creates the pseud "mine" + When I set up the comment "Wow!" on the work "Interesting" + And I select "mine" from "comment[pseud_id]" + And I press "Comment" + And I view the work "Interesting" with comments + Then I should see "mine (before)" + When it is currently 1 second from now + And I visit the change username page for before + And I fill in "New username" with "after" + And I fill in "Password" with "password" + And I press "Change Username" + And I view the work "Interesting" with comments + Then I should see "after" within ".comment h4.byline" + And I should not see "mine (before)" + + Scenario: Collections reflect username changes of the owner after the cache expires + When I am logged in as "before" with password "password" + And I create the collection "My Collection Thing" + And I go to the collections page + Then I should see "My Collection Thing" + And I should see "before" within "#main" + When I change my username to "after" + And I go to the collections page + Then I should see "My Collection Thing" + And I should see "before" 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" within "#main" + And I should not see "before" within "#main" + + Scenario: Collections reflect username changes of moderators after the cache expires + Given I am logged in as "mod1" + And I create the collection "My Collection Thing" + And I have added a co-moderator "before" to collection "My Collection Thing" + When I go to the collections page + Then I should see "My Collection Thing" + And I should see "before" within "#main" + When I am logged in as "before" with password "password" + And I change my username to "after" + And I go to the collections page + Then I should see "My Collection Thing" + And I should see "before" 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" within "#main" + And I should not see "before" within "#main" + + Scenario: Changing username updates series blurbs + Given I have no users + And I am logged in as "oldusername" with password "password" + And I add the work "Great Work" to series "Best Series" + When I go to the dashboard page for user "oldusername" with pseud "oldusername" + And I follow "Series" + Then I should see "Best Series by oldusername" + When I visit the change username page for oldusername + And I fill in "New username" with "newusername" + 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, newusername" + When I follow "Series" + Then I should see "Best Series by newusername" + + Scenario: Changing username updates chapter bylines + Given the work "Title" by "pikachu" with chapter two co-authored with "before" + And I am logged in as "before" with password "password" + And I post a chapter for the work "Title" + When I view the work "Title" + And I view the 3rd chapter + Then I should see "Chapter by before" + When I visit the change username page for before + And I fill in "New username" with "after" + And I fill in "Password" with "password" + And it is currently 1 second from now + And I press "Change Username" + Then I should see "Your username has been successfully updated." + When I view the work "Title" + And I view the 3rd chapter + Then I should see "Chapter by after" + + Scenario: Changing the username from a forbidden name to non-forbidden + Given I have no users + And the following activated user exists + | login | password | + | forbidden | secret | + And the username "forbidden" is on the forbidden list + When I am logged in as "forbidden" with password "secret" + And I visit the change username page for forbidden + And I fill in "New username" with "notforbidden" + And I fill in "Password" with "secret" + And I press "Change Username" + Then I should get confirmation that I changed my username + And I should see "Hi, notforbidden" + + Scenario: Tag wrangling supervisors are emailed about tag wrangler username changes + Given the user "before" exists and is activated + And I am logged in as "before" with password "password" + And all emails have been delivered + And I visit the change username page for before + And I fill in "New username" with "after" + And I fill in "Password" with "password" + And I press "Change Username" + Then 0 email should be delivered to "tagwranglers-personnel@example.org" + When the user "wrangler_before" exists and has the role "tag_wrangler" + And I am logged in as "wrangler_before" with password "password" + And all emails have been delivered + And I visit the change username page for wrangler_before + And I fill in "New username" with "wrangler_after" + And I fill in "Password" with "password" + And I press "Change Username" + Then 1 email should be delivered to "tagwranglers-personnel@example.org" + And the email should contain "The wrangler" + And the email should contain "wrangler_before" + And the email should contain "has changed their name" + And the email should contain "wrangler_after" + + Scenario: Bookmarker's bookmark blurbs reflect username changes immediately + Given the work "Interesting" + And I am logged in as "before" + And I bookmark the work "Interesting" + And I go to before's bookmarks page + Then I should see "Bookmarked by before" + + When it is currently 1 second from now + And I change my username to "after" + And I go to after's bookmarks page + Then I should see "Bookmarked by after" + And I should not see "Bookmarked by before" diff --git a/features/works/chapter_edit.feature b/features/works/chapter_edit.feature new file mode 100755 index 0000000..e14b4bb --- /dev/null +++ b/features/works/chapter_edit.feature @@ -0,0 +1,620 @@ +@works +Feature: Edit chapters + In order to have an work full of chapters + As a humble user + I want to add and remove chapters + + Scenario: Add chapters to an existing work, delete chapters, edit chapters, post chapters in the wrong order, use rearrange page, create draft chapter + + Given the following activated user exists + | login | password | + | epicauthor | password | + And basic tags + When I go to epicauthor's user page + Then I should see "There are no works" + When I am logged in as "epicauthor" with password "password" + + # create a basic single-chapter work + When I follow "New Work" + Then I should see "Post New Work" + When I select "Not Rated" from "Rating" + And I check "No Archive Warnings Apply" + And I select "English" from "Choose a language" + And I fill in "Fandoms" with "New Fandom" + And I fill in "Work Title" with "New Epic Work" + And I fill in "content" with "Well, maybe not so epic." + And I press "Preview" + Then I should see "Draft was successfully created" + And I should see "1/1" + When I press "Post" + Then I should not see "Chapter 1" + And I should see "Well, maybe not so epic" + And I should see "Words:5" + + # add chapters to a single-chapter work + When I follow "Add Chapter" + And I fill in "chapter_position" with "2" + And I fill in "chapter_wip_length" with "100" + And I fill in "content" with "original chapter two" + And I press "Preview" + Then I should see "This is a draft chapter in a posted work. It will be kept unless the work is deleted." + When I press "Post" + Then I should see "2/100" + And I should see "Words:8" + When I follow "Add Chapter" + And I fill in "chapter_position" with "3" + And I fill in "chapter_wip_length" with "50" + And I fill in "content" with "entering chapter three" + And I press "Preview" + Then I should see "Chapter 3" + When I press "Post" + Then I should see "3/50" + And I should see "Words:11" + + # add chapters in the wrong order + When I follow "Add Chapter" + And I fill in "chapter_position" with "17" + And I fill in "chapter_wip_length" with "17" + And I fill in "content" with "entering fourth chapter out of order" + And I press "Preview" + Then I should see "Chapter 4" + When I press "Post" + And I should see "4/17" + And I should see "Words:17" + + # delete a chapter + When I follow "Edit" + And I follow "2" + And I follow "Delete Chapter" + And I press "Yes, Delete Chapter" + Then I should see "The chapter was successfully deleted." + And I should see "3/17" + And I should see "Words:14" + + # fill in the missing chapter + When I follow "Add Chapter" + And I fill in "chapter_position" with "2" + And I fill in "content" with "entering second chapter out of order" + And I press "Preview" + When I press "Post" + Then I should see "4/17" + And I should see "Words:20" + + # edit an existing chapter + When I follow "Edit" + And I follow "3" + And I fill in "chapter_position" with "4" + And I fill in "chapter_wip_length" with "4" + And I fill in "content" with "last chapter" + And I press "Preview" + Then I should see "Chapter 4" + When I press "Update" + Then I should see "Chapter was successfully updated" + And I should see "Chapter 4" + And I should see "4/4" + And I should see "Words:19" + When I follow "Edit" + And I follow "Manage Chapters" + Then I should see "Drag chapters to change their order." + + # view chapters in the right order + When I am logged out + And all indexing jobs have been run + And I go to epicauthor's works page + And I follow "New Epic Work" + And I follow "Entire Work" + Then I should see "Chapter 1" + And I should see "Well, maybe not so epic." within "#chapter-1" + And I should see "Chapter 2" + And I should see "entering second chapter out of order" within "#chapter-2" + And I should see "Chapter 3" + And I should see "entering fourth chapter out of order" within "#chapter-3" + And I should see "Chapter 4" + And I should see "last chapter" within "#chapter-4" + And I should not see "original chapter two" + When I follow "Chapter by Chapter" + And I follow "Chapter Index" + Then I should see "Chapter Index for New Epic Work by epicauthor" + And I should see "Chapter 1" + And I should see "Chapter 2" + And I should see "Chapter 3" + And I should see "Chapter 4" + + # move chapters around using rearrange page + When I am logged in as "epicauthor" with password "password" + And I view the work "New Epic Work" + And I follow "Edit" + And I follow "Manage Chapters" + Then I should see "Drag chapters to change their order." + When I fill in "chapters_1" with "4" + And I fill in "chapters_2" with "3" + And I fill in "chapters_3" with "2" + And I fill in "chapters_4" with "1" + And I press "Update Positions" + Then I should see "Chapter order has been successfully updated." + When I am logged out + And I go to epicauthor's works page + And I follow "New Epic Work" + And I follow "Entire Work" + Then I should see "Chapter 1" + And I should see "Well, maybe not so epic." within "#chapter-4" + And I should see "Chapter 2" + And I should see "second chapter" within "#chapter-3" + And I should see "Chapter 3" + And I should see "fourth chapter" within "#chapter-2" + And I should see "Chapter 4" + And I should see "last chapter" within "#chapter-1" + + # create a draft chapter and post it, and verify it shows up on the + # rearrange page + When I am logged in as "epicauthor" with password "password" + And a draft chapter is added to "New Epic Work" + When I view the work "New Epic Work" + And I follow "Edit" + Then I should see "5 (Draft)" + When I follow "Manage Chapters" + Then I should see "Chapter 5 (Draft)" + When I view the work "New Epic Work" + Then I should see "4/5" + When I select "5." from "selected_id" + And I press "Go" + Then I should see "This chapter is a draft and hasn't been posted yet!" + When I press "Post" + Then I should see "5/5" + When I follow "Edit" + Then I should not see "Draft" + And I should not see "draft" + When I view the work "New Epic Work" + And I select "5." from "selected_id" + And I press "Go" + Then I should not see "Draft" + And I should not see "draft" + + # create a draft chapter, preview it, edit it and then post it without preview + When I am logged in as "epicauthor" with password "password" + And I view the work "New Epic Work" + And I follow "Add Chapter" + And I fill in "Chapter Title" with "6(66) The Number of the Beast" + And I fill in "content" with "Even more awesomely epic context" + And I press "Preview" + Then I should see "This is a draft chapter in a posted work. It will be kept unless the work is deleted." + When I press "Edit" + And I fill in "content" with "Even more awesomely epic context. Plus bonus epicness" + And I press "Post" + Then I should see "Chapter was successfully posted." + And I should not see "This chapter is a draft and hasn't been posted yet!" + + # create a draft chapter, preview it, edit it, preview it again and then post it + When I am logged in as "epicauthor" with password "password" + And I view the work "New Epic Work" + And I follow "Add Chapter" + And I fill in "Chapter Title" with "6(66) The Number of the Beast" + And I fill in "content" with "Even more awesomely epic context" + And I press "Preview" + Then I should see "This is a draft chapter in a posted work. It will be kept unless the work is deleted." + When I press "Edit" + And I fill in "content" with "Even more awesomely epic context. Plus bonus epicness" + And I press "Preview" + Then I should see "Even more awesomely epic context. Plus bonus epicness" + When I press "Post" + Then I should see "Chapter was successfully posted." + And I should not see "This chapter is a draft and hasn't been posted yet!" + + + Scenario: Create a work and add a draft chapter, edit the draft chapter, and save changes to the draft chapter without previewing or posting + Given basic tags + And I am logged in as "moose" with password "muffin" + When I go to the new work page + Then I should see "Post New Work" + And I select "General Audiences" from "Rating" + And I check "No Archive Warnings Apply" + And I select "English" from "Choose a language" + And I fill in "Fandoms" with "If You Give an X a Y" + And I fill in "Work Title" with "If You Give Users a Draft Feature" + And I fill in "content" with "They will expect it to work." + And I press "Post" + When I should see "Work was successfully posted." + And I should see "They will expect it to work." + When I follow "Add Chapter" + And I fill in "content" with "And then they will request more features for it." + And I press "Preview" + Then I should see "This is a draft chapter in a posted work. It will be kept unless the work is deleted." + And I should see "And then they will request more features for it." + When I press "Edit" + And I fill in "content" with "And then they will request more features for it. Like the ability to save easily." + And I press "Save As Draft" + Then I should see "Chapter was successfully updated." + And I should see "This chapter is a draft and hasn't been posted yet!" + And I should see "Like the ability to save easily." + + + Scenario: Chapter drafts aren't updates; posted chapter drafts are + Given I am logged in as "testuser" with password "testuser" + And I post the work "Backdated Work" + And I edit the work "Backdated Work" + And I check "backdate-options-show" + And I select "1" from "work_chapter_attributes_published_at_3i" + And I select "January" from "work_chapter_attributes_published_at_2i" + And I select "1990" from "work_chapter_attributes_published_at_1i" + And I press "Post" + Then I should see "Published:1990-01-01" + When I follow "Add Chapter" + And I fill in "content" with "this is my second chapter" + And I set the publication date to today + And I press "Preview" + And I should see "This is a draft" + And I press "Save As Draft" + Then I should not see Updated today + And I should not see Completed today + And I should not see "Updated" within ".work.meta .stats" + And I should not see "Completed" within ".work.meta .stats" + When I follow "Edit Chapter" + And I press "Post" + Then I should see Completed today + + + Scenario: Posting a new chapter without previewing should set the work's updated date to now + + Given the work "First work" by "testuser" + And it is currently 2 days from now + And I am logged in as "testuser" + When I view the work "First work" + Then I should not see Updated today + When I follow "Add Chapter" + And I fill in "content" with "this is my second chapter" + And I set the publication date to today + And I press "Post" + Then I should see Completed today + When I follow "Edit" + And I fill in "work_wip_length" with "?" + And I press "Post" + Then I should see Updated today + When I post the work "A Whole New Work" + And I go to the works page + Then "A Whole New Work" should appear before "First work" + When I view the work "First work" + When I follow "Add Chapter" + And I fill in "content" with "this is my third chapter" + And I set the publication date to today + And I press "Post" + And I go to the works page + Then "First work" should appear before "A Whole New Work" + + Scenario: Posting a new chapter with a co-creator does not add them to previous or subsequent chapters + + Given I am logged in as "karma" with password "the1nonly" + And the user "sabrina" allows co-creators + And I post the work "Summer Friends" + When a chapter is set up for "Summer Friends" + And I invite the co-author "sabrina" + And I post the chapter + Then I should not see "sabrina" + When the user "sabrina" accepts all co-creator requests + And I view the work "Summer Friends" + Then I should see "karma, sabrina" + And I should see "Chapter by karma" + When I follow "Next Chapter" + Then I should not see "Chapter by" + When a chapter is set up for "Summer Friends" + Then I should see "Current co-creators" + And the "sabrina" checkbox should not be checked + When I post the chapter + Then I should see "Chapter by karma" + + Scenario: You can edit a pre-existing chapter to invite a new co-creator + + Given I am logged in as "karma" with password "the1nonly" + And the user "amy" allows co-creators + And I post the work "Forever Friends" + And a chapter is added to "Forever Friends" + When I view the work "Forever Friends" + And I view the 2nd chapter + And I follow "Edit Chapter" + And I invite the co-author "amy" + And I post the chapter + Then I should not see "amy, karma" + And 1 email should be delivered to "amy" + And the email should contain "The user karma has invited your pseud amy to be listed as a co-creator on the following chapter" + And the email should not contain "translation missing" + When the user "amy" accepts all co-creator requests + And I view the work "Forever Friends" + Then I should see "amy, karma" + And I should see "Chapter by karma" + When I follow "Next Chapter" + Then I should not see "Chapter by" + + + Scenario: You can edit a chapter to add (not invite) a co-creator who is + already on the work + + Given I am logged in as "karma" with password "the1noly" + And I post the work "Past Friends" + And a chapter with the co-author "sabrina" is added to "Past Friends" + And all emails have been delivered + And a chapter is added to "Past Friends" + When I view the work "Past Friends" + And I view the 3rd chapter + Then I should see "Chapter by karma" + When I follow "Edit Chapter" + Then the "sabrina" checkbox should not be checked + When I check "sabrina" + # Expire cached byline + And it is currently 1 second from now + And I post the chapter + Then I should not see "Chapter by karma" + And 1 email should be delivered to "sabrina" + And the email should contain "The user karma has listed your pseud sabrina as a co-creator on the following chapter" + And the email should contain "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." + And the email should not contain "translation missing" + + + Scenario: Editing a chapter with a co-creator does not allow you to remove the co-creator + + Given I am logged in as "karma" with password "the1noly" + And I post the work "Camp Friends" + And a chapter with the co-author "sabrina" is added to "Camp Friends" + When I follow "Edit Chapter" + Then the "sabrina" checkbox should be checked and disabled + + + Scenario: Removing yourself as a co-creator from the chapter edit page when + you've co-created multiple chapters on the work removes you only from that + specific chapter. Removing yourself as a co-creator from the chapter edit page + of the last chapter you've co-created also removes you from the work. + + Given the work "OP's Work" by "originalposter" with chapter two co-authored with "opsfriend" + And a chapter with the co-author "opsfriend" is added to "OP's Work" + And I am logged in as "opsfriend" + When I view the work "OP's Work" + And I view the 3rd chapter + And I follow "Edit Chapter" + When I follow "Remove Me As Chapter Co-Creator" + Then I should see "You have been removed as a creator from the chapter." + And I should see "Chapter 1" + When I view the 3rd chapter + Then I should see "Chapter 3" + And I should see "Chapter by originalposter" + When I follow "Previous Chapter" + And I follow "Edit Chapter" + And I follow "Remove Me As Chapter Co-Creator" + Then I should see "You have been removed as a creator from the work." + When I view the work "OP's Work" + Then I should not see "Edit Chapter" + + + Scenario: Removing yourself as a co-creator from the chapter manage page + + Given the work "OP's Work" by "originalposter" with chapter two co-authored with "opsfriend" + And a chapter with the co-author "opsfriend" is added to "OP's Work" + And I am logged in as "opsfriend" + When I view the work "OP's Work" + And I follow "Edit" + And I follow "Manage Chapters" + When I follow "Remove Me As Chapter Co-Creator" + Then I should see "You have been removed as a creator from the chapter." + And I should see "Chapter 1" + When I view the 2nd chapter + Then I should see "Chapter by originalposter" + + + Scenario: The option to remove yourself as a co-creator should only be + included for chapters you are a co-creator of + + Given the work "OP's Work" by "originalposter" with chapter two co-authored with "opsfriend" + And I am logged in as "opsfriend" + When I view the work "OP's Work" + And I follow "Edit" + And I follow "Manage Chapters" + Then the Remove Me As Chapter Co-Creator option should not be on the 1st chapter + And the Remove Me As Chapter Co-Creator option should be on the 2nd chapter + When I view the work "OP's Work" + And I follow "Edit Chapter" + Then I should not see "Remove Me As Chapter Co-Creator" + When I view the work "OP's Work" + And I view the 2nd chapter + And I follow "Edit Chapter" + Then I should see "Remove Me As Chapter Co-Creator" + + + Scenario: You should be able to edit a chapter you are not already co-creator + of, and you will be added to the chapter as a co-creator and your changes will + be saved + + Given I am logged in as "originalposter" + And the user "opsfriend" allows co-creators + And I post the work "OP's Work" + And a chapter with the co-author "opsfriend" is added to "OP's Work" + When I am logged in as "opsfriend" + And I view the work "OP's Work" + Then I should see "Chapter 1" + And I should see "Chapter by originalposter" + When I follow "Edit Chapter" + Then I should not see "You're not allowed to use that pseud." + When I fill in "content" with "opsfriend was here" + And I post the chapter + Then I should see "opsfriend was here" + And I should not see "Chapter by originalposter" + + + Scenario: You should be able to add a chapter with two co-creators, one of + whom is already on the work and the other of whom is not + + Given I am logged in as "rusty" + And the user "sharon" allows co-creators + And the user "brenda" allows co-creators + And I set up the draft "Rusty Has Two Moms" + And I invite the co-author "brenda" + And I post the work without preview + And the user "brenda" accepts all co-creator requests + When a chapter is set up for "Rusty Has Two Moms" + And I invite the co-author "sharon" + And I check "brenda" + And I post the chapter + Then I should see "brenda, rusty" + And I should not see "Chapter by" + When the user "sharon" accepts all co-creator requests + And I view the work "Rusty Has Two Moms" + Then I should see "brenda, rusty, sharon" + And I should see "Chapter by brenda, rusty" + When I follow "Next Chapter" + Then I should not see "Chapter by" + + + Scenario: You should be able to add a chapter with two co-creators who are not + on the work, one of whom has an ambiguous pseud + + Given "thebadmom" has the pseud "sharon" + And "thegoodmom" has the pseud "sharon" + And the user "brenda" allows co-creators + And the user "thebadmom" allows co-creators + And the user "thegoodmom" allows co-creators + And I am logged in as "rusty" + And I post the work "Rusty Has Two Moms" + When a chapter is set up for "Rusty Has Two Moms" + And I try to invite the co-authors "sharon, brenda" + And I post the chapter + Then I should see "The pseud sharon is ambiguous." + When I select "thegoodmom" from "There's more than one user with the pseud sharon." + And I press "Post" + Then I should not see "brenda" + And I should not see "sharon" + But 1 email should be delivered to "brenda" + And 1 email should be delivered to "thegoodmom" + When the user "brenda" accepts all co-creator requests + And the user "thegoodmom" accepts all co-creator requests + And I view the work "Rusty Has Two Moms" + Then I should see "brenda, rusty, sharon (thegoodmom)" + + + Scenario: You should be able to add a chapter with two co-creators, one of + whom is already on the work and the other of whom has an ambiguous pseud + + Given "thebadmom" has the pseud "sharon" + And the user "thegoodmom" allows co-creators + And the user "thebadmom" allows co-creators + And "thegoodmom" has the pseud "sharon" + And I am logged in as "rusty" + And I set up the draft "Rusty Has Two Moms" + And I invite the co-author "brenda" + And I post the work without preview + And the user "brenda" accepts all co-creator requests + When a chapter is set up for "Rusty Has Two Moms" + And I invite the co-author "sharon" + And I check "brenda" + And I post the chapter + Then I should see "The pseud sharon is ambiguous." + When I select "thegoodmom" from "There's more than one user with the pseud sharon." + And I press "Post" + Then I should see "brenda, rusty" + When the user "thegoodmom" accepts all co-creator requests + And I view the work "Rusty Has Two Moms" + Then I should see "brenda, rusty, sharon (thegoodmom)" + + + Scenario: Users can't set a chapter publication date that is in the future, + e.g. set the date to April 30 when it is April 26 + + Given I am logged in + And it is currently Wed Apr 26 22:00:00 UTC 2017 + And I post the work "Futuristic" + And a chapter is set up for "Futuristic" + When I select "30" from "chapter[published_at(3i)]" + And I press "Post" + Then I should see "Publication date can't be in the future." + When I jump in our Delorean and return to the present + + + Scenario: The Post Draft option on your drafts page only posts the first + chapter of a multi-chapter draft + Given I have a multi-chapter draft + And I follow "My Dashboard" + And I follow "Drafts (" + When I follow "Post Draft" + Then I should see "Your work was successfully posted." + And I should not see "This chapter is a draft and hasn't been posted yet!" + When I follow "Next Chapter" + Then I should see "This chapter is a draft and hasn't been posted yet!" + + Scenario: You should be able to invite a co-creator to a chapter if they allow it. + + Given the user "brenda" allows co-creators + And I am logged in as "rusty" + And I post the work "Rusty Has Two Moms" + When a chapter is set up for "Rusty Has Two Moms" + And I invite the co-author "brenda" + And I press "Post" + Then I should see "Chapter has been posted!" + And I should not see "brenda" + But 1 email should be delivered to "brenda" + And the email should contain "The user rusty has invited your pseud brenda to be listed as a co-creator on the following chapter" + And the email should not contain "translation missing" + When I am logged in as "brenda" + And I follow "Rusty Has Two Moms" in the email + Then I should not see "Edit" + When I follow "Co-Creator Requests page" + And I check "selected[]" + # Delay before accepting the request to make sure the cache is expired: + And it is currently 1 second from now + And I press "Accept" + Then I should see "You are now listed as a co-creator on Chapter 2 of Rusty Has Two Moms." + When I follow "Rusty Has Two Moms" + Then I should see "brenda, rusty" + And I should see "Edit" + + Scenario: You should not be able to invite a co-creator to a chapter if they do not allow it. + + Given the user "brenda" disallows co-creators + And I am logged in as "rusty" + And I post the work "Rusty Has Two Moms" + When a chapter is set up for "Rusty Has Two Moms" + And I try to invite the co-author "brenda" + And I press "Post" + Then I should see "brenda does not allow others to invite them to be a co-creator." + And 0 emails should be delivered to "brenda" + When I press "Preview" + Then I should see "This is a draft chapter in a posted work. It will be kept unless the work is deleted." + When I press "Post" + Then I should see "Chapter was successfully posted." + And I should see "rusty" + And I should not see "brenda" + + Scenario: You should be able to add a co-creator to a chapter if they do not allow it, if they are a co-creator of the work. + + Given the user "thegoodmom" allows co-creators + And I am logged in as "rusty" + And I set up the draft "Rusty Has Two Moms" + And I invite the co-author "thegoodmom" + And I post the work without preview + Then I should see "Work was successfully posted." + When the user "thegoodmom" accepts all co-creator requests + And I view the work "Rusty Has Two Moms" + Then I should see "rusty, thegoodmom" + When the user "thegoodmom" disallows co-creators + And I post a chapter for the work "Rusty Has Two Moms" + Then I should see "Chapter has been posted!" + And I follow "Chapter 2" + And I should see "Chapter by rusty" + And I follow "Edit Chapter" + When I check "Add co-creators?" + And I fill in "pseud_byline" with "thegoodmom" + And I press "Post" + Then I should see "Chapter was successfully updated." + And I follow "Chapter 2" + And I follow "Edit Chapter" + And I should see "Remove Me As Chapter Co-Creator" + + Scenario: You can't add a chapter to a work with too many tags + Given the user-defined tag limit is 7 + And I am logged in as a random user + And I post 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 follow "Add Chapter" + And I fill in "content" with "this is my second chapter" + And I press "Post" + Then I should see "Fandom, relationship, character, and additional tags must not add up to more than 7. Your work has 8 of these tags, so you must remove 1 of them." + When I view the work "Over the Limit" + Then I should see "1/1" + And I should not see "Next Chapter" diff --git a/features/works/work_browse.feature b/features/works/work_browse.feature new file mode 100644 index 0000000..838d31e --- /dev/null +++ b/features/works/work_browse.feature @@ -0,0 +1,186 @@ +@works @browse +Feature: Browsing works from various contexts + +Scenario: Browsing works with incorrect page params in query string + Given a canonical fandom "Johnny Be Good" + And I am logged in + And I post the work "Whatever" with fandom "Johnny Be Good" + When I browse the "Johnny Be Good" works with page parameter "" + Then I should see "1 Work" + +Scenario: If works in a listing exceed the maximum search result count, + display a notice on the last page of results + + Given a canonical fandom "Aggressive Retsuko" + And the max search result count is 4 + And 2 items are displayed per page + And I am logged in + And I post the work "Whatever 1" with fandom "Aggressive Retsuko" + # Ensure stable work order + And it is currently 1 second from now + And I post the work "Whatever 2" with fandom "Aggressive Retsuko" + And it is currently 1 second from now + And I post the work "Whatever 3" with fandom "Aggressive Retsuko" + And it is currently 1 second from now + And I post the work "Whatever 4" with fandom "Aggressive Retsuko" + + When I browse the "Aggressive Retsuko" works with page parameter "2" + Then I should see "3 - 4 of 4 Works" + And I should not see "Please use the filters" + + When it is currently 1 second from now + And I post the work "Whatever 5" with fandom "Aggressive Retsuko" + And I browse the "Aggressive Retsuko" works + Then I should see "1 - 2 of 5 Works" + And I should not see "Please use the filters" + When I follow "Next" + Then I should see "3 - 4 of 5 Works" + And I should see "Displaying 4 results out of 5. Please use the filters" + + When I browse the "Aggressive Retsuko" works with page parameter "3" + Then I should see "3 - 4 of 5 Works" + And I should see "Displaying 4 results out of 5. Please use the filters" + When I follow "Previous" + Then I should see "1 - 2 of 5 Works" + And I should not see "Please use the filters" + +Scenario: The recent chapter link should point to the last posted chapter even +if there is a draft chapter + + Given I am logged in as a random user + And a canonical fandom "Canonical Fandom" + And I post the 2 chapter work "My WIP" with fandom "Canonical Fandom" + When I browse the "Canonical Fandom" works + And I follow the recent chapter link for the work "My WIP" + Then I should be on the 2nd chapter of the work "My WIP" + When a draft chapter is added to "My WIP" + And I browse the "Canonical Fandom" works + And I follow the recent chapter link for the work "My WIP" + Then I should be on the 2nd chapter of the work "My WIP" + +Scenario: The recent chapter link in a work's blurb should show the adult +content notice to visitors who are not logged in + + Given I am logged in as a random user + And a canonical fandom "Canonical Fandom" + And I post the 3 chapter work "WIP" with fandom "Canonical Fandom" with rating "Mature" + When I am logged out + And I browse the "Canonical Fandom" works + And I follow the recent chapter link for the work "WIP" + Then I should see "adult content" + When I follow "Yes, Continue" + Then I should be on the 3rd chapter of the work "WIP" + +Scenario: The recent chapter link in a work's blurb should honor the logged-in +user's "Show me adult content without checking" preference + + Given I am logged in as a random user + And a canonical fandom "Canonical Fandom" + And I post the 2 chapter work "WIP" with fandom "Canonical Fandom" with rating "Mature" + When I am logged in as "adultuser" + And I set my preferences to show adult content without warning + And I browse the "Canonical Fandom" works + And I follow the recent chapter link for the work "WIP" + Then I should not see "adult content" + And I should be on the 2nd chapter of the work "WIP" + When I set my preferences to warn before showing adult content + And I browse the "Canonical Fandom" works + And I follow the recent chapter link for the work "WIP" + Then I should see "adult content" + When I follow "Yes, Continue" + Then I should be on the 2nd chapter of the work "WIP" + +Scenario: The recent chapter link in a work's blurb should point to +chapter-by-chapter mode even if the logged-in user's preference is "Show the +whole work by default" + + Given I am logged in as a random user + And a canonical fandom "Canonical Fandom" + And I post the 2 chapter work "WIP" with fandom "Canonical Fandom" with rating "Mature" + When I am logged in as "fullworker" + And I set my preferences to View Full Work mode by default + And I browse the "Canonical Fandom" works + And I follow the recent chapter link for the work "WIP" + Then I should be on the 2nd chapter of the work "WIP" + +Scenario: The recent chapter link in a work's blurb points to the last posted +chapter when the chapters are reordered. + + Given I am logged in as a random user + And a canonical fandom "Canonical Fandom" + And I post the 2 chapter work "My WIP" with fandom "Canonical Fandom" + When I browse the "Canonical Fandom" works + And I follow the recent chapter link for the work "My WIP" + Then I should be on the 2nd chapter of the work "My WIP" + When I follow "Edit" + And I follow "Manage Chapters" + And I fill in "chapters_1" with "2" + And I fill in "chapters_2" with "1" + And I press "Update Positions" + Then I should see "Chapter order has been successfully updated." + When I browse the "Canonical Fandom" works + And I follow the recent chapter link for the work "My WIP" + Then I should be on the 2nd chapter of the work "My WIP" + + Scenario: Kudos link from from work browsing leads to full work page + Given the chaptered work with 2 chapters "Awesome Work" + When I am logged in as "reader" + And I go to the works page + Then I should not see "Kudos: 1" within the work blurb of "Awesome Work" + When I view the work "Awesome Work" + And I leave kudos on "Awesome Work" + Then I should see "reader left kudos on this work!" + When I am logged out + And the cache for the work "Awesome Work" is cleared + And I go to the works page + Then I should see "Kudos: 1" within the work blurb of "Awesome Work" + When I follow the kudos link for the work "Awesome Work" + Then I should be on the work "Awesome Work" + And I should see "reader left kudos on this work!" + + Scenario: Comments link from from work browsing leads to full work page + Given the chaptered work with 2 chapters "Awesome Work" + When I am logged in as "reader" + And I go to the works page + Then I should not see "Comments: 1" within the work blurb of "Awesome Work" + When I post the comment "Bravo!" on the work "Awesome Work" + Then I should see "Bravo!" + When I am logged out + And the cache for the work "Awesome Work" is cleared + And I go to the works page + Then I should see "Comments: 1" within the work blurb of "Awesome Work" + When I follow the comments link for the work "Awesome Work" + Then I should be on the work "Awesome Work" + And I should see "Bravo!" + +Scenario: Can also browse work indexed by language + Given basic languages + And Persian language + And basic tags + And I am logged in + And I post the work "Whatever 1" with fandom "Aggressive Retsuko" + And I post the work "Whatever 2" with fandom "Aggressive Retsuko" + When I go to the new work page + And I select "Not Rated" from "Rating" + And I check "No Archive Warnings Apply" + And I fill in "Fandoms" with "Weiß Kreuz" + And I fill in "Work Title" with "Überraschende Überraschung" + And I fill in "content" with "Dies ist eine Fanfic in Deutsch." + And I select "Deutsch" from "Choose a language" + When I press "Post" + Then I should see "Work was successfully posted." + And I should see "Deutsch" within "dd.language" + When I browse works in language "English" + Then I should see "2 Works in English" + When I press "Sort and Filter" + Then I should see "2 Works in English" + When I browse works in language "Deutsch" + Then I should see "1 Work in Deutsch" + When I browse works in language "Persian" + Then I should see "0 Works in Persian" + +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 the work "Test" + When I go to the works page + Then I should see an HTML comment containing the number 1744477200 within "li.work.blurb" diff --git a/features/works/work_create.feature b/features/works/work_create.feature new file mode 100755 index 0000000..6736ceb --- /dev/null +++ b/features/works/work_create.feature @@ -0,0 +1,427 @@ +@works @tags +Feature: Create Works + In order to have an archive full of works + As an author + I want to create new works + + Scenario: You can't create a work unless you're logged in + When I go to the new work page + Then I should see "Please log in" + + Scenario: Creating a new minimally valid work + Given basic tags + And I am logged in as "newbie" + When I go to the new work page + Then I should see "Post New Work" + And I select "Not Rated" from "Rating" + And I check "No Archive Warnings Apply" + And I select "English" from "Choose a language" + And I fill in "Fandoms" with "Supernatural" + And I fill in "Work Title" with "All Hell Breaks Loose 🤬💩" + And I fill in "content" with "Bad things happen, etc. 🤬💩" + When I press "Preview" + Then I should see "Preview" + When I press "Post" + Then I should see "Work was successfully posted." + And I should see "Bad things happen, etc. 🤬💩" + When I go to the works page + Then I should see "All Hell Breaks Loose 🤬💩" + + Scenario: Creating a new minimally valid work and posting without preview + Given I am logged in as "newbie" + When I set up the draft "All Hell Breaks Loose" + And I fill in "content" with "Bad things happen, etc." + And I press "Post" + Then I should see "Work was successfully posted." + And I should see "Bad things happen, etc." + When I go to the works page + Then I should see "All Hell Breaks Loose" + + Scenario: Creating a new minimally valid work when you have more than one pseud + Given I am logged in as "newbie" + And "newbie" creates the pseud "Pointless Pseud" + When I set up the draft "All Hell Breaks Loose" + And I unselect "newbie" from "Creator/Pseud(s)" + And I select "Pointless Pseud" from "Creator/Pseud(s)" + And I press "Post" + Then I should see "Work was successfully posted." + When I go to the works page + Then I should see "All Hell Breaks Loose" + And I should see "by Pointless Pseud" + + @javascript + Scenario: Creating a new work with everything filled in, and we do mean everything + Given basic tags + And the following activated users exist + | login | email | + | coauthor | coauthor@example.org | + | cosomeone | cosomeone@example.org | + | giftee | giftee@example.org | + | recipient | recipient@example.org | + And the user "coauthor" allows co-creators + And the user "cosomeone" allows co-creators + And the user "giftee" allows gifts + And the user "recipient" allows gifts + And I have a collection "Collection 1" with name "collection1" + And I have a collection "Collection 2" with name "collection2" + And "thorough" has the pseud "Pseud2" + And "thorough" has the pseud "Pseud3" + And I am logged in as "thorough" + And all emails have been delivered + When I go to the new work page + And I select "Not Rated" from "Rating" + And I check "No Archive Warnings Apply" + And I select "English" from "Choose a language" + And I check "F/M" + And I fill in "Fandoms" with "Supernatural" + And I fill in "Work Title" with "All Something Breaks Loose" + And I fill in "content" with "Bad things happen, etc." + And I check "at the beginning" + And I fill in "Notes" with "This is my beginning note" + And I fill in "End Notes" with "This is my endingnote" + And I fill in "Summary" with "Have a short summary" + And I fill in "Characters" with "Sam Winchester, Dean Winchester," + And I fill in "Relationships" with "Harry/Ginny" + And I fill in "Additional Tags" with "An extra tag" + And I fill in "Gift this work to" with "Someone else, recipient" + And I check "This work is part of a series" + And I fill in "Or create and use a new one:" with "My new series" + And I select "Pseud2" from "Creator/Pseud(s)" + And I select "Pseud3" from "Creator/Pseud(s)" + And I fill in "pseud_byline_autocomplete" with "coauthor" + And I fill in "Post to Collections / Challenges" with "collection1, collection2" + And I press "Preview" + Then I should see "Draft was successfully created" + When I press "Post" + Then I should see "Work was successfully posted." + And 1 email should be delivered to "coauthor@example.org" + And the email should contain "The user thorough has invited your pseud coauthor to be listed as a co-creator on the following work" + And the email should not contain "translation missing" + And 1 email should be delivered to "recipient@example.org" + And the email should contain "A gift work has been posted for you" + When I go to the works page + Then I should see "All Something Breaks Loose" + When I follow "All Something Breaks Loose" + Then I should see "All Something Breaks Loose" + And I should see "Fandom: Supernatural" + And I should see "Rating: Not Rated" + And I should see "No Archive Warnings Apply" + And I should not see "Choose Not To Use Archive Warnings" + And I should see "Category: F/M" + And I should see "Characters: Sam Winchester Dean Winchester" + And I should see "Relationship: Harry/Ginny" + And I should see "Additional Tags: An extra tag" + And I should see "For Someone else, recipient" + And I should see "Collections: Collection 1, Collection 2" + And I should see "Notes" + And I should see "This is my beginning note" + And I should see "See the end of the work for more notes" + And I should see "This is my endingnote" + And I should see "Summary" + And I should see "Have a short summary" + And I should see "My new series" + And I should see "Bad things happen, etc." + And I should see "Pseud2" within ".byline" + And I should see "Pseud3" within ".byline" + But I should not see "coauthor" within ".byline" + When the user "coauthor" accepts all co-creator requests + And I view the work "All Something Breaks Loose" + Then I should see "coauthor" within ".byline" + When I follow "Add Chapter" + And I fill in "Chapter Title" with "This is my second chapter" + And I fill in "content" with "Let's write another story" + And I press "Preview" + Then I should see "Chapter 2: This is my second chapter" + And I should see "Let's write another story" + When I press "Post" + Then I should see "All Something Breaks Loose" + And I should not see "Bad things happen, etc." + And I should see "Let's write another story" + When I follow "Previous Chapter" + And I should see "Bad things happen, etc." + When I follow "Entire Work" + Then I should see "Bad things happen, etc." + And I should see "Let's write another story" + When I follow "Edit" + And I check "Add co-creators?" + And I fill in "pseud_byline_autocomplete" with "Does_not_exist" + And I press "Preview" + Then I should see "Invalid creator: Could not find a pseud Does_not_exist." + When all emails have been delivered + And I choose "cosomeone" from the "pseud_byline_autocomplete" autocomplete + And I press "Preview" + And I press "Update" + Then I should see "Work was successfully updated" + And I should see "coauthor" within ".byline" + And I should see "Pseud2" within ".byline" + And I should see "Pseud3" within ".byline" + But I should not see "cosomeone" within ".byline" + And 1 email should be delivered to "cosomeone@example.org" + When the user "cosomeone" accepts all co-creator requests + And I view the work "All Something Breaks Loose" + Then I should see "cosomeone" within ".byline" + When all emails have been delivered + And I follow "Edit" + And I remove selected values from the autocomplete field within "dd.recipient" + And I give the work to "giftee" + And I press "Preview" + And I press "Update" + Then I should see "Work was successfully updated" + And I should see "For giftee" + And 1 email should be delivered to "giftee@example.org" + When I go to giftee's user page + Then I should see "Gifts (1)" + + Scenario: Creating a new work with some maybe-invalid things + # TODO: needs some more actually invalid things as well + Given basic tags + And the following activated users exist + | login | password | email | + | coauthor | something | coauthor@example.org | + | badcoauthor | something | badcoauthor@example.org | + And I am logged in as "thorough" with password "something" + And user "badcoauthor" is banned + And the user "coauthor" allows co-creators + When I set up the draft "Bad Draft" + And I fill in "Fandoms" with "Invalid12./" + And I fill in "Work Title" with "/" + And I fill in "content" with "T" + And I check "chapters-options-show" + And I fill in "work_wip_length" with "text" + And I press "Preview" + Then I should see "Content must be at least 10 characters long." + When I fill in "content" with "Text and some longer text" + And I fill in "work_collection_names" with "collection1, collection2" + And I press "Preview" + Then I should see "Sorry! We couldn't save this work because:" + And I should see a collection not found message for "collection1" + # Collections are now parsed by collectible.rb which only shows the first failing collection and nothing else + # And I should see a collection not found message for "collection2" + When I fill in "work_collection_names" with "" + And I fill in "pseud_byline" with "badcoauthor" + And I press "Preview" + Then I should see "badcoauthor cannot be listed as a co-creator" + When I fill in "pseud_byline" with "coauthor" + And I fill in "Additional Tags" with "this is a very long tag more than one hundred fifty characters in length how would this normally even be created plus some extra words to pad this out 1" + And I press "Preview" + Then I should see "try using less than 150 characters or using commas to separate your tags" + When I fill in "Additional Tags" with "this is a shorter tag" + And I press "Preview" + Then I should see "Draft was successfully created" + And I should see "Chapter" + And I should see "1/?" + + Scenario: Creating a new work in a new series with some invalid things should return to the new work page with an error message and the newly created series selected + Given basic tags + And I am logged in as "thorough" with password "something" + When I set up the draft "Bad Draft" + And I fill in "Fandoms" with "Invalid12./" + And I fill in "Work Title" with "/" + And I fill in "content" with "T" + And I check "This work has multiple chapters" + And I fill in "Post to Collections / Challenges" with "collection1, collection2" + And I check "This work is part of a series" + And I fill in "Or create and use a new one:" with "My new series" + And I press "Preview" + Then I should see "Sorry! We couldn't save this work because:" + And I should see a collection not found message for "collection1" + And I should see "My new series" in the "Or create and use a new one:" input + And I should not see "Remove Work From Series" + + Scenario: Creating a new work in an existing series with some invalid things should return to the new work page with an error message and series information still filled in + Given basic tags + And I am logged in as "thorough" with password "something" + And I post the work "Work one" as part of a series "My existing series" + When I set up the draft "Bad Draft" + And I fill in "Fandoms" with "Invalid12./" + And I fill in "Work Title" with "/" + And I fill in "content" with "T" + And I check "This work has multiple chapters" + And I fill in "Post to Collections / Challenges" with "collection1, collection2" + And I check "This work is part of a series" + And I select "My existing series" from "Choose one of your existing series:" + And I press "Preview" + Then I should see "Sorry! We couldn't save this work because:" + And I should see a collection not found message for "collection1" + And "My existing series" should be selected within "Choose one of your existing series:" + And I should not see "Remove Work From Series" + + Scenario: test for integer title and multiple fandoms + Given I am logged in + When I set up the draft "02138" + And I fill in "Fandoms" with "Supernatural, Smallville" + When I press "Post" + Then I should see "Work was successfully posted." + And I should see "Supernatural" + And I should see "Smallville" + And I should see "02138" within "h2.title" + + Scenario: test for < and > in title + Given I am logged in + When I set up the draft "4 > 3 and 2 < 5" + When I press "Post" + Then I should see "Work was successfully posted." + And I should see "4 > 3 and 2 < 5" within "h2.title" + + Scenario: posting a chapter without preview + Given I am logged in as "newbie" with password "password" + And I post the work "All Hell Breaks Loose" + When I follow "Add Chapter" + And I fill in "Chapter Title" with "This is my second chapter" + And I fill in "content" with "Let's write another story" + And I press "Post" + Then I should see "Chapter 2: This is my second chapter" + And I should see "Chapter has been posted!" + And I should not see "This is a preview" + + Scenario: RTE and HTML buttons are separate + Given the default ratings exist + And I am logged in as "newbie" + When I go to the new work page + Then I should see "Post New Work" + And I should see "Rich Text" within ".rtf-html-switch" + And I should see "HTML" within ".rtf-html-switch" + + Scenario: posting a backdated work + Given I am logged in as "testuser" with password "testuser" + And I post the work "This One Stays On Top" + And I set up the draft "Backdated" + And I check "backdate-options-show" + And I select "1" from "work_chapter_attributes_published_at_3i" + And I select "January" from "work_chapter_attributes_published_at_2i" + And I select "1990" from "work_chapter_attributes_published_at_1i" + And I press "Preview" + When I press "Post" + Then I should see "Published:1990-01-01" + When I go to the works page + Then "This One Stays On Top" should appear before "Backdated" + + Scenario: Users must set something as a warning and Author Chose Not To Use Archive Warnings should not be added automatically + Given basic tags + And I am logged in + When I go to the new work page + And I select "English" from "Choose a language" + And I fill in "Fandoms" with "Dallas" + And I fill in "Work Title" with "I Shot J.R.: Kristin's Story" + And I fill in "content" with "It wasn't my fault, you know." + And I press "Post" + Then I should see "We couldn't save this work" + And I should see "Please select at least one warning." + When I check "No Archive Warnings Apply" + And I press "Post" + Then I should see "Work was successfully posted." + And I should see "No Archive Warnings Apply" + And I should not see "Author Chose Not To Use Archive Warnings" + And I should see "It wasn't my fault, you know." + + Scenario: Users can co-create a work with a co-creator who has multiple pseuds + Given basic tags + And "myself" has the pseud "Me" + And "herself" has the pseud "Me" + And the user "myself" allows co-creators + And the user "herself" allows co-creators + When I am logged in as "testuser" with password "testuser" + And I go to the new work page + And I fill in the basic work information for "All Hell Breaks Loose" + And I check "Add co-creators?" + And I fill in "pseud_byline" with "Me" + And I check "This work is part of a series" + And I fill in "Or create and use a new one:" with "My new series" + And I press "Post" + Then I should see "There's more than one user with the pseud Me." + And I select "myself" from "Please choose the one you want:" + And I press "Preview" + Then I should see "Draft was successfully created." + And I press "Post" + Then I should see "Work was successfully posted. It should appear in work listings within the next few minutes." + And I should not see "Me (myself)" + And I should see "My new series" + When the user "myself" accepts all co-creator requests + And I view the work "All Hell Breaks Loose" + Then I should see "Me (myself), testuser" + + Scenario: Users can only create a work with a co-creator who allows it. + Given basic tags + And "Burnham" has the pseud "Michael" + And "Pike" has the pseud "Christopher" + And the user "Burnham" allows co-creators + When I am logged in as "testuser" with password "testuser" + And I go to the new work page + And I fill in the basic work information for "Thats not my Spock" + And I check "Add co-creators?" + And I fill in "pseud_byline" with "Michael,Christopher" + And I press "Post" + Then I should see "Christopher (Pike) does not allow others to invite them to be a co-creator." + When I fill in "pseud_byline" with "Michael" + And I press "Preview" + Then I should see "Draft was successfully created." + When I press "Post" + Then I should see "Work was successfully posted. It should appear in work listings within the next few minutes." + But I should not see "Michael (Burnham)" + When the user "Burnham" accepts all co-creator requests + And I view the work "Thats not my Spock" + Then I should see "Michael (Burnham), testuser" + + Scenario: Users can't set a publication date that is in the future, e.g. set + the date to April 30 when it is April 26 + Given I am logged in + And it is currently Wed Apr 26 22:00:00 UTC 2017 + And I set up a draft "Futuristic" + When I check "Set a different publication date" + And I select "30" from "work[chapter_attributes][published_at(3i)]" + And I press "Post" + Then I should see "Publication date can't be in the future." + When I jump in our Delorean and return to the present + + Scenario: Inviting a co-author adds the co-author to all existing chapters when they accept the invite + Given the user "foobar" exists and is activated + And the user "barbaz" exists and is activated + + When I am logged in as "foobar" + And I post the chaptered work "Chaptered Work" + And I edit the work "Chaptered Work" + And I invite the co-author "barbaz" + And I press "Post" + Then I should not see "barbaz" + But 1 email should be delivered to "barbaz" + When I am logged in as "barbaz" + And I view the work "Chaptered Work" + Then I should not see "Edit" + # Delay to make sure that the cache expires when we accept the request: + When it is currently 1 second from now + And I follow "Co-Creator Requests page" + And I check "selected[]" + And I press "Accept" + Then I should see "You are now listed as a co-creator on Chaptered Work." + When I follow "Chaptered Work" + Then I should see "Edit" + And I should see "barbaz, foobar" + And I should not see "Chapter by" + When I follow "Next Chapter" + Then I should see "barbaz, foobar" + And I should not see "Chapter by" + + Scenario: You cannot create a work with too many tags + Given the user-defined tag limit is 7 + And I am logged in as a random user + When I set up the draft "Over the Limit" + 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 fill in "Additional Tags" with "Additional Tag 1, Additional Tag 2" + And I press "Post" + Then I should see "Fandom, relationship, character, and additional tags must not add up to more than 7. Your work has 8 of these tags, so you must remove 1 of them." + + @javascript + Scenario: "Please wait..." message disappears when validation errors are fixed + Given basic tags + And I am logged in as "test_user" + When I go to the new work page + And I fill in "Work Title" with "Unicorns Abound" + And I select "English" from "Choose a language" + And I fill in "Fandoms" with "Dallas" + And I press "Post" + Then I should see "Brevity is the soul of wit, but your content does have to be at least 10 characters long." + And I should see a button with text "Please wait..." + When I fill in "content" with "help there are unicorns everywhere" + Then I should see a button with text "Post" diff --git a/features/works/work_dates_edit.feature b/features/works/work_dates_edit.feature new file mode 100644 index 0000000..8619c1d --- /dev/null +++ b/features/works/work_dates_edit.feature @@ -0,0 +1,85 @@ +@works +Feature: Edit Works Dates + In order to have an archive full of works + As an author + I want to edit existing works + + Scenario: Editing dates on a work + When "AO3-2539" is fixed +# Given I have loaded the fixtures +# And I am logged in as "testuser" with password "testuser" +# And all indexing jobs have been run +# When I am on testuser's works page +# Then I should not see "less than 1 minute ago" +# And I should see "29 Apr 2012" +# When I follow "First work" +# Then I should see "first fandom" +# And I should see "Edit" + + # Editing a work doesn't change the published date +# When I follow "Edit" +# Then I should see "Edit Work" +# When I fill in "content" with "first chapter content" +# And I check "chapters-options-show" +# And I fill in "work_wip_length" with "3" +# And I press "Preview" +# Then I should see "Preview" +# And I should see "Fandom: first fandom" +# And I should see "first chapter content" +# And I should see "Published:2010-04-30" +# When I update the work +# Then I should see "Work was successfully updated." +# And I should see "Published:2010-04-30" +# And I should not see Updated today + + # Adding a chapter doesn't change the published date, but adds "Updated today" +# When I follow "Add Chapter" +# And I fill in "content" with "this is my second chapter" +# And I press "Preview" +# Then I should see "This is a draft chapter in a posted work. It will be kept unless the work is deleted." +# When I press "Post" +# Then I should see "Chapter was successfully posted." +# And I should see "Published:2010-04-30" +# And I should see Updated today +# When I am on testuser's works page +# Then I should see "less than 1 minute ago" +# And I should not see "29 Apr 2010" + + # Backdating the first chapter (the Work) changes published date but not the updated date +# When I edit the work "First work" +# And I check "backdate-options-show" +# When I select "1" from "work_chapter_attributes_published_at_3i" +# And I select "January" from "work_chapter_attributes_published_at_2i" +# And I select "1990" from "work_chapter_attributes_published_at_1i" +# And I press "Preview" +# And I press "Update" +# Then I should see "Published:1990-01-01" +# And I should see "first chapter content" +# And I should not see "this is my second chapter" +# And I should see Updated today + + # The entire work is backdated. Now, I want to edit chapter two to have a "Chapter Publication + # Date" date set to January 16th, 2013. This should not affect the work's published date, but + # the work's updated date should change to match the most recent chapter pub date +# When I follow "Next Chapter" +# And I follow "Edit Chapter" +# And I select "16" from "chapter_published_at_3i" +# And I select "January" from "chapter_published_at_2i" +# And I select "2013" from "chapter_published_at_1i" +# And I press "Preview" +# And I press "Update" +# Then I should see "Updated:2013-01-16" +# And I should see "Published:1990-01-01" +# When I follow "Full-page index" +# Then I should see "1. Chapter 1 (1990-01-01)" +# And I should see "2. Chapter 2 (2013-01-16)" + + Scenario: Users cannot backdate a work back to the future + Given it is currently 1/1/2019 + And I am logged in as a random user + And I post the work "Beauty and the Beast 2077" + When I edit the work "Beauty and the Beast 2077" + And I check "Set a different publication date" + And I select "December" from "work_chapter_attributes_published_at_2i" + And I press "Post" + Then I should see "Sorry! We couldn't save this work because: Publication date can't be in the future." diff --git a/features/works/work_delete.feature b/features/works/work_delete.feature new file mode 100755 index 0000000..cc0e71f --- /dev/null +++ b/features/works/work_delete.feature @@ -0,0 +1,214 @@ +@works @tags +Feature: Delete Works + Check that everything disappears correctly when deleting a work + + Scenario: Deleting a minimally valid work + Given I am logged in as "newbie" + And I post the work "All Hell Breaks Loose" + When I delete the work "All Hell Breaks Loose" + Then I should see "Your work All Hell Breaks Loose was deleted." + And "newbie" should be notified by email about the deletion of "All Hell Breaks Loose" + When I go to the works page + Then I should not see "All Hell Breaks Loose" + When I go to newbie's user page + Then I should not see "All Hell Breaks Loose" + + Scenario: Deleting a work with escapable characters in title + Given I am logged in as "newbie" + And I post the work "All Hell Breaks Loose" + When I delete the work "All Hell Breaks Loose" + Then I should see "Your work All Hell Breaks Loose was deleted." + And "newbie" should be notified by email about the deletion of "All Hell <b>Breaks</b> Loose" + When I go to the works page + Then I should not see "All Hell Breaks Loose" + When I go to newbie's user page + Then I should not see "All Hell Breaks Loose" + + Scenario: Deleting minimally valid work when you have more than one pseud + Given basic tags + And I am logged in as "newbie" + And "newbie" creates the default pseud "Pointless Pseud" + When I set up the draft "All Hell Breaks Loose" with fandom "Supernatural" + And I select "Pointless Pseud" from "Creator/Pseud(s)" + And I press "Preview" + And I press "Post" + Then I should see "Work was successfully posted." + When I go to the works page + Then I should see "All Hell Breaks Loose" + When I delete the work "All Hell Breaks Loose" + Then I should see "Your work All Hell Breaks Loose was deleted." + And 1 email should be delivered + And the email should not contain "translation missing" + When I go to the works page + Then I should not see "All Hell Breaks Loose" + When I go to newbie's user page + Then I should not see "All Hell Breaks Loose" + + @javascript + Scenario: Deleting a work with everything filled in, and we do mean everything + Given basic tags + And the following activated users exist + | login | email | + | coauthor | coauthor@example.org | + | cosomeone | cosomeone@example.org | + | giftee | giftee@example.org | + | recipient | recipient@example.org | + And the user "coauthor" allows co-creators + And the user "cosomeone" allows co-creators + And the user "giftee" allows gifts + And the user "recipient" allows gifts + And I have a collection "Collection 1" with name "collection1" + And I have a collection "Collection 2" with name "collection2" + And "thorough" has the pseud "Pseud2" + And "thorough" has the pseud "Pseud3" + And I am logged in as "thorough" + When I go to the new work page + And all emails have been delivered + And I select "Not Rated" from "Rating" + And I check "No Archive Warnings Apply" + And I select "English" from "Choose a language" + And I check "F/M" + And I fill in "Fandoms" with "Supernatural" + And I fill in "Work Title" with "All Something Breaks Loose" + And I fill in "content" with "Bad things happen, etc." + And I check "at the beginning" + And I fill in "Notes" with "This is my beginning note" + And I fill in "End Notes" with "This is my endingnote" + And I fill in "Summary" with "Have a short summary" + And I fill in "Characters" with "Sam Winchester, Dean Winchester," + And I fill in "Relationships" with "Harry/Ginny" + And I fill in "Gift this work to" with "Someone else, recipient" + And I check "This work is part of a series" + And I fill in "Or create and use a new one:" with "My new series" + And I select "Pseud2" from "Creator/Pseud(s)" + And I select "Pseud3" from "Creator/Pseud(s)" + And I fill in "pseud_byline_autocomplete" with "coauthor" + And I fill in "Post to Collections / Challenges" with "collection1, collection2" + And I press "Preview" + Then I should see "Preview" + When I press "Post" + Then I should see "Work was successfully posted." + And 1 email should be delivered to "coauthor@example.org" + And the email should contain "The user thorough has invited your pseud coauthor to be listed as a co-creator" + And 1 email should be delivered to "recipient@example.org" + And the email should contain "A gift work has been posted for you" + When I go to the works page + Then I should see "All Something Breaks Loose" + When I follow "All Something Breaks Loose" + Then I should see "All Something Breaks Loose" + And I should see "Fandom: Supernatural" + And I should see "Rating: Not Rated" + And I should see "No Archive Warnings Apply" + And I should not see "Choose Not To Use Archive Warnings" + And I should see "Category: F/M" + And I should see "Characters: Sam Winchester Dean Winchester" + And I should see "Relationship: Harry/Ginny" + And I should see "For Someone else, recipient" + And I should see "Collections: Collection 1, Collection 2" + And I should see "Notes" + And I should see "This is my beginning note" + And I should see "See the end of the work for more notes" + And I should see "This is my endingnote" + And I should see "Summary" + And I should see "Have a short summary" + And I should see "My new series" + And I should see "Bad things happen, etc." + And I should see "Pseud2" within ".byline" + And I should see "Pseud3" within ".byline" + But I should not see "coauthor" within ".byline" + When the user "coauthor" accepts all co-creator requests + And I view the work "All Something Breaks Loose" + Then I should see "coauthor" within ".byline" + When I follow "Add Chapter" + And I fill in "Chapter Title" with "This is my second chapter" + And I fill in "content" with "Let's write another story" + And I press "Preview" + Then I should see "Chapter 2: This is my second chapter" + And I should see "Let's write another story" + When I press "Post" + Then I should see "All Something Breaks Loose" + And I should not see "Bad things happen, etc." + And I should see "Let's write another story" + When I follow "Previous Chapter" + Then I should see "Bad things happen, etc." + And I should not see "Let's write another story" + When I follow "Entire Work" + Then I should see "Bad things happen, etc." + And I should see "Let's write another story" + When I follow "Edit" + And I check "Add co-creators?" + And I fill in "pseud_byline_autocomplete" with "Does_not_exist" + And I press "Preview" + Then I should see "Invalid creator: Could not find a pseud Does_not_exist." + When all emails have been delivered + And I choose "cosomeone" from the "pseud_byline_autocomplete" autocomplete + And I press "Preview" + And I press "Update" + Then I should see "Work was successfully updated" + And I should see "coauthor" within ".byline" + And I should see "Pseud2" within ".byline" + And I should see "Pseud3" within ".byline" + But I should not see "cosomeone" within ".byline" + And 1 email should be delivered to "cosomeone@example.org" + When the user "cosomeone" accepts all co-creator requests + And I view the work "All Something Breaks Loose" + Then I should see "cosomeone" within ".byline" + When all emails have been delivered + And I am logged in as "someone_else" + And I view the work "All Something Breaks Loose" + And I press "Kudos" + Then I should see "someone_else left kudos on this work!" + When I follow "Bookmark" + And I fill in "Notes" with "My thoughts on the work" + And I press "Create" + Then I should see "Bookmark was successfully created" + When all indexing jobs have been run + And I go to the bookmarks page + Then I should see "All Something Breaks Loose" + When I am logged in as "thorough" + And I go to recipient's user page + Then I should see "Gifts (1)" + When I delete the work "All Something Breaks Loose" + And all indexing jobs have been run + Then I should see "Your work All Something Breaks Loose was deleted." + When I go to recipient's user page + Then I should see "Gifts (0)" + And I should not see "All Something Breaks Loose" + When I go to cosomeone's user page + Then I should not see "All Something Breaks Loose" + When I go to thorough's user page + Then I should not see "All Something Breaks Loose" + # This is correct behaviour - bookmark details are preserved even though the work is gone + When all indexing jobs have been run + And I go to the bookmarks page + Then I should not see "All Something Breaks Loose" + When I go to someone_else's bookmarks page + Then I should not see "All Something Breaks Loose" + And I should see "This has been deleted, sorry!" + And I should see "My thoughts on the work" + + Scenario: A work with too many tags can be deleted + Given the user-defined tag limit is 2 + And the work "Over the Limit" + And the work "Over the Limit" has 3 fandom tags + When I am logged in as the author of "Over the Limit" + And I delete the work "Over the Limit" + Then I should see "Your work Over the Limit was deleted." + + Scenario: Deleting a work sends translated deletion notification emails + Given a locale with translated emails + And the user "owner" exists and is activated + And the user "owner" enables translated emails + And the user "someone_else" exists and is activated + And the user "someone_else" enables translated emails + And the work "Many" by "owner", "someone_else" and "off" + And I am logged in as "owner" + When I delete the work "Many" + Then I should see "Your work Many was deleted." + And 3 emails should be delivered + And the email to "owner" should contain "was deleted at your request" + And the email to "owner" should be translated + And the email to "someone_else" should contain "was deleted at the request of" + And the email to "someone_else" should be translated + And the email to "off" should contain "was deleted at the request of" + And the email to "off" should be non-translated diff --git a/features/works/work_download.feature b/features/works/work_download.feature new file mode 100644 index 0000000..f416dab --- /dev/null +++ b/features/works/work_download.feature @@ -0,0 +1,422 @@ +@works +Feature: Download a work + + Scenario: Download a work in various formats + + Given I am logged in as "myname" + And I post the work "Tittle with doubble letters" + Then I should be able to download all versions of "Tittle with doubble letters" + + + Scenario: Download works with double quotes in title + + Given I am logged in as "myname" + And I set up the draft "Foo" + And I fill in "Work Title" with + """ + "Has double quotes" + """ + And I fill in "content" with "some random stuff" + When I press "Preview" + And I press "Post" + And I follow "PDF" + Then I should receive a file of type "pdf" + When I go to the work page with title "Has double quotes" + And I follow "MOBI" + Then I should receive a file of type "mobi" + When I go to the work page with title "Has double quotes" + And I follow "EPUB" + Then I should receive a file of type "epub" + When I go to the work page with title "Has double quotes" + And I follow "AZW3" + Then I should receive a file of type "azw3" + When I go to the work page with title "Has double quotes" + And I follow "HTML" + Then I should receive a file of type "html" + And the page title should include '"Has double quotes"' + + + Scenario: Download works with non-ASCII characters in title + + Given I am logged in as "myname" + When I post the work "Первый_маг" + Then I should be able to download all versions of "Первый_маг" + When I post the work "Hàs curly’d quotes" + Then I should be able to download all versions of "Hàs curly’d quotes" + When I post the work "♥ é Türkçe Karakterler başlıkta nasıl görünüyor" + Then I should be able to download all versions of "♥ é Türkçe Karakterler başlıkta nasıl görünüyor" + When I post the work "à ø something" + Then I should be able to download all versions of "à ø something" + When I post the work "流亡在阿尔比恩" + Then I should be able to download all versions of "流亡在阿尔比恩" + When I post the work "-dash in title-" + Then I should be able to download all versions of "-dash in title-" + When I post the work "Emjoi 🤩 Yay 🥳" + Then I should be able to download all versions of "Emjoi 🤩 Yay 🥳" + + + Scenario: Downloaded work header contains expected meta fields in expected order + + Given basic tags + And I have a collection "My Collection 1" with name "mycollection1" + And I have a collection "My Collection 2" with name "mycollection2" + And I am logged in + And I go to the new work page + And I select "General" from "Rating" + And I check "No Archive Warnings Apply" + And I check "Gen" + And I fill in "Fandoms" with "Cool Fandom" + And I fill in "Characters" with "Character 1, Character 2, Character 3" + And I fill in "Relationships" with "Character 1/Character 2, Character 1 & Character 3" + And I fill in "Additional Tags" with "Modern AU" + And I set the publication date to 10 January 2015 + And I check "This work is part of a series" + And I fill in "Or create and use a new one:" with "THE DOWN" + And I fill in "Post to Collections / Challenges" with "mycollection1, mycollection2" + And I fill in "Work Title" with "Downloadable" + And I fill in "content" with "Could be downloaded" + And I select "English" from "Choose a language" + And I press "Post" + And I follow "Add Chapter" + And I fill in "content" with "Remember, remember the 5th of November" + And I set the publication date to 5 November 2020 + And I press "Post" + When I view the work "Downloadable" + And I follow "HTML" + Then I should see "Downloadable" + And I should see "Rating: General Audiences" + And I should see "Archive Warning: No Archive Warnings Apply" + And I should see "Category: Gen" + And I should see "Fandom: Cool Fandom" + And I should see "Relationships: Character 1/Character 2, Character 1 & Character 3" + And I should see "Characters: Character 1, Character 2, Character 3" + And I should see "Additional Tags: Modern AU" + And I should see "Language: English" + And I should see "Series: Part 1 of THE DOWN" + And I should see "Collections: My Collection 1, My Collection 2" + And I should see "Published: 2015-01-10" + And I should see "Completed: 2020-11-05" + And I should see "Words: 9" + And I should see "Chapters: 2/2" + And "Rating:" should appear before "Archive Warning" + And "Archive Warning:" should appear before "Category" + And "Category:" should appear before "Fandom" + And "Fandom:" should appear before "Relationship" + And "Relationships:" should appear before "Character" + And "Characters:" should appear before "Additional Tags" + And "Additional Tags:" should appear before "Language" + And "Language:" should appear before "Series" + And "Series:" should appear before "Collections" + And "Collections:" should appear before "Published" + And "Published:" should appear before "Completed" + And "Completed:" should appear before "Chapters" + And "Words:" should appear before "Chapters:" + And "Chapters:" should appear before "Could be downloaded" + + Scenario: Downloaded work afterword does not mention author + + Given the work "Downloadable" + When I view the work "Downloadable" + And I follow "HTML" + Then I should not see "to let the author know if you enjoyed" + But I should see "to let the creator know if you enjoyed" + + Scenario: Download of chaptered works includes chapters + + Given the chaptered work "Bazinga" + When I view the work "Bazinga" + And I follow "HTML" + Then I should see "Chapter 2" + + Scenario: Download of chaptered work without posted chapters does not include chapters + + Given the work "Bazinga" + And a draft chapter is added to "Bazinga" + And I delete chapter 1 of "Bazinga" + When I view the work "Bazinga" + And I follow "HTML" + Then I should not see "Chapter 1" + And I should not see "Chapter 2" + And I should be able to download all versions of "Bazinga" + + Scenario: Download chaptered works + + Given I am logged in as "author" + When I post the chaptered work "Epic Novel" + Then I should be able to download all versions of "Epic Novel" + + + Scenario: Works can be downloaded when anonymous + + Given there is a work "Test Work" in an anonymous collection "Anonymous" + When I am a visitor + And I view the work "Test Work" + And I follow "HTML" + Then I should see "Anonymous" + And I should be able to download all versions of "Test Work" + + + Scenario: Multifandom works can be downloaded + + Given I am logged in + And I set up the draft "Many Fandom Work" + And I fill in "Fandoms" with "Fandom 1, Fandom 2, Fandom 3, Fandom 4" + And I press "Post" + When I log out + And I view the work "Many Fandom Work" + And I follow "HTML" + Then the page title should include "Multifandom" + And I should be able to download all versions of "Many Fandom Work" + + + Scenario: Download work shows inspiring work link + + Given I have related works setup + When I post a related work as remixer + And I view the work "Followup" + And I follow "HTML" + Then I should see the inspiring parent work link + + Scenario: Download work shows inspiring external inspiring work link + + Given I have related works setup + When I post a related work as remixer for an external work + And I view the work "Followup" + And I follow "HTML" + Then I should see the external inspiring work link + + Scenario: Work and chapter with notes and end notes show with "more" in the link to end notes. + + Given I am logged in + And I set up the draft "got notes?" + And I check "at the beginning" + And I fill in "Notes" with "READ THE TAGS FIRST" + And I check "at the end" + And I fill in "End Notes" with "That's all, folks!" + And I fill in "content" with "Could be downloaded" + And I press "Post" + And I follow "Add Chapter" + And I fill in "content" with "Remember, remember the 5th of November" + And I check "at the beginning" + And I fill in "Notes" with "hey guys its been a while 🙃" + And I check "at the end" + And I fill in "End Notes" with "Next update soon!!!" + And I press "Post" + When I view the work "got notes?" + And I follow "HTML" + Then I should see "Notes" + And I should see "READ THE TAGS FIRST" + And I should see "See the end of the work for more notes" + And I should not see "See the end of the work for notes" + And I should see "End Notes" + And I should see "That's all, folks!" + And I should see "Chapter Notes" + And I should see "hey guys its been a while 🙃" + And I should see "See the end of the chapter for more notes" + And I should not see "See the end of the chapter for notes" + And I should see "Chapter End Notes" + And I should see "Next update soon!!!" + + Scenario: Work and chapter with only end notes show without "more" in the link to end notes. + + Given I am logged in + And I set up the draft "got notes?" + And I check "at the end" + And I fill in "End Notes" with "That's all, folks!" + And I fill in "content" with "Could be downloaded" + And I press "Post" + And I follow "Add Chapter" + And I fill in "content" with "Remember, remember the 5th of November" + And I check "at the end" + And I fill in "End Notes" with "Next update soon!!!" + And I press "Post" + When I view the work "got notes?" + And I follow "HTML" + Then I should see "Notes" + And I should not see "See the end of the work for more notes" + And I should see "See the end of the work for notes" + And I should see "End Notes" + And I should see "That's all, folks!" + And I should see "Chapter Notes" + And I should not see "See the end of the chapter for more notes" + And I should see "See the end of the chapter for notes" + And I should see "Chapter End Notes" + And I should see "Next update soon!!!" + + Scenario: Work and chapter with only notes show without the link to end notes. + + Given I am logged in + And I set up the draft "got notes?" + And I check "at the beginning" + And I fill in "Notes" with "READ THE TAGS FIRST" + And I fill in "content" with "Could be downloaded" + And I press "Post" + And I follow "Add Chapter" + And I fill in "content" with "Remember, remember the 5th of November" + And I check "at the beginning" + And I fill in "Notes" with "hey guys its been a while 🙃" + And I press "Post" + When I view the work "got notes?" + And I follow "HTML" + Then I should see "Notes" + And I should see "READ THE TAGS FIRST" + And I should not see "See the end of the work for " + And I should not see "End Notes" + And I should see "Chapter Notes" + And I should see "hey guys its been a while 🙃" + And I should not see "See the end of the chapter for " + And I should not see "Chapter End Notes" + + Scenario: Work and chapter with no notes and no end notes show without the link to end notes or empty sections. + + Given I am logged in + And I set up the draft "got notes?" + And I fill in "content" with "Could be downloaded" + And I press "Post" + And I follow "Add Chapter" + And I fill in "content" with "Remember, remember the 5th of November" + And I press "Post" + When I view the work "got notes?" + And I follow "HTML" + Then I should not see "Notes" + And I should not see "See the end of the work for " + And I should not see "End Notes" + And I should not see "Chapter Notes" + And I should not see "See the end of the chapter for " + And I should not see "Chapter End Notes" + + Scenario: Download option is unavailable if work is unrevealed. + + Given there is a work "Blabla" in an unrevealed collection "Unrevealed" + And I am logged in as the author of "Blabla" + Then I should not see "Download" + + + Scenario: Download option is unavailable if work is unposted. + + Given I am logged in + And the draft "Unposted Work" + When I view the work "Unposted Work" + Then I should not see "Download" + + + Scenario: Download option is unavailable if work is hidden by admin. + + Given I am logged in + And I post the work "TOS Violation" + When I am logged in as a "policy_and_abuse" admin + And I hide the work "TOS Violation" + Then I should not see "Download" + + Scenario: Downloads of related work update when parent work's anonymity changes. + + Given a hidden collection "Hidden" + And I have related works setup + And I post a related work as remixer + And I post a translation as translator + And I log out + When I view the work "Followup" + And I follow "HTML" + Then I should see "Worldbuilding by inspiration" + When I view the work "Worldbuilding Translated" + And I follow "HTML" + Then I should see "Worldbuilding by inspiration" + # Going from revealed to unrevealed + When I am logged in as "inspiration" + And I edit the work "Worldbuilding" to be in the collection "Hidden" + And I log out + And I view the work "Followup" + And I follow "HTML" + Then I should not see "inspiration" + And I should see "Inspired by a work in an unrevealed collection" + When I view the work "Worldbuilding Translated" + And I follow "HTML" + Then I should not see "inspiration" + And I should see "A translation of a work in an unrevealed collection" + # Going from unrevealed to revealed + When I reveal works for "Hidden" + And I log out + And I view the work "Followup" + And I follow "HTML" + Then I should see "Worldbuilding by inspiration" + When I view the work "Worldbuilding Translated" + And I follow "HTML" + Then I should see "Worldbuilding by inspiration" + + Scenario: Downloads of related work update when child work's anonymity changes. + + Given a hidden collection "Hidden" + And I have related works setup + And a related work has been posted and approved + When I view the work "Worldbuilding" + And I follow "HTML" + Then I should see "Followup by remixer" + And I should not see "A work in an unrevealed collection" + # Going from revealed to unrevealed + When I am logged in as "remixer" + And I edit the work "Followup" to be in the collection "Hidden" + And I view the work "Worldbuilding" + And I follow "HTML" + Then I should not see "Followup by remixer" + And I should see "A work in an unrevealed collection" + # Going from unrevealed to revealed + When I reveal works for "Hidden" + And I log out + And I view the work "Worldbuilding" + And I follow "HTML" + Then I should see "Followup by remixer" + And I should not see "A work in an unrevealed collection" + + Scenario: Downloads hide titles of restricted related works + + Given I have related works setup + And a related work has been posted and approved + And I am logged in as "remixer" + And I lock the work "Followup" + When I am logged out + And I view the work "Worldbuilding" + And I follow "HTML" + Then I should see "[Restricted Work] by remixer" + When I am logged in as "inspiration" + And I lock the work "Worldbuilding" + And I am logged in as "remixer" + And I unlock the work "Followup" + And I am logged out + And I view the work "Followup" + And I follow "HTML" + Then I should see "Inspired by [Restricted Work] by inspiration" + + Scenario: Downloads of translated work update when translation's revealed status changes. + + Given a hidden collection "Hidden" + And I have related works setup + And a translation has been posted and approved + And I log out + When I view the work "Worldbuilding" + And I follow "HTML" + Then I should see "Worldbuilding Translated by translator" + # Going from revealed to unrevealed + When I am logged in as "translator" + And I edit the work "Worldbuilding Translated" to be in the collection "Hidden" + And I log out + And I view the work "Worldbuilding" + And I follow "HTML" + Then I should not see "Worldbuilding Translated by translator" + And I should see "A work in an unrevealed collection" + # Going from unrevealed to revealed + When I reveal works for "Hidden" + And I log out + And I view the work "Worldbuilding" + And I follow "HTML" + Then I should see "Worldbuilding Translated by translator" + + Scenario: Downloads hide titles of restricted work translations + + Given I have related works setup + And a translation has been posted and approved + And I am logged in as "translator" + And I lock the work "Worldbuilding Translated" + When I am logged out + And I view the work "Worldbuilding" + And I follow "HTML" + Then I should see "[Restricted Work] by translator" diff --git a/features/works/work_drafts.feature b/features/works/work_drafts.feature new file mode 100644 index 0000000..27b8267 --- /dev/null +++ b/features/works/work_drafts.feature @@ -0,0 +1,189 @@ +@works @search +Feature: Work Drafts + + Scenario: Creating a work draft + Given I am logged in as "Scott" with password "password" + When the draft "scotts draft" + And I press "Cancel" + Then I should see "The work was not posted. It will be saved here in your drafts for one month, then deleted from the Archive." + + Scenario: Creating a work draft, editing it, and saving the changes without posting or previewing and then double check that it is saved and I didn't get the success message erroneously + Given basic tags + And I am logged in as "persnickety" with password "editingisfun" + When I go to the new work page + Then I should see "Post New Work" + And I select "General Audiences" from "Rating" + And I check "No Archive Warnings Apply" + And I select "English" from "Choose a language" + And I fill in "Fandoms" with "MASH (TV)" + And I fill in "Work Title" with "Draft Dodging" + And I fill in "content" with "Klinger lay under his porch." + And I press "Preview" + Then I should see "Draft was successfully created. It will be scheduled for deletion on" + When I press "Edit" + Then I should see "Edit Work" + And I fill in "content" with "Klinger, in Uncle Gus's Aunt Gussie dress, lay under his porch." + And I press "Save As Draft" + Then I should see "This work is a draft and has not been posted." + And I should see "Klinger, in Uncle Gus's Aunt Gussie dress, lay under his porch." + When I am on persnickety's works page + Then I should not see "Draft Dodging" + And I should see "Drafts (1)" + When I follow "Drafts (1)" + Then I should see "Draft Dodging" + When I follow "Draft Dodging" + Then I should see "Klinger, in Uncle Gus's Aunt Gussie dress, lay under his porch." + + Scenario: Creating an draft Chapter on a draft Work + Given I am logged in as "Scott" with password "password" + And the draft "scotts other draft" + And I press "Cancel" + And I edit the work "scotts other draft" + And I follow "Add Chapter" + And I fill in "content" with "this is second chapter content" + And I press "Preview" + Then I should see "This is a draft chapter in an unposted work. The work will be scheduled for deletion on" + + Scenario: Purging old drafts + Given I am logged in as "drafter" with password "something" + When the work "old draft work" was created 31 days ago + And the work "new draft work" was created 2 days ago + When I am on drafter's works page + Then I should see "Drafts (2)" + When the purge_old_drafts rake task is run + And I reload the page + Then I should see "Drafts (1)" + + Scenario: Drafts cannot be found by search + Given I am logged in as "drafter" with password "something" + And the draft "draft to post" + Given all indexing jobs have been run + When I fill in "site_search" with "draft" + And I press "Search" + Then I should see "No results found" + + Scenario: Posting drafts from drafts page + Given I am logged in as "drafter" with password "something" + And the draft "draft to post" + When I am on drafter's works page + Then I should see "Drafts (1)" + When I follow "Drafts (1)" + Then I should see "draft to post" + And the page title should include "drafter - Drafts" + And I should see "Post Draft" within "#main .own.work.blurb .actions" + And I should see "Delete Draft" within "#main .own.work.blurb .actions" + When I follow "Post Draft" + Then I should see "draft to post" + And I should see "drafter" + And I should not see "Preview" + + Scenario: Deleting drafts from drafts page + Given I am logged in as "drafter" with password "something" + And the draft "draft to delete" + When I am on drafter's works page + Then I should see "Drafts (1)" + When I follow "Drafts (1)" + Then I should see "draft to delete" + And I should see "Post Draft" within "#main .own.work.blurb .actions" + And I should see "Delete Draft" within "#main .own.work.blurb .actions" + When I follow "Delete Draft" + Then I should not see "All bookmarks, comments, and kudos will be lost." + And I should not see "Orphan Work Instead" + When I press "Yes, Delete Draft" + Then I should see "Your work draft to delete was deleted" + + Scenario: Saving changes to an existing draft without posting and then double check that it is saved and I didn't get the success message erroneously + Given I am logged in as "drafty" with password "breezeinhere" + And the draft "Windbag" + When I am on drafty's works page + Then I should see "Drafts (1)" + When I follow "Drafts (1)" + Then I should see "Windbag" + And I should see "Edit" within "#main .own.work.blurb .actions" + When I follow "Edit" + Then I should see "Edit Work" + When I fill in "content" with "My draft has changed!" + And I press "Save As Draft" + Then I should see "This work is a draft and has not been posted" + And I should see "My draft has changed!" + When I am on drafty's works page + Then I should see "Drafts (1)" + When I follow "Drafts (1)" + Then I should see "Windbag" + When I follow "Windbag" + Then I should see "My draft has changed!" + + Scenario: Editing a draft and previewing it should warn that it has not been saved. + Given I am logged in as "ringadingding" + And the draft "Walking Into Mordor" + When I edit the draft "Walking Into Mordor" + And I press "Preview" + Then I should see "Please post your work or save as draft if you want to keep them." + + Scenario: A chaptered draft should be able to have beginning and end notes, and it should display them. + Given I am logged in as "composer" + And I post the chaptered draft "Epic in Progress" + When I edit the draft "Epic in Progress" + And I add the beginning notes "Some beginning notes." + And I add the end notes "Some end notes." + And I press "Save As Draft" + Then I should see "Some beginning notes." + And I should see "See the end of the work for more notes." + When I follow "more notes" + Then I should see "Some end notes." + + Scenario: If a chaptered draft belongs to a series, the series should be listed on the draft + Given I am logged in as "two_can_sam" + And I post the chaptered draft "Cereal Serial" + When I add the draft "Cereal Serial" to series "Aisle 5" + And I follow "Next Chapter" + Then I should see "Series this work belongs to:" + And I should see "Aisle 5" + + Scenario: Word count should appear after creating single chapter work, saving as draft, and posting draft from user's drafts page + Given I am logged in as "test_user" + And I set up the draft "Unicorns are everywhere" + And I fill in "content" with "Help there are unicorns everywhere" + And I press "Preview" + And I press "Save As Draft" + When I follow "My Dashboard" + And I follow "Drafts (" + And I follow "Post Draft" + Then I should be on the work "Unicorns are everywhere" + And I should see "Words:5" + + Scenario: Word count should equal all draft chapters' word counts if work isn't posted + Given I am logged in as "test_user" + And I set up the draft "Unicorns are everywhere" + And I fill in "content" with "Help there are unicorns everywhere" + And I press "Preview" + And I press "Save As Draft" + When a chapter is set up for "Unicorns are everywhere" + And I press "Preview" + And I press "Save As Draft" + Then I should see "Words:16" + When a chapter is set up for "Unicorns are everywhere" + And I press "Preview" + When I press "Save As Draft" + Then I should see "Words:27" + + Scenario: When posting chapter(s) in unpublished multichapter work, word count should equal posted chapter(s) word count + Given I am logged in as "test_user" + And I set up the draft "Unicorns are everywhere" + And I fill in "content" with "Help there are unicorns everywhere" + And I press "Preview" + And I press "Save As Draft" + When a chapter is set up for "Unicorns are everywhere" + And I press "Preview" + And I press "Save As Draft" + Then I should see "Words:16" + When a chapter is set up for "Unicorns are everywhere" + And I press "Preview" + And I press "Save As Draft" + Then I should see "Words:27" + When I view the work "Unicorns are everywhere" + And I press "Post Chapter" + Then I should see "Words:5" + When I follow "Next Chapter" + And I press "Post Chapter" + Then I should see "Words:16" diff --git a/features/works/work_edit.feature b/features/works/work_edit.feature new file mode 100644 index 0000000..0c7f30b --- /dev/null +++ b/features/works/work_edit.feature @@ -0,0 +1,320 @@ +@works @tags +Feature: Edit Works + In order to have an archive full of works + As an author + I want to edit existing works + + Scenario: You can't edit a work unless you're logged in and it's your work + Given the work "First work" by "testuser" with fandom "first fandom" + And "testuser" has the pseud "testy" + And the work "fourth" by "testuser2" + # I'm not logged in + When I view the work "First work" + Then I should not see "Edit" + Given I am logged in as "testuser" + And all indexing jobs have been run + # This isn't my work + When I view the work "fourth" + Then I should not see "Edit" + When I am on testuser's works page + # These are my works and should all have edit links on the blurbs + Then I should see "Edit" + When I follow "First work" + # This is my individual work and should have an edit link on the show page + Then I should see "first fandom" + And I should see "Edit" + # make sure this tag isn't on before we add it + And I should not see "new tag" + When I follow "Edit" + Then I should see "Edit Work" + When I fill in "work_freeform" with "new tag" + And I fill in "content" with "first chapter content" + And I press "Preview" + Then I should see "Preview" + And I should see "Fandom: first fandom" + And I should see "Additional Tags: new tag" + And I should see "first chapter content" + And I should see "Words:3" + When I press "Update" + Then I should see "Work was successfully updated." + And I should see "Additional Tags: new tag" + And I should see "Words:3" + When all indexing jobs have been run + And I go to testuser's works page + Then I should see "First work" + And I should see "first fandom" + And I should see "new tag" + When I edit the work "First work" + And I follow "Add Chapter" + And I fill in "content" with "second chapter content" + And I press "Preview" + Then I should see "This is a draft chapter in a posted work. It will be kept unless the work is deleted." + And I should see "second chapter content" + When I press "Post" + Then I should see "Chapter was successfully posted." + And I should not see "first chapter content" + And I should see "second chapter content" + And I should see "Words:6" + When I edit the work "First work" + Then I should not see "chapter content" + When I follow "1" + And I fill in "content" with "first chapter new content" + And I press "Preview" + Then I should see "first chapter new content" + When I press "Update" + Then I should see "Chapter was successfully updated." + And I should see "first chapter new content" + And I should not see "second chapter content" + And I should see "Words:7" + When I edit the work "First Work" + And I follow "2" + And I fill in "content" with "second chapter new content" + And I press "Preview" + And I press "Cancel" + Then I should see "second chapter content" + And I should see "Words:7" + # Test changing pseuds on a work + When I go to testuser's works page + And I follow "Edit" + And I select "testy" from "work_author_attributes_ids" + And I unselect "testuser" from "work_author_attributes_ids" + # Expire byline cache + And it is currently 1 second from now + And I press "Post" + Then I should see "testy" + And I should not see "testuser," + + Scenario: Editing a work in a moderated collection + # TODO: Find a way to appove works without using this hack method I have here + Given the following activated users exist + | login | password | + | Scott | password | + And I have a moderated collection "Digital Hoarders 2013" with name "digital_hoarders_2013" + When I am logged in as "Scott" with password "password" + And I post the work "Murder in Milan" in the collection "Digital Hoarders 2013" + Then I should see "You have submitted your work to the moderated collection 'Digital Hoarders 2013'. It will not become a part of the collection until it has been approved by a moderator." + When I am logged in as "moderator" + And I go to "Digital Hoarders 2013" collection's page + And I follow "Collection Settings" + And I uncheck "This collection is moderated" + And I press "Update" + Then I should see "Collection was successfully updated" + When I am logged in as "Scott" + And I post the work "Murder by Numbers" in the collection "Digital Hoarders 2013" + Then I should see "Work was successfully posted" + When I am logged in as "moderator" + And I go to "Digital Hoarders 2013" collection's page + And I follow "Collection Settings" + And I check "This collection is moderated" + And I press "Update" + Then I should see "Collection was successfully updated" + When I am logged in as "Scott" + And I edit the work "Murder by Numbers" + And I press "Post" + And I should see "Work was successfully updated" + Then I should not see "You have submitted your work to the moderated collection 'Digital Hoarders 2013'. It will not become a part of the collection until it has been approved by a moderator." + + Scenario: Previewing edits to a posted work should not refer to the work as a draft + Given I am logged in as "editor" + And I post the work "Load of Typos" + When I edit the work "Load of Typos" + And I press "Preview" + Then I should not see "draft" + + Scenario: You can invite a co-author to an already-posted work + Given I am logged in as "leadauthor" + And the user "coauthor" exists and is activated + And the user "coauthor" allows co-creators + And I post the work "Dialogue" + When I follow "Edit" + And I invite the co-author "coauthor" + And I press "Post" + Then I should see "Work was successfully updated" + And I should not see "coauthor" within ".byline" + But 1 email should be delivered to "coauthor" + And the email should contain "The user leadauthor has invited your pseud coauthor to be listed as a co-creator on the following work" + When I am logged in as "coauthor" + And I follow "Dialogue" in the email + Then I should not see "Edit" + When I follow "Co-Creator Requests page" + And I check "selected[]" + # Expire cached byline + And it is currently 1 second from now + And I press "Accept" + Then I should see "You are now listed as a co-creator on Dialogue." + When I follow "Dialogue" + Then I should see "coauthor, leadauthor" within ".byline" + And I should see "Edit" + + Scenario: You can remove yourself as coauthor from a work + Given the following activated users exist + | login | + | coolperson | + | ex_friend | + And the user "ex_friend" allows co-creators + And I coauthored the work "Shared" as "coolperson" with "ex_friend" + And I am logged in as "coolperson" + When I view the work "Shared" + Then I should see "coolperson, ex_friend" within ".byline" + When I edit the work "Shared" + And I wait 1 second + And I follow "Remove Me As Co-Creator" + Then I should see "You have been removed as a creator from the work." + And "ex_friend" should be the creator on the work "Shared" + And "coolperson" should not be a creator on the work "Shared" + + Scenario: User applies a coauthor's work skin to their work + Given the following activated users with private work skins + | login | + | lead_author | + | coauthor | + | random_user | + And the user "coauthor" allows co-creators + And I coauthored the work "Shared" as "lead_author" with "coauthor" + And I am logged in as "lead_author" + When I edit the work "Shared" + Then I should see "Lead Author's Work Skin" within "#work_work_skin_id" + And I should see "Coauthor's Work Skin" within "#work_work_skin_id" + And I should not see "Random User's Work Skin" within "#work_work_skin_id" + When I select "Coauthor's Work Skin" from "Select work skin" + And I press "Post" + Then I should see "Work was successfully updated" + + Scenario: Previewing shows changes to tags, but cancelling afterwards doesn't save those changes + Given I am logged in as a random user + And I post the work "Work 1" with fandom "testing" + When I edit the work "Work 1" + And I fill in "Fandoms" with "foobar" + And I press "Preview" + Then I should see "Fandom: foobar" + When I press "Cancel" + And I view the work "Work 1" + Then I should see "Fandom: testing" + And I should not see "Fandom: foobar" + + Scenario: A work cannot be edited to remove its fandom + Given basic tags + And I am logged in as a random user + And I post the work "Work 1" with fandom "testing" + When I edit the work "Work 1" + And I fill in "Fandoms" with "" + And I press "Post" + Then I should see "Sorry! We couldn't save this work because: Please fill in at least one fandom." + When I view the work "Work 1" + Then I should see "Fandom: testing" + + Scenario: User can cancel editing a work + Given I am logged in as a random user + And I post the work "Work 1" with fandom "testing" + And I edit the work "Work 1" + And I fill in "Fandoms" with "" + And I press "Cancel" + When I view the work "Work 1" + Then I should see "Fandom: testing" + + Scenario: A work cannot be edited to remove its only warning + Given I am logged in as a random user + And I post the work "Work 1" + When I edit the work "Work 1" + And I uncheck "No Archive Warnings Apply" + And I press "Post" + Then I should see "Sorry! We couldn't save this work because: Please select at least one warning." + When I view the work "Work 1" + Then I should see "Archive Warning: No Archive Warnings Apply" + + Scenario: A work can be edited to remove all categories + Given I am logged in as a random user + And I post the work "Work 1" with category "F/F" + When I edit the work "Work 1" + And I uncheck "F/F" + And I press "Post" + Then I should not see "F/F" + + Scenario: When editing a work, the title field should not escape HTML + Given the work "What a title! :< :& :>" by "author" + And I am logged in as "author" + And I go to the works page + And I follow "What a title! :< :& :>" + And I follow "Edit" + Then I should see "What a title! :< :& :>" in the "Work Title" input + + Scenario: When a user changes their co-creator preference, it does not remove them from works they have already co-created. + Given basic tags + And "Burnham" has the pseud "Michael" + And "Pike" has the pseud "Christopher" + And the user "Burnham" allows co-creators + When I am logged in as "testuser" with password "testuser" + And I go to the new work page + And I fill in the basic work information for "Thats not my Spock" + And I try to invite the co-authors "Michael,Christopher" + And I press "Post" + Then I should see "Christopher (Pike) does not allow others to invite them to be a co-creator." + When I press "Post" + Then I should see "Work was successfully posted. It should appear in work listings within the next few minutes." + But I should not see "Michael" + When the user "Burnham" accepts all co-creator requests + And I view the work "Thats not my Spock" + Then I should see "Michael (Burnham), testuser" + When the user "Burnham" disallows co-creators + And I edit the work "Thats not my Spock" + And I fill in "Work Title" with "Thats not my Spock, it has too much beard" + And I press "Post" + Then I should see "Thats not my Spock, it has too much beard" + And I should see "Michael (Burnham), testuser" + + Scenario: When you have a work with two co-creators, and one of them changes their preference to disallow co-creation, the other should still be able to edit the work and add a third co-creator. + Given basic tags + And "Burnham" has the pseud "Michael" + And "Georgiou" has the pseud "Philippa" + And the user "Burnham" allows co-creators + And the user "Georgiou" allows co-creators + When I am logged in as "testuser" with password "testuser" + And I go to the new work page + And I fill in the basic work information for "Thats not my Spock" + And I try to invite the co-author "Michael" + And I press "Post" + Then I should see "Work was successfully posted. It should appear in work listings within the next few minutes." + But I should not see "Michael" + When the user "Burnham" accepts all co-creator requests + And I view the work "Thats not my Spock" + Then I should see "Michael (Burnham), testuser" + When the user "Burnham" disallows co-creators + And I edit the work "Thats not my Spock" + And I fill in "Work Title" with "Thats not my Spock, it has too much beard" + And I press "Post" + Then I should see "Thats not my Spock, it has too much beard" + And I should see "Michael (Burnham), testuser" + When I edit the work "Thats not my Spock, it has too much beard" + And I invite the co-author "Georgiou" + And I press "Post" + Then I should see "Work was successfully updated" + And I should see "Michael (Burnham), testuser" + But I should not see "Georgiou" + When the user "Georgiou" accepts all co-creator requests + And I view the work "Thats not my Spock, it has too much beard" + Then I should see "Georgiou, Michael (Burnham), testuser" + + Scenario: You cannot edit a work to add too many tags + Given the user-defined tag limit is 7 + And the work "Over the Limit" + And I am logged in as the author of "Over the Limit" + When I edit the work "Over the Limit" + 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 fill in "Additional Tags" with "Additional Tag 1, Additional Tag 2" + And I press "Post" + Then I should see "Fandom, relationship, character, and additional tags must not add up to more than 7. Your work has 8 of these tags, so you must remove 1 of them." + + Scenario: If a work has too many tags, you cannot update it without removing tags + Given the user-defined tag limit is 7 + 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 + And I am logged in as the author of "Over the Limit" + When I edit the work "Over the Limit" + And I fill in "Title" with "Over the Limit Redux" + And I press "Post" + Then I should see "Fandom, relationship, character, and additional tags must not add up to more than 7. Your work has 8 of these tags, so you must remove 1 of them." diff --git a/features/works/work_edit_multiple.feature b/features/works/work_edit_multiple.feature new file mode 100644 index 0000000..1c6f75f --- /dev/null +++ b/features/works/work_edit_multiple.feature @@ -0,0 +1,271 @@ +@works @tags +Feature: Edit Multiple Works + In order to change settings on my works more easily + As an author + I want to edit multiple works at once + + Scenario: I can delete multiple works at once + Given I am logged in as "author" + And I post the work "Glorious" with fandom "SGA" + And I post the work "Excellent" with fandom "Star Trek" + And I post the work "Lovely" with fandom "Steven Universe" + And I go to author's works page + When I follow "Edit Works" + Then I should see the page title "Edit Multiple Works" + And I should see "Edit Multiple Works" + When I select "Glorious" for editing + And I select "Excellent" for editing + And it is currently 1 second from now + And I press "Delete" + Then I should see "Are you sure you want to delete these works PERMANENTLY?" + And I should see "Glorious" + And I should see "Excellent" + And I should not see "Lovely" + When I press "Yes, Delete Works" + Then I should see "Your works Glorious, Excellent were deleted." + When all indexing jobs have been run + And I go to author's works page + Then I should not see "Glorious" + And I should not see "Excellent" + And I should see "Lovely" + + Scenario: I can edit multiple works at once + Given I am logged in as "author" + And I post the work "Glorious" with fandom "SGA" + And I post the work "Excellent" with fandom "Star Trek" + And I go to author's works page + When I follow "Edit Works" + Then I should see the page title "Edit Multiple Works" + And I should see "Edit Multiple Works" + And I should see "All" + And I should see "None" + When I select "Glorious" for editing + And I select "Excellent" for editing + And I press "Edit" + Then I should see "Your edits will be applied to all of the following works" + And I should see "Glorious" + And I should see "Excellent" + When I set the fandom to "Random" + And I press "Update All Works" + Then I should see "Your edits were put through" + And I should see "Random" + And I should not see "SGA" + And I should not see "Star Trek" + When I view the work "Glorious" + Then I should see "Random" + And I should not see "SGA" + When I view the work "Excellent" + Then I should see "Random" + And I should not see "Star Trek" + + Scenario: I can disable anon commenting on multiple works at once + Given I am logged in as "author" + And I edit the multiple works "Glorious" and "Excellent" + When I choose "Only registered users can comment" + And I press "Update All Works" + And I am logged out + And I view the work "Glorious" + Then I should see "doesn't allow non-Archive users to comment" + When I view the work "Excellent" + Then I should see "doesn't allow non-Archive users to comment" + + Scenario: I can disable commenting on multiple works at once + Given I am logged in as "author" + And I edit the multiple works "Glorious" and "Excellent" + When I choose "No one can comment" + And I press "Update All Works" + And I am logged out + And I view the work "Glorious" + Then I should see "Sorry, this work doesn't allow comments." + When I view the work "Excellent" + Then I should see "Sorry, this work doesn't allow comments." + + Scenario: I can enable comment moderation on multiple works at once + Given I am logged in as "author" + And I edit the multiple works "Glorious" and "Excellent" + And I choose "Enable comment moderation" + And I press "Update All Works" + When I am logged in as "commenter" + And I view the work "Glorious" + Then I should see "has chosen to moderate comments" + When I view the work "Excellent" + Then I should see "has chosen to moderate comments" + + Scenario: I can enable anon commenting on multiple works at once + Given I am logged in as "author" + And I edit the multiple works "Glorious" and "Excellent" + And I choose "Only registered users can comment" + And I press "Update All Works" + And I edit the multiple works "Glorious" and "Excellent" + And I choose "Registered users and guests can comment" + And I press "Update All Works" + When I am logged out + And I view the work "Glorious" + Then I should not see "doesn't allow non-Archive users to comment" + + Scenario: I can enable commenting on multiple works at once + Given I am logged in as "author" + And I edit the multiple works "Glorious" and "Excellent" + And I choose "No one can comment" + And I press "Update All Works" + And I edit the multiple works "Glorious" and "Excellent" + And I choose "Registered users and guests can comment" + And I press "Update All Works" + When I am logged out + And I view the work "Glorious" + Then I should not see "Sorry, this work doesn't allow comments." + + Scenario: I can disable comment moderation on multiple works at once + Given I am logged in as "author" + And I edit the multiple works "Glorious" and "Excellent" + And I choose "Enable comment moderation" + And I press "Update All Works" + And I edit the multiple works "Glorious" and "Excellent" + And I choose "Disable comment moderation" + And I press "Update All Works" + When I am logged out + And I view the work "Glorious" + Then I should not see "has chosen to moderate comments" + + Scenario: I can keep different comment moderation settings on different works when I edit them at once + Given I am logged in as "author" + And I edit multiple works with different comment moderation settings + When I set the fandom to "Random" + And I choose "Keep current comment moderation settings" + And I press "Update All Works" + When I am logged out + And I view the work "Work with Comment Moderation Enabled" + Then I should see "has chosen to moderate comments" + When I view the work "Work with Comment Moderation Disabled" + Then I should not see "has chosen to moderate comments" + + Scenario: I can keep different commenting settings on different works when I edit them at once + Given I am logged in as "author" + And I edit multiple works with different commenting settings + And I set the fandom to "Random" + And I choose "Keep current comment settings" + And I press "Update All Works" + + When I view the work "Work with All Commenting Disabled" + Then I should see "Sorry, this work doesn't allow comments." + + When I am logged out + And I view the work "Work with Anonymous Commenting Disabled" + Then I should see "Sorry, this work doesn't allow non-Archive users to comment." + + When I view the work "Work with All Commenting Enabled" + Then I should not see "Sorry, this work doesn't allow comments." + And I should not see "Sorry, this work doesn't allow non-Archive users to comment." + + Scenario: User can change the pseud on multiple works at once + Given I am logged in as "author" + And "author" creates the pseud "My New Pseud" + And I edit the multiple works "First" and "Second" + And it is currently 1 second from now + When I select "My New Pseud" from "Creator/Pseud(s)" + And I press "Update All Works" + Then I should see "Your edits were put through" + When I view the work "First" + Then I should see "My New Pseud" within ".byline" + When I view the work "Second" + Then I should see "My New Pseud" within ".byline" + + Scenario: User can invite a co-creator to multiple works at once + Given the following activated users exist + | login | + | lead_author | + | coauthor | + And the user "coauthor" allows co-creators + And I am logged in as "lead_author" + And I edit the multiple works "First Shared" and "Second Shared" + When I fill in "Add co-creators" with "coauthor" + And I press "Update All Works" + Then I should see "Your edits were put through" + And 2 emails should be delivered to "coauthor" + When I view the work "First Shared" + Then I should not see "coauthor" within ".byline" + When I view the work "First Shared" + Then I should not see "coauthor" within ".byline" + When the user "coauthor" accepts all co-creator requests + And I view the work "First Shared" + Then I should see "coauthor" within ".byline" + When I view the work "Second Shared" + Then I should see "coauthor" within ".byline" + + Scenario: User can remove themselves from multiple works at once + Given the following activated users exist + | login | + | lead_author | + | coauthor | + And the user "coauthor" allows co-creators + And I am logged in as "lead_author" + And I edit multiple works coauthored as "lead_author" with "coauthor" + When I check "Remove me as co-creator" + And it is currently 1 second from now + And I press "Update All Works" + Then I should see "Your edits were put through" + When I view the work "Shared Work 1" + Then I should not see "lead_author" within ".byline" + When I view the work "Shared Work 2" + Then I should not see "lead_author" within ".byline" + + Scenario: User can remove themselves from one work even if they're the only creator on the other + Given the user "lead_creator" exists and is activated + And the user "co_creator" exists and is activated + And I am logged in as "lead_creator" + And I post the work "Solo" + And I coauthored the work "Shared" as "lead_creator" with "co_creator" + When I follow "My Dashboard" + And I follow "Works (" + And I follow "Edit Works" + And I select "Solo" for editing + And I select "Shared" for editing + And I press "Edit" + And I check "Remove me as co-creator" + And it is currently 1 second from now + And I press "Update All Works" + Then I should see "You cannot remove yourself as co-creator of the work Solo because you are the only listed creator." + When I view the work "Solo" + Then I should see "lead_creator" within ".byline" + When I view the work "Shared" + Then I should not see "lead_creator" within ".byline" + + Scenario: User applies a private work skin to multiple coauthored works + Given the following activated users with private work skins + | login | + | lead_author | + | coauthor | + And the user "coauthor" allows co-creators + And I am logged in as "lead_author" + And I edit multiple works coauthored as "lead_author" with "coauthor" + Then I should see "Lead Author's Work Skin" within "#work_work_skin_id" + And I should not see "Coauthor's Work Skin" within "#work_work_skin_id" + When I select "Lead Author's Work Skin" from "Select work skin" + And I press "Update All Works" + Then I should see "Your edits were put through" + + Scenario: I can add several works to a collection at once + Given I am logged in as "author" + And I create the collection "MyCollection" + And I edit the multiple works "Glorious" and "Excellent" + When I fill in "Add to collections" with "MyCollection" + And I press "Update All Works" + Then I should see "Your edits were put through" + When I view the work "Glorious" + Then I should see "MyCollection" + When I view the work "Excellent" + Then I should see "MyCollection" + + Scenario: I can remove several works from a collection at once + Given I am logged in as "author" + And I create the collection "MyCollection" + And I post the work "Glorious" to the collection "MyCollection" + And I post the work "Excellent" to the collection "MyCollection" + And I edit the multiple works "Glorious" and "Excellent" + When I check "MyCollection" + And I press "Update All Works" + Then I should see "Your edits were put through" + When I view the work "Glorious" + Then I should not see "MyCollection" + When I view the work "Excellent" + Then I should not see "MyCollection" diff --git a/features/works/work_edit_tags.feature b/features/works/work_edit_tags.feature new file mode 100644 index 0000000..189385c --- /dev/null +++ b/features/works/work_edit_tags.feature @@ -0,0 +1,86 @@ +@tags @works +Feature: Edit tags on a work + In order to have an archive full of works + As a humble user + I want to edit the tags on one of my works + + Scenario: Edit tags on a work + + Given the following activated user exists + | login | password | + | myname | something | + And I am logged in as "myname" with password "something" + Then I should see "Hi, myname!" + And I should see "Log Out" + When I post the work "Testerwork" + Then I should see "Work was successfully posted." + And I should see "Stargate SG-1" + And I should not see "Hana Yori Dango" + And I should not see "Alternate Universe" + When I follow "myname" + Then I should see "Testerwork" + And I should see "Edit Tags" + When I follow "Edit Tags" + Then I should see "Edit Work Tags for " + And I should see "Testerwork" + And I should not see "Save As Draft" + When I fill in "Fandoms" with "Stargate SG-1, Hana Yori Dango" + And I fill in "Additional Tags" with "Alternate Universe" + And I press "Post" + Then I should see "Stargate SG-1" + And I should see "Hana Yori Dango" + And I should see "Alternate Universe" + And I should see "Work was successfully updated" + + Scenario: Edit tags on a draft + Given I am logged in as "imit" with password "tagyoure" + And the draft "Freeze Tag" + When I am on imit's works page + Then I should see "Drafts (1)" + When I follow "Drafts (1)" + Then I should see "Freeze Tag" + And I should see "Edit Tags" within "#main .own.work.blurb .actions" + When I follow "Edit Tags" + Then I should see the page title "Edit Work Tags" + And I should see "Edit Work Tags" + When I fill in "Fandoms" with "Games, Anthropomorphic" + And I fill in "Additional Tags" with "The cooler version of tag" + And I press "Save As Draft" + Then I should see "Tags were successfully updated" + And I should see "This work is a draft and has not been posted" + And I should see "Games" + And I should see "Anthropomorphic" + And I should see "The cooler version of tag" + + Scenario: Ampersands and angle brackets should display in work titles on Edit Tags page + Given the work "I am <strong>er Than Yesterday & Other Lies" by "testuser2" + And I am logged in as "testuser2" + When I view the work "I am <strong>er Than Yesterday & Other Lies" + And I follow "Edit Tags" + Then I should see "I am er Than Yesterday & Other Lies" + + Scenario: Unlike admins, regular users do not see the language option on the Edit Tags page + Given I am logged in as "regularuser" + And I post the work "Some Work" + When I view the work "Some Work" + And I follow "Edit Tags" + Then I should not see "Choose a language" + + Scenario: A work's tags cannot be edited to remove its fandom + Given I am logged in as a random user + And I post the work "Work 1" with fandom "testing" + 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: Please fill in at least one fandom." + + Scenario: User can cancel editing a work's tags + Given I am logged in as a random user + And I post the work "Work 1" with fandom "testing" + And I view the work "Work 1" + And I follow "Edit Tags" + And I fill in "Fandoms" with "" + And I press "Cancel" + When I view the work "Work 1" + Then I should see "Fandom: testing" diff --git a/features/works/work_languages.feature b/features/works/work_languages.feature new file mode 100755 index 0000000..1701783 --- /dev/null +++ b/features/works/work_languages.feature @@ -0,0 +1,58 @@ +@works + +Feature: Create Works + In order to have an archive full of works + As an author + I want to create new works + + Scenario: Creating a new work with international characters + + Given basic tags + And basic languages + And I am logged in as "germanfan" with password "password" + When I go to the new work page + Then I should see "Post New Work" + And I select "Not Rated" from "Rating" + And I check "No Archive Warnings Apply" + And I fill in "Fandoms" with "Weiß Kreuz" + And I fill in "Work Title" with "Überraschende Überraschung" + And I fill in "content" with "Dies ist eine Fanfic in Deutsch." + And I select "Deutsch" from "Choose a language" + And I press "Preview" + Then I should see "Preview" + When I press "Post" + Then I should see "Work was successfully posted." + And I should see "Deutsch" within "dd.language" + When I go to the works page + Then I should see "Überraschende Überraschung" + When I follow "Weiß Kreuz" + Then I should see "Überraschende Überraschung" + When I follow "Überraschende Überraschung" + Then I should see "Dies ist eine Fanfic in Deutsch." + When I log out + Then I should see "Successfully logged out" + + # another example with a different character set (language not in fixtures) + + Given I am logged in as "finnishfan" with password "password" + When I go to the new work page + Then I should see "Post New Work" + And I select "Not Rated" from "Rating" + And I check "No Archive Warnings Apply" + And I select "English" from "Choose a language" + And I fill in "Fandoms" with "A fandom" + And I fill in "Work Title" with "Ennen päivänlaskua ei voi" + And I fill in "content" with "A story that is long enough to count" + And I press "Preview" + Then I should see "Preview" + When I press "Post" + Then I should see "Work was successfully posted." + + Scenario: Previewing a work after changing the language should show the new language + Given basic languages + And I am logged in as a random user + And I post the work "Incorrect" + When I edit the work "Incorrect" + And I select "Deutsch" from "Choose a language" + And I press "Preview" + Then I should see "Language: Deutsch" diff --git a/features/works/work_lock.feature b/features/works/work_lock.feature new file mode 100644 index 0000000..b03f067 --- /dev/null +++ b/features/works/work_lock.feature @@ -0,0 +1,91 @@ +@works @search + +Feature: Locking works to archive users only + In order to keep my works under the radar + As a registered archive user + I should be able to make my works visible only to other registered users + +Scenario: Posting locked work + Given I am logged in as "fandomer" with password "password" + And basic tags + And I go to the new work page + And I select "Not Rated" from "Rating" + And I check "No Archive Warnings Apply" + And I select "English" from "Choose a language" + And I fill in "Fandoms" with "Supernatural" + And I fill in "Characters" with "Sammy" + And I fill in "Work Title" with "Awesomeness" + And I fill in "content" with "The story of how they met and how they got into trouble" + And I lock the work + When I press "Preview" + + # shows as restricted + Then I should see the image "title" text "Restricted" within "h2.title" + When I post the work + Then I should see the image "alt" text "(Restricted)" within "h2.title" + When I go to the works tagged "Supernatural" + Then I should see "Awesomeness" within "h4" + And I should see the image "alt" text "(Restricted)" within "h4" + When all indexing jobs have been run + And I fill in "site_search" with "Awesomeness" + And I press "Search" + Then I should see "1 Found" + And I should see "fandomer" within "#main" + + # doesn't show when logged out + When I am logged out + And I go to the works tagged "Supernatural" + Then I should not see "Awesomeness" + And I should not see the image "alt" text "(Restricted)" + When I am on fandomer's works page + Then I should not see "Awesomeness" + When I fill in "site_search" with "Awesomeness" + And I press "Search" + Then I should see "No results found" + And I should not see "fandomer" + + # shows again if you log in as another user + When I am logged in as "testuser" with password "password" + And I am on fandomer's works page + Then I should see "Awesomeness" + +Scenario: Editing posted work + Given I am logged in as "fandomer" with password "password" + And I post the work "Sad generic work" + And all indexing jobs have been run + When I am logged out + And I go to fandomer's works page + Then I should see "Sad generic work" + When I am logged in as "fandomer" with password "password" + And I edit the work "Sad generic work" + And I lock the work + And I fill in "Fandoms" with "Supernatural" + When I press "Preview" + Then I should see the image "title" text "Restricted" within "h2.title" + When I update the work + Then I should see the image "alt" text "(Restricted)" within "h2.title" + When I go to the works tagged "Supernatural" + Then I should see "Sad generic work" within "h4" + And I should see the image "alt" text "(Restricted)" within "h4" + When I am logged out + And I go to the works page + Then I should not see "Sad generic work" + And I should not see the image "alt" text "(Restricted)" + When I am logged in as "fandomer" with password "password" + And I edit the work "Sad generic work" + And I fill in "Notes" with "Random blather" + And I press "Preview" + Then I should see the image "alt" text "(Restricted)" within "h2.title" + When I update the work + Then I should see "Work was successfully updated." + And I should see the image "alt" text "(Restricted)" within "h2.title" + When I edit the work "Sad generic work" + And I unlock the work + And I press "Preview" + Then I should not see the image "alt" text "(Restricted)" + When I update the work + Then I should see "Work was successfully updated." + And I should not see the image "alt" text "(Restricted)" + When I am logged out + And I go to the works page + Then I should see "Sad generic work" diff --git a/features/works/work_notes.feature b/features/works/work_notes.feature new file mode 100644 index 0000000..f0b0302 --- /dev/null +++ b/features/works/work_notes.feature @@ -0,0 +1,25 @@ +@works +Feature: Display work notes + In order to provide information about my work + As an author + I want to be able to add notes to my work + + Scenario: User posts a work without notes + Given the work "Work Without Notes" + When I view the work "Work Without Notes" + Then I should not see "Notes:" + + Scenario: User posts a work with beginning notes + Given the work "Work with Beginning Notes" + When I add the beginning notes "These are my beginning notes. There are no recipients, approved translations, inspirations, or claims here." to the work "Work with Beginning Notes" + And I view the work "Work with Beginning Notes" + Then I should see "Notes:" + And I should see "These are my beginning notes. There are no recipients, approved translations, inspirations, or claims here." + And I should not find a list for associations + + Scenario: User posts a work with end notes + Given the work "Work with End Notes" + When I add the end notes "These are my end notes." to the work "Work with End Notes" + And I view the work "Work with End Notes" + Then I should see "Notes:" within ".end" + And I should see "These are my end notes." within ".end" diff --git a/features/works/work_related.feature b/features/works/work_related.feature new file mode 100644 index 0000000..e6d8882 --- /dev/null +++ b/features/works/work_related.feature @@ -0,0 +1,786 @@ +@works +Feature: Inspirations, remixes and translations + In order to reflect the connections between some fanworks + As a fan author, part of a fan community + I want to be able to list related works + +Scenario: Posting a remix / related work emails the creator of the original work and lists the parent work in the proper location on the remix / related work + + Given I have related works setup + When I post a related work as remixer + Then a parent related work should be seen + And the original author should be emailed + +Scenario: Remixer can see their remix / related work on their related works page + + Given I have related works setup + When I post a related work as remixer + When I go to remixer's user page + Then I should see "Related Works (1)" + When I follow "Related Works" + Then I should see "Works that inspired remixer" + And I should see "Worldbuilding by inspiration" + +Scenario: Creator of original work can see a remix on their related works page + + Given I have related works setup + And a related work has been posted + When I am logged in as "inspiration" + And I view my related works + Then I should see "Works inspired by inspiration" + And I should see "Followup by remixer" + +Scenario: Posting a translation emails the creator of the original work and lists the parent work in the proper location on the translation + + Given I have related works setup + When I post a translation as translator + Then a parent translated work should be seen + And the original author should be emailed + +Scenario: Translator can see their translation on their related works page + + Given I have related works setup + When I post a translation as translator + When I go to translator's user page + Then I should see "Related Works (1)" + When I follow "Related Works" + Then I should see "Works translated by translator" + And I should see "Worldbuilding by inspiration" + And I should see "From English to Deutsch" + +Scenario: Creator of original work can see a translation on their related works page + + Given I have related works setup + And a translation has been posted + When I am logged in as "inspiration" + And I view my related works + Then I should see "Translations of inspiration's works" + And I should see "Worldbuilding Translated by translator" + And I should see "From English to Deutsch" + +Scenario: Unapproved translations do not appear or produce an associations list on the original work + + Given I have related works setup + When I post a translation as translator + When I view the work "Worldbuilding" + Then I should not see the translation listed on the original work + And I should not find a list for associations + +Scenario: Unapproved related works do not appear or produce an associations list on the original work + + Given I have related works setup + When I post a related work as remixer + When I view the work "Worldbuilding" + Then I should not see the related work listed on the original work + And I should not find a list for associations + +Scenario: The creator of the original work can approve a related work that is NOT a translation and see it referenced in the beginning notes and linked in the end notes + + Given I have related works setup + And a related work has been posted + When I am logged in as "inspiration" + And I view my related works + When I follow "Approve" + Then I should see "Approve Link" + When I press "Yes, link me!" + Then I should see "Link was successfully approved" + And I should see a beginning note about related works + And I should see the related work in the end notes + And I should not find a list for associations + +Scenario: The creator of the original work can approve a translation and see it linked in an associations list in the beginning notes, and there should not be a list of "works inspired by this one" + + Given I have related works setup + And a translation has been posted + When I approve a related work + Then I should see "Link was successfully approved" + And I should see the translation in the beginning notes + And I should not see "Works inspired by this one:" + And I should find a list for associations + +Scenario: Translation, related work, and parent work links appear in the right places even when viewing a multi-chapter work with draft chapters in chapter-by-chapter mode + + Given I have related works setup + And a translation has been posted and approved + And a related work has been posted and approved + And an inspiring parent work has been posted + When I am logged in as "inspiration" + And I edit the work "Worldbuilding" + And I list the work "Parent Work" as inspiration + And I press "Post" + And a chapter is added to "Worldbuilding" + And a draft chapter is added to "Worldbuilding" + When I view the work "Worldbuilding" + Then I should find a list for associations + And I should see a beginning note about related works + And I should see the translation in the beginning notes + And I should see the inspiring parent work in the beginning notes + When I follow "other works inspired by this one" + Then I should see the related work in the end notes + And I should not see the translation in the end notes + +Scenario: The creator of the original work can see approved and unapproved relationships on their related works page + + Given I have related works setup + And a translation has been posted + And a related work has been posted + When I approve a related work + When I view my related works + Then I should see "Worldbuilding Approve" + And I should see "Deutsch Remove" + +Scenario: The creator of the original work can remove a previously approved related work + + Given I have related works setup + And a related work has been posted and approved + When I view my related works + And I follow "Remove" + Then I should see "Remove Link" + When I press "Remove link" + Then I should see "Link was successfully removed" + And I should not see the related work listed on the original work + +Scenario: The creator of the original work can remove a previously approved translation + + Given I have related works setup + And a translation has been posted and approved + When I view my related works + And I follow "Remove" within "#translationsofme" + Then I should see "Remove Link" + When I press "Remove link" + Then I should see "Link was successfully removed" + And I should not see the translation listed on the original work + +Scenario: Editing an existing work to add an inspiration (parent work) should send email to the creator of the original work + + Given I have related works setup + When I post a related work as remixer + And I edit the work "Followup" + And all emails have been delivered + And I list the work "Worldbuilding Two" as inspiration + And I press "Preview" + When I press "Update" + Then I should see "Work was successfully updated" + And I should see "Inspired by Worldbuilding Two by inspiration" + And "AO3-1506" is fixed + # And 1 email should be delivered + +Scenario: Remixer receives comments on remix, creator of original work doesn't + + Given I have related works setup + And a related work has been posted + And all emails have been delivered + When I am logged in as "commenter" + When I post the comment "Blah" on the work "Followup" + Then "remixer" should be emailed + And "inspiration" should not be emailed + +Scenario: Translator receives comments on translation, creator of original work doesn't + + Given I have related works setup + And a translation has been posted + And all emails have been delivered + When I am logged in as "commenter" + When I post the comment "Blah" on the work "Worldbuilding Translated" + Then "translator" should be emailed + And "inspiration" should not be emailed + +# TODO +# Scenario: Creator of original work chooses to receive comments on translation + + #Given I have related works setup + # And a translation has been posted + # And all emails have been delivered + #When I am logged in as "inspiration" + # And I approve a related work + # And I set my preferences to receive comments on translated works + #When I am logged in as "commenter" + # And I post the comment "Blah" on the work "Worldbuilding Translated" + #Then "translator" should be emailed + # And "inspiration" should be emailed + +# TODO +# Scenario: Creator of original work doesn't receive comments if they haven't approved the translation + + #Given I have related works setup + # And a translation has been posted + # And all emails have been delivered + #When I am logged in as "inspiration" + # And I set my preferences to receive comments on translated works + #When I am logged in as "commenter" + #When I post the comment "Blah" on the work "Worldbuilding Translated" + #Then "inspiration" should not be emailed + +# TODO +# Scenario: Can post a translation of a mystery work + +# TODO +# Scenario: Posting a translation of a mystery work should not allow you to see the work + +# TODO +# Scenario: Can post a translation of an anonymous work + +# TODO +# Scenario: Posting a translation of an anonymous work should not allow you to see the author + +Scenario: Translate your own work + + Given I have related works setup + When I post a translation of my own work + And I approve a related work + Then approving the related work should succeed + +Scenario: Draft works should not show up on related works + + Given I have related works setup + And I am logged in as "translator" + And I draft a translation + When I am logged in as "inspiration" + And I go to inspiration's user page + Then I should not see "Related Works (1)" + When I view my related works + Then I should not see "Worldbuilding Translated" + +Scenario: Listing external works as inspirations + + Given basic tags + And mock websites with no content + When I am logged in as "remixer" with password "password" + And I set up the draft "Followup" + And I check "parent-options-show" + And I fill in "URL" with "http://example.org/200" + And I press "Preview" + Then I should see a save error message + And I should see "The title of a parent work outside the archive can't be blank" + And I should see "The author of a parent work outside the archive can't be blank" + When I fill in "Title" with "Worldbuilding" + And I fill in "Author" with "BNF" + And I check "This is a translation" + And I press "Preview" + Then I should see "Draft was successfully created" + When I press "Post" + Then I should see "Work was successfully posted" + And I should see "A translation of Worldbuilding by BNF" + When I edit the work "Followup" + And I check "parent-options-show" + And I fill in "URL" with "http://example.org/301" + And I press "Preview" + Then I should see a save error message + And I should see "The title of a parent work outside the archive can't be blank" + And I should see "The author of a parent work outside the archive can't be blank" + When I fill in "Title" with "Worldbuilding Two" + And I fill in "Author" with "BNF" + And I press "Preview" + Then I should see "Preview" + When I press "Update" + Then I should see "Work was successfully updated" + And I should see "A translation of Worldbuilding by BNF" + And I should see "Inspired by Worldbuilding Two by BNF" + When I view my related works + Then I should see "From N/A to English" + # inactive URL should give a helpful message (AO3-1783) + # unreachable URL should give a more helpful message (A03-3536) + When I edit the work "Followup" + And I check "parent-options-show" + And I fill in "URL" with "http://example.org/404" + And I fill in "Title" with "Worldbuilding Two" + And I fill in "Author" with "BNF" + And I press "Preview" + Then I should see "Parent work URL could not be reached. If the URL is correct and the site is currently down, please try again later." + +Scenario: External work language + + Given basic tags + And basic languages + And mock websites with no content + When I am logged in as "remixer" with password "password" + And I go to the new work page + And I select "Not Rated" from "Rating" + And I check "No Archive Warnings Apply" + And I select "English" from "Choose a language" + And I fill in "Fandoms" with "Stargate" + And I fill in "Work Title" with "Followup 4" + And I fill in "content" with "That could be an amusing crossover." + And I check "parent-options-show" + And I fill in "URL" with "http://example.org/200" + And I fill in "Title" with "German Worldbuilding" + And I fill in "Author" with "BNF" + And I select "Deutsch" from "Language" + And I check "This is a translation" + And I press "Preview" + Then I should see "Draft was successfully created" + When I press "Post" + Then I should see "Work was successfully posted" + And I should see "A translation of German Worldbuilding by BNF" + When I view my related works + Then I should see "From Deutsch to English" + And I should not see "From N/A to English" + +# TODO after issue 1741 is resolved +# Scenario: Test that I can remove relationships that I initiated from my own works +# especially during posting / editing / previewing a work +# especially from the related_works page, which works but redirects to a non-existant page right now + +Scenario: Restricted works listed as Inspiration show up [Restricted] for guests + Given I have related works setup + And a related work has been posted and approved + When I am logged in as "remixer" + And I lock the work "Followup" + When I am logged out + And I view the work "Worldbuilding" + Then I should see "[Restricted Work] by remixer" + When I am logged in as "remixer" + And I unlock the work "Followup" + When I am logged out + And I view the work "Followup" + Then I should see "Inspired by Worldbuilding by inspiration" + When I am logged in as "inspiration" + And I lock the work "Worldbuilding" + When I am logged out + And I view the work "Followup" + Then I should see "Inspired by [Restricted Work] by inspiration" + +Scenario: Anonymous works listed as inspiration should have links to the authors, + but only for the authors themselves and admins + Given I have related works setup + And I have the anonymous collection "Muppets Anonymous" + And a related work has been posted and approved + + When I am logged in as "remixer" + And I edit the work "Followup" to be in the collection "Muppets_Anonymous" + And I view the work "Worldbuilding" + Then I should see "Works inspired by this one: Followup by Anonymous [remixer]" + When I follow "remixer" within ".afterword .children" + Then I should be on the dashboard page for user "remixer" with pseud "remixer" + + When I am logged in as an admin + And I view the work "Worldbuilding" + Then I should see "Works inspired by this one: Followup by Anonymous [remixer]" + When I follow "remixer" within ".afterword .children" + Then I should be on the dashboard page for user "remixer" with pseud "remixer" + + When I am logged out + And I view the work "Worldbuilding" + Then I should see "Works inspired by this one: Followup by Anonymous" + And I should not see "remixer" within ".afterword .children" + +Scenario: When a user is notified that a co-authored work has been inspired by a work they posted, + the e-mail should link to each author's URL instead of showing escaped HTML + Given I have related works setup + And the user "misterdeejay" exists and is activated + And the user "misterdeejay" allows co-creators + And I am logged in as "inspiration" + And I post the work "Seed of an Idea" + When I am logged in as "inspired" + And I set up the draft "Seedling of an Idea" + And I invite the co-author "misterdeejay" + And I preview the work + Then I should not see "misterdeejay" + But 1 email should be delivered to "misterdeejay" + And the email should contain "The user inspired has invited your pseud misterdeejay to be listed as a co-creator on the following work" + When the user "misterdeejay" accepts all co-creator requests + And I edit the work "Seedling of an Idea" + And I list the work "Seed of an Idea" as inspiration + And I preview the work + And I post the work + Then 1 email should be delivered to "inspiration" + And the email should link to inspired's user url + And the email should not contain "<a href="http://archiveofourown.org/users/inspired/pseuds/inspired"" + And the email should link to misterdeejay's user url + And the email should not contain "<a href="http://archiveofourown.org/users/misterdeejay/pseuds/misterdeejay"" + + Scenario: When using an invalid URL + Given I am logged in + And I set up a draft "Naughty" + When I check "parent-options-show" + And I fill in "URL" with "not valid." + And I fill in "Title" with "Breaking rules" + And I fill in "Author" with "human" + And I press "Post" + Then I should see a save error message + And I should see "Parent work URL does not appear to be a valid URL." + + Scenario: When using a URL on the site to cite a parent work, the URL can't be + for something that isn't a work + Given I am logged in + And I set up a draft "Inspired" + When I list a series as inspiration + And I press "Post" + Then I should see "Only a link to a work can be listed as an inspiration." + + Scenario: When using a URL on the site to cite a parent work, the URL must be + for a work that exists + Given I am logged in + And I set up a draft "Inspired" + When I list a nonexistent work as inspiration + And I press "Post" + Then I should see "The work you listed as an inspiration does not seem to exist." + + Scenario: Protected users cannot have their works cited as related works + Given I have related works setup + And the user "inspiration" is a protected user + When I post a related work as remixer + Then I should see "You can't use the related works function to cite works by the protected user inspiration." + + Scenario: When editing a work with an existing citation of a protected user's work, the citation remains + Given I have related works setup + And a related work has been posted and approved + When the user "inspiration" is a protected user + And I am logged in as "remixer" + And I edit the work "Followup" + And I fill in "Fandoms" with "I forgot about the witches" + And I press "Post" + Then I should see "Work was successfully updated." + And I should see "Inspired by Worldbuilding by inspiration" + + Scenario: Protected users can approve existing citations of their works + Given I have related works setup + And I post a related work as remixer + When the user "inspiration" is a protected user + And I am logged in as "inspiration" + And I go to inspiration's related works page + Then I should see "inspiration's Related Works" + And I should see "Followup by remixer" + And I should see "Approve" + When I follow "Approve" + And I press "Yes, link me!" + Then I should see "Link was successfully approved" + And I should see a beginning note about related works + And I should see the related work in the end notes + And I should not find a list for associations + + Scenario: Protected users can remove existing citations of their works + Given I have related works setup + And a related work has been posted and approved + When the user "inspiration" is a protected user + And I am logged in as "inspiration" + And I go to inspiration's related works page + Then I should see "inspiration's Related Works" + And I should see "Followup by remixer" + And I should see "Remove" + When I follow "Remove" + And I press "Remove link" + Then I should see "Link was successfully removed" + And I should not see the related work listed on the original work + + Scenario: Citing an anonymous work by a protected user does not break anonymity + Given an anonymous collection "Anonymous" + And I have related works setup + And the user "inspiration" is a protected user + When I am logged in as "inspiration" + And I edit the work "Worldbuilding" to be in the collection "Anonymous" + When I post a related work as remixer + Then I should not see "You can't use the related works function to cite works by the protected user inspiration." + When I am logged in as "remixer" + And I go to remixer's related works page + Then I should see "Works that inspired remixer" + And I should see "Worldbuilding by Anonymous" + And I should not see "inspiration" + + Scenario: Citing an unrevealed work by a protected user does not break anonymity + Given a hidden collection "Hidden" + And I have related works setup + And the user "inspiration" is a protected user + When I am logged in as "inspiration" + And I edit the work "Worldbuilding" to be in the collection "Hidden" + When I post a related work as remixer + Then I should not see "You can't use the related works function to cite works by the protected user inspiration." + When I am logged in as "remixer" + And I go to remixer's related works page + Then I should see "Works that inspired remixer" + And I should see "A work in an unrevealed collection" + And I should not see "inspiration" + + Scenario: When a remix is anonymous, it's visible on the original creator's related works page, but not on the remixer's related works page + Given an anonymous collection "Anonymous" + And I have related works setup + And I post a related work as remixer + When I am logged in as "remixer" + And I edit the work "Followup" to be in the collection "Anonymous" + And I go to remixer's related works page + Then I should see "Works that inspired remixer" + And I should see "Worldbuilding by inspiration" + When I go to inspiration's related works page + Then I should see "Works inspired by inspiration" + And I should see "Followup by Anonymous [remixer]" + When I am logged in as "inspiration" + And I go to remixer's related works page + Then I should not see "Works that inspired remixer" + And I should not see "Worldbuilding by inspiration" + When I go to inspiration's related works page + Then I should see "Works inspired by inspiration" + And I should see "Followup by Anonymous" + And I should not see "remixer" + + Scenario: When a remix is unrevealed, it's visible on the original creator's related works page, but not on the remixer's related works page + Given a hidden collection "Hidden" + And I have related works setup + And I post a related work as remixer + When I am logged in as "remixer" + And I edit the work "Followup" to be in the collection "Hidden" + And I go to remixer's related works page + Then I should see "Works that inspired remixer" + And I should see "Worldbuilding by inspiration" + When I go to inspiration's related works page + Then I should see "Works inspired by inspiration" + And I should see "A work in an unrevealed collection" + When I am logged in as "inspiration" + And I go to remixer's related works page + Then I should not see "Works that inspired remixer" + And I should not see "A work in an unrevealed collection" + And I should not see "Worldbuilding by inspiration" + When I go to inspiration's related works page + Then I should see "Works inspired by inspiration" + And I should see "A work in an unrevealed collection" + And I should not see "remixer" + + Scenario: A remix of an anonymous work is shown on the remixer's related works page, but not on the original creator's related works page + Given an anonymous collection "Anonymous" + And I have related works setup + And I post a related work as remixer + When I am logged in as "inspiration" + And I edit the work "Worldbuilding" to be in the collection "Anonymous" + And I go to remixer's related works page + Then I should see "Works that inspired remixer" + And I should see "Worldbuilding by Anonymous [inspiration]" + When I go to inspiration's related works page + Then I should see "Works inspired by inspiration" + And I should see "Followup by remixer" + When I am logged in as "remixer" + And I go to remixer's related works page + Then I should see "Works that inspired remixer" + And I should see "Worldbuilding by Anonymous" + And I should not see "inspiration" + When I go to inspiration's related works page + Then I should not see "Works inspired by inspiration" + And I should not see "Followup" + + Scenario: A remix of an unrevealed work is shown on the remixer's related works page, but not on the original creator's related works page + Given a hidden collection "Hidden" + And I have related works setup + And I post a related work as remixer + When I am logged in as "inspiration" + And I edit the work "Worldbuilding" to be in the collection "Hidden" + And I go to remixer's related works page + Then I should see "Works that inspired remixer" + And I should see "A work in an unrevealed collection" + When I go to inspiration's related works page + Then I should see "Works inspired by inspiration" + And I should see "Followup by remixer" + When I am logged in as "remixer" + And I go to remixer's related works page + Then I should see "Works that inspired remixer" + And I should see "A work in an unrevealed collection" + And I should not see "inspiration" + When I go to inspiration's related works page + Then I should not see "Works inspired by inspiration" + And I should not see "A work in an unrevealed collection" + And I should not see "Followup" + + Scenario: When a translation is anonymous, it's visible on the original creator's related works page, but not on the translator's related works page + Given an anonymous collection "Anonymous" + And I have related works setup + And I post a translation as translator + When I am logged in as "translator" + And I edit the work "Worldbuilding Translated" to be in the collection "Anonymous" + And I go to translator's related works page + Then I should see "Works translated by translator" + And I should see "Worldbuilding by inspiration" + And I should see "From English to Deutsch" + When I go to inspiration's related works page + Then I should see "Translations of inspiration's works" + And I should see "Worldbuilding Translated by Anonymous [translator]" + And I should see "From English to Deutsch" + When I am logged in as "inspiration" + And I go to translator's related works page + Then I should not see "Works translated by translator" + And I should not see "Worldbuilding by inspiration" + And I should not see "From English to Deutsch" + When I go to inspiration's related works page + Then I should see "Translations of inspiration's works" + And I should see "Worldbuilding Translated by Anonymous" + And I should see "From English to Deutsch" + And I should not see "translator" + + Scenario: When a translation is unrevealed, it's visible on the original creator's related works page, but not on the translator's related works page + Given a hidden collection "Hidden" + And I have related works setup + And I post a translation as translator + When I am logged in as "translator" + And I edit the work "Worldbuilding Translated" to be in the collection "Hidden" + And I go to translator's related works page + Then I should see "Works translated by translator" + And I should see "Worldbuilding by inspiration" + And I should see "From English to Deutsch" + When I go to inspiration's related works page + Then I should see "Translations of inspiration's works" + And I should see "A work in an unrevealed collection" + When I am logged in as "inspiration" + And I go to translator's related works page + Then I should not see "Works translated by translator" + And I should not see "Worldbuilding by inspiration" + And I should not see "From English to Deutsch" + When I go to inspiration's related works page + Then I should see "Translations of inspiration's works" + And I should see "A work in an unrevealed collection" + And I should not see "translator" + + Scenario: A translation of an anonymous work is shown on the translator's related works page, but not on the original creator's related works page + Given an anonymous collection "Anonymous" + And I have related works setup + And I post a translation as translator + When I am logged in as "inspiration" + And I edit the work "Worldbuilding" to be in the collection "Anonymous" + And I go to translator's related works page + Then I should see "Works translated by translator" + And I should see "Worldbuilding by Anonymous [inspiration]" + And I should see "From English to Deutsch" + When I go to inspiration's related works page + Then I should see "Translations of inspiration's works" + And I should see "Worldbuilding Translated by translator" + And I should see "From English to Deutsch" + When I am logged in as "translator" + And I go to translator's related works page + Then I should see "Works translated by translator" + And I should see "Worldbuilding by Anonymous" + And I should see "From English to Deutsch" + And I should not see "inspiration" + When I go to inspiration's related works page + Then I should not see "Translations of inspiration's works" + And I should not see "Worldbuilding Translated by translator" + And I should not see "From English to Deutsch" + + Scenario: A translation of an unrevealed work is shown on the translator's related works page, but not on the original creator's related works page + Given a hidden collection "Hidden" + And I have related works setup + And I post a translation as translator + When I am logged in as "inspiration" + And I edit the work "Worldbuilding" to be in the collection "Hidden" + And I go to translator's related works page + Then I should see "Works translated by translator" + And I should see "A work in an unrevealed collection" + And I should see "From English to Deutsch" + When I go to inspiration's related works page + Then I should see "Translations of inspiration's works" + And I should see "Worldbuilding Translated by translator" + And I should see "From English to Deutsch" + When I am logged in as "translator" + And I go to translator's related works page + Then I should see "Works translated by translator" + And I should see "A work in an unrevealed collection" + And I should see "From English to Deutsch" + And I should not see "inspiration" + When I go to inspiration's related works page + Then I should not see "Translations of inspiration's works" + And I should not see "A work in an unrevealed collection" + And I should not see "Worldbuilding Translated by translator" + And I should not see "From English to Deutsch" + + Scenario: Notes of related work do not break anonymity of parent work in an unrevealed collection + Given a hidden collection "Hidden" + And I have related works setup + When I am logged in as "inspiration" + And I edit the work "Worldbuilding" to be in the collection "Hidden" + And I post a related work as remixer + And I post a translation as translator + And I log out + # Check remix + When I view the work "Followup" + Then I should not see "Worldbuilding" + And I should not see "inspiration" + And I should see "Inspired by a work in an unrevealed collection" + # Check translated work + When I view the work "Worldbuilding Translated" + Then I should not see "inspiration" + And I should see "A translation of a work in an unrevealed collection" + + Scenario: Notes of parent work do not break anonymity of child related works in an unrevealed collection + Given a hidden collection "Hidden" + And I have related works setup + And a translation has been posted and approved + And a related work has been posted and approved + When I am logged in as "translator" + And I edit the work "Worldbuilding Translated" to be in the collection "Hidden" + When I am logged in as "remixer" + And I edit the work "Followup" to be in the collection "Hidden" + And I log out + When I view the work "Worldbuilding" + Then I should not see "Worldbuilding Translated by translator" + And I should not see "Followup by remixer" + And I should see "A work in an unrevealed collection" + + Scenario: Work notes updates when anonymity of related works change + Given a hidden collection "Hidden" + And I have related works setup + And an inspiring parent work has been posted + And a translation has been posted and approved + And a related work has been posted and approved + # Going from revealed to unrevealed + When I am logged in as "inspiration" + And I edit the work "Worldbuilding" + And I list the work "Parent Work" as inspiration + And I press "Post" + And I am logged in as "translator" + And I edit the work "Worldbuilding Translated" to be in the collection "Hidden" + And I am logged in as "remixer" + And I edit the work "Followup" to be in the collection "Hidden" + And I am logged in as "testuser" + And I edit the work "Parent Work" to be in the collection "Hidden" + And I log out + And I view the work "Worldbuilding" + Then I should not see the inspiring parent work in the beginning notes + And I should see "Translation into Deutsch available:" + And I should see "A work in an unrevealed collection" + And I should not see "Worldbuilding Translated by translator" + And I should not see "Followup by remixer" + # Going from unrevealed to revealed + When I reveal works for "Hidden" + And I log out + When I view the work "Worldbuilding" + Then I should see the inspiring parent work in the beginning notes + And I should see the translation listed on the original work + And I should see the related work listed on the original work + +Scenario: Notification emails for related works are translated + + Given a locale with translated emails + And I have related works setup + And the user "inspiration" enables translated emails + And the user "encouragement" allows co-creators + When I am logged in as "inspiration" + And I edit the work "Worldbuilding" + And I invite the co-author "encouragement" + And I press "Post" + Then 1 email should be delivered to "encouragement" + And the email should contain "The user inspiration has invited your pseud encouragement to be listed as a co-creator on the following work" + When the user "encouragement" accepts all co-creator requests + And a related work has been posted + Then 3 emails should be delivered + And "inspiration" should receive 1 email + And the email to "inspiration" should be translated + And the email should have "Related work notification" in the subject + And "encouragement" should receive 2 emails + And the last email to "encouragement" should be non-translated + And the last email should have "Related work notification" in the subject + +Scenario: Notification emails for translations are translated + + Given a locale with translated emails + And I have related works setup + And the user "inspiration" enables translated emails + And the user "encouragement" allows co-creators + When I am logged in as "inspiration" + And I edit the work "Worldbuilding" + And I invite the co-author "encouragement" + And I press "Post" + Then 1 email should be delivered to "encouragement" + And the email should contain "The user inspiration has invited your pseud encouragement to be listed as a co-creator on the following work" + When the user "encouragement" accepts all co-creator requests + And a translation has been posted + Then 3 emails should be delivered + And "inspiration" should receive 1 email + And the email to "inspiration" should be translated + And the email should have "Related work notification" in the subject + And "encouragement" should receive 2 emails + And the last email to "encouragement" should be non-translated + And the last email should have "Related work notification" in the subject diff --git a/features/works/work_share.feature b/features/works/work_share.feature new file mode 100644 index 0000000..6f61d51 --- /dev/null +++ b/features/works/work_share.feature @@ -0,0 +1,85 @@ +# We need to load the site skin to make the share modal work properly: +@works @load-default-skin +Feature: Share Works + Testing the "Share" button on works, with Javascript emulation + + @javascript + Scenario: Share a work + Given the work "Blabla" by "testuser1" with fandom "Stargate SG-1" + And I am logged in as "testuser1" + When I view the work "Blabla" + Then 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 'Blabla (8 words)' within "#share textarea" + And I should see 'by testuser1' 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" + When I view the work "Blabla" + And I log out + Then 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 'Blabla (8 words)' within "#share textarea" + And I should see 'by testuser1' 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 should be disabled if all creators have set the option to disable sharing on their works + + Given I am logged in as "PrivaC" + And I set my preferences to hide the share buttons on my work + And the work "Don't Lie When You're Hurting Inside" by "PrivaC" + And the user "EitherWay" allows co-creators + When I view the work "Don't Lie When You're Hurting Inside" + Then I should not see "Share" + When I add the co-author "EitherWay" to the work "Don't Lie When You're Hurting Inside" + And I view the work "Don't Lie When You're Hurting Inside" + Then I should see "Share" + When I am logged in as "EitherWay" + And I set my preferences to hide the share buttons on my work + And I view the work "Don't Lie When You're Hurting Inside" + Then I should not see "Share" + + @javascript + Scenario: Sharing should work for multi-chapter works + Given the chaptered work "Whatever" + When I view the work "Whatever" + Then I should see "Share" + When I follow "Share" + Then I should see "Copy and paste the following code to link back to this work" + And I should see ">Whatever (10 words) b" within "#share textarea" + + @javascript + Scenario: Share URL should not be used for post-login redirect + Given I have a work "Blabla" + And the following activated user exists + | login | password | + | MadUser | password | + When I am a visitor + And I view the work "Blabla" + Then I should see "Share" + When I follow "Share" + Then I should see "Close" within "#modal" + When I follow "Close" + And I follow "Log In" + And I fill in "Username or email:" with "maduser" + And I fill in "Password:" with "password" + And I press "Log In" + Then the url should not include "share" + # Shown when the share url is accessed directly + And I should not see "Sorry, you need to have JavaScript enabled for this." diff --git a/features/works/work_view.feature b/features/works/work_view.feature new file mode 100644 index 0000000..5182b7c --- /dev/null +++ b/features/works/work_view.feature @@ -0,0 +1,82 @@ +@works @comments + +Feature: View a work with various options + + Scenario: viewing a work in explicit View Full Work mode, with JavaScript turned off (Issue 2205) + Given the chaptered work with 2 comments "Whatever" + When I view the work "Whatever" in full mode + And I follow "Comments (2)" + Then I should see "Bla bla" + + Scenario: Regular logged-in user doesn't have the option to troubleshoot a work + Given the work "Whatever" + And I am logged in + When I view the work "Whatever" + Then I should not see "Troubleshoot" + + Scenario: Logged-out user doesn't have the option to troubleshoot a work + Given the work "Whatever" + And I am a visitor + When I view the work "Whatever" + Then I should not see "Troubleshoot" + + Scenario: viewing a work when logged in and having set full mode in the preferences + Given the chaptered work "Whatever" + 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 "Whatever" + Then I should see "Chapter 2" + + Scenario: viewing a work and chapter that have been deleted + Given I am logged in as a random user + And I view a deleted work + Then I should be on the homepage + And I should see "Sorry, we couldn't find the work you were looking for." + When I follow "Site Map" + And I should not see "Sorry, we couldn't find the work you were looking for." + + Scenario: viewing a deleted chapter on a work that still exists + Given I am logged in as a random user + And I view a deleted chapter + And I should see "Sorry, we couldn't find the chapter you were looking for." + And I should see "DeletedChapterWork" + And I follow "Site Map" + Then I should not see "Sorry, we couldn't find the chapter you were looking for." + + Scenario: other users cannot collect a work by default + Given the work "Whatever" + When I have the collection "test collection" with name "test_collection" + And I am logged in as "moderator" + And I view the work "Whatever" + Then I should not see a link "Invite To Collections" + And I should not see the "new_collection_item" form + + Scenario: other users can collect a work when the creator has opted-in + Given the work "Whatever" + And I am logged in as the author of "Whatever" + And I set my preferences to allow collection invitations + When I have the collection "test collection" with name "test_collection" + And I am logged in as "moderator" + And I view the work "Whatever" + Then I should see a link "Invite To Collections" + And I should see the "new_collection_item" form + + Scenario: archivists can add works to collections regardless of invitation preferences + Given the work "Imported Work" + And I have an archivist "archivist" + And I am logged in as "archivist" + When I create the collection "Open Doors Collection 1" + And I view the work "Imported Work" + Then I should see a link "Add to Collections" + And I should see the "new_collection_item" form + + Scenario: chapter title displays in View Full Work mode when chaptered work has one published chapter + Given I am logged in as a random user + And I set my preferences to View Full Work mode by default + When I set up the draft "multiChap" + And I check "This work has multiple chapters" + And I fill in "Chapter Title" with "cool chapter title" + And I fill in "Chapter 1 of" with "?" + And I press "Post" + Then I should see "Chapter 1: " + And I should see "cool chapter title" diff --git a/lib/ EXAMPLE_MODULE.rb.example b/lib/ EXAMPLE_MODULE.rb.example new file mode 100644 index 0000000..3519def --- /dev/null +++ b/lib/ EXAMPLE_MODULE.rb.example @@ -0,0 +1,23 @@ +# Describe what the module does up here +# This then gets included in whatever class you're working on: +# "include [module name]" +module Example + + # Class methods are ones that you run with "ClassName.whatever" -- you would define them + # in the class as "def self.whatever" + # Here you need to define them WITHOUT the "self." part :) + module ClassMethods + + end # CLASS METHODS + + # This last housekeeping method will load up the class methods and + # any associations or validations into your class + def self.included(base) + base.class_eval do + # put any association or validations here, eg has_many, etc + end + base.extend(ClassMethods) + end + + +end \ No newline at end of file diff --git a/lib/acts_as_commentable/comment_methods.rb b/lib/acts_as_commentable/comment_methods.rb new file mode 100644 index 0000000..24acb36 --- /dev/null +++ b/lib/acts_as_commentable/comment_methods.rb @@ -0,0 +1,150 @@ +module ActsAsCommentable::CommentMethods + + def self.included(comment) + comment.class_eval do + include InstanceMethods + + before_destroy :fix_threading_on_destroy + after_destroy :check_can_destroy_parent + end + end + + module InstanceMethods + + # Gets the object (chapter, bookmark, etc.) that the comment ultimately belongs to + def ultimate_parent + self.parent + end + + # gets the comment that is the parent of this thread + def thread_parent + self.reply_comment? ? self.commentable.thread_parent : self + end + + # Only destroys childless comments, sets is_deleted to true for the rest + def destroy_or_mark_deleted + if self.children_count > 0 + self.is_deleted = true + self.comment_content = "deleted comment" # wipe out the content + self.save(validate: false) + else + self.destroy + end + end + + # Returns true if the comment is a reply to another comment + def reply_comment? + self.commentable_type == self.class.to_s + end + + # Returns the total number of sub-comments + def children_count + self.threaded_right ? (self.threaded_right - self.threaded_left - 1)/2 : 0 + end + + # Returns all sub-comments plus the comment itself + # Returns comment itself if unthreaded + def full_set + if self.threaded_left + Comment.includes(:pseud).where("threaded_left BETWEEN (?) and (?) AND thread = (?)", + self.threaded_left, self.threaded_right, self.thread).order(:threaded_left) + else + return [self] + end + end + + # TODO: Remove when AO3-5939 is fixed. + def set_to_freeze_or_unfreeze + # Our set always starts with the comment we pressed the button on. + comment_set = [self] + + # We're going to find all of the comments on @comment's ultimate parent + # and then use the comments' commentables to figure which comments belong + # to the set (thread) we are freezing or unfreezing. + all_comments = self.ultimate_parent.find_all_comments + + # First, we'll loop through all_comments to find any direct replies to + # self. Then we'll loop through again to find any direct replies to + # _those_ replies. We'll repeat this until we find no more replies. + newest_ids = [self.id] + + while newest_ids.present? + child_comments_by_commentable = all_comments.where(commentable_id: newest_ids, commentable_type: "Comment") + + comment_set << child_comments_by_commentable unless child_comments_by_commentable.empty? + newest_ids = child_comments_by_commentable.pluck(:id) + end + comment_set.flatten + end + + # Returns all sub-comments + def all_children + self.children_count > 0 ? Comment.includes(:pseud).where("threaded_left > (?) and threaded_right < (?) and thread = (?)", + self.threaded_left, self.threaded_right, self.thread).order(:threaded_left) : [] + end + + # Returns a full comment thread + def full_thread + Comment.where("thread = (?)", self.thread).order(:threaded_left) + end + + + # Adds a child to this object in the tree. This method will update all of the + # other elements in the tree and shift them to the right, keeping everything + # balanced. + # + # Skips validations so that we can reply to invalid comments. + def add_child( child ) + if ( (self.threaded_left == nil) || (self.threaded_right == nil) ) + # Looks like we're now the root node! Woo + self.threaded_left = 1 + self.threaded_right = 4 + + return nil unless save(validate: false) + + child.commentable_id = self.id + child.threaded_left = 2 + child.threaded_right= 3 + else + # OK, we need to add and shift everything else to the right + child.commentable_id = self.id + right_bound = self.threaded_right + child.threaded_left = right_bound + child.threaded_right = right_bound + 1 + self.threaded_right += 2 + # Updates all comments in the thread to set their relative positions + Comment.transaction { + Comment.where(["thread = (?) AND threaded_left >= (?)", self.thread, right_bound]).update_all("threaded_left = (threaded_left + 2)") + Comment.where(["thread = (?) AND threaded_right >= (?)", self.thread, right_bound]).update_all("threaded_right = (threaded_right + 2)") + save(validate: false) + } + end + end + + # Adjusts left and right threading counts when a comment is deleted + # otherwise, children_count is wrong + def fix_threading_on_destroy + Comment.transaction { + Comment.where(["thread = (?) AND threaded_left > (?)", self.thread, self.threaded_left]).update_all("threaded_left = (threaded_left - 2)") + Comment.where(["thread = (?) AND threaded_right > (?)", self.thread, self.threaded_right]).update_all("threaded_right = (threaded_right - 2)") + } + end + + # When we delete a comment, we may be deleting the last of our parent's + # children. If our parent was marked as deleted (but not actually + # destroyed), we may be able to destroy it. + def check_can_destroy_parent + # We're in the middle of a cascade deletion (e.g. a work is being destroyed), + # so don't try to recursively reload and check our parents. + return if destroyed_by_association + + immediate_parent = commentable.reload + + return unless immediate_parent.is_a?(Comment) + return unless immediate_parent.is_deleted + return unless immediate_parent.children_count.zero? + + immediate_parent.destroy + end + end +end diff --git a/lib/acts_as_commentable/commentable.rb b/lib/acts_as_commentable/commentable.rb new file mode 100644 index 0000000..dc94b9b --- /dev/null +++ b/lib/acts_as_commentable/commentable.rb @@ -0,0 +1,17 @@ +module ActsAsCommentable + module Commentable + def self.included(base) + base.extend ClassMethods + end + + module ClassMethods + def acts_as_commentable + send :include, CommentableEntity + end + + def has_comment_methods + send :include, CommentMethods + end + end + end +end diff --git a/lib/acts_as_commentable/commentable_entity.rb b/lib/acts_as_commentable/commentable_entity.rb new file mode 100644 index 0000000..81cb8a7 --- /dev/null +++ b/lib/acts_as_commentable/commentable_entity.rb @@ -0,0 +1,100 @@ +module ActsAsCommentable::CommentableEntity + + def self.included(commentable) + commentable.class_eval do + has_many :comments, as: :commentable, dependent: :destroy + has_many :total_comments, class_name: 'Comment', as: :parent + extend ClassMethods + end + end + + module ClassMethods + end + + # Returns all comments + def find_all_comments + self.total_comments.order('thread, threaded_left') + end + + # Returns the total number of comments + def count_all_comments + self.total_comments.count + end + + # These below have all been redefined to work for the archive + + # The total number of visible comments on this commentable, not including + # deleted comments, spam comments, unreviewed comments, and comments hidden + # by an admin. + # + # This is the uncached version, and should only be used when calculating an + # accurate value is important (e.g. when updating the StatCounter in the + # database). + def count_visible_comments_uncached + self.total_comments.where( + hidden_by_admin: false, + is_deleted: false, + unreviewed: false, + approved: true + ).count + end + + # The total number of visible comments on this commentable. Cached to reduce + # computation. The cache is manually expired whenever a comment is added, + # removed, or changes visibility, but the cache also expires after a fixed + # amount of time in case of issues with the cache (e.g. stale data when + # calculating the count). + def count_visible_comments + @count_visible_comments ||= + Rails.cache.fetch(count_visible_comments_key, + expires_in: ArchiveConfig.SECONDS_UNTIL_COMMENT_COUNTS_EXPIRE.seconds, + race_condition_ttl: 10.seconds) do + count_visible_comments_uncached + end + end + + def count_visible_comments_key + "#{self.class.table_name}/#{self.id}/count_visible_comments" + end + + def expire_comments_count + @count_visible_comments = nil + Rails.cache.delete(count_visible_comments_key) + end + + # Return the name of this commentable object + # Should be overridden in the implementing class if necessary + def commentable_name + begin + self.title + rescue + "" + end + end + + def commentable_owners + begin + self.pseuds.map {|p| p.user}.uniq + rescue + begin + [self.pseud.user] + rescue + [] + end + end + end + + # Return the email to reach the owner of this commentable object + # Should be overridden in the implementing class if necessary + def commentable_owner_email + if self.commentable_owners.empty? + begin + self.email + rescue + "" + end + else + self.commentable_owners.email.join(',') + end + end +end diff --git a/lib/autocomplete_source.rb b/lib/autocomplete_source.rb new file mode 100644 index 0000000..3e4e58d --- /dev/null +++ b/lib/autocomplete_source.rb @@ -0,0 +1,323 @@ +module AutocompleteSource + AUTOCOMPLETE_DELIMITER = ": ".freeze + AUTOCOMPLETE_COMPLETION_KEY = "completion".freeze + AUTOCOMPLETE_SCORE_KEY = "score".freeze + AUTOCOMPLETE_CACHE_KEY = "cache".freeze + AUTOCOMPLETE_RANGE_LENGTH = 50 # not random + AUTOCOMPLETE_BOOST = 1000 # amt by which we boost results that have all the words + + # this marks a completed word in the completion set -- we use double commas because + # commas are not allowed in pseud and tag names, and double-commas have been disallowed + # from collection titles + AUTOCOMPLETE_WORD_TERMINATOR = ",,".freeze + + def transliterate(input) + input = input.to_s.mb_chars.unicode_normalize(:nfkd).gsub(/[\u0300-\u036F]/, "") + result = "" + input.each_char do |char| + tl = ActiveSupport::Inflector.transliterate(char) + # If transliterate returns "?", the original character is either unsupported + # (e.g. a non-Latin character) or was actually a question mark. + # In both cases, we should keep the original. + result << if tl == "?" + char + else + tl + end + end + result + end + + # override to define any autocomplete prefix spaces where this object should live + def autocomplete_prefixes + [self.transliterate("autocomplete_#{self.class.name.downcase}")] + end + + def autocomplete_search_string + self.transliterate(name) + end + + def autocomplete_search_string_was + self.transliterate(name_was) + end + + def autocomplete_search_string_before_last_save + self.transliterate(name_before_last_save) + end + + def autocomplete_value + "#{id}#{AUTOCOMPLETE_DELIMITER}#{name}" + (self.respond_to?(:title) ? "#{AUTOCOMPLETE_DELIMITER}#{title}" : "") + end + + def autocomplete_value_was + "#{id}#{AUTOCOMPLETE_DELIMITER}#{name_was}" + (self.respond_to?(:title) ? "#{AUTOCOMPLETE_DELIMITER}#{title_was}" : "") + end + + def autocomplete_value_before_last_save + "#{id}#{AUTOCOMPLETE_DELIMITER}#{name_before_last_save}" + (self.respond_to?(:title) ? "#{AUTOCOMPLETE_DELIMITER}#{title_before_last_save}" : "") + end + + def autocomplete_score + 0 + end + + def add_to_autocomplete(score = nil) + score = autocomplete_score unless score + self.class.autocomplete_pieces(autocomplete_search_string).each do |word_piece| + # each prefix represents an autocompletion space -- eg, "autocomplete_collection_all" + autocomplete_prefixes.each do |prefix| + # We put each prefix and the word + completion token into the set of all completions, + # with score 0 so they just get sorted lexicographically -- + # this will be used to quickly find all possible completions in this space + REDIS_AUTOCOMPLETE.zadd(self.transliterate(self.class.autocomplete_completion_key(prefix)), 0, word_piece) + + # We put each complete search string into a separate set indexed by word with specified score + if self.class.is_complete_word?(word_piece) + REDIS_AUTOCOMPLETE.zadd(self.transliterate(self.class.autocomplete_score_key(prefix, word_piece)), score, autocomplete_value) + end + end + end + end + + def remove_from_autocomplete + self.class.remove_from_autocomplete(self.autocomplete_search_string, self.autocomplete_prefixes, self.autocomplete_value) + end + + def remove_stale_from_autocomplete + self.class.remove_from_autocomplete(self.autocomplete_search_string_before_last_save, self.autocomplete_prefixes, self.autocomplete_value_before_last_save) + end + + module ClassMethods + include AutocompleteSource + + # returns a properly escaped and case-insensitive regexp for a more manual search + def get_search_regex(search_param) + Regexp.new(Regexp.escape(search_param), Regexp::IGNORECASE) + end + + # takes either an array or string of search terms (typically extra values passed in through live params, like fandom) + # and returns an array of stripped and lowercase words for actual searching or use in keys + def get_search_terms(search_term) + terms = if search_term.is_a?(Array) + search_term.map { |term| term.split(",") }.flatten + else + search_term.blank? ? [] : search_term.split(",") + end + terms.map { |term| self.transliterate(term.strip.downcase) } + end + + def parse_autocomplete_value(current_autocomplete_value) + current_autocomplete_value.split(AUTOCOMPLETE_DELIMITER, 3) + end + + def fullname_from_autocomplete(current_autocomplete_value) + current_autocomplete_value.split(AUTOCOMPLETE_DELIMITER, 2)[1] + end + + def id_from_autocomplete(current_autocomplete_value) + parse_autocomplete_value(current_autocomplete_value)[0] + end + + def name_from_autocomplete(current_autocomplete_value) + parse_autocomplete_value(current_autocomplete_value)[1] + end + + def title_from_autocomplete(current_autocomplete_value) + parse_autocomplete_value(current_autocomplete_value)[2] + end + + def autocomplete_lookup(options = {}) + options.reverse_merge!({search_param: "", autocomplete_prefix: "", sort: "down"}) + search_param = options[:search_param] + autocomplete_prefix = options[:autocomplete_prefix] + if REDIS_AUTOCOMPLETE.exists(autocomplete_cache_key(autocomplete_prefix, search_param)) + return REDIS_AUTOCOMPLETE.zrange(autocomplete_cache_key(autocomplete_prefix, search_param), 0, -1) + end + + # we assume that if the user is typing in a phrase, any words they have + # entered are the exact word they want, so we only get the prefixes for + # the very last word they have entered so far + search_pieces = autocomplete_phrase_split(search_param).map { |w| w + AUTOCOMPLETE_WORD_TERMINATOR } + + # for each complete word, we look up the phrases in that word's set + # along with their scores and add up the scores + scored_results = {} + count = {} + exact = {} + search_regex = Regexp.new(Regexp.escape(search_param), Regexp::IGNORECASE) + + search_pieces.each_with_index do |search_piece, index| + lastpiece = false + if index == search_pieces.size - 1 + lastpiece = true + search_piece.gsub!(/#{Tag::AUTOCOMPLETE_WORD_TERMINATOR}$/, '') + + break if search_pieces.size > 1 && search_piece.length == 1 + end + + # Get all the complete words which could match this search term + completions = autocomplete_word_completions(search_piece, autocomplete_prefix) + + completions.each do |word| + # O(logN + M) where M is number of items returned -- we could speed up even more by putting in a limit + phrases_with_scores = [] + if lastpiece && search_piece.length < 3 + # use a limit + phrases_with_scores = REDIS_AUTOCOMPLETE.zrevrangebyscore(autocomplete_score_key(autocomplete_prefix, word), + 'inf', 0, withscores: true, limit: [0, 50]) + else + phrases_with_scores = REDIS_AUTOCOMPLETE.zrevrangebyscore(autocomplete_score_key(autocomplete_prefix, word), + 'inf', 0, withscores: true) + end + + phrases_with_scores.each do |phrase, score| + score = score.to_i + if options[:constraint_sets] + # phrases must be in these sets or else no go + # O(logN) complexity + next unless options[:constraint_sets].all {|set| REDIS_AUTOCOMPLETE.zrank(set, phrase)} + end + + if count[phrase] + # if we've already seen this phrase, increase the score + scored_results[phrase] += score + count[phrase] += 1 + else + # initialize the score and check if it exactly matches our regexp + scored_results[phrase] = score + if lastpiece + # don't count if it only matches the last search piece + count[phrase] = 0 + else + count[phrase] = 1 + end + if phrase.match(search_regex) + exact[phrase] = true + else + exact[phrase] = false + end + end + end + end + end + + # final sort is O(NlogN) but N is only the number of complete phrase results which should be relatively small + results = scored_results.keys.sort do |k1, k2| + exact[k1] && !exact[k2] ? -1 : (exact[k2] && !exact[k1] ? 1 : + count[k1] > count[k2] ? -1 : (count[k2] > count[k1] ? 1 : + scored_results[options[:sort] == "down" ? k2 : k1].to_i <=> scored_results[options[:sort] == "down" ? k1 : k2].to_i)) + end + limit = options[:limit] || 15 + + if search_param.length <= 2 + # cache the result for really quick response when only 1-2 letters entered + # adds only a little bit to memory and saves doing a lot of processing of many phrases + results[0..limit].each_with_index {|res, index| REDIS_AUTOCOMPLETE.zadd(autocomplete_cache_key(autocomplete_prefix, search_param), index, res)} + # expire every 24 hours so new entries get added if appropriate + REDIS_AUTOCOMPLETE.expire(autocomplete_cache_key(autocomplete_prefix, search_param), 24*60*60) + end + results[0..limit] + end + + def is_complete_word?(word_piece) + word_piece.match(/#{AUTOCOMPLETE_WORD_TERMINATOR}$/) + end + + def get_word(word_piece) + word_piece.gsub(/#{AUTOCOMPLETE_WORD_TERMINATOR}$/, '') + end + + def autocomplete_score_key(autocomplete_prefix, word) + self.transliterate(autocomplete_prefix + "_" + AUTOCOMPLETE_SCORE_KEY + "_" + get_word(word)) + end + + def autocomplete_completion_key(autocomplete_prefix) + self.transliterate(autocomplete_prefix + "_" + AUTOCOMPLETE_COMPLETION_KEY) + end + + def autocomplete_cache_key(autocomplete_prefix, search_param) + self.transliterate(autocomplete_prefix + "_" + AUTOCOMPLETE_CACHE_KEY + "_" + search_param) + end + + # Split a string into words. + def autocomplete_phrase_split(string) + # Use the ActiveSupport::Multibyte::Chars class to handle downcasing + # instead of the basic string class, because it can handle downcasing + # letters with accents or other diacritics. + normalized = self.transliterate(string).downcase.to_s + + # Split on one or more spaces, ampersands, slashes, double quotation marks, + # opening parentheses, closing parentheses (just in case), tildes, hyphens, vertical bars + normalized.split(%r{(?:\s|&|/|"|\(|\)|~|-|\|)+}) + end + + def autocomplete_pieces(string) + # prefixes for autocomplete + prefixes = [] + + words = autocomplete_phrase_split(string) + + words.each do |word| + prefixes << self.transliterate(word) + AUTOCOMPLETE_WORD_TERMINATOR + word.length.downto(1).each do |last_index| + prefixes << word.slice(0, last_index) + end + end + + prefixes + end + + # overall time complexity: O(log N) + def autocomplete_word_completions(word_piece, autocomplete_prefix) + get_exact = is_complete_word?(word_piece) + + # the rank of the word piece tells us where to start looking + # in the completion set for possible completions + # O(logN) N = number of things in the completion set (ie all the possible prefixes for all the words) + start_position = REDIS_AUTOCOMPLETE.zrank(autocomplete_completion_key(autocomplete_prefix), word_piece) + return [] unless start_position + + results = [] + # start from that position and go for the specified range length + # O(logN + M) M is the range length, so reduces to logN + REDIS_AUTOCOMPLETE.zrange(autocomplete_completion_key(autocomplete_prefix), start_position, start_position + AUTOCOMPLETE_RANGE_LENGTH - 1).each do |entry| + minlen = [entry.length, word_piece.length].min + # if the entry stops matching the prefix then we've passed out of + # the completions that could belong to this word -- return + return results if entry.slice(0, minlen) != word_piece.slice(0, minlen) + + # otherwise if we've hit a complete word add it to the results + if is_complete_word?(entry) + results << entry + return results if get_exact + end + end + + results + end + + # generic method to remove pieces for given search string and value from the given autocomplete prefixes + def remove_from_autocomplete(search_string, prefixes, value) + autocomplete_pieces(search_string).each do |word_piece| + prefixes.each do |prefix| + # we leave the word pieces in the completion set so we don't accidentally trash + # parts of other completions -- doing a weekly reload for cleanup is good enough + if is_complete_word?(word_piece) + word = get_word(word_piece) + phrases = REDIS_AUTOCOMPLETE.zrevrangebyscore(autocomplete_score_key(prefix, word), 'inf', 0) + if phrases.count == 1 && phrases.first == value + # there's only one phrase for this word and we're removing it, remove the completed word from the completion set + REDIS_AUTOCOMPLETE.zrem(autocomplete_completion_key(prefix), word_piece) + end + # remove the phrase we're deleting from the score set + REDIS_AUTOCOMPLETE.zrem(autocomplete_score_key(prefix, word_piece), value) + end + end + end + end + end + + def self.included(base) + base.extend(ClassMethods) + end +end diff --git a/lib/backwards_compatible_password_decryptor.rb b/lib/backwards_compatible_password_decryptor.rb new file mode 100644 index 0000000..0a3df64 --- /dev/null +++ b/lib/backwards_compatible_password_decryptor.rb @@ -0,0 +1,45 @@ +module BackwardsCompatiblePasswordDecryptor + # http://stackoverflow.com/questions/6113375/converting-existing-password-hash-to-devise + # https://github.com/binarylogic/authlogic/blob/master/lib/authlogic/acts_as_authentic/password.rb#L361 + # https://www.ruby-forum.com/topic/217465 + # Not useful but https://github.com/plataformatec/devise/issues/511 + def self.included(base) + base.class_eval do + alias :devise_valid_password? :valid_password? + end + end + + def valid_password?(password) + begin + result = super(password) + # Now the common form is that we are using an authlogic method so let's + # test that on failure + return true if result + # This is the backwards compatibility with what we used to authenticate + # with bcrypt and authlogic. + # https://github.com/binarylogic/authlogic/blob/master/lib/authlogic/acts_as_authentic/password.rb#L361 + + # if Authlogic::CryptoProviders::BCrypt.matches?(encrypted_password, [password, password_salt].compact) + # same as + if BCrypt::Password.new(encrypted_password) == [password, password_salt].flatten.join + # I am commenting the following line so that if we need to roll back the + # migration because of reasons the authentication would still work. + # self.password = password + return true + end + return false + rescue BCrypt::Errors::InvalidHash + # Now a really old password hash + # This is the backwards compatibility for the old she512 passwords, all 1 + # of them + # http://stackoverflow.com/questions/6113375/converting-existing-password-hash-to-devise/9079088 + digest = "#{password}#{password_salt}" + 20.times { digest = Digest::SHA512.hexdigest(digest) } + return false unless digest == encrypted_password + # I am commenting the following line so that if we needed to roll back the + # migration because of reasons the authentication would still work. + # self.password = password + true + end + end +end diff --git a/lib/bookmark_count_caching.rb b/lib/bookmark_count_caching.rb new file mode 100644 index 0000000..4d4d722 --- /dev/null +++ b/lib/bookmark_count_caching.rb @@ -0,0 +1,15 @@ +module BookmarkCountCaching + def key_for_public_bookmarks_count + "/v1/public_bookmarks_count/#{self.id}" + end + + def public_bookmarks_count + Rails.cache.fetch(self.key_for_public_bookmarks_count) do + self.bookmarks.is_public.count + end + end + + def invalidate_public_bookmarks_count + Rails.cache.delete(self.key_for_public_bookmarks_count) + end +end diff --git a/lib/bookmarkable.rb b/lib/bookmarkable.rb new file mode 100644 index 0000000..f0efb4d --- /dev/null +++ b/lib/bookmarkable.rb @@ -0,0 +1,28 @@ +module Bookmarkable + + def self.included(bookmarkable) + bookmarkable.class_eval do + has_many :bookmarks, as: :bookmarkable, inverse_of: :bookmarkable + has_many :user_tags, through: :bookmarks, source: :tags + after_update :update_bookmarks_index + after_update :update_bookmarker_pseuds_index + after_destroy :update_bookmarker_pseuds_index + end + end + + def public_bookmark_count + Rails.cache.fetch("#{self.cache_key}/bookmark_count", expires_in: 2.hours) do + self.bookmarks.is_public.count + end + end + + def update_bookmarks_index + IndexQueue.enqueue_ids(Bookmark, bookmarks.pluck(:id), :background) + end + + def update_bookmarker_pseuds_index + return unless respond_to?(:should_reindex_pseuds?) + return unless should_reindex_pseuds? + IndexQueue.enqueue_ids(Pseud, bookmarks.pluck(:pseud_id), :background) + end +end diff --git a/lib/challenge_core.rb b/lib/challenge_core.rb new file mode 100644 index 0000000..c9e811d --- /dev/null +++ b/lib/challenge_core.rb @@ -0,0 +1,102 @@ +module ChallengeCore + + # Used to ensure allowed is no less than required + def update_allowed_values + self.class::PROMPT_TYPES.each do |prompt_type| + required = required(prompt_type) + eval("#{prompt_type}_num_allowed = required") if required > allowed(prompt_type) + end + end + + # make sure that challenge sign-up dates / open dates aren't contradictory + def validate_signup_dates + # some variables for clarity + error_message = [] + open_date = self.signups_open_at + close_date = self.signups_close_at + signups_open = self.signup_open + if signups_open + if close_date && close_date.past? + error_message << ts("If sign-ups are open, sign-up close date cannot be in the past.") + end + if open_date && open_date.future? + error_message << ts("If sign-ups are open, sign-up open date cannot be in the future.") + end + # rubocop:disable Style/IfUnlessModifier + if close_date && open_date && close_date.to_fs(:number) < open_date.to_fs(:number) + error_message << ts("Close date cannot be before open date.") + end + # rubocop:enable Style/IfUnlessModifier + end + unless error_message.empty? + error_message.each do |errors| + self.errors.add(:base, errors) + end + end + end + + # When Challenges are deleted, there are two references left behind that need to be reset to nil + def clear_challenge_references + collection.challenge_id = nil + collection.challenge_type = nil + collection.save! + end + + # a couple of handy shorthand methods + def required(type) + self.send("#{type}_num_required") + end + + def allowed(type) + self.send("#{type}_num_allowed") + end + + def allowed_range_string(type) + "#{required(type)}" + (allowed(type) != required(type) ? " - #{allowed(type)}" : '') + end + + #### Management + + def user_allowed_to_see_signups?(user) + self.collection.user_is_maintainer?(user) + end + + def user_allowed_to_see_assignments?(user) + self.collection.user_is_maintainer?(user) + end + + def user_allowed_to_sign_up?(user) + self.collection.user_is_maintainer?(user) || self.signup_open + end + + def user_allowed_to_see_prompt?(user, prompt) + true + end + + # whether users can change the name on their signup or not -- override in challenge class as appropriate + def allow_name_change? + true + end + + module ClassMethods + # override datetime setters so we can take strings + def override_datetime_setters + %w(signups_open_at signups_close_at assignments_due_at works_reveal_at authors_reveal_at).each do |datetime_attr| + define_method("#{datetime_attr}_string") do + return unless (datetime = self[datetime_attr]) + + datetime.in_time_zone(time_zone.presence || Time.zone).strftime(ArchiveConfig.DEFAULT_DATETIME_FORMAT) + end + + define_method("#{datetime_attr}_string=") do |datetimestring| + self[datetime_attr] = Timeliness.parse(datetimestring, zone: (time_zone.presence || Time.zone)) + end + end + end + end + + def self.included(base) + base.extend(ClassMethods) + end + +end diff --git a/lib/collectible.rb b/lib/collectible.rb new file mode 100644 index 0000000..650c704 --- /dev/null +++ b/lib/collectible.rb @@ -0,0 +1,150 @@ +module Collectible + + def self.included(collectible) + collectible.class_eval do + + has_many :collection_items, as: :item, inverse_of: :item + accepts_nested_attributes_for :collection_items, allow_destroy: true + has_many :approved_collection_items, -> { approved_by_both }, class_name: "CollectionItem", as: :item + has_many :user_approved_collection_items, -> { approved_by_user }, class_name: "CollectionItem", as: :item + + has_many :collections, + through: :collection_items, + after_add: :set_visibility, + after_remove: :set_visibility, + before_remove: :destroy_collection_item + has_many :approved_collections, + through: :approved_collection_items, + source: :collection + has_many :user_approved_collections, + through: :user_approved_collection_items, + source: :collection + has_many :rejected_collections, + -> { CollectionItem.rejected_by_user }, + through: :collection_items, + source: :collection + + # Note: this scope includes the items in the children of the specified collection + scope :in_collection, lambda { |collection| + distinct.joins(:approved_collection_items).merge(collection.all_items) + } + + after_destroy :clean_up_collection_items + end + end + + # add collections based on a comma-separated list of names + def collections_to_add=(collection_names) + old_collections = self.collection_items.collect(&:collection_id) + names = trim_collection_names(collection_names) + names.each do |name| + c = Collection.find_by(name: name) + errors.add(:base, ts("We couldn't find the collection %{name}.", name: name)) and return if c.nil? + if c.closed? + errors.add(:base, ts("The collection %{name} is not currently open.", name: name)) and return unless c.user_is_maintainer?(User.current_user) || old_collections.include?(c.id) + end + add_to_collection(c) + end + end + + # remove collections based on an array of ids + def collections_to_remove=(collection_ids) + collection_ids.reject {|id| id.blank?}.map {|id| id.is_a?(String) ? id.strip : id}.each do |id| + c = Collection.find(id) || nil + remove_from_collection(c) + end + end + def collections_to_add; nil; end + def collections_to_remove; nil; end + + def add_to_collection(collection) + if collection && !self.collections.include?(collection) + self.collections << collection + end + end + + def remove_from_collection(collection) + if collection && self.collections.include?(collection) + self.collections -= [collection] + end + end + + private + def trim_collection_names(names) + names.split(',').map{ |name| name.strip }.reject {|name| name.blank?} + end + + public + # Set ALL of an item's collections based on a list of collection names + # Refactored to use collections_to_(add,remove) above so we only have one set of code + # performing the actual add/remove actions + # This method now just does the convenience work of getting the removed ids -- any missing collections + # will be identified + # IMPORTANT: cannot delete all existing collections, or else items in closed collections + # can't be edited + def collection_names=(new_collection_names) + new_names = trim_collection_names(new_collection_names) + remove_ids = self.collections.reject {|c| new_names.include?(c.name)}.collect(&:id) + self.collections_to_add = new_names.join(",") + self.collections_to_remove = remove_ids + end + + # NOTE: better to use collections_to_add/remove above instead for more consistency + def collection_names + @collection_names ? @collection_names : self.collections.collect(&:name).uniq.join(",") + end + + + #### UNREVEALED/ANONYMOUS + + # Set the anonymous/unrevealed status of the collectible based on its collections + # We can't check for user approval because the collection item doesn't exist + # and don't need to because this only gets called when the work is a new record and + # therefore being created by its author + def set_anon_unrevealed + if self.respond_to?(:in_anon_collection) && self.respond_to?(:in_unrevealed_collection) + # if we have collection items saved here then the collectible is not a new object + if self.id.nil? || self.collection_items.empty? + self.in_anon_collection = !self.collections.select(&:anonymous?).empty? + self.in_unrevealed_collection = !self.collections.select(&:unrevealed?).empty? + else + update_anon_unrevealed + end + end + return true + end + + # TODO: need a better, DRY, long-term fix + # Collection items can be revealed independently of a collection, so we don't want + # to check the collection status when those are updated + # Only include collections approved by the user + def update_anon_unrevealed + if self.respond_to?(:in_anon_collection) && self.respond_to?(:in_unrevealed_collection) + self.in_anon_collection = self.user_approved_collection_items.anonymous.any? + self.in_unrevealed_collection = self.user_approved_collection_items.unrevealed.any? + end + end + + #### CALLBACKS + + # Calculate (but don't save) whether this work should be anonymous and/or + # unrevealed. Saving the results of this will be handled when the work saves, + # or by the collection item's callbacks. + def set_visibility(collection) + set_anon_unrevealed + end + + # We want to do this after the work is deleted to avoid issues with + # accidentally trying to reveal the work during deletion (which wouldn't + # successfully reveal the work because it'd fail while trying to save the + # partially invalid work, but would cause an error). + def clean_up_collection_items + self.collection_items.destroy_all + end + + # Destroy the collection item before the collection is deleted, so that we + # trigger the CollectionItem's after_destroy callbacks. + def destroy_collection_item(collection) + self.collection_items.find_by(collection: collection).try(:destroy) + end +end diff --git a/lib/creation_notifier.rb b/lib/creation_notifier.rb new file mode 100644 index 0000000..63a2cf4 --- /dev/null +++ b/lib/creation_notifier.rb @@ -0,0 +1,102 @@ +module CreationNotifier + def notify_after_creation # after_create + return unless self.posted? + do_notify + end + + def notify_after_update + return unless self.valid? && self.posted? + + if self.saved_change_to_posted? + do_notify + else + notify_subscribers_on_reveal + end + end + + # send the appropriate notifications + def do_notify + if self.is_a?(Work) + notify_parents + notify_subscribers + notify_prompters + elsif self.is_a?(Chapter) && self.position != 1 + notify_subscribers + end + end + + # notify recipients that they have gotten a story! + # we also need to check to see if the work is in a collection + # only notify a recipient once for each work + def notify_recipients + return unless self.posted && self.new_gifts.present? && !self.unrevealed? + + recipient_pseuds = Pseud.parse_bylines(self.new_gifts.collect(&:recipient).join(","))[:pseuds] + # check user prefs to see which recipients want to get gift notifications + # (since each user has only one preference item, this removes duplicates) + recip_preferences = Preference.where(user_id: recipient_pseuds.map(&:user_id), recipient_emails_off: false) + recip_preferences.each do |userpref| + I18n.with_locale(userpref.locale_for_mails) do + if self.collections.empty? || self.collections.first.nil? + UserMailer.recipient_notification(userpref.user_id, self.id).deliver_after_commit + else + UserMailer.recipient_notification(userpref.user_id, self.id, self.collections.first.id).deliver_after_commit + end + end + end + end + + # notify people subscribed to this creation or its authors + def notify_subscribers + work = self.respond_to?(:work) ? self.work : self + if work && !work.unrevealed? + Subscription.for_work(work).each do |subscription| + RedisMailQueue.queue_subscription(subscription, self) + end + end + end + + # Check whether the work's creator has just been revealed (whether because + # a collection has just revealed its works, or a collection has just revealed + # creators). If so, queue up creator subscription emails. + def notify_subscribers_on_reveal + # Double-check that it's a posted work. + return unless self.is_a?(Work) && self.posted + + # Bail out if the work or its creator is currently unrevealed. + return if self.in_anon_collection || self.in_unrevealed_collection + + # If we've reached here, the creator of the work must be public. + # So now we want to check whether that's a recent thing. + pertinent = %w[in_anon_collection in_unrevealed_collection] + if (pertinent & saved_changes.keys).any? + # Prior to this save, the work was either anonymous or unrevealed. + # Either way, the author was just revealed, so we should trigger + # a creator subscription email. + Subscription.where( + subscribable_id: self.pseuds.pluck(:user_id), + subscribable_type: "User" + ).each do |subscription| + RedisMailQueue.queue_subscription(subscription, self) + end + end + end + + # notify prompters of response to their prompt + def notify_prompters + if !self.challenge_claims.empty? && !self.unrevealed? + if self.collections.first.nil? + UserMailer.prompter_notification(self.id,).deliver_after_commit + else + UserMailer.prompter_notification(self.id, self.collections.first.id).deliver_after_commit + end + end + end + + # notify authors of related work + def notify_parents + return if unrevealed? + + parents_after_saving.each(&:notify_parent_owners) + end +end diff --git a/lib/css_cleaner.rb b/lib/css_cleaner.rb new file mode 100644 index 0000000..ca26a6c --- /dev/null +++ b/lib/css_cleaner.rb @@ -0,0 +1,289 @@ +# Use css parser to break up style blocks +require "css_parser" + +module CssCleaner + include CssParser + + # constant regexps for css values + ALPHA_REGEX = Regexp.new('[a-z\-]+') + UNITS_REGEX = Regexp.new('deg|cm|em|ex|in|mm|pc|pt|px|s|%', Regexp::IGNORECASE) + NUMBER_REGEX = Regexp.new('-?\.?\d{1,3}\.?\d{0,3}') + NUMBER_WITH_UNIT_REGEX = Regexp.new("#{NUMBER_REGEX}\s*#{UNITS_REGEX}?\s*,?\s*") + PAREN_NUMBER_REGEX = Regexp.new('\(\s*' + NUMBER_WITH_UNIT_REGEX.to_s + '+\s*\)') + PREFIX_REGEX = Regexp.new('moz|ms|o|webkit') + + FUNCTION_NAME_REGEX = Regexp.new('scalex?y?|translatex?y?|skewx?y?|rotatex?y?|matrix', Regexp::IGNORECASE) + TRANSFORM_FUNCTION_REGEX = Regexp.new("#{FUNCTION_NAME_REGEX}#{PAREN_NUMBER_REGEX}") + + SHAPE_NAME_REGEX = Regexp.new('rect', Regexp::IGNORECASE) + SHAPE_FUNCTION_REGEX = Regexp.new("#{SHAPE_NAME_REGEX}#{PAREN_NUMBER_REGEX}") + + RGBA_REGEX = Regexp.new("rgba?" + PAREN_NUMBER_REGEX.to_s, Regexp::IGNORECASE) + HSLA_REGEX = Regexp.new("hsla?" + PAREN_NUMBER_REGEX.to_s, Regexp::IGNORECASE) + COLOR_REGEX = Regexp.new("#[0-9a-f]{3,6}|" + ALPHA_REGEX.to_s + "|" + RGBA_REGEX.to_s + "|" + HSLA_REGEX.to_s) + COLOR_STOP_FUNCTION_REGEX = Regexp.new('color-stop\s*\(' + NUMBER_WITH_UNIT_REGEX.to_s + '\s*\,?\s*' + COLOR_REGEX.to_s + '\s*\)', Regexp::IGNORECASE) + + # list of filter functions can be found at https://developer.mozilla.org/en-US/docs/Web/CSS/filter#syntax + FILTER_NAME_REGEX = Regexp.new("blur|brightness|contrast|grayscale|hue-rotate|invert|opacity|saturate|sepia", Regexp::IGNORECASE) + FILTER_FUNCTION_REGEX = Regexp.new("#{FILTER_NAME_REGEX}#{PAREN_NUMBER_REGEX}") + + # drop-shadow can take multiple values, which are a mix of numbers and colors + DROP_SHADOW_NAME_REGEX = Regexp.new("drop-shadow", Regexp::IGNORECASE) + DROP_SHADOW_VALUE_REGEX = Regexp.new("\\(\\s*(#{NUMBER_WITH_UNIT_REGEX}|#{COLOR_REGEX}\\s*)+\\s*\\)") + DROP_SHADOW_FUNCTION_REGEX = Regexp.new("#{DROP_SHADOW_NAME_REGEX}#{DROP_SHADOW_VALUE_REGEX}") + + # Custom properties (variables) are declared using --name: value and accessed + # using property: var(--name). The var() function can be more complex, e.g., + # var(--name, fallback value), but we're keeping our implementation simple. + CUSTOM_PROPERTY_NAME_REGEXP = Regexp.new("\\-\\-[0-9a-z\\-_]+", Regexp::IGNORECASE) + PAREN_CUSTOM_PROPERTY_REGEX = Regexp.new("\\(\\s*#{CUSTOM_PROPERTY_NAME_REGEXP}\\s*\\)", Regexp::IGNORECASE) + VAR_FUNCTION_REGEX = Regexp.new("var#{PAREN_CUSTOM_PROPERTY_REGEX}", Regexp::IGNORECASE) + + # To allow the url() function, it is also necessary to include "url" in ArchiveConfig.SUPPORTED_CSS_KEYWORDS + # from the ICANN list at http://www.icann.org/en/registries/top-level-domains.htm + TOP_LEVEL_DOMAINS = %w(ac ad ae aero af ag ai al am an ao aq ar arpa as asia at au aw ax az ba bb bd be bf bg bh bi biz bj bm bn bo br bs bt bv bw by bz ca cat cc cd cf cg ch ci ck cl cm cn co com coop cr cu cv cx cy cz de dj dk dm do dz ec edu ee eg er es et eu fi fj fk fm fo fr ga gb gd ge gf gg gh gi gl gm gn gov gp gq gr gs gt gu gw gy hk hm hn hr ht hu id ie il im in info int io iq ir is it je jm jo jobs jp ke kg kh ki km kn kp kr kw ky kz la lb lc li lk lr ls lt lu lv ly ma mc md me mg mh mil mk ml mm mn mo mobi mp mq mr ms mt mu museum mv mw mx my mz na name nc ne net nf ng ni nl no np nr nu nz om org pa pe pf pg ph pk pl pm pn pr pro ps pt pw py qa re ro rs ru rw sa sb sc sd se sg sh si sj sk sl sm sn so sr st su sv sy sz tc td tel tf tg th tj tk tl tm tn to tp tr travel tt tv tw tz ua ug uk us uy uz va vc ve vg vi vn vu wf ws xn xxx ye yt za zm zw) + DOMAIN_REGEX = Regexp.new('https?://\w[\w\-\.]+\.(' + TOP_LEVEL_DOMAINS.join('|') + ')') + DOMAIN_OR_IMAGES_REGEX = Regexp.new('\/images|' + DOMAIN_REGEX.to_s) + URI_REGEX = Regexp.new(DOMAIN_OR_IMAGES_REGEX.to_s + '/[\w\-\.\/]*[\w\-]\.(' + ArchiveConfig.SUPPORTED_EXTERNAL_URLS.join('|') + ')') + URL_REGEX = Regexp.new(URI_REGEX.to_s + '|"' + URI_REGEX.to_s + '"|\'' + URI_REGEX.to_s + '\'') + URL_FUNCTION_REGEX = Regexp.new('url\(\s*' + URL_REGEX.to_s + '\s*\)') + + VALUE_REGEX = Regexp.new("#{TRANSFORM_FUNCTION_REGEX}|#{URL_FUNCTION_REGEX}|#{COLOR_STOP_FUNCTION_REGEX}|#{COLOR_REGEX}|#{NUMBER_WITH_UNIT_REGEX}|#{ALPHA_REGEX}|#{SHAPE_FUNCTION_REGEX}|#{FILTER_FUNCTION_REGEX}|#{DROP_SHADOW_FUNCTION_REGEX}|#{VAR_FUNCTION_REGEX}") + + + # For use in ActiveRecord models + # We parse and clean the CSS line by line in order to provide more helpful error messages. + # The prefix is used if you want to make sure a particular prefix appears on all the selectors in + # this block of css, eg ".userstuff p" instead of just "p" + def clean_css_code(css_code, options = {}) + return "" if !css_code.match(/\w/) # only spaces of various kinds + clean_css = "" + parser = CssParser::Parser.new + parser.add_block!(css_code) + + prefix = options[:prefix] || '' + caller_check = options[:caller_check] + + errors.add(:base, :no_valid_css) if parser.to_s.blank? + + parser.each_rule_set do |rs| + selectors = rs.selectors.map do |selector| + if selector.match(/@font-face/i) + errors.add(:base, :font_face) + next + end + # remove whitespace and convert > entities back to the > direct child selector + sel = selector.gsub(/\n/, "").gsub(">", ">").strip + (prefix.blank? || sel.start_with?(prefix)) ? sel : "#{prefix} #{sel}" + end + clean_declarations = "" + # Do not internationalize the , used as a join in these errors -- it's reflective of the comma used in the list of selectors, which does not change based on locale. + rs.each_declaration do |property, value, is_important| + if property.blank? || value.blank? + errors.add(:base, :no_valid_css_for_selectors, selectors: rs.selectors.join(", ")) + elsif sanitize_css_property(property).blank? + # If it starts with --, assume the user was trying to define a custom property. + if property.match(/\A--/) + errors.add(:base, :invalid_custom_property_name, property: property, selectors: rs.selectors.join(", ")) + else + errors.add(:base, :banned_property, property: property) + end + elsif (cleanval = sanitize_css_declaration_value(property, value)).blank? + errors.add(:base, :banned_value_for_property, property: property, selectors: rs.selectors.join(", "), value: value) + elsif !caller_check || caller_check.call(rs, property, value) + clean_declarations += " #{property}: #{cleanval}#{is_important ? ' !important' : ''};\n" + end + end + if clean_declarations.blank? + errors.add(:base, :no_rules_for_selectors, selectors: rs.selectors.join(", ")) + else + # everything looks ok, add it to the css + clean_css += "#{selectors.join(",\n")} {\n" + clean_css += clean_declarations + clean_css += "}\n\n" + end + end + return clean_css + end + + def legal_property?(property) + ArchiveConfig.SUPPORTED_CSS_PROPERTIES.include?(property) || + property.match(/-(#{PREFIX_REGEX})-(#{ArchiveConfig.SUPPORTED_CSS_PROPERTIES.join('|')})/) + end + + def legal_shorthand_property?(property) + property.match(/#{ArchiveConfig.SUPPORTED_CSS_SHORTHAND_PROPERTIES.join('|')}/) + end + + def custom_property?(property) + property.match(/\A(#{CUSTOM_PROPERTY_NAME_REGEXP})\z/) + end + + def sanitize_css_property(property) + return property if legal_property?(property) || legal_shorthand_property?(property) || custom_property?(property) + end + + # A declaration must match the format `property: value;` (space and semicolon + # are optional in user input). + # All properties must appear in ArchiveConfig.SUPPORTED_CSS_PROPERTIES or + # ArchiveConfig.SUPPORTED_CSS_SHORTHAND_PROPERTIES, or that property and its + # value will be removed and an error message will be given. + # All values are sanitized. If any values in a declaration are invalid, the + # value will be blanked out and an empty property returned, which will result + # in an error. + def sanitize_css_declaration_value(property, value) + clean = "" + if property == "font-family" + # preserve the original capitalization + clean = value if sanitize_css_font(value).present? + elsif property == "content" + # don't allow var() function + clean = value.match(/\bvar\b/i) ? "" : sanitize_css_content(value) + # The url() function can be used in the values for certain properties, + # provided "url" is included in ArchiveConfig.SUPPORTED_CSS_KEYWORDS. If + # those criteria are not met, we strip the value here. If they are met, the + # value will undergo sanitization in tokenize_and_sanitize_css_value or + # sanitize_css_value. + elsif value.match(/\burl\b/i) && (ArchiveConfig.SUPPORTED_CSS_KEYWORDS.exclude?("url") || %w[background background-image border border-image list-style list-style-image].exclude?(property)) + clean = "" + elsif legal_shorthand_property?(property) || custom_property?(property) + clean = tokenize_and_sanitize_css_value(value) + elsif legal_property?(property) + clean = sanitize_css_value(value) + end + clean.strip + end + + # divide a css value into tokens and clean them individually + def tokenize_and_sanitize_css_value(value) + cleanval = "" + scanner = StringScanner.new(value) + + # we scan until we find either a space, a comma, or an open parenthesis + while scanner.exist?(/\s+|,|\(/) + # we have some tokens left to break up + in_paren = 0 + token = scanner.scan_until(/\s+|,|\(/) + if token.blank? || token == "," + cleanval += token + next + end + in_paren = 1 if token.match(/\($/) + while in_paren > 0 + # scan until closing paren or another opening paren + nextpart = scanner.scan_until(/\(|\)/) + if nextpart + token += nextpart + in_paren += 1 if token.match(/\($/) + in_paren -= 1 if token.match(/\)$/) + else + # mismatched parens + return "" + end + end + + # we now have a single token + separator = token.match(/(\s|,)$/) || "" + token.strip! + token.chomp!(',') + cleantoken = sanitize_css_token(token) + return "" if cleantoken.blank? + cleanval += cleantoken + separator.to_s + end + + token = scanner.rest + if token && !token.blank? + cleantoken = sanitize_css_token(token) + return "" if cleantoken.blank? + cleanval += cleantoken + end + + return cleanval + end + + def sanitize_css_token(token) + if token.match?(/gradient/) + sanitize_css_gradient(token) + else + sanitize_css_value(token) + end + end + + # sanitize a CSS gradient + # background:-webkit-gradient( linear, left bottom, left top, color-stop(0, rgb(82,82,82)), color-stop(1, rgb(125,124,125))); + # -moz-linear-gradient(bottom, rgba(120,120,120,1) 5%, rgba(94,94,94,1) 50%, rgba(108,108,108,1) 55%, rgba(137,137,137,1) 100%); + def sanitize_css_gradient(value) + if value.match(/^([a-z\-]+)\((.*)\)/) + function = $1 + interior = $2 + cleaned_interior = tokenize_and_sanitize_css_value(interior) + if function.match(/gradient/) && !cleaned_interior.blank? + return "#{function}(#{cleaned_interior})" + end + end + return "" + end + + # All values must be either + # - in ArchiveConfig.SUPPORTED_CSS_KEYWORDS + # - URLs of the format url(http://url/) + # - rgba(), hsla(), hex, or named colors + # - numeric values + # - transform, shape, filter, drop shadow, or variable functions + # Comma-separated lists of these values are also allowed. + def sanitize_css_value(value) + value_stripped = strip_value(value) + + # If it's a comma-separated set of valid values, it's fine. However, we need + # to downcase any var() functions to match the css_parser gem's downcasing + # of property names. + if value_stripped.match?(/^(#{VALUE_REGEX},?\s*)+$/i) + return value unless value.match?(/#{VAR_FUNCTION_REGEX}/) + + return value.gsub(/#{VAR_FUNCTION_REGEX}/, &:downcase) + end + + # If the value is explicitly in our list of supported keywords, it's fine. + # However, note that !important is always allowed (refer to the comments on + # strip_value(value) and ArchiveConfig.SUPPORTED_CSS_KEYWORDS for more), and + # that the url() function is allowed by the VALUE_REGEX above. Excluding + # url() from SUPPORTED_CSS_KEYWORDS only strips it because of the check in + # sanitize_css_declaration_value. + return value if value_stripped.split(",").all? { |subval| ArchiveConfig.SUPPORTED_CSS_KEYWORDS.include?(subval.strip) } + + return "" + end + + def sanitize_css_content(value) + # For now we only allow a single completely quoted string + return value if value =~ /^\'([^\']*)\'$/ + return value if value =~ /^\"([^\"]*)\"$/ + + # or a valid img url + return value if value.match(Regexp.new("^#{URL_FUNCTION_REGEX}$")) + + # or "none" + return value if value == "none" + + return "" + end + + # Font family names may be alphanumeric values with dashes + def sanitize_css_font(value) + value_stripped = strip_value(value) + if value_stripped.split(',').all? {|fontname| fontname.strip =~ /^(\'?[a-z0-9\- ]+\'?|\"?[a-z0-9\- ]+\"?)$/} + return value + else + return "" + end + end + + # Remove !important and trailing spaces from values to simplify sanitization. + # In most cases, we return the original value after sanitizaiton, which + # restores the !important keyword. + # Note that this means !important is always allowed, regardless of whether it + # is included in ArchiveConfig.SUPPORTED_CSS_KEYWORDS. + def strip_value(value) + value.downcase.gsub(/(!important)/, "").strip + end +end diff --git a/lib/devise_failure_message_options.rb b/lib/devise_failure_message_options.rb new file mode 100644 index 0000000..f673e2c --- /dev/null +++ b/lib/devise_failure_message_options.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# Mark the error messages as html_safe (to allow links), and define a few +# variables that can be used in all messages. +class DeviseFailureMessageOptions < Devise::FailureApp + def default_i18n_variables + @default_i18n_variables ||= { + reset_path: new_user_password_path, + problems_path: admin_post_path(12035), + app_name: ArchiveConfig.APP_SHORT_NAME + } + end + + def i18n_options(options) + options.merge(default_i18n_variables) + end + + def i18n_message(*args) + super(*args).html_safe + end +end diff --git a/lib/html_cleaner.rb b/lib/html_cleaner.rb new file mode 100644 index 0000000..c2be06c --- /dev/null +++ b/lib/html_cleaner.rb @@ -0,0 +1,160 @@ +# note, if you modify this file you have to restart the server or console +module HtmlCleaner + # If we aren't sure that this field hasn't been sanitized since the last sanitizer version, + # we sanitize it before we allow it to pass through (and save it if possible). + # For certain highly visible fields, we might want to be cautious about + # images. Use image_safety_mode: true to prevent images from embedding as-is. + def sanitize_field(object, fieldname, image_safety_mode: false) + return "" if object.send(fieldname).nil? + + sanitizer_version = object.try("#{fieldname}_sanitizer_version") + sanitized_field = + if sanitizer_version && sanitizer_version >= ArchiveConfig.SANITIZER_VERSION + # return the field without sanitizing + object.send(fieldname) + else + # no sanitizer version information, so re-sanitize + sanitize_value(fieldname, object.send(fieldname)) + end + + sanitized_field = strip_images(sanitized_field, keep_src: true) if image_safety_mode + sanitized_field + end + + # yank out bad end-of-line characters and evil msword curly quotes + def fix_bad_characters(text) + return "" if text.nil? + + # get the text into UTF-8 and get rid of invalid characters + text = text.encode("UTF-8", invalid: :replace, undef: :replace, replace: "") + + text.gsub! "<3", "<3" + + # convert carriage returns to newlines + text.gsub!(/\r\n?/, "\n") + + # argh, get rid of ____spacer____ inserts + text.gsub! "____spacer____", "" + + return text + end + + def sanitize_value(field, value) + return value if ArchiveConfig.FIELDS_WITHOUT_SANITIZATION.include?(field.to_s) + if ArchiveConfig.NONZERO_INTEGER_PARAMETERS.has_key?(field.to_s) + return (value.to_i > 0) ? value.to_i : ArchiveConfig.NONZERO_INTEGER_PARAMETERS[field.to_s] + end + return "" if value.blank? + unfrozen_value = value&.dup + unfrozen_value.strip! + if field.to_s == 'title' + # prevent invisible titles + unfrozen_value.gsub!("<", "<") + unfrozen_value.gsub!(">", ">") + end + if ArchiveConfig.FIELDS_ALLOWING_LESS_THAN.include?(field.to_s) + unfrozen_value.gsub!("<", "<") + end + if ArchiveConfig.FIELDS_ALLOWING_HTML.include?(field.to_s) + # We're allowing users to use HTML in this field + transformers = [ + Sanitize::Config::OPEN_ATTRIBUTE_TRANSFORMER, + Sanitize::Config::RELATIVE_IMAGE_PATH_TRANSFORMER + ] + if ArchiveConfig.FIELDS_ALLOWING_MEDIA_EMBEDS.include?(field.to_s) + transformers << OtwSanitize::EmbedSanitizer.transformer + transformers << OtwSanitize::MediaSanitizer.transformer + end + if ArchiveConfig.FIELDS_ALLOWING_CSS.include?(field.to_s) + transformers << OtwSanitize::UserClassSanitizer.transformer + end + # Now that we know what transformers we need, let's sanitize the unfrozen value + if ArchiveConfig.FIELDS_ALLOWING_CSS.include?(field.to_s) + unfrozen_value = Sanitize.clean(add_paragraphs_to_text(fix_bad_characters(unfrozen_value)), + Sanitize::Config::CSS_ALLOWED.merge(transformers: transformers)) + else + unfrozen_value = Sanitize.clean(add_paragraphs_to_text(fix_bad_characters(unfrozen_value)), + Sanitize::Config::ARCHIVE.merge(transformers: transformers)) + end + doc = Nokogiri::HTML5::Document.new + doc.encoding = "UTF-8" + unfrozen_value = doc.fragment(unfrozen_value).to_html + else + # clean out all tags + unfrozen_value = Sanitize.clean(fix_bad_characters(unfrozen_value)) + end + + # Plain text fields can't contain & entities: + unfrozen_value.gsub!(/&/, '&') unless (ArchiveConfig.FIELDS_ALLOWING_HTML_ENTITIES + ArchiveConfig.FIELDS_ALLOWING_HTML).include?(field.to_s) + + # Temporary hack to evade conversions by strip_html_breaks() for textarea: + # Accidentally or not, for a long time sanitization code gets nbsp unescaped, + # and this replacement keeps that behavior + unfrozen_value.gsub!(" ", "\u00A0") + unfrozen_value + end + + # grabbed from http://code.google.com/p/sanitizeparams/ and tweaked + def sanitize_params(new_params = params) + walk_hash(new_params) if new_params + end + + def walk_hash(hash) + hash.keys.each do |key| + if hash[key].is_a? String + hash[key] = sanitize_value(key, hash[key]) + elsif hash[key].is_a?(ActionController::Parameters) + hash[key] = hash[key].to_hash + elsif hash[key].is_a?(Hash) + hash[key] = walk_hash(hash[key]) + elsif hash[key].is_a? Array + hash[key] = walk_array(hash[key]) + end + end + hash + end + + def walk_array(array) + array.each_with_index do |el,i| + if el.is_a? String + array[i] = sanitize_value("", el) + elsif el.is_a? Hash + array[i] = walk_hash(el) + elsif el.is_a? Array + array[i] = walk_array(el) + end + end + array + end + + def add_paragraphs_to_text(text) + # Adding paragraphs in place of linebreaks + doc = Nokogiri::HTML5.fragment("#{text}") + myroot = doc.children.first + ParagraphMaker.process(myroot) + myroot.children.to_html + end + + ### STRIPPING FOR DISPLAY ONLY + # Regexps for stripping particular tags and attributes for display. + # These assume they are running on well-formed XHTML, which we can do + # because they will only be used on already-cleaned fields. + + # strip img tags, optionally leaving the HTML attributes (e.g. src and alt) exposed + def strip_images(value, keep_src: false) + value.gsub(%r{(?:<(img .*?) ?/?>)}, keep_src ? "\\1" : "") + end + + def strip_html_breaks_simple(value) + return "" if value.blank? + value.gsub(/\s*
    \s*/, "
    \n"). + gsub(/\s*]*>\s* \s*<\/p>\s*/, "\n\n\n"). + gsub(/\s*]*>(.*?)<\/p>\s*/m, "\n\n" + '\1'). + strip + end + + def add_break_between_paragraphs(value) + return "" if value.blank? + value.gsub(%r{\s*

    \s*

    \s*}, "


    ") + end +end diff --git a/lib/otw_sanitize/embed_sanitizer.rb b/lib/otw_sanitize/embed_sanitizer.rb new file mode 100644 index 0000000..6edf210 --- /dev/null +++ b/lib/otw_sanitize/embed_sanitizer.rb @@ -0,0 +1,205 @@ +# frozen_string_literal: true + +require "addressable/uri" +require "cgi" + +module OtwSanitize + # Creates a Sanitize transformer to sanitize embedded media + class EmbedSanitizer + ALLOWLIST_REGEXES = { + "4shared": %r{^4shared\.com/web/embed}, + audiocom: %r{^audio\.com/embed/audio/}, + archiveorg: %r{^archive\.org/embed/}, + bilibili: %r{^(player\.)?bilibili\.com/}, + criticalcommons: %r{^criticalcommons\.org/}, + eighttracks: %r{^8tracks\.com/}, + google: %r{^google\.com/}, + podfic: %r{^podfic\.com/}, + soundcloud: %r{^(w\.)?soundcloud\.com/}, + spotify: %r{^(open\.)?spotify\.com/}, + viddersnet: %r{^vidders\.net/}, + viddertube: %r{^viddertube\.com/}, + vimeo: %r{^(player\.)?vimeo\.com/}, + youtube: %r{^youtube(-nocookie)?\.com/} + }.freeze + + ALLOWS_FLASHVARS = %i[ + criticalcommons eighttracks google + podfic soundcloud spotify viddersnet + ].freeze + + SUPPORTS_HTTPS = %i[ + 4shared audiocom + archiveorg bilibili eighttracks podfic + soundcloud spotify viddersnet viddertube vimeo youtube + ].freeze + + # Creates a callable transformer for the sanitizer to use + def self.transformer + lambda do |env| + # Don't continue if this node is already safelisted. + return if env[:is_allowlisted] + + new(env[:node]).sanitized_node + end + end + + attr_reader :node + + # Takes a Nokogiri node + def initialize(node) + @node = node + end + + def sanitized_node + return unless embed_node? + return unless source_url && source + + ensure_https + + if parent_name == "object" + sanitize_object + else + sanitize_embed + end + end + + def node_name + node.name.to_s.downcase + end + + delegate :parent, to: :node + + def parent_name + parent.name.to_s.downcase if parent + end + + # Since the transformer receives the deepest nodes first, we look for a + # element whose parent is an , or an embed or iframe + def embed_node? + (node_name == "param" && parent_name == "object") || + %w[embed iframe].include?(node_name) + end + + # Compare the url to our list of allowlisted sources + # and return the appropriate source symbol + def source + return @source if @source + + ALLOWLIST_REGEXES.each_pair do |name, reg| + if source_url =~ reg + @source = name + break + end + end + @source + end + + # Get the url of the thing we're embedding and standardize it + def source_url + return @source_url if @source_url + + if node_name == "param" + # Quick XPath search to find the node that contains the video URL. + return unless (movie_node = node.parent.search('param[@name="movie"]')[0]) + + url = movie_node["value"] + else + url = node["src"] + end + @source_url = standardize_url(url) + end + + def standardize_url(url) + # strip off optional protocol and www + protocol_regex = %r{^(?:https?:)?//(?:www\.)?}i + # normalize the url + url = url&.gsub(protocol_regex, "") + begin + Addressable::URI.parse(url).normalize.to_s + rescue StandardError + nil + end + end + + # For sites that support https, ensure we use a secure embed + def ensure_https + return unless supports_https? && node["src"].present? + + node["src"] = node["src"].gsub("http:", "https:") + return unless allows_flashvars? && node["flashvars"].present? + + node["flashvars"] = node["flashvars"].gsub("http:", "https:") + node["flashvars"] = node["flashvars"].gsub("http%3A", "https%3A") + end + + # We're now certain that this is an embed from a trusted source, but we + # still need to run it through a special Sanitize step to ensure + # that no unwanted elements or attributes that don't belong in + # a video embed can sneak in. + def sanitize_object + Sanitize.clean_node!( + parent, + elements: %w[embed object param], + attributes: { + "embed" => %w[allowfullscreen height src type width], + "object" => %w[height width], + "param" => %w[name value] + } + ) + + disable_scripts(parent) + + { node_allowlist: [node, parent] } + end + + def sanitize_embed + Sanitize.clean_node!( + node, + elements: %w[embed iframe], + attributes: { + "embed" => %w[ + allowfullscreen height src type width + ] + optional_embed_attributes, + "iframe" => %w[ + allowfullscreen frameborder height src title + class type width + ] + } + ) + + if node_name == "embed" + disable_scripts(node) + node["flashvars"] = "" unless allows_flashvars? + end + { node_allowlist: [node] } + end + + # disable script access and networking + def disable_scripts(embed_node) + embed_node["allowscriptaccess"] = "never" + embed_node["allownetworking"] = "internal" + + embed_node.search("param").each do |param_node| + param_node.unlink if param_node[:name].casecmp?("allowscriptaccess") || + param_node[:name].casecmp?("allownetworking") + end + end + + def optional_embed_attributes + if allows_flashvars? + %w[wmode flashvars] + else + [] + end + end + + def allows_flashvars? + ALLOWS_FLASHVARS.include?(source) + end + + def supports_https? + SUPPORTS_HTTPS.include?(source) + end + end +end diff --git a/lib/otw_sanitize/media_sanitizer.rb b/lib/otw_sanitize/media_sanitizer.rb new file mode 100644 index 0000000..e5cab04 --- /dev/null +++ b/lib/otw_sanitize/media_sanitizer.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +# Creates a Sanitize transformer to sanitize audio and video tags +module OtwSanitize + class MediaSanitizer + # Attribute allowlists + AUDIO_ATTRIBUTES = %w[ + class controls crossorigin dir + loop muted preload src title + ].freeze + + VIDEO_ATTRIBUTES = %w[ + class controls crossorigin dir height loop + muted playsinline poster preload src title width + ].freeze + + SOURCE_ATTRIBUTES = %w[src type].freeze + TRACK_ATTRIBUTES = %w[default kind label src srclang].freeze + + ALLOWLIST_CONFIG = { + elements: %w[ + audio video source track + ] + Sanitize::Config::ARCHIVE[:elements], + attributes: { + "audio" => AUDIO_ATTRIBUTES, + "video" => VIDEO_ATTRIBUTES, + "source" => SOURCE_ATTRIBUTES, + "track" => TRACK_ATTRIBUTES + }, + add_attributes: { + "audio" => { + "controls" => "controls", + "crossorigin" => "anonymous", + "preload" => "metadata" + }, + "video" => { + "controls" => "controls", + "playsinline" => "playsinline", + "crossorigin" => "anonymous", + "preload" => "metadata" + } + }, + protocols: { + "audio" => { + "src" => %w[http https] + }, + "video" => { + "poster" => %w[http https], + "src" => %w[http https] + }, + "source" => { + "src" => %w[http https] + }, + "track" => { + "src" => %w[http https] + } + } + }.freeze + + # Creates a callable transformer for the sanitizer to use + def self.transformer + lambda do |env| + # Don't continue if this node is already safelisted. + return if env[:is_allowlisted] + + new(env[:node]).sanitized_node + end + end + + attr_reader :node + + # Takes a Nokogiri node + def initialize(node) + @node = node + end + + # Skip if it's not media or if we don't want to allowlist it + def sanitized_node + return unless media_node? + return if banned_source? + + config = Sanitize::Config.merge(Sanitize::Config::ARCHIVE, ALLOWLIST_CONFIG) + Sanitize.clean_node!(node, config) + tidy_boolean_attributes(node) + { node_allowlist: [node] } + end + + def node_name + node.name.to_s.downcase + end + + def media_node? + %w[audio video source track].include?(node_name) + end + + def source_url + node["src"] || "" + end + + def source_host + url = source_url + return nil if url.blank? + + # Just in case we're missing a protocol + url = "https://" + url unless url =~ /http/ + Addressable::URI.parse(url).normalize.host + end + + def banned_source? + return unless source_host + + ArchiveConfig.BANNED_MULTIMEDIA_SRCS.any? do |blocked| + source_host.match(blocked) + end + end + + # Sanitize outputs boolean attributes as attribute="". While this works, + # attribute="attribute" is more consistent with the way we handle the + # boolean attributes we automatically add (e.g. controls="controls"). + def tidy_boolean_attributes(node) + node["default"] = "default" if node["default"] + node["loop"] = "loop" if node["loop"] + node["muted"] = "muted" if node["muted"] + end + end +end diff --git a/lib/otw_sanitize/user_class_sanitizer.rb b/lib/otw_sanitize/user_class_sanitizer.rb new file mode 100644 index 0000000..e32651c --- /dev/null +++ b/lib/otw_sanitize/user_class_sanitizer.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +# allow users to specify class attributes in their html +# scrub invalid class names +module OtwSanitize + class UserClassSanitizer + def self.transformer + lambda do |env| + # Check this node even if it is already safelisted. + new(env[:node]).sanitized_node + end + end + + attr_reader :node + + # Takes a Nokogiri node + def initialize(node) + @node = node + end + + # Update the class attribute with the sanitized value + def sanitized_node + return if user_classes.blank? + node['class'] = sanitized_classes + node + end + + # Turn the class string into an array, select the valid values + # then rejoin them + def sanitized_classes + user_classes.split(" "). + select { |user_class| valid_class?(user_class) }. + join(" ") + end + + # let through alphanumeric class names with a dash/underscore + def valid_class?(str) + str =~ /^[a-zA-Z][\w\-]+$/ + end + + # If the element is something like

    + # then the value will be the string "apple banana" + def user_classes + node['class'] + end + end +end diff --git a/lib/pagination_list_link_renderer.rb b/lib/pagination_list_link_renderer.rb new file mode 100644 index 0000000..b344dbb --- /dev/null +++ b/lib/pagination_list_link_renderer.rb @@ -0,0 +1,62 @@ +# encoding: UTF-8 +# Almost entirely taken from http://thewebfellas.com/blog/2010/8/22/revisited-roll-your-own-pagination-links-with-will_paginate-and-rails-3 +# +#

    Pages Navigation

    +# + +class PaginationListLinkRenderer < WillPaginate::ActionView::LinkRenderer + + def to_html + html = pagination.map do |item| + if item.is_a?(Integer) + page_number(item, @options[:remote]) + else + send(item) + end + end.join(@options[:link_separator]) + + @options[:container] ? html_container(html) : html + end + + protected + + def gap + tag(:li, "…", class: "gap") + end + + def page_number(page, remote = nil) + unless page == current_page + tag(:li, link(page, page, {rel: rel_value(page)}.merge(:"data-remote" => remote))) + else + tag(:li, tag(:span, page, class: "current")) + end + end + + def previous_page + num = @collection.current_page > 1 && @collection.current_page - 1 + previous_or_next_page(num, @options[:previous_label], 'previous', @options[:remote]) + end + + def next_page + num = @collection.current_page < @collection.total_pages && @collection.current_page + 1 + previous_or_next_page(num, @options[:next_label], 'next', @options[:remote]) + end + + def previous_or_next_page(page, text, classname, remote = nil) + if page + tag(:li, link(text, page, {:"data-remote" => remote}), class: classname, title: classname) + else + tag(:li, tag(:span, text, class: "disabled"), class: classname, title: classname) + end + end + + def html_container(html) + tag(:h4, "Pages Navigation", class: "landmark heading") + + tag(:ol, html, container_attributes.merge(class: "pagination actions", role: "navigation", title: "pagination")) + end + +end diff --git a/lib/paragraph_maker.rb b/lib/paragraph_maker.rb new file mode 100644 index 0000000..cc13153 --- /dev/null +++ b/lib/paragraph_maker.rb @@ -0,0 +1,280 @@ +# frozen_string_literals: true + +# Traverse a Nokogiri document tree recursively in order to insert linebreaks. +module ParagraphMaker + extend self + + # Tags that will be stripped by the sanitizer that are inline, and should be + # wrapped in paragraphs and have their contents left untouched: + INLINE_INVALID_TAGS = %w[button input label map select textarea].freeze + + # Tags that will be stripped by the sanitizer that are block tags, and should + # have nearby whitespace stripped: + BLOCK_INVALID_TAGS = %w[fieldset form].freeze + + # Tags that will be completely removed by the sanitizer, and should have + # nearby whitespace removed, and their contents left untouched: + REMOVED_INVALID_TAGS = Sanitize::Config::ARCHIVE[:remove_contents] + + # Tags whose content we don't touch + TAG_NAMES_TO_SKIP = (%w[ + a abbr acronym address audio dl embed figure h1 h2 h3 h4 h5 h6 hr img ol + object p pre source summary table track video ul + ] + INLINE_INVALID_TAGS + REMOVED_INVALID_TAGS).freeze + + # Tags that need to go inside p tags + TAG_NAMES_TO_WRAP = (%w[ + a abbr acronym b big br cite code del dfn em i img ins kbd q rp rt ruby + s samp small span strike strong sub sup tt u var + ] + INLINE_INVALID_TAGS).freeze + + # Tags that can't be inside p tags + TAG_NAMES_TO_UNWRAP = %w[ + audio details dl figure h1 h2 h3 h4 h5 h6 hr ol p pre source summary table track ul video + ].freeze + + # Tags before and after which we don't want to convert linebreaks + # into br's and p's + TAG_NAMES_STRIP_WHITESPACE = (%w[ + audio blockquote br center details dl div figure figcaption h1 h2 h3 h4 h5 h6 hr ol + p pre source summary table track ul video + ] + BLOCK_INVALID_TAGS + REMOVED_INVALID_TAGS).freeze + + private + + # Traverse all nodes from the given root, yielding each node to the given + # block. Processes nodes that are on the list of TAG_NAMES_TO_SKIP, but + # doesn't process any of their children. + def traverse(node, &block) + block.call(node) + + return if TAG_NAMES_TO_SKIP.include?(node.name) + + node.children.each do |child| + traverse(child, &block) + end + end + + # Checks whether the given node is in a paragraph. This includes nodes where + # the paragraph is many layers up. + def in_paragraph?(node) + return false if node.parent.nil? + return true if node.parent.name == "p" + + in_paragraph?(node.parent) + end + + # Strip whitespace before and after tags in the TAG_NAMES_STRIP_WHITESPACE + # list. + def strip_whitespace(root) + traverse(root) do |node| + next unless node.text? || node.cdata? + + # This text node immediately follows either the closing tag of its + # previous sibling, or the opening tag of its parent if it has no + # previous sibling. If we're supposed to ignore whitespace after that + # tag, we should lstrip to get rid of it. + node.content = node.content.lstrip if TAG_NAMES_STRIP_WHITESPACE.include?(node.previous_sibling&.name || node.parent&.name) + + # This text node is immediately followed by either the opening tag of its + # next sibling, or the closing tag of its parent. If that tag is one in + # the TAG_NAMES_STRIP_WHITESPACE list, we want to rstrip this node. + node.content = node.content.rstrip if TAG_NAMES_STRIP_WHITESPACE.include?(node.next_sibling&.name || node.parent&.name) + + node.unlink if node.content.empty? + end + end + + # Split text nodes when they have newlines: + def split_text_at_newlines(root) + traverse(root) do |node| + next unless node.text? + + pieces = node.to_s.split(/([[:space:]]*\n[[:space:]]*)/) + next unless pieces.length > 1 + + pieces.each do |piece| + next if piece.empty? + + case piece.count("\n") + when 0 + # No newlines, it's just a normal piece of text: + node.add_previous_sibling(piece) + when 1 + # One newline, replace it with a
    . + node.add_previous_sibling("
    \n") + when 2 + # Two newlines, replace it with a special tag (to be processed later) + # that marks the paragraph to be split at this location. + node.add_previous_sibling("") + else + # Three or more newlines, replace it with a special tag (to be + # processed later) that marks the paragraph to be split at this + # location, with a

     

    paragraph inserted to increase the + # spacing between lines. + node.add_previous_sibling("") + end + end + + node.unlink + end + end + + # Traverse the tree in search of sequences of
    tags. We want to find the + # last
    tag in the sequence, so that we don't process the sequence more + # than once. Once we've found the
    tag that has a prior
    tag, but no + # following
    tag, we can yank out all of the
    tags in the sequence, + # counting them as we go. + def merge_br_tags(root) + traverse(root) do |node| + next unless node.name == "br" && + node.next_sibling&.name != "br" && + node.previous_sibling&.name == "br" + + break_count = 1 + while node.previous_sibling&.name == "br" + node.previous_sibling.unlink + break_count += 1 + end + + if break_count == 2 + # Replace the
    sequence with a special tag (to be processed later) + # that indicates that whatever paragraph we're in needs to be split at + # this point. This corresponds to having two
    tags in a row. + node.replace("") + else + # Replace the
    sequence with a special tag (to be processed later) + # that indicates that whatever paragraph we're in needs to be split at + # this point, and there needs to be a

     

    paragraph inserted + # into the split. This corresponds to having 3+
    tags in a row. + node.replace("") + end + end + end + + # Go through all nodes that should be inside paragraph tags, and make sure + # they're wrapped: + def wrap_all(root) + # We don't use traverse(root) here to handle the traversal because we're + # doing something much more complex than usual with our children. + return if TAG_NAMES_TO_SKIP.include?(root.name) || in_paragraph?(root) || root.name == "p" + + # Divide up the list of children into sublists based on whether the node + # needs to have a paragraph wrapped around it, or not. + chunks = root.children.chunk do |node| + node.text? || node.cdata? || TAG_NAMES_TO_WRAP.include?(node.name) + end + + chunks.each do |should_wrap, children| + if should_wrap + # Create a paragraph object, and then transfer all of the children in + # this chunk to it. + paragraph = root.document.create_element("p") + children.first.add_previous_sibling(paragraph) + children.each do |node| + paragraph.add_child(node) + end + else + # These nodes don't need to be wrapped in a paragraph node, but some of + # their children might, so we have to process them. + children.each do |node| + wrap_all(node) + end + end + end + end + + # Given a node, "split" its parent at that node and move the node upwards one + # level -- that is, split its siblings by whether they're left/right of the + # node, and split the parent into two copies, one containing the left siblings + # and one containing the right siblings. + # + # For example, if we have something like this: + #
      + #
    • First
    • + #
    • Second
    • + #
    • Third
    • + #
    + # Then calling split_parent on the second list item will produce this: + #
      + #
    • First
    • + #
    + #
  • Second
  • + #
      + #
    • Third
    • + #
    + def split_parent(node) + # The easy cases: if this node is the first or last node in its parent, then + # there's no point in a proper split, because one of the two tags would just + # be empty. Instead, we rearrange ourselves relative to our parent. + return node.parent.add_previous_sibling(node) if node.previous_sibling.nil? + return node.parent.add_next_sibling(node) if node.next_sibling.nil? + + # Create a shallow copy of the parent: + new_parent = node.parent.dup(0) + + # Copy over all of the attributes: + node.parent.attributes.each do |name, attribute| + new_parent.set_attribute(name, attribute.value) + end + + # Put the new parent in the right place: + node.parent.add_next_sibling(new_parent) + + # Move over all of the siblings: + new_parent.add_child(node.next_sibling) until node.next_sibling.nil? + + # Put the node in the right place: + node.parent.add_next_sibling(node) + end + + # Remove the given node from its containing paragraph tag, if it has one. + def extract_from_paragraph(node) + split_parent(node) while in_paragraph?(node) + end + + # Go through all nodes that shouldn't be inside paragraph tags, and remove them + # from paragraph tags. + def unwrap_all(root) + traverse(root) do |node| + extract_from_paragraph(node) if TAG_NAMES_TO_UNWRAP.include?(node.name) + end + end + + # Process the tags inserted by split_text_at_newlines and + # merge_br_tags. Either way, we use extract_from_paragraph to split its + # parents until we reach the paragraph tag that contains it. If the split is + # marked as "long," we need to insert a

     

    blank paragraph. + # Reintroduce newlines instead to preserve chunks in separate lines. + def replace_splits(root) + root.css("split").each do |node| + extract_from_paragraph(node) + + if node.attribute("long") + node.replace("\n

     

    \n") + else + node.replace("\n") + end + end + end + + # Traverse the tree and delete any paragraph nodes that have no children. + def delete_empty_paragraphs(root) + traverse(root) do |node| + node.unlink if node.name == "p" && node.children.empty? + end + end + + public + + # Process the given node to add paragraphs to it and all of its children. + def process(root) + strip_whitespace(root) + split_text_at_newlines(root) + merge_br_tags(root) + wrap_all(root) + unwrap_all(root) + replace_splits(root) + delete_empty_paragraphs(root) + end +end diff --git a/lib/redis_scanning.rb b/lib/redis_scanning.rb new file mode 100644 index 0000000..b0b85c1 --- /dev/null +++ b/lib/redis_scanning.rb @@ -0,0 +1,11 @@ +module RedisScanning + def scan_set_in_batches(redis, key, batch_size:, &block) + redis.sscan_each(key, count: batch_size).each_slice(batch_size, &block) + end + + def scan_hash_in_batches(redis, key, batch_size:, &block) + redis.hscan_each(key, count: batch_size).each_slice(batch_size) do |batch| + block.call(batch.to_h) + end + end +end diff --git a/lib/redis_test_setup.rb b/lib/redis_test_setup.rb new file mode 100644 index 0000000..0373e05 --- /dev/null +++ b/lib/redis_test_setup.rb @@ -0,0 +1,20 @@ +# RAILS_ROOT/lib/redis_test_setup.rb + +module RedisTestSetup + + def start_redis!(rails_root, env) + dir_temp = File.expand_path(File.join(rails_root, 'log')) + dir_conf = File.expand_path(File.join(rails_root, 'config')) + cwd = Dir.getwd + Dir.chdir(rails_root) + raise "unable to launch redis-server" unless system("redis-server #{dir_conf}/redis-#{env}.conf") + Dir.chdir(cwd) + at_exit do + if (pid = `cat #{dir_temp}/redis-#{env}.pid`.strip) =~ /^\d+$/ + `rm -f #{dir_temp}/redis-cucumber-#{env}.rdb` + `rm -f #{dir_temp}/redis-#{env}.pid` + Process.kill("KILL", pid.to_i) + end + end + end +end diff --git a/lib/responder.rb b/lib/responder.rb new file mode 100644 index 0000000..4c31ec6 --- /dev/null +++ b/lib/responder.rb @@ -0,0 +1,21 @@ +module Responder + def update_work_stats + work = get_work + return unless work.present? + REDIS_GENERAL.sadd('works_to_update_stats', work.id) + end + + def get_work + work = nil + if self.respond_to?(:ultimate_parent) + work = self.ultimate_parent + elsif self.respond_to?(:commentable) + work = self.commentable + elsif self.respond_to?(:bookmarkable) + work = self.bookmarkable + end + + work.is_a?(Work) ? work : nil + end +end + diff --git a/lib/searchable.rb b/lib/searchable.rb new file mode 100644 index 0000000..e9bab94 --- /dev/null +++ b/lib/searchable.rb @@ -0,0 +1,65 @@ +module Searchable + + def self.included(searchable) + searchable.class_eval do + after_save :enqueue_to_index + after_destroy :enqueue_to_index + end + searchable.extend(ClassMethods) + end + + module ClassMethods + # A class method to reindex every item in the current relation. + def reindex_all(queue = :background) + distinct.select(:id).find_in_batches do |batch| + IndexQueue.enqueue_ids(base_class, batch.map(&:id), queue) + end + end + + def successful_reindex(ids) + # override to do something in response + end + + # Given search results from Elasticsearch, retrieve the corresponding hits + # from the database, ordered the same way. (If the database items + # corresponding to the search results don't exist, don't error, just notify + # IndexSweeper so that the Elasticsearch indices can be cleaned up.) + # Override for special behavior. + def load_from_elasticsearch(hits, scopes: nil) + ids = hits.map { |item| item['_id'] } + + # Apply the desired scopes to the ActiveRecord relation: + relation = self.all + Array(scopes).each do |scope| + relation = relation.public_send(scope) + end + + # Find results with where rather than find in order to avoid + # ActiveRecord::RecordNotFound + items = relation.where(id: ids).group_by(&:id) + IndexSweeper.async_cleanup(self, ids, items.keys) + ids.flat_map { |id| items[id.to_i] }.compact + end + end + + def enqueue_to_index + IndexQueue.enqueue(self, :main) + end + + def indexers + Indexer.for_object(self) + end + + def reindex_document(options = {}) + responses = [] + self.indexers.each do |indexer| + if options[:async] + queue = options[:queue] || :main + responses << AsyncIndexer.index(indexer, [id], queue) + else + responses << indexer.new([id]).index_document(self) + end + end + responses + end +end diff --git a/lib/skin_wizard.rb b/lib/skin_wizard.rb new file mode 100644 index 0000000..4fd418c --- /dev/null +++ b/lib/skin_wizard.rb @@ -0,0 +1,367 @@ +module SkinWizard + def header_styles(color) + if color.present? + " + #header .primary, + #footer, + .autocomplete .dropdown ul li:hover, + .autocomplete .dropdown li.selected, + a.tag:hover, + .listbox .heading a.tag:visited:hover, + .splash .favorite li:nth-of-type(odd) a:hover, + .splash .favorite li:nth-of-type(odd) a:focus, + #tos_prompt .heading { + background-image: none; + background-color: #{color}; + } + + #header .heading a, + #header .user a:hover, + #header .user a:focus, + #dashboard a:hover, + .actions a:hover, + .actions button:hover, + .actions input:hover, + .actions a:focus, + .actions button:focus, + .actions input:focus, + label.action:hover, + .action:hover, + .action:focus, + a.cloud1, + a.cloud2, + a.cloud3, + a.cloud4, + a.cloud5, + a.cloud6, + a.cloud7, + a.cloud8, + a.work, + .blurb h4 a:link, + .splash .module h3, + .splash .browse li a:before { + color: #{color}; + } + + #dashboard, + #dashboard.own { + border-color: #{color}; + } + + .actions .reindex a { + border-bottom-color: #{color}; + } + " + else + "" + end + end + + def font_size_styles(percentage) + if percentage.present? + " + body { + font-size: #{percentage}%; + } + " + else + "" + end + end + + def font_styles(names) + if names.present? + " + body, + .toggled form, + .dynamic form, + .secondary, + .dropdown, + blockquote, + pre, + input, + textarea, + button, + .heading .actions, + .heading .action, + .heading span.actions, + span.unread, + .replied, + span.claimed, + .actions span.defaulted { + font-family: #{names}; + } + " + else + "" + end + end + + def background_color_styles(color) + if color.present? + " + body, + .toggled form, + .dynamic form, + .secondary, + .dropdown, + th, + tr:hover, + col.name, + div.dynamic, + fieldset fieldset, + fieldset dl dl, + form blockquote.userstuff, + form.verbose legend, + .verbose form legend, + #modal, + .own, + .draft, + .draft .wrapper, + .unread, + .child, + .unwrangled, + .unreviewed, + .thread .even, + .listbox .index, + .nomination dt, + #tos_prompt { + background: #{color}; + } + + @media only screen and (max-width: 42em) { + #outer { + background: #{color} + } + } + + a.tag:hover, + .listbox .heading a.tag:visited:hover { + color: #{color}; + } + + tbody tr, + thead td, + #footer, + #modal { + border-color: #{color}; + } + + .toggled form, + .dynamic form, + .secondary, + .wrapper { + box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.5) + } + + .listbox, + fieldset fieldset.listbox { + box-shadow: 0 0 0 1px #{color}; + } + + .listbox .index { + box-shadow: inset 1px 1px 3px rgba(0, 0, 0, 0.5); + } + " + else + "" + end + end + + def foreground_color_styles(color) + if color.present? + " + body, + .toggled form, + .dynamic form, + .secondary, + .dropdown, + #header .search, + form dd.required, + .post .required .warnings, + dd.required, + .required .autocomplete, + span.series .divider, + .filters .expander, + .userstuff h2 { + color: #{color}; + } + + /* these colors should be separate, but for now... */ + a, + a:link, + a:visited, + a:hover, + #header a, + #header a:visited, + #header .primary .open a, + #header .primary .dropdown:hover a, + #header .primary .dropdown a:focus, + #header .primary .menu a, + #dashboard a, + #dashboard span, + a.tag, + .listbox > .heading, + .listbox .heading a:visited, + .filters dt a:hover { + color: #{color}; + } + + form dt, + .filters .group dt.bookmarker, + .faq .categories h3, + .splash .module h3, + .userstuff h3 { + border-color: #{color}; + } + + /* some things with unchanging background colors (e.g. caution notices) need the default text color */ + .qtip-content, + .notice:not(.required), + .comment_notice, + .kudos_notice, + ul.notes, + .caution, + .notice a { + color: #2a2a2a; + } + + .current, + a.current { + color: #111; + } + " + else + "" + end + end + + def paragraph_margin_styles(ems) + if ems.present? + " + .userstuff p { + margin: #{ems}em auto; + } + " + else + "" + end + end + + def accent_color_styles(color) + if color.present? + " + table, + thead td, + #header .actions a:hover, + #header .actions a:focus, + #header .dropdown:hover a, + #header .open a, + #header .menu, + #small_login, + fieldset, + form dl, + fieldset dl dl, + fieldset fieldset fieldset, + fieldset fieldset dl dl, + .ui-sortable li, + .ui-sortable li:hover, + dd.hideme, + form blockquote.userstuff, + dl.index dd, + .statistics .index li:nth-of-type(even), + .listbox, + fieldset fieldset.listbox, + .item dl.visibility, + .reading h4.viewed, + .comment h4.byline, + .splash .favorite li:nth-of-type(odd) a, + .splash .module div.account, + .search [role=\"tooltip\"] { + background: #{color}; + border-color: #{color}; + } + + #dashboard a:hover, + #dashboard .current, + li.relationships a { + background: #{color}; + } + + li.blurb, + fieldset, + form dl, + thead, + tfoot, + tfoot td, + th, + tr:hover, + col.name, + #dashboard ul, + .toggled form, + .dynamic form, + form.verbose legend, + .verbose form legend, + .secondary, + dl.meta, + .bookmark .user, + div.comment, + li.comment, + .comment div.icon, + .splash .news li, + .userstuff blockquote { + border-color: #{color}; + } + + fieldset, + form dl, + fieldset dl dl, + fieldset fieldset fieldset, + fieldset fieldset dl dl, + form blockquote.userstuff { + box-shadow: inset 1px 0 5px rgba(0, 0, 0, 0.5); + } + + fieldset dl, + fieldset.actions, + fieldset dl fieldset dl { + box-shadow: none; + } + + form.verbose legend, + .verbose form legend, + .ui-sortable li:hover { + box-shadow: 1px 2px 3px rgba(0, 0, 0, 0.5); + } + + @media only screen and (max-width: 62em) { + #dashboard .secondary { + background: #{color}; + box-shadow: none; + } + } + + @media only screen and (max-width: 42em) { + .javascript { + background: #{color}; + } + } + " + else + "" + end + end + + def work_margin_styles(percentage) + if percentage.present? + " + #workskin { + margin: auto #{percentage}%; + max-width: 100%; + } + " + else + "" + end + end +end diff --git a/lib/sortable_list.rb b/lib/sortable_list.rb new file mode 100644 index 0000000..a93112b --- /dev/null +++ b/lib/sortable_list.rb @@ -0,0 +1,14 @@ +class SortableList < Array + def reorder_list(positions) + list = self + changed = {} + positions.collect!(&:to_i).each_with_index do |new_position, old_position| + if new_position != 0 && new_position <= list.length && !changed.has_key?(new_position) + changed.merge!({new_position => list[old_position]}) + end + end + list -= changed.values + changed.sort.each {|pair| pair.first > list.length ? list << pair.last : list.insert(pair.first-1, pair.last)} + list.each_with_index {|list_item, index| list_item.update_attribute(:position, index + 1)} + end +end \ No newline at end of file diff --git a/lib/string_cleaner.rb b/lib/string_cleaner.rb new file mode 100644 index 0000000..b0b76f5 --- /dev/null +++ b/lib/string_cleaner.rb @@ -0,0 +1,12 @@ +module StringCleaner + + def remove_articles_from_string(str) + str.gsub(article_removing_regex, '') + end + + def article_removing_regex + Regexp.new(/^(a|an|the|la|le|les|l'|un|une|des|die|das|il|el|las|los|der|den)\s/i) + end + +end + diff --git a/lib/tasks/admin_tasks.rake b/lib/tasks/admin_tasks.rake new file mode 100644 index 0000000..ce32205 --- /dev/null +++ b/lib/tasks/admin_tasks.rake @@ -0,0 +1,32 @@ +namespace :admin do + desc "Unsuspend suspended users who have been suspended_until up to 12 hours from now" + task(:unsuspend_users => :environment) do + User.where(["suspended_until <= ?", 12.hours.from_now]).update_all("suspended_until = NULL, suspended = false") + puts "Users unsuspended." + end + + desc "Resend sign-up notification emails after 24 hours" + task(:resend_signup_emails => :environment) do + @users = User.where(confirmed_at: nil, created_at: 48.hours.ago..24.hours.ago) + @users.each do |user| + UserMailer.signup_notification(user.id).deliver_later + end + puts "Sign-up notification emails resent" + end + + desc "Purge unvalidated accounts created more than 2 weeks ago" + task(:purge_unvalidated_users => :environment) do + users = User.where("confirmed_at IS NULL AND created_at < ?", AdminSetting.current.days_to_purge_unactivated.weeks.ago) + puts users.map(&:login).join(", ") + users.map(&:destroy) + puts "Unvalidated accounts created more than two weeks ago have been purged" + + # Purged users are allowed to reuse their invitations: + invite_ids = users.map(&:invitation_id) + Invitation.includes(:creator).where(id: invite_ids).each do |invite| + invite.update(redeemed_at: nil, invitee: nil) + end + puts "Invitations for the purged accounts have been reset" + end + +end diff --git a/lib/tasks/after_tasks.rake b/lib/tasks/after_tasks.rake new file mode 100644 index 0000000..0ed0ee8 --- /dev/null +++ b/lib/tasks/after_tasks.rake @@ -0,0 +1,583 @@ +namespace :After do + # Keep only the most recent tasks, i.e., about two years' worth. + # If you need older tasks, check GitHub. + + desc "Update the mapping for the work index" + task(update_work_mapping: :environment) do + WorkIndexer.create_mapping + end + + desc "Fix tags with extra spaces" + task(fix_tags_with_extra_spaces: :environment) do + total_tags = Tag.count + total_batches = (total_tags + 999) / 1000 + puts "Inspecting #{total_tags} tags in #{total_batches} batches" + + report_string = ["Tag ID", "Old tag name", "New tag name"].to_csv + Tag.find_in_batches.with_index do |batch, index| + batch_number = index + 1 + progress_msg = "Batch #{batch_number} of #{total_batches} complete" + + batch.each do |tag| + next unless tag.name != tag.name.squish + + old_tag_name = tag.name + new_tag_name = old_tag_name.gsub(/[[:space:]]/, "_") + + new_tag_name << "_" while Tag.find_by(name: new_tag_name) + tag.update_attribute(:name, new_tag_name) + + report_row = [tag.id, old_tag_name, new_tag_name].to_csv + report_string += report_row + end + + puts(progress_msg) && STDOUT.flush + end + puts(report_string) && STDOUT.flush + end + + desc "Fix works imported with a noncanonical Teen & Up Audiences rating tag" + task(fix_teen_and_up_imported_rating: :environment) do + borked_rating_tag = Rating.find_by!(name: "Teen & Up Audiences") + canonical_rating_tag = Rating.find_by!(name: ArchiveConfig.RATING_TEEN_TAG_NAME) + + work_ids = [] + invalid_work_ids = [] + borked_rating_tag.works.find_each do |work| + work.ratings << canonical_rating_tag + work.ratings = work.ratings - [borked_rating_tag] + if work.save + work_ids << work.id + else + invalid_work_ids << work.id + end + print(".") && STDOUT.flush + end + + unless work_ids.empty? + puts "Converted '#{borked_rating_tag.name}' rating tag on #{work_ids.size} works:" + puts work_ids.join(", ") + STDOUT.flush + end + + unless invalid_work_ids.empty? + puts "The following #{invalid_work_ids.size} works failed validations and could not be saved:" + puts invalid_work_ids.join(", ") + STDOUT.flush + end + end + + desc "Clean up multiple rating tags" + task(clean_up_multiple_ratings: :environment) do + default_rating_tag = Rating.find_by!(name: ArchiveConfig.RATING_DEFAULT_TAG_NAME) + es_results = $elasticsearch.search(index: WorkIndexer.index_name, body: { + query: { + bool: { + filter: { + script: { + script: { + source: "doc['rating_ids'].length > 1", + lang: "painless" + } + } + } + } + } + }) + invalid_works = QueryResult.new("Work", es_results) + + puts "There are #{invalid_works.size} works with multiple ratings." + + fixed_work_ids = [] + unfixed_word_ids = [] + invalid_works.each do |work| + work.ratings = [default_rating_tag] + work.rating_string = default_rating_tag.name + + if work.save + fixed_work_ids << work.id + else + unfixed_word_ids << work.id + end + print(".") && $stdout.flush + end + + unless fixed_work_ids.empty? + puts "Cleaned up having multiple ratings on #{fixed_work_ids.size} works:" + puts fixed_work_ids.join(", ") + $stdout.flush + end + + unless unfixed_word_ids.empty? + puts "The following #{unfixed_word_ids.size} works failed validations and could not be saved:" + puts unfixed_word_ids.join(", ") + $stdout.flush + end + end + + desc "Clean up noncanonical rating tags" + task(clean_up_noncanonical_ratings: :environment) do + canonical_not_rated_tag = Rating.find_by!(name: ArchiveConfig.RATING_DEFAULT_TAG_NAME) + noncanonical_ratings = Rating.where(canonical: false) + puts "There are #{noncanonical_ratings.size} noncanonical rating tags." + + next if noncanonical_ratings.empty? + + puts "The following noncanonical Ratings will be changed into Additional Tags:" + puts noncanonical_ratings.map(&:name).join("\n") + + work_ids = [] + invalid_work_ids = [] + noncanonical_ratings.find_each do |tag| + works_using_tag = tag.works + tag.update_attribute(:type, "Freeform") + + works_using_tag.find_each do |work| + next unless work.ratings.empty? + + work.ratings = [canonical_not_rated_tag] + if work.save + work_ids << work.id + else + invalid_work_ids << work.id + end + print(".") && STDOUT.flush + end + end + + unless work_ids.empty? + puts "The following #{work_ids.size} works were left without a rating and successfully received the Not Rated rating:" + puts work_ids.join(", ") + STDOUT.flush + end + + unless invalid_work_ids.empty? + puts "The following #{invalid_work_ids.size} works failed validations and could not be saved:" + puts invalid_work_ids.join(", ") + STDOUT.flush + end + end + + desc "Clean up noncanonical category tags" + task(clean_up_noncanonical_categories: :environment) do + Category.where(canonical: false).find_each do |tag| + tag.update_attribute(:type, "Freeform") + puts "Noncanonical Category tag '#{tag.name}' was changed into an Additional Tag." + end + STDOUT.flush + end + + desc "Add default rating to works missing a rating" + task(add_default_rating_to_works: :environment) do + work_count = Work.count + total_batches = (work_count + 999) / 1000 + puts("Checking #{work_count} works in #{total_batches} batches") && STDOUT.flush + updated_works = [] + + Work.find_in_batches.with_index do |batch, index| + batch_number = index + 1 + + batch.each do |work| + next unless work.ratings.empty? + + work.ratings << Rating.find_by!(name: ArchiveConfig.RATING_DEFAULT_TAG_NAME) + work.save + updated_works << work.id + end + puts("Batch #{batch_number} of #{total_batches} complete") && STDOUT.flush + end + puts("Added default rating to works: #{updated_works}") && STDOUT.flush + end + + desc "Backfill renamed_at for existing users" + task(add_renamed_at_from_log: :environment) do + total_users = User.all.size + total_batches = (total_users + 999) / 1000 + puts "Updating #{total_users} users in #{total_batches} batches" + + User.find_in_batches.with_index do |batch, index| + batch.each do |user| + renamed_at_from_log = user.log_items.where(action: ArchiveConfig.ACTION_RENAME).last&.created_at + next unless renamed_at_from_log + + user.update_column(:renamed_at, renamed_at_from_log) + end + + batch_number = index + 1 + progress_msg = "Batch #{batch_number} of #{total_batches} complete" + puts(progress_msg) && STDOUT.flush + end + puts && STDOUT.flush + end + + desc "Fix threads for comments from 2009" + task(fix_2009_comment_threads: :environment) do + def fix_comment(comment) + comment.with_lock do + if comment.reply_comment? + comment.update_column(:thread, comment.commentable.thread) + else + comment.update_column(:thread, comment.id) + end + comment.comments.each { |reply| fix_comment(reply) } + end + end + + incorrect = Comment.top_level.where("thread != id") + total = incorrect.count + + puts "Updating #{total} thread(s)" + + incorrect.find_each.with_index do |comment, index| + fix_comment(comment) + + puts "Fixed thread #{index + 1} out of #{total}" if index % 100 == 99 + end + end + + desc "Remove translation_admin role" + task(remove_translation_admin_role: :environment) do + r = Role.find_by(name: "translation_admin") + r&.destroy + end + + desc "Remove full-width and ideographic commas from tags" + task(remove_invalid_commas_from_tags: :environment) do + puts("Tags can only be renamed by an admin, who will be listed as the tag's last wrangler. Enter the admin login we should use:") + login = $stdin.gets.chomp.strip + admin = Admin.find_by(login: login) + + if admin.present? + User.current_user = admin + + [",", "、"].each do |comma| + tags = Tag.where("name LIKE ?", "%#{comma}%") + tags.each do |tag| + new_name = tag.name.gsub(/#{comma}/, "") + if tag.update(name: new_name) || tag.update(name: "#{new_name} - AO3-6626") + puts(tag.reload.name) + else + puts("Could not rename #{tag.reload.name}") + end + $stdout.flush + end + end + else + puts("Admin not found.") + end + end + + desc "Add suffix to existing Underage Sex tag in preparation for Underage warning rename" + task(add_suffix_to_underage_sex_tag: :environment) do + puts("Tags can only be renamed by an admin, who will be listed as the tag's last wrangler. Enter the admin login we should use:") + login = $stdin.gets.chomp.strip + admin = Admin.find_by(login: login) + + if admin.present? + User.current_user = admin + + tag = Tag.find_by_name("Underage Sex") + + if tag.blank? + puts("No Underage Sex tag found.") + elsif tag.is_a?(ArchiveWarning) + puts("Underage Sex is already an Archive Warning.") + else + suffixed_name = "Underage Sex - #{tag.class}" + if tag.update(name: suffixed_name) + puts("Renamed Underage Sex tag to #{tag.reload.name}.") + else + puts("Failed to rename Underage Sex tag to #{suffixed_name}.") + end + $stdout.flush + end + else + puts("Admin not found.") + end + end + + desc "Rename Underage warning to Underage Sex" + task(rename_underage_warning: :environment) do + puts("Tags can only be renamed by an admin, who will be listed as the tag's last wrangler. Enter the admin login we should use:") + login = $stdin.gets.chomp.strip + admin = Admin.find_by(login: login) + + if admin.present? + User.current_user = admin + + tag = ArchiveWarning.find_by_name("Underage") + + if tag.blank? + puts("No Underage warning tag found.") + else + new_name = "Underage Sex" + if tag.update(name: new_name) + puts("Renamed Underage warning tag to #{tag.reload.name}.") + else + puts("Failed to rename Underage warning tag to #{new_name}.") + end + $stdout.flush + end + else + puts("Admin not found.") + end + end + + desc "Migrate collection icons to ActiveStorage paths" + task(migrate_collection_icons: :environment) do + require "aws-sdk-s3" + require "open-uri" + + return unless Rails.env.staging? || Rails.env.production? + + bucket_name = ENV["S3_BUCKET"] + prefix = "collections/icons/" + s3 = Aws::S3::Resource.new( + region: ENV["S3_REGION"], + access_key_id: ENV["S3_ACCESS_KEY_ID"], + secret_access_key: ENV["S3_SECRET_ACCESS_KEY"] + ) + old_bucket = s3.bucket(bucket_name) + new_bucket = s3.bucket(ENV["TARGET_BUCKET"]) + + Collection.no_touching do + old_bucket.objects(prefix: prefix).each do |object| + # Path example: staging/icons/108621/original.png + path_parts = object.key.split("/") + next unless path_parts[-1]&.include?("original") + next if ActiveStorage::Attachment.where(record_type: "Collection", record_id: path_parts[-2]).any? + + collection_id = path_parts[-2] + old_icon = URI.open("https://s3.amazonaws.com/#{bucket_name}/#{object.key}") + checksum = OpenSSL::Digest.new("MD5").tap do |result| + while (chunk = old_icon.read(5.megabytes)) + result << chunk + end + old_icon.rewind + end.base64digest + + key = nil + ActiveRecord::Base.transaction do + blob = ActiveStorage::Blob.create_before_direct_upload!( + filename: path_parts[-1], + byte_size: old_icon.size, + checksum: checksum, + content_type: Marcel::MimeType.for(old_icon) + ) + key = blob.key + blob.attachments.create( + name: "icon", + record_type: "Collection", + record_id: collection_id + ) + end + + new_bucket.put_object(key: key, body: old_icon, acl: "bucket-owner-full-control") + puts "Finished collection #{collection_id}" + $stdout.flush + end + end + end + + desc "Migrate pseud icons to ActiveStorage paths" + task(migrate_pseud_icons: :environment) do + require "aws-sdk-s3" + require "open-uri" + + return unless Rails.env.staging? || Rails.env.production? + + bucket_name = ENV["S3_BUCKET"] + prefix = Rails.env.production? ? "icons/" : "staging/icons/" + s3 = Aws::S3::Resource.new( + region: ENV["S3_REGION"], + access_key_id: ENV["S3_ACCESS_KEY_ID"], + secret_access_key: ENV["S3_SECRET_ACCESS_KEY"] + ) + old_bucket = s3.bucket(bucket_name) + new_bucket = s3.bucket(ENV["TARGET_BUCKET"]) + + Pseud.no_touching do + old_bucket.objects(prefix: prefix).each do |object| + # Path example: staging/icons/108621/original.png + path_parts = object.key.split("/") + next unless path_parts[-1]&.include?("original") + next if ActiveStorage::Attachment.where(record_type: "Pseud", record_id: path_parts[-2]).any? + + pseud_id = path_parts[-2] + old_icon = URI.open("https://s3.amazonaws.com/#{bucket_name}/#{object.key}") + checksum = OpenSSL::Digest.new("MD5").tap do |result| + while (chunk = old_icon.read(5.megabytes)) + result << chunk + end + old_icon.rewind + end.base64digest + + key = nil + ActiveRecord::Base.transaction do + blob = ActiveStorage::Blob.create_before_direct_upload!( + filename: path_parts[-1], + byte_size: old_icon.size, + checksum: checksum, + content_type: Marcel::MimeType.for(old_icon) + ) + key = blob.key + blob.attachments.create( + name: "icon", + record_type: "Pseud", + record_id: pseud_id + ) + end + + new_bucket.put_object(key: key, body: old_icon, acl: "bucket-owner-full-control") + puts "Finished pseud #{pseud_id}" + $stdout.flush + end + end + end + + desc "Migrate skin icons to ActiveStorage paths" + task(migrate_skin_icons: :environment) do + require "aws-sdk-s3" + require "open-uri" + + return unless Rails.env.staging? || Rails.env.production? + + bucket_name = ENV["S3_BUCKET"] + prefix = "skins/icons/" + s3 = Aws::S3::Resource.new( + region: ENV["S3_REGION"], + access_key_id: ENV["S3_ACCESS_KEY_ID"], + secret_access_key: ENV["S3_SECRET_ACCESS_KEY"] + ) + old_bucket = s3.bucket(bucket_name) + new_bucket = s3.bucket(ENV["TARGET_BUCKET"]) + + Skin.no_touching do + old_bucket.objects(prefix: prefix).each do |object| + # Path example: staging/icons/108621/original.png + path_parts = object.key.split("/") + next unless path_parts[-1]&.include?("original") + next if ActiveStorage::Attachment.where(record_type: "Skin", record_id: path_parts[-2]).any? + + skin_id = path_parts[-2] + old_icon = URI.open("https://s3.amazonaws.com/#{bucket_name}/#{object.key}") + checksum = OpenSSL::Digest.new("MD5").tap do |result| + while (chunk = old_icon.read(5.megabytes)) + result << chunk + end + old_icon.rewind + end.base64digest + + key = nil + ActiveRecord::Base.transaction do + blob = ActiveStorage::Blob.create_before_direct_upload!( + filename: path_parts[-1], + byte_size: old_icon.size, + checksum: checksum, + content_type: Marcel::MimeType.for(old_icon) + ) + key = blob.key + blob.attachments.create( + name: "icon", + record_type: "Skin", + record_id: skin_id + ) + end + + new_bucket.put_object(key: key, body: old_icon, acl: "bucket-owner-full-control") + puts "Finished skin #{skin_id}" + $stdout.flush + end + end + end + + desc "Migrate pinch_request_signup to request_signup" + task(migrate_pinch_request_signup: :environment) do + count = ChallengeAssignment.where("pinch_request_signup_id IS NOT NULL AND request_signup_id IS NULL").update_all("request_signup_id = pinch_request_signup_id") + puts("Migrated pinch_request_signup for #{count} challenge assignments.") + end + + desc "Reindex tags associated with works that are hidden or unrevealed" + task(reindex_hidden_unrevealed_tags: :environment) do + hidden_count = Work.hidden.count + hidden_batches = (hidden_count + 999) / 1_000 + puts "Inspecting #{hidden_count} hidden works in #{hidden_batches} batches" + Work.hidden.find_in_batches.with_index do |batch, index| + batch.each { |work| work.taggings.each(&:update_search) } + puts "Finished batch #{index + 1} of #{hidden_batches}" + end + + unrevealed_count = Work.unrevealed.count + unrevealed_batches = (unrevealed_count + 999) / 1_000 + puts "Inspecting #{unrevealed_count} unrevealed works in #{unrevealed_batches} batches" + Work.unrevealed.find_in_batches.with_index do |batch, index| + batch.each { |work| work.taggings.each(&:update_search) } + puts "Finished batch #{index + 1} of #{unrevealed_batches}" + end + + puts "Finished reindexing tags on hidden and unrevealed works" + end + + desc "Convert user kudos from users with the official role to guest kudos" + task(convert_official_kudos: :environment) do + official_users = Role.find_by(name: "official")&.users + if official_users.blank? + puts "No official users found" + else + official_users.each do |user| + kudos = user.kudos + next if kudos.blank? + + puts "Updating #{kudos.size} kudos from #{user.login}" + user.remove_user_from_kudos + end + + puts "Finished converting kudos from official users to guest kudos" + end + end + + desc "Convert user kudos from users with the archivist role to guest kudos" + task(convert_archivist_kudos: :environment) do + archivist_users = Role.find_by(name: "archivist")&.users + if archivist_users.blank? + puts "No archivist users found" + else + archivist_users.each do |user| + kudos = user.kudos + next if kudos.blank? + + puts "Updating #{kudos.size} kudos from #{user.login}" + user.remove_user_from_kudos + end + + puts "Finished converting kudos from archivist users to guest kudos" + end + end + + desc "Create TagSetAssociations for non-canonical tags belonging to canonical fandoms in TagSets" + task(create_non_canonical_tagset_associations: :environment) do + # We want to get all set taggings where the tag is not canonical, but has a parent fandom that _is_ canonical. + # This might be possible with pure Ruby, but unfortunately the parent tag in common_taggings is polymorphic + # (even though it doesn't need to be), which makes that trickier. + non_canonicals = SetTagging + .joins("INNER JOIN `tag_sets` `tag_set` ON `tag_set`.`id` = `set_taggings`.`tag_set_id` INNER JOIN `owned_tag_sets` ON `owned_tag_sets`.`tag_set_id` = `tag_set`.`id` INNER JOIN `tags` `tag` ON `tag`.`id` = `set_taggings`.`tag_id` INNER JOIN `common_taggings` `common_tagging` ON `common_tagging`.`common_tag_id` = `tag`.`id` INNER JOIN `tags` `parent_tag` ON `common_tagging`.`filterable_id` = `parent_tag`.`id`") + .where("`tag`.`canonical` = FALSE AND `tag`.`type` IN ('Character', 'Relationship') AND `tag_set`.`id` IS NOT NULL AND `parent_tag`.`type` = 'Fandom' AND `parent_tag`.`canonical` = TRUE") + .distinct + + non_canonicals.find_in_batches.with_index do |batch, index| + puts "Creating TagSetAssociations for batch #{index + 1}" + batch.each do |set_tagging| + owned_tag_set = set_tagging.tag_set.owned_tag_set + tag = set_tagging.tag + + fandoms = tag.fandoms.joins(:set_taggings).where(canonical: true, set_taggings: { tag_set_id: set_tagging.tag_set.id }) + fandoms.find_each do |fandom| + TagSetAssociation.create!(owned_tag_set: owned_tag_set, tag: tag, parent_tag: fandom) + rescue ActiveRecord::RecordInvalid + puts "Association already exists for fandom '#{fandom.name}' and tag '#{tag.name}'" + end + end + end + end + # This is the end that you have to put new tasks above. +end diff --git a/lib/tasks/creatorships.rake b/lib/tasks/creatorships.rake new file mode 100644 index 0000000..8aff09b --- /dev/null +++ b/lib/tasks/creatorships.rake @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +namespace :creatorships do + desc "Clean up creatorships for deleted works" + task(remove_deleted_work_creatorships: :environment) do + Creatorship.joins("LEFT JOIN works ON " \ + "creatorships.creation_id = works.id"). + where(works: { id: nil }, + creatorships: { creation_type: "Work" }). + in_batches.delete_all + end + + desc "Clean up creatorships for deleted chapters" + task(remove_deleted_chapter_creatorships: :environment) do + Creatorship.joins("LEFT JOIN chapters ON " \ + "creatorships.creation_id = chapters.id"). + where(chapters: { id: nil }, + creatorships: { creation_type: "Chapter" }). + in_batches.delete_all + end + + desc "Clean up creatorships for deleted series" + task(remove_deleted_series_creatorships: :environment) do + Creatorship.joins("LEFT JOIN series ON " \ + "creatorships.creation_id = series.id"). + where(series: { id: nil }, + creatorships: { creation_type: "Series" }). + in_batches.delete_all + end + + desc "Add missing series creatorships" + task(add_missing_series_creatorships: :environment) do + SerialWork.includes(:series, work: [creatorships: [:pseud]]). + find_each(&:update_series_creatorships) + end + + desc "Remove empty series with no creators" + task(remove_orphaned_empty_series: :environment) do + Series.left_joins(:serial_works).where(serial_works: { id: nil }). + find_each do |series| + series.destroy unless series.pseuds.exists? + end + end +end diff --git a/lib/tasks/cucumber.rake b/lib/tasks/cucumber.rake new file mode 100644 index 0000000..d611351 --- /dev/null +++ b/lib/tasks/cucumber.rake @@ -0,0 +1,76 @@ +# IMPORTANT: This file is generated by cucumber-rails - edit at your own peril. +# It is recommended to regenerate this file in the future when you upgrade to a +# newer version of cucumber-rails. Consider adding your own code to a new file +# instead of editing this one. Cucumber will automatically load all features/**/*.rb +# files. + + +unless ARGV.any? {|a| a =~ /^gems/} # Don't load anything when running the gems:* tasks + +vendored_cucumber_bin = Dir["#{Rails.root}/vendor/{gems,plugins}/cucumber*/bin/cucumber"].first +$LOAD_PATH.unshift(File.dirname(vendored_cucumber_bin) + '/../lib') unless vendored_cucumber_bin.nil? + +begin + require 'cucumber/rake/task' + + namespace :cucumber do + Cucumber::Rake::Task.new({ok: 'test:prepare'}, 'Run features that should pass') do |t| + t.binary = vendored_cucumber_bin # If nil, the gem's binary is used. + t.fork = true # You may get faster startup if you set this to false + t.profile = 'default' + end + + Cucumber::Rake::Task.new({wip: 'test:prepare'}, 'Run features that are being worked on') do |t| + t.binary = vendored_cucumber_bin + t.fork = true # You may get faster startup if you set this to false + t.profile = 'wip' + end + + Cucumber::Rake::Task.new({rerun: 'test:prepare'}, 'Record failing features and run only them if any exist') do |t| + t.binary = vendored_cucumber_bin + t.fork = true # You may get faster startup if you set this to false + t.profile = 'rerun' + end + + desc 'Run all features' + task all: [:ok, :wip] + + task :statsetup do + require 'rails/code_statistics' + ::STATS_DIRECTORIES << %w(Cucumber\ features features) if File.exist?('features') + ::CodeStatistics::TEST_TYPES << "Cucumber features" if File.exist?('features') + end + + task :annotations_setup do + Rails.application.configure do + if config.respond_to?(:annotations) + config.annotations.directories << 'features' + config.annotations.register_extensions('feature') { |tag| /#\s*(#{tag}):?\s*(.*)$/ } + end + end + end + end + desc 'Alias for cucumber:ok' + task cucumber: 'cucumber:ok' + + task default: :cucumber + + task features: :cucumber do + STDERR.puts "*** The 'features' task is deprecated. See rake -T cucumber ***" + end + + # In case we don't have the generic Rails test:prepare hook, append a no-op task that we can depend upon. + task 'test:prepare' do + end + + task stats: 'cucumber:statsetup' + + task notes: 'cucumber:annotations_setup' +rescue LoadError + desc 'cucumber rake task not available (cucumber not installed)' + task :cucumber do + abort 'Cucumber rake task is not available. Be sure to install cucumber as a gem or plugin' + end +end + +end diff --git a/lib/tasks/database_seed.rake b/lib/tasks/database_seed.rake new file mode 100644 index 0000000..f913ce5 --- /dev/null +++ b/lib/tasks/database_seed.rake @@ -0,0 +1,21 @@ +namespace :db do + desc "Raise an error unless the environment is development or test" + task :test_environment_only do + raise "Only supported in test and development!" unless Rails.env.development? || Rails.env.test? + end + + desc "Drop the database, recreate it from schema files and run remaining migrations" + task reset_and_migrate: [ + :environment, :test_environment_only, + # We can't use: + # - db:reset, because schema files may not be up-to-date and migrations are required. + # - db:migrate:reset, because we've deleted old migrations at various points. + :drop, :create, "schema:load", :migrate + ] + + desc "Reset and seed the database with data from test/fixtures/" + task otwseed: [ + :reset_and_migrate, :seed, "fixtures:load", + "work:missing_stat_counters", "Tag:reset_filters", "Tag:reset_filter_counts" + ] +end diff --git a/lib/tasks/defaults.rake b/lib/tasks/defaults.rake new file mode 100644 index 0000000..cde2a13 --- /dev/null +++ b/lib/tasks/defaults.rake @@ -0,0 +1,8 @@ +namespace :defaults do + desc "Create default roles by name" + task(create_roles: :environment) do + %w[archivist no_resets opendoors protected_user tag_wrangler translator official].each do |role| + Role.find_or_create_by(name: role) + end + end +end diff --git a/lib/tasks/deploy_tasks.rake b/lib/tasks/deploy_tasks.rake new file mode 100644 index 0000000..95a5503 --- /dev/null +++ b/lib/tasks/deploy_tasks.rake @@ -0,0 +1,16 @@ +namespace :deploy do + + desc "Get servername" + task(:get_servername) do + @server ||= %x{hostname -s}.chomp + end + + desc "clear subscriptions on stage" + task(:clear_subscriptions => [:get_servername, :environment]) do + if @server == "stage" + Subscription.delete_all + else + puts "Don't clear subscriptions except on stage!!!" + end + end +end diff --git a/lib/tasks/language_tasks.rake b/lib/tasks/language_tasks.rake new file mode 100644 index 0000000..56e249d --- /dev/null +++ b/lib/tasks/language_tasks.rake @@ -0,0 +1,19 @@ +namespace :db do + desc "Check for duplicate language names before adding unique index migration" + task check_language_name_duplicates: :environment do + duplicates = Language.group(:name) + .having("COUNT(*) > 1") + .count + + if duplicates.any? + puts "Duplicate language names found:" + duplicates.each do |name, count| + puts "#{name} appears #{count} times" + end + + abort("Please resolve duplicate language names before running migrations.") + else + puts "No duplicate language names found." + end + end +end diff --git a/lib/tasks/load_autocomplete_data.rake b/lib/tasks/load_autocomplete_data.rake new file mode 100644 index 0000000..fdbb861 --- /dev/null +++ b/lib/tasks/load_autocomplete_data.rake @@ -0,0 +1,122 @@ +namespace :autocomplete do + # we need to delete the keys in batches to avoid stack level too deep error + KEYSLICE_SIZE = 10_000 + + desc "Clear autocomplete data" + task(clear_data: :environment) do + keys = REDIS_AUTOCOMPLETE.keys("autocomplete_*") + keys.each_slice(KEYSLICE_SIZE) { |keyslice| REDIS_AUTOCOMPLETE.del(*keyslice) } + puts "Cleared all autocomplete data" + end + + desc "Load data into Redis for autocomplete" + task(load_data: [:load_tag_data, :load_pseud_data, :load_collection_data, :load_tagset_data, :load_association_data]) do + puts "Loaded all autocomplete data" + end + + desc "Clear and reload data into Redis for autocomplete" + task(reload_data: [:clear_data, :load_data]) do + puts "Finished reloading" + end + + desc "Clear tag data" + task(clear_tag_data: :environment) do + keys = REDIS_AUTOCOMPLETE.keys("autocomplete_tag_*") + REDIS_AUTOCOMPLETE.keys("autocomplete_fandom_*") + keys.each_slice(KEYSLICE_SIZE) { |keyslice| REDIS_AUTOCOMPLETE.del(*keyslice) } + end + + desc "Clear tagset data" + task(clear_tagset_data: :environment) do + keys = REDIS_AUTOCOMPLETE.keys("autocomplete_tagset_*") + keys.each_slice(KEYSLICE_SIZE) { |keyslice| REDIS_AUTOCOMPLETE.del(*keyslice) } + end + + desc "Clear tagset association data" + task(clear_association_data: :environment) do + keys = REDIS_AUTOCOMPLETE.keys("autocomplete_association_*") + keys.each_slice(KEYSLICE_SIZE) { |keyslice| REDIS_AUTOCOMPLETE.del(*keyslice) } + end + + desc "Clear pseud data" + task(clear_pseud_data: :environment) do + keys = REDIS_AUTOCOMPLETE.keys("autocomplete_pseud_*") + keys.each_slice(KEYSLICE_SIZE) { |keyslice| REDIS_AUTOCOMPLETE.del(*keyslice) } + end + + desc "Clear collection data" + task(clear_collection_data: :environment) do + keys = REDIS_AUTOCOMPLETE.keys("autocomplete_collection_*") + keys.each_slice(KEYSLICE_SIZE) { |keyslice| REDIS_AUTOCOMPLETE.del(*keyslice) } + end + + desc "Load tag data into Redis for autocomplete" + task(load_tag_data: :environment) do + (Tag::TYPES - ['Banned']).each do |type| + query = type.constantize.canonical + query = query.includes(:parents) if type == "Character" || type == "Relationship" + query.find_each do |tag| + tag.add_to_autocomplete + end + end + end + + desc "Load pseud data into Redis for autocomplete" + task(load_pseud_data: :environment) do + Pseud.not_orphaned.includes(:user).find_each do |pseud| + pseud.add_to_autocomplete + end + end + + desc "Load collection data into Redis for autocomplete" + task(load_collection_data: :environment) do + Collection.with_item_count.includes(:collection_preference).find_each do |collection| + collection.add_to_autocomplete(collection.item_count) + end + end + + desc "Load tagsets into Redis" + task(load_tagset_data: :environment) do + # we only load tagsets used in challenge settings, not ones used in + # individual signups + OwnedTagSet.includes(tag_set: :tags).find_each do |owned_tag_set| + # this is just a check to save from the seed db missing tag sets + next if owned_tag_set.tag_set.nil? + owned_tag_set.tag_set.add_to_autocomplete + end + end + + desc "Load tag set associations into Redis" + task(load_association_data: :environment) do + # TODO: we probably only want to load associations for + # tag sets being used in challenges that are open for + # signups. + TagSetAssociation.includes(:owned_tag_set, :tag, :parent_tag).find_each do |assoc| + assoc.add_to_autocomplete + end + end + + desc "Clear and reload tag data into Redis for autocomplete" + task(reload_tag_data: [:clear_tag_data, :load_tag_data]) do + puts "Finished reloading tags" + end + + desc "Clear and reload pseud data into Redis for autocomplete" + task(reload_pseud_data: [:clear_pseud_data, :load_pseud_data]) do + puts "Finished reloading pseuds" + end + + desc "Clear and reload collection data into Redis for autocomplete" + task(reload_collection_data: [:clear_collection_data, :load_collection_data]) do + puts "Finished reloading collections" + end + + desc "Clear and reload tagset data into Redis for autocomplete" + task(reload_tagset_data: [:clear_tagset_data, :load_tagset_data]) do + puts "Finished reloading tagsets" + end + + desc "Clear and reload tag set association data into Redis for autocomplete" + task(reload_association_data: [:clear_association_data, :load_association_data]) do + puts "Finished reloading tag set associations" + end +end diff --git a/lib/tasks/memcached.rake b/lib/tasks/memcached.rake new file mode 100644 index 0000000..961b963 --- /dev/null +++ b/lib/tasks/memcached.rake @@ -0,0 +1,15 @@ +namespace :memcached do + + # For example + # WORKS="posted = 0" rake memcached:clear_work + # + desc "Clear memcached" + task :expire_work_blurbs => :environment do + works=ENV['WORKS'] || 'id=1' + Work.where(works).find_each do |work| + puts "Clear memcached #{work.id}" + Work.expire_work_blurb_version(work.id) + end + end + +end diff --git a/lib/tasks/notifications.rake b/lib/tasks/notifications.rake new file mode 100644 index 0000000..af42284 --- /dev/null +++ b/lib/tasks/notifications.rake @@ -0,0 +1,31 @@ +namespace :notifications do + + desc "Send next set of kudos notifications" + task(deliver_kudos: :environment) do + RedisMailQueue.deliver_kudos + end + + desc "Send next set of subscription notifications" + task(deliver_subscriptions: :environment) do + RedisMailQueue.deliver_subscriptions + end + + # Usage with 10473 as admin post id: rails notifications:send_tos_update[10473] + desc "Send TOS Update notification to all users" + task(:send_tos_update, [:admin_post_id] => [:environment]) do |_t, args| + total_users = User.all.size + total_batches = (total_users + 999) / 1000 + puts "Notifying #{total_users} users in #{total_batches} batches" + + User.find_in_batches.with_index do |batch, index| + batch.each do |user| + TosUpdateMailer.tos_update_notification(user, args.admin_post_id).deliver_later(queue: :tos_update) + end + + batch_number = index + 1 + progress_msg = "Batch #{batch_number} of #{total_batches} complete" + puts(progress_msg) && $stdout.flush + end + puts && $stdout.flush + end +end diff --git a/lib/tasks/opendoors.rake b/lib/tasks/opendoors.rake new file mode 100644 index 0000000..da057d2 --- /dev/null +++ b/lib/tasks/opendoors.rake @@ -0,0 +1,44 @@ +namespace :opendoors do + + class UrlUpdater + def update_work(row) + begin + work = Work.find(row["AO3 id"]) + if work&.imported_from_url.blank? || work&.imported_from_url == row["URL Imported From"] + work.imported_from_url = row["Original URL"] + work.save! + "#{work.id}\twas updated: its import url is now #{work.imported_from_url}" + else + "#{work.id}\twas not changed: its import url is #{work.imported_from_url}" + end + rescue StandardError => e + "#{row["AO3 id"]}\twas not changed: #{e}" + end + end + end + + desc "Map import urls based on spreadsheet data - required fields: 'AO3 id', 'URL Imported From', 'Original URL'" + task :import_url_mapping, [:csv] => :environment do |_t, args| + loc = if args[:csv].nil? + puts "Where is the Open Doors CSV located?" + STDIN.gets.chomp + else + args[:csv] + end + + begin + f = File.open("opendoors_result.txt", "w") + url_updater = UrlUpdater.new + + CSV.foreach(loc, headers: true) do |row| + result = url_updater.update_work(row) + puts result + f.write(result) + end + rescue TypeError => e # No or invalid CSV file + puts "Error parsing CSV file #{loc}: #{e.message}" + ensure + f.close + end + end +end diff --git a/lib/tasks/resanitize.rake b/lib/tasks/resanitize.rake new file mode 100644 index 0000000..6dc04f4 --- /dev/null +++ b/lib/tasks/resanitize.rake @@ -0,0 +1,40 @@ +namespace :resanitize do + desc "Re-run the sanitizer on all fields." + task(all: :environment) do + [ + AbuseReport, + AdminActivity, + AdminBanner, + AdminPost, + AdminSetting, + Bookmark, + Chapter, + Collection, + CollectionProfile, + Comment, + ExternalWork, + Feedback, + GiftExchange, + KnownIssue, + OwnedTagSet, + Profile, + Prompt, + PromptMeme, + Pseud, + Question::Translation, + Series, + Skin, + Work, + WranglingGuideline + ].each do |klass| + next unless klass.exists? + + puts "Enqueueing all #{klass} objects for resanitization." + + klass.find_in_batches.with_index do |batch, index| + puts "Enqueuing batch #{index + 1} of #{klass} objects." + ResanitizeBatchJob.perform_later(klass.to_s, batch.map(&:id)) + end + end + end +end diff --git a/lib/tasks/resque.rake b/lib/tasks/resque.rake new file mode 100644 index 0000000..2995ebe --- /dev/null +++ b/lib/tasks/resque.rake @@ -0,0 +1,58 @@ +# Resque tasks +require 'resque/tasks' +require 'resque/scheduler/tasks' + +namespace :resque do + task :setup do + require 'resque' + require 'resque/scheduler/tasks' + + # you probably already have this somewhere + # Resque.redis = 'localhost:6379' + + # If you want to be able to dynamically change the schedule, + # uncomment this line. A dynamic schedule can be updated via the + # Resque::Scheduler.set_schedule (and remove_schedule) methods. + # When dynamic is set to true, the scheduler process looks for + # schedule changes and applies them on the fly. + # Note: This feature is only available in >=2.0.0. + Resque::Scheduler.dynamic = true + + # The schedule doesn't need to be stored in a YAML, it just needs to + # be a hash. YAML is usually the easiest. + Resque.schedule = YAML.load_file("#{Rails.root}/config/resque_schedule.yml") + + # If your schedule already has +queue+ set for each job, you don't + # need to require your jobs. This can be an advantage since it's + # less code that resque-scheduler needs to know about. But in a small + # project, it's usually easier to just include you job classes here. + # So, something like this: + # require 'jobs' + end + + def process_job(count) + job = Resque::Failure.all(count,1) + return unless job + klass = job["payload"]["class"] + args = job["payload"]["args"] + klass.constantize.perform(*args) + Resque::Failure.remove(count) + rescue ActiveRecord::RecordNotFound + pp args + Resque::Failure.remove(count) + rescue Exception => e + puts "Job failed with error #{e.message}" + pp args + end + + desc "Run jobs in failure queue. +Removes them silently unless there are errors. +If it gets RecordNotFound prints the args to the whenever log. +If there are other exceptions prints out more information + but does not remove it from the queue. + These jobs will need to be removed manually." + task(:run_failures => :environment) do + (Resque::Failure.count-1).downto(0).each {|i| process_job(i)} + end + +end diff --git a/lib/tasks/search.rake b/lib/tasks/search.rake new file mode 100644 index 0000000..ac87459 --- /dev/null +++ b/lib/tasks/search.rake @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +namespace :search do + BATCH_SIZE = 1000 + + desc "Update all index mappings" + task(update_all_mappings: :environment) do + # If multiple indexers share an index and a mapping, we only need to call + # create_mapping on one of them. + Indexer.all.group_by(&:index_name).values.map(&:first).map(&:create_mapping) + end + + desc "Recreate tag index" + task(index_tags: :environment) do + if Rails.env.production? || Rails.env.test? + puts 'Running this task will temporarily empty some wrangling bins and affect tag search. + Have you warned the wrangling team this task is being run? + Enter YES to continue:' + + confirmation = $stdin.gets.chomp.strip.upcase + unless confirmation == "YES" + puts "Task aborted." + exit + end + end + TagIndexer.index_all + end + + desc "Recreate pseud index" + task(index_pseuds: :environment) do + PseudIndexer.index_all + end + + desc "Recreate work index" + task(index_works: :environment) do + WorkIndexer.index_all + WorkCreatorIndexer.index_from_db + end + + desc "Recreate bookmark index" + task(index_bookmarks: :environment) do + BookmarkIndexer.index_all + end + + desc "Recreate admin users index" + task(index_admin_users: :environment) do + UserIndexer.index_all + end + + desc "Reindex all works without recreating the index" + task(reindex_works: :environment) do + WorkIndexer.index_from_db + WorkCreatorIndexer.index_from_db + end + + desc "Reindex all bookmarkables without recreating the index" + task(reindex_bookmarkables: :environment) do + BookmarkedExternalWorkIndexer.index_from_db + BookmarkedSeriesIndexer.index_from_db + BookmarkedWorkIndexer.index_from_db + end + + desc "Reindex users without recreating the admin users index" + task(reindex_admin_users: :environment) do + UserIndexer.index_from_db + end + + desc "Reindex all recently-modified items" + task timed_all: %i[timed_works timed_tags timed_pseud timed_bookmarks] do + end + + desc "Reindex recent bookmarks" + task timed_bookmarks: :environment do + time = ENV["TIME_PERIOD"] || "NOW() - INTERVAL 1 DAY" + ExternalWork.where("external_works.updated_at > #{time}").select(:id).find_in_batches(batch_size: BATCH_SIZE) do |group| + AsyncIndexer.new(BookmarkedExternalWorkIndexer, :world).enqueue_ids(group.map(&:id)) + end + Series.where("series.updated_at > #{time}").select(:id).find_in_batches(batch_size: BATCH_SIZE) do |group| + AsyncIndexer.new(BookmarkedSeriesIndexer, :world).enqueue_ids(group.map(&:id)) + end + Work.where("works.revised_at > #{time}").select(:id).find_in_batches(batch_size: BATCH_SIZE) do |group| + AsyncIndexer.new(BookmarkedWorkIndexer, :world).enqueue_ids(group.map(&:id)) + end + Bookmark.where("bookmarks.updated_at > #{time}").select(:id).find_in_batches(batch_size: BATCH_SIZE) do |group| + AsyncIndexer.new(BookmarkIndexer, :world).enqueue_ids(group.map(&:id)) + end + end + + desc "Reindex recent works" + task timed_works: :environment do + time = ENV["TIME_PERIOD"] || "NOW() - INTERVAL 1 DAY" + Work.where("works.revised_at > #{time}").select(:id).find_in_batches(batch_size: BATCH_SIZE) do |group| + AsyncIndexer.new(WorkIndexer, :world).enqueue_ids(group.map(&:id)) + end + end + + desc "Reindex recent tags" + task timed_tags: :environment do + time = ENV["TIME_PERIOD"] || "NOW() - INTERVAL 1 DAY" + Tag.where("tags.updated_at > #{time}").select(:id).find_in_batches(batch_size: BATCH_SIZE) do |group| + AsyncIndexer.new(TagIndexer, :world).enqueue_ids(group.map(&:id)) + end + end + + desc "Reindex pseuds" + task timed_pseud: :environment do + time = ENV["TIME_PERIOD"] || "NOW() - INTERVAL 1 DAY" + Pseud.where("pseuds.updated_at > #{time}").select(:id).find_in_batches(batch_size: BATCH_SIZE) do |group| + AsyncIndexer.new(PseudIndexer, :world).enqueue_ids(group.map(&:id)) + end + end + + desc "Run tasks enqueued to the world queue by IndexQueue." + task run_world_index_queue: :environment do + ScheduledReindexJob::MAIN_CLASSES.each do |klass| + IndexQueue.from_class_and_label(klass, :world).run + end + end +end diff --git a/lib/tasks/skin_tasks.rake b/lib/tasks/skin_tasks.rake new file mode 100644 index 0000000..68c1b2a --- /dev/null +++ b/lib/tasks/skin_tasks.rake @@ -0,0 +1,193 @@ +namespace :skins do + + def ask(message) + print message + STDIN.gets.chomp.strip + end + + def replace_or_new(skin_content) + skin = Skin.new + if skin_content.match(/REPLACE:\s*(\d+)/) + id = $1.to_i + skin = Skin.where(:id => id).first + unless skin + puts "Couldn't find skin with id #{id} to replace" + return nil + end + end + skin + end + + def set_parents(skin, parent_names) + # clear existing ones + SkinParent.where(:child_skin_id => skin.id).delete_all + + parent_position = 1 + parents = parent_names.split(/,\s?/).map {|pn| pn.strip} + parents.each do |parent_name| + if parent_name.match(/^(\d+)$/) + parent_skin = Skin.where("title LIKE 'Archive 2.0: (#{parent_name})%'").first + elsif parent_name.blank? + puts "Empty parent name for #{skin.title}" + next + else + parent_skin = Skin.where(:title => parent_name).first + end + unless parent_skin + puts "Couldn't find parent #{parent_name} to add, skipping" + next + end + if (parent_skin.role == "site" || parent_skin.role == "override") && skin.role != "override" + skin.role = "override" + skin.save or puts "Problem updating skin #{skin.title} to be replacement skin: #{skin.errors.full_messages.join(', ')}" + next + end + p = skin.skin_parents.build(:parent_skin => parent_skin, :position => parent_position) + if p.save + parent_position += 1 + else + puts "Skipping skin parent #{parent_name}: #{p.errors.full_messages.join(', ')}" + end + end + end + + def get_user_skins + dir = Skin.site_skins_dir + 'user_skins_to_load' + default_preview_filename = "#{dir}/previews/default_preview.png" + user_skin_files = Dir.entries(dir).select {|f| f.match(/css$/)} + skins = [] + user_skin_files.each do |skin_file| + skins << File.read("#{dir}/#{skin_file}").split(/\/\*\s*END SKIN\s*\*\//) + end + skins.flatten! + end + + desc "Purge user skins parents" + task(:purge_user_skins_parents => :environment) do + get_user_skins.each do |skin_content| + skin = replace_or_new(skin_content) + if skin.new_record? && skin_content.match(/SKIN:\s*(.*)\s*\*\//) + skin = Skin.find_by_title($1.strip) + end + skin.skin_parents.delete_all + end + end + + desc "Load user skins" + task(:load_user_skins => :environment) do + replace = ask("Replace existing skins with same titles? (y/n) ") == "y" + Rake::Task['skins:purge_user_skins_parents'].invoke if replace + + author = User.find_by_login("lim") + dir = Skin.site_skins_dir + 'user_skins_to_load' + + skins = get_user_skins + skins.each do |skin_content| + next if skin_content.blank? + + # Determine if we're replacing or creating new + next unless (skin = replace_or_new(skin_content)) + + # set the title and preview + if skin_content.match(/SKIN:\s*(.*)\s*\*\//) + title = $1.strip + if (oldskin = Skin.find_by_title(title)) && oldskin.id != skin.id + if replace + skin = oldskin + else + puts "Existing skin with title #{title} - did you mean to replace? Skipping." + next + end + end + skin.title = title + preview_filename = "#{dir}/previews/#{title.gsub(/[^\w\s]+/, '')}.png" + unless File.exists?(preview_filename) + puts "No preview filename #{preview_filename} found for #{title}" + preview_filename = "#{dir}/previews/default_preview.png" + end + File.open(preview_filename, 'rb') {|preview_file| skin.icon = preview_file} + else + puts "No skin title found for skin #{skin_content}" + next + end + + # set the css and make public + skin.css = skin_content + skin.public = true + skin.official = true + skin.author = author unless skin.author + + if skin_content.match(/DESCRIPTION:\s*(.*?)\*\//m) + skin.description = "
    #{$1}
    " + end + if skin_content.match(/PARENT_ONLY/) + skin.unusable = true + end + + # make sure we have valid skin now + if skin.save + puts "Saved skin #{skin.title}" + else + puts "Problem with skin #{skin.title}: #{skin.errors.full_messages.join(', ')}" + next + end + + # recache any cached skins + if skin.cached? + skin.cache! + end + + # set parents + if skin_content.match(/PARENTS:\s*(.*)\s*\*\//) + parent_string = $1 + set_parents(skin, parent_string) + end + end + + end + + desc "Load site skins" + task(:load_site_skins => :environment) do + settings = AdminSetting.first + if settings.default_skin_id.nil? + settings.default_skin_id = Skin.default.id + settings.save(validate: false) + end + Skin.load_site_css + Skin.set_default_to_current_version + end + + desc "Cache all site skins in the skin chooser" + task(cache_chooser_skins: :environment) do + # The default skin can be changed to something other than Skin.default via + # admin settings, so we want to cache that skin, not Skin.default. + skins = Skin.where(id: AdminSetting.default_skin_id).or(Skin.in_chooser) + successes = [] + failures = [] + + skins.each do |skin| + if skin.cache! + successes << skin.title + else + failures << skin.title + end + end + puts + puts("Cached #{successes.join(',')}") if successes.any? + puts("Couldn't cache #{failures.join(',')}") if failures.any? + STDOUT.flush + end + + desc "Remove all existing skins from preferences" + task(:disable_all => :environment) do + default_id = AdminSetting.default_skin_id + Preference.update_all(:skin_id => default_id) + end + + desc "Unapprove all existing official skins" + task(:unapprove_all => :environment) do + default_id = AdminSetting.default_skin_id + Skin.where("id != ?", default_id).update_all(:official => false) + end + +end diff --git a/lib/tasks/spam_report.rake b/lib/tasks/spam_report.rake new file mode 100644 index 0000000..8165251 --- /dev/null +++ b/lib/tasks/spam_report.rake @@ -0,0 +1,7 @@ +namespace :spam do + + desc "Print list of potential spammers" + task(:print_possible => :environment) do + SpamReport.run + end +end diff --git a/lib/tasks/tag_tasks.rake b/lib/tasks/tag_tasks.rake new file mode 100644 index 0000000..1af40a8 --- /dev/null +++ b/lib/tasks/tag_tasks.rake @@ -0,0 +1,178 @@ +namespace :Tag do + desc "Reset common taggings - slow" + task(reset_common: :environment) do + Work.find_each do |w| + print "." if w.id.modulo(100) == 0; STDOUT.flush + #w.update_common_tags + end + puts "Common tags reset." + end + + desc "Reset tag count" + task(reset_count: :environment) do + Tag.find_each do |t| + t.taggings_count + end + puts "Tag count reset." + end + + desc "Reset taggings count for obviously wrong taggings_count" + task(fix_taggings_count: :environment) do + tag_scope = Tag.where("taggings_count_cache < 0") + tag_count = tag_scope.count + tag_scope.each_with_index do |tag, index| + puts "#{index} / #{tag_count}" + tag.taggings_count + end + puts "Taggings count for less-than-zero counts has been reset." + end + + desc "Update relationship has_characters" + task(update_has_characters: :environment) do + Relationship.find_each do |relationship| + relationship.update_attribute(:has_characters, true) unless relationship.characters.blank? + end + end + + desc "Delete unused tags" + task(delete_unused: :environment) do + start = Time.current + deleted_names = [] + Tag.where(canonical: false, merger_id: nil, taggings_count_cache: 0).find_in_batches do |batch| + batch.each do |t| + next unless t.taggings.count.zero? + next unless t.child_taggings.count.zero? + next unless t.set_taggings.count.zero? + + deleted_names << t.name + begin + t.destroy + print "+" + rescue ActiveRecord::LockWaitTimeout + sleep(10) + print("Retrying ", t.name) + retry + end + end + print "." + end + unless deleted_names.blank? + puts "The following unused tags were deleted:" + puts deleted_names.join(", ") + puts "Started #{start}" + puts "Ended #{Time.current}" + puts "Deleted #{deleted_names.length}" + end + end + + desc "Convert non-canonical warnings into freeforms" + task(convert_non_canonical_warnings_to_freeforms: :environment) do + default_warning = ArchiveWarning.find_by(name: ArchiveConfig.WARNING_DEFAULT_TAG_NAME) + warnings = ArchiveWarning.where(canonical: false) + puts "Total non-canonical warnings: #{warnings.count}" + + warnings.find_each do |warning| + puts("#{warning.name} (#{warning.works.count})") && STDOUT.flush + + warning.works.find_each do |work| + # If the only warning is non-canonical, add a default warning + # so the work won't be left with no warnings. + work.archive_warnings << default_warning if work.archive_warnings.count <= 1 + end + + warning.update_attribute(:type, "Freeform") + end + end + + desc "Delete unused admin post tags" + task(delete_unused_admin_post_tags: :environment) do + AdminPostTag.joins("LEFT JOIN `admin_post_taggings` ON admin_post_taggings.admin_post_tag_id = admin_post_tags.id").where("admin_post_taggings.id IS NULL").destroy_all + end + + desc "Clean up orphaned taggings" + task(clean_up_taggings: :environment) do + Tagging.find_each { |t| t.destroy if t.taggable.nil? } + CommonTagging.find_each { |t| t.destroy if t.common_tag.nil? } + end + + desc "Reset filter taggings" + task(reset_filters: :environment) do + puts "Adding jobs for work filter updates to the reindex_world queue:" + + Work.update_filters(async_update: true, + job_queue: :reindex_world, + reindex_queue: :world) do + print(".") && STDOUT.flush + end + + print("\n") && STDOUT.flush + + puts "Adding jobs for external work filter updates to the reindex_world queue:" + ExternalWork.update_filters(async_update: true, + job_queue: :reindex_world, + reindex_queue: :world) do + print(".") && STDOUT.flush + end + + print("\n") && STDOUT.flush + + puts "All jobs enqueued! Once all jobs have finished running, call rake search:run_world_index_queue." + end + + desc "Reset inherited meta taggings" + task(reset_meta_tags: :environment) do + InheritedMetaTagUpdater.update_all { print(".") && STDOUT.flush } + print("\n") && STDOUT.flush + end + + desc "Reset filter counts" + task(reset_filter_counts: :environment) do + FilterCount.set_all + end + + desc "Reset filter counts from date" + task(unsuspend_filter_counts: :environment) do + admin_settings = AdminSetting.current + if admin_settings && admin_settings.suspend_filter_counts_at + FilterTagging.update_filter_counts_since(admin_settings.suspend_filter_counts_at) + end + end + + desc "Clean up invalid CommonTaggings" + task(destroy_invalid_common_taggings: :environment) do + count = 0 + + CommonTagging.destroy_invalid do |ct, valid| + unless valid + puts "Deleting invalid CommonTagging: " \ + "#{ct.filterable.try(:name)} > #{ct.common_tag.try(:name)}" + puts ct.errors.full_messages + end + + if ((count += 1) % 1000).zero? + puts "Processed #{count} CommonTaggings." + end + end + + puts "Processed #{count} CommonTaggings." + end + + desc "Clean up invalid MetaTaggings" + task(destroy_invalid_meta_taggings: :environment) do + count = 0 + + MetaTagging.destroy_invalid do |mt, valid| + unless valid + puts "Deleting invalid MetaTagging: " \ + "#{mt.meta_tag.try(:name)} > #{mt.sub_tag.try(:name)}" + puts mt.errors.full_messages + end + + if ((count += 1) % 1000).zero? + puts "Processed #{count} MetaTaggings." + end + end + + puts "Processed #{count} MetaTaggings." + end +end diff --git a/lib/tasks/work_tasks.rake b/lib/tasks/work_tasks.rake new file mode 100644 index 0000000..954798a --- /dev/null +++ b/lib/tasks/work_tasks.rake @@ -0,0 +1,55 @@ +namespace :work do + desc "Purge drafts created more than a month ago" + task(:purge_old_drafts => :environment) do + count = 0 + Work.unposted.where('works.created_at < ?', 1.month.ago).find_each do |work| + begin + work.destroy! + count += 1 + rescue StandardError => e + puts "The following error occurred while trying to destroy draft #{work.id}:" + puts "#{e.class}: #{e.message}" + puts e.backtrace + end + end + puts "Unposted works (#{count}) created more than one month ago have been purged" + end + + desc "create missing hit counters" + task(:missing_stat_counters => :environment) do + Work.find_each do |work| + counter = work.stat_counter + unless counter + counter = StatCounter.create(:work => work, :hit_count => 1) + end + end + end + + # Usage: rake work:reset_word_counts[en] + desc "Reset word counts for works in the specified language" + task(:reset_word_counts, [:lang] => :environment) do |_t, args| + language = Language.find_by(short: args.lang) + + updated_works = "ALL" + if language.nil? + works = Work.all + else + works = Work.where(language: language) + updated_works = language.short + end + + print "Resetting word count for #{works.count} '#{updated_works}' works: " + + works.find_in_batches do |batch| + batch.each do |work| + work.chapters.each do |chapter| + chapter.content_will_change! + chapter.save + end + work.save + end + print(".") && STDOUT.flush + end + puts && STDOUT.flush + end +end diff --git a/lib/url_formatter.rb b/lib/url_formatter.rb new file mode 100644 index 0000000..c3c89e0 --- /dev/null +++ b/lib/url_formatter.rb @@ -0,0 +1,67 @@ +require 'uri' +require 'cgi' +require 'addressable/uri' + +class UrlFormatter + + attr_accessor :url + + def initialize(url) + @url = url || "" + end + + def original + url + end + + # Remove anchors and query parameters, preserve sid parameter for eFiction sites + def minimal (input = url) + uri = Addressable::URI.parse(input) + queries = CGI::parse(uri.query) unless uri.query.nil? + if queries.nil? + input.gsub(/[?#].*$/, "") + else + queries.keep_if { |k, _| ["sid"].include? k } + querystring = "?#{URI.encode_www_form(queries)}" unless queries.empty? + input.gsub(/[?#].*$/, "") << querystring.to_s + end + end + + def minimal_no_protocol_no_www + minimal.gsub(%r{^https?://(www\.)?}, "") + end + + def no_www + minimal.gsub(%r{^(https?)://www\.}, "\\1://") + end + + def with_www + minimal.gsub(%r{^(https?)://}, "\\1://www.") + end + + def with_http + minimal.gsub(%r{^https?://}, "").prepend("http://") + end + + def with_https + minimal.gsub(%r{^https?://}, "").prepend("https://") + end + + def encoded + minimal URI::Parser.new.escape(url) + end + + def decoded + URI::Parser.new.unescape(minimal) + end + + # Adds http if not present, downcases the host and hyphenates spaces + # Extracted from story parser class + # Returns a Generic::URI + def standardized + uri = URI.parse(url) + uri = URI.parse("http://#{url}") if uri.instance_of?(URI::Generic) + uri.host = uri.host.downcase.tr(" ", "-") + uri + end +end diff --git a/lib/word_counter.rb b/lib/word_counter.rb new file mode 100644 index 0000000..a247b46 --- /dev/null +++ b/lib/word_counter.rb @@ -0,0 +1,37 @@ +# encoding=utf-8 + +require 'nokogiri' + +class WordCounter + + attr_accessor :text + + def initialize(text) + @text = text + end + + # only count actual text + # scan by word boundaries after stripping hyphens and apostrophes + # so one-word and one's will be counted as one word, not two. + # -- is replaced by — (emdash) before strip so one--two will count as 2 + def count + count = 0 + # avoid blank? so we don't need to load Rails for tests + return count if @text.nil? || @text.empty? + + # Scripts such as Chinese and Japanese that do not have space between words + # are counted based on the number of characters. If a text include mixed + # languages, only characters in these languages would be counted as words, + # words in other languages are counted as usual + character_count_scripts = ArchiveConfig.CHARACTER_COUNT_SCRIPTS.map { |lang| "\\p{#{lang}}" }.join("|") + body = Nokogiri::HTML5.parse(@text).xpath("//body").first + body.traverse do |node| + if node.text? + count += node.inner_text.gsub(/--/, "—").gsub(/['’‘-]/, "") + .scan(/#{character_count_scripts}|((?!#{character_count_scripts})[[:word:]])+/).size + end + end + count + end + +end diff --git a/lib/work_chapter_count_caching.rb b/lib/work_chapter_count_caching.rb new file mode 100644 index 0000000..e26973b --- /dev/null +++ b/lib/work_chapter_count_caching.rb @@ -0,0 +1,15 @@ +# This module is included by both the work and chapter models +module WorkChapterCountCaching + def key_for_chapter_posted_counting(work) + "/v1/chapters_posted/#{work.id}" + end + + def key_for_chapter_total_counting(work) + "/v1/chapters_total/#{work.id}" + end + + def invalidate_work_chapter_count(work) + Rails.cache.delete(key_for_chapter_total_counting(work)) + Rails.cache.delete(key_for_chapter_posted_counting(work)) + end +end diff --git a/lib/works_owner.rb b/lib/works_owner.rb new file mode 100644 index 0000000..bc5681b --- /dev/null +++ b/lib/works_owner.rb @@ -0,0 +1,52 @@ +# Used to generate cache keys for any works index page +# Include in models that can "own" works, eg ...tags/TAGNAME/works or users/LOGIN/works +module WorksOwner + + def self.included(base) + base.extend(ClassMethods) + end + + module ClassMethods + # expire a bunch of keys without having to look up the objects in the database + def expire_ids(ids) + ids.each do |id| + klass = (self.superclass == Tag) ? 'tag' : self.to_s.underscore + REDIS_GENERAL.set("#{klass}_#{id}_windex", Time.now.to_i.to_s) + end + end + end + + # Used in works_controller to determine whether to expire the cache for this object's works index page + # The timestamp should reflect the last update that would cause the list to need refreshing + # When both a collection and a tag are given, include both in the key and use the tag's timestamp + def works_index_cache_key(tag=nil) + key = "works_index_for_#{self.class.name.underscore}_#{self.id}_" + if tag.present? + key << "tag_#{tag.id}_#{tag.works_index_timestamp}" + else + key << "#{self.works_index_timestamp}" + end + key + end + + # Set the timestamp if it doesn't yet exist + def works_index_timestamp + REDIS_GENERAL.get(redis_works_index_key) || update_works_index_timestamp! + end + + # Should be called wherever works are updated + # Making the timestamp a stringy integer mostly for ease of testing + def update_works_index_timestamp! + t = Time.now.to_i.to_s + REDIS_GENERAL.set(redis_works_index_key, t) + return t + end + + private + + def redis_works_index_key + klass = self.is_a?(Tag) ? 'tag' : self.class.to_s.underscore + "#{klass}_#{self.id}_windex" + end + +end diff --git a/lib/zoho_auth_client.rb b/lib/zoho_auth_client.rb new file mode 100644 index 0000000..a7a5b0d --- /dev/null +++ b/lib/zoho_auth_client.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +class ZohoAuthClient + ACCESS_TOKEN_REQUEST_ENDPOINT = "https://accounts.zoho.com/oauth/v2/token" + ACCESS_TOKEN_CACHE_KEY = "/v3/zoho_access_token" + + SCOPE = [ + # - Find and create contacts before submitting tickets + # - Create tickets + # - Find tickets to justify admin actions + "Desk.contacts.CREATE", + "Desk.contacts.READ", + "Desk.search.READ", + "Desk.tickets.CREATE", + "Desk.tickets.READ", + "Desk.tickets.UPDATE" + ].join(",").freeze + + def access_token + if (cached_token = Rails.cache.read(ACCESS_TOKEN_CACHE_KEY)).present? + return cached_token + end + + response = HTTParty.post(ACCESS_TOKEN_REQUEST_ENDPOINT, query: access_token_params).parsed_response + access_token = response["access_token"] + + if (expires_in = response["expires_in_sec"]).present? + # We don't want the token to expire while we're in the middle of a sequence + # of requests, so we take the stated expiration time and subtract a little. + Rails.cache.write(ACCESS_TOKEN_CACHE_KEY, access_token, + expires_in: expires_in - 1.minute) + end + + # Return the access token: + access_token + end + + private + + def access_token_params + { + client_id: ArchiveConfig.ZOHO_CLIENT_ID, + client_secret: ArchiveConfig.ZOHO_CLIENT_SECRET, + redirect_uri: ArchiveConfig.ZOHO_REDIRECT_URI, + scope: SCOPE, + grant_type: "refresh_token", + refresh_token: ArchiveConfig.ZOHO_REFRESH_TOKEN + } + end +end diff --git a/lib/zoho_resource_client.rb b/lib/zoho_resource_client.rb new file mode 100644 index 0000000..546e84d --- /dev/null +++ b/lib/zoho_resource_client.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +class ZohoResourceClient + CONTACT_SEARCH_ENDPOINT = "https://desk.zoho.com/api/v1/contacts/search" + CONTACT_CREATE_ENDPOINT = "https://desk.zoho.com/api/v1/contacts" + TICKET_SEARCH_ENDPOINT = "https://desk.zoho.com/api/v1/tickets/search" + TICKET_CREATE_ENDPOINT = "https://desk.zoho.com/api/v1/tickets" + + def initialize(access_token:, email: nil) + @access_token = access_token + @email = email + end + + def retrieve_contact_id + (find_contact || create_contact).fetch("id") + end + + def find_ticket(ticket_number) + response = HTTParty.get( + TICKET_SEARCH_ENDPOINT, + query: search_params.merge(ticketNumber: ticket_number), + headers: headers + ).parsed_response + + # Note that Zoho returns an empty 204 if the ticket is marked as spam. + return if response.blank? || response.key?("errorCode") + + response.fetch("data").first + end + + def create_ticket(ticket_attributes:) + HTTParty.post( + TICKET_CREATE_ENDPOINT, + headers: headers, + body: ticket_attributes.to_json + ).parsed_response + end + + def create_ticket_attachment(ticket_id:, attachment_attributes:) + response = HTTParty.post( + ticket_attachment_create_endpoint(ticket_id), + headers: headers, + body: attachment_attributes + ).parsed_response + raise response["message"] if response["errorCode"] + + response + end + + def find_contact + response = HTTParty.get( + CONTACT_SEARCH_ENDPOINT, + query: search_params.merge(email: @email), + headers: headers + ).parsed_response + return if response.blank? || response.key?("errorCode") + + response.fetch("data").first + end + + def create_contact + HTTParty.post( + CONTACT_CREATE_ENDPOINT, + headers: headers, + body: contact_body.to_json + ).parsed_response + end + + def search_params + { + limit: 1, + sortBy: "modifiedTime" + } + end + + def headers + { + "Content-Type" => "application/json", + "orgId" => ArchiveConfig.ZOHO_ORG_ID, + "Authorization" => "Zoho-oauthtoken #{@access_token}" + } + end + + def contact_body + { + "lastName" => @email, + "email" => @email + } + end + + private + + def ticket_attachment_create_endpoint(ticket_id) + "#{ArchiveConfig.ZOHO_URL}/api/v1/tickets/#{ticket_id}/attachments" + end +end diff --git a/multi-master.info b/multi-master.info new file mode 100644 index 0000000..e69de29 diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..d9d211c --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,40 @@ +# General Apache options +AddHandler fastcgi-script .fcgi +AddHandler cgi-script .cgi +Options +FollowSymLinks +ExecCGI + +# If you don't want Rails to look in certain directories, +# use the following rewrite rules so that Apache won't rewrite certain requests +# +# Example: +# RewriteCond %{REQUEST_URI} ^/notrails.* +# RewriteRule .* - [L] + +# Redirect all requests not available on the filesystem to Rails +# By default the cgi dispatcher is used which is very slow +# +# For better performance replace the dispatcher with the fastcgi one +# +# Example: +# RewriteRule ^(.*)$ dispatch.fcgi [QSA,L] +RewriteEngine On + +# If your Rails application is accessed via an Alias directive, +# then you MUST also set the RewriteBase in this htaccess file. +# +# Example: +# Alias /myrailsapp /path/to/myrailsapp/public +# RewriteBase /myrailsapp + +RewriteRule ^$ index.html [QSA] +RewriteRule ^([^.]+)$ $1.html [QSA] +RewriteCond %{REQUEST_FILENAME} !-f +RewriteRule ^(.*)$ dispatch.cgi [QSA,L] + +# In case Rails experiences terminal errors +# Instead of displaying this message you can supply a file here which will be rendered instead +# +# Example: +# ErrorDocument 500 /500.html + +ErrorDocument 500 "

    Application error

    Rails application failed to start properly" diff --git a/public/403.html b/public/403.html new file mode 100644 index 0000000..1c057e3 --- /dev/null +++ b/public/403.html @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + Access Blocked | Archive of Our Own + + + + + + + + + + + + + +
    + + + + +
    +
    +

    Error 403

    +

    Access blocked.

    +

    Your IP address has been blocked from accessing the Archive of Our Own. These blocks are generally long-term. If you believe you have been blocked by mistake, please contact us.

    +
    + +
    + + +
    + + + + + + + + + diff --git a/public/406-unsupported-browser.html b/public/406-unsupported-browser.html new file mode 100644 index 0000000..68f19a0 --- /dev/null +++ b/public/406-unsupported-browser.html @@ -0,0 +1,149 @@ + + + + + + + + + + + + + + Archive of Our Own » Error 406 + + + + + + + + + + + + + +
    + + + + +
    +
    +

    Your browser is not supported.

    +

    Please upgrade your browser to continue.

    +
    + +
    + + +
    + + + + + + + + + diff --git a/public/445.html b/public/445.html new file mode 100644 index 0000000..ebaad82 --- /dev/null +++ b/public/445.html @@ -0,0 +1,149 @@ + + + + + + + + + + + + + + Archive of Our Own » Error 445 + + + + + + + + + + + + + +
    + + + + +
    +
    +

    Error 445

    +

    The Archive is currently experiencing heavy traffic. Please wait a few minutes and try again.

    +
    + +
    + + +
    + + + + + + + + + diff --git a/public/500.html b/public/500.html new file mode 100644 index 0000000..183ea3e --- /dev/null +++ b/public/500.html @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + Archive of Our Own » internal_server_error + + + + + + + + + + + + + +
    + + + + +
    +
    +

    Error 500

    +

    We're sorry, but something went wrong.

    +

    If you are receiving this error repeatedly, please contact Support. 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/public/502.html b/public/502.html new file mode 100644 index 0000000..d511d8d --- /dev/null +++ b/public/502.html @@ -0,0 +1,152 @@ + + + + + + + + + + + + + + Archive Down + + + + + + + + + + + + + +
    + + + + +
    +
    +

    Error 502

    +

    The page was responding too slowly.

    + +

    We're experiencing heavy load. The problem should be temporary; try refreshing the page.

    +

    Follow @AO3_Status on Twitter or ao3org on Tumblr for updates if this keeps happening.

    +
    + +
    + + +
    + + + + + + + + + diff --git a/public/503.html b/public/503.html new file mode 100644 index 0000000..80a5f28 --- /dev/null +++ b/public/503.html @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + Page responding too slowly. + + + + + + + + + + + + + +
    + + + + +
    +
    +

    Error 503

    +

    The page was responding too slowly.

    + +

    Follow @AO3_Status on Twitter or ao3org on Tumblr for updates if this keeps happening.

    +
    + +
    + + +
    + + + + + + + + + diff --git a/public/507.html b/public/507.html new file mode 100644 index 0000000..24ea3f9 --- /dev/null +++ b/public/507.html @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + Exceeded maximum posting rate. + + + + + + + + + + + + + +
    + + + + +
    +
    +

    Error 507

    +

    Exceeded maximum posting rate.

    +

    To combat bots, we are currently banning IP addresses that post too many works in a short time period. If you see this page repeatedly please pause a while between posting works. If you are banned, you will be unable to access the Archive. Access will be restored 24 hours after the ban started.

    +

    Follow @AO3_Status on Twitter or ao3org on Tumblr for updates.

    +
    + +
    + + +
    + + + + + + + + + diff --git a/public/OTW_Membership_Donation_Form.pdf b/public/OTW_Membership_Donation_Form.pdf new file mode 100644 index 0000000..2bdc827 Binary files /dev/null and b/public/OTW_Membership_Donation_Form.pdf differ diff --git a/public/apple-touch-icon-114x114-precomposed.png b/public/apple-touch-icon-114x114-precomposed.png new file mode 100644 index 0000000..cbbc686 Binary files /dev/null and b/public/apple-touch-icon-114x114-precomposed.png differ diff --git a/public/apple-touch-icon-114x114.png b/public/apple-touch-icon-114x114.png new file mode 100644 index 0000000..cbbc686 Binary files /dev/null and b/public/apple-touch-icon-114x114.png differ diff --git a/public/apple-touch-icon-120x120-precomposed.png b/public/apple-touch-icon-120x120-precomposed.png new file mode 100644 index 0000000..3219f30 Binary files /dev/null and b/public/apple-touch-icon-120x120-precomposed.png differ diff --git a/public/apple-touch-icon-120x120.png b/public/apple-touch-icon-120x120.png new file mode 100644 index 0000000..f348170 Binary files /dev/null and b/public/apple-touch-icon-120x120.png differ diff --git a/public/apple-touch-icon-144x144-precomposed.png b/public/apple-touch-icon-144x144-precomposed.png new file mode 100644 index 0000000..57dd4e6 Binary files /dev/null and b/public/apple-touch-icon-144x144-precomposed.png differ diff --git a/public/apple-touch-icon-144x144.png b/public/apple-touch-icon-144x144.png new file mode 100644 index 0000000..7fe263c Binary files /dev/null and b/public/apple-touch-icon-144x144.png differ diff --git a/public/apple-touch-icon-152x152-precomposed.png b/public/apple-touch-icon-152x152-precomposed.png new file mode 100644 index 0000000..e1eeeb3 Binary files /dev/null and b/public/apple-touch-icon-152x152-precomposed.png differ diff --git a/public/apple-touch-icon-152x152.png b/public/apple-touch-icon-152x152.png new file mode 100644 index 0000000..7bfed5f Binary files /dev/null and b/public/apple-touch-icon-152x152.png differ diff --git a/public/apple-touch-icon-167x167-precomposed.png b/public/apple-touch-icon-167x167-precomposed.png new file mode 100644 index 0000000..2ddbaf5 Binary files /dev/null and b/public/apple-touch-icon-167x167-precomposed.png differ diff --git a/public/apple-touch-icon-180x180-precomposed.png b/public/apple-touch-icon-180x180-precomposed.png new file mode 100644 index 0000000..64586d4 Binary files /dev/null and b/public/apple-touch-icon-180x180-precomposed.png differ diff --git a/public/apple-touch-icon-180x180.png b/public/apple-touch-icon-180x180.png new file mode 100644 index 0000000..d0afbe5 Binary files /dev/null and b/public/apple-touch-icon-180x180.png differ diff --git a/public/apple-touch-icon-57x57-precomposed.png b/public/apple-touch-icon-57x57-precomposed.png new file mode 100644 index 0000000..b7877ab Binary files /dev/null and b/public/apple-touch-icon-57x57-precomposed.png differ diff --git a/public/apple-touch-icon-57x57.png b/public/apple-touch-icon-57x57.png new file mode 100644 index 0000000..b7877ab Binary files /dev/null and b/public/apple-touch-icon-57x57.png differ diff --git a/public/apple-touch-icon-72x72-precomposed.png b/public/apple-touch-icon-72x72-precomposed.png new file mode 100644 index 0000000..36b121b Binary files /dev/null and b/public/apple-touch-icon-72x72-precomposed.png differ diff --git a/public/apple-touch-icon-72x72.png b/public/apple-touch-icon-72x72.png new file mode 100644 index 0000000..36b121b Binary files /dev/null and b/public/apple-touch-icon-72x72.png differ diff --git a/public/apple-touch-icon-76x76-precomposed.png b/public/apple-touch-icon-76x76-precomposed.png new file mode 100644 index 0000000..60609f6 Binary files /dev/null and b/public/apple-touch-icon-76x76-precomposed.png differ diff --git a/public/apple-touch-icon-76x76.png b/public/apple-touch-icon-76x76.png new file mode 100644 index 0000000..d60caae Binary files /dev/null and b/public/apple-touch-icon-76x76.png differ diff --git a/public/apple-touch-icon-precomposed.png b/public/apple-touch-icon-precomposed.png new file mode 100644 index 0000000..57dd4e6 Binary files /dev/null and b/public/apple-touch-icon-precomposed.png differ diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..7fe263c Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/apple-touch-icon120x120.png b/public/apple-touch-icon120x120.png new file mode 100644 index 0000000..f348170 Binary files /dev/null and b/public/apple-touch-icon120x120.png differ diff --git a/public/apple-touch-icon152x152.png b/public/apple-touch-icon152x152.png new file mode 100644 index 0000000..7bfed5f Binary files /dev/null and b/public/apple-touch-icon152x152.png differ diff --git a/public/apple-touch-icon76x76.png b/public/apple-touch-icon76x76.png new file mode 100644 index 0000000..d60caae Binary files /dev/null and b/public/apple-touch-icon76x76.png differ diff --git a/public/dispatch.cgi b/public/dispatch.cgi new file mode 100644 index 0000000..c584d66 --- /dev/null +++ b/public/dispatch.cgi @@ -0,0 +1,10 @@ +#!c:/ruby/bin/ruby + +require File.dirname(__FILE__) + "/../config/environment" unless defined?(RAILS_ROOT) + +# If you're using RubyGems and mod_ruby, this require should be changed to an absolute path one, like: +# "/usr/local/lib/ruby/gems/1.8/gems/rails-0.8.0/lib/dispatcher" -- otherwise performance is severely impaired +require "dispatcher" + +ADDITIONAL_LOAD_PATHS.reverse.each { |dir| $:.unshift(dir) if File.directory?(dir) } if defined?(Apache::RubyRun) +Dispatcher.dispatch \ No newline at end of file diff --git a/public/dispatch.fcgi b/public/dispatch.fcgi new file mode 100644 index 0000000..5d9b8ec --- /dev/null +++ b/public/dispatch.fcgi @@ -0,0 +1,24 @@ +#!c:/ruby/bin/ruby +# +# You may specify the path to the FastCGI crash log (a log of unhandled +# exceptions which forced the FastCGI instance to exit, great for debugging) +# and the number of requests to process before running garbage collection. +# +# By default, the FastCGI crash log is RAILS_ROOT/log/fastcgi.crash.log +# and the GC period is nil (turned off). A reasonable number of requests +# could range from 10-100 depending on the memory footprint of your app. +# +# Example: +# # Default log path, normal GC behavior. +# RailsFCGIHandler.process! +# +# # Default log path, 50 requests between GC. +# RailsFCGIHandler.process! nil, 50 +# +# # Custom log path, normal GC behavior. +# RailsFCGIHandler.process! '/var/log/myapp_fcgi_crash.log' +# +require File.dirname(__FILE__) + "/../config/environment" +require 'fcgi_handler' + +RailsFCGIHandler.process! diff --git a/public/dispatch.rb b/public/dispatch.rb new file mode 100644 index 0000000..6f48fda --- /dev/null +++ b/public/dispatch.rb @@ -0,0 +1,10 @@ +#!c:/ruby/bin/ruby + +require File.dirname(__FILE__) + "/../config/environment" unless defined?(Rails.root) + +# If you're using RubyGems and mod_ruby, this require should be changed to an absolute path one, like: +# "/usr/local/lib/ruby/gems/1.8/gems/rails-0.8.0/lib/dispatcher" -- otherwise performance is severely impaired +require "dispatcher" + +ADDITIONAL_LOAD_PATHS.reverse.each { |dir| $:.unshift(dir) if File.directory?(dir) } if defined?(Apache::RubyRun) +Dispatcher.dispatch \ No newline at end of file diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..7e6c4fd Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/help/add-collectible-to-collection.html b/public/help/add-collectible-to-collection.html new file mode 100644 index 0000000..20bdf58 --- /dev/null +++ b/public/help/add-collectible-to-collection.html @@ -0,0 +1,18 @@ +

    Adding To Collections

    + +

    + Enter collection names as a comma-separated list, and the item you are editing will be added to all the collections you specify. +

    + +

    + Note that you need to use the collection's name, which gets used in the collection's URL, and not the collection's spiffy title (because different collections can have the same title). A collection's name is the equivalent of your user login. Names will be auto-completed for you if you have JavaScript turned on. +

    + +

    + Also note: if the collection you are submitting to is moderated, and you aren't a member, your story will not be added to the collection automatically - it will have to be approved by one of the collection's moderators. If this is an anonymous and/or unrevealed collection your work will be anonymous and/or hidden as soon as you post it, including while it's waiting for approval. If your work is rejected, it will remain anonymous and/or unrevealed until you edit it to remove it from the collection or until the moderator removes it. +

    + +

    + If you change your mind about having an item in a collection, you can either edit the list of collections + when editing it, or you can manage all your collected items under the "My Collections" page in your account. +

    diff --git a/public/help/add-work-to-assignment.html b/public/help/add-work-to-assignment.html new file mode 100644 index 0000000..8bc2ae1 --- /dev/null +++ b/public/help/add-work-to-assignment.html @@ -0,0 +1,15 @@ +

    Fulfilling Assignments

    + +

    + If you have any open challenge assignments, you can choose one of them here if the story you are posting is meant to fulfill one of them. + This will automatically add your story to the challenge collection (or submit it for approval) and also mark your assignment + as fulfilled for the challenge moderators. +

    + +

    + Note: if the collection you are submitting to is moderated, and you aren't a member, your story will not be + added to the collection automatically — it will have to be approved by one of the collection's moderators. + If this is an anonymous and/or unrevealed collection your work will be anonymous and/or hidden as soon as you post it, including while it's waiting for approval. + If your work is rejected, it will remain anonymous and/or unrevealed until you edit it to remove it from the collection or until the moderator removes it. +

    + diff --git a/public/help/additional-tags-help.html b/public/help/additional-tags-help.html new file mode 100644 index 0000000..6cfdbe5 --- /dev/null +++ b/public/help/additional-tags-help.html @@ -0,0 +1,6 @@ +

    Additional Tags

    + +

    (For more information, see the Tags on the Archive FAQ.)

    + +

    Any other tags you want to give your work (for example, "Angst", "Crossover", or "Tentacles"). You may also use this field to warn for things not covered by the Archive Warnings. Please do not enter fandom, relationship, or character names in this field. Multiple tags should be separated by commas.

    + diff --git a/public/help/backdating-help.html b/public/help/backdating-help.html new file mode 100644 index 0000000..f05058b --- /dev/null +++ b/public/help/backdating-help.html @@ -0,0 +1,16 @@ +

    Publication Date Options

    + +

    + When posting a work, you have the option to set a different publication date + — in other words, to backdate the work. You can also set publication dates for + individual chapters. Please note that in both cases, this affects whether and where + your work appears on your dashboard and the works page, etc. These pages which show + the date when your work was last updated — the publication date of your work or + of one of your chapters, whichever is the most recent date. +

    + +

    + Any subsequent chapters you add will have that date pre-set on the form, though you + will still have the option of overriding that date. This means that if you don't know + or care exactly when each chapter was originally published, you can still backdate your work conveniently. +

    diff --git a/public/help/bookmark-filters-exclude-tags.html b/public/help/bookmark-filters-exclude-tags.html new file mode 100644 index 0000000..49a3f19 --- /dev/null +++ b/public/help/bookmark-filters-exclude-tags.html @@ -0,0 +1,25 @@ +

    Tag Filters: Exclude Tags

    + +

    + The filters list the ten most popular tags for each tag category. To filter by any other tags, use the "Other work tags to exclude" or "Other bookmarker's tags to exclude" fields. +

    + +

    + If the tag you want to exclude isn't in the top ten, start entering the tag you need in either the "Other work tags to exclude" or "Other bookmarker's tags to exclude" field—all tag categories are valid here, and you can add as many tags as you want. The autocomplete will help you find the canonical version of the tag. Use these to take full advantage of the wrangled tagging structure, which will exclude all works using subtags and tags with synonymous meanings. +

    + +

    + You can also enter tags that aren't in the autocomplete. If the tag you enter has been used on the Archive but is not marked canonical, then the filters will look for works that use the exact tag you've entered. If the tag you've entered has never been used on the Archive, but in that case the filters will do a simple text match and may bring up unexpected results. The "Search within results" and "Search bookmarker's tags and notes" fields will text-match more accurately, especially in the case of relationship tags and other tags with "/" or other non-text characters. +

    + +

    + Choosing any tag from a category or entering a tag in the "Other work tags to exclude" or "Other bookmarker's tags to exclude" field will do an OR search with all the tags you select. This means that if you're filtering the bookmarks tagged with the F/F category, select the Major Character Death warning, the canonical Alternate Universe tag in the Additional Tags category, and enter or select the canonical tag Drama in the "Other work tags to exclude" field, only bookmarks of works or series tagged without any of these tags will be included in the results. +

    + +

    + To look up tags and see which ones are canonical, use the Tag Search. +

    + +

    + You can find more information about tags in our Tags FAQ. To read the guidelines tag wranglers use to mark tags canonical, among other things, or to better understand the AO3-specific vocabulary for tags, read the Wrangling Guidelines. +

    diff --git a/public/help/bookmark-filters-include-tags.html b/public/help/bookmark-filters-include-tags.html new file mode 100644 index 0000000..b348b3d --- /dev/null +++ b/public/help/bookmark-filters-include-tags.html @@ -0,0 +1,29 @@ +

    Tag Filters: Include Tags

    + +

    + The filters list the ten most popular tags for each tag category. To filter by any other tags, use the "Other work tags to include" and "Other bookmarker's tags to include" fields. +

    + +

    + If the tag that interests you isn't in the top ten, start entering the tag you need in either the "Other work tags to include" or "Other bookmarker's tags to include" field—all tag categories are valid here, and you can add as many tags as you want. The autocomplete will help you find the canonical version of the tag. Use these to take full advantage of the wrangled tagging structure, which will find all works using subtags and tags with synonymous meanings. +

    + +

    + You can also enter tags that aren't in the autocomplete. If the tag you enter has been used on the Archive but is not marked canonical, then the filters will look for works that use the exact tag you've entered. If the tag you've entered has never been used on the Archive, the filters will do a simple text match and may bring up unexpected results. The "Search within results" and "Search bookmarker's tags and notes" fields will text-match more accurately, especially in the case of relationship tags and other tags with "/" or other non-text characters. +

    + +

    + Choosing any tag from a category or entering a tag in the "Other work tags to include" or "Other bookmarker's tags to include" field will do an AND search with all the tags you select. This means that if you're filtering the bookmarks tagged with the F/F category, select the Teen and Up Audiences rating, the canonical Romance tag in the Additional Tags category, and enter or select the canonical tag Drama in the "Other work tags to include" field, only bookmarks of works or series tagged with all these tags will be included in the results. +

    + +

    + To get results with Tag A OR Tag B, use the "Search within results" or "Search bookmarker's tags and notes" fields. +

    + +

    + To look up tags and see which ones are canonical, use the Tag Search. +

    + +

    + You can find more information about tags in our Tags FAQ. To read the guidelines tag wranglers use to mark tags canonical, among other things, or to better understand the AO3-specific vocabulary for tags, read the Wrangling Guidelines. +

    diff --git a/public/help/bookmark-search-bookmarker-tag.html b/public/help/bookmark-search-bookmarker-tag.html new file mode 100644 index 0000000..9dd5e39 --- /dev/null +++ b/public/help/bookmark-search-bookmarker-tag.html @@ -0,0 +1,5 @@ +

    Bookmark Search: Bookmarker's Tags

    + +

    + The "Bookmarker's tags" field searches all tags added to a bookmark. This does not include tags on the bookmarked series or work itself. Tags can be any of the following: Rating, Warning, Category, Fandom, Character, Relationship, Additional Tags. The field will suggest canonical tags as you enter search terms. +

    diff --git a/public/help/bookmark-search-date-bookmarked-help.html b/public/help/bookmark-search-date-bookmarked-help.html new file mode 100644 index 0000000..547c353 --- /dev/null +++ b/public/help/bookmark-search-date-bookmarked-help.html @@ -0,0 +1,18 @@ +

    Bookmark Search: Date Bookmarked

    + +

    Specify a range of times to find bookmarks which were created during that time frame. This may differ from the time when the bookmarked item was posted or updated.

    + +

    You can search by year, month, week, day, or hour.

    + +

    Examples:
    + +
    +
    < 3 days ago
    +
    will find bookmarks that were created within the past three days
    +
    > 3 years ago
    +
    will find bookmarks that were created more than three years ago
    +
    3-9 months ago
    +
    will find bookmarks that were created between 3 and 9 months ago
    +
    + +

    The "ago" is optional. Note that "1 day ago" is not a range, and would only find bookmarks created at this exact instant yesterday. Create a range instead.

    diff --git a/public/help/bookmark-search-date-updated-help.html b/public/help/bookmark-search-date-updated-help.html new file mode 100644 index 0000000..48eaea9 --- /dev/null +++ b/public/help/bookmark-search-date-updated-help.html @@ -0,0 +1,18 @@ +

    Bookmark Search: Date Updated

    + +

    Specify a range of times to find bookmarked items that were posted or updated during that time frame: works with new chapters, for example, or series with new works. If a work was backdated by its creator (i.e. had a different publication date set when it was uploaded to the Archive), that date will be used for this search.

    + +

    You can search by year, month, week, day, or hour.

    + +

    Examples:
    + +
    +
    < 3 days ago
    +
    will find bookmarks of works that were posted or updated within the past three days
    +
    > 3 years ago
    +
    will find bookmarks of works that were posted or updated more than three years ago
    +
    3-9 months ago
    +
    will find bookmarks of works that were posted or updated between 3 and 9 months ago
    +
    + +

    The "ago" is optional. Note that "1 day ago" is not a range, and would only find items updated at this exact instant yesterday. Create a range instead.

    diff --git a/public/help/bookmark-search-notes-help.html b/public/help/bookmark-search-notes-help.html new file mode 100644 index 0000000..72c1033 --- /dev/null +++ b/public/help/bookmark-search-notes-help.html @@ -0,0 +1,3 @@ +

    Bookmark Search: With Notes

    + +

    Select this option if you want to limit the search to bookmarks with a note added by the bookmarker.

    \ No newline at end of file diff --git a/public/help/bookmark-search-rec-help.html b/public/help/bookmark-search-rec-help.html new file mode 100644 index 0000000..f38da12 --- /dev/null +++ b/public/help/bookmark-search-rec-help.html @@ -0,0 +1,3 @@ +

    Bookmark Search: Rec

    + +

    Select this option if you want to limit the search to bookmarks which were marked as recs by the bookmarker.

    diff --git a/public/help/bookmark-search-results-help.html b/public/help/bookmark-search-results-help.html new file mode 100644 index 0000000..16ff0e5 --- /dev/null +++ b/public/help/bookmark-search-results-help.html @@ -0,0 +1,3 @@ +

    Bookmarks Search: Results

    + +

    Results are sorted by relevance. Please note that the list will include all bookmarks for a work, as each bookmark is factored into the results individually. To search for works instead of bookmarks, use the Work Search.

    diff --git a/public/help/bookmark-search-text-help.html b/public/help/bookmark-search-text-help.html new file mode 100644 index 0000000..584f577 --- /dev/null +++ b/public/help/bookmark-search-text-help.html @@ -0,0 +1,18 @@ +

    Bookmark Search: Text

    + +

    Use the following guidelines for entering search terms and search operators. "Any field" combines all text fields in the search form, including tags. "Bookmarker" lets you search for bookmarks created by a particular user. "Notes" searches for terms within all bookmarkers' notes.

    + +

    The characters ":" and "@" have special meanings. Leave them out of your search or you will get unexpected results.

    + +
    +
    *: any characters
    +
    book* will find book and books and booking.
    +
    space: a space acts like AND
    +
    Harry Potter will find Harry Potter and Harry James Potter but not Harry.
    +
    ||: OR (not exclusive)
    +
    Harry || Potter will find Harry, Harry Potter, and Potter.
    +
    "": words in exact sequence
    +
    "Harry Lockhart" will find Harry Lockhart but not Harry Potter/Gilderoy Lockhart.
    +
    -: NOT
    +
    Harry -Lockhart will find Harry Potter but not Harry Lockhart or Gilderoy Lockhart/Harry Potter.
    +
    diff --git a/public/help/bookmark-search-type-help.html b/public/help/bookmark-search-type-help.html new file mode 100644 index 0000000..0a5b5ce --- /dev/null +++ b/public/help/bookmark-search-type-help.html @@ -0,0 +1,7 @@ +

    Bookmark Search: Type

    + +

    + Select the type of bookmarked item to limit your search results to Work, + Series, or External Work. Note that selecting External Work will return any + bookmarks for works hosted off the Archive. +

    diff --git a/public/help/bookmark-search-work-tag.html b/public/help/bookmark-search-work-tag.html new file mode 100644 index 0000000..fa1ea7d --- /dev/null +++ b/public/help/bookmark-search-work-tag.html @@ -0,0 +1,5 @@ +

    Bookmark Search: Work Tags

    + +

    + The "Work tags" field searches all tags added to a bookmarked item by the item's creator. This does not include tags added by the bookmarker. Tags can be any of the following: Rating, Warning, Category, Fandom, Character, Relationship, Additional Tags. The field will suggest canonical tags as you enter search terms. +

    diff --git a/public/help/bookmark-symbols-key.html b/public/help/bookmark-symbols-key.html new file mode 100644 index 0000000..c40c329 --- /dev/null +++ b/public/help/bookmark-symbols-key.html @@ -0,0 +1,11 @@ +

    Bookmark symbols

    +
    +
    Rec
    +
    Rec
    +
    Public Bookmark
    +
    Public Bookmark
    +
    Private Bookmark
    +
    Private Bookmark
    +
    Bookmark Hidden by Admin
    +
    This bookmark has been hidden by an admin
    +
    diff --git a/public/help/categories-help.html b/public/help/categories-help.html new file mode 100644 index 0000000..6d40940 --- /dev/null +++ b/public/help/categories-help.html @@ -0,0 +1,21 @@ +

    Category Tags

    + +

    (For more information, see the Tags on the Archive FAQ.)

    + +

    There are 6 categories of works on the Archive. While here we have given an interpretation of the abbreviations, the exact definitions of these vary from fandom to fandom and fan to fan; use whichever you feel are applicable, or else none:

    + +
    +
    F/F
    +
    Female/Female relationships
    +
    F/M
    +
    Female/Male relationships
    +
    Gen
    +
    General: no romantic or sexual relationships, or relationships which are not the main focus of the work
    +
    M/M
    +
    Male/Male relationships
    +
    Multi
    +
    More than one kind of relationship, or a relationship with multiple partners
    +
    Other
    +
    Other relationships
    +
    + diff --git a/public/help/challenge-any.html b/public/help/challenge-any.html new file mode 100644 index 0000000..094963c --- /dev/null +++ b/public/help/challenge-any.html @@ -0,0 +1,28 @@ +

    Choosing Any

    + +

    + If you choose "Any" for a field when you are filling out your sign-up, that means that you will be matched + on this field NO MATTER WHAT -- so this is potentially risky! Please be sure that you really mean ANY! + Even if you also fill out the field, the "Any" option will override anything else you put in there. +

    + +
    Some examples
    + +

    Offering any:

    +
      +
    • You offer the fandom "Twin Peaks" and choose "Any" for relationships.
    • +
    • Mary Sue requests "Twin Peaks" as her fandom and the relationship "Log Lady/Llama"
    • +
    • You can match Mary Sue and will be expected to write a story about the epic love of the Llama and the Log Lady.
    • +
    + +

    Requesting any: (this is often especially confusing!)

    +
      +
    • Mary Sue offers the fandom "Twin Peaks" and the relationship "Log Lady/Llama"
    • +
    • You request the fandom "Twin Peaks" and choose "Any" for relationships.
    • +
    • Mary Sue may be assigned your request and the only thing she will write for you is an epic tale of the Log Lady and the Llama.
    • +
    + +

    + Your challenge moderator may choose to only allow the "Any" option for offers, or only for certain fields. +

    + \ No newline at end of file diff --git a/public/help/challenge-assignments.html b/public/help/challenge-assignments.html new file mode 100644 index 0000000..9555bee --- /dev/null +++ b/public/help/challenge-assignments.html @@ -0,0 +1,32 @@ +

    Challenge Assignments

    + +

    + You can review the assignments for your challenge in several different categories. +

    + +
    +
    Defaulted
    +
    The most urgent: the assigned person has defaulted and + the recipient needs a substitute (a "pinch hitter") assigned.
    + +
    Pinch Hits
    +
    You've assigned a pinch hitter.
    + +
    Open
    +
    The assigned person hasn't posted their work yet, or + you haven't approved their work (if your collection is moderated).
    + +
    Complete
    +
    The work has been posted (and approved if your collection is moderated).
    +
    + +

    + If a work is posted that does not meet your challenge standards, you can remove it from the collection under the + Manage Items page and the work will be removed from the listing, at which point you will be able to + come back to the assignments page and mark the writer as having defaulted. +

    + +

    + NOTE: if your collection is unrevealed or + anonymous, removing the work from the collection will cause it to be revealed! +

    diff --git a/public/help/challenge-category-tags.html b/public/help/challenge-category-tags.html new file mode 100644 index 0000000..5cfe736 --- /dev/null +++ b/public/help/challenge-category-tags.html @@ -0,0 +1,64 @@ +

    Categories

    + +

    + Here you can enter the number of relationships or orientations allowable per + request. Refer to the below for example types. +

    + +
    +
    + Second Square +
    +
    +

    Relationships and orientations

    +
    +
    + Femslash +
    +
    + F/F: female/female relationships +
    +
    + Het +
    +
    + F/M: male/female relationships +
    +
    + Gen +
    +
    + Gen: no romantic or sexual relationships +
    +
    + Slash +
    +
    + M/M: male/male relationships +
    +
    + Multi +
    +
    + Multi: more than one kind of the relationships listed here +
    +
    + Other +
    +
    + Other relationships +
    +
    + None +
    +
    + The work was not put in any categories +
    +
    +
    +
    diff --git a/public/help/challenge-include-optional-tags.html b/public/help/challenge-include-optional-tags.html new file mode 100644 index 0000000..4a729d9 --- /dev/null +++ b/public/help/challenge-include-optional-tags.html @@ -0,0 +1,28 @@ +

    Include Optional Tags

    + +

    + If you choose this option, then any optional tags you allowed people to set will be used to fulfill + required matches on this field. Otherwise, matches on optional tags will only be used to increase the ranking + of the match. +

    + +
    Example:
    + +

    + A challenge requires people to offer and request two fandoms, but also allows them to enter extra fandoms in the optional tags. + The moderator promises assignments will match on at least one fandom (and therefore chooses "1" as the number of required fandoms to match). +

    + +
      +
    • Jane signs up and requests Smallville and Queer As Folk UK. In her optional tags, she also lists Sesame Street.
    • + +
    • Bob signs up and offers Sesame Street and Project Runway.
    • + +
    • Sue signs up and offers Smallville and American Idol RPF. She also offers Sesame Street in her optional tags.
    • +
    + +

    + If the moderator turns on "include optional tags" for fandoms, then both Bob and Sue will be potential matches for Jane. + Otherwise, only Sue will match with Jane. + In both cases, Sue will be ranked as a better match because she also matches Jane in her optional tags. +

    diff --git a/public/help/challenge-matching-assignments.html b/public/help/challenge-matching-assignments.html new file mode 100644 index 0000000..5764b1d --- /dev/null +++ b/public/help/challenge-matching-assignments.html @@ -0,0 +1,89 @@ +

    Matching

    + +

    The matching process for gift exchanges runs in two stages:

    +
    +
    Potential Matching
    +
    All the sign-ups are compared and for each person we generate a list of potential givers (people who can give them a gift), and potential recipients (people they can give a gift to). This is the slowest part of the process and can take as much as a day for a very large challenge or one where almost all the participants match each other.
    + +
    Assignments
    +
    We try to give everyone a single assignment, starting with the people who have the fewest potential givers or recipients and working up until no one else can be assigned. This process is relatively fast; it generally completes within an hour.
    +
    + +

    You may have to decide how to fix several issues that can come up during matching.

    + +

    No Potential Recipients/No Potential Givers

    + +

    If someone has no potential recipients, that means no one wants what they are offering. There is no way to fix this unless the person agrees to + offer something else that someone has requested. If they do, you can edit their sign-up and then regenerate their potential matches.

    + +

    If someone has no potential givers, no one has offered what they want. You can try to find a pinch hitter for them if you prefer. Otherwise, + just as above, ask them to request something else, edit their sign-up, and then regenerate their potential matches.

    + +

    Regenerating Potential Matches

    + +

    Because regenerating ALL potential matches for everyone in your challenge is very slow, it is better to regenerate potential matches just for + each individual person who has no potential matches, which will most likely only take a few minutes. Just do them one at a time, please!

    + +

    If you have several people with no potential matches, once you have regenerated potential matches for them, you may want to + regenerate assignments to have the archive try to assign them along with everyone else. Note that this will very likely change the assignments for + EVERYONE.

    + + +

    Assignment Issues

    + +

    Once you have potential matches for everyone, the remaining problems will be with getting everyone assigned.

    + +

    No Giver

    + +

    These people could not be assigned a giver. You can shuffle the main assignments to free someone up, or you can write-in + a volunteer. If you click in the Giver field, you'll see a list of the people who could write for each person. + Note: the form will allow you to double-assign people, but remember to make sure they can write two assignments first! + (See "Duplicate Givers" below.) +

    + +

    No Recipient

    + +

    These people haven't been assigned a recipient. If you click in the Recipient field, you'll see a list of the + people that each one could write for. You can try shuffling the completed assignments to free up one of them, + or you can double-assign a recipient. (See "Duplicate Recipients" below.) + The newly matched people will disappear from this section after you update. +

    + +

    Duplicate Recipients

    + +

    + You will see the duplicate recipients listing appear if you assign a person as recipient to more than one + person. This is not necessarily a problem, they will just get more than one gift. +

    + +

    + You might deliberately want to keep duplicate recipients. Sometimes in a gift exchange, it may not be possible to get everyone + assigned one on one. Instead of shuffling to try and match everybody, you can instead recruit a few pinch hitters for the + people who don't have an assigned writer, and double-assign recipients for the people who don't have a request of their own. +

    + +

    + As far as the people getting the assignment are concerned, there is nothing different about getting a + double-assigned recipient. The recipient will just have two + givers, and assuming neither one defaults, will get two gifts instead of one. +

    + +

    Duplicate Givers

    + +

    + You will see the duplicate givers listing appear if you assign a person as giver to more than one person. + This is not a problem as long as the assigned givers are in fact willing to get more than one assignment. + However, for housekeeping purposes, you might only want one of these listings to be their "official" assignment. + If so, you can delete their name from the "Giver" column and move their name into the "Write-In" field + (in which case they will be listed on those other assignment as a pinch hitter). +

    + +

    Shuffling Assignments

    + +

    Finally, even if everyone has a recipient and a giver, you may still want to shuffle assignments around in order to + give people better matches. You can do so as much as you want.

    + +

    Please note that circular matches (where A is assigned to B and B is assigned to A) + will happen sometimes randomly. You can try and reshuffle by hand if you want to avoid these. +

    + diff --git a/public/help/challenge-matching.html b/public/help/challenge-matching.html new file mode 100644 index 0000000..1b9e709 --- /dev/null +++ b/public/help/challenge-matching.html @@ -0,0 +1,26 @@ +

    Matching

    + +

    The matching process for gift exchanges runs in two stages:

    +
    +
    Potential Matching
    +
    All the sign-ups are compared and for each person we generate a list of potential givers (people who can give them a gift), and potential recipients (people they can give a gift to). This is the slowest part of the process and can take as much as a day for a very large challenge or one where almost all the participants match each other.
    + +
    Assignments
    +
    We try to give everyone a single assignment, starting with the people who have the fewest potential givers or recipients and working up until no one else can be assigned. This process is relatively fast; it generally completes within an hour.
    +
    + +

    You may have to decide how to fix several issues that can come up during matching.

    + +

    Potential Match Issues

    + +

    Invalid Sign-ups

    + +

    Sometimes a challenge can end up with one or more invalid sign-ups -- duplicates, or sign-ups that have too many or too few offers/requests or ones that don't fit the rules of your challenge. Most commonly, this happens if someone clicks submit or update multiple times because their connection to the archive is slow. It can also happen if you have changed the rules of +your challenge partway through the sign-up process -- read further for details about this.

    + +

    If this happens, potential matching won't run, and you'll be quickly emailed a list of links to the invalid sign-ups so you can examine them. You can often tell easily when two sign-ups or offers/requests are duplicates, in which case you can just delete the duplicates. Sometimes you may have to contact a participant to figure out which of the sign-ups or prompts they want to use.

    + +

    If on the other hand you made the rules more strict mid-stream and you have a large number of invalid sign-ups as a result, you may only be able to fix this by easing your rules to match the lowest common denominator. For instance, if you started by requiring 2 offers and 2 requests, and then upped the requirement to 3 offers and 3 requests a day into sign-up, you may have a whole slew of sign-ups that only have 2/2 and are now therefore considered invalid. To fix this, you'll have to change your challenge settings back to only requiring 2/2, while allowing 3/3.

    + +

    After you have dealt with all the invalid sign-ups, you can then start matching again.

    + diff --git a/public/help/challenge-optional-tags-user.html b/public/help/challenge-optional-tags-user.html new file mode 100644 index 0000000..f9cc047 --- /dev/null +++ b/public/help/challenge-optional-tags-user.html @@ -0,0 +1,8 @@ +

    Optional Tags

    + +

    + Optional tags will be used by the moderator to try and improve matching, but may be discarded completely if + necessary to make a match. This is a good place to add more obscure and specific tags. + Note that the more tags you enter, the more likely it is your optional tags will have to be ignored, + and the slower matching runs, so don't go nuts! +

    diff --git a/public/help/challenge-optional-tags.html b/public/help/challenge-optional-tags.html new file mode 100644 index 0000000..ef30fa7 --- /dev/null +++ b/public/help/challenge-optional-tags.html @@ -0,0 +1,18 @@ +

    Optional Tags

    + +

    + By default, you set a number of tags (and if you want, a selection of specific tags) that people can use in their + requests -- for example, maybe you allow people to request a fandom and two characters. Optional tags add an extra + option to the sign-up page: the person signing up can put any canonical tags they want, of any kind, into the optional box. +

    + +

    + For a gift exchange, you don't want to offer too many ordinary tags, because that will make it harder to match people up, + especially if you promise an exact match on all the tags (you can of course also just promise as good a match as possible). +

    + +

    + Optional tags are useful if you want to try and let your challenge members match up on more detailed or obscure requests, + but want to ensure you can match without too much pain. The matching process will try to match + people on these optional tags, but if it can't, it will fall back to the basic tags. +

    diff --git a/public/help/challenge-pinch-hitter.html b/public/help/challenge-pinch-hitter.html new file mode 100644 index 0000000..e83626e --- /dev/null +++ b/public/help/challenge-pinch-hitter.html @@ -0,0 +1,6 @@ +

    Pinch Hitters

    + +

    + A pinch hitter does NOT need to be signed up for the challenge. Anyone with an archive account can be assigned. + You should probably make sure they are willing to write the assignment first, though! :) +

    diff --git a/public/help/challenge-requests-summary.html b/public/help/challenge-requests-summary.html new file mode 100644 index 0000000..deb4d58 --- /dev/null +++ b/public/help/challenge-requests-summary.html @@ -0,0 +1,8 @@ +

    Public Sign-up Summary

    + +

    + If you choose this option, all users will be able to view the requests that have been made for your challenge. + This can encourage people to make matching offers or write extra stories for participants, but does create + spoilers for requests. You may want to only turn this on later, after your exchange has finished, in order to let users write + unfilled requests. +

    diff --git a/public/help/chapter-title.html b/public/help/chapter-title.html new file mode 100644 index 0000000..26d3e9f --- /dev/null +++ b/public/help/chapter-title.html @@ -0,0 +1,3 @@ +

    Chapter Title

    + +

    You can add a chapter title, but it's not required.

    \ No newline at end of file diff --git a/public/help/characters-help.html b/public/help/characters-help.html new file mode 100644 index 0000000..4a57d37 --- /dev/null +++ b/public/help/characters-help.html @@ -0,0 +1,6 @@ +

    Characters Tags

    + +

    (For more information, see the Tags on the Archive FAQ.)

    + +

    The main character(s) in your work, separated by commas. Full names (personal name and family name) are preferred.

    + diff --git a/public/help/choosing-series.html b/public/help/choosing-series.html new file mode 100644 index 0000000..3add103 --- /dev/null +++ b/public/help/choosing-series.html @@ -0,0 +1,11 @@ +

    Choosing Series

    + +

    + A series is a set of related stories, each of which is complete on its own. + You can make new series or add stories to your series anytime you like + from inside your dashboard. +

    +

    + If you'd like to post a work in progress, or a story with chapters, you + probably want a chaptered story instead. +

    \ No newline at end of file diff --git a/public/help/collection-closed.html b/public/help/collection-closed.html new file mode 100644 index 0000000..bfb2988 --- /dev/null +++ b/public/help/collection-closed.html @@ -0,0 +1,7 @@ +

    Closed Collection

    + +

    +Once a collection is closed, no works or bookmarks can be added to it, except by the maintainers (owners and moderators). +If this is a gift exchange or other challenge, note that this won't be automatically triggered by any deadlines you set in your challenge settings, +but has to be manually set here. +

    diff --git a/public/help/collection-moderated.html b/public/help/collection-moderated.html new file mode 100644 index 0000000..4b7e642 --- /dev/null +++ b/public/help/collection-moderated.html @@ -0,0 +1,18 @@ +

    Moderated Collection

    + +

    + By default, collections are not moderated, which means any registered user of the Archive can add + their works to the collection. The owners/moderators of the collection can still reject stories + after they have been posted, if they are not appropriate. +

    + +

    + If you set your collection to be moderated, all registered users will still be able to post, but their + stories will not appear in the collection until they are approved by a moderator or owner. Approved + members will be able to post automatically without manual approval. +

    + +

    + Owners of a collection can edit the collection preferences and data, and also delete the collection + entirely. Moderators of a collection can approve/invite members and add/reject stories. +

    diff --git a/public/help/collection-name.html b/public/help/collection-name.html new file mode 100644 index 0000000..52bb72f --- /dev/null +++ b/public/help/collection-name.html @@ -0,0 +1,9 @@ +

    Collection Name

    + +

    + The name of the collection can be changed at a later point, but that will break links to the collection. +

    +

    + The name can consist only of ASCII letters (a-z, A-Z), numbers and underscores, and cannot contain spaces. +

    + \ No newline at end of file diff --git a/public/help/collection-preferences.html b/public/help/collection-preferences.html new file mode 100644 index 0000000..7c2b13b --- /dev/null +++ b/public/help/collection-preferences.html @@ -0,0 +1,32 @@ +

    Collections, Challenges and Gifts Preferences

    + +
    + +
    Allow others to invite my works to collections
    +
    +

    + Enabling this option will allow other AO3 users to invite your work into + their collection. Your work won't be added to the collection until you + have accepted the request. For more information on accepting collection + invitations, refer to How do I approve or reject an invitation to include my work in a collection? +

    +

    + Leaving this option disabled will prevent other users from inviting your + works into their collections at all, and you won't receive any + notifications. +

    +

    Changing this setting won't affect any existing works in collections.

    +
    + +
    Allow anyone to gift me works
    +
    If this option is disabled, users are only able to give a gift work to you if they're completing a gift exchange assignment or fulfilling a claimed prompt in a prompt meme. If you'd like to allow users to give you gift works outside of assignments and claimed prompts, enable this option. Note that you're always able to refuse gifts individually. Check out How do I refuse a gift? for instructions.
    + +
    Turn off emails from collections
    +
    Enable this option if you would prefer not to receive email alerts from collections, such as creator and work reveals. We'll still email you if your username or work is hidden after being added to a collection. Notifications will still be delivered to your Archive inbox unless you have chosen to disable them.
    + +
    Turn off inbox messages from collections
    +
    Enable this option if you would prefer not to receive notifications in your Archive inbox from collections, for example, notifying you when hidden works are revealed. Notifications will still be emailed to you unless you have chosen to disable them.
    + +
    Turn off emails about gift works
    +
    Enable this option if you would prefer not to receive email alerts when someone gifts a work to you. Notifications will still be displayed on your Gifts page.
    +
    diff --git a/public/help/comment-preferences.html b/public/help/comment-preferences.html new file mode 100644 index 0000000..62325d7 --- /dev/null +++ b/public/help/comment-preferences.html @@ -0,0 +1,14 @@ +

    Comment Preferences

    + +
    +
    Turn off emails about comments
    +
    Enable this option if you would prefer not to receive email alerts when someone comments on your work or replies to a comment you have made. Comment notifications will still be delivered to your Archive inbox unless you have chosen to disable them.
    +
    Turn off messages to your inbox about comments
    +
    Enable this option if you would prefer not to receive notifications in your Archive inbox when someone comments on your work or replies to a comment you have made. Comment notifications will still be emailed to you unless you have chosen to disable them.
    +
    Turn off copies of your own comments
    +
    Enable this option if you prefer not to receive emails for your own comments (for example, when you reply to comments on your own works).
    +
    Turn off emails about kudos
    +
    Enable this option if you would prefer not to receive email notifications when someone leaves kudos on your work.
    +
    Do not allow guests to reply to my comments on news posts or other users' works
    +
    Enable this option if you would prefer not to receive replies from users who aren't logged in to an Archive account on comments you leave on news posts or other creators' works. This setting doesn't apply to comments on your own works; for more on controlling who can interact with you on your works check out the Posting and Editing FAQ.
    +
    diff --git a/public/help/comments-moderated.html b/public/help/comments-moderated.html new file mode 100644 index 0000000..63a3e5e --- /dev/null +++ b/public/help/comments-moderated.html @@ -0,0 +1,2 @@ +

    Comments Moderated

    +

    With this feature enabled, you will have to approve all comments before they appear publicly on your work.

    diff --git a/public/help/csv-download.html b/public/help/csv-download.html new file mode 100644 index 0000000..3cefb55 --- /dev/null +++ b/public/help/csv-download.html @@ -0,0 +1 @@ +To open the file properly, you might need to tell your program that the file is TAB-separated. In Open Office and Neo Office, for example, choose the TAB separator in the File Open Wizard. In Google Docs, use "Import" instead of "Open" to be able to set the separator to TAB. diff --git a/public/help/display-preferences.html b/public/help/display-preferences.html new file mode 100644 index 0000000..621e791 --- /dev/null +++ b/public/help/display-preferences.html @@ -0,0 +1,24 @@ +

    Display Preferences

    + +
    +
    Show me adult content without checking
    +
    + When this option is enabled, you won't be asked to confirm that you wish to access works rated Mature, Explicit or Not Rated before the work is displayed. +
    +
    Show the whole work by default
    +
    + When this option is enabled, works with multiple chapters will display as a single page. +
    +
    Hide warnings (you can still choose to show them)
    +
    + When this option is enabled, Archive warning tags on works will be hidden by default. You can click 'Show Warnings' to display the warnings for any individual work. You must have JavaScript enabled to use this option. +
    +
    Hide additional tags (you can still choose to show them)
    +
    + When this option is enabled, additional tags on works will be hidden by default. You can click 'Show Additional Tags' to display the tags for any individual work. You must have JavaScript enabled to use this option. +
    +
    Hide other people's work skins
    +
    + When this option is enabled, the custom skin another user has specified for their work will not be displayed. Instead your normal site skin will be applied. +
    +
    diff --git a/public/help/encoding-help.html b/public/help/encoding-help.html new file mode 100644 index 0000000..a7b67fb --- /dev/null +++ b/public/help/encoding-help.html @@ -0,0 +1,6 @@ +

    Encoding Help

    + +

    If you find that the importer strips special characters (such as umlauts or curly quotes) from your work, or fails to import whole chunks of text, there might be a problem with the automatic detection of your work's encoding. UTF-8 is a common encoding, but there are others that you might have to manually put in to help the importer handle your work.

    + +

    If you're unsure what encoding your text is using, try ISO-8859-1 (often called Latin-1) or Windows-1252 (sometimes wrongly called ANSI), both of which + are very common in the output from Windows programs.

    \ No newline at end of file diff --git a/public/help/fandom-help.html b/public/help/fandom-help.html new file mode 100644 index 0000000..fe0df50 --- /dev/null +++ b/public/help/fandom-help.html @@ -0,0 +1,5 @@ +

    Fandom Tags

    + +

    The name(s) of the fandom(s) to which your work belongs. Full names, rather than abbreviations, are preferred. You may list multiple fandoms, separated by commas (for example, if your work is a crossover).

    + +

    For more information regarding Tags, including how to add tags not currently on the Archive, please read our Tags FAQ.

    diff --git a/public/help/fr/choosing-series.html b/public/help/fr/choosing-series.html new file mode 100644 index 0000000..2ec32db --- /dev/null +++ b/public/help/fr/choosing-series.html @@ -0,0 +1,5 @@ +

    Choix de Series

    + +

    + French version of the help goes here! +

    diff --git a/public/help/html-help.html b/public/help/html-help.html new file mode 100644 index 0000000..b11c5e6 --- /dev/null +++ b/public/help/html-help.html @@ -0,0 +1,87 @@ +

    HTML on the Archive

    + +

    Allowed HTML

    +

    + a, abbr, acronym, address, [align], [alt], [axis], b, big, blockquote, br, caption, center, cite, [class], code, + col, colgroup, dd, del, details, dfn, div, dl, dt, em, figcaption, figure, h1, h2, h3, h4, h5, h6, [height], hr, [href], i, img, + ins, kbd, li, [name], ol, p, pre, q, rp, rt, ruby, s, samp, small, span, [src], strike, strong, sub, summary, sup, table, tbody, td, + tfoot, th, thead, [title], tr, tt, u, ul, var, [width] + +

    + +

    How Do We Format Your HTML?

    + +

    + When you enter HTML into the archive, we do some cleanup on it to make sure that it is safe + (so spammers and hackers cannot upload badness) and try and do some basic formatting both for + your convenience and for accessibility reasons. Here are the formatting steps we take: +

    + +
      +
    • If you leave a blank line between two paragraphs, we will put paragraph tags in for you around the two paragraphs.
    • +
    • If you have a single carriage return between two lines of text, we will put in a break tag for you.
    • +
    • If you have two break tags in a row (<br /><br />) with nothing between them, we will turn that into paragraph tags.
    • +
    • If you have two blank lines in a row between paragraphs, we will add extra whitespace in for you (with <p>&nbsp;</p>)
    • +
    • If you have mis-nested tags like this: <em><strong>text!</em></strong> we will fix the mis-nesting + (so it would become <em><strong>text!</strong></em>).
    • +
    • If you have forgotten to close a formatting tag, it will be closed at the end of the paragraph for you.
    • +
    • If you open a formatting tag in one paragraph and close it several paragraphs later, we will reopen/close it within each paragraph.
    • +
    • If you have put in some custom HTML (eg a list of items inside a <ul>) and you don't want break tags or paragraphs inserted, + just put it all on one line (sorry, this is the inconvenient tradeoff for automatically doing the paragraph/break tags).
    • +
    • If one bit of your text shows up larger than the rest, probably our formatter couldn't figure out to put paragraph tags around it. + You can fix this by manually putting in the paragraph tags around that one bit.
    • +
    + +

    + When you edit your HTML after you first put it in, you will see the results of our formatting work, so you can + correct any mistakes our formatter might have made. Please note that the best way to get good results is to put + in good HTML -- that is how you can be sure your story will look right across various browsers, + screen readers, mobile devices, and downloads. +

    + +

    + Good HTML means HTML that labels what the text is supposed to be -- so if you have + paragraphs, they should be inside paragraph tags, not just separated with break tags. If you have emphasized + text, it should be inside em tags. If you have a list of items, each item should be inside list tags. If you + don't have a list of items, you shouldn't have list tags. :) + (We are putting together a set of more detailed helpful references about this, but this is the basic idea.) +

    + +

    + If you find yourself putting in HTML that doesn't mean the right thing, in order to get a particular visual effect, + please resist! + The work skin feature allows you to apply custom CSS to works, and that lets you make them look + just about any way you want them to (and is easier if you are starting from good HTML). +

    + +

    Some specific recommendations:

    + +
    +
    For headings, use heading tags: h1, h2, h3, h4, h5, h6
    +
    +
      +
    • <h1>Title<h1>

    • +
    • <h2>Subtitle<h2>

    • +
    • <h3>Chapter title<h3>

    • +
    • <h4>Scene title<h4>

    • +
    • <h5>Subtitle<h5>
    • +
    • <h6>Footnote title<h6>
    • +
    +
    +
    For emphasis, use emphasis tags em, strong
    +
    +
      +
    • <em>Rodney</em> McKay

    • +
    • I will <strong>never</strong> understand you!

    • +
    +
    +
    To quote poetry, phrases or titles use quote tags: blockquote, q, cite
    +
    +
      +
    • <blockquote>

      To quote a block of text

      </blockquote>
    • +
    • Use q to <q>To quote a phrase</q>

    • +
    • Try cite to cite <cite>a phrase or title</cite>

    • +
    +
    +
    + diff --git a/public/help/icon-alt-text.html b/public/help/icon-alt-text.html new file mode 100644 index 0000000..cc2a0e4 --- /dev/null +++ b/public/help/icon-alt-text.html @@ -0,0 +1,10 @@ +

    Icon Alt Text

    + +

    + The purpose of alt text is to explain the meaning of an image if the image isn't showing. This is used by people browsing with images turned off, or visually impaired people using screenreader technology. + Please do not use alt text for attribution! +

    + +

    + For example, the AO3 logo has the following alt text: "Archive of Our Own". +

    diff --git a/public/help/languages-help.html b/public/help/languages-help.html new file mode 100644 index 0000000..26c3a29 --- /dev/null +++ b/public/help/languages-help.html @@ -0,0 +1,4 @@ +

    Languages

    + +

    Your language is not on the list? Please let us know via the Support form and we'll be happy to add it! +(Not to worry, you can post your work now and still change the language later.) You are not required to select a language for an external work.

    diff --git a/public/help/locale-preferences.html b/public/help/locale-preferences.html new file mode 100644 index 0000000..64d881c --- /dev/null +++ b/public/help/locale-preferences.html @@ -0,0 +1,8 @@ +

    Locale Preferences

    + +
    +
    Set preferred locale
    +
    +This preference allows you to select your preferred language for email messages that the Archive sends to you. The templates for these messages are currently being updated and translated by our volunteers. This is a work in progress; not all messages will be available in languages other than English at this time. If the template for that email has not yet been translated to your language, it will be sent in English. +
    +
    diff --git a/public/help/misc-preferences.html b/public/help/misc-preferences.html new file mode 100644 index 0000000..48b89cc --- /dev/null +++ b/public/help/misc-preferences.html @@ -0,0 +1,13 @@ +

    Miscellaneous Preferences

    + +
    + +
    Turn on History
    +
    When enabled, History keeps a log of every work you access on the Archive while logged in. You can delete individual works from your History or clear the whole History. If you enable this option and subsequently disable it, works accessed while the option was enabled will still be saved (but you will have to enable History again to see them).
    + +
    Turn the new user help banner back on
    +
    Enabling this option will turn on the banner offering information and hints on getting started with the Archive!
    + +
    Turn off the banner showing on every page
    +
    Occasionally, Archive staff may notify users of important events or site changes by displaying a banner across all pages on the site. If you want to dismiss the banner while you're logged in, enable this option. Please note that this only hides the banner which was displaying at the time you enabled this option. If the banner is changed, the new banner will be displayed until you enable this option again.
    +
    diff --git a/public/help/parent-works-help.html b/public/help/parent-works-help.html new file mode 100644 index 0000000..bd65f95 --- /dev/null +++ b/public/help/parent-works-help.html @@ -0,0 +1,10 @@ +

    Parent Works Help

    + +

    +If you are creating a new work, you can add only one inspiration now. +If you want to add more, save your work first, then click "Edit" on your posted work and add the new inspiration like the previous one. +

    + +

    +All the works you have added as inspirations will be listed below this form under the label "Current parent works". +

    \ No newline at end of file diff --git a/public/help/people-search-all-fields.html b/public/help/people-search-all-fields.html new file mode 100644 index 0000000..04a3b3a --- /dev/null +++ b/public/help/people-search-all-fields.html @@ -0,0 +1,20 @@ +

    People Search: Search All Fields

    + +

    + Enter text in "Search all fields" to find people with usernames, pseuds, or pseud descriptions containing your search terms. +

    + +

    + The characters ":" and "@" have special meanings. Leave them out of your search or you will get unexpected results. +

    + +
    +
    *: any characters
    +
    User* will find User and Users and Username.
    +
    space: a space acts like AND
    +
    A. User will find A. User and A. Test User but not User.
    +
    |: OR (not exclusive)
    +
    A. | User will find A., A. User, and User.
    +
    ": words in exact sequence
    +
    "A. User" will find "A. User" but not A. Test User.
    +
    diff --git a/public/help/privacy-preferences.html b/public/help/privacy-preferences.html new file mode 100644 index 0000000..0848ca4 --- /dev/null +++ b/public/help/privacy-preferences.html @@ -0,0 +1,41 @@ +

    Privacy Preferences

    + +
    +
    Hide my work from search engines when possible.
    +
    + Enabling this option will tell search engines not to index your user page, your works, or your series. Please note that not all search engines respect this setting. Pages which list your works or series - for example, the main works page - may also still be indexed. If you wish to avoid your works or series being indexed under any circumstances, we recommend that you restrict them to Archive users only. +
    +
    Hide the share buttons on my work.
    +
    +

    + This preference allows you to disable the one-click share buttons which allow others to recommend your work + on external sites like Twitter and Tumblr. +

    +

    + Please note that once you have posted a work online, it is always possible for a reader to copy and paste a link to your work + anywhere they wish -- if you want to restrict access to your work here, your best bet is to lock the work to registered users of + the Archive. +

    +
    +
    Allow others to invite me to be a co-creator.
    +
    +

    + Enabling this option will allow other AO3 users to invite you to be listed + as a co-creator on a work, chapter, or series. You won't be listed as a + co-creator anywhere on the site until you have accepted the request. If + you have this option enabled, you can find any requests you receive in + your Dashboard under "Co-Creator Requests". You will also receive an email + notifying you of the request. +

    +

    + Leaving this option disabled will prevent other users from inviting you to + be listed as a co-creator on a new work, chapter or series, and you won't + receive any notifications. +

    +

    + Changing this setting won't affect any existing shared works. +

    +
    +
    + +

    For more on your preferences and what these options mean, check out our Preferences FAQ.

    diff --git a/public/help/prompt-restriction-character-and-relationship.html b/public/help/prompt-restriction-character-and-relationship.html new file mode 100644 index 0000000..8dfa75d --- /dev/null +++ b/public/help/prompt-restriction-character-and-relationship.html @@ -0,0 +1,48 @@ +
    Tag Options: Characters and Relationships
    + +

    + Very often, you will want your users to only be able to choose from the characters or relationships that + belong to the fandom that they've chosen to sign up for. Unfortunately the archive can't figure out on its own that, + for instance, the character "Zaphod Beeblebrox" is associated with the fandom "Hitchhiker's Guide To The Galaxy". + These options are intended to help you get the results you want. +

    + +
    +
    Option 1: Nothing!
    +
    + By default, your participants will get an autocomplete that lets the user choose from the + canonical versions of character and relationship tags. + This is a quick and dirty way to ensure most of your participants will sign up for the same versions of the characters + or relationships and be matchable, even if you have so many characters/relationships that you don't want to create + a tag set. Users will have to know at least enough of the tag they want to type in to get autocomplete results, + but that is usually doable. +
    + +
    Option 2: Tag Set
    +
    + If you put character or relationship tags in your tag set, users will be given a set of checkboxes with each tag as a choice. + This set of checkboxes will NOT change based on the fandom. +
    + +
    Option 3: Fandom Only
    +
    + If you check this option, participants will only be allowed to sign up for a character or relationship that + matches the fandom they have chosen in the request. The list of characters/relationships will be a combination + of the tags that have been officially wrangled into that fandom, as well as any tag associations you have set up + for that fandom in your tag set(s) if you are using any. +
    + +
    Option 4: By Tag Set Fandom Only
    +
    + If you don't want all the wrangled characters available, then check this option, and ONLY the characters you've + associated in your tag set will be allowed. +
    + +
    + +

    + Hopefully with these options, you'll be able to find a solution for your challenge! If you run into difficulties or are confused by + the options, please contact Archive Support for help (but we are often backed up so please do allow plenty of lead time before you're + about to run your challenge if you're going to need extra help). +

    + diff --git a/public/help/prompt-restriction-tag-set.html b/public/help/prompt-restriction-tag-set.html new file mode 100644 index 0000000..35e605c --- /dev/null +++ b/public/help/prompt-restriction-tag-set.html @@ -0,0 +1,26 @@ +
    Tag Options
    + +

    + If you want to limit the tags that users can choose from when signing up, you can either use an existing + tag set or make one of your own, or a combination. Once you have the tag sets you want to use ready, you + can add them to your challenge here. +

    + +

    + Note: if you are running a challenge that is specifically for one given fandom or relationship or character, + you do NOT need to use tag sets -- just set that kind of tag to 0 tags required/allowed above. + Tag options should only be used where you want users to have a choice. +

    + +

    + The same group of tag sets is used for both requests and offers, since otherwise offers and + requests can't match against each other. (People can choose different tags in their requests and offers, but they + have to choose from the same set.) +

    + +

    + You do NOT have to specify tags for every kind of tag you have allowed. For instance, you can put in five specific fandoms, + and also allow one character tag without putting in tag options. + Then the person signing up will have to choose one of the five fandoms, but can just + type in a character tag. +

    diff --git a/public/help/pseud-icon-comment.html b/public/help/pseud-icon-comment.html new file mode 100644 index 0000000..d9bb494 --- /dev/null +++ b/public/help/pseud-icon-comment.html @@ -0,0 +1 @@ +

    You can put anything extra you have about your icon here, like credit for the maker of the icon.

    \ No newline at end of file diff --git a/public/help/rating-help.html b/public/help/rating-help.html new file mode 100644 index 0000000..8744cdc --- /dev/null +++ b/public/help/rating-help.html @@ -0,0 +1,26 @@ +

    Rating Tags

    + +

    (For more information, see the Ratings and Warnings section of the AO3 Terms of Service.)

    + +
    +
    Not Rated (Adult!)
    +
    + For searching, screening, and other Archive functions, this may get treated the same way as explicit-rated content. In reality, it could be anything from porn to completely family-friendly stuff. Choose this rating if you prefer not to rate your content (because you don't like ratings, because you're trying to avoid spoilers, etc.). +
    +
    General Audiences
    +
    + This content is suitable for anyone. +
    +
    Teen And Up Audiences
    +
    + The content may be inappropriate for audiences under 13. +
    +
    Mature (Adult!)
    +
    + This is for content with adult themes (sex, violence, etc.) that isn't as graphic as explicit-rated content. +
    +
    Explicit (Adult!)
    +
    + This is for porn, graphic violence, etc. +
    +
    diff --git a/public/help/recipients.html b/public/help/recipients.html new file mode 100644 index 0000000..e908bb7 --- /dev/null +++ b/public/help/recipients.html @@ -0,0 +1,10 @@ +

    Recipients

    + +

    + Enter recipient names as a comma-separated list! +

    + +

    + If your story is a gift for someone else or in their honor, you can enter their name here, and it will be displayed on the work, right under the byline. + Recipients do not need to be registered users of the archive, although the autocomplete will offer you matching pseuds if there are any. We will notify registered users if they are selected as recipients of a work. +

    diff --git a/public/help/registered-users.html b/public/help/registered-users.html new file mode 100644 index 0000000..18db8ec --- /dev/null +++ b/public/help/registered-users.html @@ -0,0 +1,6 @@ +

    Registered Users

    + +

    +A registered user is a person with an Archive account. If you check this box +your story can only be viewed by a person who is logged in. +

    \ No newline at end of file diff --git a/public/help/relationships-help.html b/public/help/relationships-help.html new file mode 100644 index 0000000..5b1b3a3 --- /dev/null +++ b/public/help/relationships-help.html @@ -0,0 +1,11 @@ +

    Relationships Tags

    + +

    (For more information, see the Tags on the Archive FAQ.)

    + +

    + The relationships in your work. Full names are preferred (for example, "Mickey Mouse/Minnie Mouse" or + "Rodney McKay & John Sheppard") when possible. You may list more than one relationship, separated by commas. + Please note that all user-created tags must be 150 characters or less; if your work contains a large poly + relationship or multiple characters with long names, you may want to consider shortening these names to just first + names or surnames with initials to avoid going over the character limit while keeping the names recognizable. +

    diff --git a/public/help/required-field.html b/public/help/required-field.html new file mode 100644 index 0000000..784108e --- /dev/null +++ b/public/help/required-field.html @@ -0,0 +1,3 @@ +

    Required Field

    + +

    Please complete all fields marked with an asterisk (*).

    \ No newline at end of file diff --git a/public/help/rte-help.html b/public/help/rte-help.html new file mode 100644 index 0000000..4dae3c6 --- /dev/null +++ b/public/help/rte-help.html @@ -0,0 +1,48 @@ +

    Rich Text

    + +

    The exact behavior of the Rich Text Editor (RTE) depends on your device, browser, and operating system as well as the source you're pasting from. However, starting with a well-formatted document will help you get the most out of the RTE. Here are some general tips to ensure that as much of your formatting as possible will be retained:

    + +
      +
    • Press Enter once between paragraphs. Pressing Enter twice will insert a blank paragraph, creating additional, and likely unwanted, space between paragraphs when you paste into the RTE. The Archive uses top and bottom margins to create the appearance of a blank line between paragraphs; you can use the paragraph formatting options in your text editor to create a similar effect without adding extra <p> tags.

    • +
    • Use preset styles for headings, block quotations, code, and so on. The Styles option generally found in a text editor's Format menu will often translate into HTML tags when pasting into the RTE. Simply changing the font size, font name, or text indent to create a visual approximation of a heading or block quote will always fail.

    • +
    + +

    Pasting from Specific Text Editors

    + +

    Google Drive

    + +

    Google Drive uses inline CSS to change the alignment of text and to produce bold, italic, underline, and strikethrough formatting. Unfortunately, we cannot allow inline styles on the Archive, so only pure HTML formatting such as headings, lists, links, and tables is retained.

    + +

    In some browsers, the formatting may appear to be preserved upon pasting into the RTE, but it will be stripped by our HTML sanitizer upon previewing or posting your work.

    + +

    Scrivener

    + +

    Scrivener users will typically get better results by pasting into the HTML editor and then switching to the RTE to make changes. To copy HTML from Scrivener, do the following:

    + +
      +
    1. Go to the Edit menu
    2. +
    3. Choose Copy Special
    4. +
    5. Select either "Copy as HTML" or "Copy as HTML (Basic, using <p> and <span>)"
    6. +
    + +

    Pasting Specific Types of Formatting

    + +

    Underline and Strikethrough

    + +

    Underline and strikethrough are often produced with CSS. Because the Archive does not allow the use of inline CSS, these text styles are frequently lost when pasting.

    + +

    Pasting from web pages that use <u>, <del>, <strike>, or <s> tags will work.

    + +

    Alignment

    + +

    Text alignment is now generally achieved with CSS, and because the Archive does not allow inline CSS, alignment will often be lost when pasting.

    + +

    Pasting from sources that use the align attribute and <center> element will keep the formatting intact, but please note that the alignment buttons in the RTE cannot modify center alignment created with the <center> tag.

    + +

    Headings

    + +

    Text editors use many different styles for their heading presets. For example, selecting Heading 4 in OpenOffice produces italicized sans-serif text. Even when successfully pasting a heading into the RTE, this visual formatting is not preserved -- only the <h4> tag is. This is not a bug. HTML is designed to tell the browser what text means (e.g. "This is a heading") and not how it should be displayed (e.g. "This should be in Arial"). If you wish to modify the style of a heading or any other part of your work, please use a Work Skin.

    + +

    Indented Text

    + +

    Indented text is a purely visual effect with no HTML equivalent and will not be preserved. Please use a Work Skin to indent text.

    \ No newline at end of file diff --git a/public/help/skins-approval.html b/public/help/skins-approval.html new file mode 100644 index 0000000..32b6f07 --- /dev/null +++ b/public/help/skins-approval.html @@ -0,0 +1,11 @@ +

    Public Skins

    + +

    + AO3 is no longer adding new + user-created skins to the list of public skins, so you can't apply to make + your skin public at this time. This checkbox can only be used by site admins + to add new public site skins to our list. However, you can still make use of + the user-created skins in Public Site + Skins and Public Work Skins and + you can still create skins for your own personal use. +

    diff --git a/public/help/skins-basics.html b/public/help/skins-basics.html new file mode 100644 index 0000000..e52495d --- /dev/null +++ b/public/help/skins-basics.html @@ -0,0 +1,14 @@ +

    + A site skin lets you customize your browsing experience when you are logged in to your account. + Don't like the Archive fonts? You can change them! Don't like the red header? Swap it out for blue! + When you make a site skin, keep in mind that you are only changing the Archive for yourself -- other Archive users + will see the Archive in whatever skin they are using. In other words, site skins are useful for creating your own ideal + browsing experience, not for changing the way a work appears for others. +

    + +

    + A work skin lets you change the way one or more of your works appears to others. Work skins will only affect + the body of the work -- that is, you can't change what the Archive navigation or background look like for someone else. + What you can do, however, is create your own classes. For instance, you can change the color of some of your text, + or indent some paragraphs in a particular way, and so on. +

    diff --git a/public/help/skins-conditions.html b/public/help/skins-conditions.html new file mode 100644 index 0000000..0796f66 --- /dev/null +++ b/public/help/skins-conditions.html @@ -0,0 +1,92 @@ +

    Skin Conditions

    + +

    + If you want a certain chunk of CSS loaded only in particular situations, you can create a skin with a specific set of conditions. + We'll load that skin only when needed. The conditions we have available are: +

    + +
      +
    • +

      What It Does

      +
      +
      add on to the archive style
      +
      What you want 95% of the time. Will be loaded after the official archive style.
      + +
      replace the archive style
      +
      Useful for complex skins where you don't want most of the default style underlying your work. (You can use the parts you want to keep as parents.)
      +
      +
    • + +
    • +

      Parent Only

      +

      + This option is mainly to help keep the skins listing tidy. + + If you select this, neither you (nor anyone else) will be able to use this skin directly: it will only be used as a parent. + The skin (even if made public) will not be listed in the main skins listing, only in the description of skins where it is + used as a parent. This makes it much easier to make components available for people to use without cluttering up the + skins listing with skins that wouldn't actually work right if someone tried to use them alone. :) +

      +
    • + +
    • +

      Media

      +

      + You can choose more than one media. + Media stylesheets will only be loaded if whatever device you're browsing on supports that particular media type. + For instance, not all screenreaders load "speech" stylesheets. If your device doesn't load your skin, + try using "all" or "screen" instead (or drop us a Support request if you need help). +

      +
      +
      all
      +
      What you want 95% of the time. Loaded for all devices. (Some very old browsers don't understand "all" and require "screen".)
      + +
      screen
      +
      Loaded for computer screens. (And usually for any device that doesn't support other media categories.)
      + +
      handheld
      +
      Will be loaded only for mobile devices and/or small screens.
      + +
      speech
      +
      Will be loaded only for screenreaders.
      + +
      print
      +
      Will be loaded only when the page is printed.
      + +
      braille, embossed, projection, tty, tv
      +
      See the W3C media specs for details.
      + +
      only screen and (max-width: 450px)
      +
      Loaded for iPhone (it doesn't load handheld stylesheets otherwise)
      +
      +
    • + +
    • +

      IE Only

      +

      + If you leave this blank, your skin will be loaded for all browsers. If you choose one of these options, + your skin will be loaded only for Internet Explorer browsers. This lets you add IE-specific overrides. +

      +
      +
      IE
      +
      Will be loaded for any Internet Explorer browser.
      + +
      IE5, IE6, IE7, IE8, IE9
      +
      Will be loaded only for this particular version of the Internet Explorer browser.
      + +
      IE8_or_lower
      +
      Will be loaded for IE8 and below
      +
      +
    • +
    + +

    + +

    Interaction With Parents

    +

    + If you also use skin parents, you can give conditions to particular parents, and then make a skin that acts differently + for different browsers. + For instance, if you have one parent skin that holds most of your CSS, one that is IE-only, one for media "handheld" and one that uses the media "print", + then your final skin will load each parent as appropriate based on the final user's browser! +

    + diff --git a/public/help/skins-creating.html b/public/help/skins-creating.html new file mode 100644 index 0000000..fe670c9 --- /dev/null +++ b/public/help/skins-creating.html @@ -0,0 +1,204 @@ +
    +
    You can create new skins for the Archive using our wizard, or by writing your own CSS (cascading style sheets) code
    +
    +

    + Note that for security reasons, you can only use a limited set of CSS code: all other declarations and comments will be removed! +

    +
    + +
    We allow the following properties including all their variations (and shorthand values)
    +
    +

    + + background, border, column, cue, flex, font, layer-background, + layout-grid, list-style, margin, marker, outline, overflow, padding, + page-break, pause, scrollbar, text, transform, transition + +

    +
    +
    We also allow the following specific 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 + +

    +
    + +
    Look at other public skins for examples
    +
    +

    + All approved public skins are visible and you can read their code and copy them to edit for + your own use. +

    +
    + +
    Use only one declaration per property per ruleset
    +
    +

    + The CSS parser we use retains only one declaration for each property, meaning that rulesets like
    +

    +        .my-class {
    +          background: -moz-linear-gradient(top, #1e5799 0%, #2989d8 50%, #207cca 51%, #7db9e8 100%);
    +          background: -o-linear-gradient(top, #1e5799 0%,#2989d8 50%,#207cca 51%,#7db9e8 100%);
    +          background: -webkit-linear-gradient(top, #1e5799 0%,#2989d8 50%,#207cca 51%,#7db9e8 100%);
    +        }
    +      
    + will have all but the last background declaration removed (so your gradient would only show up in WebKit browsers). To avoid losing declarations with repeated properties, split each one into its own ruleset, like so: +
    +        .my-class { background: -moz-linear-gradient(top, #1e5799 0%, #2989d8 50%, #207cca 51%, #7db9e8 100%); }
    +        .my-class { background: -o-linear-gradient(top, #1e5799 0%,#2989d8 50%,#207cca 51%,#7db9e8 100%); }
    +        .my-class { background: -webkit-linear-gradient(top, #1e5799 0%,#2989d8 50%,#207cca 51%,#7db9e8 100%); }
    +      
    +

    +
    + +
    Font and Font Family
    +
    +

    + Unfortunately, you cannot use the font shorthand in your CSS. All font properties have to be specified separately, e.g., + font-size: 1.1em; font-weight: bold; font-family: Cambria, Constantia, Palatino, Georgia, serif; +

    +

    + In the font-family property, we allow you to specify any font with an alphanumeric name. + You can (but don't have to) specify the name with single or double quotes + around it, just make sure the quotes match. (e.g., 'Gill Sans' and "Gill Sans" are both fine; 'Gill Sans" won't work.) + Keep in mind that a font has to be installed on the user's operating system to work. It's a good idea when specifying fonts to use + fallbacks in case your first-choice font isn't available. + See a set of web-safe fonts with fallbacks. +

    +

    + We cannot allow the @font-face attribute. Sorry! If you have an uncommon font that you want to + use in a skin you would like to share, we suggest adding a comment in the skin's "Description" field with a pointer to a place for users + to download the font themselves, and using web-safe fonts as fallbacks. +

    +
    + +
    Custom Properties (Variables)
    +
    +

    + Custom property names can contain any combination of lowercase letters (a-z) in the English alphabet, numerals zero to nine (0-9), dashes (-), and underscores (_). They can't contain single (') or double (") quotation marks, or URLs. Any uppercase letters (A-Z) will be converted to lowercase. +

    +

    + All properties except font-family and content accept the var() function as a value. We don't allow fallbacks. +

    +

    + Custom properties and the var() function are only available for site skins, and won't work on work skins. +

    +
    + +
    URLs
    +
    +

    + We allow external image URLs (specified as url('https://example.com/my_awesome_image.jpg')) in JPG, GIF, and PNG formats. + Please note, however, that skins using external images will not be approved for public use. +

    +
    + +
    Keywords
    +
    +

    + We allow all standard CSS keyword values (e.g., absolute, bottom, center, underline, etc.). +

    +
    + +
    Numeric Values
    +
    +

    + You can specify numeric values up to two decimal places, either as percentages or in various units:
    + cm, em, ex, in, mm, pc, pt, px +

    +

    + PS: we highly encourage learning about and using em, which lets you set things relative to the viewer's current font size! + It will make your layouts much more flexible and responsive to different browser/font settings. +

    +
    + +
    Colors
    +
    +

    + You can specify colors using hex values (eg, #000000 is black in hex) or with RGB or RGBA values (e.g., rgb(0,0,0) and rgba(0,0,0,0) both give you black). + This may be safer since not all browsers will necessarily support all color names. However, color names are more + readable and easier to remember, so we also allow color names. (We suggest you stick to + the set of commonly-supported color names.) +

    +
    + +
    Scale
    +
    +

    + You can specify scale (for the transform property) as scale(numeric value) where the numeric value can be + specified up to two decimal places. +

    +
    + +
    Comments
    +
    +

    Comments are stripped from CSS.

    +
    + +
    If you are new to CSS, here are the basics:
    +
    +

    + A line of CSS code looks pretty much like this: selector {property: value;} +

    +

    + The selector is either the name of an HTML tag (like body or h1), + or it can be an id or class that has been set on a tag. + The property is what you want to change in the contents of that tag (for instance the font size), + and the value is what you want to set it to. +

    +

    + Examples: +

      +
    • Inside the "body" tag, set the font size slightly larger than the baseline: body {font-size: 1.1em;}
    • +
    • Inside any tags with the id "header", set the background color to purple: #header {background-color: purple}
    • +
    • Inside any tags with the class "meta", make the text blink: (we do not advise this) .meta {font-style: blink}
    • +
    +

    + +

    + Some useful CSS tutorials for more information: +

    +

    +
    + +
    diff --git a/public/help/skins-parents.html b/public/help/skins-parents.html new file mode 100644 index 0000000..b5c495b --- /dev/null +++ b/public/help/skins-parents.html @@ -0,0 +1,25 @@ +

    Skin Parents

    + +

    + You can combine and layer multiple site skins by making one the parent of + another. Parent skins are loaded in order so that the style of all the skins + can be displayed in that order. For more information on skins, please refer to + the Skins and Interface FAQ. +

    + +

    + By default, skins will be loaded after the Archive default style. If you don't + want this, you can specify in the "What it does" menu that you want your skin + to replace rather than add to the Archive default style. +

    + +

    Load Archive Skin Components

    + +

    + If you create a replacement skin, you might want to load up all the skins that + make up the current default Archive site as parents. This option is only + available when you select "replace archive skin entirely" from the "What it + does" menu. After that, you can edit your skin and delete just the ones you + don't want. This will be easier if you are using nearly all of them, since + there are a lot! +

    diff --git a/public/help/skins-wizard-accent-color.html b/public/help/skins-wizard-accent-color.html new file mode 100644 index 0000000..e1bd449 --- /dev/null +++ b/public/help/skins-wizard-accent-color.html @@ -0,0 +1,2 @@ +

    The default is: #ddd

    +

    Replace the gray used in numerous places throughout the Archive, including form backgrounds, the dropdown menus in the main navigation, and the "Fandoms" and "Recent works" sections of dashboard pages.

    diff --git a/public/help/skins-wizard-font-size.html b/public/help/skins-wizard-font-size.html new file mode 100644 index 0000000..895c3ed --- /dev/null +++ b/public/help/skins-wizard-font-size.html @@ -0,0 +1,2 @@ +

    The default is: 100%

    +

    Font sizes on the Archive are based on a percentage of your browser's default font size. Use a number smaller than 100 to make the Archive's text smaller, or use a number larger than 100 to make the text larger. Entering 100 will keep the Archive's default font sizes.

    diff --git a/public/help/skins-wizard-font.html b/public/help/skins-wizard-font.html new file mode 100644 index 0000000..03290e3 --- /dev/null +++ b/public/help/skins-wizard-font.html @@ -0,0 +1,3 @@ +

    The default is: 'Lucida Grande', 'Lucida Sans Unicode', Verdana, Helvetica, sans-serif, 'GNU Unifont'

    +

    Put any font name in here, and if it's installed on your computer, it'll work for you. If you use several different devices, specify some fall-back fonts, with commas in between the names, in case one of your devices doesn't have the first font.

    +

    You can use either single or double quotation marks around fonts with multi-word names, e.g. "Lucida Grande" or 'Lucide Sans Unicode'.

    diff --git a/public/help/skins-wizard-vertical-gap.html b/public/help/skins-wizard-vertical-gap.html new file mode 100644 index 0000000..a496fb8 --- /dev/null +++ b/public/help/skins-wizard-vertical-gap.html @@ -0,0 +1,3 @@ +

    The default is: 1.1286em

    +

    Put any number in here and it will be applied as a multiple of the work's font size. A larger number will give you bigger spacing between paragraphs.

    +

    For example, most users see works at a font size of 15 pixels. Entering 2 will create a vertical gap of 30 pixels, and entering 0.5 will create a gap of about 8 pixels.

    diff --git a/public/help/symbols-key.html b/public/help/symbols-key.html new file mode 100644 index 0000000..5ff7998 --- /dev/null +++ b/public/help/symbols-key.html @@ -0,0 +1,64 @@ +

    Symbols we use on the Archive

    +
    +
    First Square
    +

    Content rating

    +
    +
    G
    +
    General Audiences
    +
    T
    +
    Teen And Up Audiences
    +
    M
    +
    Mature
    +
    E
    +
    Explicit: only suitable for adults
    +
    blank square
    +
    The work was not given any rating
    +
    +
    +
    Second Square
    +

    Relationships, pairings, orientations

    +
    +
    F/F
    +
    F/F: female/female relationships
    +
    F/M
    +
    F/M: female/male relationships
    +
    Gen
    +
    Gen: no romantic or sexual relationships, or relationships which are not the main focus of the work
    +
    M/M
    +
    M/M: male/male relationships
    +
    Multi
    +
    Multi: more than one kind of relationship, or a relationship with multiple partners
    +
    Other
    +
    Other relationships
    +
    blank square
    +
    The work was not put in any categories
    +
    +
    +
    Third Square
    +

    Content warnings

    +
    +
    questioned exclamation mark
    +
    The author chose not to warn for content, or Archive Warnings could apply, but the author has chosen not to specify them. +
    +
    exclamation mark
    +
    At least one of these warnings applies: graphic depictions of violence, major character death, rape/non-con, underage sex. The specific warnings are shown in the Archive Warnings tags. +
    +
    blank square
    +
    The work was not marked with any Archive Warnings. Please note that an author may have included other information about + their work in the Additional Tags (Genre, Warnings, Other Information) section.
    +
    globe
    +
    This is an external work; please consult the work itself for warnings.
    +
    +
    +
    Fourth Square
    +

    Is the work finished or the prompt fulfilled?

    +
    +
    stop sign
    +
    This is a work in progress or is incomplete/unfulfilled.
    +
    ticky
    +
    This work is completed!/This prompt is filled!
    +
    blank square
    +
    This work's status is unknown.
    +
    +
    +
    diff --git a/public/help/tag-search-results-help.html b/public/help/tag-search-results-help.html new file mode 100644 index 0000000..62d9611 --- /dev/null +++ b/public/help/tag-search-results-help.html @@ -0,0 +1,5 @@ +

    Tag Search Results

    + +

    Highlighted tags are canonical.

    +

    Very new tags will be at the top of the list. Otherwise the list is sorted alphabetically by type and then name.

    +

    If there are too many tags, try refining your search, rather than paging through the results.

    diff --git a/public/help/tag-search-text-help.html b/public/help/tag-search-text-help.html new file mode 100644 index 0000000..cbaf324 --- /dev/null +++ b/public/help/tag-search-text-help.html @@ -0,0 +1,43 @@ +

    Tag Search Text

    + +
    +
    + *: any characters +
    +
    + book* will find book and books and + booking. +
    + +
    + space: a space acts like AND +
    +
    + Harry Potter will find Harry Potter and Harry + James Potter but not Harry. +
    + +
    + ||: OR (not exclusive) +
    +
    + Harry || Potter will find Harry, Harry + Potter, and Potter. +
    + +
    + ": words in exact sequence +
    +
    + "Harry Lockhart" will find "Harry Lockhart" but not + Harry Potter/Gilderoy Lockhart. +
    + +
    + NOT: NOT +
    +
    + Harry NOT Lockhart will find Harry Potter but not + Harry Lockhart or Gilderoy Lockhart/Harry Potter. +
    +
    diff --git a/public/help/tagset-about.html b/public/help/tagset-about.html new file mode 100644 index 0000000..0013501 --- /dev/null +++ b/public/help/tagset-about.html @@ -0,0 +1,32 @@ +

    About Tag Sets

    + +

    + If you've ever wanted to run a challenge on the Archive, that's what tag sets + are for. You create a tag set to hold a list of all the tags that should be + available for sign-up, even if those tags haven't been used on the Archive + before now, and then add the tag set to your challenge. Tags in the tag set + will then automatically show up in the sign-up form. +

    + +

    + You can add as many moderators as you want to help you manage your tag set, + and they don't need to have access to the challenge settings. You can also + allow the participants in your challenge to nominate tags to be added to your + set. You and any co-owners and moderators can review those nominations, and + approve or reject them. You can add fandom associations to new tags in your + Tag Set, or you can leave them for tag wranglers to make the associations + (this may take some time). +

    + +

    + All tag sets are listed on the main tag sets page. You can browse them to get + a better idea of how they work. +

    + +

    + Some users may choose to make their tag set publicly available for others to + use in their challenges. Please note that the owners of a tag set can + delete the tag set or change it without warning, so before you use + someone else's tag set for your challenge, please be sure that you trust them + not to change it. +

    diff --git a/public/help/tagset-batch-load.html b/public/help/tagset-batch-load.html new file mode 100644 index 0000000..72c6f55 --- /dev/null +++ b/public/help/tagset-batch-load.html @@ -0,0 +1,24 @@ +

    Batch Loading

    + +
      +
    • + You can only batch load tag associations if the tags already exist. This is + to avoid mass-adding piles of wrong tags to the Archive. +
    • +
    • + If you really want to add some new tags to the Archive, you can add them to + your tag set first, and then batch load the associations here afterwards. +
    • +
    • + If two tags are already associated in the Archive or in your tag set, it + won't associate them again. +
    • +
    • + Only the associations that didn't work will remain in the form after you + select the "Submit" button. +
    • +
    • + If the batch load is taking too long and you're getting 502 errors, please + split up the number of tags you're trying to load! +
    • +
    diff --git a/public/help/tagset-fandom-for-child.html b/public/help/tagset-fandom-for-child.html new file mode 100644 index 0000000..09678d2 --- /dev/null +++ b/public/help/tagset-fandom-for-child.html @@ -0,0 +1,7 @@ +

    Specifying Fandom

    + +

    + You only need to specify the fandom if your nomination is new or not in the fandom already -- for instance, if you're + submitting a character who has just appeared in the fandom. + This information is just used to help the moderators sort out new tags. +

    diff --git a/public/help/tagset-nominated-tags.html b/public/help/tagset-nominated-tags.html new file mode 100644 index 0000000..6064694 --- /dev/null +++ b/public/help/tagset-nominated-tags.html @@ -0,0 +1,13 @@ +

    Nominated Tags

    + +

    + Some of your tags may be marked as unwrangled, meaning they currently exist in the archive but have + not been organized into our tagging system, and some may be marked as nonexistent, meaning + they don't exist in the archive at all. If you have tags marked this way, please make sure you have them typed correctly! +

    + +

    + If you see one of your tags listed with a slightly different tag afterwards in parentheses, this means that the + tag you have nominated is wrangled and has a canonical synonym on the archive. The moderators may decide to use + the canonical synonym instead of the version you used. +

    diff --git a/public/help/tagset-tag-associations.html b/public/help/tagset-tag-associations.html new file mode 100644 index 0000000..23b0247 --- /dev/null +++ b/public/help/tagset-tag-associations.html @@ -0,0 +1,18 @@ +

    Tag Set Associations

    + +

    + Tag associations let you set up associations between the fandoms, characters, and relationships in your tag set, which then + lets your participants pick from only the characters and relationships in a given fandom. +

    + +

    + Note: if the wranglers have already set up these associations, then you can just add the additional + ones that you would like -- you don't have to (and in fact aren't allowed) to create copies of canonical + associations. You can still limit your participants' choices to tags actually in your set. +

    + +

    + If you're not sure how this might work, try adding a few fandoms and characters and setting up some associations, + and then set up your challenge and try out the sign-up form! +

    + \ No newline at end of file diff --git a/public/help/translation-link.html b/public/help/translation-link.html new file mode 100644 index 0000000..1cdbfde --- /dev/null +++ b/public/help/translation-link.html @@ -0,0 +1,7 @@ +

    Translations

    + +

    +As well as citing a work that has inspired yours (e.g. if you are remixing another work) you can also state that your work is a translation of another work. +

    + +

    Just tick this box, fill in the details of the work, and make sure you select the language of your work further down the form, and the Archive will link them up.

    diff --git a/public/help/warning-help.html b/public/help/warning-help.html new file mode 100644 index 0000000..7d4057b --- /dev/null +++ b/public/help/warning-help.html @@ -0,0 +1,72 @@ +

    Warning Tags

    + +

    + The Archive of Our Own has chosen, for legal and other reasons, to mandate + that users either warn for—or explicitly choose not to warn for—a + short list of common warnings: Graphic Depictions of Violence, Major Character + Death, Rape/Non-Con, and Underage Sex. We understand that creators may wish to not + warn for some of these things, or to warn for additional content, and have + provided options for them to do so within this framework. +

    + +
    +
    + Choose Not To Use Archive Warnings: +
    +
    + Use this if you don't want to warn for anything. You may also choose this + option if you don't know what you should warn for; if you don't like warning + for certain topics or warnings in general; if you want to avoid some + spoilers, but not others; etc. +
    +
    + Graphic Depictions Of Violence: +
    +
    + This is for gory, graphic, explicitly described violence. Exactly where to + draw the line is your call. +
    +
    + Major Character Death: +
    +
    + Please use your best judgment about who counts as a major character. +
    +
    + No Archive Warnings Apply: +
    +
    + Use this if the Archive warnings don't apply to your content. (In other + words, if it contains no graphic depictions of violence, major character + death, rape/non-con, or underage sexual activity.) +
    +
    + Rape/Non-Con: +
    +
    + This is your call. If you think your content may be seen as containing + non-consensual sexual activity, but you don't feel like using this warning + or you're not sure if you should, you always have the option of using + "Choose Not to Use Archive Warnings" instead. +
    +
    + Underage Sex: +
    +
    + This is for descriptions or depictions of sexual activity involving characters + under the age of eighteen. (This doesn't include dating activity like + kissing or vague references with no actual description or depiction.) This + warning generally applies to humans; if you are creating a pornographic work + about space aliens who only live for a month or thousand-year-old vampires + with twelve-year-old bodies, please just use your best judgment. You are + always free to specify characters' ages or to use "Choose Not to Use Archive + Warnings". +
    +
    + +

    + You can also use the "Additional Tags" field to give other or more detailed + warnings. Our policies regarding warnings can be found in the Terms of Service and Terms of Service FAQ. +

    diff --git a/public/help/who-can-comment-on-this-work.html b/public/help/who-can-comment-on-this-work.html new file mode 100644 index 0000000..7d3ee9c --- /dev/null +++ b/public/help/who-can-comment-on-this-work.html @@ -0,0 +1,10 @@ +

    Who can comment on this work?

    +
    +
    Registered users and guests can comment
    +
    All users can comment on your work whether they are logged in or not.
    +
    Only registered users can comment
    +
    This is the default option. With this enabled, only logged in users will be able to comment on your work.
    +
    No one can comment
    +
    This will disable all new comments on your work.
    +
    +

    Changing these settings will not affect any existing comments. If there are comments on the work that you would like to remove, check out Can I edit or delete a comment someone else left on one of my works? to learn how.

    diff --git a/public/help/work-filters-exclude-tags.html b/public/help/work-filters-exclude-tags.html new file mode 100644 index 0000000..a2b64e6 --- /dev/null +++ b/public/help/work-filters-exclude-tags.html @@ -0,0 +1,25 @@ +

    Tag Filters: Exclude Tags

    + +

    + The filters list the ten most popular tags for each tag category. To filter by any other tags, use the "Other tags to exclude" field. +

    + +

    + If the tag you want to exclude isn't in the top ten, start entering the tag you need in the "Other tags to exclude" field—all tag categories are valid here, and you can add as many tags as you want. The autocomplete will help you find the canonical version of the tag. Use these to take full advantage of the wrangled tagging structure, which will exclude all works using subtags and tags with synonymous meanings. +

    + +

    + You can also enter tags that aren't in the autocomplete. If the tag you enter has been used on the Archive but is not marked canonical, then the filters will look for works that use the exact tag you've entered. If the tag you've entered has never been used on the Archive, the filters will do a simple text match and may bring up unexpected results. The "Search within results" field will text-match more accurately, especially in the case of relationship tags and other tags with "/" or other non-text characters. +

    + +

    + Choosing any tag from a category or entering a tag in the "Other tags to exclude" field will do an OR search with all the tags you select. This means that if you're filtering the works tagged with the F/F category, select the Major Character Death warning, the canonical Alternate Universe tag in the Additional Tags category, and enter or select the canonical tag Drama in the "Other tags to exclude" field, only works tagged without any of these tags will be included in the results. +

    + +

    + To look up tags and see which ones are canonical, use the Tag Search. +

    + +

    + You can find more information about tags in our Tags FAQ. To read the guidelines tag wranglers use to mark tags canonical, among other things, or to better understand the AO3-specific vocabulary for tags, read the Wrangling Guidelines. +

    diff --git a/public/help/work-filters-include-tags.html b/public/help/work-filters-include-tags.html new file mode 100644 index 0000000..04caaf2 --- /dev/null +++ b/public/help/work-filters-include-tags.html @@ -0,0 +1,29 @@ +

    Tag Filters: Include Tags

    + +

    + The filters list the ten most popular tags for each tag category. To filter by any other tags, use the "Other tags to include" field. +

    + +

    + If the tag that interests you isn't in the top ten, start entering the tag you need in the "Other tags to include" field—all tag categories are valid here, and you can add as many tags as you want. The autocomplete will help you find the canonical version of the tag. Use these to take full advantage of the wrangled tagging structure, which will find all works using subtags and tags with synonymous meanings. +

    + +

    + You can also enter tags that aren't in the autocomplete. If the tag you enter has been used on the Archive but is not marked canonical, then the filters will look for works that use the exact tag you've entered. If the tag you've entered has never been used on the Archive, the filters will do a simple text match and may bring up unexpected results. The "Search within results" field will text-match more accurately, especially in the case of relationship tags and other tags with "/" or other non-text characters. +

    + +

    + Choosing any tag from a category or entering a tag in the "Other tags to include" field will do an AND search with all the tags you select. This means that if you're filtering the works tagged with the F/F category, select the Teen and Up Audiences rating, the canonical Romance tag in the Additional Tags category, and enter or select the canonical tag Drama in the "Other tags to include" field, only works tagged with all these tags will be included in the results. +

    + +

    + To get results with Tag A OR Tag B, use the "Search within results" field. +

    + +

    + To look up tags and see which ones are canonical, use the Tag Search. +

    + +

    + You can find more information about tags in our Tags FAQ. To read the guidelines tag wranglers use to mark tags canonical, among other things, or to better understand the AO3-specific vocabulary for tags, read the Wrangling Guidelines. +

    diff --git a/public/help/work-import.html b/public/help/work-import.html new file mode 100644 index 0000000..06b12dc --- /dev/null +++ b/public/help/work-import.html @@ -0,0 +1,45 @@ +

    Troubleshooting Importing

    + +

    + If your text gets cut off at the point of an em dash or accented character, + you might have to manually set the encoding using the "Set custom encoding" + menu below for your work to import successfully. The type of encoding that + works can vary; you may need to try different options to find the right one. + Refer to the Encoding Help page for + more information. +

    + +

    + If you are importing a chaptered work from an e-fiction site, you need to + enter the URL for each chapter, each one on a different line. You can import a + maximum of two hundred chapters at a time. For more information on importing + works from other sites, please refer to "How do I import works from another + website?" +

    + +

    + If you are trying to transfer a work already on the Archive of Our Own from + one user account to another, you must edit the existing work to add the new + account as a co-creator, then remove the old account. You can't use the Import + tool on AO3-hosted works. +

    + +

    + Unless you check the "Override tags and notes" box, any information you enter + under Tags will only be used if the importer cannot determine tags from the + work itself. +

    + +

    + You will be able to edit and complete the standard header information after + the import has been completed. For more information on posting and editing, + please refer to the Posting and Editing + FAQ. +

    + +

    + If none of the above information is helpful to you, you might find your + problem listed on the the Known Issues + page. +

    diff --git a/public/help/work-search-crossover-help.html b/public/help/work-search-crossover-help.html new file mode 100644 index 0000000..ba307bc --- /dev/null +++ b/public/help/work-search-crossover-help.html @@ -0,0 +1,9 @@ +

    Work Search: Crossovers

    + +

    + Generally speaking, a crossover is a work with more than one fandom. For filtering purposes, a work is considered a crossover if it's tagged with two or more unrelated fandoms (we're using our tag wrangling system to make that determination). +

    + +

    + Looking for crossovers between two specific fandoms? Enter their names in the "Fandoms" field on the search form or select or enter both fandoms on the filters. +

    diff --git a/public/help/work-search-date-help.html b/public/help/work-search-date-help.html new file mode 100644 index 0000000..9c23038 --- /dev/null +++ b/public/help/work-search-date-help.html @@ -0,0 +1,27 @@ +

    Work Search: Date

    + +

    Create a range of times. If no range is given, then one will be calculated based on the time period specified.

    +

    Allowable periods: year, week, month, day, hour

    +

    +

      +
    • x days ago = 24 hour period from the beginning to the end of that day
    • +
    • x weeks ago = 7 day period from the beginning to the end of that week
    • +
    • x months ago = 1 month period from the beginning to the end of that month
    • +
    • x years ago = 1 year period from the beginning to the end of that year
    • +
    +

    + +

    Examples (taking Wednesday 25th April 2012 as the current day):

    +

    +

      +
    • 7 days ago (this will return all works posted/updated on Wednesday 18th April)
    • +
    • 1 week ago (this will return all works posted/updated in the week starting Monday 16th April and ending Sunday 22nd April)
    • +
    • 2 months ago (this will return all works posted/updated in the month of February)
    • +
    • 3 years ago (this will return all works posted/updated in 2010)
    • +
    • < 7 days (this will return all works posted/updated within the past seven days)
    • +
    • > 8 weeks (this will return all works posted/updated more than eight weeks ago)
    • +
    • 13-21 months (this will return all works posted/updated between thirteen and twenty-one months ago)
    • +
    +

    + +

    Note that the "ago" is optional.

    diff --git a/public/help/work-search-language-help.html b/public/help/work-search-language-help.html new file mode 100644 index 0000000..654e237 --- /dev/null +++ b/public/help/work-search-language-help.html @@ -0,0 +1,3 @@ +

    Work Search: Language

    + +

    Selecting a language from this drop-down will search for works in that language. Please note that this list contains all languages we are currently supporting, and not all of them will return results.

    diff --git a/public/help/work-search-numerical-help.html b/public/help/work-search-numerical-help.html new file mode 100644 index 0000000..1a0b058 --- /dev/null +++ b/public/help/work-search-numerical-help.html @@ -0,0 +1,14 @@ +

    Work Search: Numerical Values

    + +

    Use the following guidelines when looking for works with a specific amount of words, hits, kudos, comments, or bookmarks. Note that periods and commas are ignored: 1.000 = 1,000 = 1000. + +

    +
    10:
    +
    a single number will find works with that exact amount
    +
    <100:
    +
    will find works with less than that amount
    +
    >100:
    +
    will find works with more than that amount
    +
    100-1000:
    +
    will find works in the range of 100 to 1000
    +
    diff --git a/public/help/work-search-results-help.html b/public/help/work-search-results-help.html new file mode 100644 index 0000000..46225dc --- /dev/null +++ b/public/help/work-search-results-help.html @@ -0,0 +1,3 @@ +

    Work Search: Results

    + +

    Very new works that fit the criteria will be at the top of the list. Otherwise the list is sorted by relevance. If there are many works, it might be better to edit your search terms rather than page through the results.

    \ No newline at end of file diff --git a/public/help/work-search-tags-help.html b/public/help/work-search-tags-help.html new file mode 100644 index 0000000..aae099e --- /dev/null +++ b/public/help/work-search-tags-help.html @@ -0,0 +1,41 @@ +

    Work Search: Tags

    + +

    + The fields for Fandoms, Characters, Relationships, and Additional Tags suggest + tags as you enter search terms. Choosing the "canonical" or common tag, (that + is, the ones included in the autocomplete list) will return all results + containing this tag, its synonyms, and the subtags linked with the tag. For + example, picking the canonical relationship tag Erika Mustermann/Juan + Pérez will return works tagged Juan Pérez/Erika + Mustermann as well, assuming these tags have been linked by a wrangler + in the background. Refer to What is a + 'canonical' tag? for more information. +

    + +

    + If a tag doesn't appear in the autocomplete menu, it doesn't necessarily mean + that the tag doesn't exist on the Archive; it just hasn't been marked as + common by a tag wrangler. You can enter any word or phrase here. If your + phrase doesn't match a common tag exactly, it will find all tags containing + the words in your phrase. Entering People Doing Things, for + example, will also find works tagged Nice People Doing Things, + People Doing Shady Things, and People Doing Things with + Spoons. Depending on your search, results in this case will be somewhat + unpredictable. +

    + +

    + The more search terms you enter or options you pick, the more your search + results will be narrowed down. By default, all the search results are AND + search results. This means that entering two fandoms will only find works + tagged with both, not all works that are in one fandom or the other. + Entering two characters will only find works that have both. Picking + M/M and F/M will only include a work in the search + results if it has both category tags, and so on. +

    + +

    + You can find more about tagging in our Tags FAQ, and + more about tag search in the Search and + Browse FAQ. +

    diff --git a/public/help/work-search-text-help.html b/public/help/work-search-text-help.html new file mode 100644 index 0000000..608f942 --- /dev/null +++ b/public/help/work-search-text-help.html @@ -0,0 +1,36 @@ +

    Work Search: Any Field

    + +

    Searches all the fields associated with a work in the database, including summary, notes and tags, but not the full work text.

    + +

    The characters ":" and "@" have special meanings. Leave them out of your search or you will get unexpected results. Like in the Title and Creator field, you can use the following operators to combine your search terms:

    + +
    +
    *: any characters
    +
    book* will find book and books and booking.
    + +
    space: acts like AND for search terms in the same field of the work
    +
    Harry Potter will find Harry Potter and Harry James Potter in any field, but it won't find works by a creator named Harry with the character tag Sherman Potter.
    + +
    AND: searches for works which have both terms in any field
    +
    Harry AND Potter will find works by a creator named Harry with the character tag Sherman Potter.
    + +
    ||: OR (not exclusive)
    +
    Harry || Potter will find Harry, Harry Potter, and Potter.
    + +
    "": words in exact sequence
    +
    "Harry Lockhart" will find Harry Lockhart but not Harry Potter/Gilderoy Lockhart.
    + +
    -: NOT
    +
    Harry -Lockhart will find Harry Potter but not Harry Lockhart or Gilderoy Lockhart/Harry Potter.
    +
    + +
    Examples
    + +
    +
    "Fandom X" "F/F" -Explicit
    +
    will return all works from Fandom X tagged as F/F, and exclude those tagged Explicit
    +
    "Character A" OR "Character B" -"Character Death"
    +
    will return all works including Character A or Character B (or both), and no works tagged with "Character Death" in either the Warnings or the Additional tags
    +
    "Character A/Character B" "Underage Sex" (Mature OR Explicit)
    +
    will return all works for this pairing that include an Underage Sex warning and are either rated Mature or Explicit
    +
    diff --git a/public/help/work-skins.html b/public/help/work-skins.html new file mode 100644 index 0000000..77e3d57 --- /dev/null +++ b/public/help/work-skins.html @@ -0,0 +1,41 @@ +

    Work Skins

    + +

    + You can create custom stylesheets, or "skins", for your works just like you + can create skins for the Archive. The main difference is that work skins will + change the way your work appears to other users, not just + you. +

    + +

    + Work skins will only affect the body of the work they are + applied to—you can't change the Archive navigation or background with them. + What you can do, however, is create your own classes. For instance, you can + change the color of some of your text, indent some paragraphs in a particular + way, and so on. +

    + +

    + For example, let's say that you want to make a single word in your text bright + blue. Here is what you might do: +

    + +
      +
    • + Create a work skin with the + following content: .bluetext {color: blue;} +
    • +
    • + Select this skin when posting your work. +
    • +
    • + In the HTML of your text, you would then give the word this class: I + want <span class="bluetext">house</span> to be in blue +
    • +
    + +

    + Refer to Tutorial: Creating a + Work Skin and Skins and Archive + Interface FAQ for more information. +

    diff --git a/public/help/work_title_format.html b/public/help/work_title_format.html new file mode 100644 index 0000000..295d060 --- /dev/null +++ b/public/help/work_title_format.html @@ -0,0 +1,15 @@ +

    Work Title Format

    + +

    + Specify how the page title looks in your browser when you are reading a story. Some examples: +

    + +
    +
    TITLE - AUTHOR - FANDOM
    +
    This is the default format.
    +
    TITLE - AUTHOR
    +
    Don't include the fandom
    +
    FANDOM_AUTHOR_TITLE
    +
    Start with fandom, then with author, then title, with underscores instead of dashes.
    +
    + diff --git a/public/images/OTWLogo.png b/public/images/OTWLogo.png new file mode 100644 index 0000000..5342117 Binary files /dev/null and b/public/images/OTWLogo.png differ diff --git a/public/images/ao3_logos/ao3-502.png b/public/images/ao3_logos/ao3-502.png new file mode 100644 index 0000000..464f956 Binary files /dev/null and b/public/images/ao3_logos/ao3-502.png differ diff --git a/public/images/ao3_logos/logo-glitter-hat-noisemaker.gif b/public/images/ao3_logos/logo-glitter-hat-noisemaker.gif new file mode 100644 index 0000000..035edf5 Binary files /dev/null and b/public/images/ao3_logos/logo-glitter-hat-noisemaker.gif differ diff --git a/public/images/ao3_logos/logo-rails3.png b/public/images/ao3_logos/logo-rails3.png new file mode 100644 index 0000000..7bf23ca Binary files /dev/null and b/public/images/ao3_logos/logo-rails3.png differ diff --git a/public/images/ao3_logos/logo-ruby.png b/public/images/ao3_logos/logo-ruby.png new file mode 100644 index 0000000..76a4a89 Binary files /dev/null and b/public/images/ao3_logos/logo-ruby.png differ diff --git a/public/images/ao3_logos/logo-stroke.png b/public/images/ao3_logos/logo-stroke.png new file mode 100644 index 0000000..5c2eb4f Binary files /dev/null and b/public/images/ao3_logos/logo-stroke.png differ diff --git a/public/images/ao3_logos/logo.png b/public/images/ao3_logos/logo.png new file mode 100644 index 0000000..8ec2775 Binary files /dev/null and b/public/images/ao3_logos/logo.png differ diff --git a/public/images/ao3_logos/logo_42.png b/public/images/ao3_logos/logo_42.png new file mode 100644 index 0000000..8ec2775 Binary files /dev/null and b/public/images/ao3_logos/logo_42.png differ diff --git a/public/images/ao3_logos/sadface.png b/public/images/ao3_logos/sadface.png new file mode 100644 index 0000000..c8ee85c Binary files /dev/null and b/public/images/ao3_logos/sadface.png differ diff --git a/public/images/arrow-down.gif b/public/images/arrow-down.gif new file mode 100644 index 0000000..4101399 Binary files /dev/null and b/public/images/arrow-down.gif differ diff --git a/public/images/arrow-down.png b/public/images/arrow-down.png new file mode 100644 index 0000000..02d9dac Binary files /dev/null and b/public/images/arrow-down.png differ diff --git a/public/images/arrow-right.gif b/public/images/arrow-right.gif new file mode 100644 index 0000000..e1f2382 Binary files /dev/null and b/public/images/arrow-right.gif differ diff --git a/public/images/arrow-right.png b/public/images/arrow-right.png new file mode 100644 index 0000000..d1b5de4 Binary files /dev/null and b/public/images/arrow-right.png differ diff --git a/public/images/arrow-up.gif b/public/images/arrow-up.gif new file mode 100644 index 0000000..d36dc47 Binary files /dev/null and b/public/images/arrow-up.gif differ diff --git a/public/images/arrow-up.png b/public/images/arrow-up.png new file mode 100644 index 0000000..1971306 Binary files /dev/null and b/public/images/arrow-up.png differ diff --git a/public/images/beta-flag.png b/public/images/beta-flag.png new file mode 100644 index 0000000..0085086 Binary files /dev/null and b/public/images/beta-flag.png differ diff --git a/public/images/envelope_icon.gif b/public/images/envelope_icon.gif new file mode 100644 index 0000000..21ee325 Binary files /dev/null and b/public/images/envelope_icon.gif differ diff --git a/public/images/feed-icon-14x14.png b/public/images/feed-icon-14x14.png new file mode 100644 index 0000000..63ebf9f Binary files /dev/null and b/public/images/feed-icon-14x14.png differ diff --git a/public/images/imageset.png b/public/images/imageset.png new file mode 100644 index 0000000..8b18cb6 Binary files /dev/null and b/public/images/imageset.png differ diff --git a/public/images/indicator.gif b/public/images/indicator.gif new file mode 100644 index 0000000..915c198 Binary files /dev/null and b/public/images/indicator.gif differ diff --git a/public/images/key-square-1.png b/public/images/key-square-1.png new file mode 100644 index 0000000..94a5986 Binary files /dev/null and b/public/images/key-square-1.png differ diff --git a/public/images/key-square-2.png b/public/images/key-square-2.png new file mode 100644 index 0000000..c6f7b1a Binary files /dev/null and b/public/images/key-square-2.png differ diff --git a/public/images/key-square-3.png b/public/images/key-square-3.png new file mode 100644 index 0000000..d03f765 Binary files /dev/null and b/public/images/key-square-3.png differ diff --git a/public/images/key-square-4.png b/public/images/key-square-4.png new file mode 100644 index 0000000..f408f6a Binary files /dev/null and b/public/images/key-square-4.png differ diff --git a/public/images/legacy/beta-flag-legacy.png b/public/images/legacy/beta-flag-legacy.png new file mode 100644 index 0000000..4dafbfc Binary files /dev/null and b/public/images/legacy/beta-flag-legacy.png differ diff --git a/public/images/legacy/button-gradient.png b/public/images/legacy/button-gradient.png new file mode 100644 index 0000000..a64c094 Binary files /dev/null and b/public/images/legacy/button-gradient.png differ diff --git a/public/images/legacy/ccc-fff.png b/public/images/legacy/ccc-fff.png new file mode 100644 index 0000000..de89d0f Binary files /dev/null and b/public/images/legacy/ccc-fff.png differ diff --git a/public/images/legacy/drop-shadow-light.gif b/public/images/legacy/drop-shadow-light.gif new file mode 100644 index 0000000..3cb4b20 Binary files /dev/null and b/public/images/legacy/drop-shadow-light.gif differ diff --git a/public/images/legacy/fffef9-ccc.png b/public/images/legacy/fffef9-ccc.png new file mode 100644 index 0000000..3470c73 Binary files /dev/null and b/public/images/legacy/fffef9-ccc.png differ diff --git a/public/images/legacy/header-red.png b/public/images/legacy/header-red.png new file mode 100644 index 0000000..2d6bfa6 Binary files /dev/null and b/public/images/legacy/header-red.png differ diff --git a/public/images/legacy/headergradient.png b/public/images/legacy/headergradient.png new file mode 100644 index 0000000..a3a7d2d Binary files /dev/null and b/public/images/legacy/headergradient.png differ diff --git a/public/images/legacy/kudos-legacy.png b/public/images/legacy/kudos-legacy.png new file mode 100644 index 0000000..6c2e3ad Binary files /dev/null and b/public/images/legacy/kudos-legacy.png differ diff --git a/public/images/legacy/login-gradient-2.png b/public/images/legacy/login-gradient-2.png new file mode 100644 index 0000000..cade2dd Binary files /dev/null and b/public/images/legacy/login-gradient-2.png differ diff --git a/public/images/legacy/logo-stroke-legacy.gif b/public/images/legacy/logo-stroke-legacy.gif new file mode 100644 index 0000000..2df6d93 Binary files /dev/null and b/public/images/legacy/logo-stroke-legacy.gif differ diff --git a/public/images/legacy/openid.png b/public/images/legacy/openid.png new file mode 100644 index 0000000..90bb9a0 Binary files /dev/null and b/public/images/legacy/openid.png differ diff --git a/public/images/lockblack.png b/public/images/lockblack.png new file mode 100644 index 0000000..e94d550 Binary files /dev/null and b/public/images/lockblack.png differ diff --git a/public/images/lockblue.png b/public/images/lockblue.png new file mode 100644 index 0000000..efef0ae Binary files /dev/null and b/public/images/lockblue.png differ diff --git a/public/images/lockred.png b/public/images/lockred.png new file mode 100644 index 0000000..a0fc308 Binary files /dev/null and b/public/images/lockred.png differ diff --git a/public/images/logo-stroke.png b/public/images/logo-stroke.png new file mode 100644 index 0000000..5c2eb4f Binary files /dev/null and b/public/images/logo-stroke.png differ diff --git a/public/images/logo.png b/public/images/logo.png new file mode 100644 index 0000000..8ec2775 Binary files /dev/null and b/public/images/logo.png differ diff --git a/public/images/mailer/mailer-banner-corner.gif b/public/images/mailer/mailer-banner-corner.gif new file mode 100644 index 0000000..593059d Binary files /dev/null and b/public/images/mailer/mailer-banner-corner.gif differ diff --git a/public/images/mailer/mailer-logo-bottom.gif b/public/images/mailer/mailer-logo-bottom.gif new file mode 100644 index 0000000..933eb86 Binary files /dev/null and b/public/images/mailer/mailer-logo-bottom.gif differ diff --git a/public/images/mailer/mailer-logo-top.gif b/public/images/mailer/mailer-logo-top.gif new file mode 100644 index 0000000..96bf29f Binary files /dev/null and b/public/images/mailer/mailer-logo-top.gif differ diff --git a/public/images/skins/iconsets/default/bookmark-hidden.png b/public/images/skins/iconsets/default/bookmark-hidden.png new file mode 100644 index 0000000..76c456f Binary files /dev/null and b/public/images/skins/iconsets/default/bookmark-hidden.png differ diff --git a/public/images/skins/iconsets/default/bookmark-private.png b/public/images/skins/iconsets/default/bookmark-private.png new file mode 100644 index 0000000..f7473b6 Binary files /dev/null and b/public/images/skins/iconsets/default/bookmark-private.png differ diff --git a/public/images/skins/iconsets/default/bookmark-public.png b/public/images/skins/iconsets/default/bookmark-public.png new file mode 100644 index 0000000..f4c22e7 Binary files /dev/null and b/public/images/skins/iconsets/default/bookmark-public.png differ diff --git a/public/images/skins/iconsets/default/bookmark-rec.png b/public/images/skins/iconsets/default/bookmark-rec.png new file mode 100644 index 0000000..194be27 Binary files /dev/null and b/public/images/skins/iconsets/default/bookmark-rec.png differ diff --git a/public/images/skins/iconsets/default/category-femslash.png b/public/images/skins/iconsets/default/category-femslash.png new file mode 100644 index 0000000..ff5a07d Binary files /dev/null and b/public/images/skins/iconsets/default/category-femslash.png differ diff --git a/public/images/skins/iconsets/default/category-gen.png b/public/images/skins/iconsets/default/category-gen.png new file mode 100644 index 0000000..02aa9a8 Binary files /dev/null and b/public/images/skins/iconsets/default/category-gen.png differ diff --git a/public/images/skins/iconsets/default/category-het.png b/public/images/skins/iconsets/default/category-het.png new file mode 100644 index 0000000..165a790 Binary files /dev/null and b/public/images/skins/iconsets/default/category-het.png differ diff --git a/public/images/skins/iconsets/default/category-multi.png b/public/images/skins/iconsets/default/category-multi.png new file mode 100644 index 0000000..5fc146f Binary files /dev/null and b/public/images/skins/iconsets/default/category-multi.png differ diff --git a/public/images/skins/iconsets/default/category-none.png b/public/images/skins/iconsets/default/category-none.png new file mode 100644 index 0000000..e73d7dc Binary files /dev/null and b/public/images/skins/iconsets/default/category-none.png differ diff --git a/public/images/skins/iconsets/default/category-other.png b/public/images/skins/iconsets/default/category-other.png new file mode 100644 index 0000000..6fbb406 Binary files /dev/null and b/public/images/skins/iconsets/default/category-other.png differ diff --git a/public/images/skins/iconsets/default/category-slash.png b/public/images/skins/iconsets/default/category-slash.png new file mode 100644 index 0000000..870093b Binary files /dev/null and b/public/images/skins/iconsets/default/category-slash.png differ diff --git a/public/images/skins/iconsets/default/complete-no.png b/public/images/skins/iconsets/default/complete-no.png new file mode 100644 index 0000000..ac4956a Binary files /dev/null and b/public/images/skins/iconsets/default/complete-no.png differ diff --git a/public/images/skins/iconsets/default/complete-yes.png b/public/images/skins/iconsets/default/complete-yes.png new file mode 100644 index 0000000..51d55cc Binary files /dev/null and b/public/images/skins/iconsets/default/complete-yes.png differ diff --git a/public/images/skins/iconsets/default/icon-template.png b/public/images/skins/iconsets/default/icon-template.png new file mode 100644 index 0000000..4a507cd Binary files /dev/null and b/public/images/skins/iconsets/default/icon-template.png differ diff --git a/public/images/skins/iconsets/default/icon_admin.jpeg b/public/images/skins/iconsets/default/icon_admin.jpeg new file mode 100644 index 0000000..a032e35 Binary files /dev/null and b/public/images/skins/iconsets/default/icon_admin.jpeg differ diff --git a/public/images/skins/iconsets/default/icon_collection-s.png b/public/images/skins/iconsets/default/icon_collection-s.png new file mode 100644 index 0000000..7f8bc16 Binary files /dev/null and b/public/images/skins/iconsets/default/icon_collection-s.png differ diff --git a/public/images/skins/iconsets/default/icon_collection.png b/public/images/skins/iconsets/default/icon_collection.png new file mode 100644 index 0000000..0fa1893 Binary files /dev/null and b/public/images/skins/iconsets/default/icon_collection.png differ diff --git a/public/images/skins/iconsets/default/icon_mystery.png b/public/images/skins/iconsets/default/icon_mystery.png new file mode 100644 index 0000000..d045d7e Binary files /dev/null and b/public/images/skins/iconsets/default/icon_mystery.png differ diff --git a/public/images/skins/iconsets/default/icon_skins.png b/public/images/skins/iconsets/default/icon_skins.png new file mode 100644 index 0000000..9b3fe27 Binary files /dev/null and b/public/images/skins/iconsets/default/icon_skins.png differ diff --git a/public/images/skins/iconsets/default/icon_tag.png b/public/images/skins/iconsets/default/icon_tag.png new file mode 100644 index 0000000..c80fcff Binary files /dev/null and b/public/images/skins/iconsets/default/icon_tag.png differ diff --git a/public/images/skins/iconsets/default/icon_transparent.gif b/public/images/skins/iconsets/default/icon_transparent.gif new file mode 100644 index 0000000..f12d97b Binary files /dev/null and b/public/images/skins/iconsets/default/icon_transparent.gif differ diff --git a/public/images/skins/iconsets/default/icon_user.png b/public/images/skins/iconsets/default/icon_user.png new file mode 100644 index 0000000..2904990 Binary files /dev/null and b/public/images/skins/iconsets/default/icon_user.png differ diff --git a/public/images/skins/iconsets/default/kudos.png b/public/images/skins/iconsets/default/kudos.png new file mode 100644 index 0000000..25f35c4 Binary files /dev/null and b/public/images/skins/iconsets/default/kudos.png differ diff --git a/public/images/skins/iconsets/default/logo-stroke.png b/public/images/skins/iconsets/default/logo-stroke.png new file mode 100644 index 0000000..fded5cd Binary files /dev/null and b/public/images/skins/iconsets/default/logo-stroke.png differ diff --git a/public/images/skins/iconsets/default/rating-explicit.png b/public/images/skins/iconsets/default/rating-explicit.png new file mode 100644 index 0000000..6652d14 Binary files /dev/null and b/public/images/skins/iconsets/default/rating-explicit.png differ diff --git a/public/images/skins/iconsets/default/rating-general-audience.png b/public/images/skins/iconsets/default/rating-general-audience.png new file mode 100644 index 0000000..d5e80ce Binary files /dev/null and b/public/images/skins/iconsets/default/rating-general-audience.png differ diff --git a/public/images/skins/iconsets/default/rating-mature.png b/public/images/skins/iconsets/default/rating-mature.png new file mode 100644 index 0000000..b498634 Binary files /dev/null and b/public/images/skins/iconsets/default/rating-mature.png differ diff --git a/public/images/skins/iconsets/default/rating-notrated.png b/public/images/skins/iconsets/default/rating-notrated.png new file mode 100644 index 0000000..e73d7dc Binary files /dev/null and b/public/images/skins/iconsets/default/rating-notrated.png differ diff --git a/public/images/skins/iconsets/default/rating-teen-orange.png b/public/images/skins/iconsets/default/rating-teen-orange.png new file mode 100644 index 0000000..44bfd76 Binary files /dev/null and b/public/images/skins/iconsets/default/rating-teen-orange.png differ diff --git a/public/images/skins/iconsets/default/rating-teen.png b/public/images/skins/iconsets/default/rating-teen.png new file mode 100644 index 0000000..29403e1 Binary files /dev/null and b/public/images/skins/iconsets/default/rating-teen.png differ diff --git a/public/images/skins/iconsets/default/rss.png b/public/images/skins/iconsets/default/rss.png new file mode 100644 index 0000000..bdc112a Binary files /dev/null and b/public/images/skins/iconsets/default/rss.png differ diff --git a/public/images/skins/iconsets/default/warning-choosenotto.png b/public/images/skins/iconsets/default/warning-choosenotto.png new file mode 100644 index 0000000..41f18fd Binary files /dev/null and b/public/images/skins/iconsets/default/warning-choosenotto.png differ diff --git a/public/images/skins/iconsets/default/warning-death.png b/public/images/skins/iconsets/default/warning-death.png new file mode 100644 index 0000000..954e190 Binary files /dev/null and b/public/images/skins/iconsets/default/warning-death.png differ diff --git a/public/images/skins/iconsets/default/warning-external-work.png b/public/images/skins/iconsets/default/warning-external-work.png new file mode 100644 index 0000000..92ce78c Binary files /dev/null and b/public/images/skins/iconsets/default/warning-external-work.png differ diff --git a/public/images/skins/iconsets/default/warning-no.png b/public/images/skins/iconsets/default/warning-no.png new file mode 100644 index 0000000..e73d7dc Binary files /dev/null and b/public/images/skins/iconsets/default/warning-no.png differ diff --git a/public/images/skins/iconsets/default/warning-underage.png b/public/images/skins/iconsets/default/warning-underage.png new file mode 100644 index 0000000..0fe4ffd Binary files /dev/null and b/public/images/skins/iconsets/default/warning-underage.png differ diff --git a/public/images/skins/iconsets/default/warning-yes.png b/public/images/skins/iconsets/default/warning-yes.png new file mode 100644 index 0000000..874f8eb Binary files /dev/null and b/public/images/skins/iconsets/default/warning-yes.png differ diff --git a/public/images/skins/iconsets/default_large/bookmark-hidden.png b/public/images/skins/iconsets/default_large/bookmark-hidden.png new file mode 100644 index 0000000..f531a7c Binary files /dev/null and b/public/images/skins/iconsets/default_large/bookmark-hidden.png differ diff --git a/public/images/skins/iconsets/default_large/bookmark-private.png b/public/images/skins/iconsets/default_large/bookmark-private.png new file mode 100644 index 0000000..3d5338c Binary files /dev/null and b/public/images/skins/iconsets/default_large/bookmark-private.png differ diff --git a/public/images/skins/iconsets/default_large/bookmark-public.png b/public/images/skins/iconsets/default_large/bookmark-public.png new file mode 100644 index 0000000..129ac5c Binary files /dev/null and b/public/images/skins/iconsets/default_large/bookmark-public.png differ diff --git a/public/images/skins/iconsets/default_large/bookmark-rec.png b/public/images/skins/iconsets/default_large/bookmark-rec.png new file mode 100644 index 0000000..e8b9e43 Binary files /dev/null and b/public/images/skins/iconsets/default_large/bookmark-rec.png differ diff --git a/public/images/skins/iconsets/default_large/category-femslash.png b/public/images/skins/iconsets/default_large/category-femslash.png new file mode 100644 index 0000000..f1ee09c Binary files /dev/null and b/public/images/skins/iconsets/default_large/category-femslash.png differ diff --git a/public/images/skins/iconsets/default_large/category-gen.png b/public/images/skins/iconsets/default_large/category-gen.png new file mode 100644 index 0000000..a7237dc Binary files /dev/null and b/public/images/skins/iconsets/default_large/category-gen.png differ diff --git a/public/images/skins/iconsets/default_large/category-het.png b/public/images/skins/iconsets/default_large/category-het.png new file mode 100644 index 0000000..b632820 Binary files /dev/null and b/public/images/skins/iconsets/default_large/category-het.png differ diff --git a/public/images/skins/iconsets/default_large/category-multi.png b/public/images/skins/iconsets/default_large/category-multi.png new file mode 100644 index 0000000..0569fde Binary files /dev/null and b/public/images/skins/iconsets/default_large/category-multi.png differ diff --git a/public/images/skins/iconsets/default_large/category-none.png b/public/images/skins/iconsets/default_large/category-none.png new file mode 100644 index 0000000..be90153 Binary files /dev/null and b/public/images/skins/iconsets/default_large/category-none.png differ diff --git a/public/images/skins/iconsets/default_large/category-other.png b/public/images/skins/iconsets/default_large/category-other.png new file mode 100644 index 0000000..1cdf733 Binary files /dev/null and b/public/images/skins/iconsets/default_large/category-other.png differ diff --git a/public/images/skins/iconsets/default_large/category-slash.png b/public/images/skins/iconsets/default_large/category-slash.png new file mode 100644 index 0000000..37ce632 Binary files /dev/null and b/public/images/skins/iconsets/default_large/category-slash.png differ diff --git a/public/images/skins/iconsets/default_large/complete-no.png b/public/images/skins/iconsets/default_large/complete-no.png new file mode 100644 index 0000000..7930a90 Binary files /dev/null and b/public/images/skins/iconsets/default_large/complete-no.png differ diff --git a/public/images/skins/iconsets/default_large/complete-yes.png b/public/images/skins/iconsets/default_large/complete-yes.png new file mode 100644 index 0000000..e48f1b3 Binary files /dev/null and b/public/images/skins/iconsets/default_large/complete-yes.png differ diff --git a/public/images/skins/iconsets/default_large/external-work.png b/public/images/skins/iconsets/default_large/external-work.png new file mode 100644 index 0000000..d3313bc Binary files /dev/null and b/public/images/skins/iconsets/default_large/external-work.png differ diff --git a/public/images/skins/iconsets/default_large/icon_collection.png b/public/images/skins/iconsets/default_large/icon_collection.png new file mode 100644 index 0000000..0fa1893 Binary files /dev/null and b/public/images/skins/iconsets/default_large/icon_collection.png differ diff --git a/public/images/skins/iconsets/default_large/icon_mystery.png b/public/images/skins/iconsets/default_large/icon_mystery.png new file mode 100644 index 0000000..681a104 Binary files /dev/null and b/public/images/skins/iconsets/default_large/icon_mystery.png differ diff --git a/public/images/skins/iconsets/default_large/icon_tag.png b/public/images/skins/iconsets/default_large/icon_tag.png new file mode 100644 index 0000000..c80fcff Binary files /dev/null and b/public/images/skins/iconsets/default_large/icon_tag.png differ diff --git a/public/images/skins/iconsets/default_large/icon_user.png b/public/images/skins/iconsets/default_large/icon_user.png new file mode 100644 index 0000000..2904990 Binary files /dev/null and b/public/images/skins/iconsets/default_large/icon_user.png differ diff --git a/public/images/skins/iconsets/default_large/rating-explicit.png b/public/images/skins/iconsets/default_large/rating-explicit.png new file mode 100644 index 0000000..89635e0 Binary files /dev/null and b/public/images/skins/iconsets/default_large/rating-explicit.png differ diff --git a/public/images/skins/iconsets/default_large/rating-general-audience.png b/public/images/skins/iconsets/default_large/rating-general-audience.png new file mode 100644 index 0000000..4eda7d7 Binary files /dev/null and b/public/images/skins/iconsets/default_large/rating-general-audience.png differ diff --git a/public/images/skins/iconsets/default_large/rating-mature.png b/public/images/skins/iconsets/default_large/rating-mature.png new file mode 100644 index 0000000..ff175fc Binary files /dev/null and b/public/images/skins/iconsets/default_large/rating-mature.png differ diff --git a/public/images/skins/iconsets/default_large/rating-na.png b/public/images/skins/iconsets/default_large/rating-na.png new file mode 100644 index 0000000..6bb8073 Binary files /dev/null and b/public/images/skins/iconsets/default_large/rating-na.png differ diff --git a/public/images/skins/iconsets/default_large/rating-notrated.png b/public/images/skins/iconsets/default_large/rating-notrated.png new file mode 100644 index 0000000..be90153 Binary files /dev/null and b/public/images/skins/iconsets/default_large/rating-notrated.png differ diff --git a/public/images/skins/iconsets/default_large/rating-teen-orange.png b/public/images/skins/iconsets/default_large/rating-teen-orange.png new file mode 100644 index 0000000..466cb58 Binary files /dev/null and b/public/images/skins/iconsets/default_large/rating-teen-orange.png differ diff --git a/public/images/skins/iconsets/default_large/rating-teen.png b/public/images/skins/iconsets/default_large/rating-teen.png new file mode 100644 index 0000000..7c9c5f1 Binary files /dev/null and b/public/images/skins/iconsets/default_large/rating-teen.png differ diff --git a/public/images/skins/iconsets/default_large/warning-choosenotto.png b/public/images/skins/iconsets/default_large/warning-choosenotto.png new file mode 100644 index 0000000..6bb8073 Binary files /dev/null and b/public/images/skins/iconsets/default_large/warning-choosenotto.png differ diff --git a/public/images/skins/iconsets/default_large/warning-eschewed.png b/public/images/skins/iconsets/default_large/warning-eschewed.png new file mode 100644 index 0000000..6b5293c Binary files /dev/null and b/public/images/skins/iconsets/default_large/warning-eschewed.png differ diff --git a/public/images/skins/iconsets/default_large/warning-no.png b/public/images/skins/iconsets/default_large/warning-no.png new file mode 100644 index 0000000..be90153 Binary files /dev/null and b/public/images/skins/iconsets/default_large/warning-no.png differ diff --git a/public/images/skins/iconsets/default_large/warning-none.png b/public/images/skins/iconsets/default_large/warning-none.png new file mode 100644 index 0000000..be90153 Binary files /dev/null and b/public/images/skins/iconsets/default_large/warning-none.png differ diff --git a/public/images/skins/iconsets/default_large/warning-yes.png b/public/images/skins/iconsets/default_large/warning-yes.png new file mode 100644 index 0000000..5d21996 Binary files /dev/null and b/public/images/skins/iconsets/default_large/warning-yes.png differ diff --git a/public/images/skins/iconsets/default_large/warnings-death.png b/public/images/skins/iconsets/default_large/warnings-death.png new file mode 100644 index 0000000..10112ba Binary files /dev/null and b/public/images/skins/iconsets/default_large/warnings-death.png differ diff --git a/public/images/skins/iconsets/default_large/warnings-na.png b/public/images/skins/iconsets/default_large/warnings-na.png new file mode 100644 index 0000000..4020bbc Binary files /dev/null and b/public/images/skins/iconsets/default_large/warnings-na.png differ diff --git a/public/images/skins/iconsets/default_large/warnings-noncon.png b/public/images/skins/iconsets/default_large/warnings-noncon.png new file mode 100644 index 0000000..79bf8cc Binary files /dev/null and b/public/images/skins/iconsets/default_large/warnings-noncon.png differ diff --git a/public/images/skins/iconsets/default_large/warnings-underage.png b/public/images/skins/iconsets/default_large/warnings-underage.png new file mode 100644 index 0000000..42b57ad Binary files /dev/null and b/public/images/skins/iconsets/default_large/warnings-underage.png differ diff --git a/public/images/skins/iconsets/default_large/warnings-violence.png b/public/images/skins/iconsets/default_large/warnings-violence.png new file mode 100644 index 0000000..04d4014 Binary files /dev/null and b/public/images/skins/iconsets/default_large/warnings-violence.png differ diff --git a/public/images/skins/iconsets/legacy/bookmark-hidden.png b/public/images/skins/iconsets/legacy/bookmark-hidden.png new file mode 100644 index 0000000..f531a7c Binary files /dev/null and b/public/images/skins/iconsets/legacy/bookmark-hidden.png differ diff --git a/public/images/skins/iconsets/legacy/bookmark-private.png b/public/images/skins/iconsets/legacy/bookmark-private.png new file mode 100644 index 0000000..3d5338c Binary files /dev/null and b/public/images/skins/iconsets/legacy/bookmark-private.png differ diff --git a/public/images/skins/iconsets/legacy/bookmark-public.png b/public/images/skins/iconsets/legacy/bookmark-public.png new file mode 100644 index 0000000..129ac5c Binary files /dev/null and b/public/images/skins/iconsets/legacy/bookmark-public.png differ diff --git a/public/images/skins/iconsets/legacy/bookmark-rec.png b/public/images/skins/iconsets/legacy/bookmark-rec.png new file mode 100644 index 0000000..e8b9e43 Binary files /dev/null and b/public/images/skins/iconsets/legacy/bookmark-rec.png differ diff --git a/public/images/skins/iconsets/legacy/category-femslash-s.png b/public/images/skins/iconsets/legacy/category-femslash-s.png new file mode 100644 index 0000000..ff5a07d Binary files /dev/null and b/public/images/skins/iconsets/legacy/category-femslash-s.png differ diff --git a/public/images/skins/iconsets/legacy/category-gen-s.png b/public/images/skins/iconsets/legacy/category-gen-s.png new file mode 100644 index 0000000..02aa9a8 Binary files /dev/null and b/public/images/skins/iconsets/legacy/category-gen-s.png differ diff --git a/public/images/skins/iconsets/legacy/category-het-s.png b/public/images/skins/iconsets/legacy/category-het-s.png new file mode 100644 index 0000000..165a790 Binary files /dev/null and b/public/images/skins/iconsets/legacy/category-het-s.png differ diff --git a/public/images/skins/iconsets/legacy/category-multi-s.png b/public/images/skins/iconsets/legacy/category-multi-s.png new file mode 100644 index 0000000..5fc146f Binary files /dev/null and b/public/images/skins/iconsets/legacy/category-multi-s.png differ diff --git a/public/images/skins/iconsets/legacy/category-none-s.png b/public/images/skins/iconsets/legacy/category-none-s.png new file mode 100644 index 0000000..e73d7dc Binary files /dev/null and b/public/images/skins/iconsets/legacy/category-none-s.png differ diff --git a/public/images/skins/iconsets/legacy/category-other-s.png b/public/images/skins/iconsets/legacy/category-other-s.png new file mode 100644 index 0000000..6fbb406 Binary files /dev/null and b/public/images/skins/iconsets/legacy/category-other-s.png differ diff --git a/public/images/skins/iconsets/legacy/category-slash-s.png b/public/images/skins/iconsets/legacy/category-slash-s.png new file mode 100644 index 0000000..870093b Binary files /dev/null and b/public/images/skins/iconsets/legacy/category-slash-s.png differ diff --git a/public/images/skins/iconsets/legacy/complete-no-s.png b/public/images/skins/iconsets/legacy/complete-no-s.png new file mode 100644 index 0000000..ac4956a Binary files /dev/null and b/public/images/skins/iconsets/legacy/complete-no-s.png differ diff --git a/public/images/skins/iconsets/legacy/complete-yes-s.png b/public/images/skins/iconsets/legacy/complete-yes-s.png new file mode 100644 index 0000000..51d55cc Binary files /dev/null and b/public/images/skins/iconsets/legacy/complete-yes-s.png differ diff --git a/public/images/skins/iconsets/legacy/icon_admin.jpeg b/public/images/skins/iconsets/legacy/icon_admin.jpeg new file mode 100644 index 0000000..a032e35 Binary files /dev/null and b/public/images/skins/iconsets/legacy/icon_admin.jpeg differ diff --git a/public/images/skins/iconsets/legacy/icon_collection-s.png b/public/images/skins/iconsets/legacy/icon_collection-s.png new file mode 100644 index 0000000..6c339eb Binary files /dev/null and b/public/images/skins/iconsets/legacy/icon_collection-s.png differ diff --git a/public/images/skins/iconsets/legacy/icon_collection.png b/public/images/skins/iconsets/legacy/icon_collection.png new file mode 100644 index 0000000..0fa1893 Binary files /dev/null and b/public/images/skins/iconsets/legacy/icon_collection.png differ diff --git a/public/images/skins/iconsets/legacy/icon_mystery-s.png b/public/images/skins/iconsets/legacy/icon_mystery-s.png new file mode 100644 index 0000000..d045d7e Binary files /dev/null and b/public/images/skins/iconsets/legacy/icon_mystery-s.png differ diff --git a/public/images/skins/iconsets/legacy/icon_mystery.png b/public/images/skins/iconsets/legacy/icon_mystery.png new file mode 100644 index 0000000..681a104 Binary files /dev/null and b/public/images/skins/iconsets/legacy/icon_mystery.png differ diff --git a/public/images/skins/iconsets/legacy/icon_skins.png b/public/images/skins/iconsets/legacy/icon_skins.png new file mode 100644 index 0000000..9b3fe27 Binary files /dev/null and b/public/images/skins/iconsets/legacy/icon_skins.png differ diff --git a/public/images/skins/iconsets/legacy/icon_tag.png b/public/images/skins/iconsets/legacy/icon_tag.png new file mode 100644 index 0000000..c80fcff Binary files /dev/null and b/public/images/skins/iconsets/legacy/icon_tag.png differ diff --git a/public/images/skins/iconsets/legacy/icon_user.png b/public/images/skins/iconsets/legacy/icon_user.png new file mode 100644 index 0000000..2904990 Binary files /dev/null and b/public/images/skins/iconsets/legacy/icon_user.png differ diff --git a/public/images/skins/iconsets/legacy/kudos.png b/public/images/skins/iconsets/legacy/kudos.png new file mode 100644 index 0000000..25f35c4 Binary files /dev/null and b/public/images/skins/iconsets/legacy/kudos.png differ diff --git a/public/images/skins/iconsets/legacy/rating-explicit-s.png b/public/images/skins/iconsets/legacy/rating-explicit-s.png new file mode 100644 index 0000000..6652d14 Binary files /dev/null and b/public/images/skins/iconsets/legacy/rating-explicit-s.png differ diff --git a/public/images/skins/iconsets/legacy/rating-general-audience-s.png b/public/images/skins/iconsets/legacy/rating-general-audience-s.png new file mode 100644 index 0000000..d5e80ce Binary files /dev/null and b/public/images/skins/iconsets/legacy/rating-general-audience-s.png differ diff --git a/public/images/skins/iconsets/legacy/rating-mature-s.png b/public/images/skins/iconsets/legacy/rating-mature-s.png new file mode 100644 index 0000000..b498634 Binary files /dev/null and b/public/images/skins/iconsets/legacy/rating-mature-s.png differ diff --git a/public/images/skins/iconsets/legacy/rating-notrated-s.png b/public/images/skins/iconsets/legacy/rating-notrated-s.png new file mode 100644 index 0000000..e73d7dc Binary files /dev/null and b/public/images/skins/iconsets/legacy/rating-notrated-s.png differ diff --git a/public/images/skins/iconsets/legacy/rating-teen-orange-s.png b/public/images/skins/iconsets/legacy/rating-teen-orange-s.png new file mode 100644 index 0000000..44bfd76 Binary files /dev/null and b/public/images/skins/iconsets/legacy/rating-teen-orange-s.png differ diff --git a/public/images/skins/iconsets/legacy/rating-teen-s.png b/public/images/skins/iconsets/legacy/rating-teen-s.png new file mode 100644 index 0000000..29403e1 Binary files /dev/null and b/public/images/skins/iconsets/legacy/rating-teen-s.png differ diff --git a/public/images/skins/iconsets/legacy/rss.png b/public/images/skins/iconsets/legacy/rss.png new file mode 100644 index 0000000..bdc112a Binary files /dev/null and b/public/images/skins/iconsets/legacy/rss.png differ diff --git a/public/images/skins/iconsets/legacy/warning-choosenotto-s.png b/public/images/skins/iconsets/legacy/warning-choosenotto-s.png new file mode 100644 index 0000000..41f18fd Binary files /dev/null and b/public/images/skins/iconsets/legacy/warning-choosenotto-s.png differ diff --git a/public/images/skins/iconsets/legacy/warning-external-work-s.png b/public/images/skins/iconsets/legacy/warning-external-work-s.png new file mode 100644 index 0000000..92ce78c Binary files /dev/null and b/public/images/skins/iconsets/legacy/warning-external-work-s.png differ diff --git a/public/images/skins/iconsets/legacy/warning-no-s.png b/public/images/skins/iconsets/legacy/warning-no-s.png new file mode 100644 index 0000000..e73d7dc Binary files /dev/null and b/public/images/skins/iconsets/legacy/warning-no-s.png differ diff --git a/public/images/skins/iconsets/legacy/warning-yes-s.png b/public/images/skins/iconsets/legacy/warning-yes-s.png new file mode 100644 index 0000000..874f8eb Binary files /dev/null and b/public/images/skins/iconsets/legacy/warning-yes-s.png differ diff --git a/public/images/skins/iconsets/legacy/warnings-death-s.png b/public/images/skins/iconsets/legacy/warnings-death-s.png new file mode 100644 index 0000000..954e190 Binary files /dev/null and b/public/images/skins/iconsets/legacy/warnings-death-s.png differ diff --git a/public/images/skins/iconsets/legacy/warnings-underage-s.png b/public/images/skins/iconsets/legacy/warnings-underage-s.png new file mode 100644 index 0000000..0fe4ffd Binary files /dev/null and b/public/images/skins/iconsets/legacy/warnings-underage-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-bookmark-public-s.png b/public/images/skins/iconsets/low_vision/lvb-bookmark-public-s.png new file mode 100644 index 0000000..3f1ed06 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-bookmark-public-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-bookmark-public.png b/public/images/skins/iconsets/low_vision/lvb-bookmark-public.png new file mode 100644 index 0000000..61317ea Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-bookmark-public.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-bookmark-rec-s.png b/public/images/skins/iconsets/low_vision/lvb-bookmark-rec-s.png new file mode 100644 index 0000000..d5586f5 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-bookmark-rec-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-bookmark-rec.png b/public/images/skins/iconsets/low_vision/lvb-bookmark-rec.png new file mode 100644 index 0000000..d03dc75 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-bookmark-rec.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-category-femslash-s.png b/public/images/skins/iconsets/low_vision/lvb-category-femslash-s.png new file mode 100644 index 0000000..c734fa7 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-category-femslash-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-category-femslash.png b/public/images/skins/iconsets/low_vision/lvb-category-femslash.png new file mode 100644 index 0000000..964b846 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-category-femslash.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-category-gen-s.png b/public/images/skins/iconsets/low_vision/lvb-category-gen-s.png new file mode 100644 index 0000000..97c30d9 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-category-gen-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-category-gen.png b/public/images/skins/iconsets/low_vision/lvb-category-gen.png new file mode 100644 index 0000000..db2fed0 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-category-gen.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-category-het-s.png b/public/images/skins/iconsets/low_vision/lvb-category-het-s.png new file mode 100644 index 0000000..e5388eb Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-category-het-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-category-het.png b/public/images/skins/iconsets/low_vision/lvb-category-het.png new file mode 100644 index 0000000..0028676 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-category-het.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-category-multi-s.png b/public/images/skins/iconsets/low_vision/lvb-category-multi-s.png new file mode 100644 index 0000000..4a2d4dd Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-category-multi-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-category-multi.png b/public/images/skins/iconsets/low_vision/lvb-category-multi.png new file mode 100644 index 0000000..2c44a1b Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-category-multi.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-category-other-s.png b/public/images/skins/iconsets/low_vision/lvb-category-other-s.png new file mode 100644 index 0000000..4378686 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-category-other-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-category-other.png b/public/images/skins/iconsets/low_vision/lvb-category-other.png new file mode 100644 index 0000000..a2973a6 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-category-other.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-category-slash-s.png b/public/images/skins/iconsets/low_vision/lvb-category-slash-s.png new file mode 100644 index 0000000..0bace86 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-category-slash-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-category-slash.png b/public/images/skins/iconsets/low_vision/lvb-category-slash.png new file mode 100644 index 0000000..01602be Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-category-slash.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-collections.png b/public/images/skins/iconsets/low_vision/lvb-collections.png new file mode 100644 index 0000000..13652e5 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-collections.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-complete-no-s.png b/public/images/skins/iconsets/low_vision/lvb-complete-no-s.png new file mode 100644 index 0000000..a2808b6 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-complete-no-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-complete-no.png b/public/images/skins/iconsets/low_vision/lvb-complete-no.png new file mode 100644 index 0000000..b10fa7c Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-complete-no.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-complete-yes-s.png b/public/images/skins/iconsets/low_vision/lvb-complete-yes-s.png new file mode 100644 index 0000000..d71edc5 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-complete-yes-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-complete-yes.png b/public/images/skins/iconsets/low_vision/lvb-complete-yes.png new file mode 100644 index 0000000..2ee7654 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-complete-yes.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-external-work-s.png b/public/images/skins/iconsets/low_vision/lvb-external-work-s.png new file mode 100644 index 0000000..4fd8bca Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-external-work-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-external-work.png b/public/images/skins/iconsets/low_vision/lvb-external-work.png new file mode 100644 index 0000000..03160d6 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-external-work.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-mystery-s.png b/public/images/skins/iconsets/low_vision/lvb-mystery-s.png new file mode 100644 index 0000000..5b34b33 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-mystery-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-mystery.png b/public/images/skins/iconsets/low_vision/lvb-mystery.png new file mode 100644 index 0000000..05f62d8 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-mystery.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-rating-explicit-s.png b/public/images/skins/iconsets/low_vision/lvb-rating-explicit-s.png new file mode 100644 index 0000000..5445daa Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-rating-explicit-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-rating-explicit.png b/public/images/skins/iconsets/low_vision/lvb-rating-explicit.png new file mode 100644 index 0000000..7358d77 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-rating-explicit.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-rating-general-audience-s.png b/public/images/skins/iconsets/low_vision/lvb-rating-general-audience-s.png new file mode 100644 index 0000000..8b9505f Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-rating-general-audience-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-rating-general-audience.png b/public/images/skins/iconsets/low_vision/lvb-rating-general-audience.png new file mode 100644 index 0000000..a44c222 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-rating-general-audience.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-rating-mature-s.png b/public/images/skins/iconsets/low_vision/lvb-rating-mature-s.png new file mode 100644 index 0000000..ba69cf7 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-rating-mature-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-rating-mature.png b/public/images/skins/iconsets/low_vision/lvb-rating-mature.png new file mode 100644 index 0000000..fdbfb54 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-rating-mature.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-rating-notrated-s.png b/public/images/skins/iconsets/low_vision/lvb-rating-notrated-s.png new file mode 100644 index 0000000..adcd6f6 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-rating-notrated-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-rating-notrated.png b/public/images/skins/iconsets/low_vision/lvb-rating-notrated.png new file mode 100644 index 0000000..662939a Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-rating-notrated.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-rating-teen-s.png b/public/images/skins/iconsets/low_vision/lvb-rating-teen-s.png new file mode 100644 index 0000000..3d8d5a5 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-rating-teen-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-rating-teen.png b/public/images/skins/iconsets/low_vision/lvb-rating-teen.png new file mode 100644 index 0000000..b6632a6 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-rating-teen.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-warning-choosenotto-s.png b/public/images/skins/iconsets/low_vision/lvb-warning-choosenotto-s.png new file mode 100644 index 0000000..e69a0fc Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-warning-choosenotto-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-warning-choosenotto.png b/public/images/skins/iconsets/low_vision/lvb-warning-choosenotto.png new file mode 100644 index 0000000..e8896ad Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-warning-choosenotto.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-warning-eschewed-s.png b/public/images/skins/iconsets/low_vision/lvb-warning-eschewed-s.png new file mode 100644 index 0000000..e38811c Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-warning-eschewed-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-warning-eschewed.png b/public/images/skins/iconsets/low_vision/lvb-warning-eschewed.png new file mode 100644 index 0000000..b7147de Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-warning-eschewed.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-warning-no-s.png b/public/images/skins/iconsets/low_vision/lvb-warning-no-s.png new file mode 100644 index 0000000..adcd6f6 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-warning-no-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-warning-no.png b/public/images/skins/iconsets/low_vision/lvb-warning-no.png new file mode 100644 index 0000000..662939a Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-warning-no.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-warning-yes-s.png b/public/images/skins/iconsets/low_vision/lvb-warning-yes-s.png new file mode 100644 index 0000000..ed27d49 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-warning-yes-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvb-warning-yes.png b/public/images/skins/iconsets/low_vision/lvb-warning-yes.png new file mode 100644 index 0000000..d41e28f Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvb-warning-yes.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-bookmark-public-s.png b/public/images/skins/iconsets/low_vision/lvy-bookmark-public-s.png new file mode 100644 index 0000000..71be02e Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-bookmark-public-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-bookmark-public.png b/public/images/skins/iconsets/low_vision/lvy-bookmark-public.png new file mode 100644 index 0000000..d54d0b0 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-bookmark-public.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-bookmark-rec-s.png b/public/images/skins/iconsets/low_vision/lvy-bookmark-rec-s.png new file mode 100644 index 0000000..58c04df Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-bookmark-rec-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-bookmark-rec.png b/public/images/skins/iconsets/low_vision/lvy-bookmark-rec.png new file mode 100644 index 0000000..ac97b00 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-bookmark-rec.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-category-femslash-s.png b/public/images/skins/iconsets/low_vision/lvy-category-femslash-s.png new file mode 100644 index 0000000..c8e1cb9 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-category-femslash-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-category-femslash.png b/public/images/skins/iconsets/low_vision/lvy-category-femslash.png new file mode 100644 index 0000000..70b3abd Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-category-femslash.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-category-gen-s.png b/public/images/skins/iconsets/low_vision/lvy-category-gen-s.png new file mode 100644 index 0000000..bd33704 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-category-gen-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-category-gen.png b/public/images/skins/iconsets/low_vision/lvy-category-gen.png new file mode 100644 index 0000000..19032e1 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-category-gen.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-category-het-s.png b/public/images/skins/iconsets/low_vision/lvy-category-het-s.png new file mode 100644 index 0000000..73fc9b9 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-category-het-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-category-het.png b/public/images/skins/iconsets/low_vision/lvy-category-het.png new file mode 100644 index 0000000..fad4ebb Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-category-het.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-category-multi-s.png b/public/images/skins/iconsets/low_vision/lvy-category-multi-s.png new file mode 100644 index 0000000..e29f082 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-category-multi-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-category-multi.png b/public/images/skins/iconsets/low_vision/lvy-category-multi.png new file mode 100644 index 0000000..59d81c9 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-category-multi.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-category-other-s.png b/public/images/skins/iconsets/low_vision/lvy-category-other-s.png new file mode 100644 index 0000000..066abe9 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-category-other-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-category-other.png b/public/images/skins/iconsets/low_vision/lvy-category-other.png new file mode 100644 index 0000000..b3ed265 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-category-other.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-category-slash-s.png b/public/images/skins/iconsets/low_vision/lvy-category-slash-s.png new file mode 100644 index 0000000..7ba4682 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-category-slash-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-category-slash.png b/public/images/skins/iconsets/low_vision/lvy-category-slash.png new file mode 100644 index 0000000..05e7d20 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-category-slash.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-collections.png b/public/images/skins/iconsets/low_vision/lvy-collections.png new file mode 100644 index 0000000..ba857dd Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-collections.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-complete-no-s.png b/public/images/skins/iconsets/low_vision/lvy-complete-no-s.png new file mode 100644 index 0000000..de03356 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-complete-no-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-complete-no.png b/public/images/skins/iconsets/low_vision/lvy-complete-no.png new file mode 100644 index 0000000..fac0a6e Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-complete-no.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-complete-yes-s.png b/public/images/skins/iconsets/low_vision/lvy-complete-yes-s.png new file mode 100644 index 0000000..1dc227a Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-complete-yes-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-complete-yes.png b/public/images/skins/iconsets/low_vision/lvy-complete-yes.png new file mode 100644 index 0000000..11db9c5 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-complete-yes.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-external-work-s.png b/public/images/skins/iconsets/low_vision/lvy-external-work-s.png new file mode 100644 index 0000000..800b0ab Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-external-work-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-external-work.png b/public/images/skins/iconsets/low_vision/lvy-external-work.png new file mode 100644 index 0000000..86cca40 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-external-work.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-mystery-s.png b/public/images/skins/iconsets/low_vision/lvy-mystery-s.png new file mode 100644 index 0000000..f7a8744 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-mystery-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-mystery.png b/public/images/skins/iconsets/low_vision/lvy-mystery.png new file mode 100644 index 0000000..e1f4b4b Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-mystery.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-rating-explicit-s.png b/public/images/skins/iconsets/low_vision/lvy-rating-explicit-s.png new file mode 100644 index 0000000..1239733 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-rating-explicit-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-rating-explicit.png b/public/images/skins/iconsets/low_vision/lvy-rating-explicit.png new file mode 100644 index 0000000..0602069 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-rating-explicit.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-rating-general-audience-s.png b/public/images/skins/iconsets/low_vision/lvy-rating-general-audience-s.png new file mode 100644 index 0000000..8ac6381 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-rating-general-audience-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-rating-general-audience.png b/public/images/skins/iconsets/low_vision/lvy-rating-general-audience.png new file mode 100644 index 0000000..f908502 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-rating-general-audience.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-rating-mature-s.png b/public/images/skins/iconsets/low_vision/lvy-rating-mature-s.png new file mode 100644 index 0000000..e7ecf48 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-rating-mature-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-rating-mature.png b/public/images/skins/iconsets/low_vision/lvy-rating-mature.png new file mode 100644 index 0000000..96751ef Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-rating-mature.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-rating-notrated-s.png b/public/images/skins/iconsets/low_vision/lvy-rating-notrated-s.png new file mode 100644 index 0000000..6492266 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-rating-notrated-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-rating-notrated.png b/public/images/skins/iconsets/low_vision/lvy-rating-notrated.png new file mode 100644 index 0000000..4153de3 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-rating-notrated.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-rating-teen-s.png b/public/images/skins/iconsets/low_vision/lvy-rating-teen-s.png new file mode 100644 index 0000000..7b369ac Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-rating-teen-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-rating-teen.png b/public/images/skins/iconsets/low_vision/lvy-rating-teen.png new file mode 100644 index 0000000..3383987 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-rating-teen.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-warning-choosenotto-s.png b/public/images/skins/iconsets/low_vision/lvy-warning-choosenotto-s.png new file mode 100644 index 0000000..34f0aa0 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-warning-choosenotto-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-warning-choosenotto.png b/public/images/skins/iconsets/low_vision/lvy-warning-choosenotto.png new file mode 100644 index 0000000..33ddf49 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-warning-choosenotto.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-warning-eschewed-s.png b/public/images/skins/iconsets/low_vision/lvy-warning-eschewed-s.png new file mode 100644 index 0000000..e6e219d Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-warning-eschewed-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-warning-eschewed.png b/public/images/skins/iconsets/low_vision/lvy-warning-eschewed.png new file mode 100644 index 0000000..bce297b Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-warning-eschewed.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-warning-no-s.png b/public/images/skins/iconsets/low_vision/lvy-warning-no-s.png new file mode 100644 index 0000000..6492266 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-warning-no-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-warning-no.png b/public/images/skins/iconsets/low_vision/lvy-warning-no.png new file mode 100644 index 0000000..4153de3 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-warning-no.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-warning-yes-s.png b/public/images/skins/iconsets/low_vision/lvy-warning-yes-s.png new file mode 100644 index 0000000..257440e Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-warning-yes-s.png differ diff --git a/public/images/skins/iconsets/low_vision/lvy-warning-yes.png b/public/images/skins/iconsets/low_vision/lvy-warning-yes.png new file mode 100644 index 0000000..db575f0 Binary files /dev/null and b/public/images/skins/iconsets/low_vision/lvy-warning-yes.png differ diff --git a/public/images/skins/iconsets/template-grids/icon-template-invert-mono.png b/public/images/skins/iconsets/template-grids/icon-template-invert-mono.png new file mode 100644 index 0000000..132d63e Binary files /dev/null and b/public/images/skins/iconsets/template-grids/icon-template-invert-mono.png differ diff --git a/public/images/skins/iconsets/template-grids/icon-template-invert.png b/public/images/skins/iconsets/template-grids/icon-template-invert.png new file mode 100644 index 0000000..be72134 Binary files /dev/null and b/public/images/skins/iconsets/template-grids/icon-template-invert.png differ diff --git a/public/images/skins/iconsets/template-grids/icon-template-mono.png b/public/images/skins/iconsets/template-grids/icon-template-mono.png new file mode 100644 index 0000000..57aa572 Binary files /dev/null and b/public/images/skins/iconsets/template-grids/icon-template-mono.png differ diff --git a/public/images/skins/iconsets/template-grids/icon-template.png b/public/images/skins/iconsets/template-grids/icon-template.png new file mode 100644 index 0000000..17f68c8 Binary files /dev/null and b/public/images/skins/iconsets/template-grids/icon-template.png differ diff --git a/public/images/skins/objects/blank-stamp.png b/public/images/skins/objects/blank-stamp.png new file mode 100644 index 0000000..46d9550 Binary files /dev/null and b/public/images/skins/objects/blank-stamp.png differ diff --git a/public/images/skins/objects/coffee-cup.png b/public/images/skins/objects/coffee-cup.png new file mode 100644 index 0000000..8e99f5b Binary files /dev/null and b/public/images/skins/objects/coffee-cup.png differ diff --git a/public/images/skins/objects/glitter-pens-l-b-matte.png b/public/images/skins/objects/glitter-pens-l-b-matte.png new file mode 100644 index 0000000..830a553 Binary files /dev/null and b/public/images/skins/objects/glitter-pens-l-b-matte.png differ diff --git a/public/images/skins/objects/glitter-pens-l-bmatte.png b/public/images/skins/objects/glitter-pens-l-bmatte.png new file mode 100644 index 0000000..447c975 Binary files /dev/null and b/public/images/skins/objects/glitter-pens-l-bmatte.png differ diff --git a/public/images/skins/objects/glitter-pens.png b/public/images/skins/objects/glitter-pens.png new file mode 100644 index 0000000..69eddba Binary files /dev/null and b/public/images/skins/objects/glitter-pens.png differ diff --git a/public/images/skins/objects/track-bike.png b/public/images/skins/objects/track-bike.png new file mode 100644 index 0000000..fc595f8 Binary files /dev/null and b/public/images/skins/objects/track-bike.png differ diff --git a/public/images/skins/previews/basic_formatting.png b/public/images/skins/previews/basic_formatting.png new file mode 100644 index 0000000..8b73d5b Binary files /dev/null and b/public/images/skins/previews/basic_formatting.png differ diff --git a/public/images/skins/previews/default.png b/public/images/skins/previews/default.png new file mode 100644 index 0000000..fa6651b Binary files /dev/null and b/public/images/skins/previews/default.png differ diff --git a/public/images/skins/previews/plaintext.png b/public/images/skins/previews/plaintext.png new file mode 100644 index 0000000..986ef69 Binary files /dev/null and b/public/images/skins/previews/plaintext.png differ diff --git a/public/images/skins/textures/tiles/aaaaa-readme.txt b/public/images/skins/textures/tiles/aaaaa-readme.txt new file mode 100644 index 0000000..154fe0f --- /dev/null +++ b/public/images/skins/textures/tiles/aaaaa-readme.txt @@ -0,0 +1,21 @@ +README + +these images are all free for commercial and noncommercial use +or have been made by ao3 +many are from +http://webtreats.mysitemyway.com/ +http://www.dinpattern.com/ (most illos) +http://elemis.iki-bir.com +http://borysses.deviantart.com/ +http://www.fuzzimo.com/ +http://www.bittbox.com +http://colorburned.com/ + +please make sure additions: +1) are under 75k +2) seamlessly tile +3) are named conventionally +4) are of excellent quality +5) do not have a restricted license + +If they don't match these criteria, they don't go in this folder. diff --git a/public/images/skins/textures/tiles/black-denim.jpg b/public/images/skins/textures/tiles/black-denim.jpg new file mode 100644 index 0000000..5af3d35 Binary files /dev/null and b/public/images/skins/textures/tiles/black-denim.jpg differ diff --git a/public/images/skins/textures/tiles/black-illo-breeze.gif b/public/images/skins/textures/tiles/black-illo-breeze.gif new file mode 100644 index 0000000..d1ba032 Binary files /dev/null and b/public/images/skins/textures/tiles/black-illo-breeze.gif differ diff --git a/public/images/skins/textures/tiles/black-metal-grid.jpg b/public/images/skins/textures/tiles/black-metal-grid.jpg new file mode 100644 index 0000000..2e9a3c0 Binary files /dev/null and b/public/images/skins/textures/tiles/black-metal-grid.jpg differ diff --git a/public/images/skins/textures/tiles/black-noise.jpg b/public/images/skins/textures/tiles/black-noise.jpg new file mode 100644 index 0000000..baa55dd Binary files /dev/null and b/public/images/skins/textures/tiles/black-noise.jpg differ diff --git a/public/images/skins/textures/tiles/black-scribble-x.png b/public/images/skins/textures/tiles/black-scribble-x.png new file mode 100644 index 0000000..2460236 Binary files /dev/null and b/public/images/skins/textures/tiles/black-scribble-x.png differ diff --git a/public/images/skins/textures/tiles/black-stripe.gif b/public/images/skins/textures/tiles/black-stripe.gif new file mode 100644 index 0000000..182d8cf Binary files /dev/null and b/public/images/skins/textures/tiles/black-stripe.gif differ diff --git a/public/images/skins/textures/tiles/black-wallpaper-charcoal.gif b/public/images/skins/textures/tiles/black-wallpaper-charcoal.gif new file mode 100644 index 0000000..2476bd1 Binary files /dev/null and b/public/images/skins/textures/tiles/black-wallpaper-charcoal.gif differ diff --git a/public/images/skins/textures/tiles/black-zebra-fur.jpg b/public/images/skins/textures/tiles/black-zebra-fur.jpg new file mode 100644 index 0000000..8f2aa77 Binary files /dev/null and b/public/images/skins/textures/tiles/black-zebra-fur.jpg differ diff --git a/public/images/skins/textures/tiles/blue-denim-1.jpg b/public/images/skins/textures/tiles/blue-denim-1.jpg new file mode 100644 index 0000000..c0f51e1 Binary files /dev/null and b/public/images/skins/textures/tiles/blue-denim-1.jpg differ diff --git a/public/images/skins/textures/tiles/blue-denim.jpg b/public/images/skins/textures/tiles/blue-denim.jpg new file mode 100644 index 0000000..6275e2a Binary files /dev/null and b/public/images/skins/textures/tiles/blue-denim.jpg differ diff --git a/public/images/skins/textures/tiles/blue-geometric.jpg b/public/images/skins/textures/tiles/blue-geometric.jpg new file mode 100644 index 0000000..f8fb110 Binary files /dev/null and b/public/images/skins/textures/tiles/blue-geometric.jpg differ diff --git a/public/images/skins/textures/tiles/blue-heartflowers.jpg b/public/images/skins/textures/tiles/blue-heartflowers.jpg new file mode 100644 index 0000000..689e425 Binary files /dev/null and b/public/images/skins/textures/tiles/blue-heartflowers.jpg differ diff --git a/public/images/skins/textures/tiles/blue-illo-ravens.gif b/public/images/skins/textures/tiles/blue-illo-ravens.gif new file mode 100644 index 0000000..e83dfb7 Binary files /dev/null and b/public/images/skins/textures/tiles/blue-illo-ravens.gif differ diff --git a/public/images/skins/textures/tiles/blue-illo-waves.gif b/public/images/skins/textures/tiles/blue-illo-waves.gif new file mode 100644 index 0000000..e49e22b Binary files /dev/null and b/public/images/skins/textures/tiles/blue-illo-waves.gif differ diff --git a/public/images/skins/textures/tiles/blue-lotus.gif b/public/images/skins/textures/tiles/blue-lotus.gif new file mode 100644 index 0000000..46ec6c2 Binary files /dev/null and b/public/images/skins/textures/tiles/blue-lotus.gif differ diff --git a/public/images/skins/textures/tiles/blue-metal-grid.jpg b/public/images/skins/textures/tiles/blue-metal-grid.jpg new file mode 100644 index 0000000..52e7905 Binary files /dev/null and b/public/images/skins/textures/tiles/blue-metal-grid.jpg differ diff --git a/public/images/skins/textures/tiles/brown-canvas.jpg b/public/images/skins/textures/tiles/brown-canvas.jpg new file mode 100644 index 0000000..f946709 Binary files /dev/null and b/public/images/skins/textures/tiles/brown-canvas.jpg differ diff --git a/public/images/skins/textures/tiles/brown-corkboard.jpg b/public/images/skins/textures/tiles/brown-corkboard.jpg new file mode 100644 index 0000000..c754996 Binary files /dev/null and b/public/images/skins/textures/tiles/brown-corkboard.jpg differ diff --git a/public/images/skins/textures/tiles/brown-fine-cardboard.jpg b/public/images/skins/textures/tiles/brown-fine-cardboard.jpg new file mode 100644 index 0000000..f059833 Binary files /dev/null and b/public/images/skins/textures/tiles/brown-fine-cardboard.jpg differ diff --git a/public/images/skins/textures/tiles/brown-grunge.jpg b/public/images/skins/textures/tiles/brown-grunge.jpg new file mode 100644 index 0000000..e0b4f9b Binary files /dev/null and b/public/images/skins/textures/tiles/brown-grunge.jpg differ diff --git a/public/images/skins/textures/tiles/brown-handmade-paper.jpg b/public/images/skins/textures/tiles/brown-handmade-paper.jpg new file mode 100644 index 0000000..3519533 Binary files /dev/null and b/public/images/skins/textures/tiles/brown-handmade-paper.jpg differ diff --git a/public/images/skins/textures/tiles/brown-hessian.jpg b/public/images/skins/textures/tiles/brown-hessian.jpg new file mode 100644 index 0000000..cfd105f Binary files /dev/null and b/public/images/skins/textures/tiles/brown-hessian.jpg differ diff --git a/public/images/skins/textures/tiles/brown-illo-octo-redux.gif b/public/images/skins/textures/tiles/brown-illo-octo-redux.gif new file mode 100644 index 0000000..c4dee4e Binary files /dev/null and b/public/images/skins/textures/tiles/brown-illo-octo-redux.gif differ diff --git a/public/images/skins/textures/tiles/brown-linen.jpg b/public/images/skins/textures/tiles/brown-linen.jpg new file mode 100644 index 0000000..fcf4dff Binary files /dev/null and b/public/images/skins/textures/tiles/brown-linen.jpg differ diff --git a/public/images/skins/textures/tiles/brown-manila-card.jpg b/public/images/skins/textures/tiles/brown-manila-card.jpg new file mode 100644 index 0000000..71c11be Binary files /dev/null and b/public/images/skins/textures/tiles/brown-manila-card.jpg differ diff --git a/public/images/skins/textures/tiles/brown-metal-grid.jpg b/public/images/skins/textures/tiles/brown-metal-grid.jpg new file mode 100644 index 0000000..3c2ce2c Binary files /dev/null and b/public/images/skins/textures/tiles/brown-metal-grid.jpg differ diff --git a/public/images/skins/textures/tiles/brown-planks-nails.jpg b/public/images/skins/textures/tiles/brown-planks-nails.jpg new file mode 100644 index 0000000..77466f2 Binary files /dev/null and b/public/images/skins/textures/tiles/brown-planks-nails.jpg differ diff --git a/public/images/skins/textures/tiles/brown-twill.jpg b/public/images/skins/textures/tiles/brown-twill.jpg new file mode 100644 index 0000000..64eba1c Binary files /dev/null and b/public/images/skins/textures/tiles/brown-twill.jpg differ diff --git a/public/images/skins/textures/tiles/brown-winchester-walls.gif b/public/images/skins/textures/tiles/brown-winchester-walls.gif new file mode 100644 index 0000000..6edfcb8 Binary files /dev/null and b/public/images/skins/textures/tiles/brown-winchester-walls.gif differ diff --git a/public/images/skins/textures/tiles/green-leather.jpg b/public/images/skins/textures/tiles/green-leather.jpg new file mode 100644 index 0000000..baeaaf8 Binary files /dev/null and b/public/images/skins/textures/tiles/green-leather.jpg differ diff --git a/public/images/skins/textures/tiles/grey-argyle.jpg b/public/images/skins/textures/tiles/grey-argyle.jpg new file mode 100644 index 0000000..98bc839 Binary files /dev/null and b/public/images/skins/textures/tiles/grey-argyle.jpg differ diff --git a/public/images/skins/textures/tiles/grey-bookswirl.jpg b/public/images/skins/textures/tiles/grey-bookswirl.jpg new file mode 100644 index 0000000..124cc84 Binary files /dev/null and b/public/images/skins/textures/tiles/grey-bookswirl.jpg differ diff --git a/public/images/skins/textures/tiles/grey-canvas.jpg b/public/images/skins/textures/tiles/grey-canvas.jpg new file mode 100644 index 0000000..f6e51f3 Binary files /dev/null and b/public/images/skins/textures/tiles/grey-canvas.jpg differ diff --git a/public/images/skins/textures/tiles/grey-corrogated.jpg b/public/images/skins/textures/tiles/grey-corrogated.jpg new file mode 100644 index 0000000..ffe8471 Binary files /dev/null and b/public/images/skins/textures/tiles/grey-corrogated.jpg differ diff --git a/public/images/skins/textures/tiles/grey-illo-skulls.gif b/public/images/skins/textures/tiles/grey-illo-skulls.gif new file mode 100644 index 0000000..66cdc5f Binary files /dev/null and b/public/images/skins/textures/tiles/grey-illo-skulls.gif differ diff --git a/public/images/skins/textures/tiles/grey-shiplap.jpg b/public/images/skins/textures/tiles/grey-shiplap.jpg new file mode 100644 index 0000000..335bce2 Binary files /dev/null and b/public/images/skins/textures/tiles/grey-shiplap.jpg differ diff --git a/public/images/skins/textures/tiles/henna-leather.jpg b/public/images/skins/textures/tiles/henna-leather.jpg new file mode 100644 index 0000000..615e412 Binary files /dev/null and b/public/images/skins/textures/tiles/henna-leather.jpg differ diff --git a/public/images/skins/textures/tiles/multi-chevrons-x.png b/public/images/skins/textures/tiles/multi-chevrons-x.png new file mode 100644 index 0000000..73fb509 Binary files /dev/null and b/public/images/skins/textures/tiles/multi-chevrons-x.png differ diff --git a/public/images/skins/textures/tiles/multi-chevrons-y.png b/public/images/skins/textures/tiles/multi-chevrons-y.png new file mode 100644 index 0000000..58d1f2a Binary files /dev/null and b/public/images/skins/textures/tiles/multi-chevrons-y.png differ diff --git a/public/images/skins/textures/tiles/multi-illo-autumn-butterflies.gif b/public/images/skins/textures/tiles/multi-illo-autumn-butterflies.gif new file mode 100644 index 0000000..0f07ac5 Binary files /dev/null and b/public/images/skins/textures/tiles/multi-illo-autumn-butterflies.gif differ diff --git a/public/images/skins/textures/tiles/pink-illo-panda-madness.gif b/public/images/skins/textures/tiles/pink-illo-panda-madness.gif new file mode 100644 index 0000000..4bf1e47 Binary files /dev/null and b/public/images/skins/textures/tiles/pink-illo-panda-madness.gif differ diff --git a/public/images/skins/textures/tiles/purple-wood-desktop.jpg b/public/images/skins/textures/tiles/purple-wood-desktop.jpg new file mode 100644 index 0000000..fa754f8 Binary files /dev/null and b/public/images/skins/textures/tiles/purple-wood-desktop.jpg differ diff --git a/public/images/skins/textures/tiles/red-ao3.png b/public/images/skins/textures/tiles/red-ao3.png new file mode 100644 index 0000000..61e9ed1 Binary files /dev/null and b/public/images/skins/textures/tiles/red-ao3.png differ diff --git a/public/images/skins/textures/tiles/red-brick.jpg b/public/images/skins/textures/tiles/red-brick.jpg new file mode 100644 index 0000000..028d082 Binary files /dev/null and b/public/images/skins/textures/tiles/red-brick.jpg differ diff --git a/public/images/skins/textures/tiles/red-illo-45-caliber.gif b/public/images/skins/textures/tiles/red-illo-45-caliber.gif new file mode 100644 index 0000000..0d9c93f Binary files /dev/null and b/public/images/skins/textures/tiles/red-illo-45-caliber.gif differ diff --git a/public/images/skins/textures/tiles/red-illo-dead-revolutionaries.gif b/public/images/skins/textures/tiles/red-illo-dead-revolutionaries.gif new file mode 100644 index 0000000..6d7bc0f Binary files /dev/null and b/public/images/skins/textures/tiles/red-illo-dead-revolutionaries.gif differ diff --git a/public/images/skins/textures/tiles/red-illo-hold-fast.gif b/public/images/skins/textures/tiles/red-illo-hold-fast.gif new file mode 100644 index 0000000..c64b4dd Binary files /dev/null and b/public/images/skins/textures/tiles/red-illo-hold-fast.gif differ diff --git a/public/images/skins/textures/tiles/red-illo-indian-summer.gif b/public/images/skins/textures/tiles/red-illo-indian-summer.gif new file mode 100644 index 0000000..5ee9ef6 Binary files /dev/null and b/public/images/skins/textures/tiles/red-illo-indian-summer.gif differ diff --git a/public/images/skins/textures/tiles/red-illo-overlyRosie.gif b/public/images/skins/textures/tiles/red-illo-overlyRosie.gif new file mode 100644 index 0000000..84a668f Binary files /dev/null and b/public/images/skins/textures/tiles/red-illo-overlyRosie.gif differ diff --git a/public/images/skins/textures/tiles/red-illo-rafters.gif b/public/images/skins/textures/tiles/red-illo-rafters.gif new file mode 100644 index 0000000..c39f033 Binary files /dev/null and b/public/images/skins/textures/tiles/red-illo-rafters.gif differ diff --git a/public/images/skins/textures/tiles/red-illo-vintage-scarlet.gif b/public/images/skins/textures/tiles/red-illo-vintage-scarlet.gif new file mode 100644 index 0000000..b02b168 Binary files /dev/null and b/public/images/skins/textures/tiles/red-illo-vintage-scarlet.gif differ diff --git a/public/images/skins/textures/tiles/transp-dot.gif b/public/images/skins/textures/tiles/transp-dot.gif new file mode 100644 index 0000000..1edaec5 Binary files /dev/null and b/public/images/skins/textures/tiles/transp-dot.gif differ diff --git a/public/images/skins/textures/tiles/transp-notebook-spiral-x.png b/public/images/skins/textures/tiles/transp-notebook-spiral-x.png new file mode 100644 index 0000000..3b95518 Binary files /dev/null and b/public/images/skins/textures/tiles/transp-notebook-spiral-x.png differ diff --git a/public/images/skins/textures/tiles/transp-notebook-spiral-y.png b/public/images/skins/textures/tiles/transp-notebook-spiral-y.png new file mode 100644 index 0000000..7147d7a Binary files /dev/null and b/public/images/skins/textures/tiles/transp-notebook-spiral-y.png differ diff --git a/public/images/skins/textures/tiles/white-cartridge-paper.jpg b/public/images/skins/textures/tiles/white-cartridge-paper.jpg new file mode 100644 index 0000000..69492f1 Binary files /dev/null and b/public/images/skins/textures/tiles/white-cartridge-paper.jpg differ diff --git a/public/images/skins/textures/tiles/white-graph-paper.jpg b/public/images/skins/textures/tiles/white-graph-paper.jpg new file mode 100644 index 0000000..ad0874d Binary files /dev/null and b/public/images/skins/textures/tiles/white-graph-paper.jpg differ diff --git a/public/images/skins/textures/tiles/white-handmade-paper.jpg b/public/images/skins/textures/tiles/white-handmade-paper.jpg new file mode 100644 index 0000000..30f2d70 Binary files /dev/null and b/public/images/skins/textures/tiles/white-handmade-paper.jpg differ diff --git a/public/images/skins/textures/tiles/white-lined-paper.jpg b/public/images/skins/textures/tiles/white-lined-paper.jpg new file mode 100644 index 0000000..6160d5c Binary files /dev/null and b/public/images/skins/textures/tiles/white-lined-paper.jpg differ diff --git a/public/images/skins/textures/tiles/white-lined.jpg b/public/images/skins/textures/tiles/white-lined.jpg new file mode 100644 index 0000000..95f2b2f Binary files /dev/null and b/public/images/skins/textures/tiles/white-lined.jpg differ diff --git a/public/images/skins/textures/tiles/yellow-leaves.jpg b/public/images/skins/textures/tiles/yellow-leaves.jpg new file mode 100644 index 0000000..3e979e2 Binary files /dev/null and b/public/images/skins/textures/tiles/yellow-leaves.jpg differ diff --git a/public/images/skins/textures/tiles/yellow-postit.jpg b/public/images/skins/textures/tiles/yellow-postit.jpg new file mode 100644 index 0000000..15d8720 Binary files /dev/null and b/public/images/skins/textures/tiles/yellow-postit.jpg differ diff --git a/public/images/skins/textures/tiles/yellow-thick-card.jpg b/public/images/skins/textures/tiles/yellow-thick-card.jpg new file mode 100644 index 0000000..7e5e4df Binary files /dev/null and b/public/images/skins/textures/tiles/yellow-thick-card.jpg differ diff --git a/public/images/skins/textures/tiles/yellow-watercolour-paper.jpg b/public/images/skins/textures/tiles/yellow-watercolour-paper.jpg new file mode 100644 index 0000000..f3ade53 Binary files /dev/null and b/public/images/skins/textures/tiles/yellow-watercolour-paper.jpg differ diff --git a/public/images/unused/bluesymbol.png b/public/images/unused/bluesymbol.png new file mode 100644 index 0000000..73fa0d9 Binary files /dev/null and b/public/images/unused/bluesymbol.png differ diff --git a/public/images/unused/drag.png b/public/images/unused/drag.png new file mode 100644 index 0000000..d22d01c Binary files /dev/null and b/public/images/unused/drag.png differ diff --git a/public/images/unused/indicator.gif b/public/images/unused/indicator.gif new file mode 100644 index 0000000..915c198 Binary files /dev/null and b/public/images/unused/indicator.gif differ diff --git a/public/images/unused/rails.png b/public/images/unused/rails.png new file mode 100644 index 0000000..43651b1 Binary files /dev/null and b/public/images/unused/rails.png differ diff --git a/public/images/unused/skin_preview_none.png b/public/images/unused/skin_preview_none.png new file mode 100644 index 0000000..7df36f3 Binary files /dev/null and b/public/images/unused/skin_preview_none.png differ diff --git a/public/images/unused/spinner.gif b/public/images/unused/spinner.gif new file mode 100644 index 0000000..878eb61 Binary files /dev/null and b/public/images/unused/spinner.gif differ diff --git a/public/images/unused/wig.png b/public/images/unused/wig.png new file mode 100644 index 0000000..913bee6 Binary files /dev/null and b/public/images/unused/wig.png differ diff --git a/public/javascripts/ao3modal.js b/public/javascripts/ao3modal.js new file mode 100644 index 0000000..3f16fae --- /dev/null +++ b/public/javascripts/ao3modal.js @@ -0,0 +1,342 @@ +/* + +THIS FILE GETS MINIFIED! It is included as "ao3modal.min.js". +USE A MINIFIER AND UPDATE THE .min.js FILE AFTER MAKING ANY CHANGES HERE! + +*/ + +jQuery(document).ready(function() { + window.ao3modal = (function($) { + + var _loading = false, + _bgDiv = $('
    ', {'id': 'modal-bg'}).addClass('modal-closer'), + _loadingDiv = $('
    ').addClass('loading'), + _wrapDiv = $('
    ', {'id': 'modal-wrap'}).addClass('modal-closer'), + _modalDiv = $('
    ', {'id': 'modal'}), + _contentDiv = $('
    ').addClass('content userstuff'), + _closeButton = $('').addClass('action modal-closer') + .click(function(event) { event.preventDefault(); }) + .attr('href', '#') + .text('Close'), + _title = $('').addClass('title'), + _tallHeight, + _mobile = (function(uastring) { // detectmobilebrowsers.com (added ipad to regex) + return /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(ad|hone|od)|iris|kindle|lge |maemo|midp|mmp|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows (ce|phone)|xda|xiino/i.test(uastring) || + /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(uastring.substr(0,4)); + })(navigator.userAgent||navigator.vendor||window.opera), + _mobileScrollTop = -1, // for returning to previous page scroll position after closing modal + _scrollbarWidth = _mobile ? 0 : (function() { // used as a margin when scrollbar is hidden + var inner = $('

    ').css({'width': '100%', 'height': 200}), + outer = $('

    ').css({ + 'height': 150, + 'left': 0, + 'overflow': 'hidden', + 'top': 0, + 'visibility': 'hidden', + 'width': 200 + }).append(inner).appendTo($('body')), + w1 = inner[0].offsetWidth, + w2; + + outer.css('overflow', 'scroll'); + w2 = inner[0].offsetWidth; + if (w1 == w2) w2 = outer[0].clientWidth; + + outer.remove(); + return (w1 - w2); + })(), + _keyboard; + + function _toggleScroll(on) { + if (_mobile) { return; } + $('body').css({ + 'margin-right': on ? '' : _scrollbarWidth, + 'overflow': on ? '' : 'hidden', + 'height': on ? '' : _bgDiv.height() + }); + } + + function _calcSize() { + var img, scaledHeight, maxHeight, + hidden = !_modalDiv.is(':visible'); + + if (_mobile && _mobileScrollTop < 0) { + _mobileScrollTop = $(window).scrollTop(); + } + + if (hidden) { _modalDiv.css('opacity', 0).show(); } + + _modalDiv.removeClass('tall'); + + if (_modalDiv.hasClass('img')) { + img = _contentDiv.children('img').first(); + + _modalDiv.toggleClass('tall', _modalDiv.height() >= 0.95*_bgDiv.height()); + } else if (!_mobile) { + scaledHeight = _bgDiv.height()*_tallHeight.scale; + maxHeight = Math.min(scaledHeight, _tallHeight.max); + + _modalDiv.toggleClass('tall', (!maxHeight || _modalDiv[0].scrollHeight > maxHeight)); + } + + if (hidden) { _modalDiv.hide().css('opacity', ''); } + + if (_mobile) { + _wrapDiv.css('top', _mobileScrollTop); + } else { + _wrapDiv.css('top', $(window).scrollTop()); + } + } + + function _setContent(content, title) { + _loading = false; + + _contentDiv.empty(); + if (content instanceof $) { _contentDiv.append(content); } + else { _contentDiv.html(content); } + + title = (title instanceof $) ? title : $('').addClass('title').text(title || ''); + _title.replaceWith(title); + _title = title; + + if (!_contentDiv.is(':empty')) { + _calcSize(); + + if (!_contentDiv.is(':visible')) { + _keyboard.toggleHandlers(true); + _loadingDiv.hide(); + _modalDiv.fadeIn(function() { + _contentDiv.focus(); + }); + } + } + } + + function _show(href, title) { + _modalDiv.hide(); + _wrapDiv.show(); + if ($.support.opacity) { _bgDiv.add(_loadingDiv).fadeIn(); } + else { _bgDiv.add(_loadingDiv).show(); } + + _toggleScroll(false); + + _loading = true; + + if (href.indexOf('#') === 0) { + _setContent($(href).html(), title || ''); + } else if (href.indexOf('.jpg') == href.length-'.jpg'.length || + href.indexOf('.gif') == href.length-'.gif'.length || + href.indexOf('.bmp') == href.length-'.bmp'.length || + href.indexOf('.png') == href.length-'.png'.length) { + + _modalDiv.addClass('img'); + + var img = $('').appendTo($('body')).attr('src', href), + imgLoad = function() { + + var srcLink = $('').addClass('title') + .attr({'href': href, 'title': 'View original', 'target': '_blank'}) + .text(title || + (href.indexOf('/') != -1 ? href.substring(href.lastIndexOf('/')+1) : href) + ).css('text-decoration', 'underline'); + + img.remove(); + if (!_loading) { return; } + + if (title) { img.attr('alt', title); } + + _setContent(img[0], srcLink); + }; + + if (img[0].complete) { imgLoad(); } + else { img.load(imgLoad); } + } else { + $.ajax({ + url: href, + success: function(data) { + if (!_loading) { return; } + _setContent(data, title); + } + }); + } + } + + function _hide() { + _loading = false; + _title.text(''); + + _keyboard.toggleHandlers(false); + + _wrapDiv.fadeOut(function() { + _setContent(''); + _toggleScroll(true); + _modalDiv.css('width', '').removeClass('tall img'); + if (_mobile && _mobileScrollTop >= 0) { + $('html, body').animate({'scrollTop': _mobileScrollTop}, 'fast'); + _mobileScrollTop = -1; + } + }); + + if ($.support.opacity) { _bgDiv.fadeOut(); } + else { _bgDiv.hide(); } + } + + function _addLink(elements) { + elements.each(function() { + var img = $(this).is('img') ? $(this).removeClass('modal') : false, + a = !img ? $(this) : $('') + .css('border', 'none') + .attr({ + 'title': (img.attr('title') || img.attr('alt')), + 'href': img.attr('src') + }).replaceAll(img) + .append(img); + + a.addClass('modal modal-attached') + .attr('aria-controls', 'modal') + .filter(function() { + return $(this).closest('.userstuff').length === 0; + }).click(function(event){ + _show($(this).attr('href'), $(this).attr('title')); + event.preventDefault(); + }); + + // if link refers to in-page modal content, find it and hide it + if (a.attr('href').indexOf('#') === 0) { + $(a.attr('href')).hide(); + } + }); + } + + // add modal elements to page + $('body').append( + _bgDiv.append(_loadingDiv), + _wrapDiv.append( + _modalDiv.append( + _contentDiv, + $('
    ').addClass('footer') + .append( + _title, + _closeButton + ) + ).trap() + ) + ); + + // find the height scale and max-height of modal windows whose content is taller than the viewport + // values should come from the css ruleset for #modal.tall + _tallHeight = _mobile ? null : (function() { + var heights = {}, modalDiv = _modalDiv.clone(); + + modalDiv.css('margin-top', 9001).addClass('tall').appendTo(_bgDiv); + heights.scale = !modalDiv.height() ? 0.8 : parseInt(modalDiv.css('height'))/100; + heights.max = parseInt(modalDiv.css('max-height')); + + modalDiv.remove(); + return heights; + })(); + + // enable exit controls + $('body').click(function(event) { + if ($(event.target).hasClass('modal-closer') || + (_modalDiv.hasClass('img') && $(event.target).hasClass('content'))) { + _hide(); + } + }); + + // recalculate modal size on viewport resize + $(window).resize(function() { + if (_modalDiv.is(':visible')) { _calcSize(); } + }); + + // set up keyboard handling + _keyboard = (function() { + var scrollValues = { + 38: -20, // up arrow + 40: 20, // down arrow + 33: -200, // page up + 34: 200 // page down + }, + tabbed = false, + handlers = { + 'keydown': function(event) { + if (scrollValues[event.which]) { + _contentDiv[0].scrollTop += scrollValues[event.which]; + event.preventDefault(); + } else if (_modalDiv.is(':visible')) { + var target = event.target, + tag = target.tagName.toLowerCase(), + escKey = event.which == 27, + enterKey = event.which == 13, + targetIsInput = /^(input|textarea|a|button)$/.test(tag), + // ignore enter keypresses on links & inputs + targetInModal = !!$(target).closest('#modal')[0]; + + if (escKey || enterKey && (!targetInModal || !targetIsInput)) { + _hide(); + } + + // key events triggered from outside the modal should also die, + // except for ctrl combinations like ctrl+c (or cmd+c on macOS) + var keyShortcut = event.ctrlKey || event.metaKey; + if (escKey || (!targetInModal && !keyShortcut) || enterKey && !targetIsInput) { + event.preventDefault(); + event.stopPropagation(); + } + } + }, + 'keypress': function(event) { + if (scrollValues[event.which]) { event.preventDefault(); } + }, + 'keyup': function(event) { + if (scrollValues[event.which]) { event.preventDefault(); } + else if (event.which == 9 && !tabbed) { + _closeButton.focus(); + tabbed = true; + event.preventDefault(); + } + } + }; + + return { + toggleHandlers: function(on) { + tabbed = false; + for (var eventType in handlers) { + if (handlers.hasOwnProperty(eventType)) { + if (on) { + $('body').on(eventType, handlers[eventType]); + } else { + $('body').off(eventType, handlers[eventType]); + } + } + } + } + }; + })(); + + // set modal-classed links to open modal boxes + _addLink($('a.modal, img.modal')); + + // ensure handlers are attached to dynamically added modal invokers + $('body').on('click', 'a.modal, img.modal', function(event) { + var $this = $(this); + if ($this.is('.modal-attached')) { return; } + _addLink($this); + event.preventDefault(); + if ($this.is('img')) { + $this.parent().click(); + } else { + $this.click(); + } + }); + + return { + show: _show, + setContent: _setContent, + hide: _hide, + addLink: _addLink + }; + + })(jQuery); + +}); diff --git a/public/javascripts/ao3modal.min.js b/public/javascripts/ao3modal.min.js new file mode 100644 index 0000000..612e712 --- /dev/null +++ b/public/javascripts/ao3modal.min.js @@ -0,0 +1 @@ +jQuery(document).ready(function(){window.ao3modal=function(t){var e,a,i,o,s,l,n,d,c,r,p,h,m=!1,$=t("
    ",{id:"modal-bg"}).addClass("modal-closer"),f=t("
    ").addClass("loading"),g=t("
    ",{id:"modal-wrap"}).addClass("modal-closer"),u=t("
    ",{id:"modal"}),v=t("
    ").addClass("content userstuff"),w=t("").addClass("action modal-closer").click(function(t){t.preventDefault()}).attr("href","#").text("Close"),b=t("").addClass("title"),_=(l=navigator.userAgent||navigator.vendor||window.opera,/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(ad|hone|od)|iris|kindle|lge |maemo|midp|mmp|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows (ce|phone)|xda|xiino/i.test(l)||/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(l.substr(0,4))),k=-1,y=_?0:(d=t("

    ").css({width:"100%",height:200}),c=t("

    ").css({height:150,left:0,overflow:"hidden",top:0,visibility:"hidden",width:200}).append(d).appendTo(t("body")),r=d[0].offsetWidth,c.css("overflow","scroll"),r==(n=d[0].offsetWidth)&&(n=c[0].clientWidth),c.remove(),r-n);function x(e){!_&&t("body").css({"margin-right":e?"":y,overflow:e?"":"hidden",height:e?"":$.height()})}function C(){var e,a,i,o=!u.is(":visible");_&&k<0&&(k=t(window).scrollTop()),o&&u.css("opacity",0).show(),u.removeClass("tall"),u.hasClass("img")?(e=v.children("img").first(),u.toggleClass("tall",u.height()>=.95*$.height())):_||(i=Math.min(a=$.height()*p.scale,p.max),u.toggleClass("tall",!i||u[0].scrollHeight>i)),o&&u.hide().css("opacity",""),_?g.css("top",k):g.css("top",t(window).scrollTop())}function z(e,a){m=!1,v.empty(),e instanceof t?v.append(e):v.html(e),a=a instanceof t?a:t("").addClass("title").text(a||""),b.replaceWith(a),b=a,v.is(":empty")||(C(),v.is(":visible")||(h.toggleHandlers(!0),f.hide(),u.fadeIn(function(){v.focus()})))}function O(e,a){if(u.hide(),g.show(),t.support.opacity?$.add(f).fadeIn():$.add(f).show(),x(!1),m=!0,0===e.indexOf("#"))z(t(e).html(),a||"");else if(e.indexOf(".jpg")==e.length-4||e.indexOf(".gif")==e.length-4||e.indexOf(".bmp")==e.length-4||e.indexOf(".png")==e.length-4){u.addClass("img");var i=t("").appendTo(t("body")).attr("src",e),o=function(){var o=t("").addClass("title").attr({href:e,title:"View original",target:"_blank"}).text(a||(-1!=e.indexOf("/")?e.substring(e.lastIndexOf("/")+1):e)).css("text-decoration","underline");i.remove(),m&&(a&&i.attr("alt",a),z(i[0],o))};i[0].complete?o():i.load(o)}else t.ajax({url:e,success:function(t){m&&z(t,a)}})}function D(){m=!1,b.text(""),h.toggleHandlers(!1),g.fadeOut(function(){z(""),x(!0),u.css("width","").removeClass("tall img"),_&&k>=0&&(t("html, body").animate({scrollTop:k},"fast"),k=-1)}),t.support.opacity?$.fadeOut():$.hide()}function j(e){e.each(function(){var e=!!t(this).is("img")&&t(this).removeClass("modal"),a=e?t("").css("border","none").attr({title:e.attr("title")||e.attr("alt"),href:e.attr("src")}).replaceAll(e).append(e):t(this);a.addClass("modal modal-attached").attr("aria-controls","modal").filter(function(){return 0===t(this).closest(".userstuff").length}).click(function(e){O(t(this).attr("href"),t(this).attr("title")),e.preventDefault()}),0===a.attr("href").indexOf("#")&&t(a.attr("href")).hide()})}return t("body").append($.append(f),g.append(u.append(v,t("
    ").addClass("footer").append(b,w)).trap())),p=_?null:(e={},(a=u.clone()).css("margin-top",9001).addClass("tall").appendTo($),e.scale=a.height()?parseInt(a.css("height"))/100:.8,e.max=parseInt(a.css("max-height")),a.remove(),e),t("body").click(function(e){(t(e.target).hasClass("modal-closer")||u.hasClass("img")&&t(e.target).hasClass("content"))&&D()}),t(window).resize(function(){u.is(":visible")&&C()}),h=(i={38:-20,40:20,33:-200,34:200},o=!1,s={keydown:function(e){if(i[e.which])v[0].scrollTop+=i[e.which],e.preventDefault();else if(u.is(":visible")){var a=e.target,o=a.tagName.toLowerCase(),s=27==e.which,l=13==e.which,n=/^(input|textarea|a|button)$/.test(o),d=!!t(a).closest("#modal")[0];(s||l&&(!d||!n))&&D();var c=e.ctrlKey||e.metaKey;!s&&(d||c)&&(!l||n)||(e.preventDefault(),e.stopPropagation())}},keypress:function(t){i[t.which]&&t.preventDefault()},keyup:function(t){i[t.which]?t.preventDefault():9!=t.which||o||(w.focus(),o=!0,t.preventDefault())}},{toggleHandlers:function(e){for(var a in o=!1,s)s.hasOwnProperty(a)&&(e?t("body").on(a,s[a]):t("body").off(a,s[a]))}}),j(t("a.modal, img.modal")),t("body").on("click","a.modal, img.modal",function(e){var a=t(this);!a.is(".modal-attached")&&(j(a),e.preventDefault(),a.is("img")?a.parent().click():a.click())}),{show:O,setContent:z,hide:D,addLink:j}}(jQuery)}); diff --git a/public/javascripts/application.js b/public/javascripts/application.js new file mode 100644 index 0000000..fe43d74 --- /dev/null +++ b/public/javascripts/application.js @@ -0,0 +1,641 @@ +// Place your application-specific JavaScript functions and classes here +// This file is automatically included by javascript_include_tag :defaults + +//things to do when the page loads +$j(document).ready(function() { + setupToggled(); + if ($j('form#work-form')) { hideFormFields(); } + hideHideMe(); + showShowMe(); + handlePopUps(); + attachCharacterCounters(); + setupAccordion(); + setupDropdown(); + updateCachedTokens(); + + // add clear to items on the splash page in older browsers + $j('.splash').children('div:nth-of-type(odd)').addClass('odd'); + + // make Share buttons on works and own bookmarks visible + $j('.actions').children('.share').removeClass('hidden'); + + // make Approve buttons on inbox items visible + $j('#inbox-form, .messages').find('.unreviewed').find('.review').find('a').removeClass('hidden'); + + prepareDeleteLinks(); + thermometer(); + $j('body').addClass('javascript'); +}); + +/////////////////////////////////////////////////////////////////// +// Autocomplete +/////////////////////////////////////////////////////////////////// + +function get_token_input_options(self) { + return { + searchingText: self.data('autocomplete-searching-text'), + hintText: self.data('autocomplete-hint-text'), + noResultsText: self.data('autocomplete-no-results-text'), + minChars: self.data('autocomplete-min-chars'), + queryParam: "term", + preventDuplicates: true, + tokenLimit: self.data('autocomplete-token-limit'), + liveParams: self.data('autocomplete-live-params'), + makeSortable: self.data('autocomplete-sortable') + }; +} + +// Look for autocomplete_options in application helper and throughout the views to +// see how to use this! +var input = $j('input.autocomplete'); +if (input.livequery) { + jQuery(function($) { + $('input.autocomplete').livequery(function(){ + var self = $(this); + var token_input_options = get_token_input_options(self); + var method; + try { + method = $.parseJSON(self.data('autocomplete-method')); + } catch (err) { + method = self.data('autocomplete-method'); + } + self.tokenInput(method, token_input_options); + }); + }); +} + +/////////////////////////////////////////////////////////////////// + +// expand, contract, shuffle +jQuery(function($){ + $(".expand").each(function(){ + // start by hiding the list in the page + list = $($(this).data("action-target")); + if (!list.data("force-expand") || list.children().size() > 25 || list.data("force-contract")) { + list.hide(); + $(this).show(); + } else { + // show the shuffle and contract button only + $(this).nextAll(".shuffle").show(); + $(this).next(".contract").show(); + } + + // set up click event to expand the list + $(this).click(function(event){ + list = $($(this).data("action-target")); + list.show(); + + // show the contract & shuffle buttons and hide us + $(this).next(".contract").show(); + $(this).nextAll(".shuffle").show(); + $(this).hide(); + }); + }); + + $(".contract").each(function(){ + $(this).click(function(event){ + // hide the list when clicked + list = $($(this).data("action-target")); + list.hide(); + + // show the expand and shuffle buttons and hide us + $(this).prev(".expand").show(); + $(this).nextAll(".shuffle").hide(); + $(this).hide(); + }); + }); + + $(".shuffle").each(function(){ + // shuffle the list's children when clicked + $(this).click(function(event){ + list = $($(this).data("action-target")); + list.children().shuffle(); + }); + }); + + $(".expand_all").each(function(){ + target = "." + $(this).data("target-class"); + $(this).click(function(event) { + $(this).closest(target).find(".expand").click(); + }); + }); + + $(".contract_all").each(function(){ + target = "." + $(this).data("target-class"); + $(this).click(function(event) { + $(this).closest(target).find(".contract").click(); + }); + }); +}); + +// check all or none within the parent fieldset, optionally with a string to match on the id attribute of the checkboxes +// stored in the "data-checkbox-id-filter" attribute on the all/none links. +// allow for some flexibility by checking the next and previous fieldset if the checkboxes aren't in this one +jQuery(function($){ + $('.check_all').each(function(){ + $(this).click(function(event){ + var filter = $(this).data('checkbox-id-filter'); + var checkboxes; + if (filter) { + checkboxes = $(this).closest('fieldset').find('input[id*="' + filter + '"][type="checkbox"]'); + } else { + checkboxes = $(this).closest("fieldset").find(':checkbox'); + if (checkboxes.length == 0) { + checkboxes = $(this).closest("fieldset").next().find(':checkbox'); + if (checkboxes.length == 0) { + checkboxes = $(this).closest("fieldset").prev().find(':checkbox'); + } + } + } + checkboxes.prop('checked', true); + event.preventDefault(); + }); + }); + + $('.check_none').each(function(){ + $(this).click(function(event){ + var filter = $(this).data('checkbox-id-filter'); + var checkboxes; + if (filter) { + checkboxes = $(this).closest('fieldset').find('input[id*="' + filter + '"][type="checkbox"]'); + } else { + checkboxes = $(this).closest("fieldset").find(':checkbox'); + if (checkboxes.length == 0) { + checkboxes = $(this).closest("fieldset").next().find(':checkbox'); + if (checkboxes.length == 0) { + checkboxes = $(this).closest("fieldset").prev().find(':checkbox'); + } + } + } + checkboxes.prop('checked', false); + event.preventDefault(); + }); + }); +}); + +// Set up open and close toggles for a given object +// Typical setup (this will leave the toggled item open for users without javascript but hide the controls from them): +// +//
    +// foo! +// +//
    +// +// Notes: +// - The open button CANNOT be inside the toggled div, the close button can be (but doesn't have to be) +// - You can have multiple open and close buttons for the same div since those are labeled with classes +// - You don't have to use div and a, those are just examples. Anything you put the toggled and _open/_close classes on will work. +// - If you want the toggled item not to be visible to users without JavaScript by default, add the class "hidden" to the toggled item as well. +// (and you can then add an alternative link for them using
    ";$tp=$(i);if(b.timeOnly===true){$tp.prepend('
    '+'
    '+b.timeOnlyTitle+"
    "+"
    ");a.find(".ui-datepicker-header, .ui-datepicker-calendar").hide()}this.hour_slider=$tp.find("#ui_tpicker_hour_"+g).slider({orientation:"horizontal",value:this.hour,min:b.hourMin,max:d,step:b.stepHour,slide:function(a,b){c.hour_slider.slider("option","value",b.value);c._onTimeChange()}});this.minute_slider=$tp.find("#ui_tpicker_minute_"+g).slider({orientation:"horizontal",value:this.minute,min:b.minuteMin,max:e,step:b.stepMinute,slide:function(a,b){c.minute_slider.slider("option","value",b.value);c._onTimeChange()}});this.second_slider=$tp.find("#ui_tpicker_second_"+g).slider({orientation:"horizontal",value:this.second,min:b.secondMin,max:f,step:b.stepSecond,slide:function(a,b){c.second_slider.slider("option","value",b.value);c._onTimeChange()}});this.timezone_select=$tp.find("#ui_tpicker_timezone_"+g).append("").find("select");$.fn.append.apply(this.timezone_select,$.map(b.timezoneList,function(a,b){return $("