# typed: strict module Domain::PostsHelper extend T::Sig extend T::Helpers include HelpersInterface include LogEntriesHelper include Domain::UsersHelper include PathsHelper include Domain::DomainsHelper include Domain::DomainModelHelper include Pundit::Authorization require "base64" abstract! class DomainData < T::Struct include T::Struct::ActsAsComparable const :domain_icon_path, String const :domain_icon_title, String end DEFAULT_DOMAIN_DATA = DomainData.new( domain_icon_path: "generic-domain.svg", domain_icon_title: "Unknown", ) DOMAIN_DATA = T.let( { Domain::DomainType::Fa => DomainData.new( domain_icon_path: "domain-icons/fa.png", domain_icon_title: "Furaffinity", ), Domain::DomainType::E621 => DomainData.new( domain_icon_path: "domain-icons/e621.png", domain_icon_title: "E621", ), Domain::DomainType::Inkbunny => DomainData.new( domain_icon_path: "domain-icons/inkbunny.png", domain_icon_title: "Inkbunny", ), Domain::DomainType::Sofurry => DomainData.new( domain_icon_path: "domain-icons/sofurry.png", domain_icon_title: "SoFurry", ), }, T::Hash[Domain::DomainType, DomainData], ) sig { params(model: HasDomainType).returns(String) } def domain_model_icon_path(model) path = if (domain_data = DOMAIN_DATA[model.domain_type]) domain_data.domain_icon_path else DEFAULT_DOMAIN_DATA.domain_icon_path end asset_path(path) end sig { params(post: Domain::Post).returns(T.nilable(Domain::PostFile)) } def gallery_file_for_post(post) file = post.primary_file_for_view return nil unless file.present? return nil unless file.state_ok? || file.last_status_code == 200 return nil unless file.log_entry_id.present? content_type = file.log_entry&.content_type return nil unless content_type.present? return nil unless is_thumbable_content_type?(content_type) file end sig { params(post: Domain::Post).returns(T.any(T.nilable(String), Symbol)) } def gallery_file_info_for_post(post) return :post_pending if post.pending_scan? file = post.primary_file_for_view return nil unless file.present? return :file_pending if file.state_pending? return nil unless file.state_ok? return nil unless file.log_entry_id.present? content_type = file.log_entry&.content_type || "" pretty_content_type(content_type) end sig { params(post: Domain::Post).returns(T.nilable(String)) } def thumbnail_for_post_path(post) return nil unless policy(post).view_file? file = gallery_file_for_post(post) return nil unless file.present? return nil unless file.state_ok? return nil unless file.log_entry_id.present? if (log_entry = file.log_entry) && (response_sha256 = log_entry.response_sha256) blob_path(HexUtil.bin2hex(response_sha256), format: "jpg", thumb: "small") end end # Create a data URI thumbnail from an image file sig do params(file_path: String, content_type: String, max_size: Integer).returns( T.nilable(String), ) end def create_image_thumbnail_data_uri(file_path, content_type, max_size = 180) # Load the Vips library properly instead of using require directly begin # Load the image image = ::Vips::Image.new_from_file(file_path) # Calculate the scaling factor to keep within max_size scale = [max_size.to_f / image.width, max_size.to_f / image.height].min # Only scale down, not up scale = 1.0 if scale > 1.0 # Resize the image (use nearest neighbor for speed as this is just a thumbnail) thumbnail = image.resize(scale) # Get the image data in the original format # For JPEG use quality 85 for a good balance of quality vs size output_format = content_type.split("/").last case output_format when "jpeg", "jpg" image_data = thumbnail.write_to_buffer(".jpg", Q: 85) when "png" image_data = thumbnail.write_to_buffer(".png", compression: 6) when "gif" image_data = thumbnail.write_to_buffer(".gif") else # Default to JPEG if format not recognized image_data = thumbnail.write_to_buffer(".jpg", Q: 85) content_type = "image/jpeg" end # Create the data URI "data:#{content_type};base64,#{Base64.strict_encode64(image_data)}" rescue => e Rails.logger.error("Error creating thumbnail: #{e.message}") nil end end sig { params(content_type: String).returns(String) } def pretty_content_type(content_type) case content_type when %r{text/plain} "Plain Text Document" when %r{application/pdf} "PDF Document" when %r{application/msword} "Microsoft Word Document" when %r{application/vnd\.openxmlformats-officedocument\.wordprocessingml\.document} "Microsoft Word Document (OpenXML)" when %r{application/vnd\.oasis\.opendocument\.text} "OpenDocument Text" when %r{application/rtf} "Rich Text Document" when %r{image/jpeg} "JPEG Image" when %r{image/png} "PNG Image" when %r{image/gif} "GIF Image" when %r{video/webm} "Webm Video" when %r{audio/mpeg} "MP3 Audio" when %r{audio/mp3} "MP3 Audio" when %r{audio/wav} "WAV Audio" else content_type.split(";").first&.split("/")&.last&.titleize || "Unknown" end end sig { params(post: Domain::Post).returns(T.nilable(String)) } def gallery_file_size_for_post(post) file = post.primary_file_for_view return nil unless file.present? return nil unless file.state_ok? return nil unless file.log_entry_id.present? file.log_entry&.response_size&.then { |size| number_to_human_size(size) } end sig { params(url: String).returns(T.nilable(String)) } def icon_asset_for_url(url) domain = extract_domain(url) return nil unless domain icon_path_for_domain(domain) end sig { params(category: Symbol).returns(String) } def tailwind_tag_category_class(category) case category when :general "bg-blue-300" # Light blue when :artist "bg-indigo-300" # Light indigo when :copyright "bg-purple-300" # Light purple when :character "bg-green-300" # Light green when :species "bg-teal-300" # Light teal when :invalid "bg-slate-300" # Medium gray when :meta "bg-amber-300" # Light amber when :lore "bg-cyan-300" # Light cyan else "bg-white" # White (default) end end sig { returns(T::Array[Symbol]) } def tag_category_order TAG_CATEGORY_ORDER end sig { params(category: Symbol).returns(T.nilable(String)) } def font_awesome_category_icon(category) case category when :artist "fa-brush" when :species "fa-paw" when :character "fa-user" when :copyright "fa-copyright" when :general "fa-tag" when :lore "fa-book" when :meta "fa-info" when :invalid "fa-ban" end end class LinkForSource < T::ImmutableStruct include T::Struct::ActsAsComparable const :model, ReduxApplicationRecord const :title, String const :model_path, String const :icon_path, T.nilable(String) end class SourceResult < T::ImmutableStruct include T::Struct::ActsAsComparable const :model, ReduxApplicationRecord const :title, String end class SourceMatcher < T::ImmutableStruct extend T::Generic include T::Struct::ActsAsComparable const :hosts, T::Array[String] const :patterns, T::Array[Regexp] const :find_proc, T .proc .params(helper: Domain::PostsHelper, match: MatchData, url: String) .returns(T.nilable(SourceResult)) end FA_HOSTS = %w[*.furaffinity.net furaffinity.net] FA_CDN_HOSTS = %w[d.furaffinity.net *.facdn.net facdn.net] IB_HOSTS = %w[*.inkbunny.net inkbunny.net] IB_CDN_HOSTS = %w[*.ib.metapix.net ib.metapix.net] E621_HOSTS = %w[www.e621.net e621.net] URL_SUFFIX_QUERY = T.let(<<-SQL.strip.chomp.freeze, String) lower('url_str') = lower(?) SQL MATCHERS = T.let( [ # Furaffinity posts SourceMatcher.new( hosts: FA_HOSTS, patterns: [ %r{/view/(\d+)/?}, %r{/full/(\d+)/?}, %r{/controls/submissions/changeinfo/(\d+)/?}, ], find_proc: ->(helper, match, _) do if post = Domain::Post::FaPost.find_by(fa_id: match[1]) SourceResult.new(model: post, title: post.title_for_view) end end, ), # Furaffinity posts via direct file URL SourceMatcher.new( hosts: FA_CDN_HOSTS, patterns: [/.+/], find_proc: ->(helper, _, url) do url = Addressable::URI.parse(url) post_file = Domain::PostFile.where( "lower('url_str') IN (?, ?, ?, ?, ?, ?)", "d.furaffinity.net#{url.host}/#{url.path}", "//d.furaffinity.net#{url.host}/#{url.path}", "https://d.furaffinity.net#{url.host}/#{url.path}", "d.facdn.net#{url.host}/#{url.path}", "//d.facdn.net#{url.host}/#{url.path}", "https://d.facdn.net#{url.host}/#{url.path}", ).first if post_file && (post = post_file.post) SourceResult.new(model: post, title: post.title_for_view) end end, ), # Furaffinity users SourceMatcher.new( hosts: FA_HOSTS, patterns: [%r{/user/([^/]+)/?}], find_proc: ->(helper, match, _) do if user = Domain::User::FaUser.find_by(url_name: match[1]) SourceResult.new( model: user, title: user.name_for_view || "unknown", ) end end, ), # Inkbunny posts SourceMatcher.new( hosts: IB_HOSTS, patterns: [%r{/s/(\d+)/?}, %r{/submissionview\.php\?id=(\d+)/?}], find_proc: ->(helper, match, _) do if post = Domain::Post::InkbunnyPost.find_by(ib_id: match[1]) SourceResult.new(model: post, title: post.title_for_view) end end, ), # Inkbunny posts via direct file URL SourceMatcher.new( hosts: IB_CDN_HOSTS, patterns: [//], find_proc: ->(helper, _, url) do url = Addressable::URI.parse(url) if post_file = Domain::PostFile.where( "#{URL_SUFFIX_QUERY}", "ib.metapix.net#{url.path}", ).first if post = post_file.post SourceResult.new(model: post, title: post.title_for_view) end end end, ), # Inkbunny users SourceMatcher.new( hosts: IB_HOSTS, patterns: [%r{/(\w+)/?$}], find_proc: ->(_, match, _) do if user = Domain::User::InkbunnyUser.where( "name = lower(?)", match[1], ).first SourceResult.new( model: user, title: user.name_for_view || "unknown", ) end end, ), # E621 posts SourceMatcher.new( hosts: E621_HOSTS, patterns: [%r{/posts/(\d+)/?}], find_proc: ->(helper, match, _) do if post = Domain::Post::E621Post.find_by(e621_id: match[1]) SourceResult.new(model: post, title: post.title_for_view) end end, ), # E621 users SourceMatcher.new( hosts: E621_HOSTS, patterns: [%r{/users/(\d+)/?}], find_proc: ->(helper, match, _) do if user = Domain::User::E621User.find_by(e621_id: match[1]) SourceResult.new( model: user, title: user.name_for_view || "unknown", ) end end, ), ], T::Array[SourceMatcher], ) sig { params(source: String).returns(T.nilable(LinkForSource)) } def link_for_source(source) return nil if source.blank? # normalize the source to a lowercase string with a protocol source.downcase! source = "https://" + source unless source.include?("://") begin uri = URI.parse(source) rescue StandardError return nil end uri_host = uri.host return nil if uri_host.blank? for matcher in MATCHERS if matcher.hosts.any? { |host| File.fnmatch?(host, uri_host, File::FNM_PATHNAME) } for pattern in matcher.patterns if (match = pattern.match(uri.to_s)) object = matcher.find_proc.call(self, match, uri.to_s) return nil unless object model = object.model if model.is_a?(Domain::Post) model_path = Rails.application.routes.url_helpers.domain_post_path(model) elsif model.is_a?(Domain::User) model_path = Rails.application.routes.url_helpers.domain_user_path(model) icon_path = domain_user_avatar_img_src_path( model.avatar, thumb: "64-avatar", ) else model_path = "#" end return( LinkForSource.new( model:, title: object.title, model_path:, icon_path:, ) ) end end end end nil end sig { params(post: Domain::Post::FaPost).returns(T::Array[String]) } def keywords_for_fa_post(post) post.keywords.map(&:strip).reject(&:blank?).compact end sig { params(post: Domain::Post::InkbunnyPost).returns(T::Array[String]) } def keywords_for_ib_post(post) post.keywords.map { |keyword| keyword["keyword_name"] }.compact end sig do params(time: T.nilable(T.any(ActiveSupport::TimeWithZone, Time))).returns( String, ) end def time_ago_in_words_no_prefix(time) return "never" if time.nil? time = time.in_time_zone if time.is_a?(Time) time_ago_in_words(time).delete_prefix("over ").delete_prefix("about ") end sig do params(faved_at_type: Domain::UserPostFav::FavedAtType).returns(String) end def faved_at_type_icon(faved_at_type) case faved_at_type when Domain::UserPostFav::FavedAtType::PostedAt "fa-clock" # Clock icon for fallback to posted_at when Domain::UserPostFav::FavedAtType::Explicit "fa-calendar-check" # Calendar check for explicitly set time when Domain::UserPostFav::FavedAtType::Inferred "fa-chart-line" # Chart line for inferred from regression model when Domain::UserPostFav::FavedAtType::InferredNow "fa-bolt" # Lightning bolt for computed on the fly end end sig do params(faved_at_type: Domain::UserPostFav::FavedAtType).returns(String) end def faved_at_type_tooltip(faved_at_type) case faved_at_type when Domain::UserPostFav::FavedAtType::PostedAt "Estimated from posted date" when Domain::UserPostFav::FavedAtType::Explicit "Exact time recorded" when Domain::UserPostFav::FavedAtType::Inferred "Estimated from regression model" when Domain::UserPostFav::FavedAtType::InferredNow "Estimated in real-time from regression model" end end private sig { params(url: String).returns(T.nilable(String)) } def extract_domain(url) URI.parse(url).host rescue URI::InvalidURIError nil end TAG_CATEGORY_ORDER = T.let( %i[artist copyright character species general meta lore invalid].freeze, T::Array[Symbol], ) end