654 lines
19 KiB
Ruby
654 lines
19 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",
|
|
),
|
|
Domain::DomainType::Bluesky =>
|
|
DomainData.new(
|
|
domain_icon_path: "domain-icons/bluesky.png",
|
|
domain_icon_title: "Bluesky",
|
|
),
|
|
},
|
|
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 do
|
|
params(
|
|
post_files: T::Array[Domain::PostFile],
|
|
initial_file_index: T.nilable(Integer),
|
|
).returns(T::Hash[Symbol, T.untyped])
|
|
end
|
|
def props_for_post_files(post_files:, initial_file_index: nil)
|
|
files_data =
|
|
post_files.map.with_index do |post_file, index|
|
|
thumbnail_path = nil
|
|
content_html = nil
|
|
log_entry = post_file.log_entry
|
|
|
|
if log_entry && (log_entry.status_code == 200)
|
|
if (response_sha256 = log_entry.response_sha256)
|
|
thumbnail_path = {
|
|
type: "url",
|
|
value:
|
|
blob_path(
|
|
HexUtil.bin2hex(response_sha256),
|
|
format: "jpg",
|
|
thumb: "small",
|
|
),
|
|
}
|
|
end
|
|
|
|
# Generate content HTML
|
|
content_html =
|
|
ApplicationController.renderer.render(
|
|
partial: "log_entries/content_container",
|
|
locals: {
|
|
log_entry: log_entry,
|
|
},
|
|
assigns: {
|
|
current_user: nil,
|
|
},
|
|
)
|
|
elsif post_file.state_pending?
|
|
thumbnail_path = {
|
|
type: "icon",
|
|
value: "fa-solid fa-file-arrow-down",
|
|
}
|
|
end
|
|
|
|
{
|
|
id: post_file.id,
|
|
fileState: post_file.state,
|
|
thumbnailPath: thumbnail_path,
|
|
hasContent: post_file.log_entry&.status_code == 200,
|
|
index: index,
|
|
contentHtml: content_html,
|
|
fileDetails:
|
|
(
|
|
if log_entry
|
|
{
|
|
contentType: log_entry.content_type,
|
|
fileSize: log_entry.response_size,
|
|
responseTimeMs: log_entry.response_time_ms,
|
|
responseStatusCode: log_entry.status_code,
|
|
postFileState: post_file.state,
|
|
logEntryId: log_entry.id,
|
|
logEntryPath: log_entry_path(log_entry),
|
|
}
|
|
else
|
|
nil
|
|
end
|
|
),
|
|
}
|
|
end
|
|
|
|
# Validate initial_file_index
|
|
validated_initial_index = 0
|
|
if initial_file_index && initial_file_index >= 0 &&
|
|
initial_file_index < post_files.count
|
|
validated_initial_index = initial_file_index
|
|
end
|
|
|
|
{ files: files_data, initialSelectedIndex: validated_initial_index }
|
|
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]
|
|
BLUESKY_HOSTS = %w[bsky.app]
|
|
|
|
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,
|
|
),
|
|
# Bluesky posts
|
|
SourceMatcher.new(
|
|
hosts: BLUESKY_HOSTS,
|
|
patterns: [%r{/profile/([^/]+)/post/(\w+)}],
|
|
find_proc: ->(helper, match, _) do
|
|
handle_or_did = match[1]
|
|
post_rkey = match[2]
|
|
if handle_or_did.start_with?("did:")
|
|
did = handle_or_did
|
|
else
|
|
user = Domain::User::BlueskyUser.find_by(handle: handle_or_did)
|
|
did = user&.did
|
|
end
|
|
next unless did
|
|
at_uri = "at://#{did}/app.bsky.feed.post/#{post_rkey}"
|
|
post = Domain::Post::BlueskyPost.find_by(at_uri:)
|
|
SourceResult.new(model: post, title: post.title_for_view) if post
|
|
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 do
|
|
params(post: Domain::Post::InkbunnyPost).returns(
|
|
T.nilable(T::Array[String]),
|
|
)
|
|
end
|
|
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
|
|
|
|
sig { returns(T::Hash[Symbol, T.untyped]) }
|
|
def props_for_visual_search_form
|
|
{
|
|
actionUrl:
|
|
Rails.application.routes.url_helpers.visual_results_domain_posts_path,
|
|
csrfToken: form_authenticity_token,
|
|
}
|
|
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
|