コミットを比較
11 コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| cde0a2deae | |||
| 43a3772976 | |||
| 90d8d3ff08 | |||
| d1de631eed | |||
| 6f3ccb0ffa | |||
| 95c3f08a44 | |||
| ef95b20a7e | |||
| f76fbe6711 | |||
| 0224c4d2f4 | |||
| ead825e3ee | |||
| 3d88fdecb1 |
@@ -211,6 +211,13 @@ records.each {
|
||||
- In TypeScript and TSX, block bodies for components, functions, callbacks,
|
||||
`if`, `try`, `catch`, `finally`, loops, and JSX nesting use 2 spaces per
|
||||
level.
|
||||
- In TypeScript and TSX, put the opening brace of `try`, `catch`, and
|
||||
`finally` blocks on the next line at the same indentation as the keyword.
|
||||
- Do not indent the opening `{` one level deeper than `try`, `catch`, or
|
||||
`finally`.
|
||||
- Indent the block body 2 spaces deeper than the keyword and opening brace.
|
||||
- Put the closing `}` on its own line at the same indentation as the keyword.
|
||||
- Do not write `try {`, `catch {`, or `finally {`.
|
||||
- In TypeScript and TSX, use 4-space continuation indentation for wrapped
|
||||
expressions, arguments, conditions, arrays, object literals, JSX
|
||||
attributes, and similar continuations.
|
||||
@@ -274,6 +281,10 @@ case 'no':
|
||||
- In TypeScript and TSX, use `value == null` and `value != null` as the
|
||||
default nullish checks. Do not use `=== null`, `=== undefined`,
|
||||
`!== null`, or `!== undefined`.
|
||||
- In JavaScript, JSX, TypeScript, and TSX, never use `_1`, `_2`, or similar
|
||||
Ruby-style numbered parameter names. Reserve numbered parameters for Ruby.
|
||||
Use a meaningful callback parameter name such as `row`, `item`, `value`,
|
||||
`entry`, or `result`.
|
||||
- If code appears to need a distinction between `null` and `undefined`, treat
|
||||
that as a design smell and revise the logic to avoid the distinction.
|
||||
External library APIs that explicitly require distinguishing the two are the
|
||||
@@ -578,6 +589,13 @@ and layout reuse, follow `frontend/AGENTS.md`.
|
||||
declarations, unless imports, exports, or file boundaries make that awkward.
|
||||
- In TSX, use 2-space block indentation and 4-space continuation
|
||||
indentation.
|
||||
- In TypeScript and TSX, put the opening brace of `try`, `catch`, and
|
||||
`finally` blocks on the next line at the same indentation as the keyword.
|
||||
- Do not indent the opening `{` one level deeper than `try`, `catch`, or
|
||||
`finally`.
|
||||
- Indent the block body 2 spaces deeper than the keyword and opening brace.
|
||||
- Put the closing `}` on its own line at the same indentation as the keyword.
|
||||
- Do not write `try {`, `catch {`, or `finally {`.
|
||||
- In TypeScript and TSX, convert every complete leading run of 8 spaces to a
|
||||
tab character.
|
||||
- A leading tab is exactly equivalent to 8 leading spaces.
|
||||
@@ -628,6 +646,22 @@ and layout reuse, follow `frontend/AGENTS.md`.
|
||||
single physical line.
|
||||
- Always add braces around `if`, `else`, or `for` bodies when the body spans
|
||||
two or more physical lines, even if it is one statement.
|
||||
- `try` / `catch` / `finally` brace placement example:
|
||||
|
||||
```ts
|
||||
try
|
||||
{
|
||||
doWork ()
|
||||
}
|
||||
catch
|
||||
{
|
||||
recover ()
|
||||
}
|
||||
finally
|
||||
{
|
||||
cleanUp ()
|
||||
}
|
||||
```
|
||||
- Do not use a leading semicolon for expression statements such as
|
||||
`;([...]).forEach(...)`; rewrite the expression to avoid ASI hazards
|
||||
explicitly, for example with `void`.
|
||||
|
||||
+167
-10
@@ -1,11 +1,23 @@
|
||||
class Post < ApplicationRecord
|
||||
require 'date'
|
||||
require 'mini_magick'
|
||||
require 'stringio'
|
||||
|
||||
class RemoteThumbnailFetchFailed < StandardError; end
|
||||
|
||||
ORIGINAL_CREATED_INVALID_MESSAGE = 'オリジナルの作成日時の形式が不正です.'.freeze
|
||||
ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE =
|
||||
'オリジナルの作成日時は分単位で入力してください.'.freeze
|
||||
ORIGINAL_CREATED_ORDER_MESSAGE = 'オリジナルの作成日時の順番がをかしぃです.'.freeze
|
||||
ORIGINAL_CREATED_MINIMUM_RANGE_MESSAGE =
|
||||
'オリジナルの作成日時の範囲は1分以上必要です.'.freeze
|
||||
|
||||
def self.resized_thumbnail_attachment(upload)
|
||||
upload.rewind
|
||||
image = MiniMagick::Image.read(upload.read)
|
||||
image.resize '180x180'
|
||||
image.resize '180x180^'
|
||||
image.gravity 'Center'
|
||||
image.extent '180x180'
|
||||
image.format 'jpg'
|
||||
|
||||
{ io: StringIO.new(image.to_blob),
|
||||
@@ -118,7 +130,11 @@ class Post < ApplicationRecord
|
||||
'%d:%02d' % [min, s]
|
||||
end
|
||||
|
||||
remainder_ms.positive? ? "#{ base }.#{ remainder_ms.to_s.rjust(3, '0') }" : base
|
||||
if remainder_ms.positive?
|
||||
"#{ base }.#{ remainder_ms.to_s.rjust(3, '0') }"
|
||||
else
|
||||
base
|
||||
end
|
||||
end
|
||||
|
||||
def snapshot_parent_post_ids = parents.order(:id).pluck(:id)
|
||||
@@ -140,19 +156,36 @@ class Post < ApplicationRecord
|
||||
thumbnail.attach(self.class.resized_thumbnail_attachment(StringIO.new(thumbnail.download)))
|
||||
end
|
||||
|
||||
def attach_thumbnail_from_url! raw_url
|
||||
response = Preview::ThumbnailFetcher.fetch_image_response(raw_url)
|
||||
thumbnail.attach(
|
||||
self.class.resized_thumbnail_attachment(
|
||||
StringIO.new(response.body)))
|
||||
rescue Preview::UrlSafety::UnsafeUrl,
|
||||
Preview::ThumbnailFetcher::GenerationFailed,
|
||||
Preview::HttpFetcher::FetchFailed,
|
||||
Preview::HttpFetcher::FetchTimeout,
|
||||
Preview::HttpFetcher::ResponseTooLarge,
|
||||
MiniMagick::Error => e
|
||||
raise RemoteThumbnailFetchFailed, e.message
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_original_created_range
|
||||
f = original_created_from
|
||||
b = original_created_before
|
||||
return if f.blank? || b.blank?
|
||||
return if skip_original_created_validation?
|
||||
|
||||
f = Time.zone.parse(f) if String === f
|
||||
b = Time.zone.parse(b) if String === b
|
||||
return if !(f) || !(b)
|
||||
f = parse_original_created_value(:original_created_from)
|
||||
b = parse_original_created_value(:original_created_before)
|
||||
return if f.nil? || b.nil?
|
||||
|
||||
if f >= b
|
||||
errors.add :original_created_at, 'オリジナルの作成日時の順番がをかしぃです.'
|
||||
if b <= f
|
||||
errors.add :original_created_at, ORIGINAL_CREATED_ORDER_MESSAGE
|
||||
return
|
||||
end
|
||||
|
||||
if b - f < 1.minute
|
||||
errors.add :original_created_at, ORIGINAL_CREATED_MINIMUM_RANGE_MESSAGE
|
||||
end
|
||||
end
|
||||
|
||||
@@ -175,4 +208,128 @@ class Post < ApplicationRecord
|
||||
|
||||
self.url = PostUrlNormaliser.normalise(url) || url.strip
|
||||
end
|
||||
|
||||
def parse_original_created_value field
|
||||
raw_value = public_send("#{ field }_before_type_cast")
|
||||
value = public_send(field)
|
||||
return nil if raw_value.blank? && value.blank?
|
||||
|
||||
time =
|
||||
case raw_value
|
||||
when String
|
||||
parse_original_created_string(raw_value)
|
||||
when Time, ActiveSupport::TimeWithZone
|
||||
raw_value.in_time_zone
|
||||
else
|
||||
value&.in_time_zone
|
||||
end
|
||||
if time.nil?
|
||||
errors.add field, ORIGINAL_CREATED_INVALID_MESSAGE
|
||||
return nil
|
||||
end
|
||||
unless minute_precision_time?(time)
|
||||
errors.add field, ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE
|
||||
return nil
|
||||
end
|
||||
time
|
||||
end
|
||||
|
||||
def parse_original_created_string raw_value
|
||||
value = raw_value.to_s.strip
|
||||
return nil if value.blank?
|
||||
|
||||
if (match = value.match(/\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})\z/))
|
||||
year = match[1].to_i
|
||||
month = match[2].to_i
|
||||
day = match[3].to_i
|
||||
hour = match[4].to_i
|
||||
minute = match[5].to_i
|
||||
return nil unless valid_original_created_components?(year, month, day, hour, minute, 0)
|
||||
|
||||
return Time.zone.local(year, month, day, hour, minute)
|
||||
end
|
||||
|
||||
match =
|
||||
value.match(
|
||||
/\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/ \
|
||||
'(?::(\d{2})(?:\.(\d+))?)?' \
|
||||
'(Z|[+-]\d{2}:?\d{2})?\z/')
|
||||
return nil if match.nil?
|
||||
|
||||
year = match[1].to_i
|
||||
month = match[2].to_i
|
||||
day = match[3].to_i
|
||||
hour = match[4].to_i
|
||||
minute = match[5].to_i
|
||||
second = match[6]&.to_i || 0
|
||||
fraction = match[7]
|
||||
offset = match[8]
|
||||
return nil unless valid_original_created_components?(year, month, day, hour, minute, second)
|
||||
return nil if offset.present? && !(valid_original_created_offset?(offset))
|
||||
|
||||
if offset.present?
|
||||
return Time.new(
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
hour,
|
||||
minute,
|
||||
second + Rational(parse_original_created_nanoseconds(fraction), 1_000_000_000),
|
||||
normalise_original_created_offset(offset)).in_time_zone
|
||||
end
|
||||
|
||||
Time.zone.local(
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
hour,
|
||||
minute,
|
||||
second).change(nsec: parse_original_created_nanoseconds(fraction))
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
|
||||
def minute_precision_time? value
|
||||
value.sec.zero? && value.nsec.zero?
|
||||
end
|
||||
|
||||
def valid_original_created_components? year, month, day, hour, minute, second
|
||||
return false unless Date.valid_date?(year, month, day)
|
||||
return false unless hour.between?(0, 23)
|
||||
return false unless minute.between?(0, 59)
|
||||
return false unless second.between?(0, 59)
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def valid_original_created_offset? value
|
||||
match = value.match(/\A([+-])(\d{2}):?(\d{2})\z/)
|
||||
return true if value == 'Z'
|
||||
return false if match.nil?
|
||||
|
||||
hours = match[2].to_i
|
||||
minutes = match[3].to_i
|
||||
hours.between?(0, 23) && minutes.between?(0, 59)
|
||||
end
|
||||
|
||||
def parse_original_created_nanoseconds value
|
||||
return 0 if value.blank?
|
||||
|
||||
digits = value[0, 9].ljust(9, '0')
|
||||
Integer(digits, 10)
|
||||
end
|
||||
|
||||
def normalise_original_created_offset value
|
||||
return '+00:00' if value == 'Z'
|
||||
|
||||
value.match?(/\A[+-]\d{2}:\d{2}\z/) ? value : "#{ value[0, 3] }:#{ value[3, 2] }"
|
||||
end
|
||||
|
||||
def skip_original_created_validation?
|
||||
return false if new_record?
|
||||
return false if will_save_change_to_original_created_from?
|
||||
return false if will_save_change_to_original_created_before?
|
||||
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
class PostCreator
|
||||
class VideoMsParseError < ArgumentError; end
|
||||
|
||||
attr_reader :field_warnings
|
||||
|
||||
def initialize actor:, attributes:
|
||||
@actor = actor
|
||||
@attributes = attributes.symbolize_keys
|
||||
@field_warnings = { }
|
||||
end
|
||||
|
||||
def create!
|
||||
@@ -13,8 +16,7 @@ class PostCreator
|
||||
uploaded_user: @actor,
|
||||
original_created_from: @attributes[:original_created_from].presence,
|
||||
original_created_before: @attributes[:original_created_before].presence)
|
||||
thumbnail = @attributes[:thumbnail]
|
||||
post.thumbnail.attach(Post.resized_thumbnail_attachment(thumbnail)) if thumbnail.present?
|
||||
attach_thumbnail!(post)
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
post.save!
|
||||
@@ -34,6 +36,21 @@ class PostCreator
|
||||
|
||||
private
|
||||
|
||||
def attach_thumbnail! post
|
||||
thumbnail = @attributes[:thumbnail]
|
||||
if thumbnail.present?
|
||||
post.thumbnail.attach(Post.resized_thumbnail_attachment(thumbnail))
|
||||
return
|
||||
end
|
||||
|
||||
thumbnail_base = post.thumbnail_base
|
||||
return if thumbnail_base.blank?
|
||||
|
||||
post.attach_thumbnail_from_url!(thumbnail_base)
|
||||
rescue Post::RemoteThumbnailFetchFailed => e
|
||||
@field_warnings[:thumbnail_base] = [e.message]
|
||||
end
|
||||
|
||||
def tag_names = @attributes[:tags].to_s.split
|
||||
|
||||
def parent_post_ids
|
||||
@@ -58,8 +75,6 @@ class PostCreator
|
||||
duration = @attributes[:duration]
|
||||
return nil if duration.blank?
|
||||
|
||||
return duration.to_i if duration.is_a?(Numeric) && duration.to_i.positive?
|
||||
|
||||
value = Tag.time_to_ms!(duration.to_s, tag_name: '動画時間')
|
||||
raise VideoMsParseError unless value.positive?
|
||||
|
||||
|
||||
@@ -157,7 +157,15 @@ class PostImportPreviewer
|
||||
|
||||
def initial_attributes row
|
||||
attributes = row[:attributes]&.stringify_keys || { }
|
||||
FIELDS.to_h { |field| [field, attributes[field].to_s] }
|
||||
FIELDS.to_h { |field|
|
||||
value = attributes[field]
|
||||
normalised =
|
||||
if field == 'duration'
|
||||
normalise_duration_attribute(value)
|
||||
else
|
||||
value.to_s
|
||||
end
|
||||
[field, normalised] }
|
||||
end
|
||||
|
||||
def initial_provenance row
|
||||
@@ -250,16 +258,26 @@ class PostImportPreviewer
|
||||
def sanitise_metadata_time value
|
||||
return nil unless value.is_a?(String)
|
||||
|
||||
time = Time.iso8601(value)
|
||||
time.nsec.zero? ? time.iso8601 : time.iso8601(9)
|
||||
Time.iso8601(value).in_time_zone.change(sec: 0, nsec: 0).iso8601
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
|
||||
def sanitise_metadata_duration value
|
||||
return nil unless value.is_a?(Numeric)
|
||||
return nil unless value.is_a?(String)
|
||||
|
||||
value.positive? ? value.to_i : nil
|
||||
value.presence
|
||||
end
|
||||
|
||||
def normalise_duration_attribute value
|
||||
return '' if value.nil?
|
||||
return value if value.is_a?(String)
|
||||
|
||||
milliseconds = Integer(value, exception: false)
|
||||
return value.to_s if milliseconds.nil? || milliseconds <= 0
|
||||
|
||||
seconds_string = (milliseconds / 1_000.0).to_s
|
||||
seconds_string.end_with?('.0') ? seconds_string.delete_suffix('.0') : seconds_string
|
||||
end
|
||||
|
||||
def preload_metadata! prepared_rows, fetch_metadata, metadata_cache, existing_posts, url_counts
|
||||
@@ -460,7 +478,7 @@ class PostImportPreviewer
|
||||
def parse_duration value, errors
|
||||
return nil if value.blank?
|
||||
|
||||
value.is_a?(Numeric) ? value.to_i : Tag.time_to_ms!(value.to_s, tag_name: '動画時間')
|
||||
Tag.time_to_ms!(value.to_s, tag_name: '動画時間')
|
||||
rescue Tag::SectionLiteralParseError
|
||||
errors[:video_ms] = ['動画時間の記法が不正です.']
|
||||
nil
|
||||
|
||||
@@ -5,9 +5,10 @@ class PostImportRowNormaliser
|
||||
'thumbnail_base',
|
||||
'original_created_from',
|
||||
'original_created_before',
|
||||
'duration',
|
||||
'tags',
|
||||
'parent_post_ids'].freeze
|
||||
FLEXIBLE_FIELDS = ['duration', 'video_ms'].freeze
|
||||
FLEXIBLE_FIELDS = ['video_ms'].freeze
|
||||
ATTRIBUTE_FIELDS = (STRING_FIELDS + FLEXIBLE_FIELDS).freeze
|
||||
|
||||
def self.normalise! rows, allow_warning_fields: false
|
||||
|
||||
@@ -36,8 +36,13 @@ class PostImportRunner
|
||||
|
||||
attributes['tags'] = preview[:attributes]['tags']
|
||||
attributes['url'] = row['url']
|
||||
post = PostCreator.new(actor: @actor, attributes:).create!
|
||||
{ source_row: row['source_row'], status: 'created', post: PostRepr.base(post) }
|
||||
creator = PostCreator.new(actor: @actor, attributes:)
|
||||
post = creator.create!
|
||||
result = { source_row: row['source_row'], status: 'created', post: PostRepr.base(post) }
|
||||
if creator.field_warnings.present?
|
||||
result[:field_warnings] = creator.field_warnings
|
||||
end
|
||||
result
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
existing_post = existing_post_for_race(row, e.record)
|
||||
if existing_post
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
require 'time'
|
||||
require 'date'
|
||||
|
||||
class PostMetadataFetcher
|
||||
TIMESTAMP_PATTERN =
|
||||
@@ -30,7 +31,7 @@ class PostMetadataFetcher
|
||||
Preview::KnownSiteExtractor.thumbnail_url(uri) || metadata[:image_url],
|
||||
original_created_from: serialise_time(created_range&.first),
|
||||
original_created_before: serialise_time(created_range&.last),
|
||||
duration: duration&.to_f&.then { _1.positive? ? (_1 * 1_000).round : nil },
|
||||
duration: serialise_duration(duration),
|
||||
tags: platform_tags.join(' ') }
|
||||
end
|
||||
|
||||
@@ -54,12 +55,16 @@ class PostMetadataFetcher
|
||||
when /\A(\d{4})-(\d{2})\z/
|
||||
year = Regexp.last_match(1).to_i
|
||||
month = Regexp.last_match(2).to_i
|
||||
return nil unless month.between?(1, 12)
|
||||
|
||||
from = Time.zone.local(year, month, 1)
|
||||
[from, from + 1.month]
|
||||
when /\A(\d{4})-(\d{2})-(\d{2})\z/
|
||||
year = Regexp.last_match(1).to_i
|
||||
month = Regexp.last_match(2).to_i
|
||||
day = Regexp.last_match(3).to_i
|
||||
return nil unless Date.valid_date?(year, month, day)
|
||||
|
||||
from = Time.zone.local(year, month, day)
|
||||
[from, from + 1.day]
|
||||
else
|
||||
@@ -84,36 +89,76 @@ class PostMetadataFetcher
|
||||
second = match[6]&.to_i || 0
|
||||
fraction = match[7]
|
||||
offset = match[8]
|
||||
whole_second = second + fractional_seconds(fraction)
|
||||
from =
|
||||
return nil unless valid_timestamp_components?(year, month, day, hour, minute, second)
|
||||
return nil unless valid_offset?(offset)
|
||||
|
||||
nanoseconds = parse_nanoseconds(fraction)
|
||||
timestamp =
|
||||
if offset.present?
|
||||
Time.new(year, month, day, hour, minute, whole_second, parse_offset(offset)).in_time_zone
|
||||
Time.new(
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
hour,
|
||||
minute,
|
||||
second + Rational(nanoseconds, 1_000_000_000),
|
||||
parse_offset(offset)).in_time_zone
|
||||
else
|
||||
Time.zone.local(year, month, day, hour, minute, whole_second)
|
||||
Time.zone.local(year, month, day, hour, minute, second).change(nsec: nanoseconds)
|
||||
end
|
||||
|
||||
from = timestamp.change(sec: 0, nsec: 0)
|
||||
before =
|
||||
if fraction.present?
|
||||
from + (10**(-fraction.length))
|
||||
elsif match[6].present?
|
||||
from + 1.second
|
||||
elsif match[5].present?
|
||||
from + 1.minute
|
||||
else
|
||||
if match[5].nil?
|
||||
from + 1.hour
|
||||
else
|
||||
from + 1.minute
|
||||
end
|
||||
[from, before]
|
||||
end
|
||||
|
||||
def self.parse_nanoseconds value
|
||||
return 0 if value.blank?
|
||||
|
||||
digits = value[0, 9].ljust(9, '0')
|
||||
Integer(digits, 10)
|
||||
end
|
||||
|
||||
def self.parse_offset value
|
||||
return '+00:00' if value == 'Z'
|
||||
|
||||
value.match?(/\A[+-]\d{2}:\d{2}\z/) ? value : "#{ value[0, 3] }:#{ value[3, 2] }"
|
||||
end
|
||||
|
||||
def self.fractional_seconds value
|
||||
return 0 if value.blank?
|
||||
def self.serialise_duration value
|
||||
seconds = Float(value)
|
||||
return nil unless seconds.positive?
|
||||
|
||||
Rational(value.to_i, 10**value.length)
|
||||
milliseconds = (seconds * 1_000).round
|
||||
seconds_string = (milliseconds / 1_000.0).to_s
|
||||
seconds_string.end_with?('.0') ? seconds_string.delete_suffix('.0') : seconds_string
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
|
||||
def self.valid_timestamp_components? year, month, day, hour, minute, second
|
||||
return false unless Date.valid_date?(year, month, day)
|
||||
return false unless hour.between?(0, 23)
|
||||
return false unless minute.between?(0, 59)
|
||||
return false unless second.between?(0, 59)
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def self.valid_offset? value
|
||||
return true if value.nil? || value == 'Z'
|
||||
|
||||
match = value.match(/\A([+-])(\d{2}):?(\d{2})\z/)
|
||||
return false if match.nil?
|
||||
|
||||
hours = match[2].to_i
|
||||
minutes = match[3].to_i
|
||||
hours.between?(0, 23) && minutes.between?(0, 59)
|
||||
end
|
||||
|
||||
def self.serialise_time value
|
||||
@@ -125,7 +170,10 @@ class PostMetadataFetcher
|
||||
private_class_method :platform_tags,
|
||||
:original_created_range,
|
||||
:parse_timestamp_range,
|
||||
:parse_nanoseconds,
|
||||
:parse_offset,
|
||||
:fractional_seconds,
|
||||
:serialise_duration,
|
||||
:valid_timestamp_components?,
|
||||
:valid_offset?,
|
||||
:serialise_time
|
||||
end
|
||||
|
||||
@@ -12,9 +12,7 @@ module Preview
|
||||
|
||||
Response = Data.define(:body, :content_type, :url)
|
||||
|
||||
def self.fetch(raw_url,
|
||||
max_bytes: DEFAULT_MAX_BYTES,
|
||||
redirects: MAX_REDIRECTS)
|
||||
def self.fetch(raw_url, max_bytes: DEFAULT_MAX_BYTES, redirects: MAX_REDIRECTS)
|
||||
uri, addresses = UrlSafety.validate(raw_url)
|
||||
response = request(uri, addresses.first, max_bytes)
|
||||
|
||||
@@ -69,9 +67,7 @@ module Preview
|
||||
log_failure(:timeout, url: uri&.to_s || raw_url, error: e.class.name, message: e.message)
|
||||
raise FetchTimeout, e.message
|
||||
rescue SocketError, SystemCallError, OpenSSL::SSL::SSLError, EOFError => e
|
||||
log_failure(:network_error,
|
||||
url: uri&.to_s || raw_url,
|
||||
error: e.class.name,
|
||||
log_failure(:network_error, url: uri&.to_s || raw_url, error: e.class.name,
|
||||
message: e.message)
|
||||
raise FetchFailed, e.message
|
||||
end
|
||||
|
||||
@@ -25,6 +25,22 @@ module Preview
|
||||
fetch_image!(metadata[:image_url])
|
||||
end
|
||||
|
||||
def self.fetch_image_response(raw_url)
|
||||
uri, = UrlSafety.validate(raw_url)
|
||||
response = HttpFetcher.fetch(uri.to_s)
|
||||
unless allowed_image_content_type?(response.content_type)
|
||||
raise GenerationFailed, 'サムネール画像が見つかりませんでした.'
|
||||
end
|
||||
|
||||
response
|
||||
rescue HttpFetcher::FetchTimeout
|
||||
raise
|
||||
rescue HttpFetcher::ResponseTooLarge
|
||||
raise
|
||||
rescue HttpFetcher::FetchFailed
|
||||
raise GenerationFailed, 'サムネール画像を取得できませんでした.'
|
||||
end
|
||||
|
||||
def self.title(raw_url)
|
||||
uri, = UrlSafety.validate(raw_url)
|
||||
HtmlMetadataExtractor.extract(
|
||||
@@ -45,18 +61,7 @@ module Preview
|
||||
end
|
||||
|
||||
def self.fetch_image!(url)
|
||||
response = HttpFetcher.fetch(url)
|
||||
unless allowed_image_content_type?(response.content_type)
|
||||
raise GenerationFailed, 'サムネール画像が見つかりませんでした.'
|
||||
end
|
||||
|
||||
response.body
|
||||
rescue HttpFetcher::FetchTimeout
|
||||
raise
|
||||
rescue HttpFetcher::ResponseTooLarge
|
||||
raise
|
||||
rescue HttpFetcher::FetchFailed
|
||||
raise GenerationFailed, 'サムネール画像を取得できませんでした.'
|
||||
fetch_image_response(url).body
|
||||
end
|
||||
|
||||
def self.niconico_thumbnail_url(uri)
|
||||
@@ -70,16 +75,20 @@ module Preview
|
||||
|
||||
xml.at_xpath('//thumbnail_url')&.text&.strip.presence
|
||||
rescue HttpFetcher::FetchFailed, HttpFetcher::FetchTimeout => e
|
||||
Rails.logger.info("preview_niconico_getthumbinfo_fallback #{ { url: uri.to_s,
|
||||
video_id:,
|
||||
error: e.class.name,
|
||||
message: e.message }.to_json }")
|
||||
payload = {
|
||||
url: uri.to_s,
|
||||
video_id:,
|
||||
error: e.class.name,
|
||||
message: e.message }
|
||||
Rails.logger.info("preview_niconico_getthumbinfo_fallback #{ payload.to_json }")
|
||||
nil
|
||||
rescue Nokogiri::XML::SyntaxError => e
|
||||
Rails.logger.info("preview_niconico_getthumbinfo_fallback #{ { url: uri.to_s,
|
||||
video_id:,
|
||||
error: e.class.name,
|
||||
message: e.message }.to_json }")
|
||||
payload = {
|
||||
url: uri.to_s,
|
||||
video_id:,
|
||||
error: e.class.name,
|
||||
message: e.message }
|
||||
Rails.logger.info("preview_niconico_getthumbinfo_fallback #{ payload.to_json }")
|
||||
nil
|
||||
end
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
require 'open-uri'
|
||||
require 'set'
|
||||
require 'time'
|
||||
|
||||
@@ -127,12 +126,12 @@ module Youtube
|
||||
return if post.thumbnail.attached?
|
||||
return if thumbnail_url.blank?
|
||||
|
||||
post.thumbnail.attach(
|
||||
io: URI.open(thumbnail_url),
|
||||
filename: File.basename(URI.parse(thumbnail_url).path),
|
||||
content_type: 'image/jpeg')
|
||||
|
||||
post.resized_thumbnail!
|
||||
post.attach_thumbnail_from_url!(thumbnail_url)
|
||||
rescue Post::RemoteThumbnailFetchFailed => e
|
||||
Rails.logger.info("youtube_sync_thumbnail_fetch_failed #{ { post_id: post.id,
|
||||
thumbnail_url:,
|
||||
error: e.class.name,
|
||||
message: e.message }.to_json }")
|
||||
end
|
||||
|
||||
def youtube_url_regexp id
|
||||
|
||||
@@ -70,11 +70,17 @@ namespace :nico do
|
||||
unless post.thumbnail.attached?
|
||||
thumbnail_base = fetch_thumbnail.(post.url) rescue nil
|
||||
if thumbnail_base.present?
|
||||
post.thumbnail.attach(
|
||||
io: URI.open(thumbnail_base),
|
||||
filename: File.basename(URI.parse(thumbnail_base).path),
|
||||
content_type: 'image/jpeg')
|
||||
attrs[:thumbnail_base] = thumbnail_base
|
||||
begin
|
||||
post.attach_thumbnail_from_url!(thumbnail_base)
|
||||
rescue Post::RemoteThumbnailFetchFailed => e
|
||||
payload = {
|
||||
post_id: post.id,
|
||||
thumbnail_base:,
|
||||
error: e.class.name,
|
||||
message: e.message }
|
||||
Rails.logger.info("nico_sync_thumbnail_fetch_failed #{ payload.to_json }")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -82,7 +88,6 @@ namespace :nico do
|
||||
post_changed = post.changed?
|
||||
if post_changed
|
||||
post.save!
|
||||
post.resized_thumbnail! if post.thumbnail.attached?
|
||||
end
|
||||
else
|
||||
post_created = true
|
||||
@@ -91,13 +96,18 @@ namespace :nico do
|
||||
post = Post.new(title:, url:, thumbnail_base:, uploaded_user: nil,
|
||||
original_created_from:, original_created_before:)
|
||||
if thumbnail_base.present?
|
||||
post.thumbnail.attach(
|
||||
io: URI.open(thumbnail_base),
|
||||
filename: File.basename(URI.parse(thumbnail_base).path),
|
||||
content_type: 'image/jpeg')
|
||||
begin
|
||||
post.attach_thumbnail_from_url!(thumbnail_base)
|
||||
rescue Post::RemoteThumbnailFetchFailed => e
|
||||
payload = {
|
||||
post_id: nil,
|
||||
thumbnail_base:,
|
||||
error: e.class.name,
|
||||
message: e.message }
|
||||
Rails.logger.info("nico_sync_thumbnail_fetch_failed #{ payload.to_json }")
|
||||
end
|
||||
end
|
||||
post.save!
|
||||
post.resized_thumbnail!
|
||||
sync_post_tags!(post, [Tag.tagme.id, Tag.bot.id, Tag.niconico.id, Tag.video.id])
|
||||
end
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
require 'rails_helper'
|
||||
require 'tempfile'
|
||||
|
||||
RSpec.describe Post, type: :model do
|
||||
before do
|
||||
@@ -65,4 +66,296 @@ RSpec.describe Post, type: :model do
|
||||
expect(post.errors.details.fetch(:url)).to include(error: :taken, value: post.url)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'thumbnail processing' do
|
||||
def image_blob(width:, height:, background:, draw: nil)
|
||||
Tempfile.create(['post-thumbnail', '.png']) do |file|
|
||||
MiniMagick::Tool::Convert.new do |convert|
|
||||
convert.size "#{ width }x#{ height }"
|
||||
convert.xc background
|
||||
draw&.call(convert)
|
||||
convert << file.path
|
||||
end
|
||||
File.binread(file.path)
|
||||
end
|
||||
end
|
||||
|
||||
def upload_for(blob)
|
||||
StringIO.new(blob).tap(&:rewind)
|
||||
end
|
||||
|
||||
def read_image(attachment)
|
||||
blob =
|
||||
attachment.is_a?(Hash) ? attachment.fetch(:io).read : attachment.download
|
||||
MiniMagick::Image.read(blob)
|
||||
end
|
||||
|
||||
def colour_at(image, x, y)
|
||||
image.get_pixels.fetch(y).fetch(x)
|
||||
end
|
||||
|
||||
def expect_green(pixel)
|
||||
expect(pixel[1]).to be > pixel[0] + 40
|
||||
expect(pixel[1]).to be > pixel[2] + 40
|
||||
end
|
||||
|
||||
def expect_red(pixel)
|
||||
expect(pixel[0]).to be > pixel[1] + 40
|
||||
expect(pixel[0]).to be > pixel[2] + 40
|
||||
end
|
||||
|
||||
def expect_blue(pixel)
|
||||
expect(pixel[2]).to be > pixel[0] + 40
|
||||
expect(pixel[2]).to be > pixel[1] + 40
|
||||
end
|
||||
|
||||
describe '.resized_thumbnail_attachment' do
|
||||
it 'centre-crops a wide image to 180x180 without distorting it' do
|
||||
blob = image_blob(
|
||||
width: 360,
|
||||
height: 180,
|
||||
background: 'red',
|
||||
draw: -> convert {
|
||||
convert.fill 'green'
|
||||
convert.draw 'rectangle 90,0 269,179'
|
||||
})
|
||||
|
||||
resized = described_class.resized_thumbnail_attachment(upload_for(blob))
|
||||
image = read_image(resized)
|
||||
|
||||
expect(image.dimensions).to eq([180, 180])
|
||||
expect_green(colour_at(image, 0, 90))
|
||||
expect_green(colour_at(image, 90, 90))
|
||||
expect_green(colour_at(image, 179, 90))
|
||||
end
|
||||
|
||||
it 'centre-crops a tall image to 180x180 without distorting it' do
|
||||
blob = image_blob(
|
||||
width: 180,
|
||||
height: 360,
|
||||
background: 'red',
|
||||
draw: -> convert {
|
||||
convert.fill 'green'
|
||||
convert.draw 'rectangle 0,90 179,269'
|
||||
})
|
||||
|
||||
resized = described_class.resized_thumbnail_attachment(upload_for(blob))
|
||||
image = read_image(resized)
|
||||
|
||||
expect(image.dimensions).to eq([180, 180])
|
||||
expect_green(colour_at(image, 90, 0))
|
||||
expect_green(colour_at(image, 90, 90))
|
||||
expect_green(colour_at(image, 90, 179))
|
||||
end
|
||||
|
||||
it 'keeps a square image square without distortion' do
|
||||
blob = image_blob(
|
||||
width: 180,
|
||||
height: 180,
|
||||
background: 'red',
|
||||
draw: -> convert {
|
||||
convert.fill 'blue'
|
||||
convert.draw 'rectangle 90,0 179,179'
|
||||
})
|
||||
|
||||
resized = described_class.resized_thumbnail_attachment(upload_for(blob))
|
||||
image = read_image(resized)
|
||||
|
||||
expect(image.dimensions).to eq([180, 180])
|
||||
expect_red(colour_at(image, 20, 90))
|
||||
expect_blue(colour_at(image, 160, 90))
|
||||
end
|
||||
end
|
||||
|
||||
describe '#attach_thumbnail_from_url!' do
|
||||
it 'attaches a fetched remote image through the common resize path' do
|
||||
blob = image_blob(
|
||||
width: 240,
|
||||
height: 180,
|
||||
background: 'red',
|
||||
draw: -> convert {
|
||||
convert.fill 'green'
|
||||
convert.draw 'rectangle 30,0 209,179'
|
||||
})
|
||||
response = Preview::HttpFetcher::Response.new(
|
||||
blob,
|
||||
'image/png',
|
||||
'https://example.com/thumb.png')
|
||||
allow(Preview::ThumbnailFetcher).to receive(:fetch_image_response)
|
||||
.with('https://example.com/thumb.png')
|
||||
.and_return(response)
|
||||
|
||||
post = described_class.create!(title: 'title', url: 'https://example.com/post')
|
||||
|
||||
expect(post.thumbnail).to receive(:attach).once.and_call_original
|
||||
post.attach_thumbnail_from_url!('https://example.com/thumb.png')
|
||||
|
||||
expect(post.thumbnail).to be_attached
|
||||
image = read_image(post.thumbnail)
|
||||
expect(image.dimensions).to eq([180, 180])
|
||||
end
|
||||
|
||||
it 'does not attach anything when thumbnail conversion fails' do
|
||||
response = Preview::HttpFetcher::Response.new(
|
||||
'not-an-image',
|
||||
'image/png',
|
||||
'https://example.com/thumb.png')
|
||||
allow(Preview::ThumbnailFetcher).to receive(:fetch_image_response)
|
||||
.with('https://example.com/thumb.png')
|
||||
.and_return(response)
|
||||
allow(described_class).to receive(:resized_thumbnail_attachment)
|
||||
.and_raise(MiniMagick::Error, 'convert failed')
|
||||
|
||||
post = described_class.create!(title: 'title', url: 'https://example.com/post')
|
||||
|
||||
expect {
|
||||
post.attach_thumbnail_from_url!('https://example.com/thumb.png')
|
||||
}.to raise_error(Post::RemoteThumbnailFetchFailed, 'convert failed')
|
||||
|
||||
expect(post.thumbnail).not_to be_attached
|
||||
end
|
||||
|
||||
it 'keeps an existing thumbnail when remote conversion fails' do
|
||||
existing = described_class.create!(title: 'title', url: 'https://example.com/post')
|
||||
existing.thumbnail.attach(
|
||||
io: StringIO.new('existing'),
|
||||
filename: 'existing.jpg',
|
||||
content_type: 'image/jpeg')
|
||||
blob_id = existing.thumbnail.blob.id
|
||||
response = Preview::HttpFetcher::Response.new(
|
||||
'not-an-image',
|
||||
'image/png',
|
||||
'https://example.com/thumb.png')
|
||||
allow(Preview::ThumbnailFetcher).to receive(:fetch_image_response)
|
||||
.with('https://example.com/thumb.png')
|
||||
.and_return(response)
|
||||
allow(described_class).to receive(:resized_thumbnail_attachment)
|
||||
.and_raise(MiniMagick::Error, 'convert failed')
|
||||
|
||||
expect {
|
||||
existing.attach_thumbnail_from_url!('https://example.com/thumb.png')
|
||||
}.to raise_error(Post::RemoteThumbnailFetchFailed, 'convert failed')
|
||||
|
||||
existing.reload
|
||||
expect(existing.thumbnail).to be_attached
|
||||
expect(existing.thumbnail.blob.id).to eq(blob_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'original created datetime validation' do
|
||||
it 'allows unrelated updates on persisted posts with second-bearing datetimes' do
|
||||
post = described_class.create!(title: 'title', url: 'https://example.com/post')
|
||||
post.update_columns(
|
||||
original_created_from: Time.zone.parse('2024-01-01T12:34:30Z'),
|
||||
original_created_before: Time.zone.parse('2024-01-01T12:35:30Z')
|
||||
)
|
||||
|
||||
post.title = 'updated title'
|
||||
|
||||
expect(post).to be_valid
|
||||
expect { post.save! }.not_to raise_error
|
||||
end
|
||||
|
||||
it 'rejects second-bearing updates when the datetime field changes' do
|
||||
post = described_class.create!(title: 'title', url: 'https://example.com/post')
|
||||
|
||||
post.original_created_from = '2024-01-01T12:34:30Z'
|
||||
|
||||
expect(post).to be_invalid
|
||||
expect(post.errors[:original_created_from]).to eq(
|
||||
[described_class::ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE]
|
||||
)
|
||||
end
|
||||
|
||||
it 'accepts fixing persisted datetimes to minute precision' do
|
||||
post = described_class.create!(title: 'title', url: 'https://example.com/post')
|
||||
post.update_columns(
|
||||
original_created_from: Time.zone.parse('2024-01-01T12:34:30Z'),
|
||||
original_created_before: Time.zone.parse('2024-01-01T12:35:30Z')
|
||||
)
|
||||
|
||||
post.original_created_from = '2024-01-01T12:34Z'
|
||||
post.original_created_before = '2024-01-01T12:35Z'
|
||||
|
||||
expect(post).to be_valid
|
||||
expect { post.save! }.not_to raise_error
|
||||
end
|
||||
|
||||
it 'adds only the minute-precision error for second precision values' do
|
||||
post = described_class.new(
|
||||
title: 'title',
|
||||
url: 'https://example.com/post',
|
||||
original_created_from: '2024-01-01T12:34:30',
|
||||
original_created_before: '2024-01-01T12:35'
|
||||
)
|
||||
|
||||
expect(post).to be_invalid
|
||||
expect(post.errors[:original_created_from]).to eq(
|
||||
[described_class::ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE]
|
||||
)
|
||||
expect(post.errors[:original_created_at]).to be_empty
|
||||
end
|
||||
|
||||
it 'adds only the minute-precision error for fractional-second values' do
|
||||
post = described_class.new(
|
||||
title: 'title',
|
||||
url: 'https://example.com/post',
|
||||
original_created_from: '2024-01-01T12:34:00.123',
|
||||
original_created_before: '2024-01-01T12:35'
|
||||
)
|
||||
|
||||
expect(post).to be_invalid
|
||||
expect(post.errors[:original_created_from]).to eq(
|
||||
[described_class::ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE]
|
||||
)
|
||||
expect(post.errors[:original_created_at]).to be_empty
|
||||
end
|
||||
|
||||
it 'checks range rules only for valid minute-precision endpoints' do
|
||||
post = described_class.new(
|
||||
title: 'title',
|
||||
url: 'https://example.com/post',
|
||||
original_created_from: '2024-01-01T12:34',
|
||||
original_created_before: '2024-01-01T12:34'
|
||||
)
|
||||
|
||||
expect(post).to be_invalid
|
||||
expect(post.errors[:original_created_at]).to eq(
|
||||
[described_class::ORIGINAL_CREATED_ORDER_MESSAGE]
|
||||
)
|
||||
end
|
||||
|
||||
it 'accepts a one-minute range at minute precision' do
|
||||
post = described_class.new(
|
||||
title: 'title',
|
||||
url: 'https://example.com/post',
|
||||
original_created_from: '2024-01-01T12:34',
|
||||
original_created_before: '2024-01-01T12:35'
|
||||
)
|
||||
|
||||
expect(post).to be_valid
|
||||
end
|
||||
|
||||
it 'rejects invalid calendar dates and invalid hours' do
|
||||
invalid_dates = [
|
||||
'2024-02-31T12:00',
|
||||
'2023-02-29T12:00',
|
||||
'2024-02-29T24:00'
|
||||
]
|
||||
|
||||
invalid_dates.each do |value|
|
||||
post = described_class.new(
|
||||
title: 'title',
|
||||
url: 'https://example.com/post',
|
||||
original_created_from: value
|
||||
)
|
||||
|
||||
expect(post).to be_invalid
|
||||
expect(post.errors[:original_created_from]).to eq(
|
||||
[described_class::ORIGINAL_CREATED_INVALID_MESSAGE]
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -84,9 +84,81 @@ RSpec.describe 'Post imports API', type: :request do
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
expect(json.fetch('message')).to eq('取込行の形式が不正です.')
|
||||
end
|
||||
|
||||
it 'returns original created datetime validation errors for minute precision' do
|
||||
sign_in_as(member)
|
||||
|
||||
post '/posts/import/validate', params: {
|
||||
rows: [{
|
||||
sourceRow: 1,
|
||||
url: 'https://example.com/post',
|
||||
metadataUrl: 'https://example.com/post',
|
||||
attributes: {
|
||||
originalCreatedFrom: '2020-01-01T00:00:30Z',
|
||||
originalCreatedBefore: '2020-01-01T00:01Z' },
|
||||
provenance: {
|
||||
url: 'manual',
|
||||
originalCreatedFrom: 'manual',
|
||||
originalCreatedBefore: 'manual' },
|
||||
tagSources: { automatic: '', manual: '' }
|
||||
}],
|
||||
changed_row: -1
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('rows').first.fetch('validation_errors')).to include(
|
||||
'original_created_from' => ['オリジナルの作成日時は分単位で入力してください.']
|
||||
)
|
||||
expect(json.fetch('rows').first.fetch('validation_errors'))
|
||||
.not_to have_key('original_created_at')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /posts/import' do
|
||||
it 'keeps the duration string contract through preview, validate, and import' do
|
||||
sign_in_as(member)
|
||||
allow(PostMetadataFetcher).to receive(:fetch).and_return(
|
||||
title: 'fetched title',
|
||||
thumbnail_base: nil,
|
||||
duration: '2.5',
|
||||
tags: '動画'
|
||||
)
|
||||
|
||||
post '/posts/import/preview', params: {
|
||||
source: 'https://example.com/video'
|
||||
}
|
||||
preview_row = json.fetch('rows').first
|
||||
expect(preview_row.dig('attributes', 'duration')).to eq('2.5')
|
||||
|
||||
post '/posts/import/validate', params: {
|
||||
rows: [{
|
||||
sourceRow: preview_row.fetch('source_row'),
|
||||
url: preview_row.fetch('url'),
|
||||
attributes: preview_row.fetch('attributes'),
|
||||
provenance: preview_row.fetch('provenance'),
|
||||
tagSources: preview_row.fetch('tag_sources'),
|
||||
metadataUrl: preview_row.fetch('metadata_url')
|
||||
}],
|
||||
changed_row: -1
|
||||
}
|
||||
validated_row = json.fetch('rows').first
|
||||
expect(validated_row.dig('attributes', 'duration')).to eq('2.5')
|
||||
|
||||
post '/posts/import', params: {
|
||||
rows: [{
|
||||
sourceRow: validated_row.fetch('source_row'),
|
||||
url: validated_row.fetch('url'),
|
||||
attributes: validated_row.fetch('attributes'),
|
||||
provenance: validated_row.fetch('provenance'),
|
||||
tagSources: validated_row.fetch('tag_sources'),
|
||||
metadataUrl: validated_row.fetch('metadata_url')
|
||||
}]
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(Post.order(:id).last.video_ms).to eq(2_500)
|
||||
end
|
||||
|
||||
it 'returns a formal skipped result for an existing post' do
|
||||
existing = create(:post, url: 'https://example.com/existing')
|
||||
sign_in_as(member)
|
||||
|
||||
@@ -2206,6 +2206,91 @@ RSpec.describe 'Posts API', type: :request do
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
|
||||
it 'rejects second-bearing original created timestamps on POST /posts' do
|
||||
sign_in_as(member)
|
||||
|
||||
post '/posts', params: post_write_params(
|
||||
title: 'invalid original created from',
|
||||
url: 'https://example.com/invalid-original-created-from',
|
||||
tags: 'spec_tag',
|
||||
thumbnail: dummy_upload,
|
||||
original_created_from: '2020-01-01T00:00:01Z')
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json.fetch('errors')).to include(
|
||||
'original_created_from' => ['オリジナルの作成日時は分単位で入力してください.']
|
||||
)
|
||||
expect(json.fetch('errors')).not_to have_key('original_created_at')
|
||||
end
|
||||
|
||||
it 'rejects fractional-second original created timestamps on POST /posts' do
|
||||
sign_in_as(member)
|
||||
|
||||
post '/posts', params: post_write_params(
|
||||
title: 'invalid original created before',
|
||||
url: 'https://example.com/invalid-original-created-before',
|
||||
tags: 'spec_tag',
|
||||
thumbnail: dummy_upload,
|
||||
original_created_before: '2020-01-01T00:00:00.123Z')
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json.fetch('errors')).to include(
|
||||
'original_created_before' => ['オリジナルの作成日時は分単位で入力してください.']
|
||||
)
|
||||
expect(json.fetch('errors')).not_to have_key('original_created_at')
|
||||
end
|
||||
|
||||
it 'rejects non-increasing original created ranges on POST /posts' do
|
||||
sign_in_as(member)
|
||||
|
||||
post '/posts', params: post_write_params(
|
||||
title: 'non-increasing original created range',
|
||||
url: 'https://example.com/non-increasing-original-created-range',
|
||||
tags: 'spec_tag',
|
||||
thumbnail: dummy_upload,
|
||||
original_created_from: '2020-01-01T00:00Z',
|
||||
original_created_before: '2020-01-01T00:00Z')
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json.fetch('errors')).to include(
|
||||
'original_created_at' => ['オリジナルの作成日時の順番がをかしぃです.']
|
||||
)
|
||||
end
|
||||
|
||||
it 'accepts original created ranges that are exactly one minute on POST /posts' do
|
||||
sign_in_as(member)
|
||||
|
||||
post '/posts', params: post_write_params(
|
||||
title: 'valid original created range',
|
||||
url: 'https://example.com/valid-original-created-range',
|
||||
tags: 'spec_tag',
|
||||
thumbnail: dummy_upload,
|
||||
original_created_from: '2020-01-01T00:00Z',
|
||||
original_created_before: '2020-01-01T00:01Z')
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
expect(Time.iso8601(json.fetch('original_created_from')))
|
||||
.to eq(Time.iso8601('2020-01-01T00:00Z'))
|
||||
expect(Time.iso8601(json.fetch('original_created_before')))
|
||||
.to eq(Time.iso8601('2020-01-01T00:01Z'))
|
||||
end
|
||||
|
||||
it 'rejects unparseable original created timestamps on PUT /posts/:id' do
|
||||
sign_in_as(member)
|
||||
base_version = create_post_version_for!(post_record)
|
||||
|
||||
put "/posts/#{post_record.id}", params: post_write_params(
|
||||
base_version_no: base_version.version_no,
|
||||
title: 'updated title',
|
||||
tags: 'spec_tag',
|
||||
original_created_from: 'not-a-time')
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json.fetch('errors')).to include(
|
||||
'original_created_from' => ['オリジナルの作成日時の形式が不正です.']
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'tag versioning from post write actions' do
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
require 'rails_helper'
|
||||
require 'base64'
|
||||
|
||||
RSpec.describe PostCreator do
|
||||
let(:actor) { create(:user, :member) }
|
||||
|
||||
def real_thumbnail_upload
|
||||
gif = Base64.decode64('R0lGODdhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=')
|
||||
|
||||
Rack::Test::UploadedFile.new(
|
||||
StringIO.new(gif),
|
||||
'image/gif',
|
||||
original_filename: 'thumbnail.gif')
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Tag).to receive(:normalise_tags!).and_return({ tags: [], sections: {} })
|
||||
allow(TagVersioning).to receive(:record_tag_snapshots!)
|
||||
allow(Tag).to receive(:expand_parent_tags).and_return([])
|
||||
allow(PostVersionRecorder).to receive(:record!)
|
||||
end
|
||||
|
||||
it 'prefers an explicit upload over thumbnail_base' do
|
||||
allow(Post).to receive(:resized_thumbnail_attachment).and_return(
|
||||
io: StringIO.new('upload'),
|
||||
filename: 'resized_thumbnail.jpg',
|
||||
content_type: 'image/jpeg')
|
||||
expect_any_instance_of(Post).not_to receive(:attach_thumbnail_from_url!)
|
||||
|
||||
post = described_class.new(
|
||||
actor:,
|
||||
attributes: {
|
||||
title: 'title',
|
||||
url: 'https://example.com/post',
|
||||
thumbnail: real_thumbnail_upload,
|
||||
thumbnail_base: 'https://example.com/thumb.jpg',
|
||||
tags: '' }).create!
|
||||
|
||||
expect(post.thumbnail).to be_attached
|
||||
expect(post.thumbnail_base).to eq('https://example.com/thumb.jpg')
|
||||
end
|
||||
|
||||
it 'uses the common remote thumbnail attach path when thumbnail_base is given' do
|
||||
expect_any_instance_of(Post).to receive(:attach_thumbnail_from_url!)
|
||||
.with('https://example.com/thumb.jpg') do |post, _url|
|
||||
post.thumbnail.attach(
|
||||
io: StringIO.new('thumbnail'),
|
||||
filename: 'thumbnail.jpg',
|
||||
content_type: 'image/jpeg')
|
||||
end
|
||||
|
||||
post = described_class.new(
|
||||
actor:,
|
||||
attributes: {
|
||||
title: 'title',
|
||||
url: 'https://example.com/post',
|
||||
thumbnail_base: 'https://example.com/thumb.jpg',
|
||||
tags: '' }).create!
|
||||
|
||||
expect(post.thumbnail_base).to eq('https://example.com/thumb.jpg')
|
||||
expect(post.thumbnail).to be_attached
|
||||
end
|
||||
|
||||
it 'keeps creating the post and records a warning when remote thumbnail fetch fails' do
|
||||
allow_any_instance_of(Post).to receive(:attach_thumbnail_from_url!)
|
||||
.and_raise(Post::RemoteThumbnailFetchFailed, 'サムネール画像を取得できませんでした.')
|
||||
creator = described_class.new(
|
||||
actor:,
|
||||
attributes: {
|
||||
title: 'title',
|
||||
url: 'https://example.com/post',
|
||||
thumbnail_base: 'https://example.com/thumb.jpg',
|
||||
tags: '' })
|
||||
|
||||
post = creator.create!
|
||||
|
||||
expect(post.thumbnail_base).to eq('https://example.com/thumb.jpg')
|
||||
expect(post.thumbnail).not_to be_attached
|
||||
expect(creator.field_warnings).to eq(
|
||||
thumbnail_base: ['サムネール画像を取得できませんでした.'])
|
||||
end
|
||||
end
|
||||
@@ -68,7 +68,7 @@ RSpec.describe PostImportPreviewer do
|
||||
allow(PostMetadataFetcher).to receive(:fetch).and_return(
|
||||
title: 'metadata title',
|
||||
thumbnail_base: 'https://example.com/thumb.jpg',
|
||||
duration: 2_000,
|
||||
duration: '2',
|
||||
tags: 'known-tag'
|
||||
)
|
||||
|
||||
@@ -79,7 +79,7 @@ RSpec.describe PostImportPreviewer do
|
||||
expect(result.fetch(:attributes)).to include(
|
||||
'title' => 'metadata title',
|
||||
'thumbnail_base' => 'https://example.com/thumb.jpg',
|
||||
'duration' => 2_000,
|
||||
'duration' => '2',
|
||||
'tags' => 'known-tag'
|
||||
)
|
||||
expect(result.fetch(:field_warnings)).not_to have_key('tags')
|
||||
|
||||
@@ -6,7 +6,7 @@ RSpec.describe PostImportRowNormaliser do
|
||||
sourceRow: '1',
|
||||
url: 'https://example.com/post',
|
||||
metadataUrl: 'https://example.com/post',
|
||||
attributes: { title: 'title', duration: 1_000 },
|
||||
attributes: { title: 'title', duration: '1' },
|
||||
provenance: { url: 'manual', title: 'automatic' },
|
||||
tagSources: { automatic: 'tag', manual: '' }
|
||||
}.deep_merge(overrides)
|
||||
@@ -21,7 +21,7 @@ RSpec.describe PostImportRowNormaliser do
|
||||
'source_row' => 1,
|
||||
'url' => 'https://example.com/post',
|
||||
'metadata_url' => 'https://example.com/post',
|
||||
'attributes' => { 'title' => 'title', 'duration' => 1_000 },
|
||||
'attributes' => { 'title' => 'title', 'duration' => '1' },
|
||||
'provenance' => { 'url' => 'manual', 'title' => 'automatic' },
|
||||
'tag_sources' => { 'automatic' => 'tag', 'manual' => '' }
|
||||
}
|
||||
|
||||
@@ -130,4 +130,23 @@ RSpec.describe PostImportRunner do
|
||||
|
||||
expect(result).to include(status: 'skipped', existing_post_id: existing.id)
|
||||
end
|
||||
|
||||
it 'returns thumbnail warnings from PostCreator on a created row' do
|
||||
previewer = instance_double(PostImportPreviewer)
|
||||
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
|
||||
allow(previewer).to receive(:preview_rows).and_return([preview])
|
||||
created_post = create(:post)
|
||||
creator = instance_double(
|
||||
PostCreator,
|
||||
create!: created_post,
|
||||
field_warnings: { thumbnail_base: ['サムネール画像を取得できませんでした.'] })
|
||||
allow(PostCreator).to receive(:new).and_return(creator)
|
||||
|
||||
result = described_class.new(actor:, rows: [row]).run.fetch(:rows).first
|
||||
|
||||
expect(result).to include(
|
||||
status: 'created',
|
||||
field_warnings: { thumbnail_base: ['サムネール画像を取得できませんでした.'] }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -37,16 +37,30 @@ RSpec.describe PostMetadataFetcher do
|
||||
)
|
||||
end
|
||||
|
||||
it 'preserves an input offset and fractional-second precision' do
|
||||
it 'rounds second and fractional-second timestamps down to one-minute ranges' do
|
||||
result = fetch_with_published_time('2024-02-03T12:34:56.123+02:30')
|
||||
|
||||
expect(Time.iso8601(result.fetch(:original_created_from)))
|
||||
.to eq(Time.iso8601('2024-02-03T12:34:56.123+02:30'))
|
||||
.to eq(Time.iso8601('2024-02-03T12:34:00+02:30'))
|
||||
expect(
|
||||
Time.iso8601(result.fetch(:original_created_before)) -
|
||||
Time.iso8601(result.fetch(:original_created_from))
|
||||
).to eq(0.001)
|
||||
expect(result.fetch(:original_created_from)).to include('.123000000')
|
||||
).to eq(60)
|
||||
expect(result.fetch(:original_created_from)).not_to include('.')
|
||||
end
|
||||
|
||||
it 'treats hour-precision timestamps as one-hour ranges with and without offsets' do
|
||||
offset_result = fetch_with_published_time('2024-02-03T12+02:30')
|
||||
local_result = fetch_with_published_time('2024-02-03T12')
|
||||
|
||||
expect(Time.iso8601(offset_result.fetch(:original_created_from)))
|
||||
.to eq(Time.iso8601('2024-02-03T12:00:00+02:30'))
|
||||
expect(
|
||||
Time.iso8601(offset_result.fetch(:original_created_before)) -
|
||||
Time.iso8601(offset_result.fetch(:original_created_from))
|
||||
).to eq(3600)
|
||||
expect(local_result.fetch(:original_created_from)).to eq('2024-02-03T12:00:00Z')
|
||||
expect(local_result.fetch(:original_created_before)).to eq('2024-02-03T13:00:00Z')
|
||||
end
|
||||
|
||||
it 'adds platform tags for known video URLs' do
|
||||
@@ -61,4 +75,49 @@ RSpec.describe PostMetadataFetcher do
|
||||
expect(result.fetch(:original_created_from)).to be_nil
|
||||
expect(result.fetch(:original_created_before)).to be_nil
|
||||
end
|
||||
|
||||
it 'returns nil dates for an invalid timestamp second value' do
|
||||
result = fetch_with_published_time('2024-02-03T12:34:99Z')
|
||||
|
||||
expect(result.fetch(:original_created_from)).to be_nil
|
||||
expect(result.fetch(:original_created_before)).to be_nil
|
||||
end
|
||||
|
||||
it 'returns nil dates for invalid calendar dates and invalid hour values' do
|
||||
invalid_day = fetch_with_published_time('2024-02-31T12:00Z')
|
||||
invalid_leap = fetch_with_published_time('2023-02-29T12:00Z')
|
||||
invalid_hour = fetch_with_published_time('2024-02-29T24:00Z')
|
||||
|
||||
[invalid_day, invalid_leap, invalid_hour].each do |result|
|
||||
expect(result.fetch(:original_created_from)).to be_nil
|
||||
expect(result.fetch(:original_created_before)).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
it 'accepts a valid leap-day timestamp at minute precision' do
|
||||
result = fetch_with_published_time('2024-02-29T12:34Z')
|
||||
|
||||
expect(result.fetch(:original_created_from)).to eq('2024-02-29T12:34:00Z')
|
||||
expect(result.fetch(:original_created_before)).to eq('2024-02-29T12:35:00Z')
|
||||
end
|
||||
|
||||
it 'serialises metadata duration as the same seconds string contract used by forms' do
|
||||
html = <<~HTML
|
||||
<html><head>
|
||||
<meta property="article:published_time" content="2024-02-03T12:34:56.123+02:30">
|
||||
<meta property="og:video:duration" content="2.5">
|
||||
</head></html>
|
||||
HTML
|
||||
uri = URI.parse('https://example.com/video')
|
||||
allow(Preview::UrlSafety).to receive(:validate)
|
||||
.with('https://example.com/video')
|
||||
.and_return([uri, ['8.8.8.8']])
|
||||
allow(Preview::HttpFetcher).to receive(:fetch).and_return(Response.new(html))
|
||||
allow(Preview::HtmlMetadataExtractor).to receive(:extract)
|
||||
.and_return(title: 'title', image_url: nil)
|
||||
|
||||
result = described_class.fetch('https://example.com/video')
|
||||
|
||||
expect(result.fetch(:duration)).to eq('2.5')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -48,4 +48,56 @@ RSpec.describe Preview::ThumbnailFetcher do
|
||||
expect(described_class.fetch('https://example.com/page')).to eq('jpeg-bytes')
|
||||
end
|
||||
end
|
||||
|
||||
describe '.fetch_image_response' do
|
||||
it 'rejects an unsafe input URL before HTTP fetch' do
|
||||
allow(Preview::UrlSafety).to receive(:validate)
|
||||
.and_raise(Preview::UrlSafety::UnsafeUrl, '安全でない接続先は使用できません.')
|
||||
expect(Preview::HttpFetcher).not_to receive(:fetch)
|
||||
|
||||
expect {
|
||||
described_class.fetch_image_response('https://unsafe.example.com/thumb.jpg')
|
||||
}.to raise_error(Preview::UrlSafety::UnsafeUrl, '安全でない接続先は使用できません.')
|
||||
end
|
||||
|
||||
it 'rejects an unsafe redirect target' do
|
||||
allow(Preview::UrlSafety).to receive(:validate)
|
||||
.and_return([URI.parse('https://example.com/thumb.jpg'), ['8.8.8.8']])
|
||||
allow(Preview::HttpFetcher).to receive(:fetch)
|
||||
.with('https://example.com/thumb.jpg')
|
||||
.and_raise(Preview::UrlSafety::UnsafeUrl, '安全でない接続先は使用できません.')
|
||||
|
||||
expect {
|
||||
described_class.fetch_image_response('https://example.com/thumb.jpg')
|
||||
}.to raise_error(Preview::UrlSafety::UnsafeUrl, '安全でない接続先は使用できません.')
|
||||
end
|
||||
|
||||
it 'rejects an oversized image response' do
|
||||
allow(Preview::UrlSafety).to receive(:validate)
|
||||
.and_return([URI.parse('https://example.com/thumb.jpg'), ['8.8.8.8']])
|
||||
allow(Preview::HttpFetcher).to receive(:fetch)
|
||||
.with('https://example.com/thumb.jpg')
|
||||
.and_raise(Preview::HttpFetcher::ResponseTooLarge, 'too large')
|
||||
|
||||
expect {
|
||||
described_class.fetch_image_response('https://example.com/thumb.jpg')
|
||||
}.to raise_error(Preview::HttpFetcher::ResponseTooLarge)
|
||||
end
|
||||
|
||||
it 'rejects a non-image content type' do
|
||||
allow(Preview::UrlSafety).to receive(:validate)
|
||||
.and_return([URI.parse('https://example.com/thumb.jpg'), ['8.8.8.8']])
|
||||
allow(Preview::HttpFetcher).to receive(:fetch)
|
||||
.with('https://example.com/thumb.jpg')
|
||||
.and_return(
|
||||
Preview::HttpFetcher::Response.new(
|
||||
'<html></html>',
|
||||
'text/html',
|
||||
'https://example.com/thumb.jpg'))
|
||||
|
||||
expect {
|
||||
described_class.fetch_image_response('https://example.com/thumb.jpg')
|
||||
}.to raise_error(Preview::ThumbnailFetcher::GenerationFailed)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,6 +10,37 @@ RSpec.describe Youtube::Sync do
|
||||
allow(sync).to receive(:attach_thumbnail_if_needed!)
|
||||
end
|
||||
|
||||
describe '#attach_thumbnail_if_needed!' do
|
||||
it 'uses the common remote thumbnail attach path' do
|
||||
post = create(:post, thumbnail_base: nil)
|
||||
allow(sync).to receive(:attach_thumbnail_if_needed!).and_call_original
|
||||
|
||||
expect(post).to receive(:attach_thumbnail_from_url!)
|
||||
.with('https://example.com/thumb.jpg')
|
||||
|
||||
sync.send(
|
||||
:attach_thumbnail_if_needed!,
|
||||
post,
|
||||
'https://example.com/thumb.jpg')
|
||||
end
|
||||
|
||||
it 'retries on a later sync when the previous remote attach failed' do
|
||||
post = create(:post, thumbnail_base: nil)
|
||||
allow(sync).to receive(:attach_thumbnail_if_needed!).and_call_original
|
||||
expect(post).to receive(:attach_thumbnail_from_url!)
|
||||
.with('https://example.com/thumb.jpg')
|
||||
.twice
|
||||
.and_raise(Post::RemoteThumbnailFetchFailed, 'failed')
|
||||
|
||||
2.times do
|
||||
sync.send(
|
||||
:attach_thumbnail_if_needed!,
|
||||
post,
|
||||
'https://example.com/thumb.jpg')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#sync!' do
|
||||
it 'returns without fetching video details when no video ids are discovered' do
|
||||
allow(sync).to receive(:query_terms).and_return([])
|
||||
@@ -76,7 +107,7 @@ RSpec.describe Youtube::Sync do
|
||||
sync.sync!
|
||||
end
|
||||
|
||||
it 'creates a YouTube post with default tags and no_deerjikist when no deerjikist mapping exists' do
|
||||
it 'creates a YouTube post with default tags when no deerjikist mapping exists' do
|
||||
Tag.tagme
|
||||
Tag.bot
|
||||
Tag.youtube
|
||||
|
||||
@@ -18,7 +18,11 @@ RSpec.describe "nico:sync" do
|
||||
|
||||
it "既存 post を見つけて、nico tag と linked tag を追加し、差分が出たら bot を付ける" do
|
||||
# 既存 post(正規表現で拾われるURL)
|
||||
post = Post.create!(title: "old", url: "https://www.nicovideo.jp/watch/sm9", uploaded_user: nil)
|
||||
post = Post.create!(
|
||||
title: "old",
|
||||
url: "https://www.nicovideo.jp/watch/sm9",
|
||||
uploaded_user: nil
|
||||
)
|
||||
|
||||
# 既存の非nicoタグ(kept_non_nico_ids)
|
||||
kept_general = create_tag!("spec_kept", category: "general")
|
||||
@@ -60,8 +64,54 @@ RSpec.describe "nico:sync" do
|
||||
expect(active_tag_names).to include("bot操作")
|
||||
end
|
||||
|
||||
it '既存 post のサムネール取得に共通 attach 経路を使ふ' do
|
||||
post = Post.create!(
|
||||
title: 'old',
|
||||
url: 'https://www.nicovideo.jp/watch/sm9',
|
||||
uploaded_user: nil)
|
||||
Tag.bot
|
||||
Tag.tagme
|
||||
|
||||
stub_python([{ 'code' => 'sm9', 'title' => 't', 'tags' => [] }])
|
||||
allow(URI).to receive(:open)
|
||||
.and_return(
|
||||
StringIO.new(
|
||||
'<meta name="thumbnail" content="https://example.com/thumb.jpg">'))
|
||||
expect(post).to receive(:attach_thumbnail_from_url!)
|
||||
.with('https://example.com/thumb.jpg')
|
||||
|
||||
run_rake_task('nico:sync')
|
||||
end
|
||||
|
||||
it 'サムネール取得失敗後も次回同期で再試行できる' do
|
||||
post = Post.create!(
|
||||
title: 'old',
|
||||
url: 'https://www.nicovideo.jp/watch/sm9',
|
||||
uploaded_user: nil)
|
||||
Tag.bot
|
||||
Tag.tagme
|
||||
|
||||
stub_python([{ 'code' => 'sm9', 'title' => 't', 'tags' => [] }])
|
||||
allow(URI).to receive(:open)
|
||||
.and_return(
|
||||
StringIO.new(
|
||||
'<meta name="thumbnail" content="https://example.com/thumb.jpg">'))
|
||||
expect(post).to receive(:attach_thumbnail_from_url!)
|
||||
.with('https://example.com/thumb.jpg')
|
||||
.twice
|
||||
.and_raise(Post::RemoteThumbnailFetchFailed, 'failed')
|
||||
|
||||
2.times do
|
||||
run_rake_task('nico:sync')
|
||||
end
|
||||
end
|
||||
|
||||
it "既存 post にあった古い nico tag は active から外され、履歴として discard される" do
|
||||
post = Post.create!(title: "old", url: "https://www.nicovideo.jp/watch/sm9", uploaded_user: nil)
|
||||
post = Post.create!(
|
||||
title: "old",
|
||||
url: "https://www.nicovideo.jp/watch/sm9",
|
||||
uploaded_user: nil
|
||||
)
|
||||
|
||||
# 旧nicoタグ(今回の同期結果に含まれない)
|
||||
old_nico = create_tag!("nico:OLD", category: "nico")
|
||||
|
||||
+29
-1
@@ -153,6 +153,13 @@ pass or the remaining failure is clearly blocked.
|
||||
closing `</div>)` forms when nearby code uses them.
|
||||
- Block bodies for components, functions, callbacks, `if`, `try`, `catch`,
|
||||
`finally`, loops, and JSX nesting use 2 spaces per level.
|
||||
- Put the opening brace of `try`, `catch`, and `finally` blocks on the next
|
||||
line at the same indentation as the keyword.
|
||||
- Do not indent the opening `{` one level deeper than `try`, `catch`, or
|
||||
`finally`.
|
||||
- Indent the block body 2 spaces deeper than the keyword and opening brace.
|
||||
- Put the closing `}` on its own line at the same indentation as the keyword.
|
||||
- Do not write `try {`, `catch {`, or `finally {`.
|
||||
- Wrapped expressions, arguments, ternary branches, method chains, and object
|
||||
pairs use 4-space continuation indentation relative to the owning
|
||||
expression. Do not confuse this with 2-space block indentation.
|
||||
@@ -166,6 +173,10 @@ pass or the remaining failure is clearly blocked.
|
||||
`?` / `:` pairing.
|
||||
- Keep short inline props types local when they remain readable and within the
|
||||
line limit; do not mechanically extract a named type with no reuse benefit.
|
||||
- In JavaScript, JSX, TypeScript, and TSX, never use `_1`, `_2`, or similar
|
||||
Ruby-style numbered parameter names. Reserve numbered parameters for Ruby.
|
||||
Use a meaningful callback parameter name such as `row`, `item`, `value`,
|
||||
`entry`, or `result`.
|
||||
- In multi-line object literals, keep the opening `{` with the first pair when
|
||||
the line length allows it; do not mechanically explode short objects into
|
||||
Prettier-style vertical blocks.
|
||||
@@ -229,12 +240,29 @@ const Component = () => {
|
||||
}
|
||||
```
|
||||
|
||||
- `try` / `catch` / `finally` brace placement example:
|
||||
|
||||
```ts
|
||||
try
|
||||
{
|
||||
doWork ()
|
||||
}
|
||||
catch
|
||||
{
|
||||
recover ()
|
||||
}
|
||||
finally
|
||||
{
|
||||
cleanUp ()
|
||||
}
|
||||
```
|
||||
|
||||
- Continuation indentation example:
|
||||
|
||||
```ts
|
||||
const editingRow =
|
||||
Number.isFinite (editingSourceRow)
|
||||
? rows.find (_1 => _1.sourceRow === editingSourceRow) ?? null
|
||||
? rows.find (row => row.sourceRow === editingSourceRow) ?? null
|
||||
: null
|
||||
```
|
||||
|
||||
|
||||
@@ -19,8 +19,8 @@ const toastApi = vi.hoisted (() => ({
|
||||
vi.mock ('@/lib/posts', () => postsApi)
|
||||
vi.mock ('@/lib/api', () => api)
|
||||
vi.mock ('@/components/ui/use-toast', () => toastApi)
|
||||
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
|
||||
useDialogue: () => ({
|
||||
vi.mock ('@/lib/dialogues/useDialogue', () => ({
|
||||
default: () => ({
|
||||
choice: vi.fn (),
|
||||
}),
|
||||
}))
|
||||
@@ -78,15 +78,39 @@ describe ('PostEditForm', () => {
|
||||
|
||||
render (<PostEditForm post={post} onSave={vi.fn ()}/>)
|
||||
|
||||
expect (screen.getByRole ('spinbutton')).toHaveValue (180.5)
|
||||
expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('180.5')
|
||||
|
||||
const tags = screen.getAllByRole ('textbox')[2]
|
||||
fireEvent.change (tags, { target: { value: 'general-tag' } })
|
||||
expect (screen.queryByRole ('spinbutton')).not.toBeInTheDocument ()
|
||||
expect (screen.queryByPlaceholderText ('例: 2 / 2.5 / 1:23')).not.toBeInTheDocument ()
|
||||
|
||||
fireEvent.change (tags, {
|
||||
target: { value: '動画 general-tag' },
|
||||
})
|
||||
expect (screen.getByRole ('spinbutton')).toHaveValue (180.5)
|
||||
expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('180.5')
|
||||
})
|
||||
|
||||
it ('shows deduplicated original-created endpoint errors on the shared datetime field', async () => {
|
||||
const post = buildPost ()
|
||||
api.isApiError.mockReturnValue (true)
|
||||
postsApi.updatePost.mockRejectedValueOnce ({
|
||||
response: {
|
||||
status: 422,
|
||||
data: {
|
||||
type: 'validation_error',
|
||||
errors: {
|
||||
original_created_at: ['日時を確認してください.'],
|
||||
original_created_from: ['日時を確認してください.'],
|
||||
original_created_before: ['終了を確認してください.'] },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
render (<PostEditForm post={post} onSave={vi.fn ()}/>)
|
||||
fireEvent.submit (screen.getByRole ('button', { name: '更新' }).closest ('form')!)
|
||||
|
||||
expect (await screen.findByText ('日時を確認してください.')).toBeInTheDocument ()
|
||||
expect (screen.getByText ('終了を確認してください.')).toBeInTheDocument ()
|
||||
expect (screen.getAllByText ('日時を確認してください.')).toHaveLength (1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import PostFormTagsArea from '@/components/PostFormTagsArea'
|
||||
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
||||
import FieldError from '@/components/common/FieldError'
|
||||
import FormField from '@/components/common/FormField'
|
||||
import { useDialogue } from '@/components/dialogues/DialogueProvider'
|
||||
import PostDurationField from '@/components/posts/PostDurationField'
|
||||
import PostTagsField from '@/components/posts/PostTagsField'
|
||||
import PostTextField from '@/components/posts/PostTextField'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { toast } from '@/components/ui/use-toast'
|
||||
import { isApiError } from '@/lib/api'
|
||||
import useDialogue from '@/lib/dialogues/useDialogue'
|
||||
import { updatePost } from '@/lib/posts'
|
||||
import { inputClass, msToTime } from '@/lib/utils'
|
||||
import { msToTime } from '@/lib/utils'
|
||||
import { useValidationErrors } from '@/lib/useValidationErrors'
|
||||
|
||||
import type { FC, FormEvent } from 'react'
|
||||
@@ -17,7 +18,11 @@ import type { FC, FormEvent } from 'react'
|
||||
import type { Post, TagWithSections } from '@/types'
|
||||
|
||||
type PostFormField =
|
||||
'parentPostIds' | 'tags' | 'videoMs' | 'originalCreatedAt'
|
||||
'parentPostIds' | 'tags' | 'videoMs'
|
||||
| 'originalCreatedAt' | 'originalCreatedFrom' | 'originalCreatedBefore'
|
||||
|
||||
const groupedMessages = (...values: (string[] | undefined)[]): string[] =>
|
||||
[...new Set (values.flatMap (value => value ?? []))]
|
||||
|
||||
const videoMsToDurationValue = (videoMs: number | null): string =>
|
||||
videoMs == null ? '' : String (videoMs / 1_000)
|
||||
@@ -157,32 +162,20 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
||||
<form onSubmit={handleSubmit} className="max-w-xl pt-2 space-y-4">
|
||||
<FieldError messages={baseErrors}/>
|
||||
|
||||
{/* タイトル */}
|
||||
<FormField label="タイトル">
|
||||
{({ invalid }) => (
|
||||
<input
|
||||
type="text"
|
||||
disabled={disabled}
|
||||
className={inputClass (invalid)}
|
||||
value={title ?? ''}
|
||||
onChange={e => setTitle (e.target.value)}/>)}
|
||||
</FormField>
|
||||
<PostTextField
|
||||
label="タイトル"
|
||||
value={title ?? ''}
|
||||
disabled={disabled}
|
||||
onChange={setTitle}/>
|
||||
|
||||
{/* 親投稿 */}
|
||||
<FormField label="親投稿" messages={fieldErrors.parentPostIds}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<input
|
||||
type="text"
|
||||
disabled={disabled}
|
||||
value={parentPostIds}
|
||||
onChange={e => setParentPostIds (e.target.value)}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
className={inputClass (invalid)}/>)}
|
||||
</FormField>
|
||||
<PostTextField
|
||||
label="親投稿"
|
||||
value={parentPostIds}
|
||||
disabled={disabled}
|
||||
errors={fieldErrors.parentPostIds}
|
||||
onChange={setParentPostIds}/>
|
||||
|
||||
{/* タグ */}
|
||||
<PostFormTagsArea
|
||||
<PostTagsField
|
||||
disabled={disabled}
|
||||
tags={tags}
|
||||
setTags={setTags}
|
||||
@@ -195,21 +188,17 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
||||
setOriginalCreatedFrom={setOriginalCreatedFrom}
|
||||
originalCreatedBefore={originalCreatedBefore}
|
||||
setOriginalCreatedBefore={setOriginalCreatedBefore}
|
||||
errors={fieldErrors.originalCreatedAt}/>
|
||||
errors={groupedMessages (
|
||||
fieldErrors.originalCreatedAt,
|
||||
fieldErrors.originalCreatedFrom,
|
||||
fieldErrors.originalCreatedBefore)}/>
|
||||
|
||||
{/* 動画時間 */}
|
||||
{videoFlg && (
|
||||
<FormField label="動画時間" messages={fieldErrors.videoMs}>
|
||||
{({ invalid }) => (
|
||||
<input
|
||||
type="number"
|
||||
min="0.001"
|
||||
step="0.001"
|
||||
disabled={disabled}
|
||||
className={inputClass (invalid)}
|
||||
value={duration}
|
||||
onChange={e => setDuration (e.target.value)}/>)}
|
||||
</FormField>)}
|
||||
<PostDurationField
|
||||
value={duration}
|
||||
disabled={disabled}
|
||||
errors={fieldErrors.videoMs}
|
||||
onChange={setDuration}/>)}
|
||||
|
||||
{/* 送信 */}
|
||||
<Button type="submit" disabled={disabled}>
|
||||
|
||||
@@ -20,8 +20,10 @@ describe ('PostOriginalCreatedTimeField', () => {
|
||||
fireEvent.change (inputs[0], { target: { value: '2026-01-02T03:04' } })
|
||||
fireEvent.change (inputs[1], { target: { value: '2026-01-03T03:04' } })
|
||||
|
||||
expect (setFrom).toHaveBeenCalledWith (expect.any (String))
|
||||
expect (setBefore).toHaveBeenCalledWith (expect.any (String))
|
||||
expect (setFrom).toHaveBeenCalledWith (expect.stringMatching (
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$/))
|
||||
expect (setBefore).toHaveBeenCalledWith (expect.stringMatching (
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$/))
|
||||
})
|
||||
|
||||
it ('infers an exclusive before value on blur', () => {
|
||||
@@ -38,7 +40,43 @@ describe ('PostOriginalCreatedTimeField', () => {
|
||||
const input = screen.getAllByDisplayValue ('')[0]
|
||||
fireEvent.blur (input, { target: { value: '2026-01-02T03:04' } })
|
||||
|
||||
expect (setBefore).toHaveBeenCalledWith (expect.any (String))
|
||||
const value = setBefore.mock.calls.at (-1)?.[0]
|
||||
expect (value).toMatch (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$/)
|
||||
expect (new Date (value).getTime () - new Date ('2026-01-02T03:04').getTime ())
|
||||
.toBe (60_000)
|
||||
})
|
||||
|
||||
it ('does not rewrite mounted values that only differ by offset notation', () => {
|
||||
const setFrom = vi.fn ()
|
||||
const setBefore = vi.fn ()
|
||||
|
||||
render (
|
||||
<PostOriginalCreatedTimeField
|
||||
originalCreatedFrom="2024-01-01T12:34+09:00"
|
||||
setOriginalCreatedFrom={setFrom}
|
||||
originalCreatedBefore="2024-01-01T12:35+09:00"
|
||||
setOriginalCreatedBefore={setBefore}/>,
|
||||
)
|
||||
|
||||
expect (setFrom).not.toHaveBeenCalled ()
|
||||
expect (setBefore).not.toHaveBeenCalled ()
|
||||
})
|
||||
|
||||
it ('emits minute-precision UTC values only when the user edits the input', () => {
|
||||
const setFrom = vi.fn ()
|
||||
|
||||
render (
|
||||
<PostOriginalCreatedTimeField
|
||||
originalCreatedFrom="2024-01-01T12:34+09:00"
|
||||
setOriginalCreatedFrom={setFrom}
|
||||
originalCreatedBefore={null}
|
||||
setOriginalCreatedBefore={vi.fn ()}/>,
|
||||
)
|
||||
|
||||
const input = screen.getDisplayValue ('2024-01-01T12:34')
|
||||
fireEvent.change (input, { target: { value: '2024-01-01T12:35' } })
|
||||
|
||||
expect (setFrom).toHaveBeenCalledWith ('2024-01-01T03:35Z')
|
||||
})
|
||||
|
||||
it ('resets both values', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import DateTimeField from '@/components/common/DateTimeField'
|
||||
import DateTimeField, { toMinutePrecisionIsoUtc } from '@/components/common/DateTimeField'
|
||||
import FormField from '@/components/common/FormField'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
@@ -19,72 +19,74 @@ const PostOriginalCreatedTimeField: FC<Props> = (
|
||||
setOriginalCreatedFrom,
|
||||
originalCreatedBefore,
|
||||
setOriginalCreatedBefore,
|
||||
errors }: Props) => (
|
||||
<FormField label="オリジナルの作成日時" messages={errors}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<>
|
||||
<div className="my-1 flex flex-col gap-2 sm:flex-row sm:items-start">
|
||||
<div className="min-w-0 flex-1">
|
||||
<DateTimeField
|
||||
className="w-full"
|
||||
disabled={disabled ?? false}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
invalid={invalid}
|
||||
value={originalCreatedFrom ?? undefined}
|
||||
onChange={setOriginalCreatedFrom}
|
||||
onBlur={ev => {
|
||||
const v = ev.target.value
|
||||
if (!(v))
|
||||
return
|
||||
errors }: Props) => {
|
||||
return (
|
||||
<FormField label="オリジナルの作成日時" messages={errors}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<>
|
||||
<div className="my-1 flex flex-col gap-2 sm:flex-row sm:items-start">
|
||||
<div className="min-w-0 flex-1">
|
||||
<DateTimeField
|
||||
className="w-full"
|
||||
disabled={disabled ?? false}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
invalid={invalid}
|
||||
value={originalCreatedFrom ?? undefined}
|
||||
onChange={setOriginalCreatedFrom}
|
||||
onBlur={ev => {
|
||||
const v = ev.target.value
|
||||
if (!(v))
|
||||
return
|
||||
|
||||
const d = new Date (v)
|
||||
if (d.getMinutes () === 0 && d.getHours () === 0)
|
||||
d.setDate (d.getDate () + 1)
|
||||
else
|
||||
d.setMinutes (d.getMinutes () + 1)
|
||||
setOriginalCreatedBefore (d.toISOString ())
|
||||
}}/>
|
||||
以降
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-gray-600 text-white rounded"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
setOriginalCreatedFrom (null)
|
||||
}}>
|
||||
リセット
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
const d = new Date (v)
|
||||
if (d.getMinutes () === 0 && d.getHours () === 0)
|
||||
d.setDate (d.getDate () + 1)
|
||||
else
|
||||
d.setMinutes (d.getMinutes () + 1)
|
||||
setOriginalCreatedBefore (toMinutePrecisionIsoUtc (d.toISOString ()))
|
||||
}}/>
|
||||
以降
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-gray-600 text-white rounded"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
setOriginalCreatedFrom (null)
|
||||
}}>
|
||||
リセット
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="my-1 flex flex-col gap-2 sm:flex-row sm:items-start">
|
||||
<div className="min-w-0 flex-1">
|
||||
<DateTimeField
|
||||
className="w-full"
|
||||
disabled={disabled}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
invalid={invalid}
|
||||
value={originalCreatedBefore ?? undefined}
|
||||
onChange={setOriginalCreatedBefore}/>
|
||||
より前
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-gray-600 text-white rounded"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
setOriginalCreatedBefore (null)
|
||||
}}>
|
||||
リセット
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>)}
|
||||
</FormField>)
|
||||
<div className="my-1 flex flex-col gap-2 sm:flex-row sm:items-start">
|
||||
<div className="min-w-0 flex-1">
|
||||
<DateTimeField
|
||||
className="w-full"
|
||||
disabled={disabled}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
invalid={invalid}
|
||||
value={originalCreatedBefore ?? undefined}
|
||||
onChange={setOriginalCreatedBefore}/>
|
||||
より前
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-gray-600 text-white rounded"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
setOriginalCreatedBefore (null)
|
||||
}}>
|
||||
リセット
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>)}
|
||||
</FormField>)
|
||||
}
|
||||
|
||||
export default PostOriginalCreatedTimeField
|
||||
|
||||
@@ -8,7 +8,8 @@ const submenuItem = (role: 'guest' | 'member' | 'admin', section: string, item:
|
||||
user: buildUser ({ role }),
|
||||
wikiId: section === 'Wiki' ? 10 : null,
|
||||
pathName: section === 'Wiki' ? '/wiki/page' : '/posts' })
|
||||
return menu.find (_1 => _1.name === section)?.subMenu.find (_1 => _1.name === item)
|
||||
return menu.find (entry => entry.name === section)?.subMenu.find (
|
||||
subMenuItem => subMenuItem.name === item)
|
||||
}
|
||||
|
||||
describe ('menuOutline', () => {
|
||||
|
||||
@@ -19,7 +19,7 @@ import type { FC, MouseEvent } from 'react'
|
||||
|
||||
import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types'
|
||||
|
||||
type Props = { user: User | null, }
|
||||
type Props = { user: User | null }
|
||||
|
||||
|
||||
export const menuOutline = (
|
||||
|
||||
@@ -21,7 +21,8 @@ describe ('DateTimeField', () => {
|
||||
fireEvent.change (input, { target: { value: '' } })
|
||||
|
||||
const first = handleChange.mock.calls[0]?.[0]
|
||||
expect (new Date (first).getFullYear ()).toBe (2026)
|
||||
expect (first).toMatch (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$/)
|
||||
expect (new Date (first).getUTCSeconds ()).toBe (0)
|
||||
expect (handleChange).toHaveBeenLastCalledWith (null)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,13 +8,27 @@ import type { ComponentPropsWithoutRef, FC, FocusEvent } from 'react'
|
||||
const pad = (n: number): string => n.toString ().padStart (2, '0')
|
||||
|
||||
|
||||
const toDateTimeLocalValue = (d: Date) => {
|
||||
const y = d.getFullYear ()
|
||||
const m = pad (d.getMonth () + 1)
|
||||
const day = pad (d.getDate ())
|
||||
const h = pad (d.getHours ())
|
||||
const min = pad (d.getMinutes ())
|
||||
return `${ y }-${ m }-${ day }T${ h }:${ min }:00`
|
||||
const toDateTimeLocalValue = (value: Date) => {
|
||||
const y = value.getFullYear ()
|
||||
const m = pad (value.getMonth () + 1)
|
||||
const day = pad (value.getDate ())
|
||||
const h = pad (value.getHours ())
|
||||
const min = pad (value.getMinutes ())
|
||||
return `${ y }-${ m }-${ day }T${ h }:${ min }`
|
||||
}
|
||||
|
||||
|
||||
const toMinutePrecisionIsoUtc = (value: string) => {
|
||||
const date = new Date (value)
|
||||
if (Number.isNaN (date.getTime ()))
|
||||
return value
|
||||
|
||||
const y = date.getUTCFullYear ()
|
||||
const m = pad (date.getUTCMonth () + 1)
|
||||
const day = pad (date.getUTCDate ())
|
||||
const h = pad (date.getUTCHours ())
|
||||
const min = pad (date.getUTCMinutes ())
|
||||
return `${ y }-${ m }-${ day }T${ h }:${ min }Z`
|
||||
}
|
||||
|
||||
|
||||
@@ -47,14 +61,16 @@ const DateTimeField: FC<Props> = ({ value, onChange, className, onBlur, invalid,
|
||||
'focus:ring-2 focus:ring-blue-200']),
|
||||
className)}
|
||||
type="datetime-local"
|
||||
step={60}
|
||||
value={local}
|
||||
aria-invalid={invalid}
|
||||
onChange={ev => {
|
||||
const v = ev.target.value
|
||||
setLocal (v)
|
||||
onChange?.(v ? (new Date (v)).toISOString () : null)
|
||||
onChange?.(v ? toMinutePrecisionIsoUtc (v) : null)
|
||||
}}
|
||||
onBlur={onBlur}/>)
|
||||
}
|
||||
|
||||
export default DateTimeField
|
||||
export { toMinutePrecisionIsoUtc }
|
||||
|
||||
@@ -86,7 +86,7 @@ const DialogueProvider: FC<Props> = ({ children }) => {
|
||||
|
||||
return current.filter (request => request.id !== id)
|
||||
})
|
||||
setPendingIds (current => current.filter (_1 => _1 !== id))
|
||||
setPendingIds (current => current.filter (pendingId => pendingId !== id))
|
||||
setFormActions (current => {
|
||||
const { [id]: _, ...rest } = current
|
||||
return rest
|
||||
@@ -140,15 +140,15 @@ const DialogueProvider: FC<Props> = ({ children }) => {
|
||||
|
||||
setPendingIds (current => [...current, id])
|
||||
try
|
||||
{
|
||||
const shouldClose = await action.onSelect ()
|
||||
if (shouldClose !== false)
|
||||
closeRequest (id)
|
||||
}
|
||||
{
|
||||
const shouldClose = await action.onSelect ()
|
||||
if (shouldClose !== false)
|
||||
closeRequest (id)
|
||||
}
|
||||
finally
|
||||
{
|
||||
setPendingIds (current => current.filter (_1 => _1 !== id))
|
||||
}
|
||||
{
|
||||
setPendingIds (current => current.filter (pendingId => pendingId !== id))
|
||||
}
|
||||
},
|
||||
[closeRequest, pendingIds])
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import PostTextField from '@/components/posts/PostTextField'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
type Props = {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
errors?: string[]
|
||||
disabled?: boolean }
|
||||
|
||||
|
||||
const PostDurationField: FC<Props> = (
|
||||
{ value,
|
||||
onChange,
|
||||
errors,
|
||||
disabled },
|
||||
) => (
|
||||
<PostTextField
|
||||
label="動画時間"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
errors={errors}
|
||||
disabled={disabled}
|
||||
type="text"
|
||||
placeholder="例: 2 / 2.5 / 1:23"/>
|
||||
)
|
||||
|
||||
export default PostDurationField
|
||||
@@ -0,0 +1,29 @@
|
||||
import PostFormTagsArea from '@/components/PostFormTagsArea'
|
||||
import FieldWarning from '@/components/common/FieldWarning'
|
||||
|
||||
import type { ComponentPropsWithoutRef, FC } from 'react'
|
||||
|
||||
type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange'> & {
|
||||
tags: string
|
||||
setTags: (tags: string) => void
|
||||
warnings?: string[]
|
||||
errors?: string[] }
|
||||
|
||||
|
||||
const PostTagsField: FC<Props> = (
|
||||
{ tags,
|
||||
setTags,
|
||||
warnings,
|
||||
errors,
|
||||
...rest },
|
||||
) => (
|
||||
<div className="space-y-2">
|
||||
<PostFormTagsArea
|
||||
{...rest}
|
||||
tags={tags}
|
||||
setTags={setTags}
|
||||
errors={errors}/>
|
||||
<FieldWarning messages={warnings}/>
|
||||
</div>)
|
||||
|
||||
export default PostTagsField
|
||||
@@ -0,0 +1,49 @@
|
||||
import FieldWarning from '@/components/common/FieldWarning'
|
||||
import FormField from '@/components/common/FormField'
|
||||
import { inputClass } from '@/lib/utils'
|
||||
|
||||
import type { FC, ReactNode } from 'react'
|
||||
|
||||
type Props = {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
warnings?: string[]
|
||||
errors?: string[]
|
||||
disabled?: boolean
|
||||
type?: string
|
||||
placeholder?: string
|
||||
className?: string
|
||||
after?: ReactNode }
|
||||
|
||||
|
||||
const PostTextField: FC<Props> = (
|
||||
{ label,
|
||||
value,
|
||||
onChange,
|
||||
warnings,
|
||||
errors,
|
||||
disabled,
|
||||
type = 'text',
|
||||
placeholder,
|
||||
className,
|
||||
after },
|
||||
) => (
|
||||
<FormField label={label} messages={errors}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<>
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
onChange={ev => onChange (ev.target.value)}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
className={inputClass (invalid, className)}/>
|
||||
<FieldWarning messages={warnings}/>
|
||||
{after}
|
||||
</>)}
|
||||
</FormField>)
|
||||
|
||||
export default PostTextField
|
||||
@@ -0,0 +1,12 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
|
||||
|
||||
describe ('PostThumbnailPreview', () => {
|
||||
it ('keeps an existing blob preview URL unchanged for normal post forms', () => {
|
||||
render (<PostThumbnailPreview url="blob:preview" className="h-10 w-10"/>)
|
||||
|
||||
expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:preview')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
alt?: string
|
||||
className?: string }
|
||||
|
||||
|
||||
const PostThumbnailPreview: FC<Props> = (
|
||||
{ url, alt = 'サムネール', className = 'h-16 w-16' },
|
||||
) => {
|
||||
const [failed, setFailed] = useState (false)
|
||||
|
||||
useEffect (() => {
|
||||
setFailed (false)
|
||||
}, [url])
|
||||
|
||||
if (!(url))
|
||||
{
|
||||
return (
|
||||
<div
|
||||
className={`${ className } flex items-center justify-center rounded border
|
||||
border-border bg-muted text-xs text-muted-foreground`}>
|
||||
なし
|
||||
</div>)
|
||||
}
|
||||
|
||||
if (failed)
|
||||
{
|
||||
return (
|
||||
<div
|
||||
className={`${ className } flex items-center justify-center rounded border
|
||||
border-amber-300 bg-amber-50 p-2 text-center text-xs
|
||||
text-amber-700 dark:border-amber-900 dark:bg-amber-950
|
||||
dark:text-amber-200`}>
|
||||
サムネールを表示できません
|
||||
</div>)
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt={alt}
|
||||
className={`${ className } rounded border border-border object-cover`}
|
||||
onError={() => setFailed (true)}/>)
|
||||
}
|
||||
|
||||
export default PostThumbnailPreview
|
||||
@@ -1,12 +1,25 @@
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import PostImportRowForm from '@/components/posts/import/PostImportRowForm'
|
||||
import { buildPostImportRow } from '@/test/postImportFactories'
|
||||
|
||||
import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
||||
|
||||
const api = vi.hoisted (() => ({
|
||||
apiGet: vi.fn (),
|
||||
}))
|
||||
|
||||
vi.mock ('@/lib/api', () => api)
|
||||
|
||||
describe ('PostImportRowForm', () => {
|
||||
beforeEach (() => {
|
||||
vi.clearAllMocks ()
|
||||
globalThis.URL.createObjectURL = vi.fn (() => 'blob:preview')
|
||||
globalThis.URL.revokeObjectURL = vi.fn ()
|
||||
api.apiGet.mockResolvedValue (new Blob (['img'], { type: 'image/png' }))
|
||||
})
|
||||
|
||||
it ('resets only the draft, then saves with resetRequested', async () => {
|
||||
const row = buildPostImportRow ()
|
||||
row.attributes.title = 'manual title'
|
||||
@@ -24,7 +37,7 @@ describe ('PostImportRowForm', () => {
|
||||
const titleInput = screen.getByDisplayValue ('manual title')
|
||||
|
||||
await waitFor (() => expect (actions.at (-1)?.length).toBe (2))
|
||||
const reset = actions.at (-1)?.find (_1 => _1.label === '変更をリセット')
|
||||
const reset = actions.at (-1)?.find (action => action.label === '変更をリセット')
|
||||
expect (reset).toMatchObject ({ placement: 'start', variant: 'danger', disabled: false })
|
||||
await act (async () => {
|
||||
await reset?.onSelect ()
|
||||
@@ -34,7 +47,7 @@ describe ('PostImportRowForm', () => {
|
||||
expect (titleInput).toHaveValue ('')
|
||||
expect (onSave).not.toHaveBeenCalled ()
|
||||
|
||||
const save = actions.at (-1)?.find (_1 => _1.label === '編輯内容を保存')
|
||||
const save = actions.at (-1)?.find (action => action.label === '編輯内容を保存')
|
||||
await act (async () => {
|
||||
await save?.onSelect ()
|
||||
})
|
||||
@@ -63,7 +76,7 @@ describe ('PostImportRowForm', () => {
|
||||
await waitFor (() => expect (actions.length).toBe (2))
|
||||
|
||||
await act (async () => {
|
||||
await actions.find (_1 => _1.label === '変更をリセット')?.onSelect ()
|
||||
await actions.find (action => action.label === '変更をリセット')?.onSelect ()
|
||||
})
|
||||
|
||||
expect (screen.getByDisplayValue ('manual title')).toBeInTheDocument ()
|
||||
@@ -86,6 +99,163 @@ describe ('PostImportRowForm', () => {
|
||||
expect (screen.getByText ('duration error')).toBeInTheDocument ()
|
||||
expect (screen.getByText ('title warning')).toBeInTheDocument ()
|
||||
expect (screen.getAllByRole ('textbox').filter (
|
||||
_1 => _1.getAttribute ('aria-invalid') === 'true')).toHaveLength (3)
|
||||
textbox => textbox.getAttribute ('aria-invalid') === 'true')).toHaveLength (3)
|
||||
})
|
||||
|
||||
it ('keeps untouched original created values unchanged in the save payload', async () => {
|
||||
let actions: DialogueFormAction[] = []
|
||||
const row = buildPostImportRow ({
|
||||
attributes: {
|
||||
originalCreatedFrom: '2024-01-01T12:34+09:00',
|
||||
originalCreatedBefore: '2024-01-01T12:35+09:00' } })
|
||||
const controls: DialogueFormControls = {
|
||||
close: vi.fn (),
|
||||
confirm: vi.fn (),
|
||||
setActions: next => {
|
||||
actions = next
|
||||
} }
|
||||
const onSave = vi.fn ().mockResolvedValue ({ saved: true, row: null })
|
||||
|
||||
render (
|
||||
<PostImportRowForm
|
||||
row={row}
|
||||
controls={controls}
|
||||
onSave={onSave}/>)
|
||||
await waitFor (() => expect (actions.length).toBe (2))
|
||||
|
||||
await act (async () => {
|
||||
void actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
|
||||
})
|
||||
|
||||
expect (onSave).toHaveBeenCalledWith ({
|
||||
draft: expect.objectContaining ({
|
||||
originalCreatedFrom: '2024-01-01T12:34+09:00',
|
||||
originalCreatedBefore: '2024-01-01T12:35+09:00' }),
|
||||
resetRequested: false })
|
||||
})
|
||||
|
||||
it ('keeps the shared duration and tags string contract without leaking file upload UI', async () => {
|
||||
let actions: DialogueFormAction[] = []
|
||||
const controls: DialogueFormControls = {
|
||||
close: vi.fn (),
|
||||
confirm: vi.fn (),
|
||||
setActions: next => {
|
||||
actions = next
|
||||
} }
|
||||
const onSave = vi.fn ().mockResolvedValue ({ saved: true, row: null })
|
||||
|
||||
const { container } = render (
|
||||
<PostImportRowForm
|
||||
row={buildPostImportRow ({
|
||||
attributes: { duration: '2', tags: 'tag1' } })}
|
||||
controls={controls}
|
||||
onSave={onSave}/>)
|
||||
await waitFor (() => expect (actions.length).toBe (2))
|
||||
|
||||
expect (container.querySelector ('input[type="file"]')).toBeNull ()
|
||||
expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('2')
|
||||
expect (screen.getByDisplayValue ('tag1')).toBeInTheDocument ()
|
||||
|
||||
await act (async () => {
|
||||
await actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
|
||||
})
|
||||
|
||||
expect (onSave).toHaveBeenCalledWith ({
|
||||
draft: expect.objectContaining ({
|
||||
duration: '2',
|
||||
tags: 'tag1' }),
|
||||
resetRequested: false })
|
||||
})
|
||||
|
||||
it ('keeps reset enabled when the value matches but provenance still differs', async () => {
|
||||
const row = buildPostImportRow ({
|
||||
attributes: { title: 'same title' },
|
||||
provenance: { title: 'manual' },
|
||||
resetSnapshot: {
|
||||
url: 'https://example.com/post',
|
||||
attributes: {
|
||||
title: 'same title',
|
||||
thumbnailBase: '',
|
||||
originalCreatedFrom: '',
|
||||
originalCreatedBefore: '',
|
||||
duration: '',
|
||||
tags: '',
|
||||
parentPostIds: '' },
|
||||
provenance: {
|
||||
url: 'manual',
|
||||
title: 'automatic',
|
||||
thumbnailBase: 'automatic',
|
||||
originalCreatedFrom: 'automatic',
|
||||
originalCreatedBefore: 'automatic',
|
||||
duration: 'automatic',
|
||||
tags: 'automatic',
|
||||
parentPostIds: 'automatic' },
|
||||
tagSources: { automatic: '', manual: '' },
|
||||
fieldWarnings: { },
|
||||
baseWarnings: [] } })
|
||||
let actions: DialogueFormAction[] = []
|
||||
|
||||
render (
|
||||
<PostImportRowForm
|
||||
row={row}
|
||||
controls={{
|
||||
close: vi.fn (),
|
||||
confirm: vi.fn (),
|
||||
setActions: next => {
|
||||
actions = next
|
||||
} }}
|
||||
onSave={vi.fn ()}/>)
|
||||
await waitFor (() => expect (actions.length).toBe (2))
|
||||
|
||||
expect (actions.find (action => action.label === '変更をリセット')?.disabled).toBe (false)
|
||||
})
|
||||
|
||||
it ('disables every field while save validation is pending and re-enables them afterwards', async () => {
|
||||
let actions: DialogueFormAction[] = []
|
||||
let resolveSave:
|
||||
((value: { saved: boolean
|
||||
row: ReturnType<typeof buildPostImportRow> | null }) => void) | null
|
||||
= null
|
||||
const onSave = vi.fn (() =>
|
||||
new Promise<{ saved: boolean
|
||||
row: ReturnType<typeof buildPostImportRow> | null }> (resolve => {
|
||||
resolveSave = resolve
|
||||
}))
|
||||
|
||||
render (
|
||||
<PostImportRowForm
|
||||
row={buildPostImportRow ({ attributes: { title: 'draft title' } })}
|
||||
controls={{
|
||||
close: vi.fn (),
|
||||
confirm: vi.fn (),
|
||||
setActions: next => {
|
||||
actions = next
|
||||
} }}
|
||||
onSave={onSave}/>)
|
||||
await waitFor (() => expect (actions.length).toBe (2))
|
||||
|
||||
await act (async () => {
|
||||
await actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
|
||||
})
|
||||
|
||||
await waitFor (() => {
|
||||
screen.getAllByRole ('textbox').forEach (textbox => {
|
||||
expect (textbox).toBeDisabled ()
|
||||
})
|
||||
})
|
||||
|
||||
resolveSave?.({
|
||||
saved: false,
|
||||
row: buildPostImportRow ({
|
||||
attributes: { title: 'draft title' },
|
||||
validationErrors: { title: ['タイトルを確認してください.'] } }) })
|
||||
|
||||
await waitFor (() => {
|
||||
screen.getAllByRole ('textbox').forEach (textbox => {
|
||||
expect (textbox).not.toBeDisabled ()
|
||||
})
|
||||
})
|
||||
expect (screen.getByDisplayValue ('draft title')).toBeInTheDocument ()
|
||||
expect (screen.getByText ('タイトルを確認してください.')).toBeInTheDocument ()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,25 +3,17 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
||||
import FieldError from '@/components/common/FieldError'
|
||||
import FieldWarning from '@/components/common/FieldWarning'
|
||||
import FormField from '@/components/common/FormField'
|
||||
import TextArea from '@/components/common/TextArea'
|
||||
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview'
|
||||
import { inputClass } from '@/lib/utils'
|
||||
import PostDurationField from '@/components/posts/PostDurationField'
|
||||
import PostTagsField from '@/components/posts/PostTagsField'
|
||||
import PostTextField from '@/components/posts/PostTextField'
|
||||
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { PostImportRow } from '@/lib/postImportSession'
|
||||
import type { PostImportEditableDraft, PostImportRow } from '@/lib/postImportSession'
|
||||
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
||||
|
||||
type Draft = {
|
||||
url: string
|
||||
title: string
|
||||
thumbnailBase: string
|
||||
originalCreatedFrom: string
|
||||
originalCreatedBefore: string
|
||||
duration: string
|
||||
tags: string
|
||||
parentPostIds: string }
|
||||
type Draft = PostImportEditableDraft
|
||||
|
||||
type Props = {
|
||||
row: PostImportRow
|
||||
@@ -52,7 +44,7 @@ const buildResetDraft = (row: PostImportRow): Draft => ({
|
||||
parentPostIds: String (row.resetSnapshot.attributes.parentPostIds ?? '') })
|
||||
|
||||
const groupedMessages = (...values: (string[] | undefined)[]): string[] =>
|
||||
values.flatMap (value => value ?? [])
|
||||
[...new Set (values.flatMap (value => value ?? []))]
|
||||
|
||||
const sameDraft = (left: Draft, right: Draft): boolean =>
|
||||
left.url === right.url
|
||||
@@ -64,6 +56,19 @@ const sameDraft = (left: Draft, right: Draft): boolean =>
|
||||
&& left.tags === right.tags
|
||||
&& left.parentPostIds === right.parentPostIds
|
||||
|
||||
const sameProvenance = (
|
||||
current: PostImportRow['provenance'],
|
||||
reset: PostImportRow['resetSnapshot']['provenance'],
|
||||
): boolean =>
|
||||
Object.keys (reset).every (field => current[field] === reset[field])
|
||||
|
||||
const sameTagSources = (
|
||||
current: PostImportRow['tagSources'],
|
||||
reset: PostImportRow['resetSnapshot']['tagSources'],
|
||||
): boolean =>
|
||||
(current?.automatic ?? '') === reset.automatic
|
||||
&& (current?.manual ?? '') === reset.manual
|
||||
|
||||
|
||||
const PostImportRowForm: FC<Props> = (
|
||||
{ row,
|
||||
@@ -86,7 +91,12 @@ const PostImportRowForm: FC<Props> = (
|
||||
const resetDraft = useMemo (
|
||||
() => buildResetDraft (row),
|
||||
[row])
|
||||
const resetDisabled = saving || sameDraft (draft, resetDraft)
|
||||
const resetDisabled =
|
||||
saving
|
||||
|| (sameDraft (draft, resetDraft)
|
||||
&& sameProvenance (row.provenance, row.resetSnapshot.provenance)
|
||||
&& sameTagSources (row.tagSources, row.resetSnapshot.tagSources)
|
||||
&& row.metadataUrl === row.resetSnapshot.metadataUrl)
|
||||
|
||||
const update = <Key extends keyof Draft,> (
|
||||
key: Key,
|
||||
@@ -119,19 +129,19 @@ const PostImportRowForm: FC<Props> = (
|
||||
const save = useCallback (async (): Promise<boolean> => {
|
||||
setSaving (true)
|
||||
try
|
||||
{
|
||||
const result = await onSave ({ draft, resetRequested })
|
||||
if (result.saved)
|
||||
return true
|
||||
{
|
||||
const result = await onSave ({ draft, resetRequested })
|
||||
if (result.saved)
|
||||
return true
|
||||
|
||||
if (result.row != null)
|
||||
setMessageRow (result.row)
|
||||
return false
|
||||
}
|
||||
if (result.row != null)
|
||||
setMessageRow (result.row)
|
||||
return false
|
||||
}
|
||||
finally
|
||||
{
|
||||
setSaving (false)
|
||||
}
|
||||
{
|
||||
setSaving (false)
|
||||
}
|
||||
}, [draft, onSave, resetRequested])
|
||||
|
||||
useEffect (() => {
|
||||
@@ -153,37 +163,41 @@ const PostImportRowForm: FC<Props> = (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-6 md:grid-cols-[7rem_minmax(0,1fr)]">
|
||||
<div className="space-y-3 md:sticky md:top-0 md:self-start">
|
||||
<ThumbnailPreview
|
||||
<PostImportThumbnailPreview
|
||||
url={draft.thumbnailBase}
|
||||
className="h-28 w-28"/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<PostImportTextField
|
||||
<PostTextField
|
||||
label="URL"
|
||||
value={draft.url}
|
||||
disabled={saving}
|
||||
warnings={displayRow.fieldWarnings.url}
|
||||
errors={groupedMessages (
|
||||
displayRow.validationErrors.url,
|
||||
displayRow.importErrors?.url)}
|
||||
onChange={value => update ('url', value)}/>
|
||||
<PostImportTextField
|
||||
<PostTextField
|
||||
label="タイトル"
|
||||
value={draft.title}
|
||||
disabled={saving}
|
||||
warnings={displayRow.fieldWarnings.title}
|
||||
errors={groupedMessages (
|
||||
displayRow.validationErrors.title,
|
||||
displayRow.importErrors?.title)}
|
||||
onChange={value => update ('title', value)}/>
|
||||
<PostImportTextField
|
||||
<PostTextField
|
||||
label="サムネール基底 URL"
|
||||
value={draft.thumbnailBase}
|
||||
disabled={saving}
|
||||
warnings={displayRow.fieldWarnings.thumbnailBase}
|
||||
errors={groupedMessages (
|
||||
displayRow.validationErrors.thumbnailBase,
|
||||
displayRow.importErrors?.thumbnailBase)}
|
||||
onChange={value => update ('thumbnailBase', value)}/>
|
||||
<PostOriginalCreatedTimeField
|
||||
disabled={saving}
|
||||
originalCreatedFrom={draft.originalCreatedFrom || null}
|
||||
setOriginalCreatedFrom={value => update ('originalCreatedFrom', value ?? '')}
|
||||
originalCreatedBefore={draft.originalCreatedBefore || null}
|
||||
@@ -195,26 +209,28 @@ const PostImportRowForm: FC<Props> = (
|
||||
displayRow.importErrors?.originalCreatedAt,
|
||||
displayRow.importErrors?.originalCreatedFrom,
|
||||
displayRow.importErrors?.originalCreatedBefore)}/>
|
||||
<PostImportTextField
|
||||
label="動画時間"
|
||||
<PostDurationField
|
||||
value={draft.duration}
|
||||
disabled={saving}
|
||||
errors={groupedMessages (
|
||||
displayRow.validationErrors.duration,
|
||||
displayRow.validationErrors.videoMs,
|
||||
displayRow.importErrors?.duration,
|
||||
displayRow.importErrors?.videoMs)}
|
||||
onChange={value => update ('duration', value)}/>
|
||||
<PostImportAreaField
|
||||
label="タグ"
|
||||
value={draft.tags}
|
||||
<PostTagsField
|
||||
tags={draft.tags}
|
||||
disabled={saving}
|
||||
setTags={value => update ('tags', value)}
|
||||
warnings={displayRow.fieldWarnings.tags}
|
||||
errors={groupedMessages (
|
||||
displayRow.validationErrors.tags,
|
||||
displayRow.importErrors?.tags)}
|
||||
onChange={value => update ('tags', value)}/>
|
||||
<PostImportTextField
|
||||
rows={4}/>
|
||||
<PostTextField
|
||||
label="親投稿"
|
||||
value={draft.parentPostIds}
|
||||
disabled={saving}
|
||||
errors={groupedMessages (
|
||||
displayRow.validationErrors.parentPostIds,
|
||||
displayRow.importErrors?.parentPostIds)}
|
||||
@@ -229,49 +245,6 @@ const PostImportRowForm: FC<Props> = (
|
||||
</>)
|
||||
}
|
||||
|
||||
const PostImportTextField = (
|
||||
{ label, value, warnings, errors, onChange }: {
|
||||
label: string
|
||||
value: string
|
||||
warnings?: string[]
|
||||
errors?: string[]
|
||||
onChange: (value: string) => void },
|
||||
) => (
|
||||
<FormField label={label} messages={errors}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<>
|
||||
<input
|
||||
value={value}
|
||||
onChange={ev => onChange (ev.target.value)}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
className={inputClass (invalid)}/>
|
||||
<FieldWarning messages={warnings}/>
|
||||
</>)}
|
||||
</FormField>)
|
||||
|
||||
const PostImportAreaField = (
|
||||
{ label, value, warnings, errors, onChange }: {
|
||||
label: string
|
||||
value: string
|
||||
warnings?: string[]
|
||||
errors?: string[]
|
||||
onChange: (value: string) => void },
|
||||
) => (
|
||||
<FormField label={label} messages={errors}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<>
|
||||
<TextArea
|
||||
value={value}
|
||||
rows={4}
|
||||
onChange={ev => onChange (ev.target.value)}
|
||||
aria-describedby={describedBy}
|
||||
invalid={invalid}
|
||||
className="h-auto"/>
|
||||
<FieldWarning messages={warnings}/>
|
||||
</>)}
|
||||
</FormField>)
|
||||
|
||||
export default PostImportRowForm
|
||||
export { buildDraft }
|
||||
export type { Draft as PostImportRowDraft }
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
|
||||
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
|
||||
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview'
|
||||
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
|
||||
import { canEditReviewRow } from '@/lib/postImportSession'
|
||||
import { cn, originalCreatedAtString } from '@/lib/utils'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { PostImportRow } from '@/lib/postImportSession'
|
||||
|
||||
type Props = {
|
||||
row: PostImportRow
|
||||
onEdit: () => void }
|
||||
row: PostImportRow
|
||||
onEdit: () => void
|
||||
editDisabled?: boolean }
|
||||
|
||||
const summaryWarning = (row: PostImportRow): string | null =>
|
||||
Object.values (row.fieldWarnings ?? { }).flat ()[0]
|
||||
@@ -19,94 +20,101 @@ const summaryWarning = (row: PostImportRow): string | null =>
|
||||
?? null
|
||||
|
||||
const summaryDate = (row: PostImportRow): string =>
|
||||
[row.attributes.originalCreatedFrom, row.attributes.originalCreatedBefore]
|
||||
.filter (_1 => typeof _1 === 'string' && _1 !== '')
|
||||
.join (' ~ ')
|
||||
originalCreatedAtString (
|
||||
row.attributes.originalCreatedFrom?.toString () ?? null,
|
||||
row.attributes.originalCreatedBefore?.toString () ?? null)
|
||||
|
||||
|
||||
const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
|
||||
const warning = summaryWarning (row)
|
||||
const displayStatus = displayPostImportStatus (row)
|
||||
const PostImportRowSummary: FC<Props> = ({ row, onEdit, editDisabled }) => {
|
||||
const warning = summaryWarning (row)
|
||||
const displayStatus = displayPostImportStatus (row)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn (
|
||||
'hidden items-center gap-4 rounded-lg border p-4 md:grid',
|
||||
'md:grid-cols-[4rem_5rem_minmax(0,1fr)_auto_auto]',
|
||||
'transition-shadow hover:shadow-sm')}>
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">#{row.sourceRow}</div>
|
||||
</div>
|
||||
<ThumbnailPreview
|
||||
url={String (row.attributes.thumbnailBase ?? '')}
|
||||
className="h-16 w-16"/>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="line-clamp-2 text-sm font-medium">
|
||||
{String (row.attributes.title ?? '') || '(タイトル未取得)'}
|
||||
</div>
|
||||
<div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
|
||||
{row.url}
|
||||
</div>
|
||||
<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">
|
||||
{summaryDate (row) || '日時未取得'}
|
||||
{row.attributes.duration ? ` / ${ row.attributes.duration }` : ''}
|
||||
</div>
|
||||
{warning && (
|
||||
<div className="text-xs text-amber-700 dark:text-amber-200">
|
||||
{warning}
|
||||
</div>)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onEdit}
|
||||
disabled={row.importStatus === 'created'}>
|
||||
編輯
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn (
|
||||
'hidden items-center gap-4 rounded-lg border p-4 md:grid',
|
||||
'md:grid-cols-[4rem_5rem_minmax(0,1fr)_auto_auto]',
|
||||
'transition-shadow hover:shadow-sm')}>
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">#{row.sourceRow}</div>
|
||||
</div>
|
||||
<PostImportThumbnailPreview
|
||||
url={String (row.attributes.thumbnailBase ?? '')}
|
||||
className="h-16 w-16"/>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="line-clamp-2 text-sm font-medium">
|
||||
{String (row.attributes.title ?? '') || '(タイトル未取得)'}
|
||||
</div>
|
||||
<div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
|
||||
{row.url}
|
||||
</div>
|
||||
<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">
|
||||
{summaryDate (row)}
|
||||
{row.attributes.duration ? ` / ${ row.attributes.duration }` : ''}
|
||||
</div>
|
||||
{warning && (
|
||||
<div className="text-xs text-amber-700 dark:text-amber-200">
|
||||
{warning}
|
||||
</div>)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onEdit}
|
||||
disabled={!(canEditReviewRow (row)) || editDisabled === true}>
|
||||
編輯
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn (
|
||||
'space-y-3 rounded-lg border p-4 md:hidden',
|
||||
'transition-shadow hover:shadow-sm')}>
|
||||
<div className="flex items-start gap-3">
|
||||
<ThumbnailPreview
|
||||
url={String (row.attributes.thumbnailBase ?? '')}
|
||||
className="h-20 w-20 shrink-0"/>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="line-clamp-2 text-sm font-medium">
|
||||
{String (row.attributes.title ?? '') || '(タイトル未取得)'}
|
||||
</div>
|
||||
<div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
|
||||
{row.url}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
|
||||
</div>
|
||||
{warning && (
|
||||
<div className="text-xs text-amber-700 dark:text-amber-200">
|
||||
{warning}
|
||||
</div>)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onEdit}
|
||||
disabled={row.importStatus === 'created'}>
|
||||
編輯
|
||||
</Button>
|
||||
</div>
|
||||
</>)
|
||||
<div
|
||||
className={cn (
|
||||
'space-y-3 rounded-lg border p-4 md:hidden',
|
||||
'transition-shadow hover:shadow-sm')}>
|
||||
<div className="flex items-start gap-3">
|
||||
<PostImportThumbnailPreview
|
||||
url={String (row.attributes.thumbnailBase ?? '')}
|
||||
className="h-20 w-20 shrink-0"/>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="line-clamp-2 text-sm font-medium">
|
||||
{String (row.attributes.title ?? '') || '(タイトル未取得)'}
|
||||
</div>
|
||||
<div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
|
||||
{row.url}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{String (row.attributes.tags ?? '') || 'タグなし'}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{summaryDate (row)}
|
||||
{row.attributes.duration ? ` / ${ row.attributes.duration }` : ''}
|
||||
</div>
|
||||
{warning && (
|
||||
<div className="text-xs text-amber-700 dark:text-amber-200">
|
||||
{warning}
|
||||
</div>)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onEdit}
|
||||
disabled={!(canEditReviewRow (row)) || editDisabled === true}>
|
||||
編輯
|
||||
</Button>
|
||||
</div>
|
||||
</>)
|
||||
}
|
||||
|
||||
export default PostImportRowSummary
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
|
||||
|
||||
const api = vi.hoisted (() => ({
|
||||
apiGet: vi.fn (),
|
||||
}))
|
||||
|
||||
vi.mock ('@/lib/api', () => api)
|
||||
|
||||
describe ('PostImportThumbnailPreview', () => {
|
||||
beforeEach (() => {
|
||||
vi.clearAllMocks ()
|
||||
globalThis.URL.createObjectURL = vi.fn (() => 'blob:preview')
|
||||
globalThis.URL.revokeObjectURL = vi.fn ()
|
||||
})
|
||||
|
||||
it ('uses a backend-fetched blob URL instead of the external thumbnail URL directly', async () => {
|
||||
api.apiGet.mockResolvedValueOnce (new Blob (['img'], { type: 'image/png' }))
|
||||
|
||||
render (
|
||||
<PostImportThumbnailPreview
|
||||
url="https://example.com/thumbnail.jpg"
|
||||
className="h-10 w-10"/>)
|
||||
|
||||
await waitFor (() => {
|
||||
expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:preview')
|
||||
})
|
||||
expect (screen.getByRole ('img')).not.toHaveAttribute (
|
||||
'src',
|
||||
'https://example.com/thumbnail.jpg')
|
||||
expect (api.apiGet).toHaveBeenCalledWith ('/preview/thumbnail', {
|
||||
params: { url: 'https://example.com/thumbnail.jpg' },
|
||||
responseType: 'blob' })
|
||||
})
|
||||
|
||||
it ('does not render the unsafe URL directly when preview fetching fails', async () => {
|
||||
api.apiGet.mockRejectedValueOnce (new Error ('unsafe'))
|
||||
|
||||
render (
|
||||
<PostImportThumbnailPreview
|
||||
url="http://127.0.0.1/private.png"
|
||||
className="h-10 w-10"/>)
|
||||
|
||||
expect (await screen.findByText ('サムネールを表示できません')).toBeInTheDocument ()
|
||||
expect (screen.queryByRole ('img')).toBeNull ()
|
||||
})
|
||||
|
||||
it ('revokes the old object URL when the source URL changes', async () => {
|
||||
const createObjectUrlMock =
|
||||
globalThis.URL.createObjectURL as unknown as ReturnType<typeof vi.fn>
|
||||
createObjectUrlMock
|
||||
.mockReturnValueOnce ('blob:first')
|
||||
.mockReturnValueOnce ('blob:second')
|
||||
api.apiGet
|
||||
.mockResolvedValueOnce (new Blob (['first'], { type: 'image/png' }))
|
||||
.mockResolvedValueOnce (new Blob (['second'], { type: 'image/png' }))
|
||||
|
||||
const { rerender } = render (
|
||||
<PostImportThumbnailPreview
|
||||
url="https://example.com/first.jpg"
|
||||
className="h-10 w-10"/>)
|
||||
await waitFor (() => {
|
||||
expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:first')
|
||||
})
|
||||
|
||||
rerender (
|
||||
<PostImportThumbnailPreview
|
||||
url="https://example.com/second.jpg"
|
||||
className="h-10 w-10"/>)
|
||||
|
||||
await waitFor (() => {
|
||||
expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:second')
|
||||
})
|
||||
expect (globalThis.URL.revokeObjectURL).toHaveBeenCalledWith ('blob:first')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
|
||||
import { apiGet } from '@/lib/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
alt?: string
|
||||
className?: string }
|
||||
|
||||
|
||||
const PostImportThumbnailPreview: FC<Props> = (
|
||||
{ url,
|
||||
alt = 'サムネール',
|
||||
className = 'h-16 w-16' },
|
||||
) => {
|
||||
const [previewUrl, setPreviewUrl] = useState ('')
|
||||
const [unavailable, setUnavailable] = useState (false)
|
||||
const previewUrlRef = useRef ('')
|
||||
|
||||
useEffect (() => {
|
||||
if (previewUrlRef.current)
|
||||
{
|
||||
URL.revokeObjectURL (previewUrlRef.current)
|
||||
previewUrlRef.current = ''
|
||||
}
|
||||
setPreviewUrl ('')
|
||||
setUnavailable (false)
|
||||
|
||||
if (!(url))
|
||||
return
|
||||
|
||||
let active = true
|
||||
|
||||
const loadPreview = async () => {
|
||||
try
|
||||
{
|
||||
const blob = await apiGet<Blob> ('/preview/thumbnail', {
|
||||
params: { url },
|
||||
responseType: 'blob' })
|
||||
if (!(active))
|
||||
return
|
||||
|
||||
const nextPreviewUrl = URL.createObjectURL (blob)
|
||||
previewUrlRef.current = nextPreviewUrl
|
||||
setPreviewUrl (nextPreviewUrl)
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (active)
|
||||
setUnavailable (true)
|
||||
}
|
||||
}
|
||||
|
||||
void loadPreview ()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
if (previewUrlRef.current)
|
||||
{
|
||||
URL.revokeObjectURL (previewUrlRef.current)
|
||||
previewUrlRef.current = ''
|
||||
}
|
||||
}
|
||||
}, [url])
|
||||
|
||||
if (unavailable)
|
||||
{
|
||||
return (
|
||||
<div
|
||||
className={`${ className } flex items-center justify-center rounded border
|
||||
border-amber-300 bg-amber-50 p-2 text-center text-xs
|
||||
text-amber-700 dark:border-amber-900 dark:bg-amber-950
|
||||
dark:text-amber-200`}>
|
||||
サムネールを表示できません
|
||||
</div>)
|
||||
}
|
||||
|
||||
return (
|
||||
<PostThumbnailPreview
|
||||
url={previewUrl}
|
||||
alt={alt}
|
||||
className={className}/>)
|
||||
}
|
||||
|
||||
export default PostImportThumbnailPreview
|
||||
@@ -1,49 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
alt?: string
|
||||
className?: string }
|
||||
|
||||
|
||||
const ThumbnailPreview: FC<Props> = ({ url, alt = 'サムネール', className = 'h-16 w-16' }) => {
|
||||
const [failed, setFailed] = useState (false)
|
||||
|
||||
useEffect (() => {
|
||||
setFailed (false)
|
||||
}, [url])
|
||||
|
||||
if (!(url))
|
||||
{
|
||||
return (
|
||||
<div
|
||||
className={`${ className } flex items-center justify-center rounded border
|
||||
border-border bg-muted text-xs text-muted-foreground`}>
|
||||
なし
|
||||
</div>)
|
||||
}
|
||||
|
||||
if (failed)
|
||||
{
|
||||
return (
|
||||
<div
|
||||
className={`${ className } flex items-center justify-center rounded border
|
||||
border-amber-300 bg-amber-50 p-2 text-center text-xs
|
||||
text-amber-700 dark:border-amber-900 dark:bg-amber-950
|
||||
dark:text-amber-200`}>
|
||||
サムネールを表示できません
|
||||
</div>)
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt={alt}
|
||||
className={`${ className } rounded border border-border object-cover`}
|
||||
onError={() => setFailed (true)}/>)
|
||||
}
|
||||
|
||||
export default ThumbnailPreview
|
||||
@@ -5,7 +5,7 @@ export type PostImportDisplayStatus = 'ready' | 'skipped' | 'warning'
|
||||
export type PostImportBadgeValue = PostImportDisplayStatus
|
||||
|
||||
const hasWarnings = (row: PostImportRow): boolean =>
|
||||
Object.values (row.fieldWarnings ?? { }).some (_1 => _1.length > 0)
|
||||
Object.values (row.fieldWarnings ?? { }).some (messages => messages.length > 0)
|
||||
|| row.baseWarnings.length > 0
|
||||
|
||||
export const displayPostImportStatus = (
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { creatableImportRows,
|
||||
buildNextEditedRow,
|
||||
canEditResultRow,
|
||||
canEditReviewRow,
|
||||
canRetryResultRow,
|
||||
hasExactSourceRows,
|
||||
initialisePreviewRows,
|
||||
mergeImportResults,
|
||||
mergeValidatedImportRow,
|
||||
mergeValidatedImportRows,
|
||||
processableImportRows,
|
||||
replaceImportRow,
|
||||
resultRepairMode,
|
||||
resultRowMessages,
|
||||
resultSummaryCounts,
|
||||
retryImportRow,
|
||||
reviewSummaryCounts } from '@/lib/postImportSession'
|
||||
@@ -82,13 +91,16 @@ describe ('post import row state', () => {
|
||||
it ('merges result states and clears incompatible post identifiers', () => {
|
||||
const created = mergeImportResults ([buildPostImportRow ({
|
||||
skipReason: 'existing',
|
||||
existingPostId: 2 })], [{
|
||||
existingPostId: 2,
|
||||
recoverable: true,
|
||||
importStatus: 'pending' })], [{
|
||||
sourceRow: 1,
|
||||
status: 'created',
|
||||
post: { id: 3 } }])[0]
|
||||
const skipped = mergeImportResults ([buildPostImportRow ({
|
||||
createdPostId: 3,
|
||||
importStatus: 'created' })], [{
|
||||
importStatus: 'created',
|
||||
recoverable: true })], [{
|
||||
sourceRow: 1,
|
||||
status: 'skipped',
|
||||
existingPostId: 4 }])[0]
|
||||
@@ -97,20 +109,24 @@ describe ('post import row state', () => {
|
||||
existingPostId: 4 })], [{
|
||||
sourceRow: 1,
|
||||
status: 'failed',
|
||||
recoverable: true,
|
||||
errors: { base: ['failure'] } }])[0]
|
||||
|
||||
expect (created).toMatchObject ({
|
||||
importStatus: 'created',
|
||||
createdPostId: 3,
|
||||
existingPostId: undefined,
|
||||
recoverable: undefined,
|
||||
skipReason: undefined })
|
||||
expect (skipped).toMatchObject ({
|
||||
importStatus: 'skipped',
|
||||
existingPostId: 4,
|
||||
createdPostId: undefined,
|
||||
recoverable: undefined,
|
||||
skipReason: 'existing' })
|
||||
expect (failed).toMatchObject ({
|
||||
importStatus: 'failed',
|
||||
recoverable: true,
|
||||
createdPostId: undefined,
|
||||
existingPostId: undefined,
|
||||
skipReason: undefined,
|
||||
@@ -123,12 +139,103 @@ describe ('post import row state', () => {
|
||||
buildPostImportRow ({ sourceRow: 2, importStatus: 'skipped',
|
||||
existingPostId: 2, skipReason: 'existing' }),
|
||||
buildPostImportRow ({ sourceRow: 3, importStatus: 'failed',
|
||||
importErrors: { base: ['failed'] } })]
|
||||
recoverable: true, importErrors: { base: ['failed'] } }),
|
||||
buildPostImportRow ({ sourceRow: 4, importStatus: 'pending',
|
||||
recoverable: true, validationErrors: { base: ['failed'] } })]
|
||||
|
||||
expect (resultSummaryCounts (rows)).toEqual ({ created: 1, skipped: 1, failed: 1 })
|
||||
expect (resultSummaryCounts (rows)).toEqual ({ created: 1, skipped: 1, failed: 2 })
|
||||
expect (retryImportRow (rows, 3)[2]).toMatchObject ({
|
||||
importStatus: 'pending',
|
||||
importErrors: undefined })
|
||||
expect (retryImportRow (rows, 4)[3]).toBe (rows[3])
|
||||
})
|
||||
|
||||
it ('deduplicates messages and keeps repair mode only for repairable rows', () => {
|
||||
const repairable = buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
importStatus: 'pending',
|
||||
validationErrors: { title: ['invalid'], base: ['duplicate'] },
|
||||
importErrors: { base: ['duplicate'], url: ['network'] } })
|
||||
const complete = buildPostImportRow ({
|
||||
sourceRow: 2,
|
||||
importStatus: 'created',
|
||||
createdPostId: 2 })
|
||||
|
||||
expect (resultRowMessages (repairable)).toEqual (['invalid', 'duplicate', 'network'])
|
||||
expect (resultRepairMode ([repairable, complete])).toBe ('failed')
|
||||
expect (resultRepairMode ([complete])).toBe ('all')
|
||||
})
|
||||
|
||||
it ('classifies editable and retryable rows by terminal and recoverable state', () => {
|
||||
const ready = buildPostImportRow ()
|
||||
const skipped = buildPostImportRow ({
|
||||
importStatus: 'skipped',
|
||||
skipReason: 'existing',
|
||||
existingPostId: 2 })
|
||||
const hardFailed = buildPostImportRow ({
|
||||
importStatus: 'failed',
|
||||
importErrors: { base: ['failed'] } })
|
||||
const pendingInvalid = buildPostImportRow ({
|
||||
importStatus: 'pending',
|
||||
recoverable: true,
|
||||
validationErrors: { title: ['invalid'] } })
|
||||
const pendingValid = buildPostImportRow ({
|
||||
importStatus: 'pending',
|
||||
recoverable: true })
|
||||
|
||||
expect (canEditReviewRow (ready)).toBe (true)
|
||||
expect (canEditReviewRow (skipped)).toBe (false)
|
||||
expect (canEditReviewRow (hardFailed)).toBe (false)
|
||||
expect (canEditResultRow (pendingInvalid)).toBe (true)
|
||||
expect (canRetryResultRow (pendingInvalid)).toBe (false)
|
||||
expect (canRetryResultRow (pendingValid)).toBe (true)
|
||||
})
|
||||
|
||||
it ('detects missing, duplicate, and extra source rows exactly', () => {
|
||||
expect (hasExactSourceRows ([1, 2], [{ sourceRow: 1 }, { sourceRow: 2 }])).toBe (true)
|
||||
expect (hasExactSourceRows ([1, 2], [{ sourceRow: 1 }])).toBe (false)
|
||||
expect (hasExactSourceRows ([1, 2], [{ sourceRow: 1 }, { sourceRow: 1 }])).toBe (false)
|
||||
expect (hasExactSourceRows ([1, 2], [{ sourceRow: 1 }, { sourceRow: 3 }])).toBe (false)
|
||||
})
|
||||
|
||||
it ('merges only the validated source row and preserves other row edits', () => {
|
||||
const edited = buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
attributes: { title: 'edited row' },
|
||||
provenance: { title: 'manual' } })
|
||||
const other = buildPostImportRow ({
|
||||
sourceRow: 2,
|
||||
attributes: { title: 'keep me' },
|
||||
provenance: { title: 'manual' } })
|
||||
const validated = buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
attributes: { title: 'validated row' } })
|
||||
|
||||
const result = mergeValidatedImportRow ([edited, other], validated)
|
||||
|
||||
expect (result[0]?.attributes.title).toBe ('validated row')
|
||||
expect (result[1]?.attributes.title).toBe ('keep me')
|
||||
})
|
||||
|
||||
it ('replaces only the targeted source row and preserves the others', () => {
|
||||
const original = buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
importStatus: 'failed',
|
||||
recoverable: true,
|
||||
importErrors: { base: ['failed'] } })
|
||||
const other = buildPostImportRow ({
|
||||
sourceRow: 2,
|
||||
attributes: { title: 'keep edited row' } })
|
||||
const restored = buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
importStatus: 'failed',
|
||||
recoverable: true,
|
||||
importErrors: { base: ['failed'] } })
|
||||
|
||||
const result = replaceImportRow ([original, other], restored)
|
||||
|
||||
expect (result[0]).toEqual (restored)
|
||||
expect (result[1]?.attributes.title).toBe ('keep edited row')
|
||||
})
|
||||
|
||||
it ('copies reset snapshot values instead of sharing mutable records', () => {
|
||||
@@ -144,4 +251,38 @@ describe ('post import row state', () => {
|
||||
expect (initialised.resetSnapshot.attributes.title).toBe ('')
|
||||
expect (initialised.resetSnapshot.fieldWarnings.title).toEqual (['warning'])
|
||||
})
|
||||
|
||||
it ('builds the next edited row with shared repair semantics', () => {
|
||||
const row = buildPostImportRow ({
|
||||
url: 'https://example.com/original',
|
||||
importStatus: 'failed',
|
||||
recoverable: true,
|
||||
importErrors: { base: ['failed'] },
|
||||
attributes: { title: 'old title', tags: 'old-tag', duration: '2' },
|
||||
provenance: { title: 'automatic', tags: 'automatic', url: 'manual' },
|
||||
tagSources: { automatic: 'old-tag', manual: '' } })
|
||||
|
||||
const nextRow = buildNextEditedRow (
|
||||
row,
|
||||
{
|
||||
url: 'https://example.com/edited',
|
||||
title: 'edited title',
|
||||
thumbnailBase: '',
|
||||
originalCreatedFrom: '',
|
||||
originalCreatedBefore: '',
|
||||
duration: '2.5',
|
||||
tags: 'edited-tag',
|
||||
parentPostIds: '' },
|
||||
true)
|
||||
|
||||
expect (nextRow.importStatus).toBe ('pending')
|
||||
expect (nextRow.importErrors).toBeUndefined ()
|
||||
expect (nextRow.url).toBe ('https://example.com/edited')
|
||||
expect (nextRow.attributes.title).toBe ('edited title')
|
||||
expect (nextRow.attributes.duration).toBe ('2.5')
|
||||
expect (nextRow.attributes.tags).toBe ('edited-tag')
|
||||
expect (nextRow.provenance.title).toBe ('manual')
|
||||
expect (nextRow.provenance.url).toBe ('manual')
|
||||
expect (nextRow.tagSources?.manual).toBe ('edited-tag')
|
||||
})
|
||||
})
|
||||
|
||||
+241
-102
@@ -1,4 +1,5 @@
|
||||
import type { PostImportResultRow,
|
||||
import type { PostImportEditableDraft,
|
||||
PostImportResultRow,
|
||||
PostImportRow } from '@/lib/postImportTypes'
|
||||
|
||||
const hasSkipReason = (row: PostImportRow): boolean =>
|
||||
@@ -7,6 +8,13 @@ const hasSkipReason = (row: PostImportRow): boolean =>
|
||||
const hasValidationErrors = (row: PostImportRow): boolean =>
|
||||
Object.keys (row.validationErrors ?? { }).length > 0
|
||||
|
||||
const isRecoverableRow = (row: PostImportRow): boolean =>
|
||||
row.recoverable === true
|
||||
|
||||
const isRepairableImportStatus = (row: PostImportRow): boolean =>
|
||||
isRecoverableRow (row)
|
||||
&& (row.importStatus === 'failed' || row.importStatus === 'pending')
|
||||
|
||||
const buildResetSnapshot = (row: PostImportRow) => ({
|
||||
url: row.url,
|
||||
attributes: { ...row.attributes },
|
||||
@@ -22,15 +30,15 @@ const buildResetSnapshot = (row: PostImportRow) => ({
|
||||
|
||||
export const processableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
|
||||
rows.filter (row => {
|
||||
if (row.importStatus === 'created')
|
||||
return false
|
||||
if (row.importStatus === 'skipped')
|
||||
return false
|
||||
if (row.importStatus === 'failed')
|
||||
return false
|
||||
if (hasValidationErrors (row))
|
||||
return false
|
||||
return row.importStatus == null || row.importStatus === 'pending'
|
||||
if (row.importStatus === 'created')
|
||||
return false
|
||||
if (row.importStatus === 'skipped')
|
||||
return false
|
||||
if (row.importStatus === 'failed')
|
||||
return false
|
||||
if (hasValidationErrors (row))
|
||||
return false
|
||||
return row.importStatus == null || row.importStatus === 'pending'
|
||||
})
|
||||
|
||||
export const creatableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
|
||||
@@ -40,31 +48,151 @@ export const creatableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
|
||||
export const reviewSummaryCounts = (rows: PostImportRow[]) => ({
|
||||
total: rows.length,
|
||||
submittable: rows.filter (row =>
|
||||
creatableImportRows ([row]).length > 0
|
||||
&& !(hasValidationErrors (row))).length,
|
||||
creatableImportRows ([row]).length > 0
|
||||
&& !(hasValidationErrors (row))).length,
|
||||
skipPlanned: rows.filter (row =>
|
||||
processableImportRows ([row]).length > 0
|
||||
&& hasSkipReason (row)).length })
|
||||
processableImportRows ([row]).length > 0
|
||||
&& hasSkipReason (row)).length })
|
||||
|
||||
|
||||
export const resultSummaryCounts = (rows: PostImportRow[]) =>
|
||||
rows.reduce (
|
||||
(counts, row) => {
|
||||
if (row.importStatus === 'created')
|
||||
{
|
||||
++counts.created
|
||||
return counts
|
||||
}
|
||||
if (row.importStatus === 'skipped')
|
||||
{
|
||||
++counts.skipped
|
||||
return counts
|
||||
}
|
||||
if (row.importStatus === 'failed')
|
||||
++counts.failed
|
||||
return counts
|
||||
},
|
||||
{ created: 0, skipped: 0, failed: 0 })
|
||||
(counts, row) => {
|
||||
if (row.importStatus === 'created')
|
||||
{
|
||||
++counts.created
|
||||
return counts
|
||||
}
|
||||
if (row.importStatus === 'skipped')
|
||||
{
|
||||
++counts.skipped
|
||||
return counts
|
||||
}
|
||||
if (row.importStatus === 'failed'
|
||||
|| (row.recoverable === true && row.importStatus === 'pending'))
|
||||
++counts.failed
|
||||
return counts
|
||||
},
|
||||
{ created: 0, skipped: 0, failed: 0 })
|
||||
|
||||
|
||||
export const resultRepairMode = (
|
||||
rows: PostImportRow[],
|
||||
): 'all' | 'failed' =>
|
||||
rows.some (row => hasValidationErrors (row) || isRepairableImportStatus (row))
|
||||
? 'failed'
|
||||
: 'all'
|
||||
|
||||
|
||||
export const canEditReviewRow = (row: PostImportRow): boolean =>
|
||||
!(row.importStatus === 'created'
|
||||
|| row.importStatus === 'skipped'
|
||||
|| (row.importStatus === 'failed' && row.recoverable !== true))
|
||||
|
||||
|
||||
export const canEditResultRow = (row: PostImportRow): boolean =>
|
||||
row.recoverable === true
|
||||
&& (row.importStatus === 'failed'
|
||||
|| (row.importStatus === 'pending' && hasValidationErrors (row)))
|
||||
|
||||
|
||||
export const canRetryResultRow = (row: PostImportRow): boolean =>
|
||||
row.recoverable === true
|
||||
&& (row.importStatus === 'failed'
|
||||
|| (row.importStatus === 'pending' && !(hasValidationErrors (row))))
|
||||
|
||||
|
||||
export const resultRowMessages = (row: PostImportRow): string[] =>
|
||||
[...new Set ([
|
||||
...Object.values (row.validationErrors ?? { }).flat (),
|
||||
...Object.values (row.importErrors ?? { }).flat ()])]
|
||||
|
||||
|
||||
export const resultRowWarnings = (row: PostImportRow): string[] =>
|
||||
[...new Set ([
|
||||
...Object.values (row.fieldWarnings ?? { }).flat (),
|
||||
...row.baseWarnings])]
|
||||
|
||||
export const buildNextEditedRow = (
|
||||
editingRow: PostImportRow,
|
||||
draft: PostImportEditableDraft,
|
||||
urlChanged: boolean,
|
||||
): PostImportRow => {
|
||||
const nextProvenance = { ...editingRow.provenance }
|
||||
const nextAttributes = { ...editingRow.attributes }
|
||||
const nextTagSources = {
|
||||
automatic: editingRow.tagSources?.automatic ?? '',
|
||||
manual: editingRow.tagSources?.manual ?? '' }
|
||||
const draftFields = [
|
||||
['title', draft.title],
|
||||
['thumbnailBase', draft.thumbnailBase],
|
||||
['originalCreatedFrom', draft.originalCreatedFrom],
|
||||
['originalCreatedBefore', draft.originalCreatedBefore],
|
||||
['duration', draft.duration],
|
||||
['parentPostIds', draft.parentPostIds]] as const
|
||||
draftFields.forEach (([field, value]) => {
|
||||
nextAttributes[field] = value
|
||||
nextProvenance[field] =
|
||||
value !== String (editingRow.attributes[field] ?? '')
|
||||
? 'manual'
|
||||
: (editingRow.provenance[field] ?? 'automatic')
|
||||
})
|
||||
nextAttributes.tags = draft.tags
|
||||
if (draft.tags !== String (editingRow.attributes.tags ?? ''))
|
||||
{
|
||||
nextProvenance.tags = 'manual'
|
||||
nextTagSources.manual = draft.tags
|
||||
}
|
||||
else
|
||||
{
|
||||
nextProvenance.tags = editingRow.provenance.tags ?? 'automatic'
|
||||
nextTagSources.manual = editingRow.tagSources?.manual ?? ''
|
||||
}
|
||||
|
||||
return {
|
||||
...editingRow,
|
||||
url: draft.url,
|
||||
attributes: nextAttributes,
|
||||
provenance: {
|
||||
...nextProvenance,
|
||||
url: urlChanged ? 'manual' : (editingRow.provenance.url ?? 'manual') },
|
||||
tagSources: nextTagSources,
|
||||
importStatus: editingRow.importStatus === 'created' ? 'created' : 'pending',
|
||||
importErrors: undefined }
|
||||
}
|
||||
|
||||
|
||||
export const hasExactSourceRows = (
|
||||
expected: number[],
|
||||
actual: Array<{ sourceRow: number }>,
|
||||
): boolean => {
|
||||
if (expected.length !== actual.length)
|
||||
return false
|
||||
|
||||
const expectedSorted = [...expected].sort ((a, b) => a - b)
|
||||
const actualSorted = actual.map (row => row.sourceRow).sort ((a, b) => a - b)
|
||||
return expectedSorted.every ((value, index) => value === actualSorted[index])
|
||||
}
|
||||
|
||||
|
||||
export const replaceImportRow = (
|
||||
rows: PostImportRow[],
|
||||
nextRow: PostImportRow,
|
||||
): PostImportRow[] =>
|
||||
rows.map (row => row.sourceRow === nextRow.sourceRow ? nextRow : row)
|
||||
|
||||
|
||||
export const mergeValidatedImportRow = (
|
||||
rows: PostImportRow[],
|
||||
validated: PostImportRow,
|
||||
): PostImportRow[] => {
|
||||
const current = rows.find (row => row.sourceRow === validated.sourceRow)
|
||||
if (current == null)
|
||||
return rows
|
||||
|
||||
const [merged] = mergeValidatedImportRows ([current], [validated])
|
||||
return merged == null ? rows : replaceImportRow (rows, merged)
|
||||
}
|
||||
|
||||
|
||||
export const mergeValidatedImportRows = (
|
||||
@@ -73,46 +201,46 @@ export const mergeValidatedImportRows = (
|
||||
): PostImportRow[] => {
|
||||
const validatedMap = new Map (validated.map (row => [row.sourceRow, row]))
|
||||
return current.map (previous => {
|
||||
if (
|
||||
previous.importStatus === 'created'
|
||||
|| previous.importStatus === 'skipped'
|
||||
|| previous.importStatus === 'failed')
|
||||
return previous
|
||||
if (
|
||||
previous.importStatus === 'created'
|
||||
|| previous.importStatus === 'skipped'
|
||||
|| previous.importStatus === 'failed')
|
||||
return previous
|
||||
|
||||
const row = validatedMap.get (previous.sourceRow)
|
||||
if (row == null)
|
||||
return previous
|
||||
const row = validatedMap.get (previous.sourceRow)
|
||||
if (row == null)
|
||||
return previous
|
||||
|
||||
const fieldWarnings =
|
||||
Object.keys (row.fieldWarnings).length > 0 || row.metadataUrl !== previous.metadataUrl
|
||||
? { ...row.fieldWarnings }
|
||||
: { ...previous.fieldWarnings }
|
||||
for (const [field, origin] of Object.entries (row.provenance))
|
||||
{
|
||||
if (origin === 'manual')
|
||||
delete fieldWarnings[field]
|
||||
}
|
||||
const fieldWarnings =
|
||||
Object.keys (row.fieldWarnings).length > 0 || row.metadataUrl !== previous.metadataUrl
|
||||
? { ...row.fieldWarnings }
|
||||
: { ...previous.fieldWarnings }
|
||||
for (const [field, origin] of Object.entries (row.provenance))
|
||||
{
|
||||
if (origin === 'manual')
|
||||
delete fieldWarnings[field]
|
||||
}
|
||||
|
||||
return {
|
||||
...previous,
|
||||
url: row.url,
|
||||
attributes: row.attributes,
|
||||
provenance: row.provenance,
|
||||
tagSources: row.tagSources,
|
||||
skipReason: row.skipReason,
|
||||
existingPostId: row.existingPostId,
|
||||
fieldWarnings,
|
||||
baseWarnings:
|
||||
row.baseWarnings.length > 0 || row.metadataUrl !== previous.metadataUrl
|
||||
? row.baseWarnings
|
||||
: previous.baseWarnings,
|
||||
validationErrors: row.validationErrors,
|
||||
status: row.status,
|
||||
metadataUrl: row.metadataUrl,
|
||||
resetSnapshot:
|
||||
row.metadataUrl !== previous.metadataUrl
|
||||
? buildResetSnapshot (row)
|
||||
: previous.resetSnapshot }
|
||||
return {
|
||||
...previous,
|
||||
url: row.url,
|
||||
attributes: row.attributes,
|
||||
provenance: row.provenance,
|
||||
tagSources: row.tagSources,
|
||||
skipReason: row.skipReason,
|
||||
existingPostId: row.existingPostId,
|
||||
fieldWarnings,
|
||||
baseWarnings:
|
||||
row.baseWarnings.length > 0 || row.metadataUrl !== previous.metadataUrl
|
||||
? row.baseWarnings
|
||||
: previous.baseWarnings,
|
||||
validationErrors: row.validationErrors,
|
||||
status: row.status,
|
||||
metadataUrl: row.metadataUrl,
|
||||
resetSnapshot:
|
||||
row.metadataUrl !== previous.metadataUrl
|
||||
? buildResetSnapshot (row)
|
||||
: previous.resetSnapshot }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -123,37 +251,46 @@ export const mergeImportResults = (
|
||||
): PostImportRow[] => {
|
||||
const resultMap = new Map (results.map (row => [row.sourceRow, row]))
|
||||
return rows.map (row => {
|
||||
const result = resultMap.get (row.sourceRow)
|
||||
if (result == null)
|
||||
return row
|
||||
const result = resultMap.get (row.sourceRow)
|
||||
if (result == null)
|
||||
return row
|
||||
|
||||
switch (result.status)
|
||||
{
|
||||
case 'created':
|
||||
return {
|
||||
...row,
|
||||
importStatus: 'created',
|
||||
skipReason: undefined,
|
||||
createdPostId: result.post.id,
|
||||
existingPostId: undefined,
|
||||
importErrors: result.errors }
|
||||
case 'skipped':
|
||||
return {
|
||||
...row,
|
||||
importStatus: 'skipped',
|
||||
skipReason: 'existing',
|
||||
createdPostId: undefined,
|
||||
existingPostId: result.existingPostId,
|
||||
importErrors: result.errors }
|
||||
case 'failed':
|
||||
return {
|
||||
...row,
|
||||
importStatus: 'failed',
|
||||
skipReason: undefined,
|
||||
createdPostId: undefined,
|
||||
existingPostId: undefined,
|
||||
importErrors: result.errors }
|
||||
}
|
||||
switch (result.status)
|
||||
{
|
||||
case 'created':
|
||||
return {
|
||||
...row,
|
||||
importStatus: 'created',
|
||||
recoverable: undefined,
|
||||
skipReason: undefined,
|
||||
createdPostId: result.post.id,
|
||||
existingPostId: undefined,
|
||||
fieldWarnings: result.fieldWarnings ?? row.fieldWarnings,
|
||||
baseWarnings: result.baseWarnings ?? row.baseWarnings,
|
||||
importErrors: result.errors }
|
||||
case 'skipped':
|
||||
return {
|
||||
...row,
|
||||
importStatus: 'skipped',
|
||||
recoverable: undefined,
|
||||
skipReason: 'existing',
|
||||
createdPostId: undefined,
|
||||
existingPostId: result.existingPostId,
|
||||
fieldWarnings: result.fieldWarnings ?? row.fieldWarnings,
|
||||
baseWarnings: result.baseWarnings ?? row.baseWarnings,
|
||||
importErrors: result.errors }
|
||||
case 'failed':
|
||||
return {
|
||||
...row,
|
||||
importStatus: 'failed',
|
||||
recoverable: result.recoverable === true ? true : undefined,
|
||||
skipReason: undefined,
|
||||
createdPostId: undefined,
|
||||
existingPostId: undefined,
|
||||
fieldWarnings: result.fieldWarnings ?? row.fieldWarnings,
|
||||
baseWarnings: result.baseWarnings ?? row.baseWarnings,
|
||||
importErrors: result.errors }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -163,11 +300,13 @@ export const retryImportRow = (
|
||||
sourceRow: number,
|
||||
): PostImportRow[] =>
|
||||
rows.map (row =>
|
||||
row.sourceRow === sourceRow && row.importStatus === 'failed'
|
||||
? { ...row, importStatus: 'pending', importErrors: undefined }
|
||||
: row)
|
||||
row.sourceRow === sourceRow
|
||||
&& row.importStatus === 'failed'
|
||||
&& row.recoverable === true
|
||||
? { ...row, importStatus: 'pending', importErrors: undefined }
|
||||
: row)
|
||||
|
||||
export const initialisePreviewRows = (rows: PostImportRow[]): PostImportRow[] =>
|
||||
rows.map (row => ({
|
||||
...row,
|
||||
resetSnapshot: buildResetSnapshot (row) }))
|
||||
...row,
|
||||
resetSnapshot: buildResetSnapshot (row) }))
|
||||
|
||||
@@ -19,30 +19,30 @@ const normaliseImportUrl = (value: string): string | null => {
|
||||
|
||||
try
|
||||
{
|
||||
const url = new URL (trimmed)
|
||||
if (!(url.protocol === 'http:' || url.protocol === 'https:'))
|
||||
return null
|
||||
if (!(url.host))
|
||||
return null
|
||||
const url = 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 ()
|
||||
url.hostname = url.hostname.toLowerCase ()
|
||||
if (url.pathname.endsWith ('/'))
|
||||
url.pathname = url.pathname.replace (/\/+$/, '')
|
||||
return url.toString ()
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const countImportSourceLines = (source: string): number =>
|
||||
source
|
||||
.split (/\r\n|\n|\r/)
|
||||
.map (_1 => _1.trim ())
|
||||
.filter (_1 => _1 !== '')
|
||||
.length
|
||||
.split (/\r\n|\n|\r/)
|
||||
.map (line => line.trim ())
|
||||
.filter (line => line !== '')
|
||||
.length
|
||||
|
||||
|
||||
export const validateImportSource = (
|
||||
@@ -54,56 +54,56 @@ export const validateImportSource = (
|
||||
let count = 0
|
||||
|
||||
lines.forEach ((rawLine, index) => {
|
||||
const value = rawLine.trim ()
|
||||
if (!(value))
|
||||
return
|
||||
const value = rawLine.trim ()
|
||||
if (!(value))
|
||||
return
|
||||
|
||||
++count
|
||||
const sourceRow = index + 1
|
||||
const displayUrl = truncateUrl (value)
|
||||
const normalised = normaliseImportUrl (value)
|
||||
if (count > MAX_ROWS)
|
||||
{
|
||||
issues.push ({
|
||||
sourceRow,
|
||||
message: `取込件数は ${ MAX_ROWS } 件までです.`,
|
||||
url: displayUrl })
|
||||
return
|
||||
}
|
||||
if (!(value.startsWith ('http://') || value.startsWith ('https://')))
|
||||
{
|
||||
issues.push ({
|
||||
sourceRow,
|
||||
message: 'HTTP または HTTPS の URL ではありません.',
|
||||
url: displayUrl })
|
||||
return
|
||||
}
|
||||
if (bytesize (value) > MAX_URL_BYTES)
|
||||
{
|
||||
issues.push ({
|
||||
sourceRow,
|
||||
message: 'URL が長すぎます.',
|
||||
url: displayUrl })
|
||||
return
|
||||
}
|
||||
if (normalised == null)
|
||||
{
|
||||
issues.push ({
|
||||
sourceRow,
|
||||
message: 'URL の形式が不正です.',
|
||||
url: displayUrl })
|
||||
return
|
||||
}
|
||||
const duplicateRow = seen.get (normalised)
|
||||
if (duplicateRow != null)
|
||||
{
|
||||
issues.push ({
|
||||
sourceRow,
|
||||
message: `${ duplicateRow } 行目と同じ URL です.`,
|
||||
url: displayUrl })
|
||||
return
|
||||
}
|
||||
seen.set (normalised, sourceRow)
|
||||
++count
|
||||
const sourceRow = index + 1
|
||||
const displayUrl = truncateUrl (value)
|
||||
const normalised = normaliseImportUrl (value)
|
||||
if (count > MAX_ROWS)
|
||||
{
|
||||
issues.push ({
|
||||
sourceRow,
|
||||
message: `取込件数は ${ MAX_ROWS } 件までです.`,
|
||||
url: displayUrl })
|
||||
return
|
||||
}
|
||||
if (!(value.startsWith ('http://') || value.startsWith ('https://')))
|
||||
{
|
||||
issues.push ({
|
||||
sourceRow,
|
||||
message: 'HTTP または HTTPS の URL ではありません.',
|
||||
url: displayUrl })
|
||||
return
|
||||
}
|
||||
if (bytesize (value) > MAX_URL_BYTES)
|
||||
{
|
||||
issues.push ({
|
||||
sourceRow,
|
||||
message: 'URL が長すぎます.',
|
||||
url: displayUrl })
|
||||
return
|
||||
}
|
||||
if (normalised == null)
|
||||
{
|
||||
issues.push ({
|
||||
sourceRow,
|
||||
message: 'URL の形式が不正です.',
|
||||
url: displayUrl })
|
||||
return
|
||||
}
|
||||
const duplicateRow = seen.get (normalised)
|
||||
if (duplicateRow != null)
|
||||
{
|
||||
issues.push ({
|
||||
sourceRow,
|
||||
message: `${ duplicateRow } 行目と同じ URL です.`,
|
||||
url: displayUrl })
|
||||
return
|
||||
}
|
||||
seen.set (normalised, sourceRow)
|
||||
})
|
||||
|
||||
return issues
|
||||
|
||||
@@ -16,18 +16,27 @@ describe ('post import storage', () => {
|
||||
|
||||
it ('round-trips a valid session and source draft', () => {
|
||||
const row = buildPostImportRow ({
|
||||
attributes: { duration: '2.5' },
|
||||
recoverable: true,
|
||||
importStatus: 'pending',
|
||||
validationErrors: { title: ['invalid'] } })
|
||||
const skipped = buildPostImportRow ({
|
||||
importStatus: 'skipped',
|
||||
skipReason: 'existing',
|
||||
existingPostId: 10 })
|
||||
|
||||
expect (savePostImportSession ('session', {
|
||||
source: row.url,
|
||||
rows: [row],
|
||||
source: skipped.url,
|
||||
rows: [row, skipped],
|
||||
repairMode: 'all' })).toBe (true)
|
||||
expect (loadPostImportSession ('session')).toMatchObject ({
|
||||
version: 2,
|
||||
source: row.url,
|
||||
source: skipped.url,
|
||||
rows: [{
|
||||
attributes: { duration: '2.5' },
|
||||
recoverable: true,
|
||||
importStatus: 'pending' },
|
||||
{
|
||||
importStatus: 'skipped',
|
||||
skipReason: 'existing',
|
||||
existingPostId: 10 }] })
|
||||
@@ -49,7 +58,10 @@ describe ('post import storage', () => {
|
||||
{ ...session.rows[0], skipReason: 'existing', existingPostId: undefined },
|
||||
{ ...session.rows[0], existingPostId: 2, skipReason: undefined },
|
||||
{ ...session.rows[0], importStatus: 'created', createdPostId: undefined },
|
||||
{ ...session.rows[0], importStatus: 'failed', createdPostId: 3 }]
|
||||
{ ...session.rows[0], importStatus: 'failed', createdPostId: 3 },
|
||||
{ ...session.rows[0], importStatus: 'created', recoverable: true },
|
||||
{ ...session.rows[0], importStatus: 'skipped', recoverable: true },
|
||||
{ ...session.rows[0], recoverable: true }]
|
||||
|
||||
for (const [index, row] of invalidRows.entries ())
|
||||
{
|
||||
|
||||
+112
-102
@@ -40,12 +40,12 @@ const readStorage = (
|
||||
|
||||
try
|
||||
{
|
||||
return sessionStorage.getItem (key)
|
||||
return sessionStorage.getItem (key)
|
||||
}
|
||||
catch
|
||||
{
|
||||
onError?.('保存済みデータを読み込めませんでした.')
|
||||
return null
|
||||
onError?.('保存済みデータを読み込めませんでした.')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,13 +60,13 @@ const writeStorage = (
|
||||
|
||||
try
|
||||
{
|
||||
sessionStorage.setItem (key, value)
|
||||
return true
|
||||
sessionStorage.setItem (key, value)
|
||||
return true
|
||||
}
|
||||
catch
|
||||
{
|
||||
onError?.('ブラウザへ保存できませんでした.')
|
||||
return false
|
||||
onError?.('ブラウザへ保存できませんでした.')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,11 +80,11 @@ const removeStorage = (
|
||||
|
||||
try
|
||||
{
|
||||
sessionStorage.removeItem (key)
|
||||
sessionStorage.removeItem (key)
|
||||
}
|
||||
catch
|
||||
{
|
||||
onError?.('保存済みデータを削除できませんでした.')
|
||||
onError?.('保存済みデータを削除できませんでした.')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,9 +96,9 @@ const ensureStringListRecord = (value: unknown): Record<string, string[]> | null
|
||||
const result: Record<string, string[]> = { }
|
||||
for (const [key, entry] of Object.entries (value))
|
||||
{
|
||||
if (!(Array.isArray (entry)) || !(entry.every (_1 => typeof _1 === 'string')))
|
||||
return null
|
||||
result[key] = entry
|
||||
if (!(Array.isArray (entry)) || !(entry.every (item => typeof item === 'string')))
|
||||
return null
|
||||
result[key] = entry
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -149,7 +149,7 @@ const sanitiseResetSnapshot = (value: unknown): PostImportResetSnapshot | null =
|
||||
if (!(hasOnlyKeys (value.attributes, ATTRIBUTE_KEYS)))
|
||||
return null
|
||||
if (!(Object.values (value.attributes).every (entry =>
|
||||
typeof entry === 'string' || typeof entry === 'number')))
|
||||
typeof entry === 'string' || typeof entry === 'number')))
|
||||
return null
|
||||
if (!(isPlainObject (value.provenance)))
|
||||
return null
|
||||
@@ -161,7 +161,7 @@ const sanitiseResetSnapshot = (value: unknown): PostImportResetSnapshot | null =
|
||||
return null
|
||||
if (!(hasOnlyKeys (value.tagSources, TAG_SOURCE_KEYS)))
|
||||
return null
|
||||
if (!(Object.values (value.tagSources).every (_1 => typeof _1 === 'string')))
|
||||
if (!(Object.values (value.tagSources).every (entry => typeof entry === 'string')))
|
||||
return null
|
||||
const fieldWarnings = ensureStringListRecord (value.fieldWarnings)
|
||||
if (fieldWarnings == null)
|
||||
@@ -169,19 +169,19 @@ const sanitiseResetSnapshot = (value: unknown): PostImportResetSnapshot | null =
|
||||
if (!(hasOnlyKeys (fieldWarnings, WARNING_KEYS)))
|
||||
return null
|
||||
if (!(Array.isArray (value.baseWarnings))
|
||||
|| !(value.baseWarnings.every (_1 => typeof _1 === 'string')))
|
||||
|| !(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 }
|
||||
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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -202,6 +202,8 @@ const sanitiseRow = (value: unknown): PostImportRow | null => {
|
||||
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)
|
||||
@@ -210,6 +212,13 @@ const sanitiseRow = (value: unknown): PostImportRow | null => {
|
||||
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)
|
||||
@@ -220,20 +229,20 @@ const sanitiseRow = (value: unknown): PostImportRow | null => {
|
||||
return null
|
||||
|
||||
const importErrors =
|
||||
value.importErrors == null
|
||||
? undefined
|
||||
: ensureStringListRecord (value.importErrors)
|
||||
value.importErrors == null
|
||||
? undefined
|
||||
: ensureStringListRecord (value.importErrors)
|
||||
if (importErrors === null)
|
||||
return null
|
||||
if (!(Array.isArray (value.baseWarnings))
|
||||
|| !(value.baseWarnings.every (_1 => typeof _1 === 'string')))
|
||||
|| !(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')))
|
||||
typeof entry === 'string' || typeof entry === 'number')))
|
||||
return null
|
||||
if (!(hasOnlyKeys (value.provenance, PROVENANCE_KEYS)))
|
||||
return null
|
||||
@@ -242,33 +251,34 @@ const sanitiseRow = (value: unknown): PostImportRow | 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 (_1 => typeof _1 === 'string')))
|
||||
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
|
||||
}
|
||||
|
||||
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,
|
||||
metadataUrl: typeof value.metadataUrl === 'string' ? value.metadataUrl : undefined,
|
||||
resetSnapshot,
|
||||
createdPostId:
|
||||
isPositiveInteger (value.createdPostId) ? Number (value.createdPostId) : undefined,
|
||||
importStatus: value.importStatus ?? undefined }
|
||||
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,
|
||||
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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -292,34 +302,34 @@ export const cleanupExpiredPostImportSessions = (
|
||||
|
||||
try
|
||||
{
|
||||
for (let i = 0; i < sessionStorage.length; ++i)
|
||||
{
|
||||
const key = sessionStorage.key (i)
|
||||
if (key == null || !(key.startsWith (SESSION_PREFIX)))
|
||||
continue
|
||||
for (let i = 0; i < sessionStorage.length; ++i)
|
||||
{
|
||||
const key = sessionStorage.key (i)
|
||||
if (key == null || !(key.startsWith (SESSION_PREFIX)))
|
||||
continue
|
||||
|
||||
const raw = sessionStorage.getItem (key)
|
||||
if (raw == null)
|
||||
continue
|
||||
try
|
||||
{
|
||||
const value = JSON.parse (raw) as { savedAt?: string }
|
||||
if (typeof value.savedAt !== 'string' || isExpiredSession (value.savedAt))
|
||||
{
|
||||
sessionStorage.removeItem (key)
|
||||
--i
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
sessionStorage.removeItem (key)
|
||||
--i
|
||||
}
|
||||
}
|
||||
const raw = sessionStorage.getItem (key)
|
||||
if (raw == null)
|
||||
continue
|
||||
try
|
||||
{
|
||||
const value = JSON.parse (raw) as { savedAt?: string }
|
||||
if (typeof value.savedAt !== 'string' || isExpiredSession (value.savedAt))
|
||||
{
|
||||
sessionStorage.removeItem (key)
|
||||
--i
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
sessionStorage.removeItem (key)
|
||||
--i
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
onError?.('保存済みデータを整理できませんでした.')
|
||||
onError?.('保存済みデータを整理できませんでした.')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,12 +343,12 @@ export const loadPostImportSourceDraft = (
|
||||
|
||||
try
|
||||
{
|
||||
const value = JSON.parse (raw) as { source?: string }
|
||||
return { source: typeof value.source === 'string' ? value.source : '' }
|
||||
const value = JSON.parse (raw) as { source?: string }
|
||||
return { source: typeof value.source === 'string' ? value.source : '' }
|
||||
}
|
||||
catch
|
||||
{
|
||||
return { source: '' }
|
||||
return { source: '' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,12 +373,12 @@ export const savePostImportSession = (
|
||||
onError?: StorageErrorHandler,
|
||||
): boolean =>
|
||||
writeStorage (
|
||||
sessionKey (sessionId),
|
||||
JSON.stringify ({
|
||||
...session,
|
||||
version: SESSION_VERSION,
|
||||
savedAt: new Date ().toISOString () }),
|
||||
onError)
|
||||
sessionKey (sessionId),
|
||||
JSON.stringify ({
|
||||
...session,
|
||||
version: SESSION_VERSION,
|
||||
savedAt: new Date ().toISOString () }),
|
||||
onError)
|
||||
|
||||
|
||||
export const loadPostImportSession = (
|
||||
@@ -381,28 +391,28 @@ export const loadPostImportSession = (
|
||||
|
||||
try
|
||||
{
|
||||
const value = JSON.parse (raw) as Partial<PostImportSession>
|
||||
if (value.version !== SESSION_VERSION || !(Array.isArray (value.rows)))
|
||||
return null
|
||||
if (typeof value.savedAt !== 'string' || isExpiredSession (value.savedAt))
|
||||
{
|
||||
removeStorage (sessionKey (sessionId), onError)
|
||||
return null
|
||||
}
|
||||
const value = JSON.parse (raw) as Partial<PostImportSession>
|
||||
if (value.version !== SESSION_VERSION || !(Array.isArray (value.rows)))
|
||||
return null
|
||||
if (typeof value.savedAt !== 'string' || isExpiredSession (value.savedAt))
|
||||
{
|
||||
removeStorage (sessionKey (sessionId), onError)
|
||||
return null
|
||||
}
|
||||
|
||||
const rows = value.rows.map (sanitiseRow)
|
||||
if (rows.some (_1 => _1 == null))
|
||||
return null
|
||||
const rows = value.rows.map (sanitiseRow)
|
||||
if (rows.some (row => row == null))
|
||||
return null
|
||||
|
||||
return {
|
||||
version: SESSION_VERSION,
|
||||
savedAt: value.savedAt,
|
||||
source: typeof value.source === 'string' ? value.source : '',
|
||||
rows: rows as PostImportRow[],
|
||||
repairMode: value.repairMode === 'failed' ? 'failed' : 'all' }
|
||||
return {
|
||||
version: SESSION_VERSION,
|
||||
savedAt: value.savedAt,
|
||||
source: typeof value.source === 'string' ? value.source : '',
|
||||
rows: rows as PostImportRow[],
|
||||
repairMode: value.repairMode === 'failed' ? 'failed' : 'all' }
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,22 +34,29 @@ export type PostImportRow = {
|
||||
metadataUrl?: string
|
||||
resetSnapshot: PostImportResetSnapshot
|
||||
createdPostId?: number
|
||||
importStatus?: PostImportStatus }
|
||||
importStatus?: PostImportStatus
|
||||
recoverable?: boolean }
|
||||
|
||||
export type PostImportResultRow =
|
||||
| {
|
||||
sourceRow: number
|
||||
status: 'created'
|
||||
post: { id: number }
|
||||
fieldWarnings?: Record<string, string[]>
|
||||
baseWarnings?: string[]
|
||||
errors?: Record<string, string[]> }
|
||||
| {
|
||||
sourceRow: number
|
||||
status: 'skipped'
|
||||
existingPostId: number
|
||||
fieldWarnings?: Record<string, string[]>
|
||||
baseWarnings?: string[]
|
||||
errors?: Record<string, string[]> }
|
||||
| {
|
||||
sourceRow: number
|
||||
status: 'failed'
|
||||
fieldWarnings?: Record<string, string[]>
|
||||
baseWarnings?: string[]
|
||||
errors?: Record<string, string[]>
|
||||
recoverable?: boolean }
|
||||
|
||||
@@ -65,4 +72,14 @@ export type PostImportSourceIssue = {
|
||||
message: string
|
||||
url: string }
|
||||
|
||||
export type PostImportEditableDraft = {
|
||||
url: string
|
||||
title: string
|
||||
thumbnailBase: string
|
||||
originalCreatedFrom: string
|
||||
originalCreatedBefore: string
|
||||
duration: string
|
||||
tags: string
|
||||
parentPostIds: string }
|
||||
|
||||
export type StorageErrorHandler = (message: string) => void
|
||||
|
||||
@@ -1,82 +1,402 @@
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { Route, Routes, useLocation } from 'react-router-dom'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { HelmetProvider } from 'react-helmet-async'
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { savePostImportSession } from '@/lib/postImportSession'
|
||||
import {
|
||||
loadPostImportSession,
|
||||
savePostImportSession,
|
||||
} from '@/lib/postImportSession'
|
||||
import PostImportResultPage from '@/pages/posts/PostImportResultPage'
|
||||
import { buildUser } from '@/test/factories'
|
||||
import { buildPostImportRow } from '@/test/postImportFactories'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
import { originalCreatedAtString } from '@/lib/utils'
|
||||
|
||||
const api = vi.hoisted (() => ({ apiPost: vi.fn () }))
|
||||
const toastApi = vi.hoisted (() => ({ toast: vi.fn () }))
|
||||
import type { PostImportRow } from '@/lib/postImportSession'
|
||||
import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
||||
|
||||
const api = vi.hoisted (() => ({
|
||||
apiGet: vi.fn (),
|
||||
apiPost: vi.fn (),
|
||||
}))
|
||||
|
||||
const toastApi = vi.hoisted (() => ({
|
||||
toast: vi.fn (),
|
||||
}))
|
||||
|
||||
const dialogue = vi.hoisted (() => ({
|
||||
form: vi.fn (() => Promise.resolve ()),
|
||||
}))
|
||||
|
||||
vi.mock ('@/lib/api', () => api)
|
||||
vi.mock ('@/components/ui/use-toast', () => toastApi)
|
||||
|
||||
const ReviewLocation = () => {
|
||||
const location = useLocation ()
|
||||
return <div>{`review route ${ location.search }`}</div>
|
||||
}
|
||||
|
||||
const renderPage = () => renderWithProviders (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/posts/import/:sessionId/result"
|
||||
element={<PostImportResultPage user={buildUser ()}/>}/>
|
||||
<Route path="/posts/import/:sessionId/review" element={<ReviewLocation/>}/>
|
||||
</Routes>,
|
||||
{ route: '/posts/import/session/result' })
|
||||
vi.mock ('@/lib/api', () => api)
|
||||
vi.mock ('@/lib/dialogues/useDialogue', () => ({
|
||||
default: () => dialogue,
|
||||
}))
|
||||
|
||||
describe ('PostImportResultPage', () => {
|
||||
beforeEach (() => {
|
||||
sessionStorage.clear ()
|
||||
vi.clearAllMocks ()
|
||||
globalThis.URL.createObjectURL = vi.fn (() => 'blob:preview')
|
||||
globalThis.URL.revokeObjectURL = vi.fn ()
|
||||
api.apiGet.mockResolvedValue (new Blob (['img'], { type: 'image/png' }))
|
||||
})
|
||||
|
||||
it ('shows exclusive result counts, post links, and failed-row actions', async () => {
|
||||
const rows = [
|
||||
buildPostImportRow ({ sourceRow: 1, importStatus: 'created', createdPostId: 11 }),
|
||||
buildPostImportRow ({ sourceRow: 2, importStatus: 'skipped',
|
||||
skipReason: 'existing', existingPostId: 22 }),
|
||||
buildPostImportRow ({ sourceRow: 3, importStatus: 'failed',
|
||||
importErrors: { base: ['登録中に失敗しました.'] } })]
|
||||
savePostImportSession ('session', { source: '', rows, repairMode: 'all' })
|
||||
it ('shows edit and retry only for recoverable failures and shows warnings', () => {
|
||||
savePostImportSession ('result-session', {
|
||||
source: '',
|
||||
repairMode: 'failed',
|
||||
rows: [
|
||||
buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
attributes: { title: 'recoverable row' },
|
||||
importStatus: 'failed',
|
||||
recoverable: true,
|
||||
importErrors: { base: ['recoverable failed'] } }),
|
||||
buildPostImportRow ({
|
||||
sourceRow: 2,
|
||||
attributes: { title: 'hard failed row' },
|
||||
importStatus: 'failed',
|
||||
importErrors: { base: ['hard failed'] } }),
|
||||
buildPostImportRow ({
|
||||
sourceRow: 3,
|
||||
attributes: {
|
||||
title: 'created row',
|
||||
originalCreatedFrom: '2024-01-01T00:00:00Z',
|
||||
originalCreatedBefore: '2024-01-02T00:00:00Z' },
|
||||
importStatus: 'created',
|
||||
createdPostId: 3,
|
||||
fieldWarnings: {
|
||||
thumbnailBase: ['サムネール画像を取得できませんでした.'] } })] })
|
||||
|
||||
renderPage ()
|
||||
render (
|
||||
<HelmetProvider>
|
||||
<MemoryRouter initialEntries={['/posts/import/result-session/result']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/posts/import/:sessionId/result"
|
||||
element={<PostImportResultPage user={buildUser ()}/>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>)
|
||||
|
||||
expect (await screen.findByText (
|
||||
/登録成功\s*1件.*スキップ\s*1件.*失敗\s*1件/)).toBeInTheDocument ()
|
||||
expect (screen.getAllByRole ('link', { name: '投稿を開く' })
|
||||
.map (_1 => _1.getAttribute ('href'))).toEqual (['/posts/11', '/posts/22'])
|
||||
expect (screen.getByText ('登録中に失敗しました.')).toBeInTheDocument ()
|
||||
expect (screen.getByRole ('button', { name: '編輯' })).toBeInTheDocument ()
|
||||
expect (screen.getByRole ('button', { name: '再試行' })).toBeInTheDocument ()
|
||||
expect (screen.getAllByRole ('button', { name: '編輯' })).toHaveLength (1)
|
||||
expect (screen.getAllByRole ('button', { name: '再試行' })).toHaveLength (1)
|
||||
expect (screen.getByText ('サムネール画像を取得できませんでした.'))
|
||||
.toBeInTheDocument ()
|
||||
expect (screen.getByText (
|
||||
originalCreatedAtString ('2024-01-01T00:00:00Z', '2024-01-02T00:00:00Z'),
|
||||
)).toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('returns a retry validation error to the review dialogue route', async () => {
|
||||
const failed = buildPostImportRow ({
|
||||
importStatus: 'failed',
|
||||
importErrors: { base: ['old error'] } })
|
||||
const invalid = buildPostImportRow ({
|
||||
importStatus: 'pending',
|
||||
status: 'error',
|
||||
validationErrors: { title: ['タイトルを確認してください.'] } })
|
||||
savePostImportSession ('session', {
|
||||
source: failed.url,
|
||||
rows: [failed],
|
||||
repairMode: 'all' })
|
||||
api.apiPost.mockResolvedValue ({ rows: [invalid] })
|
||||
it ('shows hour-precision original-created ranges with the shared utility output', () => {
|
||||
const from = '2024-01-01T12:00:00Z'
|
||||
const before = '2024-01-01T13:00:00Z'
|
||||
savePostImportSession ('result-hour-range', {
|
||||
source: '',
|
||||
repairMode: 'all',
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
importStatus: 'created',
|
||||
createdPostId: 1,
|
||||
attributes: {
|
||||
title: 'created row',
|
||||
originalCreatedFrom: from,
|
||||
originalCreatedBefore: before } })] })
|
||||
|
||||
renderPage ()
|
||||
fireEvent.click (await screen.findByRole ('button', { name: '再試行' }))
|
||||
render (
|
||||
<HelmetProvider>
|
||||
<MemoryRouter initialEntries={['/posts/import/result-hour-range/result']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/posts/import/:sessionId/result"
|
||||
element={<PostImportResultPage user={buildUser ()}/>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>)
|
||||
|
||||
expect (await screen.findByText ('review route ?edit=1')).toBeInTheDocument ()
|
||||
expect (api.apiPost).toHaveBeenCalledTimes (1)
|
||||
const saved = JSON.parse (
|
||||
sessionStorage.getItem ('post-import-session:session') ?? '{}')
|
||||
expect (saved.rows[0]).toMatchObject ({
|
||||
importStatus: 'pending',
|
||||
validationErrors: { title: ['タイトルを確認してください.'] } })
|
||||
expect (screen.getByText (originalCreatedAtString (from, before))).toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('keeps recoverable pending rows actionable and hides actions for hard failures', () => {
|
||||
savePostImportSession ('result-pending', {
|
||||
source: '',
|
||||
repairMode: 'failed',
|
||||
rows: [
|
||||
buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
importStatus: 'pending',
|
||||
recoverable: true,
|
||||
validationErrors: { title: ['invalid'] } }),
|
||||
buildPostImportRow ({
|
||||
sourceRow: 2,
|
||||
importStatus: 'pending',
|
||||
recoverable: true }),
|
||||
buildPostImportRow ({
|
||||
sourceRow: 3,
|
||||
importStatus: 'failed',
|
||||
importErrors: { base: ['hard failed'] } })] })
|
||||
|
||||
render (
|
||||
<HelmetProvider>
|
||||
<MemoryRouter initialEntries={['/posts/import/result-pending/result']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/posts/import/:sessionId/result"
|
||||
element={<PostImportResultPage user={buildUser ()}/>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>)
|
||||
|
||||
expect (screen.getAllByRole ('button', { name: '編輯' })).toHaveLength (1)
|
||||
expect (screen.getAllByRole ('button', { name: '再試行' })).toHaveLength (1)
|
||||
expect (screen.getByText ('invalid')).toBeInTheDocument ()
|
||||
expect (screen.getByText ('hard failed')).toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('disables editing, retry, and navigation while retry is running', async () => {
|
||||
let resolveValidation: ((value: { rows: PostImportRow[] }) => void) | null = null
|
||||
savePostImportSession ('result-retry-busy', {
|
||||
source: '',
|
||||
repairMode: 'failed',
|
||||
rows: [
|
||||
buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
importStatus: 'failed',
|
||||
recoverable: true,
|
||||
importErrors: { base: ['failed'] } }),
|
||||
buildPostImportRow ({
|
||||
sourceRow: 2,
|
||||
importStatus: 'failed',
|
||||
recoverable: true,
|
||||
importErrors: { base: ['failed'] } })] })
|
||||
api.apiPost.mockImplementationOnce (() =>
|
||||
new Promise<{ rows: PostImportRow[] }> (resolve => {
|
||||
resolveValidation = resolve
|
||||
}))
|
||||
api.apiPost.mockResolvedValueOnce ({
|
||||
created: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
rows: [] })
|
||||
|
||||
render (
|
||||
<HelmetProvider>
|
||||
<MemoryRouter initialEntries={['/posts/import/result-retry-busy/result']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/posts/import/:sessionId/result"
|
||||
element={<PostImportResultPage user={buildUser ()}/>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>)
|
||||
|
||||
fireEvent.click (screen.getAllByRole ('button', { name: '再試行' })[0])
|
||||
|
||||
await waitFor (() => {
|
||||
screen.getAllByRole ('button', { name: '編輯' }).forEach (button => {
|
||||
expect (button).toBeDisabled ()
|
||||
})
|
||||
screen.getAllByRole ('button', { name: '再試行' }).forEach (button => {
|
||||
expect (button).toBeDisabled ()
|
||||
})
|
||||
expect (screen.getByRole ('button', { name: '確認画面へ戻る' })).toBeDisabled ()
|
||||
expect (screen.getByRole ('button', { name: '新しい URL リストを入力' })).toBeDisabled ()
|
||||
})
|
||||
|
||||
resolveValidation?.({ rows: [buildPostImportRow ({ sourceRow: 1 })] })
|
||||
})
|
||||
|
||||
it ('persists retry success to sessionStorage', async () => {
|
||||
savePostImportSession ('result-retry-success', {
|
||||
source: '',
|
||||
repairMode: 'failed',
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
importStatus: 'failed',
|
||||
recoverable: true,
|
||||
importErrors: { base: ['failed'] } })] })
|
||||
api.apiPost
|
||||
.mockResolvedValueOnce ({
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
importStatus: 'pending',
|
||||
recoverable: true })] })
|
||||
.mockResolvedValueOnce ({
|
||||
created: 1,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
rows: [{
|
||||
sourceRow: 1,
|
||||
status: 'created',
|
||||
post: { id: 10 } }] })
|
||||
|
||||
render (
|
||||
<HelmetProvider>
|
||||
<MemoryRouter initialEntries={['/posts/import/result-retry-success/result']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/posts/import/:sessionId/result"
|
||||
element={<PostImportResultPage user={buildUser ()}/>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>)
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (loadPostImportSession ('result-retry-success')?.rows[0]?.importStatus)
|
||||
.toBe ('created')
|
||||
})
|
||||
})
|
||||
|
||||
it ('keeps edited recoverable rows in session and retries with the edited values', async () => {
|
||||
savePostImportSession ('result-edit-retry', {
|
||||
source: '',
|
||||
repairMode: 'failed',
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
attributes: { title: 'old title' },
|
||||
importStatus: 'failed',
|
||||
recoverable: true,
|
||||
importErrors: { base: ['failed'] } })] })
|
||||
api.apiPost
|
||||
.mockResolvedValueOnce ({
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
attributes: { title: 'edited title' },
|
||||
importStatus: 'pending',
|
||||
recoverable: true })] })
|
||||
.mockResolvedValueOnce ({
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
attributes: { title: 'edited title' },
|
||||
importStatus: 'pending',
|
||||
recoverable: true })] })
|
||||
.mockResolvedValueOnce ({
|
||||
created: 1,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
rows: [{
|
||||
sourceRow: 1,
|
||||
status: 'created',
|
||||
post: { id: 11 } }] })
|
||||
dialogue.form.mockImplementationOnce (async options => {
|
||||
let actions: DialogueFormAction[] = []
|
||||
const controls: DialogueFormControls = {
|
||||
close: vi.fn (),
|
||||
confirm: vi.fn (),
|
||||
setActions: next => {
|
||||
actions = next
|
||||
} }
|
||||
|
||||
render (options.body (controls))
|
||||
await waitFor (() => expect (actions.length).toBe (2))
|
||||
fireEvent.change (screen.getByDisplayValue ('old title'), {
|
||||
target: { value: 'edited title' } })
|
||||
await act (async () => {
|
||||
await actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
|
||||
})
|
||||
})
|
||||
|
||||
render (
|
||||
<HelmetProvider>
|
||||
<MemoryRouter initialEntries={['/posts/import/result-edit-retry/result']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/posts/import/:sessionId/result"
|
||||
element={<PostImportResultPage user={buildUser ()}/>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>)
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '編輯' }))
|
||||
|
||||
await waitFor (() => {
|
||||
const saved = loadPostImportSession ('result-edit-retry')
|
||||
expect (saved?.rows[0]?.attributes.title).toBe ('edited title')
|
||||
expect (saved?.rows[0]?.importStatus).toBe ('pending')
|
||||
})
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (api.apiPost.mock.calls[2]?.[1]?.rows?.[0]?.attributes?.title)
|
||||
.toBe ('edited title')
|
||||
})
|
||||
})
|
||||
|
||||
it ('persists restored failed state when retry fails', async () => {
|
||||
savePostImportSession ('result-retry-failure', {
|
||||
source: '',
|
||||
repairMode: 'failed',
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
importStatus: 'failed',
|
||||
recoverable: true,
|
||||
importErrors: { base: ['failed'] } })] })
|
||||
api.apiPost.mockRejectedValueOnce (new Error ('network error'))
|
||||
|
||||
render (
|
||||
<HelmetProvider>
|
||||
<MemoryRouter initialEntries={['/posts/import/result-retry-failure/result']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/posts/import/:sessionId/result"
|
||||
element={<PostImportResultPage user={buildUser ()}/>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>)
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
|
||||
|
||||
await waitFor (() => {
|
||||
const saved = loadPostImportSession ('result-retry-failure')
|
||||
expect (saved?.rows[0]?.importStatus).toBe ('failed')
|
||||
expect (saved?.rows[0]?.recoverable).toBe (true)
|
||||
})
|
||||
})
|
||||
|
||||
it ('restores the failed row when the retry import response is incomplete', async () => {
|
||||
savePostImportSession ('result-retry-missing-row', {
|
||||
source: '',
|
||||
repairMode: 'failed',
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
importStatus: 'failed',
|
||||
recoverable: true,
|
||||
importErrors: { base: ['failed'] } })] })
|
||||
api.apiPost
|
||||
.mockResolvedValueOnce ({
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
importStatus: 'pending',
|
||||
recoverable: true })] })
|
||||
.mockResolvedValueOnce ({
|
||||
created: 1,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
rows: [] })
|
||||
|
||||
render (
|
||||
<HelmetProvider>
|
||||
<MemoryRouter initialEntries={['/posts/import/result-retry-missing-row/result']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/posts/import/:sessionId/result"
|
||||
element={<PostImportResultPage user={buildUser ()}/>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>)
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
|
||||
|
||||
await waitFor (() => {
|
||||
const saved = loadPostImportSession ('result-retry-missing-row')
|
||||
expect (saved?.rows[0]?.importStatus).toBe ('failed')
|
||||
expect (toastApi.toast).toHaveBeenCalledWith (
|
||||
expect.objectContaining ({ title: '登録結果が不完全でした' }))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,48 +1,61 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
|
||||
import FieldError from '@/components/common/FieldError'
|
||||
import FieldWarning from '@/components/common/FieldWarning'
|
||||
import PageTitle from '@/components/common/PageTitle'
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import PostImportRowForm from '@/components/posts/import/PostImportRowForm'
|
||||
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
|
||||
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { toast } from '@/components/ui/use-toast'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { apiPost } from '@/lib/api'
|
||||
import useDialogue from '@/lib/dialogues/useDialogue'
|
||||
import { canEditContent } from '@/lib/users'
|
||||
import { clearPostImportSourceDraft,
|
||||
buildNextEditedRow,
|
||||
canEditResultRow,
|
||||
canRetryResultRow,
|
||||
hasExactSourceRows,
|
||||
initialisePreviewRows,
|
||||
loadPostImportSession,
|
||||
mergeImportResults,
|
||||
mergeValidatedImportRows,
|
||||
mergeValidatedImportRow,
|
||||
replaceImportRow,
|
||||
resultRepairMode,
|
||||
resultRowMessages,
|
||||
resultRowWarnings,
|
||||
resultSummaryCounts,
|
||||
retryImportRow,
|
||||
savePostImportSession } from '@/lib/postImportSession'
|
||||
import { originalCreatedAtString } from '@/lib/utils'
|
||||
import Forbidden from '@/pages/Forbidden'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { PostImportRowDraft } from '@/components/posts/import/PostImportRowForm'
|
||||
import type { PostImportResultRow, PostImportRow } from '@/lib/postImportSession'
|
||||
import type { PostImportSession } from '@/lib/postImportSession'
|
||||
import type { User } from '@/types'
|
||||
|
||||
type Props = { user: User | null }
|
||||
|
||||
const rowMessages = (row: PostImportRow): string[] =>
|
||||
Object.values (row.importErrors ?? { }).flat ()
|
||||
|
||||
|
||||
const PostImportResultPage: FC<Props> = ({ user }) => {
|
||||
const editable = canEditContent (user)
|
||||
const dialogue = useDialogue ()
|
||||
const navigate = useNavigate ()
|
||||
const { sessionId } = useParams ()
|
||||
|
||||
const [session, setSession] = useState<PostImportSession | null> (null)
|
||||
const [missing, setMissing] = useState (false)
|
||||
const [loadingRow, setLoadingRow] = useState<number | null> (null)
|
||||
const [, setEditingRow] = useState<PostImportRow | null> (null)
|
||||
const sessionRef = useRef<PostImportSession | null> (null)
|
||||
|
||||
useEffect (() => {
|
||||
if (sessionId == null)
|
||||
@@ -62,115 +75,341 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
||||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||
}, [session, sessionId])
|
||||
|
||||
useEffect (() => {
|
||||
sessionRef.current = session
|
||||
}, [session])
|
||||
|
||||
const counts = useMemo (
|
||||
() => resultSummaryCounts (session?.rows ?? []),
|
||||
[session])
|
||||
const busy = loadingRow != null
|
||||
|
||||
const persistSession = (nextSession: PostImportSession) => {
|
||||
if (sessionId == null)
|
||||
return
|
||||
|
||||
savePostImportSession (sessionId, nextSession, message =>
|
||||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||
}
|
||||
|
||||
const saveDraft = async (
|
||||
row: PostImportRow,
|
||||
{ draft, resetRequested }: {
|
||||
draft: PostImportRowDraft
|
||||
resetRequested: boolean },
|
||||
): Promise<{ saved: boolean
|
||||
row: PostImportRow | null }> => {
|
||||
const currentSession = sessionRef.current
|
||||
if (currentSession == null)
|
||||
return { saved: false, row: null }
|
||||
if (!(canEditResultRow (row)))
|
||||
return { saved: false, row: null }
|
||||
|
||||
const baseRow =
|
||||
resetRequested
|
||||
? { ...row,
|
||||
url: row.resetSnapshot.url,
|
||||
attributes: { ...row.resetSnapshot.attributes },
|
||||
provenance: { ...row.resetSnapshot.provenance },
|
||||
tagSources: { ...row.resetSnapshot.tagSources },
|
||||
fieldWarnings: Object.fromEntries (
|
||||
Object.entries (row.resetSnapshot.fieldWarnings)
|
||||
.map (([key, values]) => [key, [...values]])),
|
||||
baseWarnings: [...row.resetSnapshot.baseWarnings],
|
||||
metadataUrl: row.resetSnapshot.metadataUrl }
|
||||
: row
|
||||
const urlChanged = draft.url !== baseRow.url
|
||||
const nextRow = buildNextEditedRow (baseRow, draft, urlChanged)
|
||||
|
||||
const nextRows = currentSession.rows.map (row =>
|
||||
row.sourceRow === baseRow.sourceRow
|
||||
? nextRow
|
||||
: row)
|
||||
try
|
||||
{
|
||||
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
|
||||
rows:
|
||||
nextRows
|
||||
.filter (row => row.importStatus !== 'created')
|
||||
.map (row => ({ sourceRow: row.sourceRow,
|
||||
url: row.url,
|
||||
attributes: row.attributes,
|
||||
provenance: row.provenance,
|
||||
tagSources: row.tagSources,
|
||||
metadataUrl: row.metadataUrl })),
|
||||
changed_row: urlChanged ? baseRow.sourceRow : -1 })
|
||||
const validatedRows = initialisePreviewRows (validated.rows)
|
||||
const target = validatedRows.find (
|
||||
validatedRow => validatedRow.sourceRow === baseRow.sourceRow)
|
||||
const latestSession = sessionRef.current
|
||||
if (latestSession == null)
|
||||
return { saved: false, row: null }
|
||||
if (target == null)
|
||||
{
|
||||
const restoredRows = replaceImportRow (latestSession.rows, row)
|
||||
const restoredSession = {
|
||||
...latestSession,
|
||||
rows: restoredRows,
|
||||
repairMode: resultRepairMode (restoredRows) }
|
||||
sessionRef.current = restoredSession
|
||||
setSession (restoredSession)
|
||||
toast ({ title: '行の再検証結果が不完全でした' })
|
||||
return { saved: false, row: null }
|
||||
}
|
||||
const editedRows = replaceImportRow (latestSession.rows, nextRow)
|
||||
const rows = mergeValidatedImportRow (editedRows, target)
|
||||
const nextSession = {
|
||||
...latestSession,
|
||||
rows,
|
||||
repairMode: resultRepairMode (rows) }
|
||||
sessionRef.current = nextSession
|
||||
setSession (nextSession)
|
||||
persistSession (nextSession)
|
||||
const mergedTarget =
|
||||
rows.find (mergedRow => mergedRow.sourceRow === baseRow.sourceRow)
|
||||
?? target
|
||||
if (Object.keys (mergedTarget.validationErrors).length > 0)
|
||||
return { saved: false, row: mergedTarget }
|
||||
return { saved: true, row: null }
|
||||
}
|
||||
catch
|
||||
{
|
||||
toast ({ title: '行の再検証に失敗しました' })
|
||||
return { saved: false, row: null }
|
||||
}
|
||||
}
|
||||
|
||||
const openEditingDialogue = async (row: PostImportRow) => {
|
||||
const saveRowDraft = (
|
||||
{ draft, resetRequested }: {
|
||||
draft: PostImportRowDraft
|
||||
resetRequested: boolean },
|
||||
) =>
|
||||
saveDraft (row, { draft, resetRequested })
|
||||
|
||||
await dialogue.form ({
|
||||
title: '投稿を編輯',
|
||||
description: `投稿 ${ row.sourceRow } の内容を確認し、必要な項目を編輯してください.`,
|
||||
cancelText: '取消',
|
||||
size: 'large',
|
||||
body: controls => (
|
||||
<PostImportRowForm
|
||||
row={row}
|
||||
controls={controls}
|
||||
onSave={saveRowDraft}/>) })
|
||||
}
|
||||
|
||||
const editRow = async (row: PostImportRow) => {
|
||||
setEditingRow (row)
|
||||
try
|
||||
{
|
||||
await openEditingDialogue (row)
|
||||
}
|
||||
finally
|
||||
{
|
||||
setEditingRow (current =>
|
||||
current?.sourceRow === row.sourceRow
|
||||
? null
|
||||
: current)
|
||||
}
|
||||
}
|
||||
|
||||
const retry = async (sourceRow: number) => {
|
||||
if (session == null || sessionId == null)
|
||||
if (session == null || sessionId == null || loadingRow != null)
|
||||
return
|
||||
|
||||
const initialSession = sessionRef.current
|
||||
if (initialSession == null)
|
||||
return
|
||||
|
||||
setLoadingRow (sourceRow)
|
||||
const originalRow = initialSession.rows.find (row => row.sourceRow === sourceRow)
|
||||
if (originalRow == null)
|
||||
{
|
||||
setLoadingRow (null)
|
||||
return
|
||||
}
|
||||
try
|
||||
{
|
||||
const pendingRows = retryImportRow (session.rows, sourceRow)
|
||||
setSession ({ ...session, rows: pendingRows })
|
||||
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
|
||||
rows: pendingRows
|
||||
.filter (row => row.importStatus !== 'created')
|
||||
.map (row => ({
|
||||
sourceRow: row.sourceRow,
|
||||
url: row.url,
|
||||
attributes: row.attributes,
|
||||
provenance: row.provenance,
|
||||
tagSources: row.tagSources,
|
||||
metadataUrl: row.metadataUrl })),
|
||||
changed_row: -1 })
|
||||
const validatedRows = mergeValidatedImportRows (
|
||||
pendingRows,
|
||||
initialisePreviewRows (validated.rows))
|
||||
const nextSession = {
|
||||
...session,
|
||||
rows: validatedRows,
|
||||
repairMode: 'failed' as const }
|
||||
setSession (nextSession)
|
||||
const target = validatedRows.find (_1 => _1.sourceRow === sourceRow)
|
||||
if (target == null)
|
||||
return
|
||||
if (Object.keys (target.validationErrors ?? { }).length > 0)
|
||||
{
|
||||
const saved = savePostImportSession (sessionId, nextSession, message =>
|
||||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||
if (saved)
|
||||
navigate (`/posts/import/${ sessionId }/review?edit=${ sourceRow }`)
|
||||
return
|
||||
}
|
||||
{
|
||||
const pendingRows = retryImportRow (initialSession.rows, sourceRow)
|
||||
const pendingSession = { ...initialSession, rows: pendingRows }
|
||||
sessionRef.current = pendingSession
|
||||
setSession (pendingSession)
|
||||
persistSession (pendingSession)
|
||||
const requestedRows = pendingRows.filter (row => row.importStatus !== 'created')
|
||||
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
|
||||
rows: requestedRows.map (row => ({
|
||||
sourceRow: row.sourceRow,
|
||||
url: row.url,
|
||||
attributes: row.attributes,
|
||||
provenance: row.provenance,
|
||||
tagSources: row.tagSources,
|
||||
metadataUrl: row.metadataUrl })),
|
||||
changed_row: -1 })
|
||||
const validatedRows = initialisePreviewRows (validated.rows)
|
||||
if (!(hasExactSourceRows (requestedRows.map (row => row.sourceRow), validatedRows)))
|
||||
{
|
||||
const latest = sessionRef.current ?? pendingSession
|
||||
const restoredRows = replaceImportRow (latest.rows, originalRow)
|
||||
const restoredSession = {
|
||||
...latest,
|
||||
rows: restoredRows,
|
||||
repairMode: resultRepairMode (restoredRows) }
|
||||
sessionRef.current = restoredSession
|
||||
setSession (restoredSession)
|
||||
persistSession (restoredSession)
|
||||
toast ({ title: '再検証結果が不完全でした' })
|
||||
return
|
||||
}
|
||||
const validatedTarget = validatedRows.find (
|
||||
row => row.sourceRow === sourceRow)
|
||||
if (validatedTarget == null)
|
||||
{
|
||||
const latest = sessionRef.current ?? pendingSession
|
||||
const restoredRows = replaceImportRow (latest.rows, originalRow)
|
||||
const restoredSession = {
|
||||
...latest,
|
||||
rows: restoredRows,
|
||||
repairMode: resultRepairMode (restoredRows) }
|
||||
sessionRef.current = restoredSession
|
||||
setSession (restoredSession)
|
||||
persistSession (restoredSession)
|
||||
toast ({ title: '再検証結果が不完全でした' })
|
||||
return
|
||||
}
|
||||
const latestAfterValidate = sessionRef.current ?? pendingSession
|
||||
const mergedValidatedRows = mergeValidatedImportRow (
|
||||
latestAfterValidate.rows,
|
||||
validatedTarget)
|
||||
const nextSession = {
|
||||
...latestAfterValidate,
|
||||
rows: mergedValidatedRows,
|
||||
repairMode: resultRepairMode (mergedValidatedRows) }
|
||||
sessionRef.current = nextSession
|
||||
setSession (nextSession)
|
||||
persistSession (nextSession)
|
||||
const target = mergedValidatedRows.find (row => row.sourceRow === sourceRow)
|
||||
if (target == null)
|
||||
{
|
||||
const restoredRows = replaceImportRow (latestAfterValidate.rows, originalRow)
|
||||
const restoredSession = {
|
||||
...latestAfterValidate,
|
||||
rows: restoredRows,
|
||||
repairMode: resultRepairMode (restoredRows) }
|
||||
sessionRef.current = restoredSession
|
||||
setSession (restoredSession)
|
||||
persistSession (restoredSession)
|
||||
toast ({ title: '再検証結果が不完全でした' })
|
||||
return
|
||||
}
|
||||
if (Object.keys (target.validationErrors ?? { }).length > 0)
|
||||
{
|
||||
const saved = savePostImportSession (sessionId, nextSession, message =>
|
||||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||
if (saved)
|
||||
void editRow (target)
|
||||
return
|
||||
}
|
||||
|
||||
const result = await apiPost<{
|
||||
created: number
|
||||
skipped: number
|
||||
failed: number
|
||||
rows: PostImportResultRow[] }> ('/posts/import', {
|
||||
rows: [{
|
||||
sourceRow: target.sourceRow,
|
||||
url: target.url,
|
||||
attributes: target.attributes,
|
||||
provenance: target.provenance,
|
||||
tagSources: target.tagSources,
|
||||
metadataUrl: target.metadataUrl }] })
|
||||
const mergedRows = mergeImportResults (nextSession.rows, result.rows)
|
||||
const recoverableRows = result.rows.filter (row =>
|
||||
row.status === 'failed'
|
||||
&& row.recoverable
|
||||
&& Object.keys (row.errors ?? { }).length > 0)
|
||||
const nextRows = mergedRows.map ((row): PostImportRow => {
|
||||
const recoverable = recoverableRows.find (_1 => _1.sourceRow === row.sourceRow)
|
||||
if (recoverable == null)
|
||||
return row
|
||||
return {
|
||||
...row,
|
||||
importStatus: 'pending',
|
||||
validationErrors: recoverable.errors ?? { },
|
||||
importErrors: undefined }
|
||||
})
|
||||
const recoverableTarget = nextRows.find (_1 => _1.sourceRow === sourceRow)
|
||||
const resultSession = {
|
||||
...nextSession,
|
||||
rows: nextRows,
|
||||
repairMode: recoverableTarget == null ? 'all' as const : 'failed' as const }
|
||||
setSession (resultSession)
|
||||
if (recoverableTarget != null
|
||||
&& Object.keys (recoverableTarget.validationErrors).length > 0)
|
||||
{
|
||||
const saved = savePostImportSession (sessionId, resultSession, message =>
|
||||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||
if (saved)
|
||||
navigate (`/posts/import/${ sessionId }/review?edit=${ sourceRow }`)
|
||||
return
|
||||
}
|
||||
}
|
||||
const result = await apiPost<{
|
||||
created: number
|
||||
skipped: number
|
||||
failed: number
|
||||
rows: PostImportResultRow[] }> ('/posts/import', {
|
||||
rows: [{
|
||||
sourceRow: target.sourceRow,
|
||||
url: target.url,
|
||||
attributes: target.attributes,
|
||||
provenance: target.provenance,
|
||||
tagSources: target.tagSources,
|
||||
metadataUrl: target.metadataUrl }] })
|
||||
if (!(hasExactSourceRows ([sourceRow], result.rows)))
|
||||
{
|
||||
const latest = sessionRef.current ?? nextSession
|
||||
const restoredRows = replaceImportRow (latest.rows, originalRow)
|
||||
const restoredSession = {
|
||||
...latest,
|
||||
rows: restoredRows,
|
||||
repairMode: resultRepairMode (restoredRows) }
|
||||
sessionRef.current = restoredSession
|
||||
setSession (restoredSession)
|
||||
persistSession (restoredSession)
|
||||
toast ({ title: '登録結果が不完全でした' })
|
||||
return
|
||||
}
|
||||
const latestAfterImport = sessionRef.current ?? nextSession
|
||||
const mergedRows = mergeImportResults (latestAfterImport.rows, result.rows)
|
||||
const recoverableRows = result.rows.filter (row =>
|
||||
row.status === 'failed'
|
||||
&& row.recoverable
|
||||
&& Object.keys (row.errors ?? { }).length > 0)
|
||||
const nextRows = mergedRows.map ((row): PostImportRow => {
|
||||
const recoverable = recoverableRows.find (
|
||||
recoverableRow => recoverableRow.sourceRow === row.sourceRow)
|
||||
if (recoverable == null)
|
||||
return row
|
||||
return {
|
||||
...row,
|
||||
importStatus: 'pending',
|
||||
recoverable: true,
|
||||
validationErrors: recoverable.errors ?? { },
|
||||
importErrors: undefined }
|
||||
})
|
||||
const recoverableTarget = nextRows.find (row => row.sourceRow === sourceRow)
|
||||
const resultSession = {
|
||||
...nextSession,
|
||||
rows: nextRows,
|
||||
repairMode: resultRepairMode (nextRows) }
|
||||
sessionRef.current = resultSession
|
||||
setSession (resultSession)
|
||||
persistSession (resultSession)
|
||||
if (recoverableTarget != null
|
||||
&& Object.keys (recoverableTarget.validationErrors).length > 0)
|
||||
{
|
||||
const saved = savePostImportSession (sessionId, resultSession, message =>
|
||||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||
if (saved)
|
||||
void editRow (recoverableTarget)
|
||||
return
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
setSession (session)
|
||||
toast ({ title: '再試行に失敗しました' })
|
||||
}
|
||||
{
|
||||
const latest = sessionRef.current
|
||||
if (latest != null)
|
||||
{
|
||||
const restoredRows = replaceImportRow (latest.rows, originalRow)
|
||||
const restoredSession = {
|
||||
...latest,
|
||||
rows: restoredRows,
|
||||
repairMode: resultRepairMode (restoredRows) }
|
||||
sessionRef.current = restoredSession
|
||||
setSession (restoredSession)
|
||||
persistSession (restoredSession)
|
||||
}
|
||||
toast ({ title: '再試行に失敗しました' })
|
||||
}
|
||||
finally
|
||||
{
|
||||
setLoadingRow (null)
|
||||
}
|
||||
{
|
||||
setLoadingRow (null)
|
||||
}
|
||||
}
|
||||
|
||||
const openRepair = (sourceRow: number) => {
|
||||
if (session == null || sessionId == null)
|
||||
const currentSession = sessionRef.current
|
||||
if (currentSession == null || sessionId == null || loadingRow != null)
|
||||
return
|
||||
|
||||
const nextSession = { ...session, repairMode: 'failed' as const }
|
||||
const nextSession = { ...currentSession, repairMode: 'failed' as const }
|
||||
sessionRef.current = nextSession
|
||||
setSession (nextSession)
|
||||
const saved = savePostImportSession (sessionId, nextSession, message =>
|
||||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||
if (!(saved))
|
||||
return
|
||||
navigate (`/posts/import/${ sessionId }/review?edit=${ sourceRow }`)
|
||||
const row = nextSession.rows.find (rowValue => rowValue.sourceRow === sourceRow)
|
||||
if (row != null)
|
||||
void editRow (row)
|
||||
}
|
||||
|
||||
if (!(editable))
|
||||
@@ -206,10 +445,8 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
||||
<div className="space-y-3">
|
||||
{session.rows.map (row => {
|
||||
const displayStatus = displayPostImportStatus (row)
|
||||
const canEdit = row.importStatus === 'failed'
|
||||
const canRetry =
|
||||
row.importStatus === 'failed'
|
||||
&& Object.keys (row.validationErrors).length === 0
|
||||
const canEdit = canEditResultRow (row)
|
||||
const canRetry = canRetryResultRow (row)
|
||||
return (
|
||||
<div
|
||||
key={row.sourceRow}
|
||||
@@ -227,7 +464,17 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{row.url}
|
||||
</div>
|
||||
<FieldError messages={rowMessages (row)}/>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{String (row.attributes.tags ?? '') || 'タグなし'}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{originalCreatedAtString (
|
||||
row.attributes.originalCreatedFrom?.toString () ?? null,
|
||||
row.attributes.originalCreatedBefore?.toString () ?? null)}
|
||||
{row.attributes.duration ? ` / ${ row.attributes.duration }` : ''}
|
||||
</div>
|
||||
<FieldWarning messages={resultRowWarnings (row)}/>
|
||||
<FieldError messages={resultRowMessages (row)}/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
@@ -242,14 +489,15 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => openRepair (row.sourceRow)}>
|
||||
onClick={() => openRepair (row.sourceRow)}
|
||||
disabled={busy}>
|
||||
編輯
|
||||
</Button>)}
|
||||
{canRetry && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => retry (row.sourceRow)}
|
||||
disabled={loadingRow === row.sourceRow}>
|
||||
disabled={busy}>
|
||||
再試行
|
||||
</Button>)}
|
||||
</div>
|
||||
@@ -261,8 +509,13 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
const nextSession = { ...session, repairMode: 'all' as const }
|
||||
const currentSession = sessionRef.current
|
||||
if (currentSession == null)
|
||||
return
|
||||
const nextSession = { ...currentSession, repairMode: 'all' as const }
|
||||
sessionRef.current = nextSession
|
||||
setSession (nextSession)
|
||||
const saved = savePostImportSession (sessionId, nextSession, message =>
|
||||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||
@@ -275,6 +528,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
clearPostImportSourceDraft (message =>
|
||||
toast ({ title: '入力内容を削除できませんでした', description: message }))
|
||||
|
||||
@@ -1,78 +1,295 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { HelmetProvider } from 'react-helmet-async'
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { savePostImportSession } from '@/lib/postImportSession'
|
||||
import { loadPostImportSession, savePostImportSession } from '@/lib/postImportSession'
|
||||
import PostImportReviewPage from '@/pages/posts/PostImportReviewPage'
|
||||
import { buildUser } from '@/test/factories'
|
||||
import { buildPostImportRow } from '@/test/postImportFactories'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
const api = vi.hoisted (() => ({ apiPost: vi.fn () }))
|
||||
const toastApi = vi.hoisted (() => ({ toast: vi.fn () }))
|
||||
import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
||||
import type { PostImportRow } from '@/lib/postImportSession'
|
||||
|
||||
const api = vi.hoisted (() => ({
|
||||
apiGet: vi.fn (),
|
||||
apiPost: vi.fn (),
|
||||
}))
|
||||
|
||||
const toastApi = vi.hoisted (() => ({
|
||||
toast: vi.fn (),
|
||||
}))
|
||||
|
||||
const dialogue = vi.hoisted (() => ({
|
||||
form: vi.fn (() => Promise.resolve ()),
|
||||
}))
|
||||
|
||||
vi.mock ('@/lib/api', () => api)
|
||||
vi.mock ('@/components/ui/use-toast', () => toastApi)
|
||||
|
||||
const renderPage = () => renderWithProviders (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/posts/import/:sessionId/review"
|
||||
element={<PostImportReviewPage user={buildUser ()}/>}/>
|
||||
<Route path="/posts/import/:sessionId/result" element={<div>result route</div>}/>
|
||||
</Routes>,
|
||||
{ route: '/posts/import/session/review' })
|
||||
vi.mock ('@/lib/dialogues/useDialogue', () => ({
|
||||
default: () => dialogue,
|
||||
}))
|
||||
|
||||
describe ('PostImportReviewPage', () => {
|
||||
beforeEach (() => {
|
||||
sessionStorage.clear ()
|
||||
vi.clearAllMocks ()
|
||||
globalThis.URL.createObjectURL = vi.fn (() => 'blob:preview')
|
||||
globalThis.URL.revokeObjectURL = vi.fn ()
|
||||
api.apiGet.mockResolvedValue (new Blob (['img'], { type: 'image/png' }))
|
||||
})
|
||||
|
||||
it ('keeps the review route and opens the first invalid row dialogue', async () => {
|
||||
const row = buildPostImportRow ({ attributes: { title: 'title' } })
|
||||
savePostImportSession ('session', { source: row.url, rows: [row], repairMode: 'all' })
|
||||
api.apiPost.mockResolvedValue ({ rows: [buildPostImportRow ({
|
||||
attributes: { title: 'title' },
|
||||
status: 'error',
|
||||
validationErrors: { title: ['タイトルを確認してください.'] } })] })
|
||||
|
||||
renderPage ()
|
||||
fireEvent.click (await screen.findByRole ('button', { name: '取込実行' }))
|
||||
|
||||
expect (await screen.findByText ('投稿を編輯')).toBeInTheDocument ()
|
||||
expect (screen.getByText ('タイトルを確認してください.')).toBeInTheDocument ()
|
||||
expect (api.apiPost).toHaveBeenCalledTimes (1)
|
||||
expect (screen.queryByText ('result route')).not.toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('submits existing rows for a formal skipped result and opens result route', async () => {
|
||||
const row = buildPostImportRow ({
|
||||
skipReason: 'existing',
|
||||
existingPostId: 10 })
|
||||
savePostImportSession ('session', { source: row.url, rows: [row], repairMode: 'all' })
|
||||
it ('navigates to the result route after an initial recoverable failure', async () => {
|
||||
savePostImportSession ('review-session', {
|
||||
source: 'https://example.com/post',
|
||||
repairMode: 'all',
|
||||
rows: [buildPostImportRow ({ sourceRow: 1 })] })
|
||||
api.apiPost
|
||||
.mockResolvedValueOnce ({ rows: [row] })
|
||||
.mockResolvedValueOnce ({
|
||||
created: 0,
|
||||
skipped: 1,
|
||||
failed: 0,
|
||||
rows: [{ sourceRow: 1, status: 'skipped', existingPostId: 10 }] })
|
||||
.mockResolvedValueOnce ({
|
||||
rows: [buildPostImportRow ({ sourceRow: 1 })] })
|
||||
.mockResolvedValueOnce ({
|
||||
created: 0,
|
||||
skipped: 0,
|
||||
failed: 1,
|
||||
rows: [{
|
||||
sourceRow: 1,
|
||||
status: 'failed',
|
||||
recoverable: true,
|
||||
errors: { title: ['invalid'] } }] })
|
||||
|
||||
renderPage ()
|
||||
fireEvent.click (await screen.findByRole ('button', { name: '取込実行' }))
|
||||
render (
|
||||
<HelmetProvider>
|
||||
<MemoryRouter initialEntries={['/posts/import/review-session/review']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/posts/import/:sessionId/review"
|
||||
element={<PostImportReviewPage user={buildUser ()}/>}/>
|
||||
<Route
|
||||
path="/posts/import/:sessionId/result"
|
||||
element={<div>RESULT ROUTE</div>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>)
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
|
||||
|
||||
expect (await screen.findByText ('result route')).toBeInTheDocument ()
|
||||
expect (api.apiPost).toHaveBeenCalledTimes (2)
|
||||
expect (api.apiPost.mock.calls[1]?.[1]).toMatchObject ({
|
||||
rows: [expect.objectContaining ({ sourceRow: 1, url: row.url })] })
|
||||
await waitFor (() => {
|
||||
const saved = JSON.parse (
|
||||
sessionStorage.getItem ('post-import-session:session') ?? '{}')
|
||||
expect (saved.rows[0]).toMatchObject ({
|
||||
importStatus: 'skipped',
|
||||
existingPostId: 10,
|
||||
skipReason: 'existing' })
|
||||
expect (screen.getByText ('RESULT ROUTE')).toBeInTheDocument ()
|
||||
})
|
||||
expect (dialogue.form).not.toHaveBeenCalled ()
|
||||
})
|
||||
|
||||
it ('disables row editing and navigation while batch import is running', async () => {
|
||||
let resolveValidation: ((value: { rows: PostImportRow[] }) => void) | null = null
|
||||
savePostImportSession ('review-loading', {
|
||||
source: 'https://example.com/post',
|
||||
repairMode: 'all',
|
||||
rows: [buildPostImportRow ({ sourceRow: 1 })] })
|
||||
api.apiPost.mockImplementationOnce (() =>
|
||||
new Promise<{ rows: PostImportRow[] }> (resolve => {
|
||||
resolveValidation = resolve
|
||||
}))
|
||||
api.apiPost.mockResolvedValueOnce ({
|
||||
created: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
rows: [] })
|
||||
|
||||
render (
|
||||
<HelmetProvider>
|
||||
<MemoryRouter initialEntries={['/posts/import/review-loading/review']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/posts/import/:sessionId/review"
|
||||
element={<PostImportReviewPage user={buildUser ()}/>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>)
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (screen.getByRole ('button', { name: '編輯' })).toBeDisabled ()
|
||||
expect (screen.getByRole ('button', { name: 'URL リスト入力へ戻る' })).toBeDisabled ()
|
||||
expect (screen.getByRole ('button', { name: '取込実行' })).toBeDisabled ()
|
||||
})
|
||||
|
||||
resolveValidation?.({ rows: [buildPostImportRow ({ sourceRow: 1 })] })
|
||||
})
|
||||
|
||||
it ('does not call import when validation omits a requested source row', async () => {
|
||||
savePostImportSession ('review-missing-row', {
|
||||
source: 'https://example.com/post-1\nhttps://example.com/post-2',
|
||||
repairMode: 'all',
|
||||
rows: [
|
||||
buildPostImportRow ({ sourceRow: 1 }),
|
||||
buildPostImportRow ({ sourceRow: 2 })] })
|
||||
api.apiPost.mockResolvedValueOnce ({
|
||||
rows: [buildPostImportRow ({ sourceRow: 1 })] })
|
||||
|
||||
render (
|
||||
<HelmetProvider>
|
||||
<MemoryRouter initialEntries={['/posts/import/review-missing-row/review']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/posts/import/:sessionId/review"
|
||||
element={<PostImportReviewPage user={buildUser ()}/>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>)
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (toastApi.toast).toHaveBeenCalledWith (
|
||||
expect.objectContaining ({ title: '再検証結果が不完全でした' }))
|
||||
})
|
||||
expect (api.apiPost).toHaveBeenCalledTimes (1)
|
||||
})
|
||||
|
||||
it ('does not navigate to result when import omits a requested source row', async () => {
|
||||
savePostImportSession ('review-missing-import-row', {
|
||||
source: 'https://example.com/post',
|
||||
repairMode: 'all',
|
||||
rows: [buildPostImportRow ({ sourceRow: 1 })] })
|
||||
api.apiPost
|
||||
.mockResolvedValueOnce ({
|
||||
rows: [buildPostImportRow ({ sourceRow: 1 })] })
|
||||
.mockResolvedValueOnce ({
|
||||
created: 1,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
rows: [] })
|
||||
|
||||
render (
|
||||
<HelmetProvider>
|
||||
<MemoryRouter initialEntries={['/posts/import/review-missing-import-row/review']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/posts/import/:sessionId/review"
|
||||
element={<PostImportReviewPage user={buildUser ()}/>}/>
|
||||
<Route
|
||||
path="/posts/import/:sessionId/result"
|
||||
element={<div>RESULT ROUTE</div>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>)
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (toastApi.toast).toHaveBeenCalledWith (
|
||||
expect.objectContaining ({ title: '登録結果が不完全でした' }))
|
||||
})
|
||||
expect (screen.queryByText ('RESULT ROUTE')).not.toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('keeps edited recoverable rows in session before the next batch submit', async () => {
|
||||
savePostImportSession ('review-edit-retry', {
|
||||
source: 'https://example.com/post',
|
||||
repairMode: 'failed',
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
attributes: { title: 'old title' },
|
||||
importStatus: 'failed',
|
||||
recoverable: true,
|
||||
importErrors: { base: ['failed'] } })] })
|
||||
api.apiPost
|
||||
.mockResolvedValueOnce ({
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
attributes: { title: 'edited title' },
|
||||
importStatus: 'pending',
|
||||
recoverable: true })] })
|
||||
.mockResolvedValueOnce ({
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
attributes: { title: 'edited title' },
|
||||
importStatus: 'pending',
|
||||
recoverable: true })] })
|
||||
.mockResolvedValueOnce ({
|
||||
created: 0,
|
||||
skipped: 0,
|
||||
failed: 1,
|
||||
rows: [{
|
||||
sourceRow: 1,
|
||||
status: 'failed',
|
||||
recoverable: true,
|
||||
errors: { title: ['invalid'] } }] })
|
||||
dialogue.form.mockImplementationOnce (async options => {
|
||||
let actions: DialogueFormAction[] = []
|
||||
const controls: DialogueFormControls = {
|
||||
close: vi.fn (),
|
||||
confirm: vi.fn (),
|
||||
setActions: next => {
|
||||
actions = next
|
||||
} }
|
||||
|
||||
render (options.body (controls))
|
||||
await waitFor (() => expect (actions.length).toBe (2))
|
||||
fireEvent.change (screen.getByDisplayValue ('old title'), {
|
||||
target: { value: 'edited title' } })
|
||||
await actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
|
||||
})
|
||||
|
||||
render (
|
||||
<HelmetProvider>
|
||||
<MemoryRouter initialEntries={['/posts/import/review-edit-retry/review']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/posts/import/:sessionId/review"
|
||||
element={<PostImportReviewPage user={buildUser ()}/>}/>
|
||||
<Route
|
||||
path="/posts/import/:sessionId/result"
|
||||
element={<div>RESULT ROUTE</div>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>)
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '編輯' }))
|
||||
|
||||
await waitFor (() => {
|
||||
const saved = loadPostImportSession ('review-edit-retry')
|
||||
expect (saved?.rows[0]?.attributes.title).toBe ('edited title')
|
||||
expect (saved?.rows[0]?.importStatus).toBe ('pending')
|
||||
})
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (api.apiPost.mock.calls[1]?.[1]?.rows?.[0]?.attributes?.title)
|
||||
.toBe ('edited title')
|
||||
})
|
||||
})
|
||||
|
||||
it ('sorts recoverable pending validation rows to the top in repair mode', () => {
|
||||
savePostImportSession ('review-repair-sort', {
|
||||
source: '',
|
||||
repairMode: 'failed',
|
||||
rows: [
|
||||
buildPostImportRow ({
|
||||
sourceRow: 2,
|
||||
attributes: { title: 'normal row' } }),
|
||||
buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
attributes: { title: 'repair row' },
|
||||
importStatus: 'pending',
|
||||
recoverable: true,
|
||||
validationErrors: { title: ['invalid'] } })] })
|
||||
|
||||
const { container } = render (
|
||||
<HelmetProvider>
|
||||
<MemoryRouter initialEntries={['/posts/import/review-repair-sort/review']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/posts/import/:sessionId/review"
|
||||
element={<PostImportReviewPage user={buildUser ()}/>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>)
|
||||
|
||||
const titles = Array.from (container.querySelectorAll ('.line-clamp-2')).map (
|
||||
node => node.textContent)
|
||||
expect (titles[0]).toBe ('repair row')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
|
||||
import PageTitle from '@/components/common/PageTitle'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
@@ -13,11 +13,17 @@ import { apiPost } from '@/lib/api'
|
||||
import useDialogue from '@/lib/dialogues/useDialogue'
|
||||
import { canEditContent } from '@/lib/users'
|
||||
import { loadPostImportSession,
|
||||
buildNextEditedRow,
|
||||
canEditReviewRow,
|
||||
creatableImportRows,
|
||||
hasExactSourceRows,
|
||||
initialisePreviewRows,
|
||||
mergeImportResults,
|
||||
mergeValidatedImportRow,
|
||||
mergeValidatedImportRows,
|
||||
processableImportRows,
|
||||
replaceImportRow,
|
||||
resultRepairMode,
|
||||
reviewSummaryCounts,
|
||||
savePostImportSession } from '@/lib/postImportSession'
|
||||
import Forbidden from '@/pages/Forbidden'
|
||||
@@ -32,17 +38,24 @@ import type { User } from '@/types'
|
||||
|
||||
type Props = { user: User | null }
|
||||
|
||||
const isRepairRow = (row: PostImportRow): boolean =>
|
||||
row.recoverable === true
|
||||
&& (row.importStatus === 'failed'
|
||||
|| (row.importStatus === 'pending'
|
||||
&& Object.keys (row.validationErrors).length > 0))
|
||||
|
||||
|
||||
const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
const editable = canEditContent (user)
|
||||
const dialogue = useDialogue ()
|
||||
const navigate = useNavigate ()
|
||||
const { sessionId } = useParams ()
|
||||
const [searchParams, setSearchParams] = useSearchParams ()
|
||||
|
||||
const [session, setSession] = useState<PostImportSession | null> (null)
|
||||
const [loading, setLoading] = useState (false)
|
||||
const [missing, setMissing] = useState (false)
|
||||
const [editingRow, setEditingRow] = useState<PostImportRow | null> (null)
|
||||
const sessionRef = useRef<PostImportSession | null> (null)
|
||||
|
||||
useEffect (() => {
|
||||
if (sessionId == null)
|
||||
@@ -62,12 +75,11 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||
}, [session, sessionId])
|
||||
|
||||
useEffect (() => {
|
||||
sessionRef.current = session
|
||||
}, [session])
|
||||
|
||||
const rows = session?.rows ?? []
|
||||
const editingSourceRow = Number (searchParams.get ('edit') ?? '')
|
||||
const editingRow =
|
||||
Number.isFinite (editingSourceRow)
|
||||
? rows.find (_1 => _1.sourceRow === editingSourceRow) ?? null
|
||||
: null
|
||||
const counts = useMemo (() => reviewSummaryCounts (rows), [rows])
|
||||
const processable = useMemo (
|
||||
() => processableImportRows (rows),
|
||||
@@ -79,9 +91,9 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
session?.repairMode === 'failed'
|
||||
? (
|
||||
[...rows].sort ((a, b) => {
|
||||
const aFailed = a.importStatus === 'failed' ? 0 : 1
|
||||
const bFailed = b.importStatus === 'failed' ? 0 : 1
|
||||
return aFailed - bFailed || a.sourceRow - b.sourceRow
|
||||
const aRepair = isRepairRow (a) ? 0 : 1
|
||||
const bRepair = isRepairRow (b) ? 0 : 1
|
||||
return aRepair - bRepair || a.sourceRow - b.sourceRow
|
||||
}))
|
||||
: rows
|
||||
|
||||
@@ -93,41 +105,37 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
element?.scrollIntoView ({ block: 'center', behavior: 'smooth' })
|
||||
}, [editingRow, session?.repairMode])
|
||||
|
||||
const updateSessionRows = (nextRows: PostImportRow[]) =>
|
||||
setSession (current =>
|
||||
current != null ? { ...current, rows: nextRows } : current)
|
||||
|
||||
const saveDraft = async (
|
||||
{ draft, resetRequested, resetSnapshot }: {
|
||||
row: PostImportRow,
|
||||
{ draft, resetRequested }: {
|
||||
draft: PostImportRowDraft
|
||||
resetRequested: boolean
|
||||
resetSnapshot: PostImportRow['resetSnapshot'] },
|
||||
resetRequested: boolean },
|
||||
): Promise<{ saved: boolean
|
||||
row: PostImportRow | null }> => {
|
||||
if (session == null)
|
||||
const currentSession = sessionRef.current
|
||||
if (currentSession == null)
|
||||
return { saved: false, row: null }
|
||||
|
||||
if (editingRow == null)
|
||||
if (!(canEditReviewRow (row)))
|
||||
return { saved: false, row: null }
|
||||
|
||||
const baseRow =
|
||||
resetRequested
|
||||
? { ...editingRow,
|
||||
url: resetSnapshot.url,
|
||||
attributes: { ...resetSnapshot.attributes },
|
||||
provenance: { ...resetSnapshot.provenance },
|
||||
tagSources: { ...resetSnapshot.tagSources },
|
||||
? { ...row,
|
||||
url: row.resetSnapshot.url,
|
||||
attributes: { ...row.resetSnapshot.attributes },
|
||||
provenance: { ...row.resetSnapshot.provenance },
|
||||
tagSources: { ...row.resetSnapshot.tagSources },
|
||||
fieldWarnings: Object.fromEntries (
|
||||
Object.entries (resetSnapshot.fieldWarnings)
|
||||
Object.entries (row.resetSnapshot.fieldWarnings)
|
||||
.map (([key, values]) => [key, [...values]])),
|
||||
baseWarnings: [...resetSnapshot.baseWarnings],
|
||||
metadataUrl: resetSnapshot.metadataUrl }
|
||||
: editingRow
|
||||
baseWarnings: [...row.resetSnapshot.baseWarnings],
|
||||
metadataUrl: row.resetSnapshot.metadataUrl }
|
||||
: row
|
||||
const urlChanged = draft.url !== baseRow.url
|
||||
const nextRow = buildNextEditedRow (baseRow, draft, urlChanged)
|
||||
|
||||
const nextRows = session.rows.map (row =>
|
||||
row.sourceRow === editingRow.sourceRow
|
||||
const nextRows = currentSession.rows.map (row =>
|
||||
row.sourceRow === baseRow.sourceRow
|
||||
? nextRow
|
||||
: row)
|
||||
try
|
||||
@@ -142,13 +150,38 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
provenance: row.provenance,
|
||||
tagSources: row.tagSources,
|
||||
metadataUrl: row.metadataUrl })),
|
||||
changed_row: urlChanged ? editingRow.sourceRow : -1 })
|
||||
changed_row: urlChanged ? baseRow.sourceRow : -1 })
|
||||
const validatedRows = initialisePreviewRows (validated.rows)
|
||||
const target = validatedRows.find (row => row.sourceRow === editingRow.sourceRow)
|
||||
if (target != null && Object.keys (target.validationErrors).length > 0)
|
||||
return { saved: false, row: target }
|
||||
updateSessionRows (mergeValidatedImportRows (nextRows, validatedRows))
|
||||
setSearchParams ({ })
|
||||
const target = validatedRows.find (
|
||||
validatedRow => validatedRow.sourceRow === baseRow.sourceRow)
|
||||
const latestSession = sessionRef.current
|
||||
if (latestSession == null)
|
||||
return { saved: false, row: null }
|
||||
if (target == null)
|
||||
{
|
||||
const restoredRows = replaceImportRow (latestSession.rows, row)
|
||||
const restoredSession = {
|
||||
...latestSession,
|
||||
rows: restoredRows,
|
||||
repairMode: resultRepairMode (restoredRows) }
|
||||
sessionRef.current = restoredSession
|
||||
setSession (restoredSession)
|
||||
toast ({ title: '行の再検証結果が不完全でした' })
|
||||
return { saved: false, row: null }
|
||||
}
|
||||
const editedRows = replaceImportRow (latestSession.rows, nextRow)
|
||||
const rows = mergeValidatedImportRow (editedRows, target)
|
||||
const nextSession = {
|
||||
...latestSession,
|
||||
rows,
|
||||
repairMode: resultRepairMode (rows) }
|
||||
sessionRef.current = nextSession
|
||||
setSession (nextSession)
|
||||
const mergedTarget =
|
||||
rows.find (mergedRow => mergedRow.sourceRow === baseRow.sourceRow)
|
||||
?? target
|
||||
if (Object.keys (mergedTarget.validationErrors).length > 0)
|
||||
return { saved: false, row: mergedTarget }
|
||||
return { saved: true, row: null }
|
||||
}
|
||||
catch
|
||||
@@ -164,10 +197,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
draft: PostImportRowDraft
|
||||
resetRequested: boolean },
|
||||
) =>
|
||||
saveDraft ({
|
||||
draft,
|
||||
resetRequested,
|
||||
resetSnapshot: row.resetSnapshot })
|
||||
saveDraft (row, { draft, resetRequested })
|
||||
|
||||
await dialogue.form ({
|
||||
title: '投稿を編輯',
|
||||
@@ -179,43 +209,73 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
row={row}
|
||||
controls={controls}
|
||||
onSave={saveRowDraft}/>) })
|
||||
setSearchParams ({ })
|
||||
}
|
||||
|
||||
useEffect (() => {
|
||||
if (editingRow == null)
|
||||
return
|
||||
|
||||
void openEditingDialogue (editingRow)
|
||||
}, [editingRow])
|
||||
const editRow = async (row: PostImportRow) => {
|
||||
setEditingRow (row)
|
||||
try
|
||||
{
|
||||
await openEditingDialogue (row)
|
||||
}
|
||||
finally
|
||||
{
|
||||
setEditingRow (current =>
|
||||
current?.sourceRow === row.sourceRow
|
||||
? null
|
||||
: current)
|
||||
}
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
if (sessionId == null || session == null || processable.length === 0)
|
||||
const currentSession = sessionRef.current
|
||||
if (sessionId == null || currentSession == null || processable.length === 0)
|
||||
return
|
||||
|
||||
setLoading (true)
|
||||
try
|
||||
{
|
||||
const validatableRows =
|
||||
currentSession.rows.filter (row => row.importStatus !== 'created')
|
||||
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
|
||||
rows:
|
||||
session.rows
|
||||
.filter (row => row.importStatus !== 'created')
|
||||
.map (row => ({ sourceRow: row.sourceRow,
|
||||
url: row.url,
|
||||
attributes: row.attributes,
|
||||
provenance: row.provenance,
|
||||
tagSources: row.tagSources,
|
||||
metadataUrl: row.metadataUrl })),
|
||||
validatableRows.map (row => ({ sourceRow: row.sourceRow,
|
||||
url: row.url,
|
||||
attributes: row.attributes,
|
||||
provenance: row.provenance,
|
||||
tagSources: row.tagSources,
|
||||
metadataUrl: row.metadataUrl })),
|
||||
changed_row: -1 })
|
||||
const validatedRows = initialisePreviewRows (validated.rows)
|
||||
const mergedRows = mergeValidatedImportRows (session.rows, validatedRows)
|
||||
const expectedSourceRows = validatableRows.map (row => row.sourceRow)
|
||||
if (!(hasExactSourceRows (expectedSourceRows, validatedRows)))
|
||||
{
|
||||
toast ({ title: '再検証結果が不完全でした' })
|
||||
return
|
||||
}
|
||||
const latestAfterValidate = sessionRef.current ?? currentSession
|
||||
const mergedRows = mergeValidatedImportRows (latestAfterValidate.rows, validatedRows)
|
||||
const firstInvalid = mergedRows.find (row => Object.keys (row.validationErrors).length > 0)
|
||||
if (firstInvalid != null)
|
||||
{
|
||||
setSession ({ ...session, rows: mergedRows })
|
||||
setSearchParams ({ edit: String (firstInvalid.sourceRow) })
|
||||
const nextSession = {
|
||||
...latestAfterValidate,
|
||||
rows: mergedRows,
|
||||
repairMode: resultRepairMode (mergedRows) }
|
||||
sessionRef.current = nextSession
|
||||
setSession (nextSession)
|
||||
void editRow (firstInvalid)
|
||||
return
|
||||
}
|
||||
const validatedSession = {
|
||||
...latestAfterValidate,
|
||||
rows: mergedRows,
|
||||
repairMode: resultRepairMode (mergedRows) }
|
||||
sessionRef.current = validatedSession
|
||||
setSession (validatedSession)
|
||||
const savedValidated = savePostImportSession (sessionId, validatedSession, message =>
|
||||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||
if (!(savedValidated))
|
||||
return
|
||||
|
||||
const result = await apiPost<{
|
||||
created: number
|
||||
@@ -229,7 +289,14 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
provenance: row.provenance,
|
||||
tagSources: row.tagSources,
|
||||
metadataUrl: row.metadataUrl })) })
|
||||
const mergedResults = mergeImportResults (mergedRows, result.rows)
|
||||
const expectedImportRows = processableImportRows (mergedRows).map (row => row.sourceRow)
|
||||
if (!(hasExactSourceRows (expectedImportRows, result.rows)))
|
||||
{
|
||||
toast ({ title: '登録結果が不完全でした' })
|
||||
return
|
||||
}
|
||||
const latestAfterImport = sessionRef.current ?? validatedSession
|
||||
const mergedResults = mergeImportResults (latestAfterImport.rows, result.rows)
|
||||
const recoverableRows = result.rows.filter (row =>
|
||||
row.status === 'failed'
|
||||
&& row.recoverable
|
||||
@@ -241,25 +308,15 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
return {
|
||||
...row,
|
||||
importStatus: 'pending',
|
||||
recoverable: true,
|
||||
validationErrors: recoverable.errors ?? { },
|
||||
importErrors: undefined }
|
||||
})
|
||||
const firstRecoverable = nextRows.find (row =>
|
||||
Object.keys (row.validationErrors).length > 0
|
||||
&& row.importStatus !== 'created')
|
||||
if (firstRecoverable != null)
|
||||
{
|
||||
const repairSession = { ...session,
|
||||
rows: nextRows,
|
||||
repairMode: 'failed' as const }
|
||||
setSession (repairSession)
|
||||
const saved = savePostImportSession (sessionId, repairSession, message =>
|
||||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||
if (saved)
|
||||
setSearchParams ({ edit: String (firstRecoverable.sourceRow) })
|
||||
return
|
||||
}
|
||||
const nextSession = { ...session, rows: nextRows, repairMode: 'all' as const }
|
||||
const nextSession = {
|
||||
...latestAfterImport,
|
||||
rows: nextRows,
|
||||
repairMode: resultRepairMode (nextRows) }
|
||||
sessionRef.current = nextSession
|
||||
setSession (nextSession)
|
||||
const saved = savePostImportSession (sessionId, nextSession, message =>
|
||||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||
@@ -309,9 +366,8 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
<div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}>
|
||||
<PostImportRowSummary
|
||||
row={row}
|
||||
onEdit={() => {
|
||||
setSearchParams ({ edit: String (row.sourceRow) })
|
||||
}}/>
|
||||
editDisabled={loading}
|
||||
onEdit={() => void editRow (row)}/>
|
||||
</div>))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -351,7 +407,7 @@ const PostImportFooter = (
|
||||
<span>スキップ予定 {skipPlannedCount}件</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button type="button" variant="outline" onClick={onBack}>
|
||||
<Button type="button" variant="outline" onClick={onBack} disabled={loading}>
|
||||
URL リスト入力へ戻る
|
||||
</Button>
|
||||
<Button
|
||||
@@ -364,52 +420,4 @@ const PostImportFooter = (
|
||||
</div>
|
||||
</div>)
|
||||
|
||||
const buildNextEditedRow = (
|
||||
editingRow: PostImportRow,
|
||||
draft: PostImportRowDraft,
|
||||
urlChanged: boolean,
|
||||
): PostImportRow => {
|
||||
const nextProvenance = { ...editingRow.provenance }
|
||||
const nextAttributes = { ...editingRow.attributes }
|
||||
const nextTagSources = {
|
||||
automatic: editingRow.tagSources?.automatic ?? '',
|
||||
manual: editingRow.tagSources?.manual ?? '' }
|
||||
const draftFields = [
|
||||
['title', draft.title],
|
||||
['thumbnailBase', draft.thumbnailBase],
|
||||
['originalCreatedFrom', draft.originalCreatedFrom],
|
||||
['originalCreatedBefore', draft.originalCreatedBefore],
|
||||
['duration', draft.duration],
|
||||
['parentPostIds', draft.parentPostIds]] as const
|
||||
draftFields.forEach (([field, value]) => {
|
||||
nextAttributes[field] = value
|
||||
nextProvenance[field] =
|
||||
value !== String (editingRow.attributes[field] ?? '')
|
||||
? 'manual'
|
||||
: (editingRow.provenance[field] ?? 'automatic')
|
||||
})
|
||||
nextAttributes.tags = draft.tags
|
||||
if (draft.tags !== String (editingRow.attributes.tags ?? ''))
|
||||
{
|
||||
nextProvenance.tags = 'manual'
|
||||
nextTagSources.manual = draft.tags
|
||||
}
|
||||
else
|
||||
{
|
||||
nextProvenance.tags = editingRow.provenance.tags ?? 'automatic'
|
||||
nextTagSources.manual = editingRow.tagSources?.manual ?? ''
|
||||
}
|
||||
|
||||
return {
|
||||
...editingRow,
|
||||
url: draft.url,
|
||||
attributes: nextAttributes,
|
||||
provenance: {
|
||||
...nextProvenance,
|
||||
url: urlChanged ? 'manual' : (editingRow.provenance.url ?? 'manual') },
|
||||
tagSources: nextTagSources,
|
||||
importStatus: editingRow.importStatus === 'created' ? 'created' : 'pending',
|
||||
importErrors: undefined }
|
||||
}
|
||||
|
||||
export default PostImportReviewPage
|
||||
|
||||
@@ -86,7 +86,8 @@ describe ('PostImportSourcePage', () => {
|
||||
expect.stringMatching (/^\/posts\/import\/[^/]+\/review$/))
|
||||
})
|
||||
const sessionKeys = Array.from ({ length: sessionStorage.length }, (_, index) =>
|
||||
sessionStorage.key (index)).filter (_1 => _1?.startsWith ('post-import-session:'))
|
||||
sessionStorage.key (index)).filter (
|
||||
key => key?.startsWith ('post-import-session:'))
|
||||
expect (sessionKeys).toHaveLength (1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -60,7 +60,7 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||
const sourceDescribedBy =
|
||||
[sourceError != null ? SOURCE_ERROR_ID : null,
|
||||
sourceIssues.length > 0 ? SOURCE_ISSUES_ID : null]
|
||||
.filter (_1 => _1 != null)
|
||||
.filter (value => value != null)
|
||||
.join (' ')
|
||||
|
||||
useEffect (() => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { toMinutePrecisionIsoUtc } from '@/components/common/DateTimeField'
|
||||
import PostNewPage from '@/pages/posts/PostNewPage'
|
||||
import { buildUser } from '@/test/factories'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
@@ -39,8 +40,8 @@ describe ('PostNewPage', () => {
|
||||
const textboxes = screen.getAllByRole ('textbox')
|
||||
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
|
||||
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } })
|
||||
fireEvent.change (textboxes[2], { target: { value: '1 2' } })
|
||||
fireEvent.change (textboxes[3], { target: { value: 'tag1 tag2' } })
|
||||
fireEvent.change (textboxes[3], { target: { value: '1 2' } })
|
||||
fireEvent.change (textboxes[4], { target: { value: 'tag1 tag2' } })
|
||||
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
|
||||
|
||||
await waitFor (() => {
|
||||
@@ -63,17 +64,19 @@ describe ('PostNewPage', () => {
|
||||
|
||||
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
|
||||
|
||||
const tags = screen.getAllByRole ('textbox')[3]
|
||||
const tags = screen.getAllByRole ('textbox')[4]
|
||||
fireEvent.change (tags, { target: { value: '動画' } })
|
||||
fireEvent.change (screen.getByRole ('spinbutton'), { target: { value: '180.5' } })
|
||||
fireEvent.change (
|
||||
screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23'),
|
||||
{ target: { value: '180.5' } })
|
||||
|
||||
fireEvent.change (tags, { target: { value: 'general-tag' } })
|
||||
expect (screen.queryByRole ('spinbutton')).not.toBeInTheDocument ()
|
||||
expect (screen.queryByPlaceholderText ('例: 2 / 2.5 / 1:23')).not.toBeInTheDocument ()
|
||||
|
||||
fireEvent.change (tags, {
|
||||
target: { value: '動画 general-tag' },
|
||||
})
|
||||
expect (screen.getByRole ('spinbutton')).toHaveValue (180.5)
|
||||
expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('180.5')
|
||||
})
|
||||
|
||||
it ('shows 422 validation errors for post fields', async () => {
|
||||
@@ -96,11 +99,64 @@ describe ('PostNewPage', () => {
|
||||
const textboxes = screen.getAllByRole ('textbox')
|
||||
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
|
||||
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } })
|
||||
fireEvent.change (textboxes[3], { target: { value: 'nico:nico_tag' } })
|
||||
fireEvent.change (textboxes[4], { target: { value: 'nico:nico_tag' } })
|
||||
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
|
||||
|
||||
expect (await screen.findByText ('投稿内容を確認してください.')).toBeInTheDocument ()
|
||||
expect (screen.getByText ('ニコニコ・タグは直接指定できません.')).toBeInTheDocument ()
|
||||
expect (screen.getAllByRole ('textbox')[3]).toHaveAttribute ('aria-invalid', 'true')
|
||||
expect (screen.getAllByRole ('textbox')[4]).toHaveAttribute ('aria-invalid', 'true')
|
||||
})
|
||||
|
||||
it ('uses the shared duration, tags, and datetime value contract for post submission', async () => {
|
||||
api.apiPost.mockResolvedValueOnce ({})
|
||||
api.apiGet.mockResolvedValue ([])
|
||||
|
||||
const { container } = renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
|
||||
|
||||
const textboxes = screen.getAllByRole ('textbox')
|
||||
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
|
||||
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } })
|
||||
fireEvent.change (textboxes[4], { target: { value: '動画 tag1 tag2' } })
|
||||
fireEvent.change (
|
||||
screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23'),
|
||||
{ target: { value: '2.5' } })
|
||||
const datetimeInputs = container.querySelectorAll ('input[type="datetime-local"]')
|
||||
fireEvent.change (datetimeInputs[0] as HTMLInputElement, {
|
||||
target: { value: '2024-01-01T12:34' } })
|
||||
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
|
||||
|
||||
await waitFor (() => expect (api.apiPost).toHaveBeenCalled ())
|
||||
|
||||
const formData = api.apiPost.mock.calls[0]?.[1] as FormData
|
||||
expect (container.querySelector ('input[type="file"]')).not.toBeNull ()
|
||||
expect (formData.get ('duration')).toBe ('2.5')
|
||||
expect (formData.get ('tags')).toBe ('動画 tag1 tag2')
|
||||
expect (formData.get ('original_created_from')).toBe (
|
||||
toMinutePrecisionIsoUtc ('2024-01-01T12:34'))
|
||||
})
|
||||
|
||||
it ('shows deduplicated original-created endpoint errors on the shared datetime field', async () => {
|
||||
api.apiGet.mockResolvedValue ([])
|
||||
api.isApiError.mockReturnValue (true)
|
||||
api.apiPost.mockRejectedValueOnce ({
|
||||
response: {
|
||||
status: 422,
|
||||
data: {
|
||||
type: 'validation_error',
|
||||
errors: {
|
||||
original_created_at: ['日時を確認してください.'],
|
||||
original_created_from: ['日時を確認してください.'],
|
||||
original_created_before: ['終了を確認してください.'] },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
|
||||
|
||||
expect (await screen.findByText ('日時を確認してください.')).toBeInTheDocument ()
|
||||
expect (screen.getByText ('終了を確認してください.')).toBeInTheDocument ()
|
||||
expect (screen.getAllByText ('日時を確認してください.')).toHaveLength (1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,19 +2,20 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import PostFormTagsArea from '@/components/PostFormTagsArea'
|
||||
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
||||
import FieldError from '@/components/common/FieldError'
|
||||
import Form from '@/components/common/Form'
|
||||
import FormField from '@/components/common/FormField'
|
||||
import PageTitle from '@/components/common/PageTitle'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import PostDurationField from '@/components/posts/PostDurationField'
|
||||
import PostTagsField from '@/components/posts/PostTagsField'
|
||||
import PostTextField from '@/components/posts/PostTextField'
|
||||
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { toast } from '@/components/ui/use-toast'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { apiGet, apiPost } from '@/lib/api'
|
||||
import { canEditContent } from '@/lib/users'
|
||||
import { inputClass } from '@/lib/utils'
|
||||
import { useValidationErrors } from '@/lib/useValidationErrors'
|
||||
import Forbidden from '@/pages/Forbidden'
|
||||
|
||||
@@ -26,7 +27,11 @@ type Props = { user: User | null }
|
||||
|
||||
type PostFormField =
|
||||
'url' | 'title' | 'tags' | 'parentPostIds'
|
||||
| 'videoMs' | 'originalCreatedAt' | 'thumbnail'
|
||||
| 'videoMs' | 'originalCreatedAt' | 'originalCreatedFrom'
|
||||
| 'originalCreatedBefore' | 'thumbnail'
|
||||
|
||||
const groupedMessages = (...values: (string[] | undefined)[]): string[] =>
|
||||
[...new Set (values.flatMap (value => value ?? []))]
|
||||
|
||||
|
||||
const PostNewPage: FC<Props> = ({ user }) => {
|
||||
@@ -138,30 +143,22 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
||||
<PageTitle>広場に投稿を追加する</PageTitle>
|
||||
<FieldError messages={baseErrors}/>
|
||||
|
||||
{/* URL */}
|
||||
<FormField label="URL" messages={fieldErrors.url}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<input type="url"
|
||||
placeholder="例:https://www.nicovideo.jp/watch/..."
|
||||
value={url}
|
||||
onChange={e => setURL (e.target.value)}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
className={inputClass (invalid)}/>)}
|
||||
</FormField>
|
||||
<PostTextField
|
||||
label="URL"
|
||||
type="url"
|
||||
value={url}
|
||||
errors={fieldErrors.url}
|
||||
placeholder="例:https://www.nicovideo.jp/watch/..."
|
||||
onChange={setURL}/>
|
||||
|
||||
{/* タイトル */}
|
||||
<FormField label="タイトル" messages={fieldErrors.title}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<div className="space-y-2">
|
||||
<input type="text"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
className={inputClass (invalid)}
|
||||
value={title}
|
||||
placeholder={titleLoading ? 'Loading...' : ''}
|
||||
onChange={ev => setTitle (ev.target.value)}
|
||||
disabled={titleLoading}/>
|
||||
<PostTextField
|
||||
label="タイトル"
|
||||
value={title}
|
||||
errors={fieldErrors.title}
|
||||
disabled={titleLoading}
|
||||
placeholder={titleLoading ? 'Loading...' : undefined}
|
||||
onChange={setTitle}
|
||||
after={
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span>必要なタイミングで URL から取得できます.</span>
|
||||
<Button
|
||||
@@ -171,59 +168,51 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
||||
disabled={!(url) || titleLoading}>
|
||||
取得
|
||||
</Button>
|
||||
</div>
|
||||
</div>)}
|
||||
</FormField>
|
||||
</div>}/>
|
||||
|
||||
{/* サムネール */}
|
||||
<FormField label="サムネール" messages={fieldErrors.thumbnail}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
|
||||
<span>必要なタイミングで URL から取得できます.</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void fetchThumbnail ()}
|
||||
disabled={!(url) || thumbnailLoading}>
|
||||
取得
|
||||
</Button>
|
||||
</div>
|
||||
{thumbnailLoading && (
|
||||
<p className="text-gray-500 text-sm">Loading...</p>)}
|
||||
<input type="file"
|
||||
accept="image/*"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
onChange={e => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file)
|
||||
{
|
||||
setThumbnailFile (file)
|
||||
setThumbnailPreview (URL.createObjectURL (file))
|
||||
}
|
||||
}}/>
|
||||
{thumbnailPreview && (
|
||||
<img src={thumbnailPreview}
|
||||
alt="preview"
|
||||
className="mt-2 max-h-48 rounded border"/>)}
|
||||
</>)}
|
||||
</FormField>
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span>必要なタイミングで URL から取得できます.</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void fetchThumbnail ()}
|
||||
disabled={!(url) || thumbnailLoading}>
|
||||
取得
|
||||
</Button>
|
||||
</div>
|
||||
{thumbnailLoading && (
|
||||
<p className="text-gray-500 text-sm">Loading...</p>)}
|
||||
<PostTextField
|
||||
label="サムネール"
|
||||
value={thumbnailFile?.name ?? ''}
|
||||
errors={fieldErrors.thumbnail}
|
||||
disabled
|
||||
onChange={() => {}}/>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={e => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file)
|
||||
{
|
||||
setThumbnailFile (file)
|
||||
setThumbnailPreview (URL.createObjectURL (file))
|
||||
}
|
||||
}}/>
|
||||
<PostThumbnailPreview
|
||||
url={thumbnailPreview}
|
||||
alt="preview"
|
||||
className="h-28 w-28"/>
|
||||
</div>
|
||||
|
||||
{/* 親投稿 */}
|
||||
<FormField label="親投稿" messages={fieldErrors.parentPostIds}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<input
|
||||
type="text"
|
||||
value={parentPostIds}
|
||||
onChange={e => setParentPostIds (e.target.value)}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
className={inputClass (invalid)}/>)}
|
||||
</FormField>
|
||||
<PostTextField
|
||||
label="親投稿"
|
||||
value={parentPostIds}
|
||||
errors={fieldErrors.parentPostIds}
|
||||
onChange={setParentPostIds}/>
|
||||
|
||||
{/* タグ */}
|
||||
<PostFormTagsArea tags={tags} setTags={setTags} errors={fieldErrors.tags}/>
|
||||
<PostTagsField tags={tags} setTags={setTags} errors={fieldErrors.tags}/>
|
||||
|
||||
{/* オリジナルの作成日時 */}
|
||||
<PostOriginalCreatedTimeField
|
||||
@@ -231,21 +220,16 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
||||
setOriginalCreatedFrom={setOriginalCreatedFrom}
|
||||
originalCreatedBefore={originalCreatedBefore}
|
||||
setOriginalCreatedBefore={setOriginalCreatedBefore}
|
||||
errors={fieldErrors.originalCreatedAt}/>
|
||||
errors={groupedMessages (
|
||||
fieldErrors.originalCreatedAt,
|
||||
fieldErrors.originalCreatedFrom,
|
||||
fieldErrors.originalCreatedBefore)}/>
|
||||
|
||||
{/* 動画時間 */}
|
||||
{(videoFlg &&
|
||||
<FormField label="動画時間" messages={fieldErrors.videoMs}>
|
||||
{({ invalid }) => (
|
||||
<input
|
||||
type="number"
|
||||
min="0.001"
|
||||
step="0.001"
|
||||
value={duration}
|
||||
onChange={e => setDuration (e.target.value)}
|
||||
aria-invalid={invalid}
|
||||
className={inputClass (invalid)}/>)}
|
||||
</FormField>)}
|
||||
{videoFlg && (
|
||||
<PostDurationField
|
||||
value={duration}
|
||||
errors={fieldErrors.videoMs}
|
||||
onChange={setDuration}/>)}
|
||||
|
||||
{/* 送信 */}
|
||||
<Button onClick={handleSubmit}
|
||||
|
||||
@@ -40,6 +40,7 @@ export const buildPostImportRow = (
|
||||
provenance,
|
||||
tagSources,
|
||||
status: overrides.status ?? 'ready',
|
||||
recoverable: overrides.recoverable,
|
||||
resetSnapshot: overrides.resetSnapshot ?? {
|
||||
url,
|
||||
attributes: { ...attributes },
|
||||
|
||||
新しい課題から参照
ユーザをブロックする