コミットを比較
11 コミット
dd2d199d04
...
e6c3c635b8
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| e6c3c635b8 | |||
| 9552081133 | |||
| 2d2a9b4bd6 | |||
| 09ac2576bb | |||
| 9eca670934 | |||
| c31d84115d | |||
| ff970f8171 | |||
| 0ae41b6266 | |||
| eb4bf5e35c | |||
| e6b7e33b83 | |||
| 3820d3d4d5 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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, '自分自身を親投稿にはできません.'
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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: '動画時間')
|
||||||
|
|||||||
@@ -1,190 +0,0 @@
|
|||||||
class PostImportRowNormaliser
|
|
||||||
ORIGINS = ['automatic', 'manual'].freeze
|
|
||||||
STRING_FIELDS = [
|
|
||||||
'title',
|
|
||||||
'thumbnail_base',
|
|
||||||
'original_created_from',
|
|
||||||
'original_created_before',
|
|
||||||
'duration',
|
|
||||||
'tags',
|
|
||||||
'parent_post_ids'].freeze
|
|
||||||
FLEXIBLE_FIELDS = ['video_ms'].freeze
|
|
||||||
ATTRIBUTE_FIELDS = (STRING_FIELDS + FLEXIBLE_FIELDS).freeze
|
|
||||||
|
|
||||||
def self.normalise! rows, allow_warning_fields: false
|
|
||||||
raise ArgumentError, '取込行の形式が不正です.' unless rows.is_a?(Array)
|
|
||||||
raise ArgumentError, '取込件数が多すぎます.' if rows.length > PostImportUrlListParser::MAX_ROWS
|
|
||||||
|
|
||||||
normalised_rows = rows.map { normalise_row!(_1, allow_warning_fields:) }
|
|
||||||
source_rows = normalised_rows.map { _1['source_row'] }
|
|
||||||
raise ArgumentError, '元行番号が重複しています.' if source_rows.uniq.length != source_rows.length
|
|
||||||
if normalised_rows.sum { row_bytesize(_1) } > PostImportUrlListParser::MAX_BYTES
|
|
||||||
raise ArgumentError, '取込データが大きすぎます.'
|
|
||||||
end
|
|
||||||
|
|
||||||
normalised_rows
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.normalise_row! row, allow_warning_fields:
|
|
||||||
unless row.is_a?(Hash) || row.is_a?(ActionController::Parameters)
|
|
||||||
raise ArgumentError, '取込行の形式が不正です.'
|
|
||||||
end
|
|
||||||
|
|
||||||
parameters =
|
|
||||||
row.is_a?(ActionController::Parameters) ? row : ActionController::Parameters.new(row)
|
|
||||||
permitted = parameters.permit(*permitted_keys(allow_warning_fields))
|
|
||||||
normalised = permitted.to_h.deep_transform_keys { _1.to_s.underscore }
|
|
||||||
|
|
||||||
normalised['source_row'] = normalise_source_row!(normalised['source_row'])
|
|
||||||
normalise_url!(normalised['url'])
|
|
||||||
normalise_metadata_url!(normalised['metadata_url'])
|
|
||||||
normalise_attributes!(normalised.fetch('attributes', { }))
|
|
||||||
normalise_provenance!(normalised.fetch('provenance', { }))
|
|
||||||
normalise_tag_sources!(normalised['tag_sources'])
|
|
||||||
normalise_warning_values!(normalised, allow_warning_fields:)
|
|
||||||
if row_bytesize(normalised) > PostImportUrlListParser::MAX_BYTES
|
|
||||||
raise ArgumentError, '取込行が大きすぎます.'
|
|
||||||
end
|
|
||||||
|
|
||||||
normalised
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.permitted_keys allow_warning_fields
|
|
||||||
keys = [
|
|
||||||
:source_row,
|
|
||||||
:sourceRow,
|
|
||||||
:url,
|
|
||||||
:metadata_url,
|
|
||||||
:metadataUrl,
|
|
||||||
{ attributes: {} },
|
|
||||||
{ provenance: {} },
|
|
||||||
{ tag_sources: {} },
|
|
||||||
{ tagSources: {} }]
|
|
||||||
return keys unless allow_warning_fields
|
|
||||||
|
|
||||||
keys + [
|
|
||||||
{ field_warnings: {} },
|
|
||||||
{ fieldWarnings: {} },
|
|
||||||
{ base_warnings: [] },
|
|
||||||
{ baseWarnings: [] }]
|
|
||||||
end
|
|
||||||
private_class_method :permitted_keys
|
|
||||||
|
|
||||||
def self.normalise_source_row! value
|
|
||||||
source_row = Integer(value, exception: false)
|
|
||||||
raise ArgumentError, '元行番号が不正です.' if source_row.nil? || source_row <= 0
|
|
||||||
|
|
||||||
source_row
|
|
||||||
end
|
|
||||||
private_class_method :normalise_source_row!
|
|
||||||
|
|
||||||
def self.normalise_url! value
|
|
||||||
unless value.is_a?(String)
|
|
||||||
raise ArgumentError, 'URL の形式が不正です.'
|
|
||||||
end
|
|
||||||
if value.bytesize > PostImportUrlListParser::MAX_URL_BYTES
|
|
||||||
raise ArgumentError, 'URL が長すぎます.'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
private_class_method :normalise_url!
|
|
||||||
|
|
||||||
def self.normalise_metadata_url! value
|
|
||||||
raise ArgumentError, 'metadata_url の形式が不正です.' unless value.nil? || value.is_a?(String)
|
|
||||||
if value.to_s.bytesize > PostImportUrlListParser::MAX_URL_BYTES
|
|
||||||
raise ArgumentError, 'metadata_url が長すぎます.'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
private_class_method :normalise_metadata_url!
|
|
||||||
|
|
||||||
def self.normalise_attributes! attributes
|
|
||||||
raise ArgumentError, 'attributes の形式が不正です.' unless attributes.is_a?(Hash)
|
|
||||||
raise ArgumentError, '取込項目が不正です.' unless (attributes.keys - ATTRIBUTE_FIELDS).empty?
|
|
||||||
|
|
||||||
attributes.each do |key, value|
|
|
||||||
case key
|
|
||||||
when *STRING_FIELDS
|
|
||||||
raise ArgumentError, '取込項目の型が不正です.' unless value.nil? || value.is_a?(String)
|
|
||||||
when *FLEXIBLE_FIELDS
|
|
||||||
unless value.nil? || value.is_a?(String) || value.is_a?(Numeric)
|
|
||||||
raise ArgumentError, '取込項目の型が不正です.'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if value.to_s.bytesize > PostImportUrlListParser::MAX_URL_BYTES
|
|
||||||
raise ArgumentError, '取込項目が大きすぎます.'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
private_class_method :normalise_attributes!
|
|
||||||
|
|
||||||
def self.normalise_provenance! provenance
|
|
||||||
unless provenance.is_a?(Hash)
|
|
||||||
raise ArgumentError, 'provenance の形式が不正です.'
|
|
||||||
end
|
|
||||||
|
|
||||||
allowed = ATTRIBUTE_FIELDS + ['url']
|
|
||||||
unless (provenance.keys - allowed).empty?
|
|
||||||
raise ArgumentError, '値の由来が不正です.'
|
|
||||||
end
|
|
||||||
unless provenance.values.all? { ORIGINS.include?(_1) }
|
|
||||||
raise ArgumentError, '値の由来が不正です.'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
private_class_method :normalise_provenance!
|
|
||||||
|
|
||||||
def self.normalise_tag_sources! tag_sources
|
|
||||||
return if tag_sources.nil?
|
|
||||||
|
|
||||||
unless tag_sources.is_a?(Hash)
|
|
||||||
raise ArgumentError, 'タグ由来の形式が不正です.'
|
|
||||||
end
|
|
||||||
unless (tag_sources.keys - ORIGINS).empty?
|
|
||||||
raise ArgumentError, 'タグ由来の形式が不正です.'
|
|
||||||
end
|
|
||||||
unless tag_sources.values.all? { _1.is_a?(String) }
|
|
||||||
raise ArgumentError, 'タグ由来の形式が不正です.'
|
|
||||||
end
|
|
||||||
if tag_sources.values.any? { _1.bytesize > PostImportUrlListParser::MAX_URL_BYTES }
|
|
||||||
raise ArgumentError, 'タグ由来が大きすぎます.'
|
|
||||||
end
|
|
||||||
if tag_sources.values.sum(&:bytesize) > PostImportUrlListParser::MAX_URL_BYTES
|
|
||||||
raise ArgumentError, 'タグ由来が大きすぎます.'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
private_class_method :normalise_tag_sources!
|
|
||||||
|
|
||||||
def self.normalise_warning_values! normalised, allow_warning_fields:
|
|
||||||
return unless allow_warning_fields
|
|
||||||
|
|
||||||
field_warnings = normalised['field_warnings']
|
|
||||||
unless field_warnings.nil? || field_warnings.is_a?(Hash)
|
|
||||||
raise ArgumentError, '警告の形式が不正です.'
|
|
||||||
end
|
|
||||||
field_warnings&.each do |key, values|
|
|
||||||
unless ATTRIBUTE_FIELDS.include?(key) || key == 'url'
|
|
||||||
raise ArgumentError, '警告の形式が不正です.'
|
|
||||||
end
|
|
||||||
unless values.is_a?(Array) && values.all? { _1.is_a?(String) }
|
|
||||||
raise ArgumentError, '警告の形式が不正です.'
|
|
||||||
end
|
|
||||||
if values.any? { _1.bytesize > PostImportUrlListParser::MAX_URL_BYTES }
|
|
||||||
raise ArgumentError, '警告が大きすぎます.'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
base_warnings = normalised['base_warnings']
|
|
||||||
return if base_warnings.nil?
|
|
||||||
|
|
||||||
unless base_warnings.is_a?(Array) && base_warnings.all? { _1.is_a?(String) }
|
|
||||||
raise ArgumentError, '警告の形式が不正です.'
|
|
||||||
end
|
|
||||||
if base_warnings.any? { _1.bytesize > PostImportUrlListParser::MAX_URL_BYTES }
|
|
||||||
raise ArgumentError, '警告が大きすぎます.'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
private_class_method :normalise_warning_values!
|
|
||||||
|
|
||||||
def self.row_bytesize row
|
|
||||||
row.to_json.bytesize
|
|
||||||
end
|
|
||||||
private_class_method :row_bytesize
|
|
||||||
end
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
class PostImportRunner
|
|
||||||
def initialize actor:, rows:
|
|
||||||
@actor = actor
|
|
||||||
@rows = rows
|
|
||||||
end
|
|
||||||
|
|
||||||
def run
|
|
||||||
normalised_rows = PostImportRowNormaliser.normalise!(@rows)
|
|
||||||
previews = PostImportPreviewer.new.preview_rows(rows: normalised_rows,
|
|
||||||
fetch_metadata: false)
|
|
||||||
preview_map = previews.index_by { _1[:source_row] }
|
|
||||||
|
|
||||||
results = normalised_rows.map do |row|
|
|
||||||
run_row(row, preview_map.fetch(row['source_row']))
|
|
||||||
end
|
|
||||||
|
|
||||||
{ created: results.count { _1[:status] == 'created' },
|
|
||||||
skipped: results.count { _1[:status] == 'skipped' },
|
|
||||||
failed: results.count { _1[:status] == 'failed' },
|
|
||||||
rows: results }
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def run_row row, preview
|
|
||||||
attributes = row.fetch('attributes', { }).transform_keys { _1.to_s.underscore }
|
|
||||||
return { source_row: row['source_row'],
|
|
||||||
status: 'failed',
|
|
||||||
errors: preview[:validation_errors],
|
|
||||||
recoverable: true } if preview[:validation_errors].present?
|
|
||||||
if preview[:skip_reason] == 'existing'
|
|
||||||
return { source_row: row['source_row'],
|
|
||||||
status: 'skipped',
|
|
||||||
existing_post_id: preview[:existing_post_id] }
|
|
||||||
end
|
|
||||||
|
|
||||||
attributes['tags'] = preview[:attributes]['tags']
|
|
||||||
attributes['url'] = row['url']
|
|
||||||
creator = PostCreator.new(actor: @actor, attributes:)
|
|
||||||
post = creator.create!
|
|
||||||
result = { source_row: row['source_row'], status: 'created', post: PostRepr.base(post) }
|
|
||||||
if creator.field_warnings.present?
|
|
||||||
result[:field_warnings] = creator.field_warnings
|
|
||||||
end
|
|
||||||
result
|
|
||||||
rescue ActiveRecord::RecordInvalid => e
|
|
||||||
existing_post = existing_post_for_race(row, e.record)
|
|
||||||
if existing_post
|
|
||||||
return { source_row: row['source_row'],
|
|
||||||
status: 'skipped',
|
|
||||||
existing_post_id: existing_post.id }
|
|
||||||
end
|
|
||||||
|
|
||||||
{ source_row: row['source_row'],
|
|
||||||
status: 'failed',
|
|
||||||
errors: e.record.errors.to_hash,
|
|
||||||
recoverable: true }
|
|
||||||
rescue ActiveRecord::RecordNotUnique => e
|
|
||||||
raise unless url_record_not_unique?(e)
|
|
||||||
|
|
||||||
existing_post = existing_post_for_race(row)
|
|
||||||
raise unless existing_post
|
|
||||||
|
|
||||||
{ source_row: row['source_row'],
|
|
||||||
status: 'skipped',
|
|
||||||
existing_post_id: existing_post.id }
|
|
||||||
rescue Tag::NicoTagNormalisationError
|
|
||||||
{ source_row: row['source_row'],
|
|
||||||
status: 'failed',
|
|
||||||
errors: { tags: ['ニコニコ・タグは直接指定できません.'] },
|
|
||||||
recoverable: true }
|
|
||||||
rescue Tag::DeprecatedTagNormalisationError
|
|
||||||
{ source_row: row['source_row'],
|
|
||||||
status: 'failed',
|
|
||||||
errors: { tags: ['廃止済みタグは付与できません.'] },
|
|
||||||
recoverable: true }
|
|
||||||
rescue PostCreator::VideoMsParseError
|
|
||||||
{ source_row: row['source_row'],
|
|
||||||
status: 'failed',
|
|
||||||
errors: { duration: ['動画時間の記法が不正です.'] },
|
|
||||||
recoverable: true }
|
|
||||||
rescue ArgumentError
|
|
||||||
{ source_row: row['source_row'],
|
|
||||||
status: 'failed',
|
|
||||||
errors: { base: ['入力値が不正です.'] },
|
|
||||||
recoverable: true }
|
|
||||||
rescue StandardError => e
|
|
||||||
Rails.logger.error("post_import_runner_failure #{ { error: e.class.name,
|
|
||||||
message: e.message }.to_json }")
|
|
||||||
{ source_row: row['source_row'],
|
|
||||||
status: 'failed',
|
|
||||||
errors: { base: ['登録中にエラーが発生しました.'] } }
|
|
||||||
end
|
|
||||||
|
|
||||||
def existing_post_for_race row, record = nil
|
|
||||||
if record && !(record.errors.of_kind?(:url, :taken))
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
|
|
||||||
normal_url = PostUrlNormaliser.normalise(row['url'])
|
|
||||||
return nil if normal_url.blank?
|
|
||||||
|
|
||||||
Post.find_by(url: normal_url)
|
|
||||||
end
|
|
||||||
|
|
||||||
def url_record_not_unique? error
|
|
||||||
error.message.include?('index_posts_on_url')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
class PostImportUrlListParser
|
|
||||||
MAX_ROWS = 100
|
|
||||||
MAX_BYTES = 1.megabyte
|
|
||||||
MAX_URL_BYTES = 20.kilobytes
|
|
||||||
|
|
||||||
def self.parse source
|
|
||||||
raw = source.to_s
|
|
||||||
raise ArgumentError, '入力が大きすぎます.' if raw.bytesize > MAX_BYTES
|
|
||||||
|
|
||||||
rows = raw.split(/\r\n|\n|\r/).each_with_index.filter_map { |line, index|
|
|
||||||
url = line.strip
|
|
||||||
next if url.blank?
|
|
||||||
|
|
||||||
if url.bytesize > MAX_URL_BYTES
|
|
||||||
raise ArgumentError, "#{ index + 1 } 行目: URL が長すぎます."
|
|
||||||
end
|
|
||||||
|
|
||||||
{ source_row: index + 1, url: }
|
|
||||||
}
|
|
||||||
raise ArgumentError, 'URL を入力してください.' if rows.empty?
|
|
||||||
raise ArgumentError, "取込件数は #{ MAX_ROWS } 件までです." if rows.length > MAX_ROWS
|
|
||||||
|
|
||||||
rows
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -0,0 +1,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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)}/>)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}/>
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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 }) => (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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}
|
||||||
/>)
|
/>)
|
||||||
|
|||||||
@@ -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) })))
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
ファイル差分が大きすぎるため省略します
差分を読込み
@@ -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}>
|
||||||
次へ
|
次へ
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする