このコミットが含まれているのは:
@@ -1,49 +0,0 @@
|
||||
class PostImportsController < ApplicationController
|
||||
before_action :require_member!
|
||||
|
||||
def preview
|
||||
rows = PostImportPreviewer.new.preview_rows(
|
||||
rows: PostImportUrlListParser.parse(params[:source]))
|
||||
render json: { rows: }
|
||||
rescue ArgumentError => e
|
||||
render_bad_request e.message
|
||||
end
|
||||
|
||||
def validate
|
||||
rows = normalised_import_rows allow_warning_fields: true
|
||||
changed_row =
|
||||
if params[:changed_row].to_s == 'all'
|
||||
true
|
||||
else
|
||||
Integer(params[:changed_row], exception: false)
|
||||
end
|
||||
result =
|
||||
PostImportPreviewer.new.preview_rows(rows:,
|
||||
fetch_metadata: changed_row,
|
||||
metadata_cache: { })
|
||||
render json: { rows: result }
|
||||
rescue ArgumentError => e
|
||||
render_bad_request e.message
|
||||
end
|
||||
|
||||
def create
|
||||
result = PostImportRunner.new(actor: current_user,
|
||||
rows: normalised_import_rows).run
|
||||
render json: result, status: result[:created].positive? ? :created : :ok
|
||||
rescue ArgumentError => e
|
||||
render_bad_request e.message
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def require_member!
|
||||
return head :unauthorized unless current_user
|
||||
return if current_user.gte_member?
|
||||
|
||||
head :forbidden
|
||||
end
|
||||
|
||||
def normalised_import_rows allow_warning_fields: false
|
||||
PostImportRowNormaliser.normalise!(params[:rows], allow_warning_fields:)
|
||||
end
|
||||
end
|
||||
@@ -114,6 +114,37 @@ class PostsController < ApplicationController
|
||||
render json: PostRepr.base(post, current_user)
|
||||
end
|
||||
|
||||
def preview
|
||||
return head :unauthorized unless current_user
|
||||
return head :forbidden unless current_user.gte_member?
|
||||
return render_bad_request('URL は必須です.') if params[:url].blank?
|
||||
|
||||
preview = PostImportPreviewer.new.preview_rows(
|
||||
rows: [{
|
||||
source_row: 1,
|
||||
url: params[:url].to_s,
|
||||
attributes: { },
|
||||
provenance: { },
|
||||
tag_sources: { } }],
|
||||
fetch_metadata: true).first
|
||||
return render_validation_error fields: preview[:validation_errors] if preview[:validation_errors].present?
|
||||
|
||||
render json: {
|
||||
url: preview[:url],
|
||||
title: preview[:attributes]['title'],
|
||||
thumbnail_base: preview[:attributes]['thumbnail_base'],
|
||||
tags: preview[:attributes]['tags'],
|
||||
original_created_from: preview[:attributes]['original_created_from'],
|
||||
original_created_before: preview[:attributes]['original_created_before'],
|
||||
duration: preview[:attributes]['duration'],
|
||||
video_ms: preview[:attributes]['video_ms'],
|
||||
field_warnings: preview[:field_warnings],
|
||||
base_warnings: preview[:base_warnings],
|
||||
existing_post: compact_post(preview[:existing_post_id]) }
|
||||
rescue ArgumentError => e
|
||||
render_bad_request e.message
|
||||
end
|
||||
|
||||
def show
|
||||
post =
|
||||
Post
|
||||
@@ -142,17 +173,21 @@ class PostsController < ApplicationController
|
||||
return head :unauthorized unless current_user
|
||||
return head :forbidden unless current_user.gte_member?
|
||||
|
||||
if bool?(:dry)
|
||||
preflight = PostCreatePreflight.new(
|
||||
attributes: post_create_attributes,
|
||||
thumbnail: params[:thumbnail]).run
|
||||
return render json: preflight
|
||||
end
|
||||
|
||||
post = PostCreator.new(actor: current_user,
|
||||
attributes: { title: params[:title], url: params[:url],
|
||||
thumbnail: params[:thumbnail], tags: params[:tags],
|
||||
original_created_from: params[:original_created_from],
|
||||
original_created_before: params[:original_created_before],
|
||||
parent_post_ids: parse_parent_post_ids,
|
||||
video_ms: params[:video_ms],
|
||||
duration: params[:duration] }).create!
|
||||
attributes: post_create_attributes.merge(
|
||||
thumbnail: params[:thumbnail])).create!
|
||||
|
||||
post.reload
|
||||
render json: PostRepr.base(post), status: :created
|
||||
rescue PostCreatePreflight::ValidationFailed => e
|
||||
render_validation_error fields: e.fields, base: e.base_errors
|
||||
rescue Tag::NicoTagNormalisationError
|
||||
render_validation_error fields: { tags: 'ニコニコ・タグは直接指定できません.' }
|
||||
rescue Tag::DeprecatedTagNormalisationError
|
||||
@@ -161,6 +196,8 @@ class PostsController < ApplicationController
|
||||
render_validation_error fields: { tags: ['タグ区間の記法が不正です.'] }
|
||||
rescue PostCreator::VideoMsParseError
|
||||
render_validation_error fields: { video_ms: ['動画時間の記法が不正です.'] }
|
||||
rescue Post::RemoteThumbnailFetchFailed
|
||||
render_validation_error fields: { thumbnail_base: ['サムネイル画像の取得に失敗しました.'] }
|
||||
rescue MiniMagick::Error
|
||||
render_validation_error fields: { thumbnail: ['サムネイル画像の変換に失敗しました.'] }
|
||||
rescue ArgumentError => e
|
||||
@@ -169,6 +206,22 @@ class PostsController < ApplicationController
|
||||
render_post_form_record_invalid e.record
|
||||
end
|
||||
|
||||
def bulk
|
||||
return head :unauthorized unless current_user
|
||||
return head :forbidden unless current_user.gte_member?
|
||||
return head :unsupported_media_type unless request.content_mime_type == Mime[:multipart_form]
|
||||
|
||||
result = PostBulkCreator.new(
|
||||
actor: current_user,
|
||||
posts: parse_bulk_posts_manifest,
|
||||
thumbnails: parse_bulk_thumbnails).run
|
||||
render json: result
|
||||
rescue JSON::ParserError
|
||||
render_bad_request 'posts manifest の JSON が不正です.'
|
||||
rescue ArgumentError => e
|
||||
render_validation_error base: [e.message]
|
||||
end
|
||||
|
||||
def viewed
|
||||
return head :unauthorized unless current_user
|
||||
|
||||
@@ -463,6 +516,63 @@ class PostsController < ApplicationController
|
||||
}.uniq
|
||||
end
|
||||
|
||||
def post_create_attributes
|
||||
{ title: params[:title],
|
||||
url: params[:url],
|
||||
thumbnail_base: params[:thumbnail_base],
|
||||
tags: params[:tags],
|
||||
original_created_from: params[:original_created_from],
|
||||
original_created_before: params[:original_created_before],
|
||||
parent_post_ids: parse_parent_post_ids,
|
||||
video_ms: params[:video_ms],
|
||||
duration: params[:duration] }
|
||||
end
|
||||
|
||||
def parse_bulk_posts_manifest
|
||||
manifest = params[:posts]
|
||||
raise ArgumentError, 'posts は必須です.' if manifest.blank?
|
||||
|
||||
posts = JSON.parse(manifest)
|
||||
raise ArgumentError, 'posts は配列で指定してください.' unless posts.is_a?(Array)
|
||||
raise ArgumentError, '投稿件数は 1 件以上必要です.' if posts.empty?
|
||||
raise ArgumentError, '投稿件数が多すぎます.' if posts.length > 100
|
||||
raise ArgumentError, 'posts 要素の形式が不正です.' unless posts.all? { _1.is_a?(Hash) }
|
||||
|
||||
posts
|
||||
end
|
||||
|
||||
def parse_bulk_thumbnails
|
||||
thumbnails = { }
|
||||
post_count = Array(JSON.parse(params[:posts].presence || '[]')).length
|
||||
|
||||
params.to_unsafe_h.each do |key, value|
|
||||
match = key.match(/\Athumbnails\[(\d+)\]\z/)
|
||||
next if match.nil?
|
||||
|
||||
index = Integer(match[1], 10)
|
||||
raise ArgumentError, 'thumbnail index が範囲外です.' if index.negative? || index >= post_count
|
||||
|
||||
thumbnails[index] = value
|
||||
end
|
||||
|
||||
thumbnails
|
||||
end
|
||||
|
||||
def compact_post post_id
|
||||
return nil if post_id.blank?
|
||||
|
||||
post = Post.with_attached_thumbnail.find_by(id: post_id)
|
||||
return nil if post.nil?
|
||||
|
||||
{ id: post.id,
|
||||
title: post.title,
|
||||
url: post.url,
|
||||
thumbnail_url:
|
||||
if post.thumbnail.attached?
|
||||
rails_storage_proxy_url(post.thumbnail, only_path: false)
|
||||
end }
|
||||
end
|
||||
|
||||
def sync_parent_posts! post, parent_post_ids
|
||||
if parent_post_ids.include?(post.id)
|
||||
post.errors.add :parent_post_ids, '自分自身を親投稿にはできません.'
|
||||
|
||||
@@ -38,27 +38,6 @@ class PreviewController < ApplicationController
|
||||
render_unprocessable_entity(e.message)
|
||||
end
|
||||
|
||||
def image
|
||||
return render_bad_request('URL は必須です.') if params[:url].blank?
|
||||
|
||||
attachment = Post.resized_thumbnail_attachment(
|
||||
StringIO.new(Preview::ThumbnailFetcher.fetch_image_response(params[:url]).body))
|
||||
attachment[:io].rewind
|
||||
send_data attachment[:io].read,
|
||||
type: attachment[:content_type],
|
||||
disposition: 'inline'
|
||||
rescue Preview::UrlSafety::UnsafeUrl => e
|
||||
render_bad_request(e.message)
|
||||
rescue Preview::HttpFetcher::FetchTimeout => e
|
||||
render_preview_error(e.message, :gateway_timeout)
|
||||
rescue Preview::HttpFetcher::ResponseTooLarge => e
|
||||
render_preview_error(e.message, :payload_too_large)
|
||||
rescue Preview::HttpFetcher::FetchFailed => e
|
||||
render_preview_error(e.message, :bad_gateway)
|
||||
rescue Preview::ThumbnailFetcher::GenerationFailed, MiniMagick::Error => e
|
||||
render_unprocessable_entity(e.message)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def require_member!
|
||||
|
||||
+13
-11
@@ -27,6 +27,18 @@ class Post < ApplicationRecord
|
||||
upload.rewind
|
||||
end
|
||||
|
||||
def self.remote_thumbnail_attachment(raw_url)
|
||||
response = Preview::ThumbnailFetcher.fetch_image_response(raw_url)
|
||||
resized_thumbnail_attachment(StringIO.new(response.body))
|
||||
rescue Preview::UrlSafety::UnsafeUrl,
|
||||
Preview::ThumbnailFetcher::GenerationFailed,
|
||||
Preview::HttpFetcher::FetchFailed,
|
||||
Preview::HttpFetcher::FetchTimeout,
|
||||
Preview::HttpFetcher::ResponseTooLarge,
|
||||
MiniMagick::Error => e
|
||||
raise RemoteThumbnailFetchFailed, e.message
|
||||
end
|
||||
|
||||
belongs_to :uploaded_user, class_name: 'User', optional: true
|
||||
|
||||
has_many :post_tags, dependent: :destroy, inverse_of: :post
|
||||
@@ -157,17 +169,7 @@ class Post < ApplicationRecord
|
||||
end
|
||||
|
||||
def attach_thumbnail_from_url! raw_url
|
||||
response = Preview::ThumbnailFetcher.fetch_image_response(raw_url)
|
||||
thumbnail.attach(
|
||||
self.class.resized_thumbnail_attachment(
|
||||
StringIO.new(response.body)))
|
||||
rescue Preview::UrlSafety::UnsafeUrl,
|
||||
Preview::ThumbnailFetcher::GenerationFailed,
|
||||
Preview::HttpFetcher::FetchFailed,
|
||||
Preview::HttpFetcher::FetchTimeout,
|
||||
Preview::HttpFetcher::ResponseTooLarge,
|
||||
MiniMagick::Error => e
|
||||
raise RemoteThumbnailFetchFailed, e.message
|
||||
thumbnail.attach(self.class.remote_thumbnail_attachment(raw_url))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
class PostBulkCreator
|
||||
def initialize actor:, posts:, thumbnails:
|
||||
@actor = actor
|
||||
@posts = posts
|
||||
@thumbnails = thumbnails
|
||||
end
|
||||
|
||||
def run
|
||||
results = @posts.each_with_index.map { |attributes, index|
|
||||
create_row(attributes, index)
|
||||
}
|
||||
|
||||
{ results: }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_row attributes, index
|
||||
preflight =
|
||||
PostCreatePreflight.new(
|
||||
attributes: attributes,
|
||||
thumbnail: thumbnail_for(index, attributes)).run
|
||||
if preflight[:existing_post].present?
|
||||
return {
|
||||
status: 'skipped',
|
||||
existing_post: preflight[:existing_post] }
|
||||
end
|
||||
|
||||
post = PostCreator.new(
|
||||
actor: @actor,
|
||||
attributes: normalised_attributes(attributes, preflight, index)).create!
|
||||
result = {
|
||||
status: 'created',
|
||||
post: { id: post.id } }
|
||||
result[:field_warnings] = preflight[:field_warnings] if preflight[:field_warnings].present?
|
||||
result[:base_warnings] = preflight[:base_warnings] if preflight[:base_warnings].present?
|
||||
result
|
||||
rescue PostCreatePreflight::ValidationFailed => e
|
||||
{
|
||||
status: 'failed',
|
||||
recoverable: true,
|
||||
errors: e.fields,
|
||||
base_errors: e.base_errors }
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
existing_post = existing_post_for_race(attributes, e.record)
|
||||
if existing_post.present?
|
||||
return {
|
||||
status: 'skipped',
|
||||
existing_post: existing_post }
|
||||
end
|
||||
|
||||
{
|
||||
status: 'failed',
|
||||
recoverable: true,
|
||||
errors: e.record.errors.to_hash,
|
||||
base_errors: e.record.errors[:base] }
|
||||
rescue ActiveRecord::RecordNotUnique => e
|
||||
raise unless e.message.include?('index_posts_on_url')
|
||||
|
||||
existing_post = existing_post_for_race(attributes)
|
||||
raise if existing_post.nil?
|
||||
|
||||
{
|
||||
status: 'skipped',
|
||||
existing_post: existing_post }
|
||||
rescue Tag::NicoTagNormalisationError
|
||||
{
|
||||
status: 'failed',
|
||||
recoverable: true,
|
||||
errors: { tags: ['ニコニコ・タグは直接指定できません.'] },
|
||||
base_errors: [] }
|
||||
rescue Tag::DeprecatedTagNormalisationError
|
||||
{
|
||||
status: 'failed',
|
||||
recoverable: true,
|
||||
errors: { tags: ['廃止済みタグは付与できません.'] },
|
||||
base_errors: [] }
|
||||
rescue PostCreator::VideoMsParseError
|
||||
{
|
||||
status: 'failed',
|
||||
recoverable: true,
|
||||
errors: { video_ms: ['動画時間の記法が不正です.'] },
|
||||
base_errors: [] }
|
||||
rescue Post::RemoteThumbnailFetchFailed
|
||||
{
|
||||
status: 'failed',
|
||||
recoverable: true,
|
||||
errors: { thumbnail_base: ['サムネイル画像の取得に失敗しました.'] },
|
||||
base_errors: [] }
|
||||
rescue ArgumentError => e
|
||||
{
|
||||
status: 'failed',
|
||||
recoverable: true,
|
||||
errors: { base: [e.message] },
|
||||
base_errors: [] }
|
||||
rescue StandardError => e
|
||||
Rails.logger.error(
|
||||
"post_bulk_creator_failure #{ { error: e.class.name,
|
||||
message: e.message }.to_json }")
|
||||
{
|
||||
status: 'failed',
|
||||
recoverable: false,
|
||||
errors: { base: ['登録中にエラーが発生しました.'] },
|
||||
base_errors: [] }
|
||||
end
|
||||
|
||||
def normalised_attributes attributes, preflight, index
|
||||
{
|
||||
url: preflight[:url],
|
||||
title: preflight[:title],
|
||||
thumbnail_base: preflight[:thumbnail_base],
|
||||
thumbnail: thumbnail_for(index, attributes),
|
||||
tags: preflight[:tags],
|
||||
parent_post_ids: preflight[:parent_post_ids],
|
||||
original_created_from: preflight[:original_created_from],
|
||||
original_created_before: preflight[:original_created_before],
|
||||
duration: preflight[:duration],
|
||||
video_ms: preflight[:video_ms] }
|
||||
end
|
||||
|
||||
def thumbnail_for index, attributes
|
||||
return nil if attributes['thumbnail_base'].present? || attributes[:thumbnail_base].present?
|
||||
|
||||
@thumbnails[index]
|
||||
end
|
||||
|
||||
def existing_post_for_race attributes, record = nil
|
||||
return nil if record.present? && !(record.errors.of_kind?(:url, :taken))
|
||||
|
||||
normal_url = PostUrlNormaliser.normalise(attributes['url'] || attributes[:url])
|
||||
return nil if normal_url.blank?
|
||||
|
||||
compact_existing_post(Post.with_attached_thumbnail.find_by(url: normal_url))
|
||||
end
|
||||
|
||||
def compact_existing_post post
|
||||
return nil if post.nil?
|
||||
|
||||
{
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
url: post.url,
|
||||
thumbnail_url:
|
||||
if post.thumbnail.attached?
|
||||
Rails.application.routes.url_helpers.rails_storage_proxy_url(
|
||||
post.thumbnail,
|
||||
only_path: false)
|
||||
end }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,104 @@
|
||||
class PostCreatePreflight
|
||||
class ValidationFailed < StandardError
|
||||
attr_reader :fields, :base_errors
|
||||
|
||||
def initialize fields: { }, base_errors: []
|
||||
super('入力内容を確認してください.')
|
||||
@fields = fields
|
||||
@base_errors = base_errors
|
||||
end
|
||||
end
|
||||
|
||||
def initialize attributes:, thumbnail: nil
|
||||
@attributes = attributes.symbolize_keys
|
||||
@thumbnail = thumbnail
|
||||
end
|
||||
|
||||
def run
|
||||
preview = PostImportPreviewer.new.preview_rows(
|
||||
rows: [preview_row],
|
||||
fetch_metadata: false).first
|
||||
|
||||
validate_thumbnail_upload!
|
||||
|
||||
if preview[:validation_errors].present?
|
||||
raise ValidationFailed.new(fields: preview[:validation_errors])
|
||||
end
|
||||
|
||||
{
|
||||
url: preview[:url],
|
||||
title: preview[:attributes]['title'],
|
||||
thumbnail_base: preview[:attributes]['thumbnail_base'],
|
||||
tags: preview[:attributes]['tags'],
|
||||
parent_post_ids: preview[:attributes]['parent_post_ids'],
|
||||
original_created_from: preview[:attributes]['original_created_from'],
|
||||
original_created_before: preview[:attributes]['original_created_before'],
|
||||
duration: preview[:attributes]['duration'],
|
||||
video_ms: preview[:attributes]['video_ms'],
|
||||
field_warnings: preview[:field_warnings],
|
||||
base_warnings: preview[:base_warnings],
|
||||
existing_post: existing_post_compact(preview[:existing_post_id]) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def preview_row
|
||||
{
|
||||
source_row: 1,
|
||||
url: @attributes[:url].to_s,
|
||||
attributes: {
|
||||
'title' => @attributes[:title].to_s,
|
||||
'thumbnail_base' => @attributes[:thumbnail_base].to_s,
|
||||
'original_created_from' => @attributes[:original_created_from].to_s,
|
||||
'original_created_before' => @attributes[:original_created_before].to_s,
|
||||
'duration' => @attributes[:duration].to_s,
|
||||
'video_ms' => @attributes[:video_ms],
|
||||
'tags' => @attributes[:tags].to_s,
|
||||
'parent_post_ids' => parent_post_ids_text },
|
||||
provenance: {
|
||||
'url' => 'manual',
|
||||
'title' => 'manual',
|
||||
'thumbnail_base' => 'manual',
|
||||
'original_created_from' => 'manual',
|
||||
'original_created_before' => 'manual',
|
||||
'duration' => 'manual',
|
||||
'video_ms' => 'manual',
|
||||
'tags' => 'manual',
|
||||
'parent_post_ids' => 'manual' },
|
||||
tag_sources: {
|
||||
'automatic' => '',
|
||||
'manual' => @attributes[:tags].to_s } }
|
||||
end
|
||||
|
||||
def parent_post_ids_text
|
||||
Array(@attributes[:parent_post_ids]).flat_map { _1.to_s.split }.join(' ')
|
||||
end
|
||||
|
||||
def validate_thumbnail_upload!
|
||||
return if @attributes[:thumbnail_base].present?
|
||||
return if @thumbnail.blank?
|
||||
|
||||
attachment = Post.resized_thumbnail_attachment(@thumbnail)
|
||||
attachment[:io].close if attachment[:io].respond_to?(:close)
|
||||
rescue MiniMagick::Error
|
||||
raise ValidationFailed.new(fields: { thumbnail: ['サムネイル画像の変換に失敗しました.'] })
|
||||
end
|
||||
|
||||
def existing_post_compact post_id
|
||||
return nil if post_id.blank?
|
||||
|
||||
post = Post.with_attached_thumbnail.find_by(id: post_id)
|
||||
return nil if post.nil?
|
||||
|
||||
{
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
url: post.url,
|
||||
thumbnail_url:
|
||||
if post.thumbnail.attached?
|
||||
Rails.application.routes.url_helpers.rails_storage_proxy_url(
|
||||
post.thumbnail,
|
||||
only_path: false)
|
||||
end }
|
||||
end
|
||||
end
|
||||
@@ -10,16 +10,17 @@ class PostCreator
|
||||
end
|
||||
|
||||
def create!
|
||||
thumbnail_attachment = prepare_thumbnail_attachment
|
||||
post = Post.new(title: @attributes[:title].presence,
|
||||
url: @attributes[:url],
|
||||
thumbnail_base: @attributes[:thumbnail_base].presence,
|
||||
uploaded_user: @actor,
|
||||
original_created_from: @attributes[:original_created_from].presence,
|
||||
original_created_before: @attributes[:original_created_before].presence)
|
||||
attach_thumbnail!(post)
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
post.save!
|
||||
post.thumbnail.attach(thumbnail_attachment) if thumbnail_attachment.present?
|
||||
Tag.normalise_tags!(tag_names, deny_deprecated: true, with_sections: true) =>
|
||||
{ tags:, sections: }
|
||||
TagVersioning.record_tag_snapshots!(tags, created_by_user: @actor)
|
||||
@@ -36,19 +37,10 @@ class PostCreator
|
||||
|
||||
private
|
||||
|
||||
def attach_thumbnail! post
|
||||
thumbnail = @attributes[:thumbnail]
|
||||
if thumbnail.present?
|
||||
post.thumbnail.attach(Post.resized_thumbnail_attachment(thumbnail))
|
||||
return
|
||||
end
|
||||
|
||||
thumbnail_base = post.thumbnail_base
|
||||
return if thumbnail_base.blank?
|
||||
|
||||
post.attach_thumbnail_from_url!(thumbnail_base)
|
||||
rescue Post::RemoteThumbnailFetchFailed => e
|
||||
@field_warnings[:thumbnail_base] = [e.message]
|
||||
def prepare_thumbnail_attachment
|
||||
PostThumbnailAttachmentBuilder.build(
|
||||
thumbnail: @attributes[:thumbnail],
|
||||
thumbnail_base: @attributes[:thumbnail_base].presence)
|
||||
end
|
||||
|
||||
def tag_names = @attributes[:tags].to_s.split
|
||||
|
||||
@@ -7,6 +7,7 @@ class PostImportPreviewer
|
||||
'thumbnail_base',
|
||||
'original_created_from',
|
||||
'original_created_before',
|
||||
'video_ms',
|
||||
'duration',
|
||||
'tags',
|
||||
'parent_post_ids'].freeze
|
||||
@@ -162,6 +163,8 @@ class PostImportPreviewer
|
||||
normalised =
|
||||
if field == 'duration'
|
||||
normalise_duration_attribute(value)
|
||||
elsif field == 'video_ms'
|
||||
value.nil? ? '' : value.to_s
|
||||
else
|
||||
value.to_s
|
||||
end
|
||||
@@ -194,7 +197,7 @@ class PostImportPreviewer
|
||||
|
||||
def clear_automatic_values! attributes, provenance, tag_sources
|
||||
['title', 'thumbnail_base', 'original_created_from',
|
||||
'original_created_before', 'duration'].each do |field|
|
||||
'original_created_before', 'video_ms', 'duration'].each do |field|
|
||||
attributes[field] = '' if provenance[field] == 'automatic'
|
||||
end
|
||||
tag_sources['automatic'] = ''
|
||||
@@ -466,7 +469,7 @@ class PostImportPreviewer
|
||||
thumbnail_base: attributes['thumbnail_base'].presence,
|
||||
original_created_from: attributes['original_created_from'].presence,
|
||||
original_created_before: attributes['original_created_before'].presence,
|
||||
video_ms: parse_duration(attributes['duration'], errors))
|
||||
video_ms: parse_video_ms(attributes, errors))
|
||||
post.valid?
|
||||
post.errors.each do |error|
|
||||
next if error.attribute == :url && error.type == :taken
|
||||
@@ -475,7 +478,19 @@ class PostImportPreviewer
|
||||
end
|
||||
end
|
||||
|
||||
def parse_duration value, errors
|
||||
def parse_video_ms attributes, errors
|
||||
video_ms = attributes['video_ms']
|
||||
if video_ms.present?
|
||||
value = Integer(video_ms, exception: false)
|
||||
if value&.positive?
|
||||
return value
|
||||
end
|
||||
|
||||
errors[:video_ms] = ['動画時間の記法が不正です.']
|
||||
return nil
|
||||
end
|
||||
|
||||
value = attributes['duration']
|
||||
return nil if value.blank?
|
||||
|
||||
Tag.time_to_ms!(value.to_s, tag_name: '動画時間')
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
class PostImportRowNormaliser
|
||||
ORIGINS = ['automatic', 'manual'].freeze
|
||||
STRING_FIELDS = [
|
||||
'title',
|
||||
'thumbnail_base',
|
||||
'original_created_from',
|
||||
'original_created_before',
|
||||
'duration',
|
||||
'tags',
|
||||
'parent_post_ids'].freeze
|
||||
FLEXIBLE_FIELDS = ['video_ms'].freeze
|
||||
ATTRIBUTE_FIELDS = (STRING_FIELDS + FLEXIBLE_FIELDS).freeze
|
||||
|
||||
def self.normalise! rows, allow_warning_fields: false
|
||||
raise ArgumentError, '取込行の形式が不正です.' unless rows.is_a?(Array)
|
||||
raise ArgumentError, '取込件数が多すぎます.' if rows.length > PostImportUrlListParser::MAX_ROWS
|
||||
|
||||
normalised_rows = rows.map { normalise_row!(_1, allow_warning_fields:) }
|
||||
source_rows = normalised_rows.map { _1['source_row'] }
|
||||
raise ArgumentError, '元行番号が重複しています.' if source_rows.uniq.length != source_rows.length
|
||||
if normalised_rows.sum { row_bytesize(_1) } > PostImportUrlListParser::MAX_BYTES
|
||||
raise ArgumentError, '取込データが大きすぎます.'
|
||||
end
|
||||
|
||||
normalised_rows
|
||||
end
|
||||
|
||||
def self.normalise_row! row, allow_warning_fields:
|
||||
unless row.is_a?(Hash) || row.is_a?(ActionController::Parameters)
|
||||
raise ArgumentError, '取込行の形式が不正です.'
|
||||
end
|
||||
|
||||
parameters =
|
||||
row.is_a?(ActionController::Parameters) ? row : ActionController::Parameters.new(row)
|
||||
permitted = parameters.permit(*permitted_keys(allow_warning_fields))
|
||||
normalised = permitted.to_h.deep_transform_keys { _1.to_s.underscore }
|
||||
|
||||
normalised['source_row'] = normalise_source_row!(normalised['source_row'])
|
||||
normalise_url!(normalised['url'])
|
||||
normalise_metadata_url!(normalised['metadata_url'])
|
||||
normalise_attributes!(normalised.fetch('attributes', { }))
|
||||
normalise_provenance!(normalised.fetch('provenance', { }))
|
||||
normalise_tag_sources!(normalised['tag_sources'])
|
||||
normalise_warning_values!(normalised, allow_warning_fields:)
|
||||
if row_bytesize(normalised) > PostImportUrlListParser::MAX_BYTES
|
||||
raise ArgumentError, '取込行が大きすぎます.'
|
||||
end
|
||||
|
||||
normalised
|
||||
end
|
||||
|
||||
def self.permitted_keys allow_warning_fields
|
||||
keys = [
|
||||
:source_row,
|
||||
:sourceRow,
|
||||
:url,
|
||||
:metadata_url,
|
||||
:metadataUrl,
|
||||
{ attributes: {} },
|
||||
{ provenance: {} },
|
||||
{ tag_sources: {} },
|
||||
{ tagSources: {} }]
|
||||
return keys unless allow_warning_fields
|
||||
|
||||
keys + [
|
||||
{ field_warnings: {} },
|
||||
{ fieldWarnings: {} },
|
||||
{ base_warnings: [] },
|
||||
{ baseWarnings: [] }]
|
||||
end
|
||||
private_class_method :permitted_keys
|
||||
|
||||
def self.normalise_source_row! value
|
||||
source_row = Integer(value, exception: false)
|
||||
raise ArgumentError, '元行番号が不正です.' if source_row.nil? || source_row <= 0
|
||||
|
||||
source_row
|
||||
end
|
||||
private_class_method :normalise_source_row!
|
||||
|
||||
def self.normalise_url! value
|
||||
unless value.is_a?(String)
|
||||
raise ArgumentError, 'URL の形式が不正です.'
|
||||
end
|
||||
if value.bytesize > PostImportUrlListParser::MAX_URL_BYTES
|
||||
raise ArgumentError, 'URL が長すぎます.'
|
||||
end
|
||||
end
|
||||
private_class_method :normalise_url!
|
||||
|
||||
def self.normalise_metadata_url! value
|
||||
raise ArgumentError, 'metadata_url の形式が不正です.' unless value.nil? || value.is_a?(String)
|
||||
if value.to_s.bytesize > PostImportUrlListParser::MAX_URL_BYTES
|
||||
raise ArgumentError, 'metadata_url が長すぎます.'
|
||||
end
|
||||
end
|
||||
private_class_method :normalise_metadata_url!
|
||||
|
||||
def self.normalise_attributes! attributes
|
||||
raise ArgumentError, 'attributes の形式が不正です.' unless attributes.is_a?(Hash)
|
||||
raise ArgumentError, '取込項目が不正です.' unless (attributes.keys - ATTRIBUTE_FIELDS).empty?
|
||||
|
||||
attributes.each do |key, value|
|
||||
case key
|
||||
when *STRING_FIELDS
|
||||
raise ArgumentError, '取込項目の型が不正です.' unless value.nil? || value.is_a?(String)
|
||||
when *FLEXIBLE_FIELDS
|
||||
unless value.nil? || value.is_a?(String) || value.is_a?(Numeric)
|
||||
raise ArgumentError, '取込項目の型が不正です.'
|
||||
end
|
||||
end
|
||||
if value.to_s.bytesize > PostImportUrlListParser::MAX_URL_BYTES
|
||||
raise ArgumentError, '取込項目が大きすぎます.'
|
||||
end
|
||||
end
|
||||
end
|
||||
private_class_method :normalise_attributes!
|
||||
|
||||
def self.normalise_provenance! provenance
|
||||
unless provenance.is_a?(Hash)
|
||||
raise ArgumentError, 'provenance の形式が不正です.'
|
||||
end
|
||||
|
||||
allowed = ATTRIBUTE_FIELDS + ['url']
|
||||
unless (provenance.keys - allowed).empty?
|
||||
raise ArgumentError, '値の由来が不正です.'
|
||||
end
|
||||
unless provenance.values.all? { ORIGINS.include?(_1) }
|
||||
raise ArgumentError, '値の由来が不正です.'
|
||||
end
|
||||
end
|
||||
private_class_method :normalise_provenance!
|
||||
|
||||
def self.normalise_tag_sources! tag_sources
|
||||
return if tag_sources.nil?
|
||||
|
||||
unless tag_sources.is_a?(Hash)
|
||||
raise ArgumentError, 'タグ由来の形式が不正です.'
|
||||
end
|
||||
unless (tag_sources.keys - ORIGINS).empty?
|
||||
raise ArgumentError, 'タグ由来の形式が不正です.'
|
||||
end
|
||||
unless tag_sources.values.all? { _1.is_a?(String) }
|
||||
raise ArgumentError, 'タグ由来の形式が不正です.'
|
||||
end
|
||||
if tag_sources.values.any? { _1.bytesize > PostImportUrlListParser::MAX_URL_BYTES }
|
||||
raise ArgumentError, 'タグ由来が大きすぎます.'
|
||||
end
|
||||
if tag_sources.values.sum(&:bytesize) > PostImportUrlListParser::MAX_URL_BYTES
|
||||
raise ArgumentError, 'タグ由来が大きすぎます.'
|
||||
end
|
||||
end
|
||||
private_class_method :normalise_tag_sources!
|
||||
|
||||
def self.normalise_warning_values! normalised, allow_warning_fields:
|
||||
return unless allow_warning_fields
|
||||
|
||||
field_warnings = normalised['field_warnings']
|
||||
unless field_warnings.nil? || field_warnings.is_a?(Hash)
|
||||
raise ArgumentError, '警告の形式が不正です.'
|
||||
end
|
||||
field_warnings&.each do |key, values|
|
||||
unless ATTRIBUTE_FIELDS.include?(key) || key == 'url'
|
||||
raise ArgumentError, '警告の形式が不正です.'
|
||||
end
|
||||
unless values.is_a?(Array) && values.all? { _1.is_a?(String) }
|
||||
raise ArgumentError, '警告の形式が不正です.'
|
||||
end
|
||||
if values.any? { _1.bytesize > PostImportUrlListParser::MAX_URL_BYTES }
|
||||
raise ArgumentError, '警告が大きすぎます.'
|
||||
end
|
||||
end
|
||||
|
||||
base_warnings = normalised['base_warnings']
|
||||
return if base_warnings.nil?
|
||||
|
||||
unless base_warnings.is_a?(Array) && base_warnings.all? { _1.is_a?(String) }
|
||||
raise ArgumentError, '警告の形式が不正です.'
|
||||
end
|
||||
if base_warnings.any? { _1.bytesize > PostImportUrlListParser::MAX_URL_BYTES }
|
||||
raise ArgumentError, '警告が大きすぎます.'
|
||||
end
|
||||
end
|
||||
private_class_method :normalise_warning_values!
|
||||
|
||||
def self.row_bytesize row
|
||||
row.to_json.bytesize
|
||||
end
|
||||
private_class_method :row_bytesize
|
||||
end
|
||||
@@ -1,109 +0,0 @@
|
||||
class PostImportRunner
|
||||
def initialize actor:, rows:
|
||||
@actor = actor
|
||||
@rows = rows
|
||||
end
|
||||
|
||||
def run
|
||||
normalised_rows = PostImportRowNormaliser.normalise!(@rows)
|
||||
previews = PostImportPreviewer.new.preview_rows(rows: normalised_rows,
|
||||
fetch_metadata: false)
|
||||
preview_map = previews.index_by { _1[:source_row] }
|
||||
|
||||
results = normalised_rows.map do |row|
|
||||
run_row(row, preview_map.fetch(row['source_row']))
|
||||
end
|
||||
|
||||
{ created: results.count { _1[:status] == 'created' },
|
||||
skipped: results.count { _1[:status] == 'skipped' },
|
||||
failed: results.count { _1[:status] == 'failed' },
|
||||
rows: results }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def run_row row, preview
|
||||
attributes = row.fetch('attributes', { }).transform_keys { _1.to_s.underscore }
|
||||
return { source_row: row['source_row'],
|
||||
status: 'failed',
|
||||
errors: preview[:validation_errors],
|
||||
recoverable: true } if preview[:validation_errors].present?
|
||||
if preview[:skip_reason] == 'existing'
|
||||
return { source_row: row['source_row'],
|
||||
status: 'skipped',
|
||||
existing_post_id: preview[:existing_post_id] }
|
||||
end
|
||||
|
||||
attributes['tags'] = preview[:attributes]['tags']
|
||||
attributes['url'] = row['url']
|
||||
creator = PostCreator.new(actor: @actor, attributes:)
|
||||
post = creator.create!
|
||||
result = { source_row: row['source_row'], status: 'created', post: PostRepr.base(post) }
|
||||
if creator.field_warnings.present?
|
||||
result[:field_warnings] = creator.field_warnings
|
||||
end
|
||||
result
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
existing_post = existing_post_for_race(row, e.record)
|
||||
if existing_post
|
||||
return { source_row: row['source_row'],
|
||||
status: 'skipped',
|
||||
existing_post_id: existing_post.id }
|
||||
end
|
||||
|
||||
{ source_row: row['source_row'],
|
||||
status: 'failed',
|
||||
errors: e.record.errors.to_hash,
|
||||
recoverable: true }
|
||||
rescue ActiveRecord::RecordNotUnique => e
|
||||
raise unless url_record_not_unique?(e)
|
||||
|
||||
existing_post = existing_post_for_race(row)
|
||||
raise unless existing_post
|
||||
|
||||
{ source_row: row['source_row'],
|
||||
status: 'skipped',
|
||||
existing_post_id: existing_post.id }
|
||||
rescue Tag::NicoTagNormalisationError
|
||||
{ source_row: row['source_row'],
|
||||
status: 'failed',
|
||||
errors: { tags: ['ニコニコ・タグは直接指定できません.'] },
|
||||
recoverable: true }
|
||||
rescue Tag::DeprecatedTagNormalisationError
|
||||
{ source_row: row['source_row'],
|
||||
status: 'failed',
|
||||
errors: { tags: ['廃止済みタグは付与できません.'] },
|
||||
recoverable: true }
|
||||
rescue PostCreator::VideoMsParseError
|
||||
{ source_row: row['source_row'],
|
||||
status: 'failed',
|
||||
errors: { duration: ['動画時間の記法が不正です.'] },
|
||||
recoverable: true }
|
||||
rescue ArgumentError
|
||||
{ source_row: row['source_row'],
|
||||
status: 'failed',
|
||||
errors: { base: ['入力値が不正です.'] },
|
||||
recoverable: true }
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("post_import_runner_failure #{ { error: e.class.name,
|
||||
message: e.message }.to_json }")
|
||||
{ source_row: row['source_row'],
|
||||
status: 'failed',
|
||||
errors: { base: ['登録中にエラーが発生しました.'] } }
|
||||
end
|
||||
|
||||
def existing_post_for_race row, record = nil
|
||||
if record && !(record.errors.of_kind?(:url, :taken))
|
||||
return nil
|
||||
end
|
||||
|
||||
normal_url = PostUrlNormaliser.normalise(row['url'])
|
||||
return nil if normal_url.blank?
|
||||
|
||||
Post.find_by(url: normal_url)
|
||||
end
|
||||
|
||||
def url_record_not_unique? error
|
||||
error.message.include?('index_posts_on_url')
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
class PostThumbnailAttachmentBuilder
|
||||
def self.build thumbnail:, thumbnail_base:
|
||||
if thumbnail_base.present?
|
||||
return Post.remote_thumbnail_attachment(thumbnail_base)
|
||||
end
|
||||
|
||||
return nil if thumbnail.blank?
|
||||
|
||||
Post.resized_thumbnail_attachment(thumbnail)
|
||||
end
|
||||
end
|
||||
新しい課題から参照
ユーザをブロックする