commit 5fba9fe725b6d2b9e59c424b7f68fa54427a91d2
Author: aggie Sentry for APM/application monitoring. |<\/p>\z| by #{byline(work, { visibility: 'public', full_path: true })} Words: #{work.word_count}, Chapters: #{chapter_total_display(work)}, Language: #{work.language ? work.language.name : 'English'} Series: #{series_list_for_feeds(work)} by #{byline(work, { visibility: 'public', full_path: true })} Words: #{work.word_count}, Chapters: #{chapter_total_display(work)}, Language: #{work.language ? work.language.name : 'English'} Series: #{series_list_for_feeds(work)} 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 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.
Slack for communications.
")
+ 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): ") + "" + errors.join("") + "
"
+ end
+ flash[:notice] = "" unless new_collections.empty? && unapproved_collections.empty?
+ unless new_collections.empty?
+ flash[:notice] = ts("Added to collection(s): %{collections}.",
+ collections: new_collections.collect(&:title).join(", "))
+ end
+ unless invited_collections.empty?
+ invited_collections.each do |needs_user_approval|
+ flash[:notice] ||= ""
+ flash[:notice] = t(".invited_to_collections_html",
+ invited_link: view_context.link_to(t(".invited"),
+ collection_items_path(needs_user_approval, status: :unreviewed_by_user)),
+ collection_title: needs_user_approval.title)
+ end
+ end
+ unless unapproved_collections.empty?
+ flash[:notice] ||= ""
+ flash[:notice] += ts(" You have submitted your work to #{unapproved_collections.size > 1 ? "moderated collections (%{all_collections}). It will not become a part of those collections" : "the moderated collection '%{all_collections}'. It will not become a part of the collection"} until it has been approved by a moderator.", all_collections: unapproved_collections.map { |f| f.title }.join(', '))
+ end
+
+ flash[:notice] = (flash[:notice]).html_safe unless flash[:notice].blank?
+ flash[:error] = (flash[:error]).html_safe unless flash[:error].blank?
+
+ redirect_to(@item)
+ end
+
+ def update_multiple
+ if @collection&.user_is_maintainer?(current_user)
+ update_multiple_with_params(
+ allowed_items: @collection.collection_items,
+ update_params: collection_update_multiple_params,
+ success_path: collection_items_path(@collection)
+ )
+ elsif @user && @user == current_user
+ update_multiple_with_params(
+ allowed_items: CollectionItem.for_user(@user),
+ update_params: user_update_multiple_params,
+ success_path: user_collection_items_path(@user)
+ )
+ else
+ flash[:error] = ts("You don't have permission to do that, sorry!")
+ redirect_to(@collection || @user)
+ end
+ end
+
+ # The main work performed by update_multiple. Uses the passed-in parameters
+ # to update, and only updates items that can be found in allowed_items (which
+ # should be a relation on CollectionItems). When all items are successfully
+ # updated, redirects to success_path.
+ def update_multiple_with_params(allowed_items:, update_params:, success_path:)
+ # Collect any failures so that we can display errors:
+ @collection_items = []
+
+ # Make sure that the keys are integers so that we can look up the
+ # parameters by ID.
+ update_params.transform_keys!(&:to_i)
+
+ # By using where() here and updating each item individually, instead of
+ # using allowed_items.update(update_params.keys, update_params.values) --
+ # which uses find() under the hood -- we ensure that we'll fail silently if
+ # the user tries to update an item they're not allowed to.
+ allowed_items.where(id: update_params.keys).each do |item|
+ item_data = update_params[item.id]
+ if item_data[:remove] == "1"
+ next unless item.user_allowed_to_destroy?(current_user)
+
+ @collection_items << item unless item.destroy
+ else
+ @collection_items << item unless item.update(item_data)
+ end
+ end
+
+ if @collection_items.empty?
+ flash[:notice] = ts("Collection status updated!")
+ redirect_to success_path
+ else
+ render action: "index"
+ end
+ end
+
+ private
+
+ def user_update_multiple_params
+ allowed = %i[user_approval_status remove]
+ params.slice(:collection_items).permit(collection_items: allowed).
+ require(:collection_items)
+ end
+
+ def collection_update_multiple_params
+ allowed = %i[collection_approval_status unrevealed anonymous remove]
+ params.slice(:collection_items).permit(collection_items: allowed).
+ require(:collection_items)
+ end
+end
diff --git a/app/controllers/collection_participants_controller.rb b/app/controllers/collection_participants_controller.rb
new file mode 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| "#{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:
+ #
+ ".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-
/, "\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(/
\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 << %()
+ 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| "
).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 = ""
+ %w(Fandom Rating ArchiveWarning Category Character Relationship Freeform).each do |type|
+ if tags[type]
+ 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 = ""
+ %w(Fandom Rating ArchiveWarning Category Character Relationship Freeform).each do |type|
+ if tags[type]
+ 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("
Contact:
', "" + contact.gsub! /<\/?(span|i)>/, "" + contact.delete! "\n" + contact.gsub! "+ <%= 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(".include.intro") %> + +
+ ++ <%= 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") %> +
+| <%= 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) %> | +
<%= submit_tag t(".search") %>
+<% end %> + +<% if @user %> +<%= 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. %> +| <%= 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") %> | +
| <%= ts("Username") %> | +<%= ts("Email") %> | + <% for role in @roles %> +<%= role.name.try(:titleize) %> | + <% end %> +<%= ts("Fannish Next of Kin") %> | +<%= ts("Update") %> | +<%= ts("Details") %> | +
|---|---|---|---|---|---|
<%= ts("Please enter a list of email addresses to search below. This form will search for exact matches.").html_safe %>
+<%= 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? %> +<%= @results[:not_found_emails].join(", ") %>
+ <% end %> + + <% if @users %> + <%= render "user_table", users: @users %> + <% end %> + <% end %> ++ <%= 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(".no_creations") %>
+ <% else %> + <%= render "user_creations_summary", works: @works, comments: @comments %> + <% end %> + ++ <%= ts("Search for users with matching usernames or pseuds.") %> +
++ <%= ts("Search for user with exact ID.") %> +
+<%= submit_tag ts("Find") %>
+ <% end %> + + <% if @users %> +| <%= ts("Username") %> | +<%= ts("Email") %> | + <% for role in @roles %> +<%= role.name.try(:titleize) %> | + <% end %> +<%= ts("Fannish Next of Kin") %> | +<%= ts("Update") %> | +<%= ts("Details") %> | +
|---|---|---|---|---|---|
<%= t(".note") %>
+<%= submit_tag t(".fnok.form.submit") %>
+ <% end %> + <% end %> + +* <%= ts("Required information") %>
+ + + +<% 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 @@ +<%= t(".search_by_name") %>
+<%= submit_tag t(".actions.find") %>
+ <% end %> + +| <%= 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 %> | +
+
|
+
<%= ts('* Required information') %>
+ + + + + +<% 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('Are you sure you want to delete this banner?').html_safe %> +
++ <%= f.submit ts('Yes, Delete Banner') %> +
+ <% end %> + +<%= 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(".describedby.password_length", + minimum: ArchiveConfig.ADMIN_PASSWORD_LENGTH_MIN, + maximum: ArchiveConfig.ADMIN_PASSWORD_LENGTH_MAX) %> +
+<%= 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("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 @@ + +<%= 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(".fields.account_age_threshold_for_comment_spam_check_note") %>
+<%= f.submit t(".update") %>
++ <%= 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 @@ +| <%= 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 %> + + + + + +| <%= 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 %> + | +
<%= 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 %> +
+<%= submit_tag ts("Update") %>
+ +<% end %> +| <%= 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 %> | + <% skin_name = skin.title.downcase.gsub(/ +/, '_') %> +<%= check_box_tag "make_unrejected[]", skin.id, false, id: "make_unrejected_#{skin_name}", disabled: disabled %> | +
<%= submit_tag ts('Update') %>
+ +<% end %> + + + + + +| <%= 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" %> + | +
+ <%= t(".choose") %> +
++ <%= 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"] %> +<%= 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("Created at %{created_date} and updated at %{updated_date}", :created_date => admin_post.created_at, :updated_date => admin_post.updated_at) %>
+<%= ts("All news posts need a title and some content.") %>
+ <%= error_messages_for @admin_post %> ++ <%= allowed_html_instructions %> + <%= ts("Type or paste formatted text.") %><%= link_to_help "rte-help" %> +
+ <% 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("Comma separated, %{max} characters per tag", max: ArchiveConfig.TAG_MAX) %> +
+ <% end %> +<%= t(".translated_post.footnote_comment_permissions") %>
+<%= t(".translated_post.footnote_tags") %>
+ <% end %> ++ <%= submit_tag ts('Post'), :name => 'post_button' %> +
+<%= 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 @@ + +<%= t(".confidentiality_reminder") %>
+ +<%= t(".responsibility") %>
+ +<%= t(".roles.none") %>
+ <% end %> + +<%= t(".log_out_reminder") %>
+<%= t(".created_date", date_created: l(archive_faq.created_at)) %>
+ +<%= ts("* Required information") %>
++ <%= link_to_add_section("Add Question", form, :questions, "question_answer_fields") %> +
+ <% end %> ++ <%= submit_tag ts("Post"), name: "post_button" %> +
+<%= submit_tag "Update Positions" %>
+ <% end %> +<%= submit_tag "Update Positions" %>
+ <% end %> ++ <%= 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? %> +<%= 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 @@ ++ <% removetext = ts("Remove Question") %> + <%= link_to_remove_section(removetext, form) %> + <% # TODO: DELETE QUESTION WITHOUT JAVASCRIPT %> + +
+ <% end %> + +<%= 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 @@ ++ <%= t(".elasticsearch_update_notice_html", elasticsearch_news_link: link_to(t(".elasticsearch_news"), admin_post_path(10_575))) %> +
+<% end %> + +<%= t(".no_category_entries") %>
+ <% else %> ++ <%= t(".screencast") %> <%= link_to q.question, q.screencast.to_s %> +
+ <% end %> + <%= raw sanitize_field(q, :content) %> + <% end %> ++ <%= t(".sure_html", block: tag.strong(t(".block")), username: @blocked.login) %> + <%= t(".will.intro") %> +
+ +<%= t(".will_not.intro") %>
+ +<%= 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(".sure_html", unblock: tag.strong(t(".unblock")), username: @block.blocked.login) %> + <%= t(".resume.intro") %> +
+ ++ <%= 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(".will.intro", block_limit: number_with_delimiter(ArchiveConfig.MAX_BLOCKED_USERS), count: ArchiveConfig.MAX_BLOCKED_USERS) %>
+ +<%= t(".will_not.intro") %>
+ +<%= t(".mute_users_instead_html", muted_users_link: link_to(t(".muted_users_link_text"), user_muted_users_path(@user))) %>
++ <%= 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") %> +
+<%= 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}") %> ++ <%= 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 %> + +<%= set_format_for_date(bookmark.created_at) %>
++ <%= get_symbol_for_bookmark(bookmark) %> +
++ <%= 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 %> +
* <%= ts('Required information') %>
+ <% end %> ++ <% if dynamic %> + × + <% else %> + × + <% end %> +
+ <% end %> + ++ <% 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) %> ++ <%= 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.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 %> +
++ <%= 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 %> + + +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("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) %> +
++ <%= link_to ts("Clear Filters"), bookmarks_original_path %> +
+ <% end %> + +<%= f.submit ts("Search Bookmarks") %>
++ <% 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 @@ + +<%= 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("No results found. You may want to edit your search to make it less specific.") %>
+<% else %> +<%= t(".no_sign_ups_yet") %>
+<% else %> + <%= will_paginate(@challenge_signups) %> + ++ <%= 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('Listed by fewest offers and most requests.') %> +
+| <%= tag_type_label_name(tag_type) %> | +<%= ts('Requests') %> | +<%= ts('Offers') %> | +
|---|---|---|
| + + <%= tag.name %> + + | +<%= tag.requests.to_i %> | +<%= tag.offers.to_i %> | +
(Participants can override)
++ <%= 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("Explain to your members how you want them to sign up.") %> <%= allowed_html_instructions %> +
+<%= ts("You can change these form fields to something more useful for your own challenge.") %>
+<%= 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 %> +<%= ts('Looking for prompts you claimed in a prompt meme? Try') %> <%= link_to ts("My Claims"), user_claims_path(@user) %>
+<% end %> + ++ <%= 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("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("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 %> ++ <%= 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 %> +
+ ++ <%=raw sanitize_field(prompt, :description) %> ++ <% end %> + + + <% if @collection.user_is_maintainer?(current_user) %> +
<%= ts('Looking for assignments you were given for a gift exchange? Try') %> <%= link_to ts("My Assignments"), user_assignments_path(@user) %>
+<% end %> + ++ <%= 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? %> ++ <%=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 %> + ++ <%=raw sanitize_field(@challenge, :signup_instructions_general) %> ++ <% end %> +
<%= 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('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}" %> ++ <%= 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 %> + ++ <%=raw sanitize_field(chapter, :summary) %> ++
+ (<%= 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 %> ++ <%=raw sanitize_field(chapter, :endnotes) %> ++
<%= ts("* Required information") %>
++ <%= f.text_field :position, class: "number" %> + <%= f.label :wip_length, ts("of"), title: ts("of total chapters") %> + <%= f.text_field :wip_length, class: "number" %> +
++ <%= allowed_html_instructions %> + <%= ts("Type or paste formatted text.") %><%= link_to_help "rte-help" %> +
++ <%= 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"))) %> +
+<%= 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("Drag chapters to change their order.") %>
+ + + <%= form_tag url_for(action: 'update_positions') do %> + +
+ <%= submit_tag ts("Update Positions") %> + <%= link_to ts("Back"), url_for(@work) %> +
+ + <% end %> ++ <%= 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"))) %> +
++ <%= 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) %> + +<%= collection_item.item_date.to_date %>
+ <% end %> ++ <% 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? %> + + <% 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 %> +
<%= 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 %> ++ <%= 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") %> +
++ <%=raw sanitize_field(@collection.collection_profile.intro.blank? ? @collection.parent.collection_profile : @collection.collection_profile, :intro) %> ++
+ <%=raw sanitize_field(@collection.collection_profile.faq.blank? ? @collection.parent.collection_profile : @collection.collection_profile, :faq) %> ++
+ <%=raw sanitize_field(@collection.collection_profile.rules.blank? ? @collection.parent.collection_profile : @collection.collection_profile, :rules) %> ++
<%= 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.") %>
+ +<%= set_format_for_date(collection.updated_at) %>
+ <% if collection.all_moderators.length > 0%> ++ <%=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") : "" %>) +
+ + ++ <%= 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') %>
+<%= hidden_field_tag "owner_pseuds[]", [current_user.default_pseud.id] %>
+ <% end %> + ++ <%= ts("%{minimum} to %{maximum} characters (A-Z, a-z, _, 0-9 only), no spaces, cannot begin or end with underscore (_)", + minimum: ArchiveConfig.TITLE_MIN, + maximum: ArchiveConfig.TITLE_MAX) %> +
++ <%= ts("(text only)") %> +
++ <%= ts("JPG, GIF, PNG") %> +
++ <%= 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.") %> +
+<%= 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.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.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.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) %> +
+ ++ <%= 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) %> +
+ ++ <%= 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 %> +<%=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") : "" %>) +
+<%= 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("Posted") %>: <%= @comment.created_at %>
+ <% unless @comment.edited_at.blank? %>
+
<%= ts("Last edited") %>: <%= @comment.edited_at %>
+ <% end %>
+
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])) %> ++ 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" %> + ++ <% 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 %> +(<%= allowed_html_instructions %>)
+ <% end %> + + <% else %> +(<%= 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 %> +
+(<%= link_to ts("%{count} more comments in this thread", count: comment.children_count), comment_path(comment) %>)
+ <%= t(".guest_comments_disabled") %> +
+ <% elsif commentable_parent.is_a?(AdminPost) && commentable_parent.disable_all_comments? %> ++ <%= t(".permissions.admin_post.disable_all") %> +
+ <% elsif commentable_parent.is_a?(AdminPost) && commentable_parent.disable_anon_comments? && !logged_in? %> ++ <%= t(".permissions.admin_post.disable_anon") %> + <%= t(".permissions.admin_post.alt_action", support_link: link_to(t(".permissions.admin_post.support_link"), new_feedback_report_path)).html_safe %> +
+ <% elsif commentable_parent.is_a?(Work) && commentable_parent.disable_all_comments? %> ++ <%= t(".permissions.work.disable_all") %> +
+ <% elsif commentable_parent.is_a?(Work) && commentable_parent.disable_anon_comments? && !logged_in? %> ++ <%= t(".permissions.work.disable_anon") %> + <%= t(".permissions.work.alt_action") %> +
+ <% elsif commentable_parent.is_a?(Work) && commentable_parent.hidden_by_admin? %> ++ <%= t(".permissions.work.hidden") %> +
+ <% elsif commentable_parent.is_a?(Work) && commentable_parent.in_unrevealed_collection %> ++ <%= t(".permissions.work.unrevealed") %> +
+ <% elsif commentable_parent.is_a?(Work) && blocked_by?(commentable_parent) %> ++ <%= t(".blocked") %> +
+ <% elsif logged_in_as_admin? %> ++ <%= t(".logged_as_admin") %> +
+ <% else %> ++ <%= ts("(Previous comment deleted.)") %> +
+ <% elsif !can_see_hidden_comment?(single_comment) %> + + <% else %> + <%# FRONT END, update this if a.user comes in %> +<%= 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? %> +<%= ts("Email: %{email}", email: single_comment.email) %>
+ <% 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 %> +<%= 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("No co-creator requests found.") %>
+<% else %> + <%= form_tag user_creatorships_path(@user, page: params[:page]), method: :put do %> +| <%= 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 %> | +
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 @@ ++ <%= @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 @@ +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 @@ +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 @@ +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(".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 @@ ++ <%= 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 %> + +<%= add_name_link(f) %>
++ <%= f.submit ts("Submit") %> +
+<%= button_to ts("Add these works to my currently-logged-in account"), complete_claim_path(invitation_token: @invitation.token) %>
+ <% else %> + + + <% 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("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("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 %> + +<%= set_format_for_date(external_work.created_at) %>
+<%= t("external_works.notice") %>
+ + ++ <%=raw strip_images(sanitize_field(external_work, :summary)) %> ++ <% end %> + + +
<%= set_format_for_date(external_work.created_at) %>
+<%= t("external_works.notice") %>
+ + ++ <%=raw strip_images(sanitize_field(external_work, :summary)) %> ++<% end %> + + +
* <%= ts("Required information") %>
+<%= 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 @@ +You can search this page by pressing ctrl F / cmd F and typing in what you are looking for.
+<% if @collection %> +"> + <%= select_tag :media_id, options_for_select(["All Media Types"] + @media.map(&:name), params[:media_id]) %> + <%= submit_tag ts("Show") %> +
+<%= 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(".do_not_spam_html") %>
+<%= t(".languages_html", list: @support_languages.map { |language| tag.span(language.name, lang: language.short) }.to_sentence.html_safe) %>
++ <%= 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")) %> ++ <%= f.submit t(".form.submit.active"), data: { disable_with: t(".form.submit.disabled") } %> +
++ <%= link_to (gift.rejected? ? ts("Accept Gift") : ts("Refuse Gift")), toggle_rejected_gift_path(gift), method: :post %> +
+ <% end %> ++ <%= 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 %> +
+<%= 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(".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.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.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.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.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.not_allowed") %>
+<%= 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.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.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.text") %> +
<%= 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.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.ao3_may_designate_html", + minimum_criteria_link: link_to(t(".mandatory_tags.minimum_criteria"), tos_faq_path(anchor: "minimum_tags"))) %> +
++ <%= 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"))) %> +
++ <%= 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"))) %> +
++ <%= 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"))) %> +
++ + <%= 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.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"))) %> +
++ <%= 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)) %> +
++ <%= 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") + } + )) %> + +
+ +<%= 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.intro") %>
++ <%= t(".removal.do_not_spam_html", + do_not_bold: tag.strong(t(".removal.do_not"))) %> +
+ ++ <%= 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.intro") %>
+<%= t(".takedown_requirements.liability") %>
+ ++ <%= 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.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.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.intro") %>
+<%= t(".counternotice_requirements.liability") %>
+ ++ <%= t(".counternotice_process.valid") %> +
++ <%= t(".counternotice_process.notification_html", + lumen_link: link_to(t(".counternotice_process.lumen"), "https://lumendatabase.org/")) %> +
+ ++ <%= 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 @@ +<%= 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" %>.
+ ++ <%= ts("Joining the Archive currently requires an invitation; however, we + are not accepting new invitation requests at this time. Please check + the %{news} for more information, or if you have already requested + an invitation, you can %{status}.", + news: link_to("\"Invitations\" tag on Symphony News", + admin_posts_path(tag: 143)), + status: link_to("check your position on the waiting list", + status_invite_requests_path) + ).html_safe %> +
+ <% end %> + +<%= ts("You can join by getting an invitation from another user or from our automated invite queue. All fans and fanworks are welcome!") %>
+<%= link_to ts("Get Invited!"), invite_requests_path %>
+ <% elsif AdminSetting.current.invite_from_queue_enabled? && AdminSetting.current.creation_requires_invite? %> +<%= ts("You can join by getting an invitation from our automated invite queue. All fans and fanworks are welcome!") %>
+<%= link_to ts("Get Invited!"), invite_requests_path %>
+ <% elsif AdminSetting.current.account_creation_enabled? && !AdminSetting.current.creation_requires_invite? %> +<%= link_to ts("Create an Account!"), signup_path %>
+ <% end %> ++ <%= 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" %> +
+<%= 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.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(".applicability.policy_covers") %>
+ <%= 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"))) %> +
++ <%= 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/")) %> +
+<%= t(".information_scope.collect_through_use") %>
<%= 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") %>
++ <%= t(".types_of_information.ip_addresses.heading") %> + <%= t(".types_of_information.ip_addresses.text") %> +
++ <%= t(".types_of_information.logs.heading") %> + <%= t(".types_of_information.logs.text") %> +
++ <%= t(".types_of_information.cookies.heading") %> + <%= t(".types_of_information.cookies.text") %> +
++ <%= link_to t(".types_of_information.fnok.heading"), archive_faq_path("fannish-next-of-kin") %> + <%= t(".types_of_information.fnok.text") %> +
++ <%= 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"))) %> +
+<%= t(".aggregate_anonymous_info.understand_ao3_usage") %>
<%= t(".aggregate_anonymous_info.anonymous_non_personal") %>
+ <%= t(".your_rights.request_data_html", + applicable_jurisdiction_link: link_to(t(".your_rights.applicable_jurisdiction"), tos_faq_path(anchor: "privacy_rights"))) %> +
++ <%= t(".your_rights.potential_other_rights_html", + other_rights_link: link_to(t(".your_rights.other_rights"), tos_faq_path(anchor: "privacy_rights_faq"))) %> +
+<%= t(".your_rights.require_user_specific_proof") %>
<%= t(".third_parties.do_not_sell_information") %>
<%= t(".third_parties.third_party_tools") %>
<%= t(".third_parties.sharing_exceptions.intro") %>
++ <%= 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/")) %> +
++ + <%= 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") %> +
++ + <%= 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") %> +
++ <%= 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/")) %> +
+<%= t(".third_parties.sharing_exceptions.legal_reasons.law_enforcement_cooperation_details") %>
+<%= t(".third_parties.sharing_exceptions.legal_reasons.attempt_to_notify") %>
++ <%= 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"))) %> +
++ <%= 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"))) %> +
++ <%= t(".account_termination.backup_copies_html", + general_principles_link: link_to(t(".account_termination.general_principles"), tos_path(anchor: "I.E.2"))) %> +
+<%= t(".account_termination.legal_enforcement_retention") %>
<%= t(".retention_of_information.text") %>
+ ++ <%= 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(".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"))) %> +
++ <%= t(".general_terms.entirety_of_agreement.html", + entirety_of_agreement: tag.strong(t(".general_terms.entirety_of_agreement.entirety_of_agreement"))) %> +
++ <%= 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"))) %> +
++ <%= t(".general_terms.non_severability.html", + non_severability: tag.strong(t(".general_terms.non_severability.non_severability"))) %> +
++ <%= t(".general_terms.limitation_on_claims.html", + limitation_on_claims: tag.strong(t(".general_terms.limitation_on_claims.limitation_on_claims"))) %> +
++ <%= t(".general_terms.no_assignment.html", + no_assignment: tag.strong(t(".general_terms.no_assignment.no_assignment"))) %> +
++ <%= 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.service_as_is") %>
<%= t(".potential_problems.breach_notification") %>
<%= t(".potential_problems.own_risk") %>
+ + <%= 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"))) %> + +
+<%= t(".potential_problems.damage_liability") %>
<%= t(".potential_problems.account_termination_liability") %>
<%= t(".potential_problems.content_access_liability") %>
<%= t(".potential_problems.not_personal_storage_html", sole_backup_responsibility: tag.strong(t(".potential_problems.sole_backup_responsibility"))) %>
<%= t(".content_you_access.external_links_html", here_link: link_to(t(".content_you_access.here"), "https://www.transformativeworks.org/where-find-us/")) %>
+ <%= 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)) %> +
+<%= t(".content_you_access.no_prescreen") %>
+ <%= 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"))) %> +
+<%= t(".content_you_access.otw_not_liable") %>
<%= t(".what_we_do_with_content.no_copyright_ownership_html", we_repeat: tag.strong(t(".what_we_do_with_content.we_repeat"))) %>
++ <%= 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"))) %> +
+<%= t(".what_we_do_with_content.license_duration") %>
+ <%= 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"))) %> +
++ <%= 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/")) %> +
+<%= 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)) %>
<%= t(".what_you_cant_do.you_agree_not_to") %>
++ <%= t(".what_you_cant_do.content_violating_policy_html", + content_policy_link: link_to(t(".what_you_cant_do.content_policy"), content_path)) %> +
++ <%= 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"))) %> +
+<%= t(".what_you_cant_do.forge_identifiers") %>
+ <%= 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")) %> +
++ <%= 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"))) %> +
+<%= t(".what_you_cant_do.software_viruses") %>
+ <%= 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"))) %> +
++ <%= 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")) %> +
++ <%= 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"))) %> +
+<%= t(".what_you_cant_do.break_applicable_law") %>
+ <%= 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"))) %> +
++ <%= 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"))) %> +
+<%= t(".age_policy.intro") %>
++ <%= 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.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"))) %> +
++ <%= 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)) %> +
++ <%= 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/")) %> +
++ <%= 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") %>
++ <%= 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") %>
++ <%= t(".abuse_policy.appeals.html", + appeal_decision_link: link_to(t(".abuse_policy.appeals.appeal_decision"), tos_faq_path(anchor: "appeal"))) %> +
+<%= 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 @@ + +<%= 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")) %> +
+ +
+
+ " 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/")) %>
+
<%= t(".tips_to_start") %>
+ ++ <%= t(".logging_in_out.html", + forgot_password_link: link_to(t(".logging_in_out.forgot_password"), new_user_password_path)) %> +
+ ++ <%= 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.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.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"))) %> +
+ ++ <% 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.html", + tags_faq_link: link_to(t(".tags.tags_faq"), archive_faq_path("tags"))) %> +
+ ++ <%= 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.html", + bookmarks_faq_link: link_to(t(".bookmarking_works.bookmarks_faq"), archive_faq_path("bookmarks"))) %> +
+ ++ <%= 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.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.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.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"))) %> +
+<%= t(".readings.note") %>
+<%= 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) %>
++ The following links will take you to all of the possible pages in the archive. + These are only links! You will still need the appropriate permissions to actually see the pages! + If you have trouble, try logging in as the test user or test admin accounts. (Those will only work on webdev or test, FYI.) +
+ ++ If any of these pages do not work at all (ie if you get a 404 or a notice that no action matches), please notify a senior + coder, since that probably means it is an unused route that should get cleaned up to improve performance. +
+ ++ Where an existing object is required to see a form (for instance when looking at a nested form), + we will attempt to make the link using one that you (that is, the user you are currently logged in as) can access. + If that doesn't work, you may need to manually edit the URL to put in the ID of an object + that you can see, or make a new object. +
+ ++ There were problems linking to the following pages, either because we couldn't find an object to use or because it's a + weird kind of page. You can manually fix and paste these routes in to see those pages. +
+ +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.
+ +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.
+ +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.
+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.
+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).
+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.
+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.
+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.
+This means that we can make the content you post on AO3 available to other people who use AO3, without paying you. We will never charge for access to AO3 or otherwise sell your content. You can also put your content on other sites if you want, or remove it from AO3 if you no longer want it here.
+This refers to how your work is displayed on the site, not how it is written, drawn, or otherwise created. For example, we may display portions of your content on some pages of the site, such as by showing your work's summary and tags in search results. We may also make changes to the formatting or display of your content in order to adapt to the technical requirements of different networks or devices, or to improve accessibility. For example, we may automatically convert HTML tags to our standard forms (for example, changing <bold> html tags to <strong>) or allow you to use nonstandard fonts and formatting while providing an alternate format for accessibility.
+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.
+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.
+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:
+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."
+Material in AO3's Terms of Service has been drawn from imeem and NearlyFreeSpeech.NET.
+Back to Top | General Questions
+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.
+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.
+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.
+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).
+ +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.
+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.
+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.
+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).
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+After the deadline, a member of the Policy & Abuse committee will review your work. The following situations may occur:
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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
+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.
+Content is anything that you post on AO3 or otherwise submit to us. This includes, but is not limited to:
+All content on AO3 must comply with our 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
+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.
+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.
+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.
+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.
+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:
+-) 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. *) before or after your search term will allow you to look for partial matches."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.
+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.
+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.
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.
+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
+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:
+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.
+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.
+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.
+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.
+Examples of fannish nonfiction allowed on AO3 include:
+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.
+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:
+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.
+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.
+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.
+A work that is simply a collection of actors' photos paired with character names would not be considered a fanwork, nor would a work that only consists of statistics or summaries of canon elements (such as what you might find on a fannish wiki). However, if you include more in-depth analytical, descriptive, or narrative content, then we would likely consider that fannish analysis (meta), which is allowed. An example would be an extended analysis of the character traits that led you to choose that particular fancasting.
+If you would like to post fancastings, statistics, or a summary about your own fanwork, you can include it in the fanwork's notes or post it as an extra chapter inside the fanwork.
+No. The incorrect quotes meme format involves a collection of quotes where the names have been substituted with the names from a different source. While the amount of quoted text is relatively small, replacing names and minor rewording of quotations is not sufficiently transformative to make the resulting content a fanwork.
+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.
+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.
+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.
+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.
+No. Please create a prompt meme to offer suggestions or challenges to other people, rather than posting a work.
+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.
+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.
+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.
+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.
+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
+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.
+Some examples of commercial activities include:
+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.
+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:
+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.
+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.
+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).
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+No. Since this involves an exchange of money, it is considered a commercial activity regardless of whether you personally make a profit.
+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.
+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.
+No. Both direct and indirect references to commercial platforms or activities are not allowed.
+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
+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.
+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.
+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?
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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
+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.
+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.
+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.
+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.
+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
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+You may not threaten other groups of fans. There is no exception for jokes or memes.
+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.
+ +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.
+Yes, it is possible that a username that is no longer used by its original user will be available to you.
+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.
+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?
+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
+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.
+AO3 has a Ratings system and an Archive Warnings system. These provide basic information about the intensity and type of content that may be present in a work. The only rating or warning information AO3 requires is listed within these two systems. Creators may add more information in the summaries, notes, or Additional tags of their works, but they are not required to do so.
+If a creator doesn't want to put a specific Rating and/or Archive Warning on their works, then they can opt out of one or both systems by applying a non-specific Rating and/or Archive Warning.
+Non-specific tags indicate that the creator has chosen not to use a more specific tag in that field. Any user who wants to avoid a particular rating and/or type of content should also avoid any work labeled with a non-specific Rating and/or Archive Warning. The non-specific Rating tag is "Not Rated", and the non-specific Archive Warning is the "Creator Chose Not To Use Archive Warnings" label. (Tags are also sometimes referred to as labels.)
+AO3 has five different rating tags that creators can apply to their works:
+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.
+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.
+The default rating is the non-specific "Not Rated" tag.
+"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.
+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.
+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.
+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.
+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".
+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.
+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.
+To avoid all fanworks that may contain explicit content, you should exclude or filter out the "Mature", "Explicit", and "Not Rated" labels.
+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:
+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.
+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.
+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.
+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.
+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.
+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.
+"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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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 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.
+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.
+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.
+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.
+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.
+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.
+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".
+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.
+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.
+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.
+No. We will only review a work that has been reported to us for potentially violating 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.
+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.
+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.
+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
+The tags on your work must meet the following criteria:
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+Please check out our Tags FAQ for more information about how tags function and how they are generally used.
+ +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.
+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.
+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.
+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
+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.
+ +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.
+Some AO3 features may display your content to the public, to other AO3 users, and/or to yourself and AO3 administrators.
+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:
+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.
+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.
+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:
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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 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
+None. We do not and will not sell, trade, or rent information, including your personal information.
+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:
+We rely on the following lawful grounds to process personal information from users in the EU and the UK:
+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.
+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.
+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.
+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.
+ ++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 @@ ++ <%= 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 @@ + +
<%= 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(".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 %> + | + <% if logged_in_as_admin? && invitation.redeemed_at.blank? %> ++ <%= link_to(t(".table.actions.delete"), + invitation, data: { confirm: t(".table.actions.delete_confirmation") }, method: :delete) %> + | + <% end %> +
<%= label_tag "invitation[number_of_invites]", ts('Number of invitations:') %> <%= f.text_field :number_of_invites %> <%= submit_tag 'Create' %>
+ <% end %> +<% else %> + +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 %> +<%= submit_tag t('.submit_invite', :default => 'Send Invitation') %>
+ <% end %> + <% end %> ++ <%= 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(".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)) %> +
+ + + ++ <%= f.label :email %> + <%= f.text_field :email %> + <%= f.submit t(".add_to_list") %> +
++ <%= 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 @@ + ++ <% 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(".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("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 %> + | +
+ <%= 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 '+ <%= 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") %> + +
+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' %> + + + ++ <%= allowed_html_instructions %> + <%= ts("Type or paste formatted text.") %><%= link_to_help "rte-help" %> +
+ <% use_tinymce %> + ++ <% 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 %> ++ <%= @kudos.map { |kudo| link_to kudo.user.login, kudo.user }.to_sentence.html_safe %> + <%= ts(" left applause for this work!") %> +
+* <%= t(".required_notice") %>
+ ++ <% if @language.new_record? %> + <%= f.submit t(".submit.create") %> + <% else %> + <%= f.submit t(".submit.update") %> + <% end %> +
++ <%= link_to t(".navigation.edit"), edit_language_path(language) %> +
+ <% end %> ++ <%= link_to t(".login"), new_user_session_path, id: "login-dropdown" %> +
+ <%= render "users/sessions/login" %> +<%= t(".faux_heading") %>
+<%= t(".faux_heading", locale: :ru) %>
+<%= t(".faux_heading", locale: :uk) %>
+<%= t(".faux_heading", locale: :"zh-CN") %>
++ hi
++ this is my personal archive dont be a dick
+ ++ + +
+ ++ + +
+ + <% if current_user.nil? %> ++ <%= button_tag t(".agree_and_consent"), disabled: true, type: "button", id: "accept_tos" %> +
+ <% else %> + <%= form_tag end_tos_prompt_user_path(current_user), + method: :post, + remote: true do %> ++ <%= submit_tag t(".agree_and_consent"), disabled: true, id: "accept_tos" %> +
+ <% end %> + <% end %> + +
+
|
+
* <%= t('.required_notice', :default => "Required information") %>
+ ++ <%= f.submit @locale.new_record? ? t('.create_button', :default => "Create Locale") : t('.edit_button', :default => "Update Locale") %> +
+| Name | +ISO Code | +Primary Locale | +Use for Email | +Use for Interface | +Created at | +Actions | +
|---|---|---|---|---|---|---|
| <%= 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} %> + | +
<%= 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 %> ++ <%= t(".sure_html", mute: tag.strong(t(".mute")), username: @muted.login) %> + <%= t(".will.intro") %> +
+ +<%= t(".will_not.intro") %>
+ ++ <%= 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(".sure_html", unmute: tag.strong(t(".unmute")), username: @mute.muted.login) %> + <%= t(".resume.intro") %> +
+ ++ <%= 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(".will.intro", mute_limit: number_with_delimiter(ArchiveConfig.MAX_MUTED_USERS), count: ArchiveConfig.MAX_MUTED_USERS) %>
+ +<%= t(".will_not.intro") %>
+ ++ <%= 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))) %> +
++ <%= 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") %> +
+<%= 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 @@ +<%= ts("Please try a different search.") %>
+<% else %> + <%= will_paginate @external_authors %> + ++ <%= ts('Test redirect: ') %> + <%= link_to @imported_from_url, redirect_path(:original_url => @imported_from_url) %> +
+<% end %> + ++ <%= 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("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('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 %> +<%= 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("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 @@ + +<%= link_to_remove_section ts("x"), form %>
+<%= l(tag_set.updated_at.to_date) %>
++ <%=raw strip_images(sanitize_field(tag_set, :description)) || " ".html_safe %> ++ + + <% if tag_set.tag_set %> +
+ <%= link_to_add_section(ts('Add Association'), tag_set_form, :tag_set_associations, "tag_set_association_fields") %> +
+ <% end %> ++ <%= 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.") %> +
+ +
<%= search_header(@comments, nil, "Comment") %>
+ <%= render "comments/comment_abbreviated_list", comments: comments %> + <%= will_paginate(comments, param_name: "comments_page", params: { anchor: "comments-summary" }) %> +