このコミットが含まれているのは:
@@ -1,6 +1,5 @@
|
||||
class PostsController < ApplicationController
|
||||
Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true)
|
||||
MAX_BULK_THUMBNAIL_BYTES = 20 * 1024 * 1024
|
||||
MAX_BULK_REQUEST_BYTES = 40 * 1024 * 1024
|
||||
|
||||
class VideoMsParseError < ArgumentError
|
||||
@@ -178,7 +177,7 @@ class PostsController < ApplicationController
|
||||
preflight = PostCreatePreflight.new(
|
||||
attributes: post_create_attributes,
|
||||
thumbnail: params[:thumbnail]).run
|
||||
return render json: preflight if bool?(:dry)
|
||||
return render json: dry_run_json(preflight) if bool?(:dry)
|
||||
|
||||
post = PostCreator.new(actor: current_user,
|
||||
attributes: post_create_attributes.merge(
|
||||
@@ -191,7 +190,10 @@ class PostsController < ApplicationController
|
||||
:original_created_from,
|
||||
:original_created_before,
|
||||
:duration,
|
||||
:video_ms).symbolize_keys).merge(
|
||||
:video_ms,
|
||||
:normalised_tags,
|
||||
:tag_sections,
|
||||
:normalised_parent_post_ids).symbolize_keys).merge(
|
||||
thumbnail: params[:thumbnail])).create!
|
||||
|
||||
post.reload
|
||||
@@ -566,7 +568,6 @@ class PostsController < ApplicationController
|
||||
unless value.is_a?(ActionDispatch::Http::UploadedFile)
|
||||
raise ArgumentError, 'thumbnail upload が不正です.'
|
||||
end
|
||||
raise ArgumentError, 'thumbnail file size が大きすぎます.' if value.size > MAX_BULK_THUMBNAIL_BYTES
|
||||
|
||||
thumbnails[index] = value
|
||||
end
|
||||
@@ -581,6 +582,22 @@ class PostsController < ApplicationController
|
||||
PostCompactRepr.base(post)
|
||||
end
|
||||
|
||||
def dry_run_json preflight
|
||||
preflight.slice(
|
||||
:url,
|
||||
:title,
|
||||
:thumbnail_base,
|
||||
:tags,
|
||||
:parent_post_ids,
|
||||
:original_created_from,
|
||||
:original_created_before,
|
||||
:duration,
|
||||
:video_ms,
|
||||
:field_warnings,
|
||||
:base_warnings,
|
||||
:existing_post)
|
||||
end
|
||||
|
||||
def sync_parent_posts! post, parent_post_ids
|
||||
if parent_post_ids.include?(post.id)
|
||||
post.errors.add :parent_post_ids, '自分自身を親投稿にはできません.'
|
||||
|
||||
@@ -1,21 +1,54 @@
|
||||
class PostBulkCreator
|
||||
def initialize actor:, posts:, thumbnails:
|
||||
@actor = actor
|
||||
@actor_id = actor.id
|
||||
@posts = posts
|
||||
@thumbnails = thumbnails
|
||||
end
|
||||
|
||||
def run
|
||||
results = @posts.each_with_index.map { |attributes, index|
|
||||
create_row(attributes, index)
|
||||
}
|
||||
results = Array.new(@posts.length)
|
||||
mutex = Mutex.new
|
||||
next_index = 0
|
||||
|
||||
workers = Array.new(2) do
|
||||
Thread.new do
|
||||
ActiveRecord::Base.connection_pool.with_connection do
|
||||
actor = User.find(@actor_id)
|
||||
loop do
|
||||
index = nil
|
||||
begin
|
||||
index = mutex.synchronize do
|
||||
current = next_index
|
||||
next_index += 1
|
||||
current
|
||||
end
|
||||
break if index >= @posts.length
|
||||
|
||||
attributes = @posts[index]
|
||||
results[index] = create_row(actor, attributes, index)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error(
|
||||
"post_bulk_creator_worker_failure #{ { error: e.class.name,
|
||||
message: e.message,
|
||||
index: }.to_json }")
|
||||
results[index] = {
|
||||
status: 'failed',
|
||||
recoverable: false,
|
||||
errors: { base: ['登録中にエラーが発生しました.'] },
|
||||
base_errors: [] }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
workers.each(&:join)
|
||||
|
||||
{ results: }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_row attributes, index
|
||||
def create_row actor, attributes, index
|
||||
preflight =
|
||||
PostCreatePreflight.new(
|
||||
attributes: attributes,
|
||||
@@ -27,7 +60,7 @@ class PostBulkCreator
|
||||
end
|
||||
|
||||
post = PostCreator.new(
|
||||
actor: @actor,
|
||||
actor: actor,
|
||||
attributes: normalised_attributes(attributes, preflight, index)).create!
|
||||
result = {
|
||||
status: 'created',
|
||||
@@ -122,7 +155,10 @@ class PostBulkCreator
|
||||
original_created_from: preflight[:original_created_from],
|
||||
original_created_before: preflight[:original_created_before],
|
||||
duration: preflight[:duration],
|
||||
video_ms: preflight[:video_ms] }
|
||||
video_ms: preflight[:video_ms],
|
||||
normalised_tags: preflight[:normalised_tags],
|
||||
tag_sections: preflight[:tag_sections],
|
||||
normalised_parent_post_ids: preflight[:normalised_parent_post_ids] }
|
||||
end
|
||||
|
||||
def thumbnail_for index, attributes
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
class PostCreatePlan
|
||||
def initialize attributes:
|
||||
@attributes = attributes.symbolize_keys
|
||||
end
|
||||
|
||||
def build!
|
||||
Tag.normalise_tags!(tag_names, with_tagme: false, deny_deprecated: true,
|
||||
with_sections: true) =>
|
||||
{ tags:, sections: }
|
||||
|
||||
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
|
||||
video_ms = normalise_video_ms(tags)
|
||||
validate_video_sections!(video_ms, sections)
|
||||
parent_post_ids = normalise_parent_post_ids
|
||||
validate_parent_post_ids!(parent_post_ids)
|
||||
|
||||
{
|
||||
url: @attributes[:url],
|
||||
title: @attributes[:title].to_s,
|
||||
thumbnail_base: @attributes[:thumbnail_base].presence,
|
||||
original_created_from: @attributes[:original_created_from].presence,
|
||||
original_created_before: @attributes[:original_created_before].presence,
|
||||
tags: serialised_tags(tags, sections),
|
||||
duration: @attributes[:duration].to_s,
|
||||
video_ms: video_ms,
|
||||
parent_post_ids: parent_post_ids.join(' '),
|
||||
normalised_tags: tags,
|
||||
tag_sections: sections,
|
||||
normalised_parent_post_ids: parent_post_ids }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def tag_names = @attributes[:tags].to_s.split
|
||||
|
||||
def normalise_parent_post_ids
|
||||
Array(@attributes[:parent_post_ids]).flat_map { _1.to_s.split }.map { |token|
|
||||
id = Integer(token, exception: false)
|
||||
raise ArgumentError, "親投稿 Id. が不正です: #{ token }" if id.nil? || id <= 0
|
||||
|
||||
id
|
||||
}.uniq.sort
|
||||
end
|
||||
|
||||
def validate_parent_post_ids! ids
|
||||
missing = ids - Post.where(id: ids).pluck(:id)
|
||||
raise ArgumentError, "存在しない親投稿 Id. があります: #{ missing.join(' ') }" if missing.present?
|
||||
end
|
||||
|
||||
def serialised_tags tags, sections
|
||||
tags.uniq(&:id).map { |tag|
|
||||
"#{ tag.name }#{ sections[tag.id].to_a.map { Post.section_literal(_1) }.join }"
|
||||
}.sort.join(' ')
|
||||
end
|
||||
|
||||
def normalise_video_ms tags
|
||||
return nil unless tags.any? { _1.id == Tag.video.id }
|
||||
|
||||
video_ms = @attributes[:video_ms]
|
||||
if video_ms.present?
|
||||
value = Integer(video_ms, exception: false)
|
||||
raise PostCreator::VideoMsParseError unless value&.positive?
|
||||
|
||||
return value
|
||||
end
|
||||
|
||||
duration = @attributes[:duration]
|
||||
return nil if duration.blank?
|
||||
|
||||
value = Tag.time_to_ms!(duration.to_s, tag_name: '動画時間')
|
||||
raise PostCreator::VideoMsParseError unless value.positive?
|
||||
|
||||
value
|
||||
rescue Tag::SectionLiteralParseError
|
||||
raise PostCreator::VideoMsParseError
|
||||
end
|
||||
|
||||
def validate_video_sections! video_ms, sections
|
||||
return unless video_ms
|
||||
|
||||
sections.each_value do |ranges|
|
||||
ranges.each do |begin_ms, end_ms|
|
||||
post = Post.new
|
||||
if begin_ms >= video_ms
|
||||
post.errors.add :video_ms, 'タグ区間の開始が動画時間以上です.'
|
||||
raise ActiveRecord::RecordInvalid, post
|
||||
end
|
||||
if end_ms && end_ms > video_ms
|
||||
post.errors.add :video_ms, 'タグ区間の終端が動画時間を超えてゐます.'
|
||||
raise ActiveRecord::RecordInvalid, post
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -18,24 +18,54 @@ class PostCreatePreflight
|
||||
preview = PostImportPreviewer.new.preview_rows(
|
||||
rows: [preview_row],
|
||||
fetch_metadata: false).first
|
||||
|
||||
validate_thumbnail_upload!
|
||||
if preview[:existing_post_id].present?
|
||||
return {
|
||||
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: final_field_warnings(preview[:field_warnings] || { }),
|
||||
base_warnings: preview[:base_warnings],
|
||||
existing_post: existing_post_compact(preview[:existing_post_id]) }
|
||||
end
|
||||
|
||||
if preview[:validation_errors].present?
|
||||
raise ValidationFailed.new(fields: preview[:validation_errors])
|
||||
end
|
||||
|
||||
validate_thumbnail_upload!
|
||||
|
||||
plan = PostCreatePlan.new(
|
||||
attributes: {
|
||||
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'] }).build!
|
||||
|
||||
{
|
||||
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],
|
||||
url: plan[:url],
|
||||
title: plan[:title],
|
||||
thumbnail_base: plan[:thumbnail_base],
|
||||
tags: plan[:tags],
|
||||
parent_post_ids: plan[:parent_post_ids],
|
||||
original_created_from: plan[:original_created_from],
|
||||
original_created_before: plan[:original_created_before],
|
||||
duration: plan[:duration],
|
||||
video_ms: plan[:video_ms],
|
||||
normalised_tags: plan[:normalised_tags],
|
||||
tag_sections: plan[:tag_sections],
|
||||
normalised_parent_post_ids: plan[:normalised_parent_post_ids],
|
||||
field_warnings: final_field_warnings(preview[:field_warnings] || { }),
|
||||
base_warnings: preview[:base_warnings],
|
||||
existing_post: existing_post_compact(preview[:existing_post_id]) }
|
||||
end
|
||||
@@ -78,10 +108,9 @@ class PostCreatePreflight
|
||||
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: ['サムネイル画像の変換に失敗しました.'] })
|
||||
PostThumbnailUploadValidator.validate!(@thumbnail)
|
||||
rescue PostThumbnailUploadValidator::InvalidUpload => e
|
||||
raise ValidationFailed.new(fields: { thumbnail: [e.message] })
|
||||
end
|
||||
|
||||
def existing_post_compact post_id
|
||||
@@ -90,4 +119,15 @@ class PostCreatePreflight
|
||||
post = Post.with_attached_thumbnail.find_by(id: post_id)
|
||||
PostCompactRepr.base(post)
|
||||
end
|
||||
|
||||
def final_field_warnings field_warnings
|
||||
thumbnail_warnings = (field_warnings['thumbnail_base'] || []).reject { _1 == 'サムネールなし' }
|
||||
if @attributes[:thumbnail_base].blank? && @thumbnail.blank?
|
||||
thumbnail_warnings = (thumbnail_warnings + ['サムネールなし']).uniq
|
||||
end
|
||||
|
||||
{
|
||||
**field_warnings,
|
||||
'thumbnail_base' => thumbnail_warnings }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -21,15 +21,13 @@ class PostCreator
|
||||
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: }
|
||||
tags = planned_tags
|
||||
sections = planned_sections
|
||||
TagVersioning.record_tag_snapshots!(tags, created_by_user: @actor)
|
||||
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
|
||||
post.video_ms = normalise_video_ms(tags)
|
||||
validate_video_sections!(post.video_ms, sections)
|
||||
post.video_ms = planned_video_ms
|
||||
post.save!
|
||||
sync_post_tags!(post, tags, sections)
|
||||
sync_parent_posts!(post, parent_post_ids)
|
||||
sync_parent_posts!(post, planned_parent_post_ids)
|
||||
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: @actor)
|
||||
end
|
||||
post
|
||||
@@ -46,52 +44,26 @@ class PostCreator
|
||||
thumbnail_base: @attributes[:thumbnail_base].presence)
|
||||
end
|
||||
|
||||
def tag_names = @attributes[:tags].to_s.split
|
||||
def planned_tags = planned_create_attributes[:normalised_tags]
|
||||
|
||||
def parent_post_ids
|
||||
Array(@attributes[:parent_post_ids]).flat_map { _1.to_s.split }.map { |token|
|
||||
id = Integer(token, exception: false)
|
||||
raise ArgumentError, "親投稿 Id. が不正です: #{ token }" if id.nil? || id <= 0
|
||||
def planned_sections = planned_create_attributes[:tag_sections]
|
||||
|
||||
id
|
||||
}.uniq
|
||||
def planned_parent_post_ids = planned_create_attributes[:normalised_parent_post_ids]
|
||||
|
||||
def planned_video_ms
|
||||
planned_create_attributes[:video_ms]
|
||||
end
|
||||
|
||||
def normalise_video_ms tags
|
||||
return nil unless tags.any? { _1.id == Tag.video.id }
|
||||
|
||||
video_ms = @attributes[:video_ms]
|
||||
if video_ms.present?
|
||||
value = Integer(video_ms, exception: false)
|
||||
raise VideoMsParseError unless value&.positive?
|
||||
|
||||
return value
|
||||
end
|
||||
duration = @attributes[:duration]
|
||||
return nil if duration.blank?
|
||||
|
||||
value = Tag.time_to_ms!(duration.to_s, tag_name: '動画時間')
|
||||
raise VideoMsParseError unless value.positive?
|
||||
|
||||
value
|
||||
rescue Tag::SectionLiteralParseError
|
||||
raise VideoMsParseError
|
||||
end
|
||||
|
||||
def validate_video_sections! video_ms, sections
|
||||
return unless video_ms
|
||||
|
||||
sections.each_value do |ranges|
|
||||
ranges.each do |begin_ms, end_ms|
|
||||
post = Post.new
|
||||
if begin_ms >= video_ms
|
||||
post.errors.add :video_ms, 'タグ区間の開始が動画時間以上です.'
|
||||
raise ActiveRecord::RecordInvalid, post
|
||||
end
|
||||
if end_ms && end_ms > video_ms
|
||||
post.errors.add :video_ms, 'タグ区間の終端が動画時間を超えてゐます.'
|
||||
raise ActiveRecord::RecordInvalid, post
|
||||
end
|
||||
def planned_create_attributes
|
||||
@planned_create_attributes ||= begin
|
||||
if @attributes.key?(:normalised_tags)
|
||||
{
|
||||
normalised_tags: @attributes[:normalised_tags],
|
||||
tag_sections: @attributes[:tag_sections] || { },
|
||||
normalised_parent_post_ids: @attributes[:normalised_parent_post_ids] || [],
|
||||
video_ms: @attributes[:video_ms] }
|
||||
else
|
||||
PostCreatePlan.new(attributes: @attributes).build!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -487,6 +487,8 @@ class PostImportPreviewer
|
||||
end
|
||||
|
||||
def parse_video_ms attributes, errors
|
||||
return nil unless attributes['tags'].to_s.split.include?('動画')
|
||||
|
||||
video_ms = attributes['video_ms']
|
||||
if video_ms.present?
|
||||
value = Integer(video_ms, exception: false)
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
class PostImportUrlListParser
|
||||
MAX_ROWS = 100
|
||||
MAX_BYTES = 1.megabyte
|
||||
MAX_URL_BYTES = 20.kilobytes
|
||||
|
||||
def self.parse source
|
||||
raw = source.to_s
|
||||
raise ArgumentError, '入力が大きすぎます.' if raw.bytesize > MAX_BYTES
|
||||
|
||||
rows = raw.split(/\r\n|\n|\r/).each_with_index.filter_map { |line, index|
|
||||
url = line.strip
|
||||
next if url.blank?
|
||||
|
||||
if url.bytesize > MAX_URL_BYTES
|
||||
raise ArgumentError, "#{ index + 1 } 行目: URL が長すぎます."
|
||||
end
|
||||
|
||||
{ source_row: index + 1, url: }
|
||||
}
|
||||
raise ArgumentError, 'URL を入力してください.' if rows.empty?
|
||||
raise ArgumentError, "取込件数は #{ MAX_ROWS } 件までです." if rows.length > MAX_ROWS
|
||||
|
||||
rows
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,18 @@
|
||||
class PostThumbnailUploadValidator
|
||||
MAX_THUMBNAIL_BYTES = 20 * 1024 * 1024
|
||||
|
||||
class InvalidUpload < StandardError; end
|
||||
|
||||
def self.validate! thumbnail
|
||||
return if thumbnail.blank?
|
||||
raise InvalidUpload, 'thumbnail upload が不正です.' unless thumbnail.is_a?(ActionDispatch::Http::UploadedFile)
|
||||
raise InvalidUpload, 'thumbnail file size が大きすぎます.' if thumbnail.size > MAX_THUMBNAIL_BYTES
|
||||
|
||||
attachment = Post.resized_thumbnail_attachment(thumbnail)
|
||||
attachment[:io].close if attachment[:io].respond_to?(:close)
|
||||
rescue MiniMagick::Error
|
||||
raise InvalidUpload, 'サムネイル画像の変換に失敗しました.'
|
||||
ensure
|
||||
thumbnail&.rewind if thumbnail.respond_to?(:rewind)
|
||||
end
|
||||
end
|
||||
新しい課題から参照
ユーザをブロックする