このコミットが含まれているのは:
@@ -115,7 +115,7 @@ class PostsController < ApplicationController
|
|||||||
render json: PostRepr.base(post, current_user)
|
render json: PostRepr.base(post, current_user)
|
||||||
end
|
end
|
||||||
|
|
||||||
def preview
|
def metadata
|
||||||
return head :unauthorized unless current_user
|
return head :unauthorized unless current_user
|
||||||
return head :forbidden unless current_user.gte_member?
|
return head :forbidden unless current_user.gte_member?
|
||||||
return render_bad_request('URL は必須です.') if params[:url].blank?
|
return render_bad_request('URL は必須です.') if params[:url].blank?
|
||||||
@@ -128,6 +128,7 @@ class PostsController < ApplicationController
|
|||||||
provenance: { },
|
provenance: { },
|
||||||
tag_sources: { } }],
|
tag_sources: { } }],
|
||||||
fetch_metadata: true).first
|
fetch_metadata: true).first
|
||||||
|
return render_validation_error fields: preview[:validation_errors] if preview[:validation_errors].present?
|
||||||
|
|
||||||
render json: {
|
render json: {
|
||||||
url: preview[:url],
|
url: preview[:url],
|
||||||
@@ -140,7 +141,6 @@ class PostsController < ApplicationController
|
|||||||
video_ms: preview[:attributes]['video_ms'],
|
video_ms: preview[:attributes]['video_ms'],
|
||||||
field_warnings: preview[:field_warnings],
|
field_warnings: preview[:field_warnings],
|
||||||
base_warnings: preview[:base_warnings],
|
base_warnings: preview[:base_warnings],
|
||||||
validation_errors: preview[:validation_errors],
|
|
||||||
existing_post: compact_post(preview[:existing_post_id]) }
|
existing_post: compact_post(preview[:existing_post_id]) }
|
||||||
rescue ArgumentError => e
|
rescue ArgumentError => e
|
||||||
render_bad_request e.message
|
render_bad_request e.message
|
||||||
@@ -178,6 +178,11 @@ class PostsController < ApplicationController
|
|||||||
attributes: post_create_attributes,
|
attributes: post_create_attributes,
|
||||||
thumbnail: params[:thumbnail]).run
|
thumbnail: params[:thumbnail]).run
|
||||||
return render json: dry_run_json(preflight) if bool?(:dry)
|
return render json: dry_run_json(preflight) if bool?(:dry)
|
||||||
|
if preflight[:existing_post].present?
|
||||||
|
post = Post.new(url: preflight[:url])
|
||||||
|
post.errors.add :url, :taken
|
||||||
|
return render_post_form_record_invalid post
|
||||||
|
end
|
||||||
|
|
||||||
post = PostCreator.new(actor: current_user,
|
post = PostCreator.new(actor: current_user,
|
||||||
attributes: post_create_attributes.merge(
|
attributes: post_create_attributes.merge(
|
||||||
@@ -191,7 +196,10 @@ class PostsController < ApplicationController
|
|||||||
:original_created_before,
|
:original_created_before,
|
||||||
:duration,
|
:duration,
|
||||||
:video_ms,
|
:video_ms,
|
||||||
:normalised_tags,
|
:direct_tag_specs,
|
||||||
|
:default_tag_specs,
|
||||||
|
:snapshot_tag_specs,
|
||||||
|
:post_tag_specs,
|
||||||
:tag_sections,
|
:tag_sections,
|
||||||
:normalised_parent_post_ids).symbolize_keys).merge(
|
:normalised_parent_post_ids).symbolize_keys).merge(
|
||||||
thumbnail: params[:thumbnail])).create!
|
thumbnail: params[:thumbnail])).create!
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class PostBulkCreator
|
|||||||
|
|
||||||
workers = Array.new(2) do
|
workers = Array.new(2) do
|
||||||
Thread.new do
|
Thread.new do
|
||||||
|
Rails.application.executor.wrap do
|
||||||
ActiveRecord::Base.connection_pool.with_connection do
|
ActiveRecord::Base.connection_pool.with_connection do
|
||||||
actor = User.find(@actor_id)
|
actor = User.find(@actor_id)
|
||||||
loop do
|
loop do
|
||||||
@@ -41,8 +42,19 @@ class PostBulkCreator
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
workers.each(&:join)
|
workers.each(&:join)
|
||||||
|
|
||||||
|
results.each_index do |index|
|
||||||
|
next if results[index].present?
|
||||||
|
|
||||||
|
results[index] = {
|
||||||
|
status: 'failed',
|
||||||
|
recoverable: false,
|
||||||
|
errors: { base: ['登録中にエラーが発生しました.'] },
|
||||||
|
base_errors: [] }
|
||||||
|
end
|
||||||
|
|
||||||
{ results: }
|
{ results: }
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -156,7 +168,10 @@ class PostBulkCreator
|
|||||||
original_created_before: preflight[:original_created_before],
|
original_created_before: preflight[:original_created_before],
|
||||||
duration: preflight[:duration],
|
duration: preflight[:duration],
|
||||||
video_ms: preflight[:video_ms],
|
video_ms: preflight[:video_ms],
|
||||||
normalised_tags: preflight[:normalised_tags],
|
direct_tag_specs: preflight[:direct_tag_specs],
|
||||||
|
default_tag_specs: preflight[:default_tag_specs],
|
||||||
|
snapshot_tag_specs: preflight[:snapshot_tag_specs],
|
||||||
|
post_tag_specs: preflight[:post_tag_specs],
|
||||||
tag_sections: preflight[:tag_sections],
|
tag_sections: preflight[:tag_sections],
|
||||||
normalised_parent_post_ids: preflight[:normalised_parent_post_ids] }
|
normalised_parent_post_ids: preflight[:normalised_parent_post_ids] }
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
class PostCreatePlan
|
class PostCreatePlan
|
||||||
|
VIDEO_TAG_NAME = '動画'.freeze
|
||||||
|
TAGME_TAG_NAME = 'タグ希望'.freeze
|
||||||
|
NO_DEERJIKIST_TAG_NAME = 'ニジラー情報不詳'.freeze
|
||||||
|
|
||||||
def initialize attributes:
|
def initialize attributes:
|
||||||
@attributes = attributes.symbolize_keys
|
@attributes = attributes.symbolize_keys
|
||||||
|
@existing_tags_by_name = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def build!
|
def build!
|
||||||
Tag.normalise_tags!(tag_names, with_tagme: false, deny_deprecated: true,
|
direct_tag_specs, tag_sections = parse_direct_tag_specs
|
||||||
with_sections: true) =>
|
default_tag_specs = build_default_tag_specs(direct_tag_specs)
|
||||||
{ tags:, sections: }
|
snapshot_tag_specs = merge_tag_specs(direct_tag_specs + default_tag_specs)
|
||||||
|
post_tag_specs = expand_parent_tag_specs(snapshot_tag_specs)
|
||||||
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
|
video_ms = normalise_video_ms(snapshot_tag_specs)
|
||||||
video_ms = normalise_video_ms(tags)
|
validate_video_sections!(video_ms, tag_sections)
|
||||||
validate_video_sections!(video_ms, sections)
|
|
||||||
parent_post_ids = normalise_parent_post_ids
|
parent_post_ids = normalise_parent_post_ids
|
||||||
validate_parent_post_ids!(parent_post_ids)
|
validate_parent_post_ids!(parent_post_ids)
|
||||||
|
|
||||||
@@ -20,12 +24,15 @@ class PostCreatePlan
|
|||||||
thumbnail_base: @attributes[:thumbnail_base].presence,
|
thumbnail_base: @attributes[:thumbnail_base].presence,
|
||||||
original_created_from: @attributes[:original_created_from].presence,
|
original_created_from: @attributes[:original_created_from].presence,
|
||||||
original_created_before: @attributes[:original_created_before].presence,
|
original_created_before: @attributes[:original_created_before].presence,
|
||||||
tags: serialised_tags(tags, sections),
|
tags: serialised_tags(direct_tag_specs, tag_sections),
|
||||||
duration: @attributes[:duration].to_s,
|
duration: @attributes[:duration].to_s,
|
||||||
video_ms: video_ms,
|
video_ms: video_ms,
|
||||||
parent_post_ids: parent_post_ids.join(' '),
|
parent_post_ids: parent_post_ids.join(' '),
|
||||||
normalised_tags: tags,
|
direct_tag_specs: direct_tag_specs,
|
||||||
tag_sections: sections,
|
default_tag_specs: default_tag_specs,
|
||||||
|
snapshot_tag_specs: snapshot_tag_specs,
|
||||||
|
post_tag_specs: post_tag_specs,
|
||||||
|
tag_sections: tag_sections,
|
||||||
normalised_parent_post_ids: parent_post_ids }
|
normalised_parent_post_ids: parent_post_ids }
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -33,6 +40,112 @@ class PostCreatePlan
|
|||||||
|
|
||||||
def tag_names = @attributes[:tags].to_s.split
|
def tag_names = @attributes[:tags].to_s.split
|
||||||
|
|
||||||
|
def parse_direct_tag_specs
|
||||||
|
tag_sections = { }
|
||||||
|
direct_tag_specs = []
|
||||||
|
|
||||||
|
tag_names.each do |raw_name|
|
||||||
|
tag_name, category, sections = parse_raw_tag_name(raw_name)
|
||||||
|
existing_tag = existing_tags_by_name[tag_name]
|
||||||
|
raise Tag::NicoTagNormalisationError if existing_tag&.nico?
|
||||||
|
raise Tag::DeprecatedTagNormalisationError, [existing_tag.name] if existing_tag&.deprecated?
|
||||||
|
|
||||||
|
direct_tag_specs << {
|
||||||
|
name: tag_name,
|
||||||
|
category: (category || existing_tag&.category || 'general').to_sym }
|
||||||
|
if sections.present?
|
||||||
|
tag_sections[tag_name] ||= []
|
||||||
|
tag_sections[tag_name].concat(sections)
|
||||||
|
tag_sections[tag_name] = Tag.merge_section_ranges(tag_sections[tag_name])
|
||||||
|
tag_sections.delete(tag_name) if tag_sections[tag_name] == [[0, nil]]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
[merge_tag_specs(direct_tag_specs), tag_sections]
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_raw_tag_name raw_name
|
||||||
|
name = raw_name.to_s
|
||||||
|
prefix, category =
|
||||||
|
Tag::CATEGORY_PREFIXES.find {
|
||||||
|
name.downcase.start_with?(_1[0])
|
||||||
|
} || ['', nil]
|
||||||
|
name = name.sub(/\A#{ prefix }/i, '')
|
||||||
|
|
||||||
|
sections = []
|
||||||
|
while (match = name.match(/\A(\S*?)\[([^\[\]\s]*)-([^\[\]\s]*)\](\S*)\z/))
|
||||||
|
name = "#{ match[1] }#{ match[4] }"
|
||||||
|
next if match[2].empty? && match[3].empty?
|
||||||
|
|
||||||
|
sections << Tag.normalise_section_range!(
|
||||||
|
begin_raw: match[2],
|
||||||
|
end_raw: match[3],
|
||||||
|
tag_name: name)
|
||||||
|
end
|
||||||
|
raise Tag::SectionLiteralParseError.new(raw_name, raw_name) if name.include?('[') || name.include?(']')
|
||||||
|
|
||||||
|
[TagName.canonicalise(name).first, category&.to_sym, sections]
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_default_tag_specs direct_tag_specs
|
||||||
|
default_tag_specs = []
|
||||||
|
if direct_tag_specs.length < 10 && direct_tag_specs.none? { _1[:name] == TAGME_TAG_NAME }
|
||||||
|
default_tag_specs << {
|
||||||
|
name: TAGME_TAG_NAME,
|
||||||
|
category: :meta }
|
||||||
|
end
|
||||||
|
if direct_tag_specs.none? { deerjikist_tag_spec?(_1) }
|
||||||
|
default_tag_specs << {
|
||||||
|
name: NO_DEERJIKIST_TAG_NAME,
|
||||||
|
category: :meta }
|
||||||
|
end
|
||||||
|
|
||||||
|
default_tag_specs
|
||||||
|
end
|
||||||
|
|
||||||
|
def expand_parent_tag_specs snapshot_tag_specs
|
||||||
|
existing_snapshot_tags = snapshot_tag_specs.filter_map { existing_tags_by_name[_1[:name]] }
|
||||||
|
expanded_parent_specs =
|
||||||
|
Tag.expand_parent_tags(existing_snapshot_tags)
|
||||||
|
.reject(&:deprecated?)
|
||||||
|
.map { |tag|
|
||||||
|
{
|
||||||
|
name: tag.name,
|
||||||
|
category: tag.category.to_sym } }
|
||||||
|
merge_tag_specs(snapshot_tag_specs + expanded_parent_specs)
|
||||||
|
end
|
||||||
|
|
||||||
|
def merge_tag_specs specs
|
||||||
|
specs.each_with_object({ }) do |spec, merged|
|
||||||
|
merged[spec[:name]] =
|
||||||
|
if merged.key?(spec[:name]) && merged[spec[:name]][:category] != :general
|
||||||
|
merged[spec[:name]]
|
||||||
|
else
|
||||||
|
{
|
||||||
|
name: spec[:name],
|
||||||
|
category: spec[:category] }
|
||||||
|
end
|
||||||
|
end.values.sort_by { _1[:name] }
|
||||||
|
end
|
||||||
|
|
||||||
|
def existing_tags_by_name
|
||||||
|
@existing_tags_by_name ||= begin
|
||||||
|
names = tag_names.map { canonical_tag_name_without_sections(_1) }.uniq
|
||||||
|
Tag.joins(:tag_name).where(tag_names: { name: names }).index_by(&:name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def canonical_tag_name_without_sections raw_name
|
||||||
|
name, = parse_raw_tag_name(raw_name)
|
||||||
|
name
|
||||||
|
end
|
||||||
|
|
||||||
|
def deerjikist_tag_spec? spec
|
||||||
|
return true if spec[:category] == :deerjikist
|
||||||
|
|
||||||
|
existing_tags_by_name[spec[:name]]&.deerjikist?
|
||||||
|
end
|
||||||
|
|
||||||
def normalise_parent_post_ids
|
def normalise_parent_post_ids
|
||||||
Array(@attributes[:parent_post_ids]).flat_map { _1.to_s.split }.map { |token|
|
Array(@attributes[:parent_post_ids]).flat_map { _1.to_s.split }.map { |token|
|
||||||
id = Integer(token, exception: false)
|
id = Integer(token, exception: false)
|
||||||
@@ -47,14 +160,14 @@ class PostCreatePlan
|
|||||||
raise ArgumentError, "存在しない親投稿 Id. があります: #{ missing.join(' ') }" if missing.present?
|
raise ArgumentError, "存在しない親投稿 Id. があります: #{ missing.join(' ') }" if missing.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
def serialised_tags tags, sections
|
def serialised_tags direct_tag_specs, tag_sections
|
||||||
tags.uniq(&:id).map { |tag|
|
direct_tag_specs.map { |spec|
|
||||||
"#{ tag.name }#{ sections[tag.id].to_a.map { Post.section_literal(_1) }.join }"
|
"#{ spec[:name] }#{ tag_sections[spec[:name]].to_a.map { Post.section_literal(_1) }.join }"
|
||||||
}.sort.join(' ')
|
}.sort.join(' ')
|
||||||
end
|
end
|
||||||
|
|
||||||
def normalise_video_ms tags
|
def normalise_video_ms snapshot_tag_specs
|
||||||
return nil unless tags.any? { _1.id == Tag.video.id }
|
return nil unless snapshot_tag_specs.any? { _1[:name] == VIDEO_TAG_NAME }
|
||||||
|
|
||||||
video_ms = @attributes[:video_ms]
|
video_ms = @attributes[:video_ms]
|
||||||
if video_ms.present?
|
if video_ms.present?
|
||||||
@@ -75,17 +188,18 @@ class PostCreatePlan
|
|||||||
raise PostCreator::VideoMsParseError
|
raise PostCreator::VideoMsParseError
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_video_sections! video_ms, sections
|
def validate_video_sections! video_ms, tag_sections
|
||||||
return unless video_ms
|
return unless video_ms
|
||||||
|
|
||||||
sections.each_value do |ranges|
|
tag_sections.each_value do |ranges|
|
||||||
ranges.each do |begin_ms, end_ms|
|
ranges.each do |begin_ms, end_ms|
|
||||||
post = Post.new
|
|
||||||
if begin_ms >= video_ms
|
if begin_ms >= video_ms
|
||||||
|
post = Post.new
|
||||||
post.errors.add :video_ms, 'タグ区間の開始が動画時間以上です.'
|
post.errors.add :video_ms, 'タグ区間の開始が動画時間以上です.'
|
||||||
raise ActiveRecord::RecordInvalid, post
|
raise ActiveRecord::RecordInvalid, post
|
||||||
end
|
end
|
||||||
if end_ms && end_ms > video_ms
|
if end_ms && end_ms > video_ms
|
||||||
|
post = Post.new
|
||||||
post.errors.add :video_ms, 'タグ区間の終端が動画時間を超えてゐます.'
|
post.errors.add :video_ms, 'タグ区間の終端が動画時間を超えてゐます.'
|
||||||
raise ActiveRecord::RecordInvalid, post
|
raise ActiveRecord::RecordInvalid, post
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -62,7 +62,10 @@ class PostCreatePreflight
|
|||||||
original_created_before: plan[:original_created_before],
|
original_created_before: plan[:original_created_before],
|
||||||
duration: plan[:duration],
|
duration: plan[:duration],
|
||||||
video_ms: plan[:video_ms],
|
video_ms: plan[:video_ms],
|
||||||
normalised_tags: plan[:normalised_tags],
|
direct_tag_specs: plan[:direct_tag_specs],
|
||||||
|
default_tag_specs: plan[:default_tag_specs],
|
||||||
|
snapshot_tag_specs: plan[:snapshot_tag_specs],
|
||||||
|
post_tag_specs: plan[:post_tag_specs],
|
||||||
tag_sections: plan[:tag_sections],
|
tag_sections: plan[:tag_sections],
|
||||||
normalised_parent_post_ids: plan[:normalised_parent_post_ids],
|
normalised_parent_post_ids: plan[:normalised_parent_post_ids],
|
||||||
field_warnings: final_field_warnings(preview[:field_warnings] || { }),
|
field_warnings: final_field_warnings(preview[:field_warnings] || { }),
|
||||||
@@ -126,8 +129,8 @@ class PostCreatePreflight
|
|||||||
thumbnail_warnings = (thumbnail_warnings + ['サムネールなし']).uniq
|
thumbnail_warnings = (thumbnail_warnings + ['サムネールなし']).uniq
|
||||||
end
|
end
|
||||||
|
|
||||||
{
|
next_warnings = field_warnings.except('thumbnail_base')
|
||||||
**field_warnings,
|
next_warnings['thumbnail_base'] = thumbnail_warnings if thumbnail_warnings.present?
|
||||||
'thumbnail_base' => thumbnail_warnings }
|
next_warnings
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -21,12 +21,13 @@ class PostCreator
|
|||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
post.save!
|
post.save!
|
||||||
post.thumbnail.attach(thumbnail_attachment) if thumbnail_attachment.present?
|
post.thumbnail.attach(thumbnail_attachment) if thumbnail_attachment.present?
|
||||||
tags = planned_tags
|
snapshot_tags = planned_snapshot_tags
|
||||||
|
post_tags = planned_post_tags
|
||||||
sections = planned_sections
|
sections = planned_sections
|
||||||
TagVersioning.record_tag_snapshots!(tags, created_by_user: @actor)
|
TagVersioning.record_tag_snapshots!(snapshot_tags, created_by_user: @actor)
|
||||||
post.video_ms = planned_video_ms
|
post.video_ms = planned_video_ms
|
||||||
post.save!
|
post.save!
|
||||||
sync_post_tags!(post, tags, sections)
|
sync_post_tags!(post, post_tags, sections)
|
||||||
sync_parent_posts!(post, planned_parent_post_ids)
|
sync_parent_posts!(post, planned_parent_post_ids)
|
||||||
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: @actor)
|
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: @actor)
|
||||||
end
|
end
|
||||||
@@ -44,7 +45,9 @@ class PostCreator
|
|||||||
thumbnail_base: @attributes[:thumbnail_base].presence)
|
thumbnail_base: @attributes[:thumbnail_base].presence)
|
||||||
end
|
end
|
||||||
|
|
||||||
def planned_tags = planned_create_attributes[:normalised_tags]
|
def planned_snapshot_tags = planned_create_attributes[:snapshot_tags]
|
||||||
|
|
||||||
|
def planned_post_tags = planned_create_attributes[:post_tags]
|
||||||
|
|
||||||
def planned_sections = planned_create_attributes[:tag_sections]
|
def planned_sections = planned_create_attributes[:tag_sections]
|
||||||
|
|
||||||
@@ -56,18 +59,65 @@ class PostCreator
|
|||||||
|
|
||||||
def planned_create_attributes
|
def planned_create_attributes
|
||||||
@planned_create_attributes ||= begin
|
@planned_create_attributes ||= begin
|
||||||
if @attributes.key?(:normalised_tags)
|
if @attributes.key?(:snapshot_tag_specs)
|
||||||
|
snapshot_tags = materialise_tags(@attributes[:snapshot_tag_specs] || [])
|
||||||
|
post_tags = materialise_tags(@attributes[:post_tag_specs] || [])
|
||||||
{
|
{
|
||||||
normalised_tags: @attributes[:normalised_tags],
|
snapshot_tags: snapshot_tags,
|
||||||
tag_sections: @attributes[:tag_sections] || { },
|
post_tags: post_tags,
|
||||||
|
tag_sections: materialise_sections(
|
||||||
|
@attributes[:tag_sections] || { },
|
||||||
|
snapshot_tags,
|
||||||
|
post_tags),
|
||||||
normalised_parent_post_ids: @attributes[:normalised_parent_post_ids] || [],
|
normalised_parent_post_ids: @attributes[:normalised_parent_post_ids] || [],
|
||||||
video_ms: @attributes[:video_ms] }
|
video_ms: @attributes[:video_ms] }
|
||||||
else
|
else
|
||||||
PostCreatePlan.new(attributes: @attributes).build!
|
build_materialised_plan
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def build_materialised_plan
|
||||||
|
plan = PostCreatePlan.new(attributes: @attributes).build!
|
||||||
|
snapshot_tags = materialise_tags(plan[:snapshot_tag_specs] || [])
|
||||||
|
post_tags = materialise_tags(plan[:post_tag_specs] || [])
|
||||||
|
{
|
||||||
|
snapshot_tags: snapshot_tags,
|
||||||
|
post_tags: post_tags,
|
||||||
|
tag_sections: materialise_sections(
|
||||||
|
plan[:tag_sections] || { },
|
||||||
|
snapshot_tags,
|
||||||
|
post_tags),
|
||||||
|
normalised_parent_post_ids: plan[:normalised_parent_post_ids] || [],
|
||||||
|
video_ms: plan[:video_ms] }
|
||||||
|
end
|
||||||
|
|
||||||
|
def materialise_tags specs
|
||||||
|
Array(specs).each_with_object({ }) do |spec, tags|
|
||||||
|
name = spec[:name] || spec['name']
|
||||||
|
category = spec[:category] || spec['category']
|
||||||
|
next if name.blank? || category.blank?
|
||||||
|
|
||||||
|
tag = Tag.find_or_create_by_tag_name!(name, category:)
|
||||||
|
tag.update!(category:) if tag.category.to_sym != category.to_sym
|
||||||
|
tags[name] ||= tag
|
||||||
|
end.values
|
||||||
|
end
|
||||||
|
|
||||||
|
def materialise_sections sections_by_name, snapshot_tags, post_tags
|
||||||
|
tags_by_name = post_tags.index_by(&:name)
|
||||||
|
snapshot_tags.each do |tag|
|
||||||
|
tags_by_name[tag.name] ||= tag
|
||||||
|
end
|
||||||
|
|
||||||
|
sections_by_name.each_with_object({ }) do |(tag_name, ranges), sections|
|
||||||
|
tag = tags_by_name[tag_name.to_s]
|
||||||
|
next if tag.nil?
|
||||||
|
|
||||||
|
sections[tag.id] = Array(ranges).map { |range| [range[0], range[1]] }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def sync_post_tags! post, desired_tags, sections
|
def sync_post_tags! post, desired_tags, sections
|
||||||
desired_ids = desired_tags.map(&:id).to_set
|
desired_ids = desired_tags.map(&:id).to_set
|
||||||
current_ids = post.tags.pluck(:id).to_set
|
current_ids = post.tags.pluck(:id).to_set
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
class PostThumbnailUploadValidator
|
class PostThumbnailUploadValidator
|
||||||
MAX_THUMBNAIL_BYTES = 20 * 1024 * 1024
|
MAX_THUMBNAIL_BYTES = 20 * 1024 * 1024
|
||||||
|
ALLOWED_CONTENT_TYPES = Preview::ThumbnailFetcher::ALLOWED_IMAGE_CONTENT_TYPES.freeze
|
||||||
|
|
||||||
class InvalidUpload < StandardError; end
|
class InvalidUpload < StandardError; end
|
||||||
|
|
||||||
@@ -7,6 +8,7 @@ class PostThumbnailUploadValidator
|
|||||||
return if thumbnail.blank?
|
return if thumbnail.blank?
|
||||||
raise InvalidUpload, 'thumbnail upload が不正です.' unless thumbnail.is_a?(ActionDispatch::Http::UploadedFile)
|
raise InvalidUpload, 'thumbnail upload が不正です.' unless thumbnail.is_a?(ActionDispatch::Http::UploadedFile)
|
||||||
raise InvalidUpload, 'thumbnail file size が大きすぎます.' if thumbnail.size > MAX_THUMBNAIL_BYTES
|
raise InvalidUpload, 'thumbnail file size が大きすぎます.' if thumbnail.size > MAX_THUMBNAIL_BYTES
|
||||||
|
raise InvalidUpload, 'サムネイル画像の形式が不正です.' unless allowed_content_type?(thumbnail.content_type)
|
||||||
|
|
||||||
attachment = Post.resized_thumbnail_attachment(thumbnail)
|
attachment = Post.resized_thumbnail_attachment(thumbnail)
|
||||||
attachment[:io].close if attachment[:io].respond_to?(:close)
|
attachment[:io].close if attachment[:io].respond_to?(:close)
|
||||||
@@ -15,4 +17,9 @@ class PostThumbnailUploadValidator
|
|||||||
ensure
|
ensure
|
||||||
thumbnail&.rewind if thumbnail.respond_to?(:rewind)
|
thumbnail&.rewind if thumbnail.respond_to?(:rewind)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.allowed_content_type? content_type
|
||||||
|
mime_type = content_type.to_s.split(';', 2).first.to_s.downcase.strip
|
||||||
|
ALLOWED_CONTENT_TYPES.include?(mime_type)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ Rails.application.routes.draw do
|
|||||||
|
|
||||||
resources :posts, only: [:index, :show, :create, :update] do
|
resources :posts, only: [:index, :show, :create, :update] do
|
||||||
collection do
|
collection do
|
||||||
get :preview
|
get :metadata
|
||||||
post :bulk
|
post :bulk
|
||||||
get :random
|
get :random
|
||||||
get :changes
|
get :changes
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ export const applyThumbnailWarnings = (rows: PostImportRow[]): PostImportRow[] =
|
|||||||
|
|
||||||
export const processableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
|
export const processableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
|
||||||
validatableImportRows (rows).filter (row => {
|
validatableImportRows (rows).filter (row => {
|
||||||
|
if (row.metadataUrl == null)
|
||||||
|
return false
|
||||||
if (row.importStatus === 'created')
|
if (row.importStatus === 'created')
|
||||||
return false
|
return false
|
||||||
if (row.importStatus === 'skipped')
|
if (row.importStatus === 'skipped')
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import {
|
|||||||
clearPostImportSourceDraft,
|
clearPostImportSourceDraft,
|
||||||
generatePostImportSessionId,
|
generatePostImportSessionId,
|
||||||
hasThumbnailBaseValue,
|
hasThumbnailBaseValue,
|
||||||
initialisePreviewRows,
|
|
||||||
isCompletedReviewRow,
|
isCompletedReviewRow,
|
||||||
isExistingSkipRow,
|
isExistingSkipRow,
|
||||||
isNonRecoverableFailedRow,
|
isNonRecoverableFailedRow,
|
||||||
@@ -62,7 +61,7 @@ import type { User } from '@/types'
|
|||||||
|
|
||||||
type Props = { user: User | null }
|
type Props = { user: User | null }
|
||||||
|
|
||||||
type PreviewResponse = {
|
type PostMetadataResponse = {
|
||||||
url: string
|
url: string
|
||||||
title?: string
|
title?: string
|
||||||
thumbnailBase?: string
|
thumbnailBase?: string
|
||||||
@@ -105,6 +104,47 @@ const isRepairRow = (row: PostImportRow): boolean =>
|
|||||||
|| (row.importStatus === 'pending'
|
|| (row.importStatus === 'pending'
|
||||||
&& Object.keys (row.validationErrors).length > 0))
|
&& Object.keys (row.validationErrors).length > 0))
|
||||||
|
|
||||||
|
const DUPLICATE_URL_MESSAGE = 'URL が重複しています.'
|
||||||
|
|
||||||
|
const applyDuplicateUrlErrors = (
|
||||||
|
rows: PostImportRow[],
|
||||||
|
): PostImportRow[] => {
|
||||||
|
const counts = rows.reduce<Record<string, number>> ((result, row) => {
|
||||||
|
if (row.url !== '')
|
||||||
|
result[row.url] = (result[row.url] ?? 0) + 1
|
||||||
|
return result
|
||||||
|
}, { })
|
||||||
|
|
||||||
|
return rows.map (row => {
|
||||||
|
const urlErrors = (row.validationErrors.url ?? []).filter (
|
||||||
|
message => message !== DUPLICATE_URL_MESSAGE)
|
||||||
|
if ((counts[row.url] ?? 0) < 2)
|
||||||
|
{
|
||||||
|
const nextValidationErrors = {
|
||||||
|
...row.validationErrors,
|
||||||
|
url: urlErrors }
|
||||||
|
const hasErrors = Object.values (nextValidationErrors).some (
|
||||||
|
messages => messages.length > 0)
|
||||||
|
return urlErrors.length === (row.validationErrors.url ?? []).length
|
||||||
|
? row
|
||||||
|
: {
|
||||||
|
...row,
|
||||||
|
validationErrors: nextValidationErrors,
|
||||||
|
status: hasErrors ? 'error' : row.status }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
skipReason: row.skipReason === 'manual' ? 'manual' : undefined,
|
||||||
|
existingPostId: undefined,
|
||||||
|
existingPost: undefined,
|
||||||
|
validationErrors: {
|
||||||
|
...row.validationErrors,
|
||||||
|
url: [...new Set ([...urlErrors, DUPLICATE_URL_MESSAGE])] },
|
||||||
|
status: 'error' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const buildSession = (rows: PostImportRow[]): PostImportSession => ({
|
const buildSession = (rows: PostImportRow[]): PostImportSession => ({
|
||||||
version: 3,
|
version: 3,
|
||||||
@@ -137,9 +177,12 @@ const buildDryRunFormData = (row: PostImportRow): FormData => {
|
|||||||
|
|
||||||
const mergeDryRunRow = (
|
const mergeDryRunRow = (
|
||||||
currentRow: PostImportRow,
|
currentRow: PostImportRow,
|
||||||
result: PreviewResponse,
|
result: PostMetadataResponse,
|
||||||
): PostImportRow =>
|
): PostImportRow => {
|
||||||
applyThumbnailWarning ({
|
const hasWarnings =
|
||||||
|
Object.values (result.fieldWarnings ?? { }).some (messages => messages.length > 0)
|
||||||
|
|| (result.baseWarnings?.length ?? 0) > 0
|
||||||
|
return applyThumbnailWarning ({
|
||||||
...currentRow,
|
...currentRow,
|
||||||
url: result.url,
|
url: result.url,
|
||||||
attributes: {
|
attributes: {
|
||||||
@@ -157,8 +200,7 @@ const mergeDryRunRow = (
|
|||||||
validationErrors: { },
|
validationErrors: { },
|
||||||
importErrors: undefined,
|
importErrors: undefined,
|
||||||
status:
|
status:
|
||||||
Object.keys (result.fieldWarnings ?? { }).length > 0
|
hasWarnings
|
||||||
|| (result.baseWarnings?.length ?? 0) > 0
|
|
||||||
? 'warning'
|
? 'warning'
|
||||||
: 'ready',
|
: 'ready',
|
||||||
skipReason: result.existingPost?.id != null ? 'existing' : undefined,
|
skipReason: result.existingPost?.id != null ? 'existing' : undefined,
|
||||||
@@ -169,10 +211,11 @@ const mergeDryRunRow = (
|
|||||||
? 'created'
|
? 'created'
|
||||||
: 'pending',
|
: 'pending',
|
||||||
recoverable: undefined })
|
recoverable: undefined })
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const buildPreviewResetSnapshot = (
|
const buildPreviewResetSnapshot = (
|
||||||
preview: PreviewResponse,
|
preview: PostMetadataResponse,
|
||||||
): PostImportRow['resetSnapshot'] => ({
|
): PostImportRow['resetSnapshot'] => ({
|
||||||
url: preview.url,
|
url: preview.url,
|
||||||
attributes: {
|
attributes: {
|
||||||
@@ -246,7 +289,7 @@ const indexedBulkResults = (
|
|||||||
|
|
||||||
const mergePreviewRow = (
|
const mergePreviewRow = (
|
||||||
currentRow: PostImportRow,
|
currentRow: PostImportRow,
|
||||||
preview: PreviewResponse,
|
preview: PostMetadataResponse,
|
||||||
): PostImportRow => {
|
): PostImportRow => {
|
||||||
const fieldWarnings = preview.fieldWarnings ?? { }
|
const fieldWarnings = preview.fieldWarnings ?? { }
|
||||||
const baseWarnings = preview.baseWarnings ?? []
|
const baseWarnings = preview.baseWarnings ?? []
|
||||||
@@ -405,7 +448,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
if (serialised == null)
|
if (serialised == null)
|
||||||
return null
|
return null
|
||||||
if (persistSequenceRef.current !== sequence)
|
if (persistSequenceRef.current !== sequence)
|
||||||
return sessionRef.current
|
return null
|
||||||
|
|
||||||
const saved = savePostImportSession (sessionId, nextSession)
|
const saved = savePostImportSession (sessionId, nextSession)
|
||||||
const loadedSession = loadPostImportSession (sessionId)
|
const loadedSession = loadPostImportSession (sessionId)
|
||||||
@@ -447,8 +490,23 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const refreshRows = async (baseSession: PostImportSession) => {
|
const refreshRows = async (baseSession: PostImportSession) => {
|
||||||
|
setLoading (true)
|
||||||
let nextIndex = 0
|
let nextIndex = 0
|
||||||
const previews = new Map<number, PreviewResponse> ()
|
const previewErrors = new Map<number, Record<string, string[]>> ()
|
||||||
|
let workingRows = [...(sessionRef.current ?? baseSession).rows]
|
||||||
|
|
||||||
|
const mergeWorkingRow = (
|
||||||
|
sourceRow: number,
|
||||||
|
updater: (row: PostImportRow) => PostImportRow,
|
||||||
|
) => {
|
||||||
|
workingRows = workingRows.map (row =>
|
||||||
|
row.sourceRow === sourceRow
|
||||||
|
? updater (row)
|
||||||
|
: row)
|
||||||
|
const nextSession = buildSession (workingRows)
|
||||||
|
sessionRef.current = nextSession
|
||||||
|
setSession (nextSession)
|
||||||
|
}
|
||||||
const worker = async () => {
|
const worker = async () => {
|
||||||
while (active)
|
while (active)
|
||||||
{
|
{
|
||||||
@@ -461,18 +519,19 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
if (baseRow.skipReason === 'manual'
|
if (baseRow.skipReason === 'manual'
|
||||||
|| baseRow.importStatus === 'created'
|
|| baseRow.importStatus === 'created'
|
||||||
|| baseRow.importStatus === 'skipped'
|
|| baseRow.importStatus === 'skipped'
|
||||||
|| isNonRecoverableFailedRow (baseRow))
|
|| isNonRecoverableFailedRow (baseRow)
|
||||||
|
|| baseRow.metadataUrl != null)
|
||||||
continue
|
continue
|
||||||
const controller = new AbortController ()
|
const controller = new AbortController ()
|
||||||
controllers.add (controller)
|
controllers.add (controller)
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const preview = await apiGet<PreviewResponse> ('/posts/preview', {
|
const preview = await apiGet<PostMetadataResponse> ('/posts/metadata', {
|
||||||
params: { url: baseRow.url },
|
params: { url: baseRow.url },
|
||||||
signal: controller.signal })
|
signal: controller.signal })
|
||||||
if (!(active) || previewSequenceRef.current !== previewSequence)
|
if (!(active) || previewSequenceRef.current !== previewSequence)
|
||||||
return
|
return
|
||||||
previews.set (baseRow.sourceRow, preview)
|
mergeWorkingRow (baseRow.sourceRow, row => mergePreviewRow (row, preview))
|
||||||
}
|
}
|
||||||
catch (requestError)
|
catch (requestError)
|
||||||
{
|
{
|
||||||
@@ -480,8 +539,25 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
return
|
return
|
||||||
if (!(active) || previewSequenceRef.current !== previewSequence)
|
if (!(active) || previewSequenceRef.current !== previewSequence)
|
||||||
return
|
return
|
||||||
if (!(isApiError (requestError)))
|
if (!(isApiError<{
|
||||||
|
errors?: Record<string, string[]>
|
||||||
|
baseErrors?: string[]
|
||||||
|
}> (requestError)))
|
||||||
continue
|
continue
|
||||||
|
if (requestError.response?.status === 422)
|
||||||
|
{
|
||||||
|
const rowErrors = {
|
||||||
|
...(requestError.response.data.errors ?? { }),
|
||||||
|
...(requestError.response.data.baseErrors?.length
|
||||||
|
? {
|
||||||
|
base: requestError.response.data.baseErrors }
|
||||||
|
: { }) }
|
||||||
|
previewErrors.set (baseRow.sourceRow, rowErrors)
|
||||||
|
mergeWorkingRow (baseRow.sourceRow, row => ({
|
||||||
|
...row,
|
||||||
|
validationErrors: rowErrors,
|
||||||
|
status: 'error' }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -493,20 +569,22 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
await Promise.all (
|
await Promise.all (
|
||||||
Array.from ({ length: Math.min (4, baseSession.rows.length) }, () => worker ()))
|
Array.from ({ length: Math.min (4, baseSession.rows.length) }, () => worker ()))
|
||||||
if (!(active) || previewSequenceRef.current !== previewSequence)
|
if (!(active) || previewSequenceRef.current !== previewSequence)
|
||||||
|
{
|
||||||
|
setLoading (false)
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const latestSession = sessionRef.current ?? baseSession
|
const nextRows = applyDuplicateUrlErrors (workingRows.map (row => {
|
||||||
const nextRows = latestSession.rows.map (row => {
|
const rowErrors = previewErrors.get (row.sourceRow)
|
||||||
const preview = previews.get (row.sourceRow)
|
if (rowErrors == null)
|
||||||
if (preview == null
|
|
||||||
|| row.skipReason === 'manual'
|
|
||||||
|| row.importStatus === 'created'
|
|
||||||
|| row.importStatus === 'skipped'
|
|
||||||
|| isNonRecoverableFailedRow (row))
|
|
||||||
return row
|
return row
|
||||||
return mergePreviewRow (row, preview)
|
return {
|
||||||
})
|
...row,
|
||||||
|
validationErrors: rowErrors,
|
||||||
|
status: 'error' as const }
|
||||||
|
}))
|
||||||
await persistSession (buildSession (nextRows))
|
await persistSession (buildSession (nextRows))
|
||||||
|
setLoading (false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const hydrate = async () => {
|
const hydrate = async () => {
|
||||||
@@ -528,89 +606,6 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
sessionRef.current = loaded
|
sessionRef.current = loaded
|
||||||
setSession (loaded)
|
setSession (loaded)
|
||||||
|
|
||||||
if (loadedSession == null && parsed?.shortcut)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
const preview = await apiGet<PreviewResponse> ('/posts/preview', {
|
|
||||||
params: { url: parsed.source } })
|
|
||||||
if (!(active))
|
|
||||||
return
|
|
||||||
const rows = [applyThumbnailWarning (initialisePreviewRows ([{
|
|
||||||
sourceRow: 1,
|
|
||||||
url: preview.url,
|
|
||||||
attributes: {
|
|
||||||
title: preview.title ?? '',
|
|
||||||
thumbnailBase: preview.thumbnailBase ?? '',
|
|
||||||
originalCreatedFrom: preview.originalCreatedFrom ?? '',
|
|
||||||
originalCreatedBefore: preview.originalCreatedBefore ?? '',
|
|
||||||
duration: preview.duration ?? '',
|
|
||||||
videoMs: preview.videoMs ?? '',
|
|
||||||
tags: preview.tags ?? '',
|
|
||||||
parentPostIds: String (preview.parentPostIds ?? '') },
|
|
||||||
fieldWarnings: preview.fieldWarnings ?? { },
|
|
||||||
baseWarnings: preview.baseWarnings ?? [],
|
|
||||||
validationErrors: { },
|
|
||||||
provenance: {
|
|
||||||
url: 'manual',
|
|
||||||
title: 'automatic',
|
|
||||||
thumbnailBase: 'automatic',
|
|
||||||
originalCreatedFrom: 'automatic',
|
|
||||||
originalCreatedBefore: 'automatic',
|
|
||||||
duration: 'automatic',
|
|
||||||
videoMs: 'automatic',
|
|
||||||
tags: 'automatic',
|
|
||||||
parentPostIds: 'automatic' },
|
|
||||||
tagSources: {
|
|
||||||
automatic: preview.tags ?? '',
|
|
||||||
manual: '' },
|
|
||||||
status:
|
|
||||||
Object.keys (preview.fieldWarnings ?? { }).length > 0
|
|
||||||
|| (preview.baseWarnings?.length ?? 0) > 0
|
|
||||||
? 'warning'
|
|
||||||
: 'ready',
|
|
||||||
skipReason: preview.existingPost?.id != null ? 'existing' : undefined,
|
|
||||||
existingPostId: preview.existingPost?.id,
|
|
||||||
existingPost: preview.existingPost ?? undefined,
|
|
||||||
resetSnapshot: {
|
|
||||||
url: preview.url,
|
|
||||||
attributes: {
|
|
||||||
title: preview.title ?? '',
|
|
||||||
thumbnailBase: preview.thumbnailBase ?? '',
|
|
||||||
originalCreatedFrom: preview.originalCreatedFrom ?? '',
|
|
||||||
originalCreatedBefore: preview.originalCreatedBefore ?? '',
|
|
||||||
duration: preview.duration ?? '',
|
|
||||||
videoMs: preview.videoMs ?? '',
|
|
||||||
tags: preview.tags ?? '',
|
|
||||||
parentPostIds: String (preview.parentPostIds ?? '') },
|
|
||||||
provenance: {
|
|
||||||
url: 'manual',
|
|
||||||
title: 'automatic',
|
|
||||||
thumbnailBase: 'automatic',
|
|
||||||
originalCreatedFrom: 'automatic',
|
|
||||||
originalCreatedBefore: 'automatic',
|
|
||||||
duration: 'automatic',
|
|
||||||
videoMs: 'automatic',
|
|
||||||
tags: 'automatic',
|
|
||||||
parentPostIds: 'automatic' },
|
|
||||||
tagSources: {
|
|
||||||
automatic: preview.tags ?? '',
|
|
||||||
manual: '' },
|
|
||||||
fieldWarnings: preview.fieldWarnings ?? { },
|
|
||||||
baseWarnings: preview.baseWarnings ?? [],
|
|
||||||
metadataUrl: preview.url } }])[0])]
|
|
||||||
const persisted = await persistSession (buildSession (rows))
|
|
||||||
if (persisted == null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
if (active)
|
|
||||||
navigate ('/posts/new', { replace: true })
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
void refreshRows (loaded)
|
void refreshRows (loaded)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,6 +644,9 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
const existingRows = sortedRows.filter (row => isExistingSkipRow (row))
|
const existingRows = sortedRows.filter (row => isExistingSkipRow (row))
|
||||||
const reviewRows = sortedRows.filter (row => !(isExistingSkipRow (row)))
|
const reviewRows = sortedRows.filter (row => !(isExistingSkipRow (row)))
|
||||||
const busy = loading || loadingRow != null
|
const busy = loading || loadingRow != null
|
||||||
|
const canSubmit =
|
||||||
|
rows.every (row => isCompletedReviewRow (row))
|
||||||
|
|| processableImportRows (rows).length > 0
|
||||||
|
|
||||||
const toggleManualSkip = async (sourceRow: number, checked: boolean) => {
|
const toggleManualSkip = async (sourceRow: number, checked: boolean) => {
|
||||||
if (busy)
|
if (busy)
|
||||||
@@ -709,10 +707,10 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
urlChanged
|
urlChanged
|
||||||
? mergePreviewRow (
|
? mergePreviewRow (
|
||||||
nextRow,
|
nextRow,
|
||||||
await apiGet<PreviewResponse> ('/posts/preview', {
|
await apiGet<PostMetadataResponse> ('/posts/metadata', {
|
||||||
params: { url: nextRow.url } }))
|
params: { url: nextRow.url } }))
|
||||||
: nextRow
|
: nextRow
|
||||||
const dryRun = await apiPost<PreviewResponse> (
|
const dryRun = await apiPost<PostMetadataResponse> (
|
||||||
'/posts?dry=1',
|
'/posts?dry=1',
|
||||||
buildDryRunFormData (candidateRow))
|
buildDryRunFormData (candidateRow))
|
||||||
const latestSession = sessionRef.current
|
const latestSession = sessionRef.current
|
||||||
@@ -971,6 +969,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
|
|
||||||
<PostImportFooter
|
<PostImportFooter
|
||||||
loading={busy}
|
loading={busy}
|
||||||
|
canSubmit={canSubmit}
|
||||||
creatableCount={counts.creatable}
|
creatableCount={counts.creatable}
|
||||||
manualSkippedCount={counts.manualSkipped}
|
manualSkippedCount={counts.manualSkipped}
|
||||||
existingSkippedCount={counts.existingSkipped}
|
existingSkippedCount={counts.existingSkipped}
|
||||||
@@ -982,12 +981,14 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
|
|
||||||
const PostImportFooter = (
|
const PostImportFooter = (
|
||||||
{ loading,
|
{ loading,
|
||||||
|
canSubmit,
|
||||||
creatableCount,
|
creatableCount,
|
||||||
manualSkippedCount,
|
manualSkippedCount,
|
||||||
existingSkippedCount,
|
existingSkippedCount,
|
||||||
pendingOrErrorCount,
|
pendingOrErrorCount,
|
||||||
onBack,
|
onBack,
|
||||||
onSubmit }: { loading: boolean
|
onSubmit }: { loading: boolean
|
||||||
|
canSubmit: boolean
|
||||||
creatableCount: number
|
creatableCount: number
|
||||||
manualSkippedCount: number
|
manualSkippedCount: number
|
||||||
existingSkippedCount: number
|
existingSkippedCount: number
|
||||||
@@ -1013,7 +1014,7 @@ const PostImportFooter = (
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onSubmit}
|
onClick={onSubmit}
|
||||||
disabled={loading}>
|
disabled={loading || !(canSubmit)}>
|
||||||
{creatableCount > 1 ? '一括追加' : '追加'}
|
{creatableCount > 1 ? '一括追加' : '追加'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import MainArea from '@/components/layout/MainArea'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { toast } from '@/components/ui/use-toast'
|
import { toast } from '@/components/ui/use-toast'
|
||||||
import { SITE_TITLE } from '@/config'
|
import { SITE_TITLE } from '@/config'
|
||||||
import { apiGet, isApiError } from '@/lib/api'
|
|
||||||
import {
|
import {
|
||||||
appendPostNewSessionId,
|
appendPostNewSessionId,
|
||||||
serialisePostNewState,
|
serialisePostNewState,
|
||||||
@@ -37,66 +36,32 @@ import type { User } from '@/types'
|
|||||||
|
|
||||||
type Props = { user: User | null }
|
type Props = { user: User | null }
|
||||||
|
|
||||||
type PreviewResponse = {
|
|
||||||
url: string
|
|
||||||
title?: string
|
|
||||||
thumbnailBase?: string
|
|
||||||
originalCreatedFrom?: string
|
|
||||||
originalCreatedBefore?: string
|
|
||||||
duration?: string
|
|
||||||
videoMs?: number
|
|
||||||
tags?: string
|
|
||||||
fieldWarnings?: Record<string, string[]>
|
|
||||||
baseWarnings?: string[]
|
|
||||||
validationErrors?: Record<string, string[]>
|
|
||||||
existingPost?: {
|
|
||||||
id: number
|
|
||||||
title: string
|
|
||||||
url: string
|
|
||||||
thumbnailUrl?: string } | null }
|
|
||||||
|
|
||||||
const MAX_ROWS = 100
|
const MAX_ROWS = 100
|
||||||
const SOURCE_ERROR_ID = 'post-import-source-error'
|
const SOURCE_ERROR_ID = 'post-import-source-error'
|
||||||
const SOURCE_ISSUES_ID = 'post-import-source-issues'
|
const SOURCE_ISSUES_ID = 'post-import-source-issues'
|
||||||
|
|
||||||
const urlIssuesFromRows = (rows: PostImportRow[], source: string) => {
|
const buildInitialRows = (source: string): PostImportRow[] =>
|
||||||
const sourceLines = source.split (/\r\n|\n|\r/)
|
source
|
||||||
|
.split (/\r\n|\n|\r/)
|
||||||
return rows.flatMap (row =>
|
.map ((line, index) => ({
|
||||||
(row.validationErrors.url ?? []).map (message => ({
|
sourceRow: index + 1,
|
||||||
|
url: line.trim () }))
|
||||||
|
.filter (row => row.url !== '')
|
||||||
|
.map (row => ({
|
||||||
sourceRow: row.sourceRow,
|
sourceRow: row.sourceRow,
|
||||||
message,
|
url: row.url,
|
||||||
url: sourceLines[row.sourceRow - 1]?.trim () ?? row.url })))
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const previewRowFromResult = (
|
|
||||||
sourceRow: number,
|
|
||||||
result: PreviewResponse,
|
|
||||||
): PostImportRow => {
|
|
||||||
const fieldWarnings = result.fieldWarnings ?? { }
|
|
||||||
const baseWarnings = result.baseWarnings ?? []
|
|
||||||
const validationErrors = result.validationErrors ?? { }
|
|
||||||
const hasWarnings =
|
|
||||||
Object.values (fieldWarnings).some (messages => messages.length > 0)
|
|
||||||
|| baseWarnings.length > 0
|
|
||||||
const hasErrors =
|
|
||||||
Object.values (validationErrors).some (messages => messages.length > 0)
|
|
||||||
const row: PostImportRow = {
|
|
||||||
sourceRow,
|
|
||||||
url: result.url,
|
|
||||||
attributes: {
|
attributes: {
|
||||||
title: result.title ?? '',
|
title: '',
|
||||||
thumbnailBase: result.thumbnailBase ?? '',
|
thumbnailBase: '',
|
||||||
originalCreatedFrom: result.originalCreatedFrom ?? '',
|
originalCreatedFrom: '',
|
||||||
originalCreatedBefore: result.originalCreatedBefore ?? '',
|
originalCreatedBefore: '',
|
||||||
duration: result.duration ?? '',
|
duration: '',
|
||||||
videoMs: result.videoMs ?? '',
|
videoMs: '',
|
||||||
tags: result.tags ?? '',
|
tags: '',
|
||||||
parentPostIds: '' },
|
parentPostIds: '' },
|
||||||
fieldWarnings,
|
fieldWarnings: { },
|
||||||
baseWarnings,
|
baseWarnings: [],
|
||||||
validationErrors,
|
validationErrors: { },
|
||||||
provenance: {
|
provenance: {
|
||||||
url: 'manual',
|
url: 'manual',
|
||||||
title: 'automatic',
|
title: 'automatic',
|
||||||
@@ -108,27 +73,19 @@ const previewRowFromResult = (
|
|||||||
tags: 'automatic',
|
tags: 'automatic',
|
||||||
parentPostIds: 'automatic' },
|
parentPostIds: 'automatic' },
|
||||||
tagSources: {
|
tagSources: {
|
||||||
automatic: result.tags ?? '',
|
automatic: '',
|
||||||
manual: '' },
|
manual: '' },
|
||||||
status:
|
status: 'ready' as const,
|
||||||
hasErrors
|
|
||||||
? 'error'
|
|
||||||
: hasWarnings
|
|
||||||
? 'warning'
|
|
||||||
: 'ready',
|
|
||||||
skipReason: result.existingPost?.id != null ? 'existing' : undefined,
|
|
||||||
existingPostId: result.existingPost?.id,
|
|
||||||
existingPost: result.existingPost ?? undefined,
|
|
||||||
resetSnapshot: {
|
resetSnapshot: {
|
||||||
url: result.url,
|
url: row.url,
|
||||||
attributes: {
|
attributes: {
|
||||||
title: result.title ?? '',
|
title: '',
|
||||||
thumbnailBase: result.thumbnailBase ?? '',
|
thumbnailBase: '',
|
||||||
originalCreatedFrom: result.originalCreatedFrom ?? '',
|
originalCreatedFrom: '',
|
||||||
originalCreatedBefore: result.originalCreatedBefore ?? '',
|
originalCreatedBefore: '',
|
||||||
duration: result.duration ?? '',
|
duration: '',
|
||||||
videoMs: result.videoMs ?? '',
|
videoMs: '',
|
||||||
tags: result.tags ?? '',
|
tags: '',
|
||||||
parentPostIds: '' },
|
parentPostIds: '' },
|
||||||
provenance: {
|
provenance: {
|
||||||
url: 'manual',
|
url: 'manual',
|
||||||
@@ -141,87 +98,10 @@ const previewRowFromResult = (
|
|||||||
tags: 'automatic',
|
tags: 'automatic',
|
||||||
parentPostIds: 'automatic' },
|
parentPostIds: 'automatic' },
|
||||||
tagSources: {
|
tagSources: {
|
||||||
automatic: result.tags ?? '',
|
automatic: '',
|
||||||
manual: '' },
|
manual: '' },
|
||||||
fieldWarnings,
|
fieldWarnings: { },
|
||||||
baseWarnings } }
|
baseWarnings: [] } }))
|
||||||
return row
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const fetchPreviewRows = async (
|
|
||||||
rows: Array<{ sourceRow: number
|
|
||||||
url: string }>,
|
|
||||||
signal: AbortSignal,
|
|
||||||
): Promise<{
|
|
||||||
rows: PostImportRow[]
|
|
||||||
issues: Array<{ sourceRow: number
|
|
||||||
message: string
|
|
||||||
url: string }>
|
|
||||||
}> => {
|
|
||||||
const results: Array<PostImportRow | null> = new Array (rows.length).fill (null)
|
|
||||||
const issues: Array<{ sourceRow: number
|
|
||||||
message: string
|
|
||||||
url: string }> = []
|
|
||||||
let nextIndex = 0
|
|
||||||
|
|
||||||
const previewIssues = (
|
|
||||||
sourceRow: number,
|
|
||||||
url: string,
|
|
||||||
requestError: unknown,
|
|
||||||
): Array<{ sourceRow: number
|
|
||||||
message: string
|
|
||||||
url: string }> => {
|
|
||||||
if (!(isApiError<{
|
|
||||||
message?: string
|
|
||||||
errors?: Record<string, string[]>
|
|
||||||
baseErrors?: string[]
|
|
||||||
}> (requestError)))
|
|
||||||
{
|
|
||||||
return [{
|
|
||||||
sourceRow,
|
|
||||||
message: '入力を確認してください.',
|
|
||||||
url }]
|
|
||||||
}
|
|
||||||
|
|
||||||
const fieldMessages = Object.values (requestError.response?.data?.errors ?? { }).flat ()
|
|
||||||
const baseMessages = requestError.response?.data?.baseErrors ?? []
|
|
||||||
const messages = [...new Set ([...fieldMessages, ...baseMessages])]
|
|
||||||
return (messages.length > 0 ? messages : [requestError.response?.data?.message ?? '入力を確認してください.'])
|
|
||||||
.map (message => ({
|
|
||||||
sourceRow,
|
|
||||||
message,
|
|
||||||
url }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const worker = async () => {
|
|
||||||
while (nextIndex < rows.length)
|
|
||||||
{
|
|
||||||
const index = nextIndex
|
|
||||||
++nextIndex
|
|
||||||
const row = rows[index]
|
|
||||||
try
|
|
||||||
{
|
|
||||||
const result = await apiGet<PreviewResponse> ('/posts/preview', {
|
|
||||||
params: { url: row.url },
|
|
||||||
signal })
|
|
||||||
results[index] = previewRowFromResult (row.sourceRow, result)
|
|
||||||
}
|
|
||||||
catch (requestError)
|
|
||||||
{
|
|
||||||
if (signal.aborted)
|
|
||||||
return
|
|
||||||
issues.push (...previewIssues (row.sourceRow, row.url, requestError))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all (
|
|
||||||
Array.from ({ length: Math.min (4, rows.length) }, () => worker ()))
|
|
||||||
return {
|
|
||||||
rows: results.filter ((row): row is PostImportRow => row != null),
|
|
||||||
issues }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const PostImportSourcePage: FC<Props> = ({ user }) => {
|
const PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||||
@@ -233,7 +113,6 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
|||||||
const [sourceError, setSourceError] = useState<string | null> (null)
|
const [sourceError, setSourceError] = useState<string | null> (null)
|
||||||
const saveTimer = useRef<number | null> (null)
|
const saveTimer = useRef<number | null> (null)
|
||||||
const editedRef = useRef (false)
|
const editedRef = useRef (false)
|
||||||
const previewController = useRef<AbortController | null> (null)
|
|
||||||
|
|
||||||
const lineCount = countImportSourceLines (source)
|
const lineCount = countImportSourceLines (source)
|
||||||
const messages = sourceError != null ? [sourceError] : []
|
const messages = sourceError != null ? [sourceError] : []
|
||||||
@@ -274,10 +153,6 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
|||||||
}
|
}
|
||||||
}, [source])
|
}, [source])
|
||||||
|
|
||||||
useEffect (() => () => {
|
|
||||||
previewController.current?.abort ()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const preview = async () => {
|
const preview = async () => {
|
||||||
if (lineCount === 0)
|
if (lineCount === 0)
|
||||||
{
|
{
|
||||||
@@ -299,42 +174,7 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
|||||||
setSourceError (null)
|
setSourceError (null)
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
previewController.current?.abort ()
|
const nextRows = initialisePreviewRows (buildInitialRows (source))
|
||||||
const controller = new AbortController ()
|
|
||||||
previewController.current = controller
|
|
||||||
const previewed = await fetchPreviewRows (
|
|
||||||
source
|
|
||||||
.split (/\r\n|\n|\r/)
|
|
||||||
.map ((line, index) => ({
|
|
||||||
sourceRow: index + 1,
|
|
||||||
url: line.trim () }))
|
|
||||||
.filter (row => row.url !== ''),
|
|
||||||
controller.signal)
|
|
||||||
if (previewed.issues.length > 0)
|
|
||||||
{
|
|
||||||
setSourceIssues (previewed.issues)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const urlIssues = urlIssuesFromRows (previewed.rows, source)
|
|
||||||
const duplicateIssues = Object.entries (
|
|
||||||
previewed.rows.reduce<Record<string, PostImportRow[]>> ((acc, row) => {
|
|
||||||
acc[row.url] ||= []
|
|
||||||
acc[row.url].push (row)
|
|
||||||
return acc
|
|
||||||
}, { }))
|
|
||||||
.flatMap (([, rows]) =>
|
|
||||||
rows.length < 2
|
|
||||||
? []
|
|
||||||
: rows.map (row => ({
|
|
||||||
sourceRow: row.sourceRow,
|
|
||||||
message: 'URL が重複しています.',
|
|
||||||
url: row.url })))
|
|
||||||
if (urlIssues.length > 0 || duplicateIssues.length > 0)
|
|
||||||
{
|
|
||||||
setSourceIssues ([...urlIssues, ...duplicateIssues])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const nextRows = initialisePreviewRows (previewed.rows)
|
|
||||||
const serialised = await serialisePostNewState (nextRows)
|
const serialised = await serialisePostNewState (nextRows)
|
||||||
if (serialised == null)
|
if (serialised == null)
|
||||||
return
|
return
|
||||||
@@ -355,16 +195,9 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
|||||||
|
|
||||||
navigate (appendPostNewSessionId (serialised.path, sessionId))
|
navigate (appendPostNewSessionId (serialised.path, sessionId))
|
||||||
}
|
}
|
||||||
catch (requestError)
|
catch
|
||||||
{
|
{
|
||||||
const message =
|
return
|
||||||
isApiError<{ message?: string, baseErrors?: string[] }> (requestError)
|
|
||||||
? (requestError.response?.data?.message
|
|
||||||
?? requestError.response?.data?.baseErrors?.[0])
|
|
||||||
: undefined
|
|
||||||
setSourceError (message ?? '入力を確認してください.')
|
|
||||||
toast ({ title: '投稿情報の取得に失敗しました',
|
|
||||||
description: message ?? '入力を確認してください.' })
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする