# Methods added to this helper will be available to all templates in the application. module ApplicationHelper include HtmlCleaner # TODO: Official recommendation from Rails indicates we should switch to # unobtrusive JavaScript instead of using anything like `link_to_function` def link_to_function(name, *args, &block) html_options = args.extract_options!.symbolize_keys function = block_given? ? update_page(&block) : args[0] || '' onclick = "#{"#{html_options[:onclick]}; " if html_options[:onclick]}#{function}; return false;" href = html_options[:href] || 'javascript:void(0)' content_tag(:a, name, html_options.merge(href: href, onclick: onclick)) end # Generates class names for the main div in the application layout def classes_for_main class_names = controller.controller_name + '-' + controller.action_name show_sidebar = ((@user || @admin_posts || @collection || show_wrangling_dashboard) && !@hide_dashboard) class_names += " dashboard" if show_sidebar class_names += " filtered" if page_has_filters? case controller.controller_name when "abuse_reports", "feedbacks", "known_issues" class_names = "system support #{controller.controller_name} #{controller.action_name}" when "archive_faqs" class_names = "system docs support faq #{controller.action_name}" when "wrangling_guidelines" class_names = "system docs guideline #{controller.action_name}" when "home" class_names = if %w(content privacy).include?(controller.action_name) "system docs tos tos-#{controller.action_name}" else "system docs #{controller.action_name}" end when "errors" class_names = "system #{controller.controller_name} error-#{controller.action_name}" end class_names end def page_has_filters? @facets.present? || (controller.action_name == 'index' && controller.controller_name == 'collections') || (controller.action_name == 'unassigned' && controller.controller_name == 'fandoms') end # This is used to make the current page we're on (determined by the path or by the specified condition) a span with class "current" and it allows us to add a title attribute to the link or the span def span_if_current(link_to_default_text, path, condition=nil, title_attribute_default_text=nil) is_current = condition.nil? ? current_page?(path) : condition span_tag = title_attribute_default_text.nil? ? "#{link_to_default_text}" : "#{link_to_default_text}" link_code = title_attribute_default_text.nil? ? link_to(link_to_default_text, path) : link_to(link_to_default_text, path, title: "#{title_attribute_default_text}") is_current ? span_tag.html_safe : link_code end def link_to_rss(link_to_feed) link_to content_tag(:span, ts("RSS Feed")), link_to_feed, title: ts("RSS Feed"), class: "rss" end # 1: default shows just the link to help # 2: show_text = true: shows "plain text with limited html" and link to help def allowed_html_instructions(show_text = true) (show_text ? h(ts("Plain text with limited HTML")) : "".html_safe) + link_to_help("html-help") end # Byline helpers def byline(creation, options={}) if creation.respond_to?(:anonymous?) && creation.anonymous? anon_byline = ts("Anonymous").html_safe if options[:visibility] != "public" && (logged_in_as_admin? || is_author_of?(creation)) anon_byline += " [#{non_anonymous_byline(creation, options[:only_path])}]".html_safe end return anon_byline end non_anonymous_byline(creation, options[:only_path]) end def non_anonymous_byline(creation, url_path = nil) only_path = url_path.nil? ? true : url_path if @preview_mode # Skip cache in preview mode return byline_text(creation, only_path) end Rails.cache.fetch("#{creation.cache_key}/byline-nonanon/#{only_path.to_s}") do byline_text(creation, only_path) end end def byline_text(creation, only_path, text_only = false) if creation.respond_to?(:author) creation.author else pseuds = @preview_mode ? creation.pseuds_after_saving : creation.pseuds.to_a pseuds = pseuds.flatten.uniq.sort archivists = Hash.new [] if creation.is_a?(Work) external_creatorships = creation.external_creatorships.select { |ec| !ec.claimed? } external_creatorships.each do |ec| archivist_pseud = pseuds.select { |p| ec.archivist.pseuds.include?(p) }.first archivists[archivist_pseud] += [ec.author_name] end end pseuds.map { |pseud| pseud_byline = text_only ? pseud.byline : pseud_link(pseud, only_path) if archivists[pseud].empty? pseud_byline else archivists[pseud].map { |ext_author| ts("%{ext_author} [archived by %{name}]", ext_author: ext_author, name: pseud_byline) }.join(', ') end }.join(', ').html_safe end end def pseud_link(pseud, only_path = true) if only_path link_to(pseud.byline, user_pseud_path(pseud.user, pseud), rel: "author") else link_to(pseud.byline, user_pseud_url(pseud.user, pseud), rel: "author") end end # A plain text version of the byline, for when we don't want to deliver a linkified version. def text_byline(creation, options={}) if creation.respond_to?(:anonymous?) && creation.anonymous? anon_byline = ts("Anonymous") if (logged_in_as_admin? || is_author_of?(creation)) && options[:visibility] != 'public' anon_byline += " [#{non_anonymous_byline(creation)}]".html_safe end anon_byline else only_path = false text_only = true byline_text(creation, only_path, text_only) end end def link_to_modal(content = "", options = {}) options[:class] ||= "" options[:for] ||= "" options[:title] ||= options[:for] html_options = { class: "#{options[:class]} modal", title: options[:title] } link_to content, options[:for], html_options end # Currently, help files are static. We may eventually want to make these dynamic? def link_to_help(help_entry, link = '?'.html_safe) help_file = "" #if Locale.active && Locale.active.language # help_file = "#{ArchiveConfig.HELP_DIRECTORY}/#{Locale.active.language.code}/#{help_entry}.html" #end unless !help_file.blank? && File.exists?("#{Rails.root}/public/#{help_file}") help_file = "#{ArchiveConfig.HELP_DIRECTORY}/#{help_entry}.html" end " ".html_safe + link_to_modal(link, for: help_file, title: help_entry.split('-').join(' ').capitalize, class: "help symbol question").html_safe end # Inserts the flash alert messages for flash[:key] wherever # <%= flash_div :key %> # is placed in the views. That is, if a controller or model sets # flash[:error] = "OMG ERRORZ AIE" # or # flash.now[:error] = "OMG ERRORZ AIE" # # then that error will appear in the view where you have # <%= flash_div :error %> # # The resulting HTML will look like this: #
OMG ERRORZ AIE
# # The CSS classes are specified in system-messages.css. # # You can also have multiple possible flash alerts in a single location with: # <%= flash_div :error, :caution, :notice %> # (These are the three varieties currently defined.) # def flash_div *keys keys.collect { |key| if flash[key] if flash[key].is_a?(Array) content_tag(:div, content_tag(:ul, safe_join(flash[key].map do |flash_item| content_tag(:li, sanitize(flash_item)) end), "\n"), class: "flash #{key}") else content_tag(:div, sanitize(flash[key]), class: "flash #{key}") end end }.join.html_safe end # Generates sorting links for index pages, with column names and directions def sort_link(title, column=nil, options = {}) condition = options[:unless] if options.has_key?(:unless) unless column.nil? current_column = (params[:sort_column] == column.to_s) || params[:sort_column].blank? && options[:sort_default] css_class = current_column ? "current" : nil if current_column # explicitly or implicitly doing the existing sorting, so we need to toggle if params[:sort_direction] direction = params[:sort_direction].to_s.upcase == 'ASC' ? 'DESC' : 'ASC' else direction = options[:desc_default] ? 'ASC' : 'DESC' end else direction = options[:desc_default] ? 'DESC' : 'ASC' end link_to_unless condition, ((direction == 'ASC' ? '↑ ' : '↓ ') + title).html_safe, current_path_with(sort_column: column, sort_direction: direction), {class: css_class, title: (direction == 'ASC' ? ts('sort up') : ts('sort down'))} else link_to_unless params[:sort_column].nil?, title, current_path_with(sort_column: nil, sort_direction: nil) end end ## Allow use of tiny_mce WYSIWYG editor def use_tinymce @content_for_tinymce = "" content_for :tinymce do javascript_include_tag "tinymce/tinymce.min.js", skip_pipeline: true end @content_for_tinymce_init = "" content_for :tinymce_init do javascript_include_tag "mce_editor.min.js", skip_pipeline: true end end # check for pages that allow tiny_mce before loading the massive javascript def allow_tinymce?(controller) %w(admin_posts archive_faqs known_issues chapters works wrangling_guidelines).include?(controller.controller_name) && %w(new create edit update).include?(controller.action_name) end # see: http://www.w3.org/TR/wai-aria/states_and_properties#aria-valuenow def generate_countdown_html(field_id, max) max = max.to_s span = content_tag(:span, max, id: "#{field_id}_counter", class: "value", "data-maxlength" => max) content_tag(:p, span + ts(' characters left'), class: "character_counter", "tabindex" => 0) end # expand/contracts all expand/contract targets inside its nearest parent with the target class (usually index or listbox etc) def expand_contract_all(target = "listbox") expand_all = button_tag(ts("Expand All"), class: "expand_all", data: { target_class: target }) contract_all = button_tag(ts("Contract All"), class: "contract_all", data: { target_class: target }) expand_all + contract_all end # Sets up expand/contract/shuffle buttons for any list whose id is passed in # See the jquery code in application.js # Note that these start hidden because if javascript is not available, we # don't want to show the user the buttons at all. def expand_contract_shuffle(list_id, shuffle: true) target = "##{list_id}" expander = button_tag("↓".html_safe, class: "expand hidden", title: "expand", data: { action_target: target }) contractor = button_tag("↑".html_safe, class: "contract hidden", title: "contract", data: { action_target: target }) shuffler = button_tag("⇆".html_safe, class: "shuffle hidden", title: "shuffle", data: { action_target: target }) if shuffle expander + contractor + shuffler end # returns the default autocomplete attributes, all of which can be overridden # note: we do this and put the message defaults here so we can use translation on them def autocomplete_options(method, options={}) { class: "autocomplete", data: { autocomplete_method: (method.is_a?(Array) ? method.to_json : "/autocomplete/#{method}"), autocomplete_hint_text: ts("Start typing for suggestions!"), autocomplete_no_results_text: ts("(No suggestions found)"), autocomplete_min_chars: 1, autocomplete_searching_text: ts("Searching...") } }.deep_merge(options) end # see http://asciicasts.com/episodes/197-nested-model-form-part-2 def link_to_add_section(linktext, form, nested_model_name, partial_to_render, locals = {}) new_nested_model = form.object.class.reflect_on_association(nested_model_name).klass.new child_index = "new_#{nested_model_name}" rendered_partial_to_add = form.fields_for(nested_model_name, new_nested_model, child_index: child_index) {|child_form| render(partial: partial_to_render, locals: {form: child_form, index: child_index}.merge(locals)) } link_to_function(linktext, "add_section(this, \"#{nested_model_name}\", \"#{escape_javascript(rendered_partial_to_add)}\")", class: "hidden showme") end # see above def link_to_remove_section(linktext, form, class_of_section_to_remove="removeme") form.hidden_field(:_destroy) + "\n" + link_to_function(linktext, "remove_section(this, \"#{class_of_section_to_remove}\")", class: "hidden showme") end # show time in the time zone specified by the first argument # add the user's time when specified in preferences def time_in_zone(time, zone = nil, user = User.current_user) return ts("(no time specified)") if time.blank? zone ||= (user&.is_a?(User) && user.preference.time_zone) ? user.preference.time_zone : Time.zone.name time_in_zone = time.in_time_zone(zone) time_in_zone_string = time_in_zone.strftime('%a %d %b %Y %I:%M%p') + " #{time_in_zone.zone} " user_time_string = "" if user.is_a?(User) && user.preference.time_zone if user.preference.time_zone != zone user_time = time.in_time_zone(user.preference.time_zone) user_time_string = "(" + user_time.strftime('%I:%M%p') + " #{user_time.zone})" elsif !user.preference.time_zone user_time_string = link_to ts("(set timezone)"), user_preferences_path(user) end end (time_in_zone_string + user_time_string).strip.html_safe end def mailto_link(user, options={}) " \"email ".html_safe end # these two handy methods will take a form object (eg from form_for) and an attribute (eg :title or '_destroy') # and generate the id or name that Rails will output for that object def field_attribute(attribute) attribute.to_s.sub(/\?$/,"") end def name_to_id(name) name.to_s.gsub(/\]\[|[^-a-zA-Z0-9:.]/, "_").sub(/_$/, "") end def field_id(form_or_object_name, attribute, index: nil, **_kwargs) name_to_id(field_name(form_or_object_name, attribute, index: index)) end # This is a partial re-implementation of ActionView::Helpers::FormTagHelper#field_name. # The method contract changed in Rails 7.0, but we can't use the default because it sometimes # includes other information that -- at a minimum -- wreaks havoc on the Cucumber feature tests. # It is used in when constructing forms, like in app/views/tags/new.html.erb. def field_name(form_or_object_name, attribute, *_method_names, multiple: false, index: nil) object_name = if form_or_object_name.respond_to?(:object_name) form_or_object_name.object_name else form_or_object_name end if object_name.blank? "#{field_attribute(attribute)}#{multiple ? '[]' : ''}" elsif index "#{object_name}[#{index}][#{field_attribute(attribute)}]#{multiple ? '[]' : ''}" else "#{object_name}[#{field_attribute(attribute)}]#{multiple ? '[]' : ''}" end end # toggle an checkboxes (scrollable checkboxes) section of a form to show all of the checkboxes def checkbox_section_toggle(checkboxes_id, checkboxes_size, options = {}) toggle_show = content_tag(:a, ts("Expand %{checkboxes_size} Checkboxes", checkboxes_size: checkboxes_size), class: "toggle #{checkboxes_id}_show") + "\n".html_safe toggle_hide = content_tag(:a, ts("Collapse Checkboxes"), style: "display: none;", class: "toggle #{checkboxes_id}_hide", href: "##{checkboxes_id}") + "\n".html_safe css_class = checkbox_section_css_class(checkboxes_size) javascript_bits = content_for(:footer_js) { javascript_tag("$j(document).ready(function(){\n" + "$j('##{checkboxes_id}').find('.actions').show();\n" + "$j('.#{checkboxes_id}_show').click(function() {\n" + "$j('##{checkboxes_id}').find('.index').attr('class', 'options index all');\n" + "$j('.#{checkboxes_id}_hide').show();\n" + "$j('.#{checkboxes_id}_show').hide();\n" + "});" + "\n" + "$j('.#{checkboxes_id}_hide').click(function() {\n" + "$j('##{checkboxes_id}').find('.index').attr('class', '#{css_class}');\n" + "$j('.#{checkboxes_id}_show').show();\n" + "$j('.#{checkboxes_id}_hide').hide();\n" + "});\n" + "})") } toggle = content_tag(:p, (options[:no_show] ? "".html_safe : toggle_show) + toggle_hide + (options[:no_js] ? "".html_safe : javascript_bits), class: "actions", style: "display: none;") end # create a scrollable checkboxes section for a form that can be toggled open/closed # form: the form this is being created in # attribute: the attribute being set # choices: the array of options (which should be objects of some sort) # checked_method: a method that can be run on the object of the form to get back a list # of currently-set options # name_method: a method that can be run on each individual option to get its pretty name for labelling (typically just "name") # value_method: a value that can be run to get the value of each individual option # # # See the prompt_form in challenge signups for example of usage def checkbox_section(form, attribute, choices, options = {}) options = { checked_method: nil, name_method: "name", name_helper_method: nil, # alternative: pass a helper method that gets passed the choice extra_info_method: nil, # helper method that gets passed the choice, for any extra information that gets attached to the label value_method: "id", disabled: false, include_toggle: true, checkbox_side: "left", include_blank: true, concise: false # specify concise to invoke alternate formatting for skimmable lists (two-column in default layout) }.merge(options) field_name = options[:field_name] || field_name(form, attribute) field_name += '[]' base_id = options[:field_id] || field_id(form, attribute) checkboxes_id = "#{base_id}_checkboxes" opts = options[:disabled] ? {disabled: "true"} : {} already_checked = case when options[:checked_method].is_a?(Array) options[:checked_method] when options[:checked_method].nil? [] else form.object.send(options[:checked_method]) || [] end checkboxes = choices.map do |choice| is_checked = !options[:checked_method] || already_checked.empty? ? false : already_checked.include?(choice) display_name = case when options[:name_helper_method] eval("#{options[:name_helper_method]}(choice)") else choice.send(options[:name_method]).html_safe end value = choice.send(options[:value_method]) checkbox_id = "#{base_id}_#{name_to_id(value)}" checkbox = check_box_tag(field_name, value, is_checked, opts.merge({id: checkbox_id})) checkbox_and_label = label_tag checkbox_id, class: "action" do options[:checkbox_side] == "left" ? checkbox + display_name : display_name + checkbox end if options[:extra_info_method] checkbox_and_label = options[:checkbox_side] == "left" ? checkbox_and_label + eval("#{options[:extra_info_method]}(choice)") : eval("#{options[:extra_info_method]}(choice)") + checkbox_and_label end content_tag(:li, checkbox_and_label) end.join("\n").html_safe # if there are only a few choices, don't show the scrolling and the toggle size = choices.size css_class = checkbox_section_css_class(size, options[:concise]) checkboxes_ul = content_tag(:ul, checkboxes, class: css_class) toggle = "".html_safe if options[:include_toggle] && !options[:concise] && size > (ArchiveConfig.OPTIONS_TO_SHOW * 6) toggle = checkbox_section_toggle(checkboxes_id, size) end # We wrap the whole thing in a div return content_tag(:div, checkboxes_ul + toggle + (options[:include_blank] ? hidden_field_tag(field_name, " ") : ''.html_safe), id: checkboxes_id) end def checkbox_section_css_class(size, concise=false) css_class = "options index group" if concise css_class += " concise lots" if size > ArchiveConfig.OPTIONS_TO_SHOW else css_class += " many" if size > ArchiveConfig.OPTIONS_TO_SHOW css_class += " lots" if size > (ArchiveConfig.OPTIONS_TO_SHOW * 6) end css_class end def check_all_none(all_text="All", none_text="None", id_filter=nil) filter_attrib = (id_filter ? " data-checkbox-id-filter=\"#{id_filter}\"" : '') ('").html_safe end def submit_button(form=nil, button_text=nil) button_text ||= (form.nil? || form.object.nil? || form.object.new_record?) ? ts("Submit") : ts("Update") content_tag(:p, (form.nil? ? submit_tag(button_text) : form.submit(button_text)), class: "submit") end def submit_fieldset(form=nil, button_text=nil) content_tag(:fieldset, content_tag(:legend, ts("Actions")) + submit_button(form, button_text)) end def first_paragraph(full_text, placeholder_text = 'No preview available.') # is there a paragraph that does not have a child image? paragraph = Nokogiri::HTML5.parse(full_text).at_xpath("//p[not(img)]") if paragraph.present? # if so, get its text and put it in a fresh p tag paragraph_text = paragraph.text return content_tag(:p, paragraph_text) else # if not, put the placeholder text in a p tag with the placeholder class return content_tag(:p, ts(placeholder_text), class: 'placeholder') end end # spans for nesting a checkbox or radio button inside its label to make custom # checkbox or radio designs def label_indicator_and_text(text) content_tag(:span, "", class: "indicator", "aria-hidden": "true") + content_tag(:span, text) end # Display a collection of radio buttons, wrapped in an unordered list. # # The parameter option_array should be a list of pairs, where the first # element in each pair is the radio button's value, and the second element in # each pair is the radio button's label. def radio_button_list(form, field_name, option_array) content_tag(:ul) do form.collection_radio_buttons(field_name, option_array, :first, :second, include_hidden: false) do |builder| content_tag(:li, builder.label { builder.radio_button + builder.text }) end end end # Identifier for creation, formatted external-work-12, series-12, work-12. def creation_id_for_css_classes(creation) return unless %w[ExternalWork Series Work].include?(creation.class.name) "#{creation.class.name.underscore.dasherize}-#{creation.id}" end # Array of creator ids, formatted user-123, user-126. # External works are not created by users, so we can skip this. def creator_ids_for_css_classes(creation) return [] unless %w[Series Work].include?(creation.class.name) return [] if creation.anonymous? # Although series.unrevealed? can be true, the creators are not concealed # in the blurb. Therefore, we do not need special handling for unrevealed # series. return [] if creation.is_a?(Work) && creation.unrevealed? creation.pseuds.pluck(:user_id).uniq.map { |id| "user-#{id}" } end def css_classes_for_creation_blurb(creation) return if creation.nil? Rails.cache.fetch("#{creation.cache_key_with_version}/blurb_css_classes-v2") do creation_id = creation_id_for_css_classes(creation) creator_ids = creator_ids_for_css_classes(creation).join(" ") "blurb group #{creation_id} #{creator_ids}".strip end end # Returns the current path, with some modified parameters. Modeled after # WillPaginate::ActionView::LinkRenderer to try to prevent any additional # security risks. def current_path_with(**kwargs) # Only throw in the query params if this is a GET request, because POST and # such don't pass their params in the URL. path_params = if request.get? || request.head? permit_all_except(params, [:script_name, :original_script_name]) else {} end path_params.deep_merge!(kwargs) path_params[:only_path] = true # prevent shenanigans url_for(path_params) end # Creates a new hash with all keys except those marked as blocked. # # This is a bit of a hack, but without this we'd have to either (a) make a # list of all permitted params each time current_path_with is called, or (b) # call params.permit! and effectively disable strong parameters for any code # called after current_path_with. def permit_all_except(params, blocked_keys) if params.respond_to?(:each_pair) {}.tap do |result| params.each_pair do |key, value| key = key.to_sym next if blocked_keys.include?(key) result[key] = permit_all_except(value, blocked_keys) end end elsif params.respond_to?(:map) params.map do |entry| permit_all_except(entry, blocked_keys) end else # not a hash or an array, just a flat value params end end def disallow_robots?(item) return unless item if item.is_a?(User) item.preference&.minimize_search_engines? elsif item.respond_to?(:users) item.users.all? { |u| u&.preference&.minimize_search_engines? } end end # Determines if the page (controller and action combination) does not need # to show the ToS (Terms of Service) popup. def tos_exempt_page? case params[:controller] when "home" %w[index content dmca privacy tos tos_faq].include?(params[:action]) when "abuse_reports", "feedbacks", "users/sessions" %w[new create].include?(params[:action]) when "archive_faqs" %w[index show].include?(params[:action]) end end def browser_page_title(page_title, page_subtitle) return page_title if page_title page = if page_subtitle page_subtitle elsif controller.action_name == "index" process_title(controller.controller_name) else "#{process_title(controller.action_name)} #{process_title(controller.controller_name.singularize)}" end # page_subtitle sometimes contains user (including admin) content, so let's # not html_safe the entire string. Let's require html_safe be called when # we set @page_subtitle, so we're conscious of what we're doing. page + " | #{ArchiveConfig.APP_NAME}" end end