From a4d05db5fa4e77ccd1a3616dfff6389158a281fb Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Mon, 21 Oct 2024 06:40:20 +0100 Subject: [PATCH 01/13] micro_auth module YARD comments --- app/helpers/micro_auth/apps_helper.rb | 4 ++++ .../micro_auth/authentication_helper.rb | 18 ++++++++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/app/helpers/micro_auth/apps_helper.rb b/app/helpers/micro_auth/apps_helper.rb index d7b548a9a..02e01b2df 100644 --- a/app/helpers/micro_auth/apps_helper.rb +++ b/app/helpers/micro_auth/apps_helper.rb @@ -1,4 +1,8 @@ module MicroAuth::AppsHelper + ## + # Builds & returns a tag-style badge indicating whether a specified app is active for use or not. + # @param app [MicroAuth::App] The app for which to build a badge. + # @return [ActiveSupport::SafeBuffer] A badge; the result of a TagBuilder. def app_active_badge(app) tag.span app.active? ? 'active' : 'inactive', class: "badge is-tag #{app.active? ? 'is-green' : 'is-red'}" diff --git a/app/helpers/micro_auth/authentication_helper.rb b/app/helpers/micro_auth/authentication_helper.rb index 476daab82..7f87fa324 100644 --- a/app/helpers/micro_auth/authentication_helper.rb +++ b/app/helpers/micro_auth/authentication_helper.rb @@ -1,8 +1,8 @@ module MicroAuth::AuthenticationHelper - # Contains all valid scopes for authentication requests. - # - # A `nil` description indicates a scope that is not displayed to the user during authentication - # requests. + ## + # Contains all valid scopes for authentication requests. A `nil` description indicates a scope that is not displayed + # to the user during authentication requests. + # @return [Hash{String => String}] A hash of valid authentication scopes. def valid_auth_scopes { 'perpetual' => nil, @@ -10,6 +10,11 @@ def valid_auth_scopes } end + ## + # Builds a redirect URI, adding specified parameters as necessary. + # @param redirect_uri [String] The base redirect URI to start from + # @param params [Hash{Symbol => #to_s}] A hash of parameters to add as query parameters to the URI + # @return [String] The final URI. def construct_redirect(redirect_uri, **params) uri = URI(redirect_uri) query = URI.decode_www_form(uri.query || '').to_h.merge(params) @@ -17,6 +22,11 @@ def construct_redirect(redirect_uri, **params) uri.to_s end + ## + # Provides a hash of user data based on what data the provided token is scoped to access. For instance, +:email+ will + # only be included when a +pii+ scoped token is presented. + # @param token [MicroAuth::Token] A Token instance to specify the scoped access level. + # @return [Hash{Symbol => Object}] A user data hash. def authenticated_user_object(token) fields = [:id, :created_at, :is_global_moderator, :is_global_admin, :username, :website, :twitter, :staff, :developer, :discord] From 0dcf2a114a2ab7c3ed953997041fc2d9d3fa3281 Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Wed, 23 Oct 2024 19:45:28 +0100 Subject: [PATCH 02/13] Add comments to Users::AvatarHelper --- .gitignore | 3 +++ app/helpers/users/avatar_helper.rb | 26 ++++++++++++++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index b17632185..44c774409 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,6 @@ dump.rdb # Ignore IRB files .irbrc .irb_history + +# YARD data +.yardoc diff --git a/app/helpers/users/avatar_helper.rb b/app/helpers/users/avatar_helper.rb index 6a6686f27..25c5bf268 100644 --- a/app/helpers/users/avatar_helper.rb +++ b/app/helpers/users/avatar_helper.rb @@ -3,6 +3,23 @@ module Users::AvatarHelper include Magick + ## + # Creates an avatar image based either on a user account or on provided values. + # @overload user_auto_avatar(size, user: nil) + # @param size [Integer] The side length of the final image, in pixels. O(n^2) at minimum - large values will take + # exponentially longer to generate. + # @param user [User] A user object from which to take the avatar letter and color + # @overload user_auto_avatar(size, letter: nil, color: nil) + # @param size [Integer] The side length of the final image, in pixels. O(n^2) at minimum - large values will take + # exponentially longer to generate. + # @param letter [String] A single character to display on the avatar if +user+ is not set + # @param color [String] An 8-digit hex code, with leading +#+, to color the avatar background if +user+ is not set + # @return [Magick::Image] The generated avatar + # @raise [ArgumentError] If a user or letter-color combination is not provided. + # @example Generate an avatar for the current user: + # helpers.user_auto_avatar(64, user: current_user) + # @example Generate a generic avatar: + # helpers.user_auto_avatar(32, letter: 'A', color: '#3C0FFEE5') def user_auto_avatar(size, user: nil, letter: nil, color: nil) raise ArgumentError, 'Either user or letter must be set' if user.nil? && letter.nil? raise ArgumentError, 'Color must be set if user is not provided' if user.nil? && color.nil? @@ -44,8 +61,13 @@ def user_auto_avatar(size, user: nil, letter: nil, color: nil) end end - # Returns on_light if the given base color is light, and vice versa. - # Useful for picking a text color to use on a dynamic background. + ## + # Returns on_light if the given base color is light, and vice versa. Useful for picking a text color to use on a + # dynamic background. Uses the YIQ color space. + # @param base [String] The base/background color on which to base the calculation. + # @param on_light [String] The text color to use on a light background. + # @param on_dark [String] The text color to use on a dark background. + # @return [String] The text color to use, either +on_light+ or +on_dark+. def yiq_contrast(base, on_light, on_dark) base = base[1..] red = base[0...2].to_i(16) From 08b16979b8b9f19377117c45743ba53d6f79bbd6 Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Thu, 24 Oct 2024 00:34:46 +0100 Subject: [PATCH 03/13] Additional comments & signature updates --- app/helpers/abilities_helper.rb | 22 ++--- app/helpers/advertisement_helper.rb | 27 +++++- app/helpers/answers_helper.rb | 4 + app/helpers/application_helper.rb | 127 ++++++++++++++++++++++++++-- app/models/user.rb | 1 + 5 files changed, 165 insertions(+), 16 deletions(-) diff --git a/app/helpers/abilities_helper.rb b/app/helpers/abilities_helper.rb index 35646a3b0..7502ca90b 100644 --- a/app/helpers/abilities_helper.rb +++ b/app/helpers/abilities_helper.rb @@ -1,18 +1,20 @@ module AbilitiesHelper - # This is a helper that will linearize the Wilson-score - # progress used by the ability calculations. - # - # Problem: 0.98 and 0.99 are not far away on a linear - # scale, but mean a change of about 2x for the "actual - # limit" used by the algorithm. - # - # Solution: We transform the ideal case formula y=(x+2)/(x+4) - # to x=(4y-2)/(1-y) and use that for the progress bar. + ## + # Linearizes the Wilson-score progress used by ability calculations. For example, 0.98 and 0.99 are not far away on a + # linear scale, but mean a change of about 2x for the actual limit used by the algorithm. This method takes that into + # account and provides an indicator of progress on a linear scale, for use in progress bars. + # @param score [Float] The Wilson score result to linearize. + # @return [Float] The linearized score. def linearize_progress(score) linear_score = ((4 * score) - 2) / (1 - score) - [0, linear_score].max + [0, linear_score].max.to_f end + ## + # Provides an error message for when a user is unable to complete an ability-restricted action, either because the + # user doesn't have the ability or because it has been suspended. + # @param internal_id [String] The +internal_id+ attribute of the ability in question. + # @return [String] An error message appropriate to the circumstances. def ability_err_msg(internal_id, action = nil) ability = Ability.find_by internal_id: internal_id ua = current_user&.privilege(ability.internal_id) diff --git a/app/helpers/advertisement_helper.rb b/app/helpers/advertisement_helper.rb index c43fae797..9bd9db8e0 100644 --- a/app/helpers/advertisement_helper.rb +++ b/app/helpers/advertisement_helper.rb @@ -1,12 +1,15 @@ module AdvertisementHelper + # Character ordinal for the start of the Unicode RTL characters block. RTL_BLOCK_START = 0x0590 + # Character ordinal for the end of the Unicode RTL characters block. RTL_BLOCK_END = 0x06FF # Used to artificially fix RTL text in environments that are incapable of rendering mixed LTR and RTL text. # Parses the given string in sections of LTR and RTL; at each boundary between sections, dumps the section # into an output buffer - forwards if the section was LTR, reversed if the section was RTL - and clears the # scan buffer. This has the effect of reversing RTL sections in-place, which seems to correct their direction. - # + # @param str [String] The mixed-text string on which to do witchcraft. + # @return [String] A "fixed" string suitable to render in RTL-ignorant environments. ImageMagick, for instance. def do_rtl_witchcraft(str) chars = str.chars output_buffer = '' @@ -43,12 +46,25 @@ def do_rtl_witchcraft(str) end end + ## + # Returns true if the provided character is part of an RTL character set. + # @param char [String] A single-character string. + # @return [Boolean] + # @raise [ArgumentError] If the string provided is longer than one character. def rtl?(char) return false if char.nil? + raise ArgumentError, 'More than one character provided' if char.length > 1 char.ord >= RTL_BLOCK_START && char.ord <= RTL_BLOCK_END end + ## + # Estimates the width of text for rendering in ImageMagick and attempts to wrap it over multiple lines to avoid text + # being cut off or too short. + # @param text [String] The text to wrap. + # @param width [Integer] The available width in pixels. + # @param font_size [Integer] The font size in which the text will be rendered. + # @return [String] The wrapped text, as one string with line breaks in the right places. def wrap_text(text, width, font_size) columns = (width * 2.5 / font_size).to_i # Source: http://viseztrance.com/2011/03/texts-over-multiple-lines-with-rmagick.html @@ -57,6 +73,15 @@ def wrap_text(text, width, font_size) end * "\n" end + ## + # Loads and returns a Magick image either from local files or from a URI for use in generating composite Magick + # images. + # @param icon_path [String] A path or URI from which to load the image. If using a path this should be the asset path + # as it would be accessible through the Rails server - see example. + # @return [Magick::ImageList] An ImageList containing the icon. + # @example Load an image from app/assets/images: + # # This uses the path from which the image would be accessed via HTTP. + # helpers.community_icon('/assets/codidact.png') def community_icon(icon_path) if icon_path.start_with? '/assets/' icon = Magick::ImageList.new("./app/assets/images/#{File.basename(icon_path)}") diff --git a/app/helpers/answers_helper.rb b/app/helpers/answers_helper.rb index ca4b3f5ee..8def57c31 100644 --- a/app/helpers/answers_helper.rb +++ b/app/helpers/answers_helper.rb @@ -1,5 +1,9 @@ # Provides helper methods for use by views under AnswersController. module AnswersHelper + ## + # Returns the current user's vote for the specified post, or nil if no user is signed in. + # @param answer [Post] The post for which to find a vote. + # @return [Vote, nil] def my_vote(answer) user_signed_in? ? answer.votes.where(user: current_user).first : nil end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 852c41c1f..72aa26132 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -2,22 +2,43 @@ module ApplicationHelper include Warden::Test::Helpers + ## + # Is the current user a moderator on the current community? + # @return [Boolean] def moderator? user_signed_in? && (current_user.is_moderator || current_user.is_admin) end + ## + # Is the current user an admin on the current community? + # @return [Boolean] def admin? user_signed_in? && current_user.is_admin end + ## + # Checks if the current user has a specified privilege on a post. + # @param post [Post] A post to use as context for the privilege. + # @param privilege [String] The +internal_id+ of the privilege to query. + # @return [Boolean] def check_your_post_privilege(post, privilege) - current_user&.has_post_privilege?(privilege, post) + !current_user.nil? && current_user&.has_post_privilege?(privilege, post) end + ## + # Check if the current user has a specified privilege. + # @param privilege [String] The +internal_id+ of the privilege to query. + # @return [Boolean] def check_your_privilege(privilege) - current_user&.privilege?(privilege) + !current_user.nil? && current_user&.privilege?(privilege) end + ## + # Utility to add additional query parameters to a URI. + # @param base_url [String, nil] A base URI to which to add parameters. If none is specified then the request URI for + # the current page will be used. + # @param params [Hash{#to_s => #to_s}] A hash of query parameters to add to the URI. + # @return [String] The stringified URI. def query_url(base_url = nil, **params) uri = URI.parse(request.original_url) query = Rack::Utils.parse_nested_query uri.query @@ -34,14 +55,27 @@ def query_url(base_url = nil, **params) uri.to_s end + ## + # Creates a link to the community's default content license based on site settings. + # @return [ActiveSupport::SafeBuffer] The result of the +link_to+ call. def license_link link_to SiteSetting['ContentLicenseName'], SiteSetting['ContentLicenseLink'] end + ## + # Checks if the search parameter specified is the currently active search. + # @param param [String] The search parameter. + # @return [Boolean] def active_search?(param) $active_search_param == param&.to_sym end + ## + # Creates a panel for display of a single statistic. Used in reports. + # @param heading [String] A title for the panel. + # @param value [#to_s] The statistic value. + # @param caption [String] A short explanatory caption to display. + # @return [ActiveSupport::SafeBuffer] def stat_panel(heading, value, caption: nil) tag.div class: 'stat-panel' do tag.h4(heading, class: 'stat-panel-heading') + @@ -50,20 +84,32 @@ def stat_panel(heading, value, caption: nil) end end + ## + # Converts a number to short-form humanized display, i.e. 100,000 = 100k. Parameters as for + # {ActiveSupport::NumberHelper#number_to_human}[https://www.rubydoc.info/gems/activesupport/ActiveSupport/NumberHelper#number_to_human-instance_method] + # @return [String, nil] The humanized number. def short_number_to_human(*args, **opts) opts = { units: { thousand: 'k', million: 'm', billion: 'b', trillion: 't', quadrillion: 'qd' }, format: '%n%u' }.merge(opts) ActiveSupport::NumberHelper.number_to_human(*args, **opts) end + ## + # Render a markdown string to HTML with consistent options. + # @param markdown [String] The markdown string to render. + # @return [String] The rendered HTML string. def render_markdown(markdown) CommonMarker.render_doc(markdown, [:FOOTNOTES, :LIBERAL_HTML_TAG, :STRIKETHROUGH_DOUBLE_TILDE], [:table, :strikethrough, :autolink]).to_html(:UNSAFE) end + ## + # Strip Markdown formatting out of a text string to use in plain-text only environments. # This isn't a perfect way to strip out Markdown, so it should only be used for non-critical things like # page descriptions - things that will later be supplemented by the full formatted content. + # @param markdown [String] The Markdown string to strip. + # @return [String] The plain-text equivalent. def strip_markdown(markdown) # Remove block-level formatting: headers, hr, references, images, HTML tags markdown = markdown.gsub(/(?:^#+ +|^-{3,}|^\[[^\]]+\]: ?.+$|^!\[[^\]]+\](?:\([^)]+\)|\[[^\]]+\])$|<[^>]+>)/, '') @@ -75,14 +121,24 @@ def strip_markdown(markdown) markdown.gsub(/!?\[([^\]]+)\](?:\([^)]+\)|\[[^\]]+\])/, '\1') end + ## + # Returns a list of top-level post type IDs. + # @return [Array] def top_level_post_types post_type_ids(is_top_level: true) end + ## + # Returns a list of second-level post type IDs. + # @return [Array] def second_level_post_types post_type_ids(is_top_level: false, has_parent: true) end + ## + # Gets a shareable URL to the specified post, taking into account post type. + # @param post [Post] The post in question. + # @return [String] def generic_share_link(post) if second_level_post_types.include?(post.post_type_id) answer_post_url(id: post.parent_id, answer: post.id, anchor: "answer-#{post.id}") @@ -91,23 +147,44 @@ def generic_share_link(post) end end + ## + # Get a shareable link to the specified post in Markdown format. + # @param post [Post] The post in question. + # @return [String] The Markdown-formatted link. def generic_share_link_md(post) "[#{post.title}](#{generic_share_link(post)})" end + ## + # Get a shareable link to a point in the specified post's history. + # @param post [Post] The post in question. + # @param history [ActiveRecord::Collection] The post's history. + # @param index [Integer] The index of the history event to link to. + # @return [String] def post_history_share_link(post, history, index) post_history_url(post, anchor: history.size - index) end + ## + # Get a shareable link to a point in the specified post's history, in Markdown form. + # Parameters as for {#post_history_share_link}. def post_history_share_link_md(post, history, index) rev_num = history.size - index "[Revision #{rev_num} — #{post.title}](#{post_history_share_link(post, history, index)})" end + ## + # Get a link to edit the specified post. + # @param post [Post] The post to link to. + # @return [String] def generic_edit_link(post) edit_post_url(post) end + ## + # Get a link to the specified post. Also works for help and policy documents. + # @param post [Post] The post to link to. + # @return [String] def generic_show_link(post) if top_level_post_types.include? post.post_type_id post_url(post) @@ -125,6 +202,11 @@ def generic_show_link(post) end end + ## + # Split a string after a specified number of characters, only breaking at word boundaries. + # @param text [String] The text to split. + # @param max_length [Integer] The maximum number of characters to leave in the resulting strings. + # @return [Array] def split_words_max_length(text, max_length) words = text.split splat = [[]] @@ -138,15 +220,34 @@ def split_words_max_length(text, max_length) splat.map { |s| s.join(' ') } end + ## + # Check if the specified item is deleted. + # @param item [ApplicationRecord] The item to check. + # @return [Boolean] def deleted_item?(item) case item.class.to_s - when 'Post', 'Comment' + when 'Post', 'Comment', 'CommunityUser' item.deleted + when 'User' + item.deleted || item.community_user.deleted else false end end + ## + # Translate a given string using {I18n#t}[https://www.rubydoc.info/gems/i18n/I18n/Base#translate-instance_method], + # after substituting values into the string based on a hash. + # @param key [String, Symbol] The translation key as passed to I18n#t. + # @param subs [Hash{#to_s => #to_s}] A list of substitutions to apply - keys of the form +:name+ in the string should + # have a corresponding +name+ entry in this hash and will be substituted for the value. + # @return [String] + # @example + # # In I18n config: + # # user_post_count: 'You have :count posts on this community.' + # helpers.i18ns('user_post_count', count: @posts.size) + # # => 'You have 23 posts on this community.' + # # or as appropriate based on locale def i18ns(key, **subs) s = I18n.t key subs.each do |f, r| @@ -155,16 +256,26 @@ def i18ns(key, **subs) s end + ## + # Get a list of network promoted posts, ignoring expired entries. + # @return [Hash{Integer => Integer}] A hash of post IDs as keys, and Unix entry timestamp as values. def promoted_posts JSON.parse(RequestContext.redis.get('network/promoted_posts') || '{}') .select { |_k, v| DateTime.now.to_i - v <= 3600 * 24 * 28 } end + ## + # Is the network in read-only mode? + # @return [Boolean] def read_only? RequestContext.redis.get('network/read_only') == 'true' end - # Redefine Devise helpers so that we can additionally check for deleted profiles/users. + ## + # Redefined Devise current_user helper. Additionally checks for deleted users - if the current user has been soft + # deleted, this will sign them out. As +current_user+ is called on every page in the header, this has the effect of + # immediately signing the user out even if they're signed in when their account is deleted. + # @return [User, nil] def current_user return nil unless defined?(warden) @@ -177,12 +288,15 @@ def current_user @current_user end + ## + # Is there a user signed in on this request? + # @return [Boolean] def user_signed_in? !!current_user && !current_user.deleted? && !current_user.community_user&.deleted? end # Check if the current request is a direct user request, or a resource load. - # @return true if the request is direct, false if not, or nil if it cannot be determined + # @return [Boolean, nil] true if the request is direct, false if not, or nil if it cannot be determined def direct_request? if request.headers['Sec-Fetch-Mode'].present? && request.headers['Sec-Fetch-Mode'] == 'navigate' true @@ -191,6 +305,9 @@ def direct_request? end end + ## + # Get the current active commit information to display in the footer. + # @return [Array(String, DateTime)] Two values: the commit hash and the timestamp. def current_commit commit_info = Rails.cache.persistent('current_commit') shasum, timestamp = commit_info diff --git a/app/models/user.rb b/app/models/user.rb index 694a0e4b3..169b26da7 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -64,6 +64,7 @@ def trust_level # This class makes heavy use of predicate names, and their use is prevalent throughout the codebase # because of the importance of these methods. # rubocop:disable Naming/PredicateName + def has_post_privilege?(name, post) if post.user == self true From e81776e00d5d38841acf84b77512fc9dd208ce66 Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Thu, 24 Oct 2024 00:37:43 +0100 Subject: [PATCH 04/13] Of course Rubocop has a problem with it --- app/helpers/application_helper.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 72aa26132..9b4dda34a 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -84,6 +84,8 @@ def stat_panel(heading, value, caption: nil) end end + # rubocop:disable Layout/LineLength because obviously rubocop has a problem with documentation + ## # Converts a number to short-form humanized display, i.e. 100,000 = 100k. Parameters as for # {ActiveSupport::NumberHelper#number_to_human}[https://www.rubydoc.info/gems/activesupport/ActiveSupport/NumberHelper#number_to_human-instance_method] @@ -94,6 +96,8 @@ def short_number_to_human(*args, **opts) ActiveSupport::NumberHelper.number_to_human(*args, **opts) end + # rubocop:enable Layout/LineLength + ## # Render a markdown string to HTML with consistent options. # @param markdown [String] The markdown string to render. From f611e861ab73d1ec2bdecaec5667eb7439bee830 Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Thu, 24 Oct 2024 16:46:18 +0100 Subject: [PATCH 05/13] 3 more helpers commented --- app/helpers/categories_helper.rb | 14 ++++++++++++++ app/helpers/comments_helper.rb | 23 ++++++++++++++++++++++- app/helpers/edit_helper.rb | 3 +++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/app/helpers/categories_helper.rb b/app/helpers/categories_helper.rb index 7efbc1617..3d83df30a 100644 --- a/app/helpers/categories_helper.rb +++ b/app/helpers/categories_helper.rb @@ -1,10 +1,17 @@ module CategoriesHelper + ## + # Checks if the specified category is the currently active page. + # @param category [Category] + # @return [Boolean] def active?(category) current_category current_page?(category_url(category)) || (category.is_homepage && current_page?(root_url)) || (defined?(@current_category) && @current_category&.id == category.id) end + ## + # Checks if the current page is within the scope of a category and can use an expanded header. + # @return [Boolean] def expandable? (defined?(@category) && !@category&.id.nil? && !current_page?(new_category_url)) || (defined?(@post) && !@post&.category.nil?) || @@ -12,6 +19,9 @@ def expandable? (defined?(@article) && !@article&.category.nil?) end + ## + # Sets and returns the currently-active category. + # @return [Category] def current_category @current_category ||= if defined?(@category) && !@category&.id.nil? @category @@ -24,6 +34,10 @@ def current_category end end + ## + # Checks if there are any pending edit suggestions in the current category. + # @note Cached - cache is manually broken when new suggestions are created. + # @return [Boolean] def pending_suggestions? Rails.cache.fetch "pending_suggestions/#{current_category.id}" do SuggestedEdit.where(post: Post.undeleted.where(category: current_category), active: true).any? diff --git a/app/helpers/comments_helper.rb b/app/helpers/comments_helper.rb index 548a269c9..863b7660b 100644 --- a/app/helpers/comments_helper.rb +++ b/app/helpers/comments_helper.rb @@ -1,4 +1,9 @@ +# Helpers related to comments. module CommentsHelper + ## + # Get a link to the specified comment, accounting for deleted comments. + # @param comment [Comment] + # @return [String] def comment_link(comment) if comment.deleted comment_thread_url(comment.comment_thread_id, show_deleted_comments: 1, anchor: "comment-#{comment.id}", @@ -8,6 +13,12 @@ def comment_link(comment) end end + ## + # Process a comment and convert ping-strings (i.e. @#1234) into links. + # @param comment [String] The text of the comment to process. + # @param pingable [Array, nil] A list of user IDs that should be pingable in this comment. Any user IDs not + # present in the list will be displayed as 'unpingable'. + # @return [ActiveSupport::SafeBuffer] def render_pings(comment, pingable: nil) comment.gsub(/@#\d+/) do |id| u = User.where(id: id[2..-1].to_i).first @@ -22,6 +33,11 @@ def render_pings(comment, pingable: nil) end.html_safe end + ## + # Process comment text and convert helper links (like [help] and [flags]) into real links. + # @param comment_text [String] The text of the comment to process. + # @param user [User] Specify a user whose pages to link to from user-related helpers. + # @return [String] def render_comment_helpers(comment_text, user = current_user) comment_text.gsub!(/\[(help( center)?)\]/, "\\1") @@ -53,6 +69,11 @@ def render_comment_helpers(comment_text, user = current_user) comment_text end + ## + # Get a list of user IDs who should be pingable in a specified comment thread. This combines the post author, answer + # authors, recent history event authors, recent comment authors on the post (in any thread), and all thread followers. + # @param thread [CommentThread] + # @return [Array] def get_pingable(thread) post = thread.post @@ -61,7 +82,6 @@ def get_pingable(thread) # last 500 history event users + # last 500 comment authors + # all thread followers - query = <<~END_SQL SELECT posts.user_id FROM posts WHERE posts.id = #{post.id} UNION DISTINCT @@ -78,6 +98,7 @@ def get_pingable(thread) end end +# HTML sanitizer for use with comments. class CommentScrubber < Rails::Html::PermitScrubber def initialize super diff --git a/app/helpers/edit_helper.rb b/app/helpers/edit_helper.rb index db4b05ad4..c9b47e072 100644 --- a/app/helpers/edit_helper.rb +++ b/app/helpers/edit_helper.rb @@ -1,4 +1,7 @@ module EditHelper + ## + # Get the maximum edit comment length for the current community, with a maximum of 255. + # @return [Integer] def max_edit_comment_length [SiteSetting['MaxEditCommentLength'] || 255, 255].min end From 974e11590a5fef8857597c5db5eeed6852422db0 Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Thu, 24 Oct 2024 17:44:00 +0100 Subject: [PATCH 06/13] More helpers --- app/helpers/markdown_tools_helper.rb | 24 +++++++++++++++++++++++ app/helpers/moderator_helper.rb | 9 +++++++++ app/helpers/post_types_helper.rb | 29 +++++++++++++++++++++++++++- 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/app/helpers/markdown_tools_helper.rb b/app/helpers/markdown_tools_helper.rb index 9dbcaa85d..47411775f 100644 --- a/app/helpers/markdown_tools_helper.rb +++ b/app/helpers/markdown_tools_helper.rb @@ -1,4 +1,23 @@ module MarkdownToolsHelper + ## + # Create a Markdown tool button. + # @param name [String] A name to display on the button. If you want to use an icon instead, pass a block and leave + # +name+ as +nil+ (default). + # @param action [String] Populates the button's +data-action+ attribute, which can be processed later via Markdown JS. + # @param label [String] Populates the button's +title+ and +aria-label+ attributes. + # @param attribs [Hash{#to_s => Object}] A hash of additional attributes to pass to the tag generator. + # @yieldparam context [ActionView::Helpers::TagHelper::TagBuilder] + # @yieldreturn [String, ActiveSupport::SafeBuffer] + # @return [ActiveSupport::SafeBuffer] + # @example Create a Bold button with icon: + # <%= md_button action: 'bold', label: 'Bold', data: { index: 1 } do %> + # + # <% end %> + # + # # => + # + # def md_button(name = nil, action: nil, label: nil, **attribs, &block) attribs.merge! href: 'javascript:void(0)', class: "#{attribs[:class] || ''} button is-muted is-outlined js-markdown-tool", @@ -14,6 +33,11 @@ def md_button(name = nil, action: nil, label: nil, **attribs, &block) end end + # Create a Markdown tool list item. Identical to + # @param (see #md_button) + # @yieldparam (see #md_button) + # @yieldreturn (see #md_button) + # @return (see #md_button) def md_list_item(name = nil, action: nil, label: nil, **attribs, &block) attribs.merge! href: 'javascript:void(0)', class: "#{attribs[:class] || ''}js-markdown-tool", diff --git a/app/helpers/moderator_helper.rb b/app/helpers/moderator_helper.rb index d99995a88..c76cfd0eb 100644 --- a/app/helpers/moderator_helper.rb +++ b/app/helpers/moderator_helper.rb @@ -1,5 +1,14 @@ # Provides helper methods for use by views under ModeratorController. module ModeratorHelper + ## + # Display text on a specified background color. + # @param cls [String] The background color class. + # @param content [String] The text to display. For uses beyond simple text, pass a block instead. + # @option opts :class [String] Additional classes to add to the element. For instance, if the background color is dark, + # consider passing a class for a light text color. + # @yieldparam context [ActionView::Helpers::TagHelper::TagBuilder] + # @yieldreturn [ActiveSupport::SafeBuffer, String] + # @return [ActiveSupport::SafeBuffer] def text_bg(cls, content = nil, **opts, &block) if block_given? tag.span class: ["has-background-color-#{cls}", opts[:class]].join(' '), &block diff --git a/app/helpers/post_types_helper.rb b/app/helpers/post_types_helper.rb index 144a2ab2d..1b0313361 100644 --- a/app/helpers/post_types_helper.rb +++ b/app/helpers/post_types_helper.rb @@ -1,14 +1,41 @@ module PostTypesHelper + ## + # Create a badge to display the specified post type. + # @param type [PostType] + # @return [ActiveSupport::SafeBuffer] def post_type_badge(type) tag.span class: 'badge is-tag is-filled is-muted' do - tag.i(class: type.icon_name) + ' ' + tag.span(type.name) # rubocop:disable Style/StringConcatenation + "#{tag.i(class: type.icon_name)} #{tag.span(type.name)}" end end + ## + # Get a list of predicate post type attributes (i.e. is_* and has_* attributes). + # @api private + # @return [Array] def post_type_criteria PostType.new.attributes.keys.select { |k| k.start_with?('has_') || k.start_with?('is_') }.map(&:to_sym) end + ## + # Get a list of post type IDs matching specified criteria. Available criteria are based on predicate attributes on the + # post_types table (i.e. +has_*+ and +is_*+ attributes). + # @option opts :has_answers [Boolean] + # @option opts :has_votes [Boolean] + # @option opts :has_tags [Boolean] + # @option opts :has_parent [Boolean] + # @option opts :has_category [Boolean] + # @option opts :has_license [Boolean] + # @option opts :is_public_editable [Boolean] + # @option opts :is_closeable [Boolean] + # @option opts :is_top_level [Boolean] + # @option opts :is_freely_editable [Boolean] + # @option opts :has_reactions [Boolean] + # @option opts :has_only_specific_reactions [Boolean] + # @return [Array] + # @example Query for IDs of top-level post types which are freely editable and have reactions: + # helpers.post_type_ids(is_top_level: true, is_freely_editable: true, has_reactions: true) + # # => [12, 23, 49] def post_type_ids(**opts) key = post_type_criteria.map { |a| opts[a] ? '1' : '0' }.join Rails.cache.fetch "network/post_types/post_type_ids/#{key}", include_community: false do From 014e0395370a30583dee21f222844b754fe2bdd7 Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Thu, 24 Oct 2024 18:57:04 +0100 Subject: [PATCH 07/13] More helpers --- app/helpers/posts_helper.rb | 40 +++++++++++++++++++++++++-------- app/helpers/questions_helper.rb | 4 ++++ 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/app/helpers/posts_helper.rb b/app/helpers/posts_helper.rb index 9fb810fd5..2cb3be976 100644 --- a/app/helpers/posts_helper.rb +++ b/app/helpers/posts_helper.rb @@ -1,8 +1,18 @@ module PostsHelper + ## + # Get markdown submitted for a post - should only be used in Markdown create/edit requests. Prioritises using the + # client-side rendered HTML over rendering server-side. + # @param scope [Symbol] The parameter scope for the markdown - i.e. if the form submits it as +posts[body_markdown]+, + # this should be +:posts+. + # @param field_name [Symbol] The parameter name for the markdown - i.e. +:body_markdown+ in the same example. + # @return [String] def post_markdown(scope, field_name) params['__html'].presence || render_markdown(params[scope][field_name]) end + ## + # Get the redirect path to use when the user cancels an edit. + # @return [String] def cancel_redirect_path(post) if post.id.present? post_url(post) @@ -15,21 +25,28 @@ def cancel_redirect_path(post) end end - # @param category [Category, Nil] - # @return [Integer] the minimum length for post bodies + ## + # Get the minimum body length for the specified category. + # @param category [Category, nil] + # @return [Integer] def min_body_length(category) category&.min_body_length || 30 end - # @param _category [Category, Nil] - # @return [Integer] the maximum length for post bodies + ## + # Get the maximum body length for the specified category. Returns a constant 30,000 at present but intended to return + # a configurable value in the future. + # @param _category [Category, nil] + # @return [Integer] def max_body_length(_category) 30_000 end - # @param category [Category, Nil] post category, if any - # @param post_type [PostType] type of the post (system limits are relaxed) - # @return [Integer] the minimum length for post titles + ## + # Get the minimum title length for the specified category. + # @param category [Category, nil] + # @param post_type [PostType] Type of the post (system limits are relaxed) + # @return [Integer] def min_title_length(category, post_type) if post_type.system? 1 @@ -38,8 +55,10 @@ def min_title_length(category, post_type) end end - # @param _category [Category, Nil] - # @return [Integer] the maximum length for post titles + ## + # Get the maximum title length for the specified category. Has a hard limit of 255 characters. + # @param _category [Category, nil] + # @return [Integer] def max_title_length(_category) [SiteSetting['MaxTitleLength'] || 255, 255].min end @@ -58,6 +77,9 @@ def skip_node?(node) end end + ## + # Get a post scrubber instance. + # @return [PostScrubber] def scrubber PostsHelper::PostScrubber.new end diff --git a/app/helpers/questions_helper.rb b/app/helpers/questions_helper.rb index 915531073..32623f996 100644 --- a/app/helpers/questions_helper.rb +++ b/app/helpers/questions_helper.rb @@ -1,5 +1,9 @@ # Provides helper methods for use by views under QuestionsController. module QuestionsHelper + ## + # Returns the current user's vote for the specified post, or nil if no user is signed in. + # @param question [Post] The post for which to find a vote. + # @return [Vote, nil] def my_vote(question) user_signed_in? ? question.votes.where(user: current_user).first : nil end From b2dccce633e832708d6c48acd283b7b620d2bf99 Mon Sep 17 00:00:00 2001 From: Oleg Valter Date: Sun, 27 Oct 2024 02:03:03 +0300 Subject: [PATCH 08/13] I swear, officer, I wasn't making my lines too long --- app/helpers/moderator_helper.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/helpers/moderator_helper.rb b/app/helpers/moderator_helper.rb index c76cfd0eb..a472c57ea 100644 --- a/app/helpers/moderator_helper.rb +++ b/app/helpers/moderator_helper.rb @@ -4,8 +4,8 @@ module ModeratorHelper # Display text on a specified background color. # @param cls [String] The background color class. # @param content [String] The text to display. For uses beyond simple text, pass a block instead. - # @option opts :class [String] Additional classes to add to the element. For instance, if the background color is dark, - # consider passing a class for a light text color. + # @option opts :class [String] Additional classes to add to the element. + # For instance, if the background color is dark, consider passing a class for a light text color. # @yieldparam context [ActionView::Helpers::TagHelper::TagBuilder] # @yieldreturn [ActiveSupport::SafeBuffer, String] # @return [ActiveSupport::SafeBuffer] From 33fa560bf4dcbb2de25c9190d1a599d3da2aaaef Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Tue, 3 Dec 2024 12:44:28 +0000 Subject: [PATCH 09/13] Document search helper --- app/helpers/search_helper.rb | 45 +++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index a655eef1f..115d12c90 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -1,4 +1,14 @@ module SearchHelper + ## + # Search & sort a default posts list based on parameters in the current request. + # + # Generates initial post list using {Post#qa_only}, including deleted posts for mods and admins. Takes search string + # from params[:search], applies any qualifiers, and searches post bodies for the remaining term(s). + # + # Search uses MySQL fulltext search in boolean mode which is what provides advanced search syntax (excluding + # qualifiers) - see {MySQL manual 14.9.2}[https://dev.mysql.com/doc/refman/8.4/en/fulltext-boolean.html]. + # + # @return [ActiveRecord::Relation] def search_posts # Check permissions posts = (current_user&.is_moderator || current_user&.is_admin ? Post : Post.undeleted) @@ -26,7 +36,11 @@ def search_posts end end - # Convert a Filter record into a form parseable by the search function + ## + # Converts a Filter record into a form parseable by the search function. + # @param filter [Filter] + # @return [Array Object}>] An array of hashes, each containing at least a +param+ key and other + # relevant information. def filter_to_qualifiers(filter) qualifiers = [] qualifiers.append({ param: :score, operator: '>=', value: filter.min_score }) unless filter.min_score.nil? @@ -39,6 +53,9 @@ def filter_to_qualifiers(filter) qualifiers end + ## + # Provides a filter-like object containing keys for each of the filter parameters. + # @return [Hash{Symbol => #to_s}] def active_filter { default: nil, @@ -53,6 +70,9 @@ def active_filter } end + ## + # Retrieves parameters from +params+, validates their values, and adds them to a qualifiers hash. + # @return [Array Object}>] def params_to_qualifiers valid_value = { date: /^[\d.]+(?:s|m|h|d|w|mo|y)?$/, @@ -94,6 +114,9 @@ def params_to_qualifiers filter_qualifiers end + ## + # Parses a raw search string and returns the base search term and qualifier strings separately. + # @return [Hash{Symbol => String}] A hash containing +:qualifiers+ and +:search+ keys. def parse_search(raw_search) qualifiers_regex = /([\w\-_]+(? Object}>] def parse_qualifier_strings(qualifiers) valid_value = { date: /^[<>=]{0,2}[\d.]+(?:s|m|h|d|w|mo|y)?$/, @@ -182,6 +210,11 @@ def parse_qualifier_strings(qualifiers) # Consider partitioning and telling the user which filters were invalid end + ## + # Parses a qualifiers hash and applies it to an ActiveRecord query. + # @param qualifiers [Array Object}>] A qualifiers hash, as returned by other methods in this module. + # @param query [ActiveRecord::Relation] An ActiveRecord query to which to add conditions based on the qualifiers. + # @return [ActiveRecord::Relation] def qualifiers_to_sql(qualifiers, query) trust_level = current_user&.trust_level || 0 allowed_categories = Category.where('IFNULL(min_view_trust_level, -1) <= ?', trust_level) @@ -235,6 +268,11 @@ def qualifiers_to_sql(qualifiers, query) end # rubocop:enable Metrics/CyclomaticComplexity + ## + # Parses a qualifier value string, including operator, as a numeric value. + # @param value [String] The value part of the qualifier, i.e. +">=10"+ + # @return [Array(String, String)] A 2-tuple containing operator and value. + # @api private def numeric_value_sql(value) operator = '' while ['<', '>', '='].include? value[0] @@ -247,6 +285,11 @@ def numeric_value_sql(value) [operator, value] end + ## + # Parses a qualifier value string, including operator, as a date value. + # @param value [String] The value part of the qualifier, i.e. +">=10d"+ + # @return [Array(String, String, String)] A 3-tuple containing operator, value, and timeframe. + # @api private def date_value_sql(value) operator = '' From 86fdcf824a3fca1e0a6a6de19975722c70a0dc3d Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Tue, 3 Dec 2024 12:54:16 +0000 Subject: [PATCH 10/13] Document tabs helper --- app/helpers/tabs_helper.rb | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/app/helpers/tabs_helper.rb b/app/helpers/tabs_helper.rb index 43327aea4..83943731a 100644 --- a/app/helpers/tabs_helper.rb +++ b/app/helpers/tabs_helper.rb @@ -1,4 +1,8 @@ module TabsHelper + ## + # Build a tab container in which to place tabs. Intended for use with a block using {#tab}. + # @yield Yields to a block that may modify +@building_tabs+ to add tabs. + # @return [ActiveSupport::SafeBuffer] def tabs @building_tabs = [] yield @@ -7,7 +11,16 @@ def tabs tag.div raw(tabs), class: 'tabs' end - def tab(text, link_url, **opts, &block) + ## + # Build a tab and add it to the container generated by {#tabs}. + # @param text [String] Link text. + # @param link_url [String] Link URL. + # @option opts :is_active [Boolean] Is the tab active? + # @option opts :class [String] Additional classes to add to the tab. + # @yieldreturn [ActiveSupport::SafeBuffer] Pass an optional block instead of +text+ and +link_url+ to customize the + # content of the tab link. + # @return [Array] The in-progress +@building_tabs+ array. + def tab(text = nil, link_url = nil, **opts, &block) active = opts[:is_active] || false opts.delete :is_active opts[:class] = if opts[:class] From c28305042b9af6dfc213da99ef88318ab3ea9c56 Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Tue, 3 Dec 2024 12:59:46 +0000 Subject: [PATCH 11/13] Document tags helper --- app/helpers/tags_helper.rb | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/app/helpers/tags_helper.rb b/app/helpers/tags_helper.rb index b68d28566..faa604957 100644 --- a/app/helpers/tags_helper.rb +++ b/app/helpers/tags_helper.rb @@ -1,4 +1,11 @@ module TagsHelper + ## + # Sort a list of tags by importance within the context of a category's set of required, moderator, and topic tags. + # @param tags [ActiveRecord::Relation] A list of tags. + # @param required_ids [Array] A list of required tag IDs. + # @param topic_ids [Array] A list of topic tag IDs. + # @param moderator_ids [Array] A list of moderator-only tag IDs. + # @return [Array] def category_sort_tags(tags, required_ids, topic_ids, moderator_ids) tags .to_a @@ -8,6 +15,11 @@ def category_sort_tags(tags, required_ids, topic_ids, moderator_ids) end end + ## + # Generate a list of classes to be applied to a tag. + # @param tag [Tag] + # @param category [Category] The category within the context of which the tag is being displayed. + # @return [String] def tag_classes(tag, category) required_ids = category&.required_tag_ids moderator_ids = category&.moderator_tag_ids @@ -18,6 +30,10 @@ def tag_classes(tag, category) "badge is-tag #{required} #{topic} #{moderator}" end + ## + # Get a list of post IDs that belong to any of the specified tag IDs. + # @param tag_ids [Array] A list of tag IDs. + # @return [Array] A list of post IDs. def post_ids_for_tags(tag_ids) sql = "SELECT post_id FROM posts_tags WHERE tag_id IN #{ApplicationRecord.sanitize_sql_in(tag_ids)}" ActiveRecord::Base.connection.execute(sql).to_a.flatten From a7148a263a990ffc2f20766ccf905f389d57c051 Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Tue, 3 Dec 2024 13:19:52 +0000 Subject: [PATCH 12/13] Document uploads helper --- app/helpers/uploads_helper.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/helpers/uploads_helper.rb b/app/helpers/uploads_helper.rb index 87d9daa0a..ff31a7729 100644 --- a/app/helpers/uploads_helper.rb +++ b/app/helpers/uploads_helper.rb @@ -6,6 +6,10 @@ def upload_remote_url(blob) "https://s3.amazonaws.com/#{bucket}/#{blob.is_a?(String) ? blob : blob.key}" end + ## + # Test if the given IO object is a valid image file by content type, extension, and content test. + # @param io [File] The file to test. + # @return [Boolean] def valid_image?(io) content_types = Rails.application.config.active_storage.web_image_content_types extensions = content_types.map { |ct| ct.gsub('image/', '') } From 58f3a9d6904e0da1de36977f782bfd4dee6455f6 Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Tue, 3 Dec 2024 13:38:42 +0000 Subject: [PATCH 13/13] Helpers are done! --- app/helpers/users_helper.rb | 64 ++++++++++++++++++++++++++++++++++--- 1 file changed, 60 insertions(+), 4 deletions(-) diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index a689060d0..9fcf7e7cd 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -1,5 +1,11 @@ # Provides helper methods for use by views under UsersController. module UsersHelper + ## + # Get a URL to the avatar for the selected user. + # @param user [User] + # @param size [Integer] Image side length, in pixels. Does not apply to uploaded avatars - size attributes must still + # be set in HTML. + # @return [String] def avatar_url(user, size = 16) if deleted_user?(user) user_auto_avatar_url(letter: 'X', color: '#E73737FF', size: size, format: :png) @@ -10,16 +16,27 @@ def avatar_url(user, size = 16) end end + ## + # Get an OAuth URL to Stack Exchange. + # @return [String] def stack_oauth_url "https://stackoverflow.com/oauth?client_id=#{SiteSetting['SEApiClientId']}" \ "&scope=&redirect_uri=#{stack_redirect_url}" end + ## + # Can the specified user change a post's category to the specified target category? + # @param user [User] + # @param target [Category] + # @return [Boolean] def can_change_category(user, target) user.privilege?('flag_curate') && (user.is_moderator || user.is_admin || target.min_trust_level.nil? || target.min_trust_level <= user.trust_level) end + ## + # Generate