コミットを比較
10 コミット
e6c3c635b8
...
e0debed94e
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| e0debed94e | |||
| 583ce22a7e | |||
| 7c808a6f76 | |||
| a5ae7c6f2d | |||
| 03dc4d0661 | |||
| 06b9c1cb50 | |||
| 688b4af575 | |||
| 040cc3f25d | |||
| b3e67d8cca | |||
| b00a62a0ac |
@@ -131,6 +131,7 @@ class PostsController < ApplicationController
|
|||||||
title: nil,
|
title: nil,
|
||||||
thumbnail_base: nil,
|
thumbnail_base: nil,
|
||||||
tags: nil,
|
tags: nil,
|
||||||
|
display_tags: [],
|
||||||
original_created_from: nil,
|
original_created_from: nil,
|
||||||
original_created_before: nil,
|
original_created_before: nil,
|
||||||
duration: nil,
|
duration: nil,
|
||||||
@@ -153,6 +154,7 @@ class PostsController < ApplicationController
|
|||||||
title: metadata[:title],
|
title: metadata[:title],
|
||||||
thumbnail_base: metadata[:thumbnail_base],
|
thumbnail_base: metadata[:thumbnail_base],
|
||||||
tags: metadata[:tags],
|
tags: metadata[:tags],
|
||||||
|
display_tags: metadata[:display_tags],
|
||||||
original_created_from: metadata[:original_created_from],
|
original_created_from: metadata[:original_created_from],
|
||||||
original_created_before: metadata[:original_created_before],
|
original_created_before: metadata[:original_created_before],
|
||||||
duration: metadata[:duration],
|
duration: metadata[:duration],
|
||||||
@@ -173,6 +175,7 @@ class PostsController < ApplicationController
|
|||||||
title: nil,
|
title: nil,
|
||||||
thumbnail_base: nil,
|
thumbnail_base: nil,
|
||||||
tags: nil,
|
tags: nil,
|
||||||
|
display_tags: [],
|
||||||
original_created_from: nil,
|
original_created_from: nil,
|
||||||
original_created_before: nil,
|
original_created_before: nil,
|
||||||
duration: nil,
|
duration: nil,
|
||||||
@@ -633,6 +636,7 @@ class PostsController < ApplicationController
|
|||||||
:title,
|
:title,
|
||||||
:thumbnail_base,
|
:thumbnail_base,
|
||||||
:tags,
|
:tags,
|
||||||
|
:display_tags,
|
||||||
:parent_post_ids,
|
:parent_post_ids,
|
||||||
:original_created_from,
|
:original_created_from,
|
||||||
:original_created_before,
|
:original_created_before,
|
||||||
|
|||||||
@@ -18,14 +18,12 @@ class PreviewController < ApplicationController
|
|||||||
def thumbnail
|
def thumbnail
|
||||||
return render_bad_request('URL は必須です.') if params[:url].blank?
|
return render_bad_request('URL は必須です.') if params[:url].blank?
|
||||||
|
|
||||||
image = MiniMagick::Image.read(Preview::ThumbnailFetcher.fetch(params[:url]))
|
attachment =
|
||||||
image.auto_orient
|
Post.resized_thumbnail_attachment(
|
||||||
image.resize '180x180>'
|
StringIO.new(Preview::ThumbnailFetcher.fetch(params[:url])))
|
||||||
image.format 'png'
|
send_data attachment[:io].read,
|
||||||
width, height = image.dimensions
|
type: attachment[:content_type],
|
||||||
raise Preview::ThumbnailFetcher::GenerationFailed, 'サムネール画像の変換に失敗しました.' if width > 180 || height > 180
|
disposition: 'inline'
|
||||||
|
|
||||||
send_data image.to_blob, type: 'image/png', disposition: 'inline'
|
|
||||||
rescue Preview::UrlSafety::UnsafeUrl => e
|
rescue Preview::UrlSafety::UnsafeUrl => e
|
||||||
render_bad_request(e.message)
|
render_bad_request(e.message)
|
||||||
rescue Preview::HttpFetcher::FetchTimeout => e
|
rescue Preview::HttpFetcher::FetchTimeout => e
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ class Post < ApplicationRecord
|
|||||||
MAX_SVG_DIMENSION = 4_096
|
MAX_SVG_DIMENSION = 4_096
|
||||||
MAX_SVG_PIXELS = 16_777_216
|
MAX_SVG_PIXELS = 16_777_216
|
||||||
THUMBNAIL_PROCESS_TIMEOUT = 5.seconds
|
THUMBNAIL_PROCESS_TIMEOUT = 5.seconds
|
||||||
|
|
||||||
def self.resized_thumbnail_attachment(upload, content_type: nil)
|
def self.resized_thumbnail_attachment(upload, content_type: nil)
|
||||||
upload.rewind
|
upload.rewind
|
||||||
bytes = upload.read
|
bytes = upload.read
|
||||||
blob = Timeout.timeout(THUMBNAIL_PROCESS_TIMEOUT) do
|
blob = Timeout.timeout(THUMBNAIL_PROCESS_TIMEOUT) do
|
||||||
image = image_for_thumbnail_upload(bytes, content_type:)
|
image = image_for_thumbnail_upload(bytes, content_type:)
|
||||||
|
image.auto_orient
|
||||||
image.resize '180x180'
|
image.resize '180x180'
|
||||||
image.format 'jpg'
|
image.format 'jpg'
|
||||||
image.to_blob
|
image.to_blob
|
||||||
@@ -225,9 +225,23 @@ class Post < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def self.image_for_thumbnail_upload(bytes, content_type: nil)
|
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)
|
if svg_content_type?(content_type) || svg_document_bytes?(bytes)
|
||||||
|
return decode_svg_thumbnail(bytes)
|
||||||
|
end
|
||||||
|
|
||||||
decode_svg_thumbnail(bytes)
|
raise MiniMagick::Error, 'サムネイル画像の形式が不正です.' unless raster_thumbnail_bytes?(bytes)
|
||||||
|
|
||||||
|
decode_raster_thumbnail(bytes)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.raster_thumbnail_bytes?(bytes)
|
||||||
|
raster_thumbnail_format(bytes).present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.remote_thumbnail_image_bytes?(bytes, content_type: nil)
|
||||||
|
return true if svg_content_type?(content_type) || svg_document_bytes?(bytes)
|
||||||
|
|
||||||
|
raster_thumbnail_bytes?(bytes)
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.svg_content_type?(content_type)
|
def self.svg_content_type?(content_type)
|
||||||
@@ -254,6 +268,18 @@ class Post < ApplicationRecord
|
|||||||
MiniMagick::Image.read(sanitised_svg_bytes(bytes))
|
MiniMagick::Image.read(sanitised_svg_bytes(bytes))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.raster_thumbnail_format(bytes)
|
||||||
|
binary = bytes.to_s.b
|
||||||
|
return 'jpeg' if binary.start_with?("\xFF\xD8\xFF".b)
|
||||||
|
return 'png' if binary.start_with?("\x89PNG\r\n\x1A\n".b)
|
||||||
|
return 'gif' if binary.start_with?('GIF87a'.b) || binary.start_with?('GIF89a'.b)
|
||||||
|
return 'webp' if binary.bytesize >= 12 &&
|
||||||
|
binary.start_with?('RIFF'.b) &&
|
||||||
|
binary.byteslice(8, 4) == 'WEBP'
|
||||||
|
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
def self.sanitised_svg_bytes(bytes)
|
def self.sanitised_svg_bytes(bytes)
|
||||||
parse_options =
|
parse_options =
|
||||||
Nokogiri::XML::ParseOptions::STRICT |
|
Nokogiri::XML::ParseOptions::STRICT |
|
||||||
@@ -354,6 +380,7 @@ class Post < ApplicationRecord
|
|||||||
:svg_content_type?,
|
:svg_content_type?,
|
||||||
:decode_raster_thumbnail,
|
:decode_raster_thumbnail,
|
||||||
:decode_svg_thumbnail,
|
:decode_svg_thumbnail,
|
||||||
|
:raster_thumbnail_format,
|
||||||
:sanitised_svg_bytes,
|
:sanitised_svg_bytes,
|
||||||
:svg_uses_disallowed_features?,
|
:svg_uses_disallowed_features?,
|
||||||
:external_svg_reference?,
|
:external_svg_reference?,
|
||||||
|
|||||||
@@ -7,20 +7,13 @@ module PostCompactRepr
|
|||||||
def base post
|
def base post
|
||||||
return nil if post.nil?
|
return nil if post.nil?
|
||||||
|
|
||||||
{
|
PostRepr
|
||||||
id: post.id,
|
.common(post)
|
||||||
title: post.title,
|
.slice(
|
||||||
url: post.url,
|
'id',
|
||||||
thumbnail_url: thumbnail_url(post) }
|
'title',
|
||||||
end
|
'url',
|
||||||
|
'thumbnail',
|
||||||
def thumbnail_url post
|
'thumbnail_base')
|
||||||
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -69,7 +69,12 @@ module PostRepr
|
|||||||
return nil unless post.thumbnail.attached?
|
return nil unless post.thumbnail.attached?
|
||||||
|
|
||||||
Rails.application.routes.url_helpers.rails_blob_url(post.thumbnail, only_path: false)
|
Rails.application.routes.url_helpers.rails_blob_url(post.thumbnail, only_path: false)
|
||||||
rescue
|
rescue ActionController::UrlGenerationError, ArgumentError, URI::InvalidURIError => e
|
||||||
|
Rails.logger.warn(
|
||||||
|
"PostRepr.thumbnail_url failed post_id=#{post.id} " \
|
||||||
|
"attachment_id=#{post.thumbnail.attachment&.id} " \
|
||||||
|
"blob_id=#{post.thumbnail.blob&.id} " \
|
||||||
|
"error_class=#{e.class} message=#{e.message}")
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ class PostCreatePlan
|
|||||||
direct_tag_specs, tag_sections = parse_direct_tag_specs
|
direct_tag_specs, tag_sections = parse_direct_tag_specs
|
||||||
default_tag_specs = build_default_tag_specs(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)
|
snapshot_tag_specs = merge_tag_specs(direct_tag_specs + default_tag_specs)
|
||||||
|
preload_existing_tags_by_name!(snapshot_tag_specs.map { _1[:name] })
|
||||||
|
validate_new_tag_specs!(snapshot_tag_specs)
|
||||||
post_tag_specs = expand_parent_tag_specs(snapshot_tag_specs)
|
post_tag_specs = expand_parent_tag_specs(snapshot_tag_specs)
|
||||||
video_ms = normalise_video_ms(snapshot_tag_specs)
|
video_ms = normalise_video_ms(snapshot_tag_specs)
|
||||||
validate_video_sections!(video_ms, tag_sections)
|
validate_video_sections!(video_ms, tag_sections)
|
||||||
@@ -25,6 +27,7 @@ class PostCreatePlan
|
|||||||
original_created_from: @attributes[:original_created_from].presence,
|
original_created_from: @attributes[:original_created_from].presence,
|
||||||
original_created_before: @attributes[:original_created_before].presence,
|
original_created_before: @attributes[:original_created_before].presence,
|
||||||
tags: serialised_tags(direct_tag_specs, tag_sections),
|
tags: serialised_tags(direct_tag_specs, tag_sections),
|
||||||
|
display_tags: display_tags(direct_tag_specs, tag_sections),
|
||||||
duration: @attributes[:duration].to_s,
|
duration: @attributes[:duration].to_s,
|
||||||
video_ms: video_ms,
|
video_ms: video_ms,
|
||||||
parent_post_ids: parent_post_ids.join(' '),
|
parent_post_ids: parent_post_ids.join(' '),
|
||||||
@@ -103,6 +106,31 @@ class PostCreatePlan
|
|||||||
default_tag_specs
|
default_tag_specs
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def validate_new_tag_specs! specs
|
||||||
|
Array(specs).each do |spec|
|
||||||
|
next if existing_tags_by_name.key?(spec[:name])
|
||||||
|
|
||||||
|
validate_new_tag_spec!(spec)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_new_tag_spec! spec
|
||||||
|
tag_name = TagName.new(name: spec[:name])
|
||||||
|
tag = Tag.new(category: spec[:category], tag_name:)
|
||||||
|
return if tag_name.valid? && tag.valid?
|
||||||
|
|
||||||
|
post = Post.new
|
||||||
|
tag_name.errors[:name].each do |message|
|
||||||
|
post.errors.add :tags, "#{ spec[:name] }: #{ message }"
|
||||||
|
end
|
||||||
|
tag.errors.each do |error|
|
||||||
|
next if error.attribute == :tag_name
|
||||||
|
|
||||||
|
post.errors.add :tags, "#{ spec[:name] }: #{ error.message }"
|
||||||
|
end
|
||||||
|
raise ActiveRecord::RecordInvalid, post
|
||||||
|
end
|
||||||
|
|
||||||
def expand_parent_tag_specs snapshot_tag_specs
|
def expand_parent_tag_specs snapshot_tag_specs
|
||||||
existing_snapshot_tags = snapshot_tag_specs.filter_map { existing_tags_by_name[_1[:name]] }
|
existing_snapshot_tags = snapshot_tag_specs.filter_map { existing_tags_by_name[_1[:name]] }
|
||||||
expanded_parent_specs =
|
expanded_parent_specs =
|
||||||
@@ -135,6 +163,17 @@ class PostCreatePlan
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def preload_existing_tags_by_name! names
|
||||||
|
wanted_names = Array(names).map { _1.to_s }.reject(&:blank?).uniq
|
||||||
|
missing_names = wanted_names - existing_tags_by_name.keys
|
||||||
|
return if missing_names.empty?
|
||||||
|
|
||||||
|
existing_tags_by_name.merge!(
|
||||||
|
Tag.joins(:tag_name)
|
||||||
|
.where(tag_names: { name: missing_names })
|
||||||
|
.index_by(&:name))
|
||||||
|
end
|
||||||
|
|
||||||
def canonical_tag_name_without_sections raw_name
|
def canonical_tag_name_without_sections raw_name
|
||||||
name, = parse_raw_tag_name(raw_name)
|
name, = parse_raw_tag_name(raw_name)
|
||||||
name
|
name
|
||||||
@@ -166,6 +205,15 @@ class PostCreatePlan
|
|||||||
}.sort.join(' ')
|
}.sort.join(' ')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def display_tags direct_tag_specs, tag_sections
|
||||||
|
direct_tag_specs.map { |spec|
|
||||||
|
{
|
||||||
|
name: spec[:name],
|
||||||
|
category: spec[:category].to_s,
|
||||||
|
section_literals: tag_sections[spec[:name]].to_a.map { Post.section_literal(_1) } }
|
||||||
|
}.sort_by { _1[:name] }
|
||||||
|
end
|
||||||
|
|
||||||
def normalise_video_ms snapshot_tag_specs
|
def normalise_video_ms snapshot_tag_specs
|
||||||
return nil unless snapshot_tag_specs.any? { _1[:name] == VIDEO_TAG_NAME }
|
return nil unless snapshot_tag_specs.any? { _1[:name] == VIDEO_TAG_NAME }
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class PostCreatePreflight
|
|||||||
original_created_before: preview[:attributes]['original_created_before'],
|
original_created_before: preview[:attributes]['original_created_before'],
|
||||||
duration: preview[:attributes]['duration'],
|
duration: preview[:attributes]['duration'],
|
||||||
video_ms: preview[:attributes]['video_ms'],
|
video_ms: preview[:attributes]['video_ms'],
|
||||||
|
display_tags: preview[:display_tags] || [],
|
||||||
field_warnings: final_field_warnings(preview[:field_warnings] || { }),
|
field_warnings: final_field_warnings(preview[:field_warnings] || { }),
|
||||||
base_warnings: preview[:base_warnings],
|
base_warnings: preview[:base_warnings],
|
||||||
existing_post_id: preview[:existing_post_id],
|
existing_post_id: preview[:existing_post_id],
|
||||||
@@ -63,6 +64,7 @@ class PostCreatePreflight
|
|||||||
original_created_before: plan[:original_created_before],
|
original_created_before: plan[:original_created_before],
|
||||||
duration: plan[:duration],
|
duration: plan[:duration],
|
||||||
video_ms: plan[:video_ms],
|
video_ms: plan[:video_ms],
|
||||||
|
display_tags: plan[:display_tags],
|
||||||
direct_tag_specs: plan[:direct_tag_specs],
|
direct_tag_specs: plan[:direct_tag_specs],
|
||||||
default_tag_specs: plan[:default_tag_specs],
|
default_tag_specs: plan[:default_tag_specs],
|
||||||
snapshot_tag_specs: plan[:snapshot_tag_specs],
|
snapshot_tag_specs: plan[:snapshot_tag_specs],
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class PostMetadataFetcher
|
|||||||
original_created_from: serialise_time(created_range&.first),
|
original_created_from: serialise_time(created_range&.first),
|
||||||
original_created_before: serialise_time(created_range&.last),
|
original_created_before: serialise_time(created_range&.last),
|
||||||
duration: serialise_duration(duration),
|
duration: serialise_duration(duration),
|
||||||
|
display_tags: display_tags(platform_tags),
|
||||||
tags: platform_tags.join(' ') }
|
tags: platform_tags.join(' ') }
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -42,6 +43,24 @@ class PostMetadataFetcher
|
|||||||
[]
|
[]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.display_tags names
|
||||||
|
return [] if names.blank?
|
||||||
|
|
||||||
|
existing_tags =
|
||||||
|
Tag
|
||||||
|
.joins(:tag_name)
|
||||||
|
.where(tag_names: { name: names })
|
||||||
|
.index_by(&:name)
|
||||||
|
|
||||||
|
names.map { |name|
|
||||||
|
tag = existing_tags[name]
|
||||||
|
{
|
||||||
|
name: name,
|
||||||
|
category: (tag&.category || 'meta'),
|
||||||
|
section_literals: [] }
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
def self.original_created_range value
|
def self.original_created_range value
|
||||||
return nil if value.blank?
|
return nil if value.blank?
|
||||||
|
|
||||||
@@ -168,6 +187,7 @@ class PostMetadataFetcher
|
|||||||
end
|
end
|
||||||
|
|
||||||
private_class_method :platform_tags,
|
private_class_method :platform_tags,
|
||||||
|
:display_tags,
|
||||||
:original_created_range,
|
:original_created_range,
|
||||||
:parse_timestamp_range,
|
:parse_timestamp_range,
|
||||||
:parse_nanoseconds,
|
:parse_nanoseconds,
|
||||||
|
|||||||
@@ -11,10 +11,15 @@ class PostThumbnailUploadValidator
|
|||||||
end
|
end
|
||||||
raise InvalidUpload, 'thumbnail file size が大きすぎます.' if thumbnail.size > MAX_THUMBNAIL_BYTES
|
raise InvalidUpload, 'thumbnail file size が大きすぎます.' if thumbnail.size > MAX_THUMBNAIL_BYTES
|
||||||
raise InvalidUpload, 'サムネイル画像の形式が不正です.' unless allowed_content_type?(thumbnail.content_type)
|
raise InvalidUpload, 'サムネイル画像の形式が不正です.' unless allowed_content_type?(thumbnail.content_type)
|
||||||
raise InvalidUpload, 'サムネイル画像の形式が不正です.' if Post.svg_document_bytes?(thumbnail.read)
|
bytes = thumbnail.read
|
||||||
thumbnail.rewind
|
raise InvalidUpload, 'サムネイル画像の形式が不正です.' if Post.svg_document_bytes?(bytes)
|
||||||
|
unless Post.raster_thumbnail_bytes?(bytes)
|
||||||
|
raise InvalidUpload, 'サムネイル画像の形式が不正です.'
|
||||||
|
end
|
||||||
|
|
||||||
attachment = Post.resized_thumbnail_attachment(thumbnail, content_type: thumbnail.content_type)
|
attachment = Post.resized_thumbnail_attachment(
|
||||||
|
StringIO.new(bytes),
|
||||||
|
content_type: thumbnail.content_type)
|
||||||
attachment[:io].close if attachment[:io].respond_to?(:close)
|
attachment[:io].close if attachment[:io].respond_to?(:close)
|
||||||
rescue MiniMagick::Error, Timeout::Error
|
rescue MiniMagick::Error, Timeout::Error
|
||||||
raise InvalidUpload, 'サムネイル画像の変換に失敗しました.'
|
raise InvalidUpload, 'サムネイル画像の変換に失敗しました.'
|
||||||
|
|||||||
@@ -4,10 +4,6 @@ module Preview
|
|||||||
RASTER_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
|
||||||
|
|
||||||
@@ -32,7 +28,9 @@ 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_remote_image_content_type?(response.content_type) || Post.svg_document_bytes?(response.body)
|
unless Post.remote_thumbnail_image_bytes?(
|
||||||
|
response.body,
|
||||||
|
content_type: response.content_type)
|
||||||
raise GenerationFailed, 'サムネール画像が見つかりませんでした.'
|
raise GenerationFailed, 'サムネール画像が見つかりませんでした.'
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -55,7 +53,9 @@ module Preview
|
|||||||
return nil if url.blank?
|
return nil if url.blank?
|
||||||
|
|
||||||
response = HttpFetcher.fetch(url)
|
response = HttpFetcher.fetch(url)
|
||||||
return nil unless allowed_remote_image_content_type?(response.content_type)
|
return nil unless Post.remote_thumbnail_image_bytes?(
|
||||||
|
response.body,
|
||||||
|
content_type: response.content_type)
|
||||||
|
|
||||||
response.body
|
response.body
|
||||||
rescue HttpFetcher::FetchTimeout
|
rescue HttpFetcher::FetchTimeout
|
||||||
@@ -96,13 +96,7 @@ module Preview
|
|||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.allowed_remote_image_content_type?(content_type)
|
|
||||||
mime_type = content_type.to_s.split(';', 2).first.downcase.strip
|
|
||||||
REMOTE_IMAGE_CONTENT_TYPES.include?(mime_type)
|
|
||||||
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_remote_image_content_type?
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -36,7 +36,11 @@ Rails.application.configure do
|
|||||||
config.action_mailer.perform_caching = false
|
config.action_mailer.perform_caching = false
|
||||||
|
|
||||||
# Set localhost to be used by links generated in mailer templates.
|
# Set localhost to be used by links generated in mailer templates.
|
||||||
config.action_mailer.default_url_options = { host: "localhost", port: 3000 }
|
config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }
|
||||||
|
Rails.application.routes.default_url_options.merge!(
|
||||||
|
host: 'localhost',
|
||||||
|
port: 3002,
|
||||||
|
protocol: 'http')
|
||||||
|
|
||||||
# Print deprecation notices to the Rails logger.
|
# Print deprecation notices to the Rails logger.
|
||||||
config.active_support.deprecation = :log
|
config.active_support.deprecation = :log
|
||||||
|
|||||||
@@ -4,9 +4,13 @@ import { cn } from '@/lib/utils'
|
|||||||
|
|
||||||
import type { ComponentProps, CSSProperties, FC, HTMLAttributes } from 'react'
|
import type { ComponentProps, CSSProperties, FC, HTMLAttributes } from 'react'
|
||||||
|
|
||||||
import type { Tag } from '@/types'
|
import type { Category, Tag } from '@/types'
|
||||||
|
|
||||||
type CommonProps = {
|
type LightweightTag = {
|
||||||
|
name: string
|
||||||
|
category: Category }
|
||||||
|
|
||||||
|
type FullCommonProps = {
|
||||||
tag: Tag
|
tag: Tag
|
||||||
nestLevel?: number
|
nestLevel?: number
|
||||||
truncateOnMobile?: boolean
|
truncateOnMobile?: boolean
|
||||||
@@ -14,18 +18,43 @@ type CommonProps = {
|
|||||||
withCount?: boolean }
|
withCount?: boolean }
|
||||||
|
|
||||||
type PropsWithLink =
|
type PropsWithLink =
|
||||||
& CommonProps
|
& FullCommonProps
|
||||||
& { linkFlg?: true }
|
& { linkFlg?: true }
|
||||||
& Partial<ComponentProps<typeof PrefetchLink>>
|
& Partial<ComponentProps<typeof PrefetchLink>>
|
||||||
|
|
||||||
type PropsWithoutLink =
|
type PropsWithoutLink =
|
||||||
& CommonProps
|
& FullCommonProps
|
||||||
& { linkFlg: false }
|
& { linkFlg: false }
|
||||||
& Partial<HTMLAttributes<HTMLSpanElement>>
|
& Partial<HTMLAttributes<HTMLSpanElement>>
|
||||||
|
|
||||||
|
type LightweightPropsWithLink =
|
||||||
|
& {
|
||||||
|
tag: LightweightTag
|
||||||
|
nestLevel?: number
|
||||||
|
truncateOnMobile?: boolean
|
||||||
|
withWiki: false
|
||||||
|
withCount: false
|
||||||
|
linkFlg?: true }
|
||||||
|
& Partial<ComponentProps<typeof PrefetchLink>>
|
||||||
|
|
||||||
|
type LightweightPropsWithoutLink =
|
||||||
|
& {
|
||||||
|
tag: LightweightTag
|
||||||
|
nestLevel?: number
|
||||||
|
truncateOnMobile?: boolean
|
||||||
|
withWiki: false
|
||||||
|
withCount: false
|
||||||
|
linkFlg: false }
|
||||||
|
& Partial<HTMLAttributes<HTMLSpanElement>>
|
||||||
|
|
||||||
type Props =
|
type Props =
|
||||||
| PropsWithLink
|
| PropsWithLink
|
||||||
| PropsWithoutLink
|
| PropsWithoutLink
|
||||||
|
| LightweightPropsWithLink
|
||||||
|
| LightweightPropsWithoutLink
|
||||||
|
|
||||||
|
const isFullTag = (tag: Tag | LightweightTag): tag is Tag =>
|
||||||
|
'id' in tag
|
||||||
|
|
||||||
|
|
||||||
const TagLink: FC<Props> = ({ tag,
|
const TagLink: FC<Props> = ({ tag,
|
||||||
@@ -50,12 +79,13 @@ const TagLink: FC<Props> = ({ tag,
|
|||||||
'inline-flex min-w-0 max-w-full flex-nowrap items-stretch align-baseline gap-x-1 md:items-baseline'
|
'inline-flex min-w-0 max-w-full flex-nowrap items-stretch align-baseline gap-x-1 md:items-baseline'
|
||||||
const markerWrapClass = 'shrink-0 self-start md:self-auto'
|
const markerWrapClass = 'shrink-0 self-start md:self-auto'
|
||||||
const countClass = 'shrink-0 self-end md:self-auto'
|
const countClass = 'shrink-0 self-end md:self-auto'
|
||||||
|
const matchedAlias = isFullTag (tag) ? tag.matchedAlias : null
|
||||||
const textTitle = title
|
const textTitle = title
|
||||||
?? (tag.matchedAlias == null ? tag.name : `${ tag.matchedAlias } → ${ tag.name }`)
|
?? (matchedAlias == null ? tag.name : `${ matchedAlias } → ${ tag.name }`)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={rootClass}>
|
<span className={rootClass}>
|
||||||
{(linkFlg && withWiki) && (
|
{(linkFlg && withWiki && isFullTag (tag)) && (
|
||||||
<span className={markerWrapClass}>
|
<span className={markerWrapClass}>
|
||||||
{(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists)
|
{(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists)
|
||||||
? (
|
? (
|
||||||
@@ -118,7 +148,7 @@ const TagLink: FC<Props> = ({ tag,
|
|||||||
style={{ paddingLeft: `${ (nestLevel - 1) }rem` }}>
|
style={{ paddingLeft: `${ (nestLevel - 1) }rem` }}>
|
||||||
↳
|
↳
|
||||||
</span>)}
|
</span>)}
|
||||||
{tag.matchedAlias != null && (
|
{matchedAlias != null && (
|
||||||
<>
|
<>
|
||||||
<span
|
<span
|
||||||
title={textTitle}
|
title={textTitle}
|
||||||
@@ -126,7 +156,7 @@ const TagLink: FC<Props> = ({ tag,
|
|||||||
style={colourStyle}
|
style={colourStyle}
|
||||||
{...props}>
|
{...props}>
|
||||||
<ResponsiveMarqueeText
|
<ResponsiveMarqueeText
|
||||||
text={tag.matchedAlias}
|
text={matchedAlias}
|
||||||
title={textTitle}
|
title={textTitle}
|
||||||
truncateOnMobile={truncateOnMobile}/>
|
truncateOnMobile={truncateOnMobile}/>
|
||||||
</span>
|
</span>
|
||||||
@@ -156,7 +186,7 @@ const TagLink: FC<Props> = ({ tag,
|
|||||||
title={textTitle}
|
title={textTitle}
|
||||||
truncateOnMobile={truncateOnMobile}/>
|
truncateOnMobile={truncateOnMobile}/>
|
||||||
</span>)}
|
</span>)}
|
||||||
{withCount && (
|
{(withCount && isFullTag (tag)) && (
|
||||||
<span className={countClass}>{tag.postCount}</span>)}
|
<span className={countClass}>{tag.postCount}</span>)}
|
||||||
</span>)
|
</span>)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import FieldError from '@/components/common/FieldError'
|
import FieldError from '@/components/common/FieldError'
|
||||||
|
import PostImportTagLinks from '@/components/posts/import/PostImportTagLinks'
|
||||||
import { Button } from '@/components/ui/button'
|
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'
|
||||||
@@ -90,10 +91,7 @@ const PostImportRowSummary: FC<Props> = (
|
|||||||
<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 ?? '') && (
|
<PostImportTagLinks tags={row.displayTags}/>
|
||||||
<div className="truncate 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">
|
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
{summaryDate (row)}
|
{summaryDate (row)}
|
||||||
</div>
|
</div>
|
||||||
@@ -153,10 +151,7 @@ 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 ?? '') && (
|
<PostImportTagLinks tags={row.displayTags}/>
|
||||||
<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">
|
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
{summaryDate (row)}
|
{summaryDate (row)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import TagLink from '@/components/TagLink'
|
||||||
|
|
||||||
|
import type { FC } from 'react'
|
||||||
|
|
||||||
|
import type { PostImportDisplayTag } from '@/lib/postImportTypes'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
tags: PostImportDisplayTag[] | undefined }
|
||||||
|
|
||||||
|
const PostImportTagLinks: FC<Props> = ({ tags }) => {
|
||||||
|
if (tags == null || tags.length === 0)
|
||||||
|
return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{tags.map (tag => {
|
||||||
|
const key = `${ tag.category }:${ tag.name }:${ tag.sectionLiterals?.join ('|') ?? '' }`
|
||||||
|
return (
|
||||||
|
<span key={key} className="inline-flex flex-nowrap items-baseline gap-1">
|
||||||
|
<TagLink
|
||||||
|
tag={{
|
||||||
|
name: tag.name,
|
||||||
|
category: tag.category }}
|
||||||
|
withWiki={false}
|
||||||
|
withCount={false}/>
|
||||||
|
{tag.sectionLiterals?.map (literal => (
|
||||||
|
<span key={literal} className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{literal}
|
||||||
|
</span>))}
|
||||||
|
</span>)
|
||||||
|
})}
|
||||||
|
</div>)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PostImportTagLinks
|
||||||
@@ -68,6 +68,10 @@ export const validatableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
|
|||||||
const buildResetSnapshot = (row: PostImportRow) => ({
|
const buildResetSnapshot = (row: PostImportRow) => ({
|
||||||
url: row.url,
|
url: row.url,
|
||||||
attributes: { ...row.attributes },
|
attributes: { ...row.attributes },
|
||||||
|
displayTags: row.displayTags?.map (tag => ({
|
||||||
|
name: tag.name,
|
||||||
|
category: tag.category,
|
||||||
|
sectionLiterals: tag.sectionLiterals == null ? undefined : [...tag.sectionLiterals] })) ?? [],
|
||||||
provenance: { ...row.provenance },
|
provenance: { ...row.provenance },
|
||||||
tagSources: {
|
tagSources: {
|
||||||
automatic: row.tagSources?.automatic ?? '',
|
automatic: row.tagSources?.automatic ?? '',
|
||||||
@@ -243,6 +247,11 @@ export const buildNextEditedRow = (
|
|||||||
...editingRow,
|
...editingRow,
|
||||||
url: draft.url,
|
url: draft.url,
|
||||||
attributes: nextAttributes,
|
attributes: nextAttributes,
|
||||||
|
displayTags:
|
||||||
|
editingRow.displayTags?.map (tag => ({
|
||||||
|
name: tag.name,
|
||||||
|
category: tag.category,
|
||||||
|
sectionLiterals: tag.sectionLiterals == null ? undefined : [...tag.sectionLiterals] })),
|
||||||
thumbnailFile: draft.thumbnailFile,
|
thumbnailFile: draft.thumbnailFile,
|
||||||
provenance: {
|
provenance: {
|
||||||
...nextProvenance,
|
...nextProvenance,
|
||||||
@@ -316,6 +325,12 @@ export const mergeValidatedImportRows = (
|
|||||||
...previous,
|
...previous,
|
||||||
url: row.url,
|
url: row.url,
|
||||||
attributes: row.attributes,
|
attributes: row.attributes,
|
||||||
|
displayTags:
|
||||||
|
row.displayTags?.map (tag => ({
|
||||||
|
name: tag.name,
|
||||||
|
category: tag.category,
|
||||||
|
sectionLiterals:
|
||||||
|
tag.sectionLiterals == null ? undefined : [...tag.sectionLiterals] })),
|
||||||
provenance: row.provenance,
|
provenance: row.provenance,
|
||||||
tagSources: row.tagSources,
|
tagSources: row.tagSources,
|
||||||
skipReason: row.skipReason,
|
skipReason: row.skipReason,
|
||||||
|
|||||||
@@ -12,23 +12,14 @@ const bytesize = (value: string): number =>
|
|||||||
new TextEncoder ().encode (value).length
|
new TextEncoder ().encode (value).length
|
||||||
|
|
||||||
|
|
||||||
const normaliseImportUrl = (value: string): string | null => {
|
const parseImportUrl = (value: string): URL | null => {
|
||||||
const trimmed = value.trim ()
|
const trimmed = value.trim ()
|
||||||
if (!(trimmed))
|
if (!(trimmed))
|
||||||
return null
|
return null
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const url = new URL (trimmed)
|
return new URL (trimmed)
|
||||||
if (!(url.protocol === 'http:' || url.protocol === 'https:'))
|
|
||||||
return null
|
|
||||||
if (!(url.host))
|
|
||||||
return null
|
|
||||||
|
|
||||||
url.hostname = url.hostname.toLowerCase ()
|
|
||||||
if (url.pathname.endsWith ('/'))
|
|
||||||
url.pathname = url.pathname.replace (/\/+$/, '')
|
|
||||||
return url.toString ()
|
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -37,12 +28,23 @@ const normaliseImportUrl = (value: string): string | null => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const countImportSourceLines = (source: string): number =>
|
const normaliseImportUrl = (url: URL): string => {
|
||||||
|
url.hostname = url.hostname.toLowerCase ()
|
||||||
|
if (url.pathname.endsWith ('/'))
|
||||||
|
url.pathname = url.pathname.replace (/\/+$/, '')
|
||||||
|
return url.toString ()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const extractImportSourceUrls = (source: string): string[] =>
|
||||||
source
|
source
|
||||||
.split (/\r\n|\n|\r/)
|
.split (/\r\n|\n|\r/)
|
||||||
.map (line => line.trim ())
|
.map (line => line.trim ())
|
||||||
.filter (line => line !== '')
|
.filter (line => line !== '')
|
||||||
.length
|
|
||||||
|
|
||||||
|
export const countImportSourceLines = (source: string): number =>
|
||||||
|
extractImportSourceUrls (source).length
|
||||||
|
|
||||||
|
|
||||||
export const validateImportSource = (
|
export const validateImportSource = (
|
||||||
@@ -61,7 +63,6 @@ export const validateImportSource = (
|
|||||||
++count
|
++count
|
||||||
const sourceRow = index + 1
|
const sourceRow = index + 1
|
||||||
const displayUrl = truncateUrl (value)
|
const displayUrl = truncateUrl (value)
|
||||||
const normalised = normaliseImportUrl (value)
|
|
||||||
if (count > MAX_ROWS)
|
if (count > MAX_ROWS)
|
||||||
{
|
{
|
||||||
issues.push ({
|
issues.push ({
|
||||||
@@ -70,14 +71,6 @@ export const validateImportSource = (
|
|||||||
url: displayUrl })
|
url: displayUrl })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!(value.startsWith ('http://') || value.startsWith ('https://')))
|
|
||||||
{
|
|
||||||
issues.push ({
|
|
||||||
sourceRow,
|
|
||||||
message: 'HTTP または HTTPS の URL ではありません.',
|
|
||||||
url: displayUrl })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (bytesize (value) > MAX_URL_BYTES)
|
if (bytesize (value) > MAX_URL_BYTES)
|
||||||
{
|
{
|
||||||
issues.push ({
|
issues.push ({
|
||||||
@@ -86,7 +79,8 @@ export const validateImportSource = (
|
|||||||
url: displayUrl })
|
url: displayUrl })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (normalised == null)
|
const parsed = parseImportUrl (value)
|
||||||
|
if (parsed == null)
|
||||||
{
|
{
|
||||||
issues.push ({
|
issues.push ({
|
||||||
sourceRow,
|
sourceRow,
|
||||||
@@ -94,6 +88,23 @@ export const validateImportSource = (
|
|||||||
url: displayUrl })
|
url: displayUrl })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!(parsed.protocol === 'http:' || parsed.protocol === 'https:'))
|
||||||
|
{
|
||||||
|
issues.push ({
|
||||||
|
sourceRow,
|
||||||
|
message: 'HTTP または HTTPS の URL ではありません.',
|
||||||
|
url: displayUrl })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!(parsed.host))
|
||||||
|
{
|
||||||
|
issues.push ({
|
||||||
|
sourceRow,
|
||||||
|
message: 'URL の形式が不正です.',
|
||||||
|
url: displayUrl })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const normalised = normaliseImportUrl (parsed)
|
||||||
const duplicateRow = seen.get (normalised)
|
const duplicateRow = seen.get (normalised)
|
||||||
if (duplicateRow != null)
|
if (duplicateRow != null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,123 +1,24 @@
|
|||||||
import type { PostImportOrigin,
|
import type { StorageErrorHandler } from '@/lib/postImportTypes'
|
||||||
PostImportExistingPost,
|
|
||||||
PostImportResetSnapshot,
|
|
||||||
PostImportRow,
|
|
||||||
PostImportSession,
|
|
||||||
PostImportStatus,
|
|
||||||
PostImportSkipReason,
|
|
||||||
StorageErrorHandler } from '@/lib/postImportTypes'
|
|
||||||
|
|
||||||
const SESSION_VERSION = 3
|
|
||||||
const SESSION_PREFIX = 'post-import-session:'
|
|
||||||
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 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 = [
|
|
||||||
'title',
|
|
||||||
'thumbnailBase',
|
|
||||||
'originalCreatedFrom',
|
|
||||||
'originalCreatedBefore',
|
|
||||||
'duration',
|
|
||||||
'videoMs',
|
|
||||||
'tags',
|
|
||||||
'parentPostIds'] as const
|
|
||||||
const PROVENANCE_KEYS = [...ATTRIBUTE_KEYS, 'url'] as const
|
|
||||||
const TAG_SOURCE_KEYS = ['automatic', 'manual'] 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> =>
|
|
||||||
typeof value === 'object' && value != null && !(Array.isArray (value))
|
|
||||||
|
|
||||||
const byteLength = (value: string): number =>
|
|
||||||
textEncoder.encode (value).byteLength
|
|
||||||
|
|
||||||
|
|
||||||
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 = (
|
||||||
key: string,
|
key: string,
|
||||||
onError?: StorageErrorHandler,
|
onError?: StorageErrorHandler,
|
||||||
): string | null => {
|
): string | null => {
|
||||||
if (typeof window === 'undefined')
|
if (typeof window === 'undefined')
|
||||||
return null
|
return null
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return sessionStorage.getItem (key)
|
return sessionStorage.getItem (key)
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
onError?.('保存済みデータを読み込めませんでした.')
|
onError?.('保存済みデータを読み込めませんでした.')
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -126,19 +27,19 @@ const writeStorage = (
|
|||||||
value: string,
|
value: string,
|
||||||
onError?: StorageErrorHandler,
|
onError?: StorageErrorHandler,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
if (typeof window === 'undefined')
|
if (typeof window === 'undefined')
|
||||||
return false
|
return false
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
sessionStorage.setItem (key, value)
|
sessionStorage.setItem (key, value)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
onError?.('ブラウザへ保存できませんでした.')
|
onError?.('ブラウザへ保存できませんでした.')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -146,450 +47,36 @@ const removeStorage = (
|
|||||||
key: string,
|
key: string,
|
||||||
onError?: StorageErrorHandler,
|
onError?: StorageErrorHandler,
|
||||||
) => {
|
) => {
|
||||||
if (typeof window === 'undefined')
|
if (typeof window === 'undefined')
|
||||||
return
|
return
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
sessionStorage.removeItem (key)
|
sessionStorage.removeItem (key)
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
onError?.('保存済みデータを削除できませんでした.')
|
onError?.('保存済みデータを削除できませんでした.')
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const ensureStringListRecord = (value: unknown): Record<string, string[]> | null => {
|
|
||||||
if (!(isPlainObject (value)))
|
|
||||||
return null
|
|
||||||
|
|
||||||
const result: Record<string, string[]> = { }
|
|
||||||
for (const [key, entry] of Object.entries (value))
|
|
||||||
{
|
|
||||||
if (!(Array.isArray (entry)) || !(entry.every (item => typeof item === 'string')))
|
|
||||||
return null
|
|
||||||
result[key] = entry
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const isValidStatus = (
|
|
||||||
value: unknown,
|
|
||||||
): value is PostImportRow['status'] =>
|
|
||||||
value === 'pending'
|
|
||||||
|| value === 'ready'
|
|
||||||
|| value === 'warning'
|
|
||||||
|| value === 'error'
|
|
||||||
|
|
||||||
|
|
||||||
const isValidImportStatus = (
|
|
||||||
value: unknown,
|
|
||||||
): value is PostImportStatus =>
|
|
||||||
value === 'pending'
|
|
||||||
|| value === 'created'
|
|
||||||
|| value === 'skipped'
|
|
||||||
|| value === 'failed'
|
|
||||||
|
|
||||||
|
|
||||||
const isValidOrigin = (
|
|
||||||
value: unknown,
|
|
||||||
): value is PostImportOrigin =>
|
|
||||||
value === 'automatic' || value === 'manual'
|
|
||||||
|
|
||||||
|
|
||||||
const isValidSkipReason = (
|
|
||||||
value: unknown,
|
|
||||||
): value is PostImportSkipReason =>
|
|
||||||
value === 'existing' || value === 'manual'
|
|
||||||
|
|
||||||
const isPositiveInteger = (value: unknown): value is number =>
|
|
||||||
Number.isInteger (value) && Number (value) > 0
|
|
||||||
|
|
||||||
const hasOnlyKeys = (
|
|
||||||
value: Record<string, unknown>,
|
|
||||||
allowedKeys: readonly string[],
|
|
||||||
): boolean =>
|
|
||||||
Object.keys (value).every (key => allowedKeys.includes (key))
|
|
||||||
|
|
||||||
const sanitiseResetSnapshot = (value: unknown): PostImportResetSnapshot | null => {
|
|
||||||
if (!(isPlainObject (value)))
|
|
||||||
return null
|
|
||||||
if (!(hasOnlyKeys (value, ['url',
|
|
||||||
'attributes',
|
|
||||||
'provenance',
|
|
||||||
'tagSources',
|
|
||||||
'fieldWarnings',
|
|
||||||
'baseWarnings',
|
|
||||||
'metadataUrl'])))
|
|
||||||
return null
|
|
||||||
if (typeof value.url !== 'string')
|
|
||||||
return null
|
|
||||||
if (!(isPlainObject (value.attributes)))
|
|
||||||
return null
|
|
||||||
if (!(hasOnlyKeys (value.attributes, ATTRIBUTE_KEYS)))
|
|
||||||
return null
|
|
||||||
if (!(Object.values (value.attributes).every (entry =>
|
|
||||||
typeof entry === 'string' || typeof entry === 'number')))
|
|
||||||
return null
|
|
||||||
if (!(isPlainObject (value.provenance)))
|
|
||||||
return null
|
|
||||||
if (!(hasOnlyKeys (value.provenance, PROVENANCE_KEYS)))
|
|
||||||
return null
|
|
||||||
if (!(Object.values (value.provenance).every (origin => isValidOrigin (origin))))
|
|
||||||
return null
|
|
||||||
if (!(isPlainObject (value.tagSources)))
|
|
||||||
return null
|
|
||||||
if (!(hasOnlyKeys (value.tagSources, TAG_SOURCE_KEYS)))
|
|
||||||
return null
|
|
||||||
if (!(Object.values (value.tagSources).every (entry => typeof entry === 'string')))
|
|
||||||
return null
|
|
||||||
const fieldWarnings = ensureStringListRecord (value.fieldWarnings)
|
|
||||||
if (fieldWarnings == null)
|
|
||||||
return null
|
|
||||||
if (!(hasOnlyKeys (fieldWarnings, WARNING_KEYS)))
|
|
||||||
return null
|
|
||||||
if (!(Array.isArray (value.baseWarnings))
|
|
||||||
|| !(value.baseWarnings.every (warning => typeof warning === 'string')))
|
|
||||||
return null
|
|
||||||
if (value.metadataUrl != null && typeof value.metadataUrl !== 'string')
|
|
||||||
return null
|
|
||||||
|
|
||||||
return {
|
|
||||||
url: value.url,
|
|
||||||
attributes: value.attributes as Record<string, string | number>,
|
|
||||||
provenance: value.provenance as Record<string, PostImportOrigin>,
|
|
||||||
tagSources: value.tagSources as Record<PostImportOrigin, string>,
|
|
||||||
fieldWarnings,
|
|
||||||
baseWarnings: value.baseWarnings,
|
|
||||||
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 => {
|
|
||||||
if (!(isPlainObject (value)))
|
|
||||||
return null
|
|
||||||
if (!(hasOnlyKeys (value, ROW_KEYS)))
|
|
||||||
return null
|
|
||||||
if (!(Number.isInteger (value.sourceRow)) || Number (value.sourceRow) <= 0)
|
|
||||||
return null
|
|
||||||
if (typeof value.url !== 'string')
|
|
||||||
return null
|
|
||||||
if (byteLength (value.url) > 20_480)
|
|
||||||
return null
|
|
||||||
if (!(isPlainObject (value.attributes)))
|
|
||||||
return null
|
|
||||||
if (!(isPlainObject (value.provenance)))
|
|
||||||
return null
|
|
||||||
if (!(isValidStatus (value.status)))
|
|
||||||
return null
|
|
||||||
if (value.importStatus != null && !(isValidImportStatus (value.importStatus)))
|
|
||||||
return null
|
|
||||||
if (value.skipReason != null && !(isValidSkipReason (value.skipReason)))
|
|
||||||
return null
|
|
||||||
if (value.recoverable != null && value.recoverable !== true)
|
|
||||||
return null
|
|
||||||
if (value.skipReason === 'existing' && !(isPositiveInteger (value.existingPostId)))
|
|
||||||
return null
|
|
||||||
if (value.skipReason !== 'existing' && value.existingPostId != null)
|
|
||||||
return null
|
|
||||||
if (value.importStatus === 'created' && !(isPositiveInteger (value.createdPostId)))
|
|
||||||
return null
|
|
||||||
if (value.importStatus !== 'created' && value.createdPostId != null)
|
|
||||||
return null
|
|
||||||
if (value.recoverable === true
|
|
||||||
&& value.importStatus !== 'failed'
|
|
||||||
&& value.importStatus !== 'pending')
|
|
||||||
return null
|
|
||||||
if ((value.importStatus === 'created' || value.importStatus === 'skipped')
|
|
||||||
&& value.recoverable != null)
|
|
||||||
return null
|
|
||||||
|
|
||||||
const validationErrors = ensureStringListRecord (value.validationErrors)
|
|
||||||
const fieldWarnings = ensureStringListRecord (value.fieldWarnings)
|
|
||||||
const resetSnapshot = sanitiseResetSnapshot (value.resetSnapshot)
|
|
||||||
if (validationErrors == null || fieldWarnings == null)
|
|
||||||
return null
|
|
||||||
if (resetSnapshot == null)
|
|
||||||
return null
|
|
||||||
|
|
||||||
const importErrors =
|
|
||||||
value.importErrors == null
|
|
||||||
? undefined
|
|
||||||
: ensureStringListRecord (value.importErrors)
|
|
||||||
if (importErrors === null)
|
|
||||||
return null
|
|
||||||
if (!(Array.isArray (value.baseWarnings))
|
|
||||||
|| !(value.baseWarnings.every (warning => typeof warning === 'string')))
|
|
||||||
return null
|
|
||||||
|
|
||||||
const provenanceEntries = Object.entries (value.provenance)
|
|
||||||
if (!(hasOnlyKeys (value.attributes, ATTRIBUTE_KEYS)))
|
|
||||||
return null
|
|
||||||
if (!(Object.values (value.attributes).every (entry =>
|
|
||||||
typeof entry === 'string' || typeof entry === 'number')))
|
|
||||||
return null
|
|
||||||
if (!(hasOnlyKeys (value.provenance, PROVENANCE_KEYS)))
|
|
||||||
return null
|
|
||||||
if (!(provenanceEntries.every (([, origin]) => isValidOrigin (origin))))
|
|
||||||
return null
|
|
||||||
|
|
||||||
if (value.tagSources != null)
|
|
||||||
{
|
|
||||||
if (!(isPlainObject (value.tagSources)))
|
|
||||||
return null
|
|
||||||
if (!(hasOnlyKeys (value.tagSources, TAG_SOURCE_KEYS)))
|
|
||||||
return null
|
|
||||||
if (!(Object.values (value.tagSources).every (entry => typeof entry === 'string')))
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
sourceRow: Number (value.sourceRow),
|
|
||||||
url: value.url,
|
|
||||||
attributes: value.attributes as Record<string, string | number>,
|
|
||||||
fieldWarnings,
|
|
||||||
baseWarnings: value.baseWarnings,
|
|
||||||
validationErrors,
|
|
||||||
importErrors,
|
|
||||||
provenance: value.provenance as Record<string, PostImportOrigin>,
|
|
||||||
tagSources: value.tagSources as Record<PostImportOrigin, string> | undefined,
|
|
||||||
status: value.status,
|
|
||||||
skipReason: value.skipReason ?? undefined,
|
|
||||||
existingPostId:
|
|
||||||
isPositiveInteger (value.existingPostId) ? Number (value.existingPostId) : undefined,
|
|
||||||
existingPost: sanitiseExistingPost (value.existingPost),
|
|
||||||
metadataUrl: typeof value.metadataUrl === 'string' ? value.metadataUrl : undefined,
|
|
||||||
resetSnapshot,
|
|
||||||
createdPostId:
|
|
||||||
isPositiveInteger (value.createdPostId) ? Number (value.createdPostId) : undefined,
|
|
||||||
importStatus: value.importStatus ?? 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 rows = value.rows.map (sanitiseRow)
|
|
||||||
if (rows.some (row => row == null))
|
|
||||||
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 ()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const cleanupExpiredPostImportSessions = (
|
|
||||||
onError?: StorageErrorHandler,
|
|
||||||
) => {
|
|
||||||
if (typeof window === 'undefined')
|
|
||||||
return
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
for (let i = 0; i < sessionStorage.length; ++i)
|
|
||||||
{
|
|
||||||
const key = sessionStorage.key (i)
|
|
||||||
if (key == null || !(key.startsWith (SESSION_PREFIX)))
|
|
||||||
continue
|
|
||||||
|
|
||||||
const sessionId = validatePostImportSessionId (key.slice (SESSION_PREFIX.length))
|
|
||||||
if (sessionId == null)
|
|
||||||
{
|
|
||||||
sessionStorage.removeItem (key)
|
|
||||||
--i
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const raw = sessionStorage.getItem (key)
|
|
||||||
if (raw == null)
|
|
||||||
continue
|
|
||||||
if (byteLength (raw) > MAX_SESSION_JSON_BYTES)
|
|
||||||
{
|
|
||||||
sessionStorage.removeItem (key)
|
|
||||||
--i
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (sanitiseSession (JSON.parse (raw)) == null)
|
|
||||||
{
|
|
||||||
sessionStorage.removeItem (key)
|
|
||||||
--i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
sessionStorage.removeItem (key)
|
|
||||||
--i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
onError?.('保存済みデータを整理できませんでした.')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const loadPostImportSourceDraft = (
|
export const loadPostImportSourceDraft = (
|
||||||
onError?: StorageErrorHandler,
|
onError?: StorageErrorHandler,
|
||||||
): { source: string } => {
|
): { source: string } => {
|
||||||
const raw = readStorage (SOURCE_DRAFT_KEY, onError)
|
const raw = readStorage (SOURCE_DRAFT_KEY, onError)
|
||||||
if (raw == null)
|
if (raw == null)
|
||||||
return { source: '' }
|
return { source: '' }
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const value = JSON.parse (raw) as { source?: string }
|
const value = JSON.parse (raw) as { source?: string }
|
||||||
return { source: typeof value.source === 'string' ? value.source : '' }
|
return { source: typeof value.source === 'string' ? value.source : '' }
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
return { source: '' }
|
return { source: '' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -603,73 +90,5 @@ export const savePostImportSourceDraft = (
|
|||||||
export const clearPostImportSourceDraft = (
|
export const clearPostImportSourceDraft = (
|
||||||
onError?: StorageErrorHandler,
|
onError?: StorageErrorHandler,
|
||||||
) => {
|
) => {
|
||||||
removeStorage (SOURCE_DRAFT_KEY, onError)
|
removeStorage (SOURCE_DRAFT_KEY, onError)
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const savePostImportSession = (
|
|
||||||
sessionId: string,
|
|
||||||
session: Omit<PostImportSession, 'version' | 'expiresAt'>,
|
|
||||||
onError?: StorageErrorHandler,
|
|
||||||
): boolean => {
|
|
||||||
const validatedId = validatePostImportSessionId (sessionId)
|
|
||||||
if (validatedId == null)
|
|
||||||
return false
|
|
||||||
|
|
||||||
return writeStorage (
|
|
||||||
sessionKey (validatedId),
|
|
||||||
JSON.stringify ({
|
|
||||||
source: session.source,
|
|
||||||
rows: serialisePostImportRows (session.rows),
|
|
||||||
repairMode: session.repairMode,
|
|
||||||
version: SESSION_VERSION,
|
|
||||||
expiresAt: new Date (Date.now () + SESSION_MAX_AGE_MS).toISOString () }),
|
|
||||||
onError)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const loadPostImportSession = (
|
|
||||||
sessionId: string,
|
|
||||||
onError?: StorageErrorHandler,
|
|
||||||
): PostImportSession | null => {
|
|
||||||
const validatedId = validatePostImportSessionId (sessionId)
|
|
||||||
if (validatedId == null)
|
|
||||||
return null
|
|
||||||
|
|
||||||
const raw = readStorage (sessionKey (validatedId), onError)
|
|
||||||
if (raw == null)
|
|
||||||
return null
|
|
||||||
if (byteLength (raw) > MAX_SESSION_JSON_BYTES)
|
|
||||||
{
|
|
||||||
removeStorage (sessionKey (validatedId), onError)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
const session = sanitiseSession (JSON.parse (raw))
|
|
||||||
if (session == null)
|
|
||||||
{
|
|
||||||
removeStorage (sessionKey (validatedId), onError)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return session
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
removeStorage (sessionKey (validatedId), onError)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const clearPostImportSession = (
|
|
||||||
sessionId: string,
|
|
||||||
onError?: StorageErrorHandler,
|
|
||||||
) => {
|
|
||||||
const validatedId = validatePostImportSessionId (sessionId)
|
|
||||||
if (validatedId == null)
|
|
||||||
return
|
|
||||||
|
|
||||||
removeStorage (sessionKey (validatedId), onError)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Category } from '@/types'
|
||||||
|
|
||||||
export type PostImportOrigin = 'automatic' | 'manual'
|
export type PostImportOrigin = 'automatic' | 'manual'
|
||||||
export type PostImportRepairMode = 'all' | 'failed'
|
export type PostImportRepairMode = 'all' | 'failed'
|
||||||
export type PostImportStatus =
|
export type PostImportStatus =
|
||||||
@@ -9,9 +11,15 @@ export type PostImportSkipReason = 'existing' | 'manual'
|
|||||||
export type PostImportResultStatus = 'created' | 'skipped' | 'failed'
|
export type PostImportResultStatus = 'created' | 'skipped' | 'failed'
|
||||||
export type PostImportAttributeValue = string | number
|
export type PostImportAttributeValue = string | number
|
||||||
|
|
||||||
|
export type PostImportDisplayTag = {
|
||||||
|
name: string
|
||||||
|
category: Category
|
||||||
|
sectionLiterals?: string[] }
|
||||||
|
|
||||||
export type PostImportResetSnapshot = {
|
export type PostImportResetSnapshot = {
|
||||||
url: string
|
url: string
|
||||||
attributes: Record<string, PostImportAttributeValue>
|
attributes: Record<string, PostImportAttributeValue>
|
||||||
|
displayTags: PostImportDisplayTag[]
|
||||||
provenance: Record<string, PostImportOrigin>
|
provenance: Record<string, PostImportOrigin>
|
||||||
tagSources: Record<PostImportOrigin, string>
|
tagSources: Record<PostImportOrigin, string>
|
||||||
fieldWarnings: Record<string, string[]>
|
fieldWarnings: Record<string, string[]>
|
||||||
@@ -22,7 +30,8 @@ export type PostImportExistingPost = {
|
|||||||
id: number
|
id: number
|
||||||
title: string
|
title: string
|
||||||
url: string
|
url: string
|
||||||
thumbnailUrl?: string }
|
thumbnail?: string | null
|
||||||
|
thumbnailBase?: string | null }
|
||||||
|
|
||||||
export type PostImportRow = {
|
export type PostImportRow = {
|
||||||
sourceRow: number
|
sourceRow: number
|
||||||
@@ -39,6 +48,7 @@ export type PostImportRow = {
|
|||||||
existingPostId?: number
|
existingPostId?: number
|
||||||
existingPost?: PostImportExistingPost
|
existingPost?: PostImportExistingPost
|
||||||
metadataUrl?: string
|
metadataUrl?: string
|
||||||
|
displayTags?: PostImportDisplayTag[]
|
||||||
resetSnapshot: PostImportResetSnapshot
|
resetSnapshot: PostImportResetSnapshot
|
||||||
createdPostId?: number
|
createdPostId?: number
|
||||||
importStatus?: PostImportStatus
|
importStatus?: PostImportStatus
|
||||||
@@ -69,13 +79,6 @@ export type PostImportResultRow =
|
|||||||
errors?: Record<string, string[]>
|
errors?: Record<string, string[]>
|
||||||
recoverable?: boolean }
|
recoverable?: boolean }
|
||||||
|
|
||||||
export type PostImportSession = {
|
|
||||||
version: number
|
|
||||||
expiresAt: string
|
|
||||||
source: string
|
|
||||||
rows: PostImportRow[]
|
|
||||||
repairMode: PostImportRepairMode }
|
|
||||||
|
|
||||||
export type PostImportSourceIssue = {
|
export type PostImportSourceIssue = {
|
||||||
sourceRow: number
|
sourceRow: number
|
||||||
message: string
|
message: string
|
||||||
|
|||||||
@@ -1,811 +1,58 @@
|
|||||||
import { resultRepairMode } from '@/lib/postImportRows'
|
const POST_NEW_REVIEW_PATH_PREFIX = '/posts/new?urls='
|
||||||
import { validatePostImportSessionId } from '@/lib/postImportStorage'
|
const MAX_POST_NEW_REVIEW_TARGET_BYTES = 4096
|
||||||
|
|
||||||
import type {
|
|
||||||
PostImportOrigin,
|
|
||||||
PostImportRepairMode,
|
|
||||||
PostImportRow,
|
|
||||||
PostImportSkipReason,
|
|
||||||
} from '@/lib/postImportTypes'
|
|
||||||
|
|
||||||
type QueryRowState = {
|
|
||||||
source_row: number
|
|
||||||
title: string
|
|
||||||
thumbnail_base: string
|
|
||||||
original_created_from: string
|
|
||||||
original_created_before: string
|
|
||||||
video_ms: string
|
|
||||||
duration: string
|
|
||||||
tags: string
|
|
||||||
parent_post_ids: string
|
|
||||||
manual: string[]
|
|
||||||
skip: PostImportSkipReason | null
|
|
||||||
existing_post_id: number | null
|
|
||||||
import_status: PostImportRow['importStatus'] | null
|
|
||||||
created_post_id: number | null
|
|
||||||
recoverable: boolean | null }
|
|
||||||
|
|
||||||
type FullRowState = QueryRowState & { url: string }
|
|
||||||
|
|
||||||
type MultiRowMetaState = {
|
|
||||||
v: 1
|
|
||||||
rows: EncodedRowState[] }
|
|
||||||
|
|
||||||
type EncodedSkip = 'e' | 'm'
|
|
||||||
type EncodedImportStatus = 'p' | 'c' | 's' | 'f'
|
|
||||||
|
|
||||||
type EncodedRowState = {
|
|
||||||
n?: number
|
|
||||||
u?: string
|
|
||||||
t?: string
|
|
||||||
h?: string
|
|
||||||
f?: string
|
|
||||||
b?: string
|
|
||||||
x?: string
|
|
||||||
d?: string
|
|
||||||
g?: string
|
|
||||||
p?: string
|
|
||||||
m?: string[]
|
|
||||||
s?: EncodedSkip
|
|
||||||
e?: number
|
|
||||||
i?: EncodedImportStatus
|
|
||||||
c?: number
|
|
||||||
r?: boolean }
|
|
||||||
|
|
||||||
type EncodedState = {
|
|
||||||
v: 1
|
|
||||||
rows: EncodedRowState[] }
|
|
||||||
|
|
||||||
export type ParsedPostNewState = {
|
|
||||||
rows: PostImportRow[]
|
|
||||||
source: string
|
|
||||||
repairMode: PostImportRepairMode
|
|
||||||
shortcut: boolean }
|
|
||||||
|
|
||||||
export type SerialisedPostNewState = {
|
|
||||||
path: string
|
|
||||||
complete: boolean }
|
|
||||||
|
|
||||||
const BASIC_KEYS = [
|
|
||||||
'url',
|
|
||||||
'title',
|
|
||||||
'thumbnail_base',
|
|
||||||
'original_created_from',
|
|
||||||
'original_created_before',
|
|
||||||
'video_ms',
|
|
||||||
'duration',
|
|
||||||
'tags',
|
|
||||||
'parent_post_ids'] as const
|
|
||||||
const STATE_KEYS = [
|
|
||||||
'manual',
|
|
||||||
'skip',
|
|
||||||
'existing_post_id',
|
|
||||||
'import_status',
|
|
||||||
'created_post_id',
|
|
||||||
'recoverable'] 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_COMPRESSED_STATE_LENGTH = 767
|
|
||||||
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 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 =>
|
|
||||||
value === 'existing' || value === 'manual'
|
|
||||||
|
|
||||||
|
|
||||||
const isValidImportStatus = (
|
const rawUrlsParam = (search: string): string | null => {
|
||||||
value: unknown,
|
const query = search.startsWith ('?') ? search.slice (1) : search
|
||||||
): value is PostImportRow['importStatus'] =>
|
if (query === '')
|
||||||
value === 'pending'
|
|
||||||
|| value === 'created'
|
|
||||||
|| value === 'skipped'
|
|
||||||
|| value === 'failed'
|
|
||||||
|
|
||||||
|
|
||||||
const encodeSkip = (
|
|
||||||
value: PostImportSkipReason | null,
|
|
||||||
): EncodedSkip | undefined =>
|
|
||||||
value === 'existing'
|
|
||||||
? 'e'
|
|
||||||
: value === 'manual'
|
|
||||||
? 'm'
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
|
|
||||||
const decodeSkip = (
|
|
||||||
value: unknown,
|
|
||||||
): PostImportSkipReason | null => {
|
|
||||||
if (value == null)
|
|
||||||
return null
|
|
||||||
if (value === 'e')
|
|
||||||
return 'existing'
|
|
||||||
if (value === 'm')
|
|
||||||
return 'manual'
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const encodeImportStatus = (
|
|
||||||
value: PostImportRow['importStatus'] | null,
|
|
||||||
): EncodedImportStatus | undefined =>
|
|
||||||
value === 'pending'
|
|
||||||
? 'p'
|
|
||||||
: value === 'created'
|
|
||||||
? 'c'
|
|
||||||
: value === 'skipped'
|
|
||||||
? 's'
|
|
||||||
: value === 'failed'
|
|
||||||
? 'f'
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
|
|
||||||
const decodeImportStatus = (
|
|
||||||
value: unknown,
|
|
||||||
): PostImportRow['importStatus'] | null => {
|
|
||||||
if (value == null)
|
|
||||||
return null
|
|
||||||
if (value === 'p')
|
|
||||||
return 'pending'
|
|
||||||
if (value === 'c')
|
|
||||||
return 'created'
|
|
||||||
if (value === 's')
|
|
||||||
return 'skipped'
|
|
||||||
if (value === 'f')
|
|
||||||
return 'failed'
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const parsePositiveInt = (value: string | null): number | null => {
|
|
||||||
if (value == null || value === '')
|
|
||||||
return null
|
return null
|
||||||
|
|
||||||
const parsed = Number.parseInt (value, 10)
|
for (const segment of query.split ('&'))
|
||||||
return Number.isInteger (parsed) && parsed > 0 ? parsed : null
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const decodeBase64UrlBytes = (value: string): Uint8Array | null => {
|
|
||||||
try
|
|
||||||
{
|
|
||||||
const padded = value.replaceAll ('-', '+').replaceAll ('_', '/')
|
|
||||||
const remainder = padded.length % 4
|
|
||||||
const base64 =
|
|
||||||
remainder === 0
|
|
||||||
? padded
|
|
||||||
: `${ padded }${ '='.repeat (4 - remainder) }`
|
|
||||||
const binary = window.atob (base64)
|
|
||||||
return Uint8Array.from (binary, char => char.charCodeAt (0))
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const encodeBase64UrlBytes = (value: Uint8Array): string => {
|
|
||||||
const binary = Array.from (value, byte => String.fromCharCode (byte)).join ('')
|
|
||||||
return window.btoa (binary).replaceAll ('+', '-').replaceAll ('/', '_').replaceAll ('=', '')
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const compressBytes = async (value: Uint8Array): Promise<Uint8Array> => {
|
|
||||||
const stream =
|
|
||||||
new Blob ([value]).stream ().pipeThrough (new CompressionStream ('gzip'))
|
|
||||||
return new Uint8Array (await new Response (stream).arrayBuffer ())
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const decompressBytes = async (value: Uint8Array): Promise<Uint8Array> => {
|
|
||||||
const stream =
|
|
||||||
new Blob ([value]).stream ().pipeThrough (new DecompressionStream ('gzip'))
|
|
||||||
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> => {
|
|
||||||
if (!(value.startsWith (COMPRESSED_STATE_PREFIX)))
|
|
||||||
return null
|
|
||||||
if (value.length > MAX_COMPRESSED_STATE_PARAM_LENGTH)
|
|
||||||
return null
|
|
||||||
|
|
||||||
const encoded = value.slice (COMPRESSED_STATE_PREFIX.length)
|
|
||||||
const compressed = decodeBase64UrlBytes (encoded)
|
|
||||||
if (compressed == null)
|
|
||||||
return null
|
|
||||||
if (compressed.byteLength > MAX_COMPRESSED_STATE_BYTES)
|
|
||||||
return null
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return textDecoder.decode (await decompressBytes (compressed))
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const encodeCompressedState = async (value: string): Promise<string> => {
|
|
||||||
const compressed = await compressBytes (textEncoder.encode (value))
|
|
||||||
return `${ COMPRESSED_STATE_PREFIX }${ encodeBase64UrlBytes (compressed) }`
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const manualFields = (row: PostImportRow): string[] =>
|
|
||||||
['title',
|
|
||||||
'thumbnailBase',
|
|
||||||
'originalCreatedFrom',
|
|
||||||
'originalCreatedBefore',
|
|
||||||
'duration',
|
|
||||||
'tags',
|
|
||||||
'parentPostIds']
|
|
||||||
.filter (field => row.provenance[field] === 'manual')
|
|
||||||
|
|
||||||
|
|
||||||
const baseRowState = (row: PostImportRow): QueryRowState => ({
|
|
||||||
source_row: row.sourceRow,
|
|
||||||
title: String (row.attributes.title ?? ''),
|
|
||||||
thumbnail_base: String (row.attributes.thumbnailBase ?? ''),
|
|
||||||
original_created_from: String (row.attributes.originalCreatedFrom ?? ''),
|
|
||||||
original_created_before: String (row.attributes.originalCreatedBefore ?? ''),
|
|
||||||
video_ms: String (row.attributes.videoMs ?? ''),
|
|
||||||
duration: String (row.attributes.duration ?? ''),
|
|
||||||
tags: String (row.attributes.tags ?? ''),
|
|
||||||
parent_post_ids: String (row.attributes.parentPostIds ?? ''),
|
|
||||||
manual: manualFields (row),
|
|
||||||
skip: row.skipReason ?? null,
|
|
||||||
existing_post_id: row.existingPostId ?? null,
|
|
||||||
import_status: row.importStatus ?? null,
|
|
||||||
created_post_id: row.createdPostId ?? null,
|
|
||||||
recoverable: row.recoverable ?? null })
|
|
||||||
|
|
||||||
|
|
||||||
const buildRow = (state: FullRowState): PostImportRow => {
|
|
||||||
const manual = new Set (state.manual)
|
|
||||||
const provenance: Record<string, PostImportOrigin> = {
|
|
||||||
url: 'manual',
|
|
||||||
title: manual.has ('title') ? 'manual' : 'automatic',
|
|
||||||
thumbnailBase: manual.has ('thumbnailBase') ? 'manual' : 'automatic',
|
|
||||||
originalCreatedFrom: manual.has ('originalCreatedFrom') ? 'manual' : 'automatic',
|
|
||||||
originalCreatedBefore: manual.has ('originalCreatedBefore') ? 'manual' : 'automatic',
|
|
||||||
videoMs: manual.has ('duration') ? 'manual' : 'automatic',
|
|
||||||
duration: manual.has ('duration') ? 'manual' : 'automatic',
|
|
||||||
tags: manual.has ('tags') ? 'manual' : 'automatic',
|
|
||||||
parentPostIds: manual.has ('parentPostIds') ? 'manual' : 'automatic' }
|
|
||||||
const tagSources = provenance.tags === 'manual'
|
|
||||||
? { automatic: '', manual: state.tags }
|
|
||||||
: { automatic: state.tags, manual: '' }
|
|
||||||
const attributes = {
|
|
||||||
title: state.title,
|
|
||||||
thumbnailBase: state.thumbnail_base,
|
|
||||||
originalCreatedFrom: state.original_created_from,
|
|
||||||
originalCreatedBefore: state.original_created_before,
|
|
||||||
videoMs: manual.has ('duration') ? '' : state.video_ms,
|
|
||||||
duration: state.duration,
|
|
||||||
tags: state.tags,
|
|
||||||
parentPostIds: state.parent_post_ids }
|
|
||||||
|
|
||||||
return {
|
|
||||||
sourceRow: state.source_row,
|
|
||||||
url: state.url,
|
|
||||||
attributes,
|
|
||||||
fieldWarnings: { },
|
|
||||||
baseWarnings: [],
|
|
||||||
validationErrors: { },
|
|
||||||
provenance,
|
|
||||||
tagSources,
|
|
||||||
status: 'pending',
|
|
||||||
skipReason: state.skip ?? undefined,
|
|
||||||
existingPostId: state.existing_post_id ?? undefined,
|
|
||||||
resetSnapshot: {
|
|
||||||
url: state.url,
|
|
||||||
attributes: { ...attributes },
|
|
||||||
provenance: { ...provenance },
|
|
||||||
tagSources: { ...tagSources },
|
|
||||||
fieldWarnings: { },
|
|
||||||
baseWarnings: [] },
|
|
||||||
createdPostId: state.created_post_id ?? undefined,
|
|
||||||
importStatus: state.import_status ?? undefined,
|
|
||||||
recoverable: state.recoverable ?? undefined }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const parseManual = (value: string | null): string[] =>
|
|
||||||
(value ?? '')
|
|
||||||
.split (',')
|
|
||||||
.map (entry => entry.trim ())
|
|
||||||
.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[] =>
|
|
||||||
Array.isArray (value) && value.every (entry => typeof entry === 'string')
|
|
||||||
|
|
||||||
|
|
||||||
const parseEncodedRow = (
|
|
||||||
value: unknown,
|
|
||||||
requireUrl: boolean,
|
|
||||||
): FullRowState | null => {
|
|
||||||
if (!(isPlainObject (value)))
|
|
||||||
return null
|
|
||||||
|
|
||||||
const row = value as Record<string, unknown>
|
|
||||||
if (!(hasOnlyKeys (row, ENCODED_ROW_KEYS)))
|
|
||||||
return null
|
|
||||||
const url = row['u']
|
|
||||||
const manual = row['m']
|
|
||||||
const skip = decodeSkip (row['s'])
|
|
||||||
const importStatus = decodeImportStatus (row['i'])
|
|
||||||
const existingPostId = row['e']
|
|
||||||
const createdPostId = row['c']
|
|
||||||
const recoverable = row['r']
|
|
||||||
const sourceRow = row['n']
|
|
||||||
|
|
||||||
if (requireUrl && typeof url !== 'string')
|
|
||||||
return null
|
|
||||||
if (typeof url === 'string' && byteLength (url) > MAX_QUERY_STRING_BYTES)
|
|
||||||
return null
|
|
||||||
if (manual != null && !(isStringArray (manual)))
|
|
||||||
return null
|
|
||||||
if ((manual ?? []).some (field => !(ALLOWED_MANUAL_FIELDS.includes (field as never))))
|
|
||||||
return null
|
|
||||||
if (row['s'] != null && skip == null)
|
|
||||||
return null
|
|
||||||
if (row['i'] != null && importStatus == null)
|
|
||||||
return null
|
|
||||||
if (existingPostId != null
|
|
||||||
&& !(typeof existingPostId === 'number'
|
|
||||||
&& Number.isInteger (existingPostId)
|
|
||||||
&& existingPostId > 0))
|
|
||||||
return null
|
|
||||||
if (createdPostId != null
|
|
||||||
&& !(typeof createdPostId === 'number'
|
|
||||||
&& Number.isInteger (createdPostId)
|
|
||||||
&& createdPostId > 0))
|
|
||||||
return null
|
|
||||||
if (recoverable != null && typeof recoverable !== 'boolean')
|
|
||||||
return null
|
|
||||||
if (sourceRow != null
|
|
||||||
&& !(typeof sourceRow === 'number'
|
|
||||||
&& Number.isInteger (sourceRow)
|
|
||||||
&& sourceRow > 0))
|
|
||||||
return null
|
|
||||||
|
|
||||||
const stringFields = [
|
|
||||||
['t', 'title'],
|
|
||||||
['h', 'thumbnail_base'],
|
|
||||||
['f', 'original_created_from'],
|
|
||||||
['b', 'original_created_before'],
|
|
||||||
['x', 'video_ms'],
|
|
||||||
['d', 'duration'],
|
|
||||||
['g', 'tags'],
|
|
||||||
['p', 'parent_post_ids']] as const
|
|
||||||
let parsedStrings: Record<(typeof stringFields)[number][1], string>
|
|
||||||
try
|
|
||||||
{
|
|
||||||
parsedStrings = Object.fromEntries (
|
|
||||||
stringFields.map (([key, target]) => {
|
|
||||||
const fieldValue = row[key]
|
|
||||||
if (fieldValue != null && typeof fieldValue !== 'string')
|
|
||||||
throw new Error ('invalid row')
|
|
||||||
if (typeof fieldValue === 'string' && byteLength (fieldValue) > MAX_QUERY_STRING_BYTES)
|
|
||||||
throw new Error ('invalid row')
|
|
||||||
return [target, fieldValue ?? '']
|
|
||||||
}),
|
|
||||||
) as Record<(typeof stringFields)[number][1], string>
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
source_row:
|
|
||||||
typeof sourceRow === 'number'
|
|
||||||
? sourceRow
|
|
||||||
: 1,
|
|
||||||
url: typeof url === 'string' ? url : '',
|
|
||||||
title: parsedStrings.title,
|
|
||||||
thumbnail_base: parsedStrings.thumbnail_base,
|
|
||||||
original_created_from: parsedStrings.original_created_from,
|
|
||||||
original_created_before: parsedStrings.original_created_before,
|
|
||||||
video_ms: parsedStrings.video_ms,
|
|
||||||
duration: parsedStrings.duration,
|
|
||||||
tags: parsedStrings.tags,
|
|
||||||
parent_post_ids: parsedStrings.parent_post_ids,
|
|
||||||
manual: manual ?? [],
|
|
||||||
skip,
|
|
||||||
existing_post_id: existingPostId ?? null,
|
|
||||||
import_status: importStatus,
|
|
||||||
created_post_id: createdPostId ?? null,
|
|
||||||
recoverable: recoverable ?? null }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const parseSingleRow = (params: URLSearchParams): ParsedPostNewState | null => {
|
|
||||||
const url = params.get ('url')
|
|
||||||
if (url == null || url === '')
|
|
||||||
return null
|
|
||||||
if (byteLength (url) > MAX_QUERY_STRING_BYTES)
|
|
||||||
return null
|
|
||||||
|
|
||||||
const hasOnlyUrl = Array.from (params.keys ()).every (
|
|
||||||
key => key === 'url' || key === 'session_id')
|
|
||||||
if (hasOnlyUrl)
|
|
||||||
{
|
{
|
||||||
const row = buildRow ({
|
if (segment === 'urls')
|
||||||
source_row: 1,
|
return ''
|
||||||
url,
|
if (segment.startsWith ('urls='))
|
||||||
title: '',
|
return segment.slice ('urls='.length)
|
||||||
thumbnail_base: '',
|
|
||||||
original_created_from: '',
|
|
||||||
original_created_before: '',
|
|
||||||
video_ms: '',
|
|
||||||
duration: '',
|
|
||||||
tags: '',
|
|
||||||
parent_post_ids: '',
|
|
||||||
manual: [],
|
|
||||||
skip: null,
|
|
||||||
existing_post_id: null,
|
|
||||||
import_status: null,
|
|
||||||
created_post_id: null,
|
|
||||||
recoverable: null })
|
|
||||||
return {
|
|
||||||
rows: [row],
|
|
||||||
source: url,
|
|
||||||
repairMode: 'all',
|
|
||||||
shortcut: true }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(Array.from (params.keys ()).every (key => SINGLE_ROW_KEYS.includes (key as never))))
|
|
||||||
return null
|
|
||||||
|
|
||||||
const skipValue = params.get ('skip')
|
|
||||||
const importStatus = params.get ('import_status')
|
|
||||||
const recoverable =
|
|
||||||
params.get ('recoverable') == null
|
|
||||||
? null
|
|
||||||
: params.get ('recoverable') === 'true'
|
|
||||||
if (skipValue != null && !(isValidSkipReason (skipValue)))
|
|
||||||
return null
|
|
||||||
if (importStatus != null && !(isValidImportStatus (importStatus)))
|
|
||||||
return null
|
|
||||||
if (!(hasOnlyAllowedManualFields (parseManual (params.get ('manual')))))
|
|
||||||
return null
|
|
||||||
|
|
||||||
const row = buildRow ({
|
|
||||||
source_row: 1,
|
|
||||||
url,
|
|
||||||
title: params.get ('title') ?? '',
|
|
||||||
thumbnail_base: params.get ('thumbnail_base') ?? '',
|
|
||||||
original_created_from: params.get ('original_created_from') ?? '',
|
|
||||||
original_created_before: params.get ('original_created_before') ?? '',
|
|
||||||
video_ms: params.get ('video_ms') ?? '',
|
|
||||||
duration: params.get ('duration') ?? '',
|
|
||||||
tags: params.get ('tags') ?? '',
|
|
||||||
parent_post_ids: params.get ('parent_post_ids') ?? '',
|
|
||||||
manual: parseManual (params.get ('manual')),
|
|
||||||
skip: skipValue,
|
|
||||||
existing_post_id: parsePositiveInt (params.get ('existing_post_id')),
|
|
||||||
import_status: importStatus,
|
|
||||||
created_post_id: parsePositiveInt (params.get ('created_post_id')),
|
|
||||||
recoverable })
|
|
||||||
|
|
||||||
return {
|
|
||||||
rows: [row],
|
|
||||||
source: url,
|
|
||||||
repairMode: resultRepairMode ([row]),
|
|
||||||
shortcut: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const parseMetaRows = (
|
|
||||||
urlsValue: string,
|
|
||||||
metaValue: string,
|
|
||||||
): ParsedPostNewState | null => {
|
|
||||||
const decodedBytes = decodeBase64UrlBytes (metaValue)
|
|
||||||
if (decodedBytes == null)
|
|
||||||
return null
|
|
||||||
|
|
||||||
let parsed: MultiRowMetaState
|
|
||||||
try
|
|
||||||
{
|
|
||||||
parsed = JSON.parse (textDecoder.decode (decodedBytes)) as MultiRowMetaState
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsed.v !== 1 || !(Array.isArray (parsed.rows)))
|
|
||||||
return null
|
|
||||||
|
|
||||||
const urls = urlsValue.split (' ').filter (url => url !== '')
|
|
||||||
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
|
|
||||||
|
|
||||||
const fullRows = parsed.rows.map ((row, index) => {
|
|
||||||
const parsedRow = parseEncodedRow (row, false)
|
|
||||||
return parsedRow == null
|
|
||||||
? null
|
|
||||||
: { ...parsedRow, url: urls[index] ?? '' }
|
|
||||||
})
|
|
||||||
if (fullRows.some (row => row == null))
|
|
||||||
return null
|
|
||||||
if ((new Set (fullRows.map (row => (row as FullRowState).source_row))).size !== fullRows.length)
|
|
||||||
return null
|
|
||||||
|
|
||||||
const rows = fullRows.map (row => buildRow (row as FullRowState))
|
|
||||||
return {
|
|
||||||
rows,
|
|
||||||
source: urls.join ('\n'),
|
|
||||||
repairMode: resultRepairMode (rows),
|
|
||||||
shortcut: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const parseFullState = async (stateValue: string): Promise<ParsedPostNewState | null> => {
|
|
||||||
const decoded = await decodeCompressedState (stateValue)
|
|
||||||
if (decoded == null)
|
|
||||||
return null
|
|
||||||
|
|
||||||
let parsed: EncodedState
|
|
||||||
try
|
|
||||||
{
|
|
||||||
parsed = JSON.parse (decoded) as EncodedState
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsed.v !== 1
|
|
||||||
|| !(Array.isArray (parsed.rows))
|
|
||||||
|| parsed.rows.length === 0
|
|
||||||
|| parsed.rows.length > MAX_STATE_ROWS)
|
|
||||||
return null
|
|
||||||
|
|
||||||
const fullRows = parsed.rows.map (row => parseEncodedRow (row, true))
|
|
||||||
if (fullRows.some (row => row == null))
|
|
||||||
return null
|
|
||||||
if ((new Set (fullRows.map (row => (row as FullRowState).source_row))).size !== fullRows.length)
|
|
||||||
return null
|
|
||||||
|
|
||||||
const rows = fullRows.map (row => buildRow (row as FullRowState))
|
|
||||||
return {
|
|
||||||
rows,
|
|
||||||
source: rows.map (row => row.url).join ('\n'),
|
|
||||||
repairMode: resultRepairMode (rows),
|
|
||||||
shortcut: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const regularQuery = (rows: PostImportRow[]): URLSearchParams => {
|
|
||||||
if (rows.length === 1)
|
|
||||||
{
|
|
||||||
const row = rows[0]
|
|
||||||
const params = new URLSearchParams ()
|
|
||||||
params.set ('url', row?.url ?? '')
|
|
||||||
params.set ('title', String (row?.attributes.title ?? ''))
|
|
||||||
params.set ('thumbnail_base', String (row?.attributes.thumbnailBase ?? ''))
|
|
||||||
params.set ('original_created_from', String (row?.attributes.originalCreatedFrom ?? ''))
|
|
||||||
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 ('tags', String (row?.attributes.tags ?? ''))
|
|
||||||
params.set ('parent_post_ids', String (row?.attributes.parentPostIds ?? ''))
|
|
||||||
const manual = manualFields (row).join (',')
|
|
||||||
if (manual !== '')
|
|
||||||
params.set ('manual', manual)
|
|
||||||
if (row.skipReason != null)
|
|
||||||
params.set ('skip', row.skipReason)
|
|
||||||
if (row.existingPostId != null)
|
|
||||||
params.set ('existing_post_id', String (row.existingPostId))
|
|
||||||
if (row.importStatus != null)
|
|
||||||
params.set ('import_status', row.importStatus)
|
|
||||||
if (row.createdPostId != null)
|
|
||||||
params.set ('created_post_id', String (row.createdPostId))
|
|
||||||
if (row.recoverable != null)
|
|
||||||
params.set ('recoverable', row.recoverable ? 'true' : 'false')
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = new URLSearchParams ()
|
|
||||||
params.set ('urls', rows.map (row => row.url).join (' '))
|
|
||||||
params.set ('meta', encodeBase64UrlBytes (textEncoder.encode (JSON.stringify ({
|
|
||||||
v: 1,
|
|
||||||
rows: rows.map (row => encodeRowState (row, false)) }))))
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const encodeRowState = (
|
|
||||||
row: PostImportRow,
|
|
||||||
includeUrl: boolean,
|
|
||||||
): EncodedRowState => {
|
|
||||||
const base = baseRowState (row)
|
|
||||||
const encoded: EncodedRowState = includeUrl ? { u: row.url } : { }
|
|
||||||
|
|
||||||
encoded.n = base.source_row
|
|
||||||
if (base.title !== '')
|
|
||||||
encoded.t = base.title
|
|
||||||
if (base.thumbnail_base !== '')
|
|
||||||
encoded.h = base.thumbnail_base
|
|
||||||
if (base.original_created_from !== '')
|
|
||||||
encoded.f = base.original_created_from
|
|
||||||
if (base.original_created_before !== '')
|
|
||||||
encoded.b = base.original_created_before
|
|
||||||
if (base.video_ms !== '')
|
|
||||||
encoded.x = base.video_ms
|
|
||||||
if (base.duration !== '')
|
|
||||||
encoded.d = base.duration
|
|
||||||
if (base.tags !== '')
|
|
||||||
encoded.g = base.tags
|
|
||||||
if (base.parent_post_ids !== '')
|
|
||||||
encoded.p = base.parent_post_ids
|
|
||||||
if (base.manual.length > 0)
|
|
||||||
encoded.m = [...base.manual]
|
|
||||||
if (base.skip != null)
|
|
||||||
encoded.s = encodeSkip (base.skip)
|
|
||||||
if (base.existing_post_id != null)
|
|
||||||
encoded.e = base.existing_post_id
|
|
||||||
if (base.import_status != null)
|
|
||||||
encoded.i = encodeImportStatus (base.import_status)
|
|
||||||
if (base.created_post_id != null)
|
|
||||||
encoded.c = base.created_post_id
|
|
||||||
if (base.recoverable != null)
|
|
||||||
encoded.r = base.recoverable
|
|
||||||
|
|
||||||
return encoded
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const encodeStateRows = (rows: PostImportRow[]): EncodedState => ({
|
|
||||||
v: 1,
|
|
||||||
rows: rows.map (row => encodeRowState (row, true)) })
|
|
||||||
|
|
||||||
|
|
||||||
export const hasPostNewReviewState = (search: string): boolean => {
|
|
||||||
const params = new URLSearchParams (search)
|
|
||||||
return params.has ('state')
|
|
||||||
|| params.has ('urls')
|
|
||||||
|| params.has ('meta')
|
|
||||||
|| 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 }`
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const parsePostNewState = async (
|
|
||||||
search: string,
|
|
||||||
): Promise<ParsedPostNewState | null> => {
|
|
||||||
const params = new URLSearchParams (search)
|
|
||||||
const stateValue = params.get ('state')
|
|
||||||
if (stateValue != null)
|
|
||||||
return await parseFullState (stateValue)
|
|
||||||
|
|
||||||
const urlsValue = params.get ('urls')
|
|
||||||
const metaValue = params.get ('meta')
|
|
||||||
if (urlsValue != null || metaValue != null)
|
|
||||||
{
|
|
||||||
if (urlsValue == null || metaValue == null)
|
|
||||||
return null
|
|
||||||
return parseMetaRows (urlsValue, metaValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
return parseSingleRow (params)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const serialiseCompressedRows = async (
|
|
||||||
rows: PostImportRow[],
|
|
||||||
): Promise<SerialisedPostNewState | null> => {
|
|
||||||
try
|
|
||||||
{
|
|
||||||
for (let count = rows.length; count > 0; --count)
|
|
||||||
{
|
|
||||||
const nextRows = rows.slice (0, count)
|
|
||||||
const encoded = encodeStateRows (nextRows)
|
|
||||||
const state = await encodeCompressedState (JSON.stringify (encoded))
|
|
||||||
const path = `/posts/new?state=${ state }`
|
|
||||||
if (path.length <= MAX_COMPRESSED_STATE_LENGTH)
|
|
||||||
return {
|
|
||||||
path,
|
|
||||||
complete: count === rows.length }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const serialisePostNewState = async (
|
export const buildPostNewReviewPath = (urls: string[]): string =>
|
||||||
rows: PostImportRow[],
|
`${ POST_NEW_REVIEW_PATH_PREFIX }${ urls.map (url => encodeURIComponent (url)).join ('+') }`
|
||||||
): Promise<SerialisedPostNewState | null> => {
|
|
||||||
const regular = regularQuery (rows).toString ()
|
|
||||||
const regularPath = `/posts/new?${ regular }`
|
|
||||||
if (regularPath.length < MAX_REGULAR_URL_LENGTH)
|
|
||||||
return {
|
|
||||||
path: regularPath,
|
|
||||||
complete: true }
|
|
||||||
|
|
||||||
return await serialiseCompressedRows (rows)
|
|
||||||
|
export const postNewReviewPathByteLength = (urls: string[]): number =>
|
||||||
|
textEncoder.encode (buildPostNewReviewPath (urls)).byteLength
|
||||||
|
|
||||||
|
|
||||||
|
export const isPostNewReviewPathWithinLimit = (urls: string[]): boolean =>
|
||||||
|
postNewReviewPathByteLength (urls) < MAX_POST_NEW_REVIEW_TARGET_BYTES
|
||||||
|
|
||||||
|
|
||||||
|
export const parsePostNewReviewUrls = (search: string): string[] => {
|
||||||
|
const raw = rawUrlsParam (search)
|
||||||
|
if (raw == null)
|
||||||
|
return []
|
||||||
|
|
||||||
|
return raw
|
||||||
|
.split ('+')
|
||||||
|
.filter (segment => segment !== '')
|
||||||
|
.map (segment => {
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return decodeURIComponent (segment)
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return segment
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const hasPostNewReviewState = (search: string): boolean =>
|
||||||
|
rawUrlsParam (search) != null
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import { useBlocker, useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
import { useDialogue } from '@/components/dialogues/DialogueProvider'
|
import { useDialogue } from '@/lib/dialogues/useDialogue'
|
||||||
|
|
||||||
import type { FC, PropsWithChildren } from 'react'
|
import type { FC, PropsWithChildren } from 'react'
|
||||||
|
|
||||||
@@ -8,12 +17,17 @@ type UnsavedChangesSource = {
|
|||||||
dirty: boolean
|
dirty: boolean
|
||||||
discard: () => void | Promise<void> }
|
discard: () => void | Promise<void> }
|
||||||
|
|
||||||
|
type UseUnsavedChangesGuardOptions = {
|
||||||
|
dirty: boolean
|
||||||
|
onDiscard?: () => void | Promise<void> }
|
||||||
|
|
||||||
type UnsavedChangesGuardContextValue = {
|
type UnsavedChangesGuardContextValue = {
|
||||||
hasUnsavedChanges: boolean
|
hasUnsavedChanges: boolean
|
||||||
registerUnsavedChangesSource: (
|
registerUnsavedChangesSource: (
|
||||||
source: UnsavedChangesSource | null,
|
source: UnsavedChangesSource | null,
|
||||||
) => void
|
) => void
|
||||||
confirmDiscardNavigation: () => Promise<boolean> }
|
confirmDiscardNavigation: () => Promise<boolean>
|
||||||
|
allowNextNavigation: () => void }
|
||||||
|
|
||||||
const UnsavedChangesGuardContext =
|
const UnsavedChangesGuardContext =
|
||||||
createContext<UnsavedChangesGuardContextValue | null> (null)
|
createContext<UnsavedChangesGuardContextValue | null> (null)
|
||||||
@@ -21,7 +35,9 @@ const UnsavedChangesGuardContext =
|
|||||||
|
|
||||||
export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children }) => {
|
export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||||
const dialogue = useDialogue ()
|
const dialogue = useDialogue ()
|
||||||
|
const location = useLocation ()
|
||||||
const [source, setSource] = useState<UnsavedChangesSource | null> (null)
|
const [source, setSource] = useState<UnsavedChangesSource | null> (null)
|
||||||
|
const bypassNextNavigationRef = useRef (false)
|
||||||
|
|
||||||
const registerUnsavedChangesSource = useCallback ((
|
const registerUnsavedChangesSource = useCallback ((
|
||||||
nextSource: UnsavedChangesSource | null,
|
nextSource: UnsavedChangesSource | null,
|
||||||
@@ -29,28 +45,81 @@ export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children })
|
|||||||
setSource (nextSource)
|
setSource (nextSource)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const allowNextNavigation = useCallback (() => {
|
||||||
|
bypassNextNavigationRef.current = true
|
||||||
|
}, [])
|
||||||
|
|
||||||
const confirmDiscardNavigation = useCallback (async (): Promise<boolean> => {
|
const confirmDiscardNavigation = useCallback (async (): Promise<boolean> => {
|
||||||
if (!(source?.dirty))
|
if (!(source?.dirty))
|
||||||
return true
|
return true
|
||||||
|
|
||||||
const confirmed = await dialogue.confirm ({
|
const confirmed = await dialogue.confirm ({
|
||||||
title: '未保存の変更があります',
|
title: '変更が破棄してページ移動しますか?',
|
||||||
description: 'このまま移動すると、保存していない変更は失われます。',
|
|
||||||
cancelText: 'このページに残る',
|
|
||||||
confirmText: '変更を破棄して移動',
|
confirmText: '変更を破棄して移動',
|
||||||
variant: 'danger' })
|
variant: 'danger' })
|
||||||
if (!(confirmed))
|
if (!(confirmed))
|
||||||
return false
|
return false
|
||||||
|
|
||||||
|
bypassNextNavigationRef.current = true
|
||||||
await source.discard ()
|
await source.discard ()
|
||||||
return true
|
return true
|
||||||
}, [dialogue, source])
|
}, [dialogue, source])
|
||||||
|
|
||||||
|
const blocker = useBlocker (() =>
|
||||||
|
source?.dirty === true && !(bypassNextNavigationRef.current))
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
if (blocker.state !== 'blocked')
|
||||||
|
return
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const confirmed = await confirmDiscardNavigation ()
|
||||||
|
if (cancelled)
|
||||||
|
return
|
||||||
|
|
||||||
|
if (confirmed)
|
||||||
|
blocker.proceed ()
|
||||||
|
else
|
||||||
|
blocker.reset ()
|
||||||
|
}) ()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [blocker, confirmDiscardNavigation])
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
bypassNextNavigationRef.current = false
|
||||||
|
}, [location.hash, location.pathname, location.search])
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
if (!(source?.dirty))
|
||||||
|
return
|
||||||
|
|
||||||
|
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||||
|
event.preventDefault ()
|
||||||
|
event.returnValue = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener ('beforeunload', handleBeforeUnload)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener ('beforeunload', handleBeforeUnload)
|
||||||
|
}
|
||||||
|
}, [source?.dirty])
|
||||||
|
|
||||||
const value = useMemo<UnsavedChangesGuardContextValue> (() => ({
|
const value = useMemo<UnsavedChangesGuardContextValue> (() => ({
|
||||||
hasUnsavedChanges: source?.dirty === true,
|
hasUnsavedChanges: source?.dirty === true,
|
||||||
registerUnsavedChangesSource,
|
registerUnsavedChangesSource,
|
||||||
confirmDiscardNavigation,
|
confirmDiscardNavigation,
|
||||||
}), [confirmDiscardNavigation, registerUnsavedChangesSource, source?.dirty])
|
allowNextNavigation,
|
||||||
|
}), [
|
||||||
|
allowNextNavigation,
|
||||||
|
confirmDiscardNavigation,
|
||||||
|
registerUnsavedChangesSource,
|
||||||
|
source?.dirty,
|
||||||
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UnsavedChangesGuardContext.Provider value={value}>
|
<UnsavedChangesGuardContext.Provider value={value}>
|
||||||
@@ -59,11 +128,26 @@ export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children })
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const useUnsavedChangesGuard = (): UnsavedChangesGuardContextValue => {
|
export const useUnsavedChangesGuard = (
|
||||||
|
options?: UseUnsavedChangesGuardOptions,
|
||||||
|
): UnsavedChangesGuardContextValue => {
|
||||||
const context = useContext (UnsavedChangesGuardContext)
|
const context = useContext (UnsavedChangesGuardContext)
|
||||||
|
|
||||||
if (context == null)
|
if (context == null)
|
||||||
throw new Error ('UnsavedChangesGuardProvider が必要です.')
|
throw new Error ('UnsavedChangesGuardProvider が必要です.')
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
if (options == null)
|
||||||
|
return
|
||||||
|
|
||||||
|
context.registerUnsavedChangesSource ({
|
||||||
|
dirty: options.dirty,
|
||||||
|
discard: options.onDiscard ?? (() => undefined) })
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
context.registerUnsavedChangesSource (null)
|
||||||
|
}
|
||||||
|
}, [context, options?.dirty, options?.onDiscard])
|
||||||
|
|
||||||
return context
|
return context
|
||||||
}
|
}
|
||||||
|
|||||||
ファイル差分が大きすぎるため省略します
差分を読込み
@@ -12,26 +12,23 @@ 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 {
|
import {
|
||||||
appendPostNewSessionId,
|
buildPostNewReviewPath,
|
||||||
serialisePostNewState,
|
isPostNewReviewPathWithinLimit,
|
||||||
} from '@/lib/postNewQueryState'
|
} from '@/lib/postNewQueryState'
|
||||||
|
import {
|
||||||
|
countImportSourceLines,
|
||||||
|
extractImportSourceUrls,
|
||||||
|
validateImportSource,
|
||||||
|
} from '@/lib/postImportSourceValidation'
|
||||||
|
import {
|
||||||
|
loadPostImportSourceDraft,
|
||||||
|
savePostImportSourceDraft,
|
||||||
|
} from '@/lib/postImportStorage'
|
||||||
import { canEditContent } from '@/lib/users'
|
import { canEditContent } from '@/lib/users'
|
||||||
import { countImportSourceLines,
|
|
||||||
generatePostImportSessionId,
|
|
||||||
cleanupExpiredPostImportSessions,
|
|
||||||
initialisePreviewRows,
|
|
||||||
loadPostImportSession,
|
|
||||||
loadPostImportSourceDraft,
|
|
||||||
resultRepairMode,
|
|
||||||
serialisedPostImportRowsEqual,
|
|
||||||
savePostImportSession,
|
|
||||||
savePostImportSourceDraft,
|
|
||||||
validateImportSource } from '@/lib/postImportSession'
|
|
||||||
import Forbidden from '@/pages/Forbidden'
|
import Forbidden from '@/pages/Forbidden'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
import type { PostImportRow } from '@/lib/postImportSession'
|
|
||||||
import type { User } from '@/types'
|
import type { User } from '@/types'
|
||||||
|
|
||||||
type Props = { user: User | null }
|
type Props = { user: User | null }
|
||||||
@@ -40,69 +37,6 @@ 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 buildInitialRows = (source: string): PostImportRow[] =>
|
|
||||||
source
|
|
||||||
.split (/\r\n|\n|\r/)
|
|
||||||
.map ((line, index) => ({
|
|
||||||
sourceRow: index + 1,
|
|
||||||
url: line.trim () }))
|
|
||||||
.filter (row => row.url !== '')
|
|
||||||
.map (row => ({
|
|
||||||
sourceRow: row.sourceRow,
|
|
||||||
url: 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 }) => {
|
||||||
const editable = canEditContent (user)
|
const editable = canEditContent (user)
|
||||||
@@ -115,6 +49,8 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
|||||||
const editedRef = useRef (false)
|
const editedRef = useRef (false)
|
||||||
|
|
||||||
const lineCount = countImportSourceLines (source)
|
const lineCount = countImportSourceLines (source)
|
||||||
|
const sourceUrls = extractImportSourceUrls (source)
|
||||||
|
const withinPathLimit = isPostNewReviewPathWithinLimit (sourceUrls)
|
||||||
const messages = sourceError != null ? [sourceError] : []
|
const messages = sourceError != null ? [sourceError] : []
|
||||||
const sourceDescribedBy =
|
const sourceDescribedBy =
|
||||||
[sourceError != null ? SOURCE_ERROR_ID : null,
|
[sourceError != null ? SOURCE_ERROR_ID : null,
|
||||||
@@ -122,13 +58,7 @@ 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 =>
|
|
||||||
toast ({ title: '保存済みデータを整理できませんでした', description: message }))
|
|
||||||
|
|
||||||
const draft = loadPostImportSourceDraft (message =>
|
const draft = loadPostImportSourceDraft (message =>
|
||||||
toast ({ title: '保存済み入力を復元できませんでした', description: message }))
|
toast ({ title: '保存済み入力を復元できませんでした', description: message }))
|
||||||
|
|
||||||
@@ -171,73 +101,15 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
|||||||
setSourceError (null)
|
setSourceError (null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!(withinPathLimit))
|
||||||
|
return
|
||||||
|
|
||||||
setLoading (true)
|
setLoading (true)
|
||||||
setSourceIssues ([])
|
setSourceIssues ([])
|
||||||
setSourceError (null)
|
setSourceError (null)
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const nextRows = initialisePreviewRows (buildInitialRows (source))
|
navigate (buildPostNewReviewPath (sourceUrls))
|
||||||
const serialised = await serialisePostNewState (nextRows)
|
|
||||||
const sessionId = (() => {
|
|
||||||
try
|
|
||||||
{
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextPath = (() => {
|
|
||||||
if (serialised != null)
|
|
||||||
{
|
|
||||||
return sessionId == null
|
|
||||||
? serialised.path
|
|
||||||
: appendPostNewSessionId (serialised.path, sessionId)
|
|
||||||
}
|
|
||||||
if (sessionRoundTripSucceeded && sessionId != null)
|
|
||||||
return `/posts/new?session_id=${ sessionId }`
|
|
||||||
return null
|
|
||||||
}) ()
|
|
||||||
if (nextPath == null)
|
|
||||||
{
|
|
||||||
showStorageError ('ブラウザへ保存できませんでした.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
navigate (nextPath)
|
|
||||||
}
|
|
||||||
catch (error)
|
|
||||||
{
|
|
||||||
console.error (error)
|
|
||||||
showStorageError ('ブラウザへ保存できませんでした.')
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -256,7 +128,7 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
|||||||
|
|
||||||
<Form className="max-w-4xl">
|
<Form className="max-w-4xl">
|
||||||
<PageTitle>広場に投稿を追加</PageTitle>
|
<PageTitle>広場に投稿を追加</PageTitle>
|
||||||
<FormField label="URL リスト">
|
<FormField label="URL リスト(1 行 1 URL)">
|
||||||
{() => (
|
{() => (
|
||||||
<TextArea
|
<TextArea
|
||||||
value={source}
|
value={source}
|
||||||
@@ -266,8 +138,9 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
|||||||
className="h-80 font-mono text-sm"
|
className="h-80 font-mono text-sm"
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
savePostImportSourceDraft (source, message =>
|
savePostImportSourceDraft (source, message =>
|
||||||
toast ({ title: '入力内容を保存できませんでした',
|
toast ({
|
||||||
description: message }))
|
title: '入力内容を保存できませんでした',
|
||||||
|
description: message }))
|
||||||
}}
|
}}
|
||||||
onChange={ev => {
|
onChange={ev => {
|
||||||
editedRef.current = true
|
editedRef.current = true
|
||||||
@@ -297,7 +170,7 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
|||||||
type="button"
|
type="button"
|
||||||
className="pointer-events-auto shrink-0"
|
className="pointer-events-auto shrink-0"
|
||||||
onClick={preview}
|
onClick={preview}
|
||||||
disabled={loading}>
|
disabled={loading || !(withinPathLimit)}>
|
||||||
次へ
|
次へ
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする