コミットを比較

...

11 コミット

作成者 SHA1 メッセージ 日付
みてるぞ e6c3c635b8 #399 2026-07-18 02:29:55 +09:00
みてるぞ 9552081133 #399 2026-07-18 02:08:20 +09:00
みてるぞ 2d2a9b4bd6 #399 2026-07-18 01:37:11 +09:00
みてるぞ 09ac2576bb #399 2026-07-18 01:28:22 +09:00
みてるぞ 9eca670934 #399 2026-07-18 01:12:55 +09:00
みてるぞ c31d84115d #399 2026-07-18 00:31:02 +09:00
みてるぞ ff970f8171 #399 2026-07-18 00:01:12 +09:00
みてるぞ 0ae41b6266 #399 2026-07-17 23:14:37 +09:00
みてるぞ eb4bf5e35c #399 2026-07-17 22:21:18 +09:00
みてるぞ e6b7e33b83 #399 2026-07-17 21:53:24 +09:00
みてるぞ 3820d3d4d5 #399 2026-07-17 21:06:26 +09:00
38個のファイルの変更2712行の追加1646行の削除
+5
ファイルの表示
@@ -529,6 +529,11 @@ and layout reuse, follow `frontend/AGENTS.md`.
- Mobile UI must be checked as a first-class layout. Avoid wide fixed content, - Mobile UI must be checked as a first-class layout. Avoid wide fixed content,
make dense controls wrap or scroll intentionally, and keep tag/filter make dense controls wrap or scroll intentionally, and keep tag/filter
controls usable without horizontal page overflow. controls usable without horizontal page overflow.
- Frontend のスマホ/PC表示境界は原則 `md` とする。
- button stack、footer action、dialogue action は `md` 未満で縦並び、
`md` 以上で横並びとする。
- 同じ画面内で `sm``md` を混在させて中間 layout を作らない。
- 明確に別の responsive 要件がある component だけを例外とする。
- For mobile horizontal scrollers, make the scroll direction and item sizing - For mobile horizontal scrollers, make the scroll direction and item sizing
explicit, and ensure chip text remains readable in both light and dark modes. explicit, and ensure chip text remains readable in both light and dark modes.
- In TypeScript and TSX, prefer direct comparison operators such as `===` and - In TypeScript and TSX, prefer direct comparison operators such as `===` and
-49
ファイルの表示
@@ -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
+188 -7
ファイルの表示
@@ -1,5 +1,6 @@
class PostsController < ApplicationController class PostsController < ApplicationController
Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true) Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true)
MAX_BULK_REQUEST_BYTES = 40 * 1024 * 1024
class VideoMsParseError < ArgumentError class VideoMsParseError < ArgumentError
; ;
@@ -114,6 +115,74 @@ class PostsController < ApplicationController
render json: PostRepr.base(post, current_user) render json: PostRepr.base(post, current_user)
end end
def metadata
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
return render_bad_request('URL は必須です.') if params[:url].blank?
normal_url = PostUrlNormaliser.normalise(params[:url].to_s)
return render_validation_error(fields: { url: ['URL が不正です.'] }) if normal_url.blank?
Preview::UrlSafety.validate(normal_url)
existing_post = Post.with_attached_thumbnail.find_by(url: normal_url)
if existing_post.present?
return render json: {
url: normal_url,
title: nil,
thumbnail_base: nil,
tags: nil,
original_created_from: nil,
original_created_before: nil,
duration: nil,
video_ms: nil,
field_warnings: { },
base_warnings: [],
existing_post_id: existing_post.id,
existing_post: compact_post(existing_post.id) }
end
metadata = PostMetadataFetcher.fetch(normal_url)
field_warnings = { }
field_warnings[:title] = ['タイトルを取得できませんでした.'] if metadata[:title].blank?
if metadata[:thumbnail_base].blank?
field_warnings[:thumbnail_base] = ['サムネールを取得できませんでした.']
end
render json: {
url: normal_url,
title: metadata[:title],
thumbnail_base: metadata[:thumbnail_base],
tags: metadata[:tags],
original_created_from: metadata[:original_created_from],
original_created_before: metadata[:original_created_before],
duration: metadata[:duration],
video_ms: metadata[:video_ms],
field_warnings: field_warnings,
base_warnings: [],
existing_post_id: nil,
existing_post: nil }
rescue ArgumentError => e
render_bad_request e.message
rescue Preview::UrlSafety::UnsafeUrl => e
render_validation_error fields: { url: [e.message] }
rescue Preview::HttpFetcher::FetchFailed,
Preview::HttpFetcher::FetchTimeout,
Preview::HttpFetcher::ResponseTooLarge
render json: {
url: normal_url,
title: nil,
thumbnail_base: nil,
tags: nil,
original_created_from: nil,
original_created_before: nil,
duration: nil,
video_ms: nil,
field_warnings: { url: ['自動取得に失敗しました.'] },
base_warnings: [],
existing_post_id: nil,
existing_post: nil }
end
def show def show
post = post =
Post Post
@@ -142,17 +211,40 @@ class PostsController < ApplicationController
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?
preflight = PostCreatePreflight.new(
attributes: post_create_attributes,
thumbnail: params[:thumbnail]).run
return render json: dry_run_json(preflight) if bool?(:dry)
if preflight[:existing_post_id].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: { title: params[:title], url: params[:url], attributes: post_create_attributes.merge(
thumbnail: params[:thumbnail], tags: params[:tags], preflight.slice(
original_created_from: params[:original_created_from], :url,
original_created_before: params[:original_created_before], :title,
parent_post_ids: parse_parent_post_ids, :thumbnail_base,
video_ms: params[:video_ms], :tags,
duration: params[:duration] }).create! :parent_post_ids,
:original_created_from,
:original_created_before,
:duration,
:video_ms,
:direct_tag_specs,
:default_tag_specs,
:snapshot_tag_specs,
:post_tag_specs,
:tag_sections,
:normalised_parent_post_ids).symbolize_keys).merge(
thumbnail: params[:thumbnail])).create!
post.reload post.reload
render json: PostRepr.base(post), status: :created render json: PostRepr.base(post), status: :created
rescue PostCreatePreflight::ValidationFailed => e
render_validation_error fields: e.fields, base: e.base_errors
rescue Tag::NicoTagNormalisationError rescue Tag::NicoTagNormalisationError
render_validation_error fields: { tags: 'ニコニコ・タグは直接指定できません.' } render_validation_error fields: { tags: 'ニコニコ・タグは直接指定できません.' }
rescue Tag::DeprecatedTagNormalisationError rescue Tag::DeprecatedTagNormalisationError
@@ -161,6 +253,8 @@ class PostsController < ApplicationController
render_validation_error fields: { tags: ['タグ区間の記法が不正です.'] } render_validation_error fields: { tags: ['タグ区間の記法が不正です.'] }
rescue PostCreator::VideoMsParseError rescue PostCreator::VideoMsParseError
render_validation_error fields: { video_ms: ['動画時間の記法が不正です.'] } render_validation_error fields: { video_ms: ['動画時間の記法が不正です.'] }
rescue Post::RemoteThumbnailFetchFailed
render_validation_error fields: { thumbnail_base: ['サムネイル画像の取得に失敗しました.'] }
rescue MiniMagick::Error rescue MiniMagick::Error
render_validation_error fields: { thumbnail: ['サムネイル画像の変換に失敗しました.'] } render_validation_error fields: { thumbnail: ['サムネイル画像の変換に失敗しました.'] }
rescue ArgumentError => e rescue ArgumentError => e
@@ -169,6 +263,21 @@ class PostsController < ApplicationController
render_post_form_record_invalid e.record render_post_form_record_invalid e.record
end 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]
return head :payload_too_large if request.content_length.to_i > MAX_BULK_REQUEST_BYTES
posts = parse_bulk_posts_manifest
thumbnails = parse_bulk_thumbnails(posts.length)
result = PostBulkCreator.new(actor: current_user, posts:, 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 def viewed
return head :unauthorized unless current_user return head :unauthorized unless current_user
@@ -463,6 +572,78 @@ class PostsController < ApplicationController
}.uniq }.uniq
end 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?
raise ArgumentError, 'posts は JSON 文字列で指定してください.' unless manifest.is_a?(String)
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 post_count
thumbnails = { }
raw = params[:thumbnails]
return thumbnails if raw.blank?
raise ArgumentError, 'thumbnail key が不正です.' unless raw.respond_to?(:to_unsafe_h)
raw.to_unsafe_h.each do |key, value|
raise ArgumentError, 'thumbnail key が不正です.' unless key.to_s.match?(/\A\d+\z/)
index = Integer(key, 10)
raise ArgumentError, 'thumbnail index が範囲外です.' if index.negative? || index >= post_count
raise ArgumentError, 'thumbnail index が重複しています.' if thumbnails.key?(index)
unless value.is_a?(ActionDispatch::Http::UploadedFile)
raise ArgumentError, 'thumbnail upload が不正です.'
end
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)
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_id,
:existing_post)
end
def sync_parent_posts! post, parent_post_ids def sync_parent_posts! post, parent_post_ids
if parent_post_ids.include?(post.id) if parent_post_ids.include?(post.id)
post.errors.add :parent_post_ids, '自分自身を親投稿にはできません.' post.errors.add :parent_post_ids, '自分自身を親投稿にはできません.'
-21
ファイルの表示
@@ -38,27 +38,6 @@ class PreviewController < ApplicationController
render_unprocessable_entity(e.message) render_unprocessable_entity(e.message)
end 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 private
def require_member! def require_member!
+171 -17
ファイルの表示
@@ -1,7 +1,9 @@
class Post < ApplicationRecord class Post < ApplicationRecord
require 'date' require 'date'
require 'mini_magick' require 'mini_magick'
require 'nokogiri'
require 'stringio' require 'stringio'
require 'timeout'
class RemoteThumbnailFetchFailed < StandardError; end class RemoteThumbnailFetchFailed < StandardError; end
@@ -11,22 +13,45 @@ class Post < ApplicationRecord
ORIGINAL_CREATED_ORDER_MESSAGE = 'オリジナルの作成日時の順番がをかしぃです.'.freeze ORIGINAL_CREATED_ORDER_MESSAGE = 'オリジナルの作成日時の順番がをかしぃです.'.freeze
ORIGINAL_CREATED_MINIMUM_RANGE_MESSAGE = ORIGINAL_CREATED_MINIMUM_RANGE_MESSAGE =
'オリジナルの作成日時の範囲は1分以上必要です.'.freeze 'オリジナルの作成日時の範囲は1分以上必要です.'.freeze
REMOTE_SVG_CONTENT_TYPE = 'image/svg+xml'.freeze
MAX_SVG_DIMENSION = 4_096
MAX_SVG_PIXELS = 16_777_216
THUMBNAIL_PROCESS_TIMEOUT = 5.seconds
def self.resized_thumbnail_attachment(upload) def self.resized_thumbnail_attachment(upload, content_type: nil)
upload.rewind upload.rewind
image = MiniMagick::Image.read(upload.read) bytes = upload.read
image.resize '180x180^' blob = Timeout.timeout(THUMBNAIL_PROCESS_TIMEOUT) do
image.gravity 'Center' image = image_for_thumbnail_upload(bytes, content_type:)
image.extent '180x180' image.resize '180x180'
image.format 'jpg' image.format 'jpg'
image.to_blob
end
{ io: StringIO.new(image.to_blob), { io: StringIO.new(blob),
filename: 'resized_thumbnail.jpg', filename: 'resized_thumbnail.jpg',
content_type: 'image/jpeg' } content_type: 'image/jpeg' }
rescue Timeout::Error
raise MiniMagick::Error, 'サムネイル画像の変換に失敗しました.'
ensure ensure
upload.rewind upload.rewind
end end
def self.remote_thumbnail_attachment(raw_url)
response = Preview::ThumbnailFetcher.fetch_image_response(raw_url)
resized_thumbnail_attachment(
StringIO.new(response.body),
content_type: response.content_type)
rescue Preview::UrlSafety::UnsafeUrl,
Preview::ThumbnailFetcher::GenerationFailed,
Preview::HttpFetcher::FetchFailed,
Preview::HttpFetcher::FetchTimeout,
Preview::HttpFetcher::ResponseTooLarge,
Timeout::Error,
MiniMagick::Error => e
raise RemoteThumbnailFetchFailed, e.message
end
belongs_to :uploaded_user, class_name: 'User', optional: true belongs_to :uploaded_user, class_name: 'User', optional: true
has_many :post_tags, dependent: :destroy, inverse_of: :post has_many :post_tags, dependent: :destroy, inverse_of: :post
@@ -157,17 +182,7 @@ class Post < ApplicationRecord
end end
def attach_thumbnail_from_url! raw_url def attach_thumbnail_from_url! raw_url
response = Preview::ThumbnailFetcher.fetch_image_response(raw_url) thumbnail.attach(self.class.remote_thumbnail_attachment(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
end end
private private
@@ -209,6 +224,145 @@ class Post < ApplicationRecord
self.url = PostUrlNormaliser.normalise(url) || url.strip self.url = PostUrlNormaliser.normalise(url) || url.strip
end end
def self.image_for_thumbnail_upload(bytes, content_type: nil)
return decode_raster_thumbnail(bytes) unless svg_content_type?(content_type) || svg_document_bytes?(bytes)
decode_svg_thumbnail(bytes)
end
def self.svg_content_type?(content_type)
content_type.to_s.split(';', 2).first.to_s.downcase.strip == REMOTE_SVG_CONTENT_TYPE
end
def self.svg_document_bytes?(bytes)
document = Nokogiri::XML(
bytes,
nil,
nil,
Nokogiri::XML::ParseOptions::STRICT |
Nokogiri::XML::ParseOptions::NONET)
document.root&.name == 'svg'
rescue Nokogiri::XML::SyntaxError
false
end
def self.decode_raster_thumbnail(bytes)
MiniMagick::Image.read(bytes)
end
def self.decode_svg_thumbnail(bytes)
MiniMagick::Image.read(sanitised_svg_bytes(bytes))
end
def self.sanitised_svg_bytes(bytes)
parse_options =
Nokogiri::XML::ParseOptions::STRICT |
Nokogiri::XML::ParseOptions::NONET
document = Nokogiri::XML(
bytes,
nil,
nil,
parse_options)
root = document.root
raise MiniMagick::Error, 'SVG が不正です.' if root == nil || root.name != 'svg'
raise MiniMagick::Error, 'SVG が不正です.' if document.internal_subset != nil
raise MiniMagick::Error, 'SVG が不正です.' if svg_uses_disallowed_features?(document)
width, height = svg_dimensions(root)
raise MiniMagick::Error, 'SVG が大きすぎます.' if width == nil || height == nil
if width > MAX_SVG_DIMENSION || height > MAX_SVG_DIMENSION || width * height > MAX_SVG_PIXELS
raise MiniMagick::Error, 'SVG が大きすぎます.'
end
document.to_xml
rescue Nokogiri::XML::SyntaxError
raise MiniMagick::Error, 'SVG が不正です.'
end
def self.svg_uses_disallowed_features?(document)
document.traverse.any? do |node|
next false unless node.element?
name = node.name.to_s.downcase
next true if name == 'script' || name == 'foreignobject'
next style_contains_disallowed_urls?(node.text.to_s) if name == 'style'
node.attribute_nodes.any? do |attribute|
attribute_name = attribute.name.to_s.downcase
attribute_value = attribute.value.to_s
attribute_name.start_with?('on') ||
external_svg_reference?(attribute_name, attribute_value)
end
end
end
def self.external_svg_reference?(attribute_name, attribute_value)
return external_svg_url?(attribute_value) if ['href', 'xlink:href', 'src'].include?(attribute_name)
return style_contains_disallowed_urls?(attribute_value) if attribute_name == 'style'
return svg_url_function_disallowed?(attribute_value) if attribute_value.match?(/url\s*\(/i)
false
end
def self.external_svg_url?(value)
stripped = value.to_s.strip
return false if stripped.blank? || stripped.start_with?('#')
true
end
def self.style_contains_disallowed_urls?(value)
text = value.to_s
text.match?(/@import/i) || svg_url_function_disallowed?(text)
end
def self.svg_url_function_disallowed?(value)
value.to_s.scan(/url\s*\(([^)]*)\)/i).flatten.any? do |entry|
reference = entry.to_s.strip.delete_prefix("'").delete_prefix('"').delete_suffix("'").delete_suffix('"')
reference.present? && !(reference.start_with?('#'))
end
end
def self.svg_dimensions(root)
width = svg_length_to_pixels(root['width'])
height = svg_length_to_pixels(root['height'])
return [width, height] if width && height
view_box = root['viewBox'].to_s.strip.split(/\s+/).map { Float(_1) rescue nil }
return [nil, nil] if view_box.length != 4 || view_box.any?(&:nil?)
return [nil, nil] unless view_box[2].finite? && view_box[2].positive?
return [nil, nil] unless view_box[3].finite? && view_box[3].positive?
[view_box[2], view_box[3]]
end
def self.svg_length_to_pixels(value)
return nil if value.blank?
matched = /\A([0-9]+(?:\.[0-9]+)?)(px)?\z/i.match(value.to_s.strip)
return nil if matched == nil
pixels = Float(matched[1])
return nil unless pixels.finite? && pixels.positive?
pixels
rescue ArgumentError
nil
end
private_class_method :image_for_thumbnail_upload,
:svg_content_type?,
:decode_raster_thumbnail,
:decode_svg_thumbnail,
:sanitised_svg_bytes,
:svg_uses_disallowed_features?,
:external_svg_reference?,
:external_svg_url?,
:style_contains_disallowed_urls?,
:svg_url_function_disallowed?,
:svg_dimensions,
:svg_length_to_pixels
def parse_original_created_value field def parse_original_created_value field
raw_value = public_send("#{ field }_before_type_cast") raw_value = public_send("#{ field }_before_type_cast")
value = public_send(field) value = public_send(field)
+26
ファイルの表示
@@ -0,0 +1,26 @@
# frozen_string_literal: true
module PostCompactRepr
module_function
def base post
return nil if post.nil?
{
id: post.id,
title: post.title,
url: post.url,
thumbnail_url: thumbnail_url(post) }
end
def thumbnail_url post
return nil unless post.thumbnail.attached?
Rails.application.routes.url_helpers.rails_storage_proxy_url(
post.thumbnail,
only_path: false)
rescue ActionController::UrlGenerationError, ArgumentError, URI::InvalidURIError
nil
end
end
+200
ファイルの表示
@@ -0,0 +1,200 @@
class PostBulkCreator
def initialize actor:, posts:, thumbnails:
@actor_id = actor.id
@posts = posts
@thumbnails = thumbnails
end
def run
results = Array.new(@posts.length)
mutex = Mutex.new
next_index = 0
workers = Array.new(2) do
Thread.new do
Rails.application.executor.wrap 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
end
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: }
end
private
def create_row actor, attributes, index
preflight =
PostCreatePreflight.new(
attributes: attributes,
thumbnail: thumbnail_for(index, attributes)).run
if preflight[:existing_post_id].present?
return {
status: 'skipped',
existing_post_id: preflight[:existing_post_id],
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_id: existing_post[:id],
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
if e.message.include?('index_posts_on_url')
existing_post = existing_post_for_race(attributes)
return {
status: 'skipped',
existing_post_id: existing_post[:id],
existing_post: existing_post } if existing_post.present?
end
Rails.logger.error(
"post_bulk_creator_record_not_unique #{ { error: e.class.name,
message: e.message }.to_json }")
{
status: 'failed',
recoverable: false,
errors: { base: ['登録中にエラーが発生しました.'] },
base_errors: [] }
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],
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],
normalised_parent_post_ids: preflight[:normalised_parent_post_ids] }
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
PostCompactRepr.base(post)
end
end
+209
ファイルの表示
@@ -0,0 +1,209 @@
class PostCreatePlan
VIDEO_TAG_NAME = '動画'.freeze
TAGME_TAG_NAME = 'タグ希望'.freeze
NO_DEERJIKIST_TAG_NAME = 'ニジラー情報不詳'.freeze
def initialize attributes:
@attributes = attributes.symbolize_keys
@existing_tags_by_name = nil
end
def build!
direct_tag_specs, tag_sections = parse_direct_tag_specs
default_tag_specs = build_default_tag_specs(direct_tag_specs)
snapshot_tag_specs = merge_tag_specs(direct_tag_specs + default_tag_specs)
post_tag_specs = expand_parent_tag_specs(snapshot_tag_specs)
video_ms = normalise_video_ms(snapshot_tag_specs)
validate_video_sections!(video_ms, tag_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(direct_tag_specs, tag_sections),
duration: @attributes[:duration].to_s,
video_ms: video_ms,
parent_post_ids: parent_post_ids.join(' '),
direct_tag_specs: direct_tag_specs,
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 }
end
private
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
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 direct_tag_specs, tag_sections
direct_tag_specs.map { |spec|
"#{ spec[:name] }#{ tag_sections[spec[:name]].to_a.map { Post.section_literal(_1) }.join }"
}.sort.join(' ')
end
def normalise_video_ms snapshot_tag_specs
return nil unless snapshot_tag_specs.any? { _1[:name] == VIDEO_TAG_NAME }
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, tag_sections
return unless video_ms
tag_sections.each_value do |ranges|
ranges.each do |begin_ms, end_ms|
if begin_ms >= video_ms
post = Post.new
post.errors.add :video_ms, 'タグ区間の開始が動画時間以上です.'
raise ActiveRecord::RecordInvalid, post
end
if end_ms && end_ms > video_ms
post = Post.new
post.errors.add :video_ms, 'タグ区間の終端が動画時間を超えてゐます.'
raise ActiveRecord::RecordInvalid, post
end
end
end
end
end
+138
ファイルの表示
@@ -0,0 +1,138 @@
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
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_id: preview[:existing_post_id],
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: 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],
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],
normalised_parent_post_ids: plan[:normalised_parent_post_ids],
field_warnings: final_field_warnings(preview[:field_warnings] || { }),
base_warnings: preview[:base_warnings],
existing_post_id: preview[:existing_post_id],
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?
PostThumbnailUploadValidator.validate!(@thumbnail)
rescue PostThumbnailUploadValidator::InvalidUpload => e
raise ValidationFailed.new(fields: { thumbnail: [e.message] })
end
def existing_post_compact post_id
return nil if post_id.blank?
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
next_warnings = field_warnings.except('thumbnail_base')
next_warnings['thumbnail_base'] = thumbnail_warnings if thumbnail_warnings.present?
next_warnings
end
end
+81 -64
ファイルの表示
@@ -10,95 +10,112 @@ class PostCreator
end end
def create! def create!
thumbnail_attachment = prepare_thumbnail_attachment
post = Post.new(title: @attributes[:title].presence, post = Post.new(title: @attributes[:title].presence,
url: @attributes[:url], url: @attributes[:url],
thumbnail_base: @attributes[:thumbnail_base].presence, thumbnail_base: @attributes[:thumbnail_base].presence,
uploaded_user: @actor, uploaded_user: @actor,
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)
attach_thumbnail!(post)
ApplicationRecord.transaction do ApplicationRecord.transaction do
post.save! post.save!
Tag.normalise_tags!(tag_names, deny_deprecated: true, with_sections: true) => post.thumbnail.attach(thumbnail_attachment) if thumbnail_attachment.present?
{ tags:, sections: } snapshot_tags = planned_snapshot_tags
TagVersioning.record_tag_snapshots!(tags, created_by_user: @actor) post_tags = planned_post_tags
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?) sections = planned_sections
post.video_ms = normalise_video_ms(tags) TagVersioning.record_tag_snapshots!(snapshot_tags, created_by_user: @actor)
validate_video_sections!(post.video_ms, sections) 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, 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
post post
rescue StandardError
post&.thumbnail&.purge if post&.thumbnail&.attached?
raise
end end
private private
def attach_thumbnail! post def prepare_thumbnail_attachment
thumbnail = @attributes[:thumbnail] PostThumbnailAttachmentBuilder.build(
if thumbnail.present? thumbnail: @attributes[:thumbnail],
post.thumbnail.attach(Post.resized_thumbnail_attachment(thumbnail)) thumbnail_base: @attributes[:thumbnail_base].presence)
return
end end
thumbnail_base = post.thumbnail_base def planned_snapshot_tags = planned_create_attributes[:snapshot_tags]
return if thumbnail_base.blank?
post.attach_thumbnail_from_url!(thumbnail_base) def planned_post_tags = planned_create_attributes[:post_tags]
rescue Post::RemoteThumbnailFetchFailed => e
@field_warnings[:thumbnail_base] = [e.message] def planned_sections = planned_create_attributes[:tag_sections]
def planned_parent_post_ids = planned_create_attributes[:normalised_parent_post_ids]
def planned_video_ms
planned_create_attributes[:video_ms]
end end
def tag_names = @attributes[:tags].to_s.split def planned_create_attributes
@planned_create_attributes ||= begin
def parent_post_ids if @attributes.key?(:snapshot_tag_specs)
Array(@attributes[:parent_post_ids]).flat_map { _1.to_s.split }.map { |token| snapshot_tags = materialise_tags(@attributes[:snapshot_tag_specs] || [])
id = Integer(token, exception: false) post_tags = materialise_tags(@attributes[:post_tag_specs] || [])
raise ArgumentError, "親投稿 Id. が不正です: #{ token }" if id.nil? || id <= 0 {
snapshot_tags: snapshot_tags,
id post_tags: post_tags,
}.uniq tag_sections: materialise_sections(
end @attributes[:tag_sections] || { },
snapshot_tags,
def normalise_video_ms tags post_tags),
return nil unless tags.any? { _1.id == Tag.video.id } normalised_parent_post_ids: @attributes[:normalised_parent_post_ids] || [],
video_ms: @attributes[:video_ms] }
video_ms = @attributes[:video_ms] else
if video_ms.present? build_materialised_plan
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 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 end
def sync_post_tags! post, desired_tags, sections def sync_post_tags! post, desired_tags, sections
+29 -4
ファイルの表示
@@ -7,6 +7,7 @@ class PostImportPreviewer
'thumbnail_base', 'thumbnail_base',
'original_created_from', 'original_created_from',
'original_created_before', 'original_created_before',
'video_ms',
'duration', 'duration',
'tags', 'tags',
'parent_post_ids'].freeze 'parent_post_ids'].freeze
@@ -162,6 +163,8 @@ class PostImportPreviewer
normalised = normalised =
if field == 'duration' if field == 'duration'
normalise_duration_attribute(value) normalise_duration_attribute(value)
elsif field == 'video_ms'
value.nil? ? '' : value.to_s
else else
value.to_s value.to_s
end end
@@ -194,7 +197,7 @@ class PostImportPreviewer
def clear_automatic_values! attributes, provenance, tag_sources def clear_automatic_values! attributes, provenance, tag_sources
['title', 'thumbnail_base', 'original_created_from', ['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' attributes[field] = '' if provenance[field] == 'automatic'
end end
tag_sources['automatic'] = '' tag_sources['automatic'] = ''
@@ -252,7 +255,15 @@ class PostImportPreviewer
def sanitise_metadata_url value def sanitise_metadata_url value
return nil unless value.is_a?(String) return nil unless value.is_a?(String)
PostUrlNormaliser.normalise(value) stripped = value.strip
return nil if stripped.blank?
uri = URI.parse(stripped)
return nil unless uri.is_a?(URI::HTTP) && uri.host.present?
stripped
rescue URI::InvalidURIError
nil
end end
def sanitise_metadata_time value def sanitise_metadata_time value
@@ -466,7 +477,7 @@ class PostImportPreviewer
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,
video_ms: parse_duration(attributes['duration'], errors)) video_ms: parse_video_ms(attributes, errors))
post.valid? post.valid?
post.errors.each do |error| post.errors.each do |error|
next if error.attribute == :url && error.type == :taken next if error.attribute == :url && error.type == :taken
@@ -475,7 +486,21 @@ class PostImportPreviewer
end end
end end
def parse_duration value, errors 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)
if value&.positive?
return value
end
errors[:video_ms] = ['動画時間の記法が不正です.']
return nil
end
value = attributes['duration']
return nil if value.blank? return nil if value.blank?
Tag.time_to_ms!(value.to_s, tag_name: '動画時間') Tag.time_to_ms!(value.to_s, tag_name: '動画時間')
-190
ファイルの表示
@@ -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
-109
ファイルの表示
@@ -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
-25
ファイルの表示
@@ -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
+11
ファイルの表示
@@ -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
+29
ファイルの表示
@@ -0,0 +1,29 @@
class PostThumbnailUploadValidator
MAX_THUMBNAIL_BYTES = 20 * 1024 * 1024
ALLOWED_CONTENT_TYPES = Preview::ThumbnailFetcher::RASTER_IMAGE_CONTENT_TYPES.freeze
class InvalidUpload < StandardError; end
def self.validate! thumbnail
return if thumbnail.blank?
unless thumbnail.is_a?(ActionDispatch::Http::UploadedFile)
raise InvalidUpload, 'thumbnail upload が不正です.'
end
raise InvalidUpload, 'thumbnail file size が大きすぎます.' if thumbnail.size > MAX_THUMBNAIL_BYTES
raise InvalidUpload, 'サムネイル画像の形式が不正です.' unless allowed_content_type?(thumbnail.content_type)
raise InvalidUpload, 'サムネイル画像の形式が不正です.' if Post.svg_document_bytes?(thumbnail.read)
thumbnail.rewind
attachment = Post.resized_thumbnail_attachment(thumbnail, content_type: thumbnail.content_type)
attachment[:io].close if attachment[:io].respond_to?(:close)
rescue MiniMagick::Error, Timeout::Error
raise InvalidUpload, 'サムネイル画像の変換に失敗しました.'
ensure
thumbnail&.rewind if thumbnail.respond_to?(:rewind)
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
+10 -6
ファイルの表示
@@ -1,9 +1,13 @@
module Preview module Preview
class ThumbnailFetcher class ThumbnailFetcher
class GenerationFailed < StandardError; end class GenerationFailed < StandardError; end
ALLOWED_IMAGE_CONTENT_TYPES = [ RASTER_IMAGE_CONTENT_TYPES = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp' 'image/jpeg', 'image/png', 'image/gif', 'image/webp'
].freeze ].freeze
REMOTE_IMAGE_CONTENT_TYPES = [
*RASTER_IMAGE_CONTENT_TYPES,
'image/svg+xml'
].freeze
HTML_MAX_BYTES = 1.megabyte HTML_MAX_BYTES = 1.megabyte
NICONICO_XML_MAX_BYTES = 256.kilobytes NICONICO_XML_MAX_BYTES = 256.kilobytes
@@ -28,7 +32,7 @@ module Preview
def self.fetch_image_response(raw_url) def self.fetch_image_response(raw_url)
uri, = UrlSafety.validate(raw_url) uri, = UrlSafety.validate(raw_url)
response = HttpFetcher.fetch(uri.to_s) response = HttpFetcher.fetch(uri.to_s)
unless allowed_image_content_type?(response.content_type) unless allowed_remote_image_content_type?(response.content_type) || Post.svg_document_bytes?(response.body)
raise GenerationFailed, 'サムネール画像が見つかりませんでした.' raise GenerationFailed, 'サムネール画像が見つかりませんでした.'
end end
@@ -51,7 +55,7 @@ module Preview
return nil if url.blank? return nil if url.blank?
response = HttpFetcher.fetch(url) response = HttpFetcher.fetch(url)
return nil unless allowed_image_content_type?(response.content_type) return nil unless allowed_remote_image_content_type?(response.content_type)
response.body response.body
rescue HttpFetcher::FetchTimeout rescue HttpFetcher::FetchTimeout
@@ -92,13 +96,13 @@ module Preview
nil nil
end end
def self.allowed_image_content_type?(content_type) def self.allowed_remote_image_content_type?(content_type)
mime_type = content_type.to_s.split(';', 2).first.downcase.strip mime_type = content_type.to_s.split(';', 2).first.downcase.strip
ALLOWED_IMAGE_CONTENT_TYPES.include?(mime_type) REMOTE_IMAGE_CONTENT_TYPES.include?(mime_type)
end end
private_class_method :fetch_image_or_nil, :fetch_image!, private_class_method :fetch_image_or_nil, :fetch_image!,
:niconico_thumbnail_url, :niconico_thumbnail_url,
:allowed_image_content_type? :allowed_remote_image_content_type?
end end
end end
+2 -7
ファイルの表示
@@ -31,13 +31,6 @@ Rails.application.routes.draw do
scope :preview, controller: :preview do scope :preview, controller: :preview do
get :title get :title
get :thumbnail get :thumbnail
get :image
end
scope 'posts/import', controller: :post_imports do
post :preview
post :validate
post '', action: :create
end end
resources :wiki_pages, path: 'wiki', only: [:index, :show, :create, :update] do resources :wiki_pages, path: 'wiki', only: [:index, :show, :create, :update] do
@@ -59,6 +52,8 @@ 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 :metadata
post :bulk
get :random get :random
get :changes get :changes
get :versions, to: 'post_versions#index' get :versions, to: 'post_versions#index'
+9
ファイルの表示
@@ -153,6 +153,9 @@ pass or the remaining failure is clearly blocked.
- When new user-facing wording appears necessary, ask the user for the exact - When new user-facing wording appears necessary, ask the user for the exact
wording and placement before implementing it. wording and placement before implementing it.
- Do not invent replacement copy when removing unrequested wording. - Do not invent replacement copy when removing unrequested wording.
- Do not use `タグなし` as user-facing copy for an empty tag state. When the
tag state is empty, show no copy. If actual data contains the tag name
`タグなし`, treat it as ordinary data and display it normally.
- When adding dynamic tag colour classes, update `tailwind.config.js` safelist - When adding dynamic tag colour classes, update `tailwind.config.js` safelist
if the class cannot be statically detected. if the class cannot be statically detected.
- Do not introduce new UI libraries or production dependencies without approval. - Do not introduce new UI libraries or production dependencies without approval.
@@ -599,6 +602,12 @@ offsets, or footer offsets, inspect existing layout components such as
Do not create a second layout shell before checking whether the current layout Do not create a second layout shell before checking whether the current layout
can be reused or minimally extended. can be reused or minimally extended.
- Frontend のスマホ/PC表示境界は原則 `md` とする。
- button stack、footer action、dialogue action は `md` 未満で縦並び、
`md` 以上で横並びとする。
- 同じ画面内で `sm``md` を混在させて中間 layout を作らない。
- 明確に別の responsive 要件がある component だけを例外とする。
### Delimiter decision table ### Delimiter decision table
Use this table before accepting any edited TypeScript or TSX hunk. The table is Use this table before accepting any edited TypeScript or TSX hunk. The table is
+6 -6
ファイルの表示
@@ -24,10 +24,10 @@ const PostOriginalCreatedTimeField: FC<Props> = (
<FormField label="オリジナルの作成日時" messages={errors}> <FormField label="オリジナルの作成日時" messages={errors}>
{({ describedBy, invalid }) => ( {({ describedBy, invalid }) => (
<> <>
<div className="my-1 flex flex-col gap-2 sm:flex-row sm:items-start"> <div className="my-1 flex">
<div className="min-w-0 flex-1"> <div className="w-80">
<DateTimeField <DateTimeField
className="w-full" className="mr-2"
disabled={disabled ?? false} disabled={disabled ?? false}
aria-describedby={describedBy} aria-describedby={describedBy}
aria-invalid={invalid} aria-invalid={invalid}
@@ -61,10 +61,10 @@ const PostOriginalCreatedTimeField: FC<Props> = (
</div> </div>
</div> </div>
<div className="my-1 flex flex-col gap-2 sm:flex-row sm:items-start"> <div className="my-1 flex">
<div className="min-w-0 flex-1"> <div className="w-80">
<DateTimeField <DateTimeField
className="w-full" className="mr-2"
disabled={disabled} disabled={disabled}
aria-describedby={describedBy} aria-describedby={describedBy}
aria-invalid={invalid} aria-invalid={invalid}
+6 -6
ファイルの表示
@@ -215,12 +215,12 @@ const DialogueProvider: FC<Props> = ({ children }) => {
<DialogFooter <DialogFooter
className="shrink-0 flex-col gap-2 pt-4 className="shrink-0 flex-col gap-2 pt-4
sm:flex-row sm:justify-between sm:gap-0 sm:space-x-0"> md:flex-row md:justify-between md:gap-0 md:space-x-0">
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row"> <div className="flex w-full flex-col gap-2 md:w-auto md:flex-row">
{startActions.map (action => ( {startActions.map (action => (
<Button <Button
key={action.label} key={action.label}
className="w-full sm:w-auto" className="w-full md:w-auto"
variant={action.variant === 'danger' variant={action.variant === 'danger'
? 'destructive' ? 'destructive'
: 'default'} : 'default'}
@@ -232,9 +232,9 @@ const DialogueProvider: FC<Props> = ({ children }) => {
</Button>))} </Button>))}
</div> </div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row"> <div className="flex w-full flex-col gap-2 md:w-auto md:flex-row">
<Button <Button
className="w-full sm:w-auto" className="w-full md:w-auto"
variant="outline" variant="outline"
onClick={() => closeRequest (active.id)} onClick={() => closeRequest (active.id)}
disabled={pendingIds.includes (active.id) disabled={pendingIds.includes (active.id)
@@ -245,7 +245,7 @@ const DialogueProvider: FC<Props> = ({ children }) => {
{endActions.map (action => ( {endActions.map (action => (
<Button <Button
key={action.label} key={action.label}
className="w-full sm:w-auto" className="w-full md:w-auto"
variant={action.variant === 'danger' variant={action.variant === 'danger'
? 'destructive' ? 'destructive'
: 'default'} : 'default'}
+6 -2
ファイルの表示
@@ -17,13 +17,15 @@ type Props = {
type?: string type?: string
placeholder?: string } placeholder?: string }
thumbnailField: ReactNode thumbnailField: ReactNode
core: PostCoreDataFieldsProps } core: PostCoreDataFieldsProps
extraFields?: ReactNode }
const PostCreationDataFields: FC<Props> = ( const PostCreationDataFields: FC<Props> = (
{ url, { url,
thumbnailField, thumbnailField,
core }, core,
extraFields },
) => ( ) => (
<> <>
<PostTextField <PostTextField
@@ -39,6 +41,8 @@ const PostCreationDataFields: FC<Props> = (
{thumbnailField} {thumbnailField}
<PostCoreDataFields {...core}/> <PostCoreDataFields {...core}/>
{extraFields}
</>) </>)
export default PostCreationDataFields export default PostCreationDataFields
+1 -2
ファイルの表示
@@ -21,8 +21,7 @@ const PostDurationField: FC<Props> = (
onChange={onChange} onChange={onChange}
errors={errors} errors={errors}
disabled={disabled} disabled={disabled}
type="text" type="text"/>
placeholder="例: 2 / 2.5 / 1:23"/>
) )
export default PostDurationField export default PostDurationField
+30 -5
ファイルの表示
@@ -6,20 +6,44 @@ import type { FC } from 'react'
type Props = { type Props = {
url: string url: string
file?: File
alt?: string alt?: string
className?: string } className?: string
referrerPolicy?: 'no-referrer' }
const PostThumbnailPreview: FC<Props> = ( const PostThumbnailPreview: FC<Props> = (
{ url, alt = 'サムネール', className = 'h-16 w-16' }, { url,
file,
alt = 'サムネール',
className = 'h-16 w-16',
referrerPolicy },
) => { ) => {
const [failed, setFailed] = useState (false) const [failed, setFailed] = useState (false)
const [fileUrl, setFileUrl] = useState<string | null> (null)
useEffect (() => { useEffect (() => {
setFailed (false) setFailed (false)
}, [url]) }, [file, url])
if (!(url) || failed) useEffect (() => {
if (file == null)
{
setFileUrl (null)
return
}
const nextUrl = URL.createObjectURL (file)
setFileUrl (nextUrl)
return () => {
URL.revokeObjectURL (nextUrl)
}
}, [file])
const resolvedUrl = url.trim () !== '' ? url : (fileUrl ?? '')
if (resolvedUrl === '' || failed)
{ {
return ( return (
<div <div
@@ -30,8 +54,9 @@ const PostThumbnailPreview: FC<Props> = (
return ( return (
<img <img
src={url} src={resolvedUrl}
alt={alt} alt={alt}
referrerPolicy={referrerPolicy}
className={cn (className, 'rounded border border-border object-cover')} className={cn (className, 'rounded border border-border object-cover')}
onError={() => setFailed (true)}/>) onError={() => setFailed (true)}/>)
} }
+64 -7
ファイルの表示
@@ -3,8 +3,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import FieldError from '@/components/common/FieldError' import FieldError from '@/components/common/FieldError'
import FieldWarning from '@/components/common/FieldWarning' import FieldWarning from '@/components/common/FieldWarning'
import PostCreationDataFields from '@/components/posts/PostCreationDataFields' import PostCreationDataFields from '@/components/posts/PostCreationDataFields'
import PostDurationField from '@/components/posts/PostDurationField'
import PostTextField from '@/components/posts/PostTextField' import PostTextField from '@/components/posts/PostTextField'
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview' import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
import { hasThumbnailBaseValue, hasVideoTag } from '@/lib/postImportRows'
import type { FC } from 'react' import type { FC } from 'react'
@@ -21,6 +23,8 @@ type Props = {
saved: boolean saved: boolean
row: PostImportRow | null }> } row: PostImportRow | null }> }
const THUMBNAIL_MISSING_WARNING = 'サムネールなし'
const buildDraft = (row: PostImportRow): Draft => ({ const buildDraft = (row: PostImportRow): Draft => ({
url: row.url, url: row.url,
title: String (row.attributes.title ?? ''), title: String (row.attributes.title ?? ''),
@@ -28,7 +32,9 @@ const buildDraft = (row: PostImportRow): Draft => ({
originalCreatedFrom: String (row.attributes.originalCreatedFrom ?? ''), originalCreatedFrom: String (row.attributes.originalCreatedFrom ?? ''),
originalCreatedBefore: String (row.attributes.originalCreatedBefore ?? ''), originalCreatedBefore: String (row.attributes.originalCreatedBefore ?? ''),
tags: String (row.attributes.tags ?? ''), tags: String (row.attributes.tags ?? ''),
parentPostIds: String (row.attributes.parentPostIds ?? '') }) parentPostIds: String (row.attributes.parentPostIds ?? ''),
duration: String (row.attributes.duration ?? ''),
thumbnailFile: row.thumbnailFile })
const buildResetDraft = (row: PostImportRow): Draft => ({ const buildResetDraft = (row: PostImportRow): Draft => ({
url: row.resetSnapshot.url, url: row.resetSnapshot.url,
@@ -37,7 +43,9 @@ const buildResetDraft = (row: PostImportRow): Draft => ({
originalCreatedFrom: String (row.resetSnapshot.attributes.originalCreatedFrom ?? ''), originalCreatedFrom: String (row.resetSnapshot.attributes.originalCreatedFrom ?? ''),
originalCreatedBefore: String (row.resetSnapshot.attributes.originalCreatedBefore ?? ''), originalCreatedBefore: String (row.resetSnapshot.attributes.originalCreatedBefore ?? ''),
tags: String (row.resetSnapshot.attributes.tags ?? ''), tags: String (row.resetSnapshot.attributes.tags ?? ''),
parentPostIds: String (row.resetSnapshot.attributes.parentPostIds ?? '') }) parentPostIds: String (row.resetSnapshot.attributes.parentPostIds ?? ''),
duration: String (row.resetSnapshot.attributes.duration ?? ''),
thumbnailFile: undefined })
const groupedMessages = (...values: (string[] | undefined)[]): string[] => const groupedMessages = (...values: (string[] | undefined)[]): string[] =>
[...new Set (values.flatMap (value => value ?? []))] [...new Set (values.flatMap (value => value ?? []))]
@@ -50,6 +58,8 @@ const sameDraft = (left: Draft, right: Draft): boolean =>
&& left.originalCreatedBefore === right.originalCreatedBefore && left.originalCreatedBefore === right.originalCreatedBefore
&& left.tags === right.tags && left.tags === right.tags
&& left.parentPostIds === right.parentPostIds && left.parentPostIds === right.parentPostIds
&& left.duration === right.duration
&& left.thumbnailFile === right.thumbnailFile
const sameProvenance = ( const sameProvenance = (
current: PostImportRow['provenance'], current: PostImportRow['provenance'],
@@ -64,6 +74,24 @@ const sameTagSources = (
(current?.automatic ?? '') === reset.automatic (current?.automatic ?? '') === reset.automatic
&& (current?.manual ?? '') === reset.manual && (current?.manual ?? '') === reset.manual
const sameWarnings = (
current: PostImportRow,
reset: PostImportRow['resetSnapshot'],
): boolean =>
JSON.stringify (current.fieldWarnings) === JSON.stringify (reset.fieldWarnings)
&& JSON.stringify (current.baseWarnings) === JSON.stringify (reset.baseWarnings)
const thumbnailWarnings = (
messages: string[] | undefined,
thumbnailBase: string,
thumbnailFile: File | undefined,
): string[] => {
const others = (messages ?? []).filter (message => message !== THUMBNAIL_MISSING_WARNING)
return hasThumbnailBaseValue (thumbnailBase) || thumbnailFile != null
? others
: [...new Set ([...others, THUMBNAIL_MISSING_WARNING])]
}
const PostImportRowForm: FC<Props> = ( const PostImportRowForm: FC<Props> = (
{ row, { row,
@@ -89,12 +117,18 @@ const PostImportRowForm: FC<Props> = (
const resetDraft = useMemo ( const resetDraft = useMemo (
() => buildResetDraft (row), () => buildResetDraft (row),
[row]) [row])
const durationVisible = hasVideoTag (draft.tags)
const currentThumbnailWarnings = thumbnailWarnings (
displayRow.fieldWarnings.thumbnailBase,
draft.thumbnailBase,
draft.thumbnailFile)
const resetDisabled = const resetDisabled =
saving saving
|| (sameDraft (draft, resetDraft) || (sameDraft (draft, resetDraft)
&& sameProvenance (row.provenance, row.resetSnapshot.provenance) && sameProvenance (row.provenance, row.resetSnapshot.provenance)
&& sameTagSources (row.tagSources, row.resetSnapshot.tagSources) && sameTagSources (row.tagSources, row.resetSnapshot.tagSources)
&& row.metadataUrl === row.resetSnapshot.metadataUrl) && row.metadataUrl === row.resetSnapshot.metadataUrl
&& sameWarnings (displayRow, row.resetSnapshot))
const update = <Key extends keyof Draft,> ( const update = <Key extends keyof Draft,> (
key: Key, key: Key,
@@ -111,7 +145,6 @@ const PostImportRowForm: FC<Props> = (
const confirmed = await controls.confirm ({ const confirmed = await controls.confirm ({
title: '変更をリセットしますか?', title: '変更をリセットしますか?',
description: '現在の URL に対する自動取得直後の内容へ戻します.',
confirmText: 'リセット', confirmText: 'リセット',
cancelText: '取消', cancelText: '取消',
variant: 'danger' }) variant: 'danger' })
@@ -164,6 +197,10 @@ const PostImportRowForm: FC<Props> = (
<div className="space-y-3 md:sticky md:top-0 md:self-start"> <div className="space-y-3 md:sticky md:top-0 md:self-start">
<PostImportThumbnailPreview <PostImportThumbnailPreview
url={committedThumbnailBase} url={committedThumbnailBase}
file={
hasThumbnailBaseValue (committedThumbnailBase)
? undefined
: draft.thumbnailFile}
className="h-28 w-28"/> className="h-28 w-28"/>
</div> </div>
@@ -183,15 +220,24 @@ const PostImportRowForm: FC<Props> = (
label="サムネール" label="サムネール"
value={draft.thumbnailBase} value={draft.thumbnailBase}
disabled={saving} disabled={saving}
warnings={displayRow.fieldWarnings.thumbnailBase} warnings={currentThumbnailWarnings}
errors={groupedMessages ( errors={groupedMessages (
displayRow.validationErrors.thumbnailBase, displayRow.validationErrors.thumbnailBase,
displayRow.importErrors?.thumbnailBase)} displayRow.importErrors?.thumbnailBase)}
onBlur={() => { onBlur={() => {
if (draft.thumbnailBase !== committedThumbnailBase) if (draft.thumbnailBase.trim () !== committedThumbnailBase.trim ())
setCommittedThumbnailBase (draft.thumbnailBase) setCommittedThumbnailBase (draft.thumbnailBase)
}} }}
onChange={value => update ('thumbnailBase', value)}/> onChange={value => update ('thumbnailBase', value)}/>
{!(hasThumbnailBaseValue (draft.thumbnailBase)) && (
<input
type="file"
accept="image/*"
disabled={saving}
onChange={event => {
const file = event.target.files?.[0]
update ('thumbnailFile', file)
}}/>)}
</>} </>}
core={{ core={{
title: { title: {
@@ -235,7 +281,18 @@ const PostImportRowForm: FC<Props> = (
disabled: saving, disabled: saving,
errors: groupedMessages ( errors: groupedMessages (
displayRow.validationErrors.parentPostIds, displayRow.validationErrors.parentPostIds,
displayRow.importErrors?.parentPostIds) } }}/> displayRow.importErrors?.parentPostIds) } }}
extraFields={
durationVisible
? (
<PostDurationField
value={draft.duration}
onChange={value => update ('duration', value)}
disabled={saving}
errors={groupedMessages (
displayRow.validationErrors.videoMs,
displayRow.importErrors?.videoMs)}/>)
: null}/>
<FieldWarning messages={displayRow.baseWarnings}/> <FieldWarning messages={displayRow.baseWarnings}/>
<FieldError messages={displayRow.validationErrors.base}/> <FieldError messages={displayRow.validationErrors.base}/>
<FieldError messages={displayRow.importErrors?.base}/> <FieldError messages={displayRow.importErrors?.base}/>
+24 -7
ファイルの表示
@@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button'
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview' import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge' import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus' import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
import { hasVideoTag } from '@/lib/postImportSession'
import { canEditReviewRow, canRetryResultRow } from '@/lib/postImportSession' import { canEditReviewRow, canRetryResultRow } from '@/lib/postImportSession'
import { cn, originalCreatedAtString } from '@/lib/utils' import { cn, originalCreatedAtString } from '@/lib/utils'
@@ -54,6 +55,8 @@ const PostImportRowSummary: FC<Props> = (
const retryAllowed = onRetry != null && canRetryResultRow (row) const retryAllowed = onRetry != null && canRetryResultRow (row)
const skipChecked = row.skipReason === 'manual' const skipChecked = row.skipReason === 'manual'
const rowNumber = displayNumber ?? row.sourceRow const rowNumber = displayNumber ?? row.sourceRow
const duration = String (row.attributes.duration ?? '')
const showDuration = hasVideoTag (row.attributes.tags) && duration !== ''
const skipControl = showSkipToggle const skipControl = showSkipToggle
? ( ? (
<label className="flex items-center gap-2 text-sm"> <label className="flex items-center gap-2 text-sm">
@@ -78,20 +81,26 @@ const PostImportRowSummary: FC<Props> = (
</div> </div>
<PostImportThumbnailPreview <PostImportThumbnailPreview
url={String (row.attributes.thumbnailBase ?? '')} url={String (row.attributes.thumbnailBase ?? '')}
file={row.thumbnailFile}
className="h-16 w-16"/> className="h-16 w-16"/>
<div className="min-w-0 space-y-1"> <div className="min-w-0 space-y-1">
<div className="line-clamp-2 text-sm font-medium"> <div className="line-clamp-2 text-sm font-medium">
{String (row.attributes.title ?? '') || '(タイトル未取得)'} {String (row.attributes.title ?? '')}
</div> </div>
<div className="truncate text-xs text-neutral-600 dark:text-neutral-300"> <div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
{row.url} {row.url}
</div> </div>
{String (row.attributes.tags ?? '') && (
<div className="truncate text-xs text-neutral-500 dark:text-neutral-400"> <div className="truncate text-xs text-neutral-500 dark:text-neutral-400">
{String (row.attributes.tags ?? '') || 'タグなし'} {String (row.attributes.tags ?? '')}
</div> </div>)}
<div className="text-xs text-neutral-500 dark:text-neutral-400"> <div className="text-xs text-neutral-500 dark:text-neutral-400">
{summaryDate (row)} {summaryDate (row)}
</div> </div>
{showDuration && (
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{duration}
</div>)}
{warning && ( {warning && (
<div className="text-xs text-amber-700 dark:text-amber-200"> <div className="text-xs text-amber-700 dark:text-amber-200">
{warning} {warning}
@@ -132,10 +141,11 @@ const PostImportRowSummary: FC<Props> = (
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<PostImportThumbnailPreview <PostImportThumbnailPreview
url={String (row.attributes.thumbnailBase ?? '')} url={String (row.attributes.thumbnailBase ?? '')}
file={row.thumbnailFile}
className="h-20 w-20 shrink-0"/> className="h-20 w-20 shrink-0"/>
<div className="min-w-0 flex-1 space-y-2"> <div className="min-w-0 flex-1 space-y-2">
<div className="line-clamp-2 text-sm font-medium"> <div className="line-clamp-2 text-sm font-medium">
{String (row.attributes.title ?? '') || '(タイトル未取得)'} {String (row.attributes.title ?? '')}
</div> </div>
<div className="truncate text-xs text-neutral-600 dark:text-neutral-300"> <div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
{row.url} {row.url}
@@ -143,12 +153,17 @@ const PostImportRowSummary: FC<Props> = (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>} {displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
</div> </div>
{String (row.attributes.tags ?? '') && (
<div className="text-xs text-neutral-500 dark:text-neutral-400"> <div className="text-xs text-neutral-500 dark:text-neutral-400">
{String (row.attributes.tags ?? '') || 'タグなし'} {String (row.attributes.tags ?? '')}
</div> </div>)}
<div className="text-xs text-neutral-500 dark:text-neutral-400"> <div className="text-xs text-neutral-500 dark:text-neutral-400">
{summaryDate (row)} {summaryDate (row)}
</div> </div>
{showDuration && (
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{duration}
</div>)}
{warning && ( {warning && (
<div className="text-xs text-amber-700 dark:text-amber-200"> <div className="text-xs text-amber-700 dark:text-amber-200">
{warning} {warning}
@@ -158,10 +173,11 @@ const PostImportRowSummary: FC<Props> = (
</div> </div>
</div> </div>
{showActions && (editVisible || retryAllowed) && ( {showActions && (editVisible || retryAllowed) && (
<div className="flex flex-col gap-2 sm:flex-row"> <div className="flex flex-col gap-2 md:flex-row">
{editVisible && ( {editVisible && (
<Button <Button
type="button" type="button"
className="w-full md:w-auto"
variant="outline" variant="outline"
onClick={onEdit} onClick={onEdit}
disabled={editDisabled === true || !(editAllowed)}> disabled={editDisabled === true || !(editAllowed)}>
@@ -170,6 +186,7 @@ const PostImportRowSummary: FC<Props> = (
{retryAllowed && ( {retryAllowed && (
<Button <Button
type="button" type="button"
className="w-full md:w-auto"
variant="outline" variant="outline"
onClick={onRetry} onClick={onRetry}
disabled={retryDisabled === true}> disabled={retryDisabled === true}>
+8 -2
ファイルの表示
@@ -10,13 +10,19 @@ type Props = {
const LABELS: Record<PostImportBadgeValue, string> = { const LABELS: Record<PostImportBadgeValue, string> = {
ready: '登録可能', ready: '登録可能',
error: '登録不可',
warning: '警告', warning: '警告',
skipped: 'スキップ' } skipped: 'スキップ',
created: '登録済み',
failed: '登録失敗' }
const TONES: Record<PostImportBadgeValue, StatusBadgeTone> = { const TONES: Record<PostImportBadgeValue, StatusBadgeTone> = {
ready: 'success', ready: 'success',
error: 'warning',
warning: 'warning', warning: 'warning',
skipped: 'neutral' } skipped: 'neutral',
created: 'success',
failed: 'warning' }
const PostImportStatusBadge: FC<Props> = ({ value }) => ( const PostImportStatusBadge: FC<Props> = ({ value }) => (
+7 -57
ファイルの表示
@@ -1,75 +1,25 @@
import { useEffect, useRef, useState } from 'react'
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview' import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
import { apiGet } from '@/lib/api'
import type { FC } from 'react' import type { FC } from 'react'
type Props = { type Props = {
url: string url: string
file?: File
alt?: string alt?: string
className?: string } className?: string }
const PostImportThumbnailPreview: FC<Props> = ( const PostImportThumbnailPreview: FC<Props> = (
{ url, { url,
file,
alt = 'サムネール', alt = 'サムネール',
className = 'h-16 w-16' }, className = 'h-16 w-16' },
) => { ) => (
const [previewUrl, setPreviewUrl] = useState ('')
const previewUrlRef = useRef ('')
useEffect (() => {
if (previewUrlRef.current)
{
URL.revokeObjectURL (previewUrlRef.current)
previewUrlRef.current = ''
}
setPreviewUrl ('')
if (!(url))
return
const controller = new AbortController ()
const loadPreview = async () => {
try
{
const blob = await apiGet<Blob> ('/preview/image', {
params: { url },
signal: controller.signal,
responseType: 'blob' })
if (controller.signal.aborted)
return
const nextPreviewUrl = URL.createObjectURL (blob)
previewUrlRef.current = nextPreviewUrl
setPreviewUrl (nextPreviewUrl)
}
catch
{
if (!(controller.signal.aborted))
setPreviewUrl ('')
}
}
void loadPreview ()
return () => {
controller.abort ()
if (previewUrlRef.current)
{
URL.revokeObjectURL (previewUrlRef.current)
previewUrlRef.current = ''
}
}
}, [url])
return (
<PostThumbnailPreview <PostThumbnailPreview
url={previewUrl} url={url}
file={file}
alt={alt} alt={alt}
className={className}/>) className={className}
} referrerPolicy="no-referrer"/>)
export default PostImportThumbnailPreview export default PostImportThumbnailPreview
+19 -7
ファイルの表示
@@ -1,6 +1,12 @@
import type { PostImportRow } from '@/lib/postImportSession' import type { PostImportRow } from '@/lib/postImportSession'
export type PostImportDisplayStatus = 'ready' | 'skipped' | 'warning' export type PostImportDisplayStatus =
'ready'
| 'error'
| 'skipped'
| 'warning'
| 'created'
| 'failed'
export type PostImportBadgeValue = PostImportDisplayStatus export type PostImportBadgeValue = PostImportDisplayStatus
@@ -11,12 +17,18 @@ const hasWarnings = (row: PostImportRow): boolean =>
export const displayPostImportStatus = ( export const displayPostImportStatus = (
row: PostImportRow, row: PostImportRow,
): PostImportDisplayStatus | null => ): PostImportDisplayStatus | null =>
(row.skipReason != null || row.importStatus === 'skipped') row.status === 'pending'
? 'skipped'
: ((Object.keys (row.validationErrors ?? { }).length > 0
|| row.importStatus === 'failed'
|| row.importStatus === 'created')
? null ? null
: (row.importStatus === 'failed')
? 'failed'
: (row.skipReason != null || row.importStatus === 'skipped')
? 'skipped'
: (row.importStatus === 'created')
? 'created'
: (row.status === 'error')
? 'error'
: (Object.values (row.validationErrors ?? { }).some (messages => messages.length > 0))
? 'error'
: ((hasWarnings (row) || row.status === 'warning') : ((hasWarnings (row) || row.status === 'warning')
? 'warning' ? 'warning'
: 'ready')) : 'ready')
+2 -2
ファイルの表示
@@ -69,7 +69,7 @@ const DialogHeader = ({
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn( className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left", "flex flex-col space-y-1.5 text-center md:text-left",
className)} className)}
{...props} {...props}
/>) />)
@@ -81,7 +81,7 @@ const DialogFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn( className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", "flex flex-col-reverse md:flex-row md:justify-end md:space-x-2",
className)} className)}
{...props} {...props}
/>) />)
+81 -11
ファイルの表示
@@ -2,6 +2,15 @@ import type { PostImportEditableDraft,
PostImportResultRow, PostImportResultRow,
PostImportRow } from '@/lib/postImportTypes' PostImportRow } from '@/lib/postImportTypes'
const THUMBNAIL_MISSING_WARNING = 'サムネールなし'
export const hasThumbnailBaseValue = (value: unknown): boolean =>
typeof value === 'string' && value.trim () !== ''
export const hasVideoTag = (value: unknown): boolean =>
typeof value === 'string'
&& value.split (/\s+/).includes ('動画')
export const isExistingSkipRow = (row: PostImportRow): boolean => export const isExistingSkipRow = (row: PostImportRow): boolean =>
row.skipReason === 'existing' row.skipReason === 'existing'
@@ -13,8 +22,21 @@ export const isManualSkipRow = (row: PostImportRow): boolean =>
const hasSkipReason = (row: PostImportRow): boolean => const hasSkipReason = (row: PostImportRow): boolean =>
row.skipReason != null row.skipReason != null
export const compactMessageRecord = (
messages: Record<string, string[]>,
): Record<string, string[]> =>
Object.fromEntries (
Object.entries (messages).filter (([, values]) => values.length > 0))
export const hasErrorMessages = (
messages: Record<string, string[]>,
): boolean =>
Object.values (messages).some (values => values.length > 0)
const hasValidationErrors = (row: PostImportRow): boolean => const hasValidationErrors = (row: PostImportRow): boolean =>
Object.keys (row.validationErrors ?? { }).length > 0 hasErrorMessages (row.validationErrors ?? { })
const isRecoverableRow = (row: PostImportRow): boolean => const isRecoverableRow = (row: PostImportRow): boolean =>
row.recoverable === true row.recoverable === true
@@ -55,9 +77,34 @@ const buildResetSnapshot = (row: PostImportRow) => ({
baseWarnings: [...row.baseWarnings], baseWarnings: [...row.baseWarnings],
metadataUrl: row.metadataUrl }) metadataUrl: row.metadataUrl })
const deduped = (values: string[]): string[] =>
[...new Set (values)]
const thumbnailWarnings = (row: PostImportRow): string[] => {
const current = row.fieldWarnings.thumbnailBase ?? []
const others = current.filter (message => message !== THUMBNAIL_MISSING_WARNING)
return hasThumbnailBaseValue (row.attributes.thumbnailBase) || row.thumbnailFile != null
? others
: deduped ([...others, THUMBNAIL_MISSING_WARNING])
}
export const applyThumbnailWarning = (row: PostImportRow): PostImportRow => ({
...row,
fieldWarnings: compactMessageRecord ({
...row.fieldWarnings,
thumbnailBase: thumbnailWarnings (row) }) })
export const applyThumbnailWarnings = (rows: PostImportRow[]): PostImportRow[] =>
rows.map (row => applyThumbnailWarning (row))
export const processableImportRows = (rows: PostImportRow[]): PostImportRow[] => export const processableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
validatableImportRows (rows).filter (row => { validatableImportRows (rows).filter (row => {
if (row.status === 'pending')
return false
if (row.importStatus === 'created') if (row.importStatus === 'created')
return false return false
if (row.importStatus === 'skipped') if (row.importStatus === 'skipped')
@@ -143,6 +190,14 @@ export const resultRowWarnings = (row: PostImportRow): string[] =>
...Object.values (row.fieldWarnings ?? { }).flat (), ...Object.values (row.fieldWarnings ?? { }).flat (),
...row.baseWarnings])] ...row.baseWarnings])]
const isManualChange = (
current: unknown,
next: string,
): boolean =>
next !== String (current ?? '')
export const buildNextEditedRow = ( export const buildNextEditedRow = (
editingRow: PostImportRow, editingRow: PostImportRow,
draft: PostImportEditableDraft, draft: PostImportEditableDraft,
@@ -158,16 +213,22 @@ export const buildNextEditedRow = (
['thumbnailBase', draft.thumbnailBase], ['thumbnailBase', draft.thumbnailBase],
['originalCreatedFrom', draft.originalCreatedFrom], ['originalCreatedFrom', draft.originalCreatedFrom],
['originalCreatedBefore', draft.originalCreatedBefore], ['originalCreatedBefore', draft.originalCreatedBefore],
['duration', draft.duration],
['parentPostIds', draft.parentPostIds]] as const ['parentPostIds', draft.parentPostIds]] as const
draftFields.forEach (([field, value]) => { draftFields.forEach (([field, value]) => {
nextAttributes[field] = value nextAttributes[field] = value
nextProvenance[field] = nextProvenance[field] =
value !== String (editingRow.attributes[field] ?? '') isManualChange (editingRow.attributes[field], value)
? 'manual' ? 'manual'
: (editingRow.provenance[field] ?? 'automatic') : (editingRow.provenance[field] ?? 'automatic')
}) })
if (nextProvenance.duration === 'manual')
{
nextAttributes.videoMs = ''
nextProvenance.videoMs = 'manual'
}
nextAttributes.tags = draft.tags nextAttributes.tags = draft.tags
if (draft.tags !== String (editingRow.attributes.tags ?? '')) if (isManualChange (editingRow.attributes.tags, draft.tags))
{ {
nextProvenance.tags = 'manual' nextProvenance.tags = 'manual'
nextTagSources.manual = draft.tags nextTagSources.manual = draft.tags
@@ -182,6 +243,7 @@ export const buildNextEditedRow = (
...editingRow, ...editingRow,
url: draft.url, url: draft.url,
attributes: nextAttributes, attributes: nextAttributes,
thumbnailFile: draft.thumbnailFile,
provenance: { provenance: {
...nextProvenance, ...nextProvenance,
url: urlChanged ? 'manual' : (editingRow.provenance.url ?? 'manual') }, url: urlChanged ? 'manual' : (editingRow.provenance.url ?? 'manual') },
@@ -241,7 +303,7 @@ export const mergeValidatedImportRows = (
return previous return previous
const fieldWarnings = const fieldWarnings =
Object.keys (row.fieldWarnings).length > 0 || row.metadataUrl !== previous.metadataUrl hasErrorMessages (row.fieldWarnings) || row.metadataUrl !== previous.metadataUrl
? { ...row.fieldWarnings } ? { ...row.fieldWarnings }
: { ...previous.fieldWarnings } : { ...previous.fieldWarnings }
for (const [field, origin] of Object.entries (row.provenance)) for (const [field, origin] of Object.entries (row.provenance))
@@ -258,6 +320,7 @@ export const mergeValidatedImportRows = (
tagSources: row.tagSources, tagSources: row.tagSources,
skipReason: row.skipReason, skipReason: row.skipReason,
existingPostId: row.existingPostId, existingPostId: row.existingPostId,
existingPost: row.existingPost,
fieldWarnings, fieldWarnings,
baseWarnings: baseWarnings:
row.baseWarnings.length > 0 || row.metadataUrl !== previous.metadataUrl row.baseWarnings.length > 0 || row.metadataUrl !== previous.metadataUrl
@@ -294,9 +357,11 @@ export const mergeImportResults = (
skipReason: undefined, skipReason: undefined,
createdPostId: result.post.id, createdPostId: result.post.id,
existingPostId: undefined, existingPostId: undefined,
fieldWarnings: result.fieldWarnings ?? row.fieldWarnings, existingPost: undefined,
fieldWarnings: compactMessageRecord (
result.fieldWarnings ?? row.fieldWarnings),
baseWarnings: result.baseWarnings ?? row.baseWarnings, baseWarnings: result.baseWarnings ?? row.baseWarnings,
importErrors: result.errors } importErrors: compactMessageRecord (result.errors ?? { }) }
case 'skipped': case 'skipped':
return { return {
...row, ...row,
@@ -305,9 +370,11 @@ export const mergeImportResults = (
skipReason: 'existing', skipReason: 'existing',
createdPostId: undefined, createdPostId: undefined,
existingPostId: result.existingPostId, existingPostId: result.existingPostId,
fieldWarnings: result.fieldWarnings ?? row.fieldWarnings, existingPost: result.existingPost ?? row.existingPost,
fieldWarnings: compactMessageRecord (
result.fieldWarnings ?? row.fieldWarnings),
baseWarnings: result.baseWarnings ?? row.baseWarnings, baseWarnings: result.baseWarnings ?? row.baseWarnings,
importErrors: result.errors } importErrors: compactMessageRecord (result.errors ?? { }) }
case 'failed': case 'failed':
return { return {
...row, ...row,
@@ -316,9 +383,11 @@ export const mergeImportResults = (
skipReason: undefined, skipReason: undefined,
createdPostId: undefined, createdPostId: undefined,
existingPostId: undefined, existingPostId: undefined,
fieldWarnings: result.fieldWarnings ?? row.fieldWarnings, existingPost: undefined,
fieldWarnings: compactMessageRecord (
result.fieldWarnings ?? row.fieldWarnings),
baseWarnings: result.baseWarnings ?? row.baseWarnings, baseWarnings: result.baseWarnings ?? row.baseWarnings,
importErrors: result.errors } importErrors: compactMessageRecord (result.errors ?? { }) }
} }
}) })
} }
@@ -336,6 +405,7 @@ export const retryImportRow = (
: row) : row)
export const initialisePreviewRows = (rows: PostImportRow[]): PostImportRow[] => export const initialisePreviewRows = (rows: PostImportRow[]): PostImportRow[] =>
applyThumbnailWarnings (
rows.map (row => ({ rows.map (row => ({
...row, ...row,
resetSnapshot: buildResetSnapshot (row) })) resetSnapshot: buildResetSnapshot (row) })))
+285 -51
ファイルの表示
@@ -1,4 +1,5 @@
import type { PostImportOrigin, import type { PostImportOrigin,
PostImportExistingPost,
PostImportResetSnapshot, PostImportResetSnapshot,
PostImportRow, PostImportRow,
PostImportSession, PostImportSession,
@@ -6,11 +7,15 @@ import type { PostImportOrigin,
PostImportSkipReason, PostImportSkipReason,
StorageErrorHandler } from '@/lib/postImportTypes' StorageErrorHandler } from '@/lib/postImportTypes'
const SESSION_VERSION = 2 const SESSION_VERSION = 3
const SESSION_PREFIX = 'post-import-session:' const SESSION_PREFIX = 'post-import-session:'
const CURRENT_SESSION_KEY = `${ SESSION_PREFIX }current`
const SOURCE_DRAFT_KEY = 'post-import-source-draft' const SOURCE_DRAFT_KEY = 'post-import-source-draft'
const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000 const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000
const MAX_SESSION_JSON_BYTES = 262_144
const MAX_SESSION_ROWS = 100
const MAX_SOURCE_BYTES = 262_144
const SESSION_ID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
const ATTRIBUTE_KEYS = [ const ATTRIBUTE_KEYS = [
'title', 'title',
'thumbnailBase', 'thumbnailBase',
@@ -23,13 +28,78 @@ const ATTRIBUTE_KEYS = [
const PROVENANCE_KEYS = [...ATTRIBUTE_KEYS, 'url'] as const const PROVENANCE_KEYS = [...ATTRIBUTE_KEYS, 'url'] as const
const TAG_SOURCE_KEYS = ['automatic', 'manual'] as const const TAG_SOURCE_KEYS = ['automatic', 'manual'] as const
const WARNING_KEYS = [...ATTRIBUTE_KEYS, 'url'] as const const WARNING_KEYS = [...ATTRIBUTE_KEYS, 'url'] as const
const ROW_KEYS = [
'sourceRow',
'url',
'attributes',
'fieldWarnings',
'baseWarnings',
'validationErrors',
'importErrors',
'provenance',
'tagSources',
'status',
'skipReason',
'existingPostId',
'existingPost',
'metadataUrl',
'resetSnapshot',
'createdPostId',
'importStatus',
'recoverable'] as const
const SESSION_KEYS = [
'version',
'expiresAt',
'source',
'rows',
'repairMode'] as const
const textEncoder = new TextEncoder ()
const isPlainObject = (value: unknown): value is Record<string, unknown> => const isPlainObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value != null && !(Array.isArray (value)) typeof value === 'object' && value != null && !(Array.isArray (value))
const byteLength = (value: string): number =>
textEncoder.encode (value).byteLength
const sessionKey = (): string => CURRENT_SESSION_KEY
export const validatePostImportSessionId = (value: unknown): string | null => {
if (typeof value !== 'string')
return null
if (value.length > 36 || !(SESSION_ID_PATTERN.test (value)))
return null
return value.toLowerCase ()
}
const generateUuidV4FromRandomValues = (): string => {
const bytes = new Uint8Array (16)
crypto.getRandomValues (bytes)
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
const hex = Array.from (
bytes,
byte => byte.toString (16).padStart (2, '0'))
return [
hex.slice (0, 4).join (''),
hex.slice (4, 6).join (''),
hex.slice (6, 8).join (''),
hex.slice (8, 10).join (''),
hex.slice (10, 16).join ('')].join ('-')
}
export const generatePostImportSessionId = (): string => {
if (typeof crypto.randomUUID === 'function')
return crypto.randomUUID ()
return generateUuidV4FromRandomValues ()
}
const sessionKey = (sessionId: string): string => `${ SESSION_PREFIX }${ sessionId }`
const readStorage = ( const readStorage = (
@@ -108,7 +178,10 @@ const ensureStringListRecord = (value: unknown): Record<string, string[]> | null
const isValidStatus = ( const isValidStatus = (
value: unknown, value: unknown,
): value is PostImportRow['status'] => ): value is PostImportRow['status'] =>
value === 'ready' || value === 'warning' || value === 'error' value === 'pending'
|| value === 'ready'
|| value === 'warning'
|| value === 'error'
const isValidImportStatus = ( const isValidImportStatus = (
@@ -143,6 +216,14 @@ const hasOnlyKeys = (
const sanitiseResetSnapshot = (value: unknown): PostImportResetSnapshot | null => { const sanitiseResetSnapshot = (value: unknown): PostImportResetSnapshot | null => {
if (!(isPlainObject (value))) if (!(isPlainObject (value)))
return null return null
if (!(hasOnlyKeys (value, ['url',
'attributes',
'provenance',
'tagSources',
'fieldWarnings',
'baseWarnings',
'metadataUrl'])))
return null
if (typeof value.url !== 'string') if (typeof value.url !== 'string')
return null return null
if (!(isPlainObject (value.attributes))) if (!(isPlainObject (value.attributes)))
@@ -185,14 +266,135 @@ const sanitiseResetSnapshot = (value: unknown): PostImportResetSnapshot | null =
metadataUrl: value.metadataUrl as string | undefined } metadataUrl: value.metadataUrl as string | undefined }
} }
const sanitiseExistingPost = (value: unknown): PostImportExistingPost | undefined => {
if (value == null)
return undefined
if (!(isPlainObject (value)))
return undefined
if (!(isPositiveInteger (value.id)))
return undefined
if (typeof value.title !== 'string' || typeof value.url !== 'string')
return undefined
if (value.thumbnailUrl != null && typeof value.thumbnailUrl !== 'string')
return undefined
return {
id: Number (value.id),
title: value.title,
url: value.url,
thumbnailUrl: value.thumbnailUrl as string | undefined }
}
const recalculateThumbnailWarning = (row: PostImportRow): PostImportRow => {
const others = (row.fieldWarnings.thumbnailBase ?? []).filter (
key => key !== 'サムネールなし')
const thumbnailBasePresent =
typeof row.attributes.thumbnailBase === 'string'
&& row.attributes.thumbnailBase.trim () !== ''
return {
...row,
fieldWarnings: {
...row.fieldWarnings,
thumbnailBase:
thumbnailBasePresent
? others
: [...new Set ([...others, 'サムネールなし'])] } }
}
const serialiseExistingPost = (value: PostImportExistingPost | undefined) =>
value == null
? undefined
: {
id: value.id,
title: value.title,
url: value.url,
thumbnailUrl: value.thumbnailUrl }
const serialiseResetSnapshot = (value: PostImportResetSnapshot) => ({
url: value.url,
attributes: Object.fromEntries (
Object.entries (value.attributes).map (([key, entry]) => [key, entry])),
provenance: Object.fromEntries (
Object.entries (value.provenance).map (([key, entry]) => [key, entry])),
tagSources: {
automatic: value.tagSources.automatic,
manual: value.tagSources.manual },
fieldWarnings: Object.fromEntries (
Object.entries (value.fieldWarnings).map (([key, entry]) => [key, [...entry]])),
baseWarnings: [...value.baseWarnings],
metadataUrl: value.metadataUrl })
export const serialisePostImportRow = (row: PostImportRow) => {
const thumbnailBaseWarnings = (() => {
const others = (row.fieldWarnings.thumbnailBase ?? []).filter (
message => message !== 'サムネールなし')
const thumbnailBasePresent =
typeof row.attributes.thumbnailBase === 'string'
&& row.attributes.thumbnailBase.trim () !== ''
return thumbnailBasePresent
? others
: [...new Set ([...others, 'サムネールなし'])]
}) ()
return {
sourceRow: row.sourceRow,
url: row.url,
attributes: Object.fromEntries (
Object.entries (row.attributes).map (([key, entry]) => [key, entry])),
fieldWarnings: Object.fromEntries (
Object.entries ({
...row.fieldWarnings,
thumbnailBase: thumbnailBaseWarnings }).map (([key, entry]) => [key, [...entry]])),
baseWarnings: [...row.baseWarnings],
validationErrors: Object.fromEntries (
Object.entries (row.validationErrors).map (([key, entry]) => [key, [...entry]])),
importErrors:
row.importErrors == null
? undefined
: Object.fromEntries (
Object.entries (row.importErrors).map (([key, entry]) => [key, [...entry]])),
provenance: Object.fromEntries (
Object.entries (row.provenance).map (([key, entry]) => [key, entry])),
tagSources:
row.tagSources == null
? undefined
: {
automatic: row.tagSources.automatic,
manual: row.tagSources.manual },
status: row.status,
skipReason: row.skipReason,
existingPostId: row.existingPostId,
existingPost: serialiseExistingPost (row.existingPost),
metadataUrl: row.metadataUrl,
resetSnapshot: serialiseResetSnapshot (row.resetSnapshot),
createdPostId: row.createdPostId,
importStatus: row.importStatus,
recoverable: row.recoverable }
}
export const serialisePostImportRows = (rows: PostImportRow[]) =>
rows.map (row => serialisePostImportRow (row))
export const serialisedPostImportRowsEqual = (
left: PostImportRow[],
right: PostImportRow[],
): boolean =>
JSON.stringify (serialisePostImportRows (left))
=== JSON.stringify (serialisePostImportRows (right))
const sanitiseRow = (value: unknown): PostImportRow | null => { const sanitiseRow = (value: unknown): PostImportRow | null => {
if (!(isPlainObject (value))) if (!(isPlainObject (value)))
return null return null
if (!(hasOnlyKeys (value, ROW_KEYS)))
return null
if (!(Number.isInteger (value.sourceRow)) || Number (value.sourceRow) <= 0) if (!(Number.isInteger (value.sourceRow)) || Number (value.sourceRow) <= 0)
return null return null
if (typeof value.url !== 'string') if (typeof value.url !== 'string')
return null return null
if (byteLength (value.url) > 20_480)
return null
if (!(isPlainObject (value.attributes))) if (!(isPlainObject (value.attributes)))
return null return null
if (!(isPlainObject (value.provenance))) if (!(isPlainObject (value.provenance)))
@@ -274,6 +476,7 @@ const sanitiseRow = (value: unknown): PostImportRow | null => {
skipReason: value.skipReason ?? undefined, skipReason: value.skipReason ?? undefined,
existingPostId: existingPostId:
isPositiveInteger (value.existingPostId) ? Number (value.existingPostId) : undefined, isPositiveInteger (value.existingPostId) ? Number (value.existingPostId) : undefined,
existingPost: sanitiseExistingPost (value.existingPost),
metadataUrl: typeof value.metadataUrl === 'string' ? value.metadataUrl : undefined, metadataUrl: typeof value.metadataUrl === 'string' ? value.metadataUrl : undefined,
resetSnapshot, resetSnapshot,
createdPostId: createdPostId:
@@ -282,10 +485,38 @@ const sanitiseRow = (value: unknown): PostImportRow | null => {
recoverable: value.recoverable === true ? true : undefined } recoverable: value.recoverable === true ? true : undefined }
} }
const sanitiseSession = (value: unknown): PostImportSession | null => {
if (!(isPlainObject (value)))
return null
if (!(hasOnlyKeys (value, SESSION_KEYS)))
return null
if (value.version !== SESSION_VERSION || !(Array.isArray (value.rows)))
return null
if (value.rows.length === 0 || value.rows.length > MAX_SESSION_ROWS)
return null
if (typeof value.expiresAt !== 'string' || isExpiredSession (value.expiresAt))
return null
if (typeof value.source !== 'string' || byteLength (value.source) > MAX_SOURCE_BYTES)
return null
const isExpiredSession = (savedAt: string): boolean => { const rows = value.rows.map (sanitiseRow)
const value = Date.parse (savedAt) if (rows.some (row => row == null))
return Number.isNaN (value) || Date.now () - value > SESSION_MAX_AGE_MS return null
if ((new Set (rows.map (row => row?.sourceRow))).size !== rows.length)
return null
return {
version: SESSION_VERSION,
expiresAt: value.expiresAt,
source: value.source,
rows: (rows as PostImportRow[]).map (row => recalculateThumbnailWarning (row)),
repairMode: value.repairMode === 'failed' ? 'failed' : 'all' }
}
const isExpiredSession = (expiresAt: string): boolean => {
const value = Date.parse (expiresAt)
return Number.isNaN (value) || value <= Date.now ()
} }
@@ -303,20 +534,27 @@ export const cleanupExpiredPostImportSessions = (
if (key == null || !(key.startsWith (SESSION_PREFIX))) if (key == null || !(key.startsWith (SESSION_PREFIX)))
continue continue
const raw = sessionStorage.getItem (key) const sessionId = validatePostImportSessionId (key.slice (SESSION_PREFIX.length))
if (raw == null) if (sessionId == null)
continue
if (key !== CURRENT_SESSION_KEY)
{ {
sessionStorage.removeItem (key) sessionStorage.removeItem (key)
--i --i
continue continue
} }
const raw = sessionStorage.getItem (key)
if (raw == null)
continue
if (byteLength (raw) > MAX_SESSION_JSON_BYTES)
{
sessionStorage.removeItem (key)
--i
continue
}
try try
{ {
const value = JSON.parse (raw) as { savedAt?: string } if (sanitiseSession (JSON.parse (raw)) == null)
if (typeof value.savedAt !== 'string' || isExpiredSession (value.savedAt))
{ {
sessionStorage.removeItem (key) sessionStorage.removeItem (key)
--i --i
@@ -370,72 +608,68 @@ export const clearPostImportSourceDraft = (
export const savePostImportSession = ( export const savePostImportSession = (
sessionOrLegacyId: string | Omit<PostImportSession, 'version' | 'savedAt'>, sessionId: string,
sessionOrOnError?: Omit<PostImportSession, 'version' | 'savedAt'> | StorageErrorHandler, session: Omit<PostImportSession, 'version' | 'expiresAt'>,
onError?: StorageErrorHandler, onError?: StorageErrorHandler,
): boolean => { ): boolean => {
const session = const validatedId = validatePostImportSessionId (sessionId)
typeof sessionOrLegacyId === 'string' if (validatedId == null)
? sessionOrOnError as Omit<PostImportSession, 'version' | 'savedAt'> return false
: sessionOrLegacyId
const errorHandler =
typeof sessionOrLegacyId === 'string'
? onError
: sessionOrOnError as StorageErrorHandler | undefined
return writeStorage ( return writeStorage (
sessionKey (), sessionKey (validatedId),
JSON.stringify ({ JSON.stringify ({
...session, source: session.source,
rows: serialisePostImportRows (session.rows),
repairMode: session.repairMode,
version: SESSION_VERSION, version: SESSION_VERSION,
savedAt: new Date ().toISOString () }), expiresAt: new Date (Date.now () + SESSION_MAX_AGE_MS).toISOString () }),
errorHandler) onError)
} }
export const loadPostImportSession = ( export const loadPostImportSession = (
legacyIdOrOnError?: string | StorageErrorHandler, sessionId: string,
onError?: StorageErrorHandler, onError?: StorageErrorHandler,
): PostImportSession | null => { ): PostImportSession | null => {
const errorHandler = const validatedId = validatePostImportSessionId (sessionId)
typeof legacyIdOrOnError === 'string' if (validatedId == null)
? onError return null
: legacyIdOrOnError
const raw = readStorage (sessionKey (), errorHandler) const raw = readStorage (sessionKey (validatedId), onError)
if (raw == null) if (raw == null)
return null return null
if (byteLength (raw) > MAX_SESSION_JSON_BYTES)
{
removeStorage (sessionKey (validatedId), onError)
return null
}
try try
{ {
const value = JSON.parse (raw) as Partial<PostImportSession> const session = sanitiseSession (JSON.parse (raw))
if (value.version !== SESSION_VERSION || !(Array.isArray (value.rows))) if (session == null)
return null
if (typeof value.savedAt !== 'string' || isExpiredSession (value.savedAt))
{ {
removeStorage (sessionKey (), errorHandler) removeStorage (sessionKey (validatedId), onError)
return null return null
} }
return session
const rows = value.rows.map (sanitiseRow)
if (rows.some (row => row == null))
return null
return {
version: SESSION_VERSION,
savedAt: value.savedAt,
source: typeof value.source === 'string' ? value.source : '',
rows: rows as PostImportRow[],
repairMode: value.repairMode === 'failed' ? 'failed' : 'all' }
} }
catch catch
{ {
removeStorage (sessionKey (validatedId), onError)
return null return null
} }
} }
export const clearPostImportSession = ( export const clearPostImportSession = (
sessionId: string,
onError?: StorageErrorHandler, onError?: StorageErrorHandler,
) => { ) => {
removeStorage (sessionKey (), onError) const validatedId = validatePostImportSessionId (sessionId)
if (validatedId == null)
return
removeStorage (sessionKey (validatedId), onError)
} }
+15 -4
ファイルの表示
@@ -18,6 +18,12 @@ export type PostImportResetSnapshot = {
baseWarnings: string[] baseWarnings: string[]
metadataUrl?: string } metadataUrl?: string }
export type PostImportExistingPost = {
id: number
title: string
url: string
thumbnailUrl?: string }
export type PostImportRow = { export type PostImportRow = {
sourceRow: number sourceRow: number
url: string url: string
@@ -28,14 +34,16 @@ export type PostImportRow = {
importErrors?: Record<string, string[]> importErrors?: Record<string, string[]>
provenance: Record<string, PostImportOrigin> provenance: Record<string, PostImportOrigin>
tagSources?: Record<PostImportOrigin, string> tagSources?: Record<PostImportOrigin, string>
status: 'ready' | 'warning' | 'error' status: 'pending' | 'ready' | 'warning' | 'error'
skipReason?: PostImportSkipReason skipReason?: PostImportSkipReason
existingPostId?: number existingPostId?: number
existingPost?: PostImportExistingPost
metadataUrl?: string metadataUrl?: string
resetSnapshot: PostImportResetSnapshot resetSnapshot: PostImportResetSnapshot
createdPostId?: number createdPostId?: number
importStatus?: PostImportStatus importStatus?: PostImportStatus
recoverable?: boolean } recoverable?: boolean
thumbnailFile?: File }
export type PostImportResultRow = export type PostImportResultRow =
| { | {
@@ -49,6 +57,7 @@ export type PostImportResultRow =
sourceRow: number sourceRow: number
status: 'skipped' status: 'skipped'
existingPostId: number existingPostId: number
existingPost?: PostImportExistingPost
fieldWarnings?: Record<string, string[]> fieldWarnings?: Record<string, string[]>
baseWarnings?: string[] baseWarnings?: string[]
errors?: Record<string, string[]> } errors?: Record<string, string[]> }
@@ -62,7 +71,7 @@ export type PostImportResultRow =
export type PostImportSession = { export type PostImportSession = {
version: number version: number
savedAt: string expiresAt: string
source: string source: string
rows: PostImportRow[] rows: PostImportRow[]
repairMode: PostImportRepairMode } repairMode: PostImportRepairMode }
@@ -78,7 +87,9 @@ export type PostImportEditableDraft = {
thumbnailBase: string thumbnailBase: string
originalCreatedFrom: string originalCreatedFrom: string
originalCreatedBefore: string originalCreatedBefore: string
duration: string
tags: string tags: string
parentPostIds: string } parentPostIds: string
thumbnailFile?: File }
export type StorageErrorHandler = (message: string) => void export type StorageErrorHandler = (message: string) => void
+148 -23
ファイルの表示
@@ -1,4 +1,5 @@
import { resultRepairMode } from '@/lib/postImportRows' import { resultRepairMode } from '@/lib/postImportRows'
import { validatePostImportSessionId } from '@/lib/postImportStorage'
import type { import type {
PostImportOrigin, PostImportOrigin,
@@ -8,10 +9,12 @@ import type {
} from '@/lib/postImportTypes' } from '@/lib/postImportTypes'
type QueryRowState = { type QueryRowState = {
source_row: number
title: string title: string
thumbnail_base: string thumbnail_base: string
original_created_from: string original_created_from: string
original_created_before: string original_created_before: string
video_ms: string
duration: string duration: string
tags: string tags: string
parent_post_ids: string parent_post_ids: string
@@ -32,11 +35,13 @@ type EncodedSkip = 'e' | 'm'
type EncodedImportStatus = 'p' | 'c' | 's' | 'f' type EncodedImportStatus = 'p' | 'c' | 's' | 'f'
type EncodedRowState = { type EncodedRowState = {
n?: number
u?: string u?: string
t?: string t?: string
h?: string h?: string
f?: string f?: string
b?: string b?: string
x?: string
d?: string d?: string
g?: string g?: string
p?: string p?: string
@@ -59,7 +64,7 @@ export type ParsedPostNewState = {
export type SerialisedPostNewState = { export type SerialisedPostNewState = {
path: string path: string
rows: PostImportRow[] } complete: boolean }
const BASIC_KEYS = [ const BASIC_KEYS = [
'url', 'url',
@@ -67,6 +72,7 @@ const BASIC_KEYS = [
'thumbnail_base', 'thumbnail_base',
'original_created_from', 'original_created_from',
'original_created_before', 'original_created_before',
'video_ms',
'duration', 'duration',
'tags', 'tags',
'parent_post_ids'] as const 'parent_post_ids'] as const
@@ -77,14 +83,56 @@ const STATE_KEYS = [
'import_status', 'import_status',
'created_post_id', 'created_post_id',
'recoverable'] as const 'recoverable'] as const
const SINGLE_ROW_KEYS = [...BASIC_KEYS, ...STATE_KEYS] as const const SINGLE_ROW_KEYS = [...BASIC_KEYS, ...STATE_KEYS, 'session_id'] as const
const ENCODED_ROW_KEYS = [
'n',
'u',
't',
'h',
'f',
'b',
'x',
'd',
'g',
'p',
'm',
's',
'e',
'i',
'c',
'r'] as const
const ALLOWED_MANUAL_FIELDS = [
'title',
'thumbnailBase',
'originalCreatedFrom',
'originalCreatedBefore',
'duration',
'tags',
'parentPostIds'] as const
const MAX_REGULAR_URL_LENGTH = 768 const MAX_REGULAR_URL_LENGTH = 768
const MAX_COMPRESSED_STATE_LENGTH = 767 const MAX_COMPRESSED_STATE_LENGTH = 767
const COMPRESSED_STATE_PREFIX = 'z1.' const COMPRESSED_STATE_PREFIX = 'z1.'
const MAX_COMPRESSED_STATE_PARAM_LENGTH = 8_192
const MAX_COMPRESSED_STATE_BYTES = 16_384
const MAX_DECOMPRESSED_STATE_BYTES = 262_144
const MAX_STATE_ROWS = 100
const MAX_QUERY_STRING_BYTES = 20_480
const textEncoder = new TextEncoder () const textEncoder = new TextEncoder ()
const textDecoder = new TextDecoder () const textDecoder = new TextDecoder ()
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value != null && !(Array.isArray (value))
const hasOnlyKeys = (
value: Record<string, unknown>,
allowedKeys: readonly string[],
): boolean =>
Object.keys (value).every (key => allowedKeys.includes (key))
const byteLength = (value: string): number =>
textEncoder.encode (value).byteLength
const isValidSkipReason = (value: unknown): value is PostImportSkipReason => const isValidSkipReason = (value: unknown): value is PostImportSkipReason =>
value === 'existing' || value === 'manual' value === 'existing' || value === 'manual'
@@ -196,18 +244,25 @@ const compressBytes = async (value: Uint8Array): Promise<Uint8Array> => {
const decompressBytes = async (value: Uint8Array): Promise<Uint8Array> => { const decompressBytes = async (value: Uint8Array): Promise<Uint8Array> => {
const stream = const stream =
new Blob ([value]).stream ().pipeThrough (new DecompressionStream ('gzip')) new Blob ([value]).stream ().pipeThrough (new DecompressionStream ('gzip'))
return new Uint8Array (await new Response (stream).arrayBuffer ()) const bytes = new Uint8Array (await new Response (stream).arrayBuffer ())
if (bytes.byteLength > MAX_DECOMPRESSED_STATE_BYTES)
throw new Error ('decompressed state too large')
return bytes
} }
const decodeCompressedState = async (value: string): Promise<string | null> => { const decodeCompressedState = async (value: string): Promise<string | null> => {
if (!(value.startsWith (COMPRESSED_STATE_PREFIX))) if (!(value.startsWith (COMPRESSED_STATE_PREFIX)))
return null return null
if (value.length > MAX_COMPRESSED_STATE_PARAM_LENGTH)
return null
const encoded = value.slice (COMPRESSED_STATE_PREFIX.length) const encoded = value.slice (COMPRESSED_STATE_PREFIX.length)
const compressed = decodeBase64UrlBytes (encoded) const compressed = decodeBase64UrlBytes (encoded)
if (compressed == null) if (compressed == null)
return null return null
if (compressed.byteLength > MAX_COMPRESSED_STATE_BYTES)
return null
try try
{ {
@@ -231,16 +286,19 @@ const manualFields = (row: PostImportRow): string[] =>
'thumbnailBase', 'thumbnailBase',
'originalCreatedFrom', 'originalCreatedFrom',
'originalCreatedBefore', 'originalCreatedBefore',
'duration',
'tags', 'tags',
'parentPostIds'] 'parentPostIds']
.filter (field => row.provenance[field] === 'manual') .filter (field => row.provenance[field] === 'manual')
const baseRowState = (row: PostImportRow): QueryRowState => ({ const baseRowState = (row: PostImportRow): QueryRowState => ({
source_row: row.sourceRow,
title: String (row.attributes.title ?? ''), title: String (row.attributes.title ?? ''),
thumbnail_base: String (row.attributes.thumbnailBase ?? ''), thumbnail_base: String (row.attributes.thumbnailBase ?? ''),
original_created_from: String (row.attributes.originalCreatedFrom ?? ''), original_created_from: String (row.attributes.originalCreatedFrom ?? ''),
original_created_before: String (row.attributes.originalCreatedBefore ?? ''), original_created_before: String (row.attributes.originalCreatedBefore ?? ''),
video_ms: String (row.attributes.videoMs ?? ''),
duration: String (row.attributes.duration ?? ''), duration: String (row.attributes.duration ?? ''),
tags: String (row.attributes.tags ?? ''), tags: String (row.attributes.tags ?? ''),
parent_post_ids: String (row.attributes.parentPostIds ?? ''), parent_post_ids: String (row.attributes.parentPostIds ?? ''),
@@ -252,10 +310,7 @@ const baseRowState = (row: PostImportRow): QueryRowState => ({
recoverable: row.recoverable ?? null }) recoverable: row.recoverable ?? null })
const buildRow = ( const buildRow = (state: FullRowState): PostImportRow => {
sourceRow: number,
state: FullRowState,
): PostImportRow => {
const manual = new Set (state.manual) const manual = new Set (state.manual)
const provenance: Record<string, PostImportOrigin> = { const provenance: Record<string, PostImportOrigin> = {
url: 'manual', url: 'manual',
@@ -263,7 +318,8 @@ const buildRow = (
thumbnailBase: manual.has ('thumbnailBase') ? 'manual' : 'automatic', thumbnailBase: manual.has ('thumbnailBase') ? 'manual' : 'automatic',
originalCreatedFrom: manual.has ('originalCreatedFrom') ? 'manual' : 'automatic', originalCreatedFrom: manual.has ('originalCreatedFrom') ? 'manual' : 'automatic',
originalCreatedBefore: manual.has ('originalCreatedBefore') ? 'manual' : 'automatic', originalCreatedBefore: manual.has ('originalCreatedBefore') ? 'manual' : 'automatic',
duration: 'automatic', videoMs: manual.has ('duration') ? 'manual' : 'automatic',
duration: manual.has ('duration') ? 'manual' : 'automatic',
tags: manual.has ('tags') ? 'manual' : 'automatic', tags: manual.has ('tags') ? 'manual' : 'automatic',
parentPostIds: manual.has ('parentPostIds') ? 'manual' : 'automatic' } parentPostIds: manual.has ('parentPostIds') ? 'manual' : 'automatic' }
const tagSources = provenance.tags === 'manual' const tagSources = provenance.tags === 'manual'
@@ -274,12 +330,13 @@ const buildRow = (
thumbnailBase: state.thumbnail_base, thumbnailBase: state.thumbnail_base,
originalCreatedFrom: state.original_created_from, originalCreatedFrom: state.original_created_from,
originalCreatedBefore: state.original_created_before, originalCreatedBefore: state.original_created_before,
videoMs: manual.has ('duration') ? '' : state.video_ms,
duration: state.duration, duration: state.duration,
tags: state.tags, tags: state.tags,
parentPostIds: state.parent_post_ids } parentPostIds: state.parent_post_ids }
return { return {
sourceRow, sourceRow: state.source_row,
url: state.url, url: state.url,
attributes, attributes,
fieldWarnings: { }, fieldWarnings: { },
@@ -287,7 +344,7 @@ const buildRow = (
validationErrors: { }, validationErrors: { },
provenance, provenance,
tagSources, tagSources,
status: 'ready', status: 'pending',
skipReason: state.skip ?? undefined, skipReason: state.skip ?? undefined,
existingPostId: state.existing_post_id ?? undefined, existingPostId: state.existing_post_id ?? undefined,
resetSnapshot: { resetSnapshot: {
@@ -310,6 +367,10 @@ const parseManual = (value: string | null): string[] =>
.filter (entry => entry !== '') .filter (entry => entry !== '')
const hasOnlyAllowedManualFields = (value: string[]): boolean =>
value.every (field => ALLOWED_MANUAL_FIELDS.includes (field as never))
const isStringArray = (value: unknown): value is string[] => const isStringArray = (value: unknown): value is string[] =>
Array.isArray (value) && value.every (entry => typeof entry === 'string') Array.isArray (value) && value.every (entry => typeof entry === 'string')
@@ -318,10 +379,12 @@ const parseEncodedRow = (
value: unknown, value: unknown,
requireUrl: boolean, requireUrl: boolean,
): FullRowState | null => { ): FullRowState | null => {
if (typeof value !== 'object' || value == null || Array.isArray (value)) if (!(isPlainObject (value)))
return null return null
const row = value as Record<string, unknown> const row = value as Record<string, unknown>
if (!(hasOnlyKeys (row, ENCODED_ROW_KEYS)))
return null
const url = row['u'] const url = row['u']
const manual = row['m'] const manual = row['m']
const skip = decodeSkip (row['s']) const skip = decodeSkip (row['s'])
@@ -329,11 +392,16 @@ const parseEncodedRow = (
const existingPostId = row['e'] const existingPostId = row['e']
const createdPostId = row['c'] const createdPostId = row['c']
const recoverable = row['r'] const recoverable = row['r']
const sourceRow = row['n']
if (requireUrl && typeof url !== 'string') if (requireUrl && typeof url !== 'string')
return null return null
if (typeof url === 'string' && byteLength (url) > MAX_QUERY_STRING_BYTES)
return null
if (manual != null && !(isStringArray (manual))) if (manual != null && !(isStringArray (manual)))
return null return null
if ((manual ?? []).some (field => !(ALLOWED_MANUAL_FIELDS.includes (field as never))))
return null
if (row['s'] != null && skip == null) if (row['s'] != null && skip == null)
return null return null
if (row['i'] != null && importStatus == null) if (row['i'] != null && importStatus == null)
@@ -350,12 +418,18 @@ const parseEncodedRow = (
return null return null
if (recoverable != null && typeof recoverable !== 'boolean') if (recoverable != null && typeof recoverable !== 'boolean')
return null return null
if (sourceRow != null
&& !(typeof sourceRow === 'number'
&& Number.isInteger (sourceRow)
&& sourceRow > 0))
return null
const stringFields = [ const stringFields = [
['t', 'title'], ['t', 'title'],
['h', 'thumbnail_base'], ['h', 'thumbnail_base'],
['f', 'original_created_from'], ['f', 'original_created_from'],
['b', 'original_created_before'], ['b', 'original_created_before'],
['x', 'video_ms'],
['d', 'duration'], ['d', 'duration'],
['g', 'tags'], ['g', 'tags'],
['p', 'parent_post_ids']] as const ['p', 'parent_post_ids']] as const
@@ -367,6 +441,8 @@ const parseEncodedRow = (
const fieldValue = row[key] const fieldValue = row[key]
if (fieldValue != null && typeof fieldValue !== 'string') if (fieldValue != null && typeof fieldValue !== 'string')
throw new Error ('invalid row') throw new Error ('invalid row')
if (typeof fieldValue === 'string' && byteLength (fieldValue) > MAX_QUERY_STRING_BYTES)
throw new Error ('invalid row')
return [target, fieldValue ?? ''] return [target, fieldValue ?? '']
}), }),
) as Record<(typeof stringFields)[number][1], string> ) as Record<(typeof stringFields)[number][1], string>
@@ -377,11 +453,16 @@ const parseEncodedRow = (
} }
return { return {
source_row:
typeof sourceRow === 'number'
? sourceRow
: 1,
url: typeof url === 'string' ? url : '', url: typeof url === 'string' ? url : '',
title: parsedStrings.title, title: parsedStrings.title,
thumbnail_base: parsedStrings.thumbnail_base, thumbnail_base: parsedStrings.thumbnail_base,
original_created_from: parsedStrings.original_created_from, original_created_from: parsedStrings.original_created_from,
original_created_before: parsedStrings.original_created_before, original_created_before: parsedStrings.original_created_before,
video_ms: parsedStrings.video_ms,
duration: parsedStrings.duration, duration: parsedStrings.duration,
tags: parsedStrings.tags, tags: parsedStrings.tags,
parent_post_ids: parsedStrings.parent_post_ids, parent_post_ids: parsedStrings.parent_post_ids,
@@ -398,16 +479,21 @@ const parseSingleRow = (params: URLSearchParams): ParsedPostNewState | null => {
const url = params.get ('url') const url = params.get ('url')
if (url == null || url === '') if (url == null || url === '')
return null return null
if (byteLength (url) > MAX_QUERY_STRING_BYTES)
return null
const hasOnlyUrl = Array.from (params.keys ()).every (key => key === 'url') const hasOnlyUrl = Array.from (params.keys ()).every (
key => key === 'url' || key === 'session_id')
if (hasOnlyUrl) if (hasOnlyUrl)
{ {
const row = buildRow (1, { const row = buildRow ({
source_row: 1,
url, url,
title: '', title: '',
thumbnail_base: '', thumbnail_base: '',
original_created_from: '', original_created_from: '',
original_created_before: '', original_created_before: '',
video_ms: '',
duration: '', duration: '',
tags: '', tags: '',
parent_post_ids: '', parent_post_ids: '',
@@ -437,13 +523,17 @@ const parseSingleRow = (params: URLSearchParams): ParsedPostNewState | null => {
return null return null
if (importStatus != null && !(isValidImportStatus (importStatus))) if (importStatus != null && !(isValidImportStatus (importStatus)))
return null return null
if (!(hasOnlyAllowedManualFields (parseManual (params.get ('manual')))))
return null
const row = buildRow (1, { const row = buildRow ({
source_row: 1,
url, url,
title: params.get ('title') ?? '', title: params.get ('title') ?? '',
thumbnail_base: params.get ('thumbnail_base') ?? '', thumbnail_base: params.get ('thumbnail_base') ?? '',
original_created_from: params.get ('original_created_from') ?? '', original_created_from: params.get ('original_created_from') ?? '',
original_created_before: params.get ('original_created_before') ?? '', original_created_before: params.get ('original_created_before') ?? '',
video_ms: params.get ('video_ms') ?? '',
duration: params.get ('duration') ?? '', duration: params.get ('duration') ?? '',
tags: params.get ('tags') ?? '', tags: params.get ('tags') ?? '',
parent_post_ids: params.get ('parent_post_ids') ?? '', parent_post_ids: params.get ('parent_post_ids') ?? '',
@@ -484,7 +574,12 @@ const parseMetaRows = (
return null return null
const urls = urlsValue.split (' ').filter (url => url !== '') const urls = urlsValue.split (' ').filter (url => url !== '')
if (urls.length < 2 || urls.length !== parsed.rows.length) if (urls.length < 2
|| urls.length > MAX_STATE_ROWS
|| urls.length !== parsed.rows.length
|| parsed.rows.length > MAX_STATE_ROWS)
return null
if (urls.some (url => byteLength (url) > MAX_QUERY_STRING_BYTES))
return null return null
const fullRows = parsed.rows.map ((row, index) => { const fullRows = parsed.rows.map ((row, index) => {
@@ -495,8 +590,10 @@ const parseMetaRows = (
}) })
if (fullRows.some (row => row == null)) if (fullRows.some (row => row == null))
return null return null
if ((new Set (fullRows.map (row => (row as FullRowState).source_row))).size !== fullRows.length)
return null
const rows = fullRows.map ((row, index) => buildRow (index + 1, row as FullRowState)) const rows = fullRows.map (row => buildRow (row as FullRowState))
return { return {
rows, rows,
source: urls.join ('\n'), source: urls.join ('\n'),
@@ -520,14 +617,19 @@ const parseFullState = async (stateValue: string): Promise<ParsedPostNewState |
return null return null
} }
if (parsed.v !== 1 || !(Array.isArray (parsed.rows)) || parsed.rows.length === 0) if (parsed.v !== 1
|| !(Array.isArray (parsed.rows))
|| parsed.rows.length === 0
|| parsed.rows.length > MAX_STATE_ROWS)
return null return null
const fullRows = parsed.rows.map (row => parseEncodedRow (row, true)) const fullRows = parsed.rows.map (row => parseEncodedRow (row, true))
if (fullRows.some (row => row == null)) if (fullRows.some (row => row == null))
return null return null
if ((new Set (fullRows.map (row => (row as FullRowState).source_row))).size !== fullRows.length)
return null
const rows = fullRows.map ((row, index) => buildRow (index + 1, row as FullRowState)) const rows = fullRows.map (row => buildRow (row as FullRowState))
return { return {
rows, rows,
source: rows.map (row => row.url).join ('\n'), source: rows.map (row => row.url).join ('\n'),
@@ -546,6 +648,7 @@ const regularQuery = (rows: PostImportRow[]): URLSearchParams => {
params.set ('thumbnail_base', String (row?.attributes.thumbnailBase ?? '')) params.set ('thumbnail_base', String (row?.attributes.thumbnailBase ?? ''))
params.set ('original_created_from', String (row?.attributes.originalCreatedFrom ?? '')) params.set ('original_created_from', String (row?.attributes.originalCreatedFrom ?? ''))
params.set ('original_created_before', String (row?.attributes.originalCreatedBefore ?? '')) params.set ('original_created_before', String (row?.attributes.originalCreatedBefore ?? ''))
params.set ('video_ms', String (row?.attributes.videoMs ?? ''))
params.set ('duration', String (row?.attributes.duration ?? '')) params.set ('duration', String (row?.attributes.duration ?? ''))
params.set ('tags', String (row?.attributes.tags ?? '')) params.set ('tags', String (row?.attributes.tags ?? ''))
params.set ('parent_post_ids', String (row?.attributes.parentPostIds ?? '')) params.set ('parent_post_ids', String (row?.attributes.parentPostIds ?? ''))
@@ -581,6 +684,7 @@ const encodeRowState = (
const base = baseRowState (row) const base = baseRowState (row)
const encoded: EncodedRowState = includeUrl ? { u: row.url } : { } const encoded: EncodedRowState = includeUrl ? { u: row.url } : { }
encoded.n = base.source_row
if (base.title !== '') if (base.title !== '')
encoded.t = base.title encoded.t = base.title
if (base.thumbnail_base !== '') if (base.thumbnail_base !== '')
@@ -589,6 +693,8 @@ const encodeRowState = (
encoded.f = base.original_created_from encoded.f = base.original_created_from
if (base.original_created_before !== '') if (base.original_created_before !== '')
encoded.b = base.original_created_before encoded.b = base.original_created_before
if (base.video_ms !== '')
encoded.x = base.video_ms
if (base.duration !== '') if (base.duration !== '')
encoded.d = base.duration encoded.d = base.duration
if (base.tags !== '') if (base.tags !== '')
@@ -623,6 +729,25 @@ export const hasPostNewReviewState = (search: string): boolean => {
|| params.has ('urls') || params.has ('urls')
|| params.has ('meta') || params.has ('meta')
|| params.has ('url') || params.has ('url')
|| parsePostNewSessionId (search) != null
}
export const parsePostNewSessionId = (search: string): string | null =>
validatePostImportSessionId (
new URLSearchParams (search).get ('session_id'))
export const appendPostNewSessionId = (
path: string,
sessionId: string,
): string => {
const validatedId = validatePostImportSessionId (sessionId)
if (validatedId == null)
return path
const separator = path.includes ('?') ? '&' : '?'
return `${ path }${ separator }session_id=${ validatedId }`
} }
@@ -657,11 +782,11 @@ const serialiseCompressedRows = async (
const nextRows = rows.slice (0, count) const nextRows = rows.slice (0, count)
const encoded = encodeStateRows (nextRows) const encoded = encodeStateRows (nextRows)
const state = await encodeCompressedState (JSON.stringify (encoded)) const state = await encodeCompressedState (JSON.stringify (encoded))
const payload = state.slice (COMPRESSED_STATE_PREFIX.length) const path = `/posts/new?state=${ state }`
if (payload.length <= MAX_COMPRESSED_STATE_LENGTH) if (path.length <= MAX_COMPRESSED_STATE_LENGTH)
return { return {
path: `/posts/new?state=${ state }`, path,
rows: nextRows } complete: count === rows.length }
} }
} }
catch catch
@@ -680,7 +805,7 @@ export const serialisePostNewState = async (
if (regularPath.length < MAX_REGULAR_URL_LENGTH) if (regularPath.length < MAX_REGULAR_URL_LENGTH)
return { return {
path: regularPath, path: regularPath,
rows } complete: true }
return await serialiseCompressedRows (rows) return await serialiseCompressedRows (rows)
} }
+1 -1
ファイルの表示
@@ -482,7 +482,7 @@ const MaterialListPage: FC = () => {
className={inputClass (invalid)}> className={inputClass (invalid)}>
<option value="all"></option> <option value="all"></option>
<option value="tagged"></option> <option value="tagged"></option>
<option value="untagged"></option> <option value="untagged"></option>
</select>)} </select>)}
</FormField> </FormField>
-544
ファイルの表示
@@ -1,544 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useNavigate, useParams } from 'react-router-dom'
import FieldError from '@/components/common/FieldError'
import FieldWarning from '@/components/common/FieldWarning'
import PageTitle from '@/components/common/PageTitle'
import PrefetchLink from '@/components/PrefetchLink'
import MainArea from '@/components/layout/MainArea'
import PostImportRowForm from '@/components/posts/import/PostImportRowForm'
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiPost } from '@/lib/api'
import useDialogue from '@/lib/dialogues/useDialogue'
import { canEditContent } from '@/lib/users'
import { clearPostImportSourceDraft,
buildNextEditedRow,
canEditResultRow,
canRetryResultRow,
hasExactSourceRows,
initialisePreviewRows,
loadPostImportSession,
mergeImportResults,
mergeValidatedImportRow,
replaceImportRow,
resultRepairMode,
resultRowMessages,
resultRowWarnings,
resultSummaryCounts,
retryImportRow,
savePostImportSession } from '@/lib/postImportSession'
import { originalCreatedAtString } from '@/lib/utils'
import Forbidden from '@/pages/Forbidden'
import type { FC } from 'react'
import type { PostImportRowDraft } from '@/components/posts/import/PostImportRowForm'
import type { PostImportResultRow, PostImportRow } from '@/lib/postImportSession'
import type { PostImportSession } from '@/lib/postImportSession'
import type { User } from '@/types'
type Props = { user: User | null }
const PostImportResultPage: FC<Props> = ({ user }) => {
const editable = canEditContent (user)
const dialogue = useDialogue ()
const navigate = useNavigate ()
const { sessionId } = useParams ()
const [session, setSession] = useState<PostImportSession | null> (null)
const [missing, setMissing] = useState (false)
const [loadingRow, setLoadingRow] = useState<number | null> (null)
const [, setEditingRow] = useState<PostImportRow | null> (null)
const sessionRef = useRef<PostImportSession | null> (null)
useEffect (() => {
if (sessionId == null)
return
const loaded = loadPostImportSession (sessionId, message =>
toast ({ title: '取込状態を復元できませんでした', description: message }))
setSession (loaded)
setMissing (loaded == null)
}, [sessionId])
useEffect (() => {
if (sessionId == null || session == null)
return
savePostImportSession (sessionId, session, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
}, [session, sessionId])
useEffect (() => {
sessionRef.current = session
}, [session])
const counts = useMemo (
() => resultSummaryCounts (session?.rows ?? []),
[session])
const busy = loadingRow != null
const persistSession = (nextSession: PostImportSession) => {
if (sessionId == null)
return
savePostImportSession (sessionId, nextSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
}
const saveDraft = async (
row: PostImportRow,
{ draft, resetRequested }: {
draft: PostImportRowDraft
resetRequested: boolean },
): Promise<{ saved: boolean
row: PostImportRow | null }> => {
const currentSession = sessionRef.current
if (currentSession == null)
return { saved: false, row: null }
if (!(canEditResultRow (row)))
return { saved: false, row: null }
const baseRow =
resetRequested
? { ...row,
url: row.resetSnapshot.url,
attributes: { ...row.resetSnapshot.attributes },
provenance: { ...row.resetSnapshot.provenance },
tagSources: { ...row.resetSnapshot.tagSources },
fieldWarnings: Object.fromEntries (
Object.entries (row.resetSnapshot.fieldWarnings)
.map (([key, values]) => [key, [...values]])),
baseWarnings: [...row.resetSnapshot.baseWarnings],
metadataUrl: row.resetSnapshot.metadataUrl }
: row
const urlChanged = draft.url !== baseRow.url
const nextRow = buildNextEditedRow (baseRow, draft, urlChanged)
const nextRows = currentSession.rows.map (row =>
row.sourceRow === baseRow.sourceRow
? nextRow
: row)
try
{
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
rows:
nextRows
.filter (row => row.importStatus !== 'created')
.map (row => ({ sourceRow: row.sourceRow,
url: row.url,
attributes: row.attributes,
provenance: row.provenance,
tagSources: row.tagSources,
metadataUrl: row.metadataUrl })),
changed_row: urlChanged ? baseRow.sourceRow : -1 })
const validatedRows = initialisePreviewRows (validated.rows)
const target = validatedRows.find (
validatedRow => validatedRow.sourceRow === baseRow.sourceRow)
const latestSession = sessionRef.current
if (latestSession == null)
return { saved: false, row: null }
if (target == null)
{
const restoredRows = replaceImportRow (latestSession.rows, row)
const restoredSession = {
...latestSession,
rows: restoredRows,
repairMode: resultRepairMode (restoredRows) }
sessionRef.current = restoredSession
setSession (restoredSession)
toast ({ title: '行の再検証結果が不完全でした' })
return { saved: false, row: null }
}
const editedRows = replaceImportRow (latestSession.rows, nextRow)
const rows = mergeValidatedImportRow (editedRows, target)
const nextSession = {
...latestSession,
rows,
repairMode: resultRepairMode (rows) }
sessionRef.current = nextSession
setSession (nextSession)
persistSession (nextSession)
const mergedTarget =
rows.find (mergedRow => mergedRow.sourceRow === baseRow.sourceRow)
?? target
if (Object.keys (mergedTarget.validationErrors).length > 0)
return { saved: false, row: mergedTarget }
return { saved: true, row: null }
}
catch
{
toast ({ title: '行の再検証に失敗しました' })
return { saved: false, row: null }
}
}
const openEditingDialogue = async (row: PostImportRow) => {
const saveRowDraft = (
{ draft, resetRequested }: {
draft: PostImportRowDraft
resetRequested: boolean },
) =>
saveDraft (row, { draft, resetRequested })
await dialogue.form ({
title: '投稿を編輯',
description: `投稿 ${ row.sourceRow } の内容を確認し、必要な項目を編輯してください.`,
cancelText: '取消',
size: 'large',
body: controls => (
<PostImportRowForm
row={row}
controls={controls}
onSave={saveRowDraft}/>) })
}
const editRow = async (row: PostImportRow) => {
setEditingRow (row)
try
{
await openEditingDialogue (row)
}
finally
{
setEditingRow (current =>
current?.sourceRow === row.sourceRow
? null
: current)
}
}
const retry = async (sourceRow: number) => {
if (session == null || sessionId == null || loadingRow != null)
return
const initialSession = sessionRef.current
if (initialSession == null)
return
setLoadingRow (sourceRow)
const originalRow = initialSession.rows.find (row => row.sourceRow === sourceRow)
if (originalRow == null)
{
setLoadingRow (null)
return
}
try
{
const pendingRows = retryImportRow (initialSession.rows, sourceRow)
const pendingSession = { ...initialSession, rows: pendingRows }
sessionRef.current = pendingSession
setSession (pendingSession)
persistSession (pendingSession)
const requestedRows = pendingRows.filter (row => row.importStatus !== 'created')
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
rows: requestedRows.map (row => ({
sourceRow: row.sourceRow,
url: row.url,
attributes: row.attributes,
provenance: row.provenance,
tagSources: row.tagSources,
metadataUrl: row.metadataUrl })),
changed_row: -1 })
const validatedRows = initialisePreviewRows (validated.rows)
if (!(hasExactSourceRows (requestedRows.map (row => row.sourceRow), validatedRows)))
{
const latest = sessionRef.current ?? pendingSession
const restoredRows = replaceImportRow (latest.rows, originalRow)
const restoredSession = {
...latest,
rows: restoredRows,
repairMode: resultRepairMode (restoredRows) }
sessionRef.current = restoredSession
setSession (restoredSession)
persistSession (restoredSession)
toast ({ title: '再検証結果が不完全でした' })
return
}
const validatedTarget = validatedRows.find (
row => row.sourceRow === sourceRow)
if (validatedTarget == null)
{
const latest = sessionRef.current ?? pendingSession
const restoredRows = replaceImportRow (latest.rows, originalRow)
const restoredSession = {
...latest,
rows: restoredRows,
repairMode: resultRepairMode (restoredRows) }
sessionRef.current = restoredSession
setSession (restoredSession)
persistSession (restoredSession)
toast ({ title: '再検証結果が不完全でした' })
return
}
const latestAfterValidate = sessionRef.current ?? pendingSession
const mergedValidatedRows = mergeValidatedImportRow (
latestAfterValidate.rows,
validatedTarget)
const nextSession = {
...latestAfterValidate,
rows: mergedValidatedRows,
repairMode: resultRepairMode (mergedValidatedRows) }
sessionRef.current = nextSession
setSession (nextSession)
persistSession (nextSession)
const target = mergedValidatedRows.find (row => row.sourceRow === sourceRow)
if (target == null)
{
const restoredRows = replaceImportRow (latestAfterValidate.rows, originalRow)
const restoredSession = {
...latestAfterValidate,
rows: restoredRows,
repairMode: resultRepairMode (restoredRows) }
sessionRef.current = restoredSession
setSession (restoredSession)
persistSession (restoredSession)
toast ({ title: '再検証結果が不完全でした' })
return
}
if (Object.keys (target.validationErrors ?? { }).length > 0)
{
const saved = savePostImportSession (sessionId, nextSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (saved)
void editRow (target)
return
}
const result = await apiPost<{
created: number
skipped: number
failed: number
rows: PostImportResultRow[] }> ('/posts/import', {
rows: [{
sourceRow: target.sourceRow,
url: target.url,
attributes: target.attributes,
provenance: target.provenance,
tagSources: target.tagSources,
metadataUrl: target.metadataUrl }] })
if (!(hasExactSourceRows ([sourceRow], result.rows)))
{
const latest = sessionRef.current ?? nextSession
const restoredRows = replaceImportRow (latest.rows, originalRow)
const restoredSession = {
...latest,
rows: restoredRows,
repairMode: resultRepairMode (restoredRows) }
sessionRef.current = restoredSession
setSession (restoredSession)
persistSession (restoredSession)
toast ({ title: '登録結果が不完全でした' })
return
}
const latestAfterImport = sessionRef.current ?? nextSession
const mergedRows = mergeImportResults (latestAfterImport.rows, result.rows)
const recoverableRows = result.rows.filter (row =>
row.status === 'failed'
&& row.recoverable
&& Object.keys (row.errors ?? { }).length > 0)
const nextRows = mergedRows.map ((row): PostImportRow => {
const recoverable = recoverableRows.find (
recoverableRow => recoverableRow.sourceRow === row.sourceRow)
if (recoverable == null)
return row
return {
...row,
importStatus: 'pending',
recoverable: true,
validationErrors: recoverable.errors ?? { },
importErrors: undefined }
})
const recoverableTarget = nextRows.find (row => row.sourceRow === sourceRow)
const resultSession = {
...nextSession,
rows: nextRows,
repairMode: resultRepairMode (nextRows) }
sessionRef.current = resultSession
setSession (resultSession)
persistSession (resultSession)
if (recoverableTarget != null
&& Object.keys (recoverableTarget.validationErrors).length > 0)
{
const saved = savePostImportSession (sessionId, resultSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (saved)
void editRow (recoverableTarget)
return
}
}
catch
{
const latest = sessionRef.current
if (latest != null)
{
const restoredRows = replaceImportRow (latest.rows, originalRow)
const restoredSession = {
...latest,
rows: restoredRows,
repairMode: resultRepairMode (restoredRows) }
sessionRef.current = restoredSession
setSession (restoredSession)
persistSession (restoredSession)
}
toast ({ title: '再試行に失敗しました' })
}
finally
{
setLoadingRow (null)
}
}
const openRepair = (sourceRow: number) => {
const currentSession = sessionRef.current
if (currentSession == null || sessionId == null || loadingRow != null)
return
const nextSession = { ...currentSession, repairMode: 'failed' as const }
sessionRef.current = nextSession
setSession (nextSession)
const saved = savePostImportSession (sessionId, nextSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (!(saved))
return
const row = nextSession.rows.find (rowValue => rowValue.sourceRow === sourceRow)
if (row != null)
void editRow (row)
}
if (!(editable))
return <Forbidden/>
if (missing || sessionId == null || session == null)
{
return (
<MainArea>
<div className="mx-auto max-w-4xl space-y-4 p-4">
<PageTitle>稿</PageTitle>
<FieldError messages={['取込状態が見つかりません.']}/>
<Button type="button" onClick={() => navigate ('/posts/import')}>
URL
</Button>
</div>
</MainArea>)
}
return (
<MainArea>
<Helmet>
<title>{`投稿インポート結果 | ${ SITE_TITLE }`}</title>
</Helmet>
<div className="mx-auto max-w-5xl space-y-4 p-4">
<PageTitle></PageTitle>
<div className="text-sm text-neutral-700 dark:text-neutral-200">
{counts.created}  {counts.skipped}  {counts.failed}
</div>
<div className="space-y-3">
{session.rows.map (row => {
const displayStatus = displayPostImportStatus (row)
const canEdit = canEditResultRow (row)
const canRetry = canRetryResultRow (row)
return (
<div
key={row.sourceRow}
className="rounded-lg border p-4 transition-shadow hover:shadow-sm">
<div className="flex flex-col gap-3 md:flex-row md:items-start
md:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium"> {row.sourceRow}</span>
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
</div>
<div className="text-sm text-neutral-700 dark:text-neutral-200">
{String (row.attributes.title ?? '') || row.url}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{row.url}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{String (row.attributes.tags ?? '') || 'タグなし'}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{originalCreatedAtString (
row.attributes.originalCreatedFrom?.toString () ?? null,
row.attributes.originalCreatedBefore?.toString () ?? null)}
{row.attributes.duration ? ` / ${ row.attributes.duration }` : ''}
</div>
<FieldWarning messages={resultRowWarnings (row)}/>
<FieldError messages={resultRowMessages (row)}/>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
{(row.createdPostId != null || row.existingPostId != null) && (
<Button type="button" variant="outline" asChild>
<PrefetchLink
to={`/posts/${ row.createdPostId ?? row.existingPostId }`}>
稿
</PrefetchLink>
</Button>)}
{canEdit && (
<Button
type="button"
variant="outline"
onClick={() => openRepair (row.sourceRow)}
disabled={busy}>
</Button>)}
{canRetry && (
<Button
type="button"
onClick={() => retry (row.sourceRow)}
disabled={busy}>
</Button>)}
</div>
</div>
</div>)})}
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
type="button"
variant="outline"
disabled={busy}
onClick={() => {
const currentSession = sessionRef.current
if (currentSession == null)
return
const nextSession = { ...currentSession, repairMode: 'all' as const }
sessionRef.current = nextSession
setSession (nextSession)
const saved = savePostImportSession (sessionId, nextSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (!(saved))
return
navigate (`/posts/import/${ sessionId }/review`)
}}>
</Button>
<Button
type="button"
variant="outline"
disabled={busy}
onClick={() => {
clearPostImportSourceDraft (message =>
toast ({ title: '入力内容を削除できませんでした', description: message }))
navigate ('/posts/import')
}}>
URL
</Button>
</div>
</div>
</MainArea>)
}
export default PostImportResultPage
ファイル差分が大きすぎるため省略します 差分を読込み
+131 -29
ファイルの表示
@@ -11,13 +11,20 @@ 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 { apiPost, isApiError } from '@/lib/api' import {
import { serialisePostNewState } from '@/lib/postNewQueryState' appendPostNewSessionId,
serialisePostNewState,
} from '@/lib/postNewQueryState'
import { canEditContent } from '@/lib/users' import { canEditContent } from '@/lib/users'
import { countImportSourceLines, import { countImportSourceLines,
generatePostImportSessionId,
cleanupExpiredPostImportSessions, cleanupExpiredPostImportSessions,
initialisePreviewRows, initialisePreviewRows,
loadPostImportSession,
loadPostImportSourceDraft, loadPostImportSourceDraft,
resultRepairMode,
serialisedPostImportRowsEqual,
savePostImportSession,
savePostImportSourceDraft, savePostImportSourceDraft,
validateImportSource } from '@/lib/postImportSession' validateImportSource } from '@/lib/postImportSession'
import Forbidden from '@/pages/Forbidden' import Forbidden from '@/pages/Forbidden'
@@ -33,15 +40,68 @@ 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 }))) attributes: {
} title: '',
thumbnailBase: '',
originalCreatedFrom: '',
originalCreatedBefore: '',
duration: '',
videoMs: '',
tags: '',
parentPostIds: '' },
fieldWarnings: { },
baseWarnings: [],
validationErrors: { },
provenance: {
url: 'manual',
title: 'automatic',
thumbnailBase: 'automatic',
originalCreatedFrom: 'automatic',
originalCreatedBefore: 'automatic',
duration: 'automatic',
videoMs: 'automatic',
tags: 'automatic',
parentPostIds: 'automatic' },
tagSources: {
automatic: '',
manual: '' },
status: 'pending' as const,
resetSnapshot: {
url: row.url,
attributes: {
title: '',
thumbnailBase: '',
originalCreatedFrom: '',
originalCreatedBefore: '',
duration: '',
videoMs: '',
tags: '',
parentPostIds: '' },
provenance: {
url: 'manual',
title: 'automatic',
thumbnailBase: 'automatic',
originalCreatedFrom: 'automatic',
originalCreatedBefore: 'automatic',
duration: 'automatic',
videoMs: 'automatic',
tags: 'automatic',
parentPostIds: 'automatic' },
tagSources: {
automatic: '',
manual: '' },
fieldWarnings: { },
baseWarnings: [] } }))
const PostImportSourcePage: FC<Props> = ({ user }) => { const PostImportSourcePage: FC<Props> = ({ user }) => {
@@ -62,6 +122,9 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
.filter (value => value != null) .filter (value => value != null)
.join (' ') .join (' ')
const showStorageError = (message: string) =>
toast ({ description: message })
useEffect (() => { useEffect (() => {
cleanupExpiredPostImportSessions (message => cleanupExpiredPostImportSessions (message =>
toast ({ title: '保存済みデータを整理できませんでした', description: message })) toast ({ title: '保存済みデータを整理できませんでした', description: message }))
@@ -114,28 +177,67 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
setSourceError (null) setSourceError (null)
try try
{ {
const data = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/preview', { source }) const nextRows = initialisePreviewRows (buildInitialRows (source))
const urlIssues = urlIssuesFromRows (data.rows, source) const serialised = await serialisePostNewState (nextRows)
if (urlIssues.length > 0) const sessionId = (() => {
try
{ {
setSourceIssues (urlIssues) return generatePostImportSessionId ()
}
catch
{
return null
}
}) ()
const session = {
source,
rows: nextRows,
repairMode: resultRepairMode (nextRows) }
const saved =
sessionId == null
? false
: savePostImportSession (sessionId, session, showStorageError)
const loadedSession =
sessionId == null
? null
: loadPostImportSession (sessionId, showStorageError)
const sessionRoundTripSucceeded =
saved
&& serialisedPostImportRowsEqual (
loadedSession?.rows ?? [],
nextRows)
const canNavigate =
serialised?.complete === true
|| sessionRoundTripSucceeded
if (!(canNavigate))
{
showStorageError ('ブラウザへ保存できませんでした.')
return return
} }
const nextRows = initialisePreviewRows (data.rows)
const serialised = await serialisePostNewState (nextRows) const nextPath = (() => {
if (serialised != null) if (serialised != null)
navigate (serialised.path)
}
catch (requestError)
{ {
const message = return sessionId == null
isApiError<{ message?: string, baseErrors?: string[] }> (requestError) ? serialised.path
? (requestError.response?.data?.message : appendPostNewSessionId (serialised.path, sessionId)
?? requestError.response?.data?.baseErrors?.[0]) }
: undefined if (sessionRoundTripSucceeded && sessionId != null)
setSourceError (message ?? '入力を確認してください.') return `/posts/new?session_id=${ sessionId }`
toast ({ title: '投稿情報の取得に失敗しました', return null
description: message ?? '入力を確認してください.' }) }) ()
if (nextPath == null)
{
showStorageError ('ブラウザへ保存できませんでした.')
return
}
navigate (nextPath)
}
catch (error)
{
console.error (error)
showStorageError ('ブラウザへ保存できませんでした.')
} }
finally finally
{ {
@@ -187,13 +289,13 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
</li>))} </li>))}
</ul> </ul>
<div className="flex items-center justify-between gap-3"> <div className="relative z-10 flex items-center justify-between gap-3">
<div className="text-sm text-neutral-600 dark:text-neutral-300"> <div className="text-sm text-neutral-600 dark:text-neutral-300">
{lineCount} / {MAX_ROWS} {lineCount} / {MAX_ROWS}
</div> </div>
<Button <Button
type="button" type="button"
className="shrink-0" className="pointer-events-auto shrink-0"
onClick={preview} onClick={preview}
disabled={loading}> disabled={loading}>