Files
redux-scraper/app/helpers/domain/posts_helper.rb
2025-07-25 19:15:58 +00:00

536 lines
16 KiB
Ruby

# 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