コミットを比較

...

11 コミット

作成者 SHA1 メッセージ 日付
みてるぞ cde0a2deae #399 2026-07-16 20:54:42 +09:00
みてるぞ 43a3772976 #399 2026-07-16 20:35:07 +09:00
みてるぞ 90d8d3ff08 #399 2026-07-16 20:05:09 +09:00
みてるぞ d1de631eed #399 2026-07-16 19:35:21 +09:00
みてるぞ 6f3ccb0ffa #399 2026-07-16 19:18:35 +09:00
みてるぞ 95c3f08a44 #399 2026-07-16 18:52:07 +09:00
みてるぞ ef95b20a7e #399 2026-07-16 18:33:45 +09:00
みてるぞ f76fbe6711 #399 2026-07-16 18:16:20 +09:00
みてるぞ 0224c4d2f4 #399 2026-07-16 12:39:35 +09:00
みてるぞ ead825e3ee #399 2026-07-16 07:56:45 +09:00
みてるぞ 3d88fdecb1 #399 2026-07-16 01:19:36 +09:00
59個のファイルの変更3907行の追加1176行の削除
+34
ファイルの表示
@@ -211,6 +211,13 @@ records.each {
- In TypeScript and TSX, block bodies for components, functions, callbacks, - In TypeScript and TSX, block bodies for components, functions, callbacks,
`if`, `try`, `catch`, `finally`, loops, and JSX nesting use 2 spaces per `if`, `try`, `catch`, `finally`, loops, and JSX nesting use 2 spaces per
level. 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 - In TypeScript and TSX, use 4-space continuation indentation for wrapped
expressions, arguments, conditions, arrays, object literals, JSX expressions, arguments, conditions, arrays, object literals, JSX
attributes, and similar continuations. attributes, and similar continuations.
@@ -274,6 +281,10 @@ case 'no':
- In TypeScript and TSX, use `value == null` and `value != null` as the - In TypeScript and TSX, use `value == null` and `value != null` as the
default nullish checks. Do not use `=== null`, `=== undefined`, default nullish checks. Do not use `=== null`, `=== undefined`,
`!== null`, or `!== 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 - 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. that as a design smell and revise the logic to avoid the distinction.
External library APIs that explicitly require distinguishing the two are the 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. declarations, unless imports, exports, or file boundaries make that awkward.
- In TSX, use 2-space block indentation and 4-space continuation - In TSX, use 2-space block indentation and 4-space continuation
indentation. 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 - In TypeScript and TSX, convert every complete leading run of 8 spaces to a
tab character. tab character.
- A leading tab is exactly equivalent to 8 leading spaces. - A leading tab is exactly equivalent to 8 leading spaces.
@@ -628,6 +646,22 @@ and layout reuse, follow `frontend/AGENTS.md`.
single physical line. single physical line.
- Always add braces around `if`, `else`, or `for` bodies when the body spans - Always add braces around `if`, `else`, or `for` bodies when the body spans
two or more physical lines, even if it is one statement. 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 - Do not use a leading semicolon for expression statements such as
`;([...]).forEach(...)`; rewrite the expression to avoid ASI hazards `;([...]).forEach(...)`; rewrite the expression to avoid ASI hazards
explicitly, for example with `void`. explicitly, for example with `void`.
+167 -10
ファイルの表示
@@ -1,11 +1,23 @@
class Post < ApplicationRecord class Post < ApplicationRecord
require 'date'
require 'mini_magick' require 'mini_magick'
require 'stringio' 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) def self.resized_thumbnail_attachment(upload)
upload.rewind upload.rewind
image = MiniMagick::Image.read(upload.read) image = MiniMagick::Image.read(upload.read)
image.resize '180x180' image.resize '180x180^'
image.gravity 'Center'
image.extent '180x180'
image.format 'jpg' image.format 'jpg'
{ io: StringIO.new(image.to_blob), { io: StringIO.new(image.to_blob),
@@ -118,7 +130,11 @@ class Post < ApplicationRecord
'%d:%02d' % [min, s] '%d:%02d' % [min, s]
end 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 end
def snapshot_parent_post_ids = parents.order(:id).pluck(:id) 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))) thumbnail.attach(self.class.resized_thumbnail_attachment(StringIO.new(thumbnail.download)))
end 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 private
def validate_original_created_range def validate_original_created_range
f = original_created_from return if skip_original_created_validation?
b = original_created_before
return if f.blank? || b.blank?
f = Time.zone.parse(f) if String === f f = parse_original_created_value(:original_created_from)
b = Time.zone.parse(b) if String === b b = parse_original_created_value(:original_created_before)
return if !(f) || !(b) return if f.nil? || b.nil?
if f >= b if b <= f
errors.add :original_created_at, 'オリジナルの作成日時の順番がをかしぃです.' 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
end end
@@ -175,4 +208,128 @@ class Post < ApplicationRecord
self.url = PostUrlNormaliser.normalise(url) || url.strip self.url = PostUrlNormaliser.normalise(url) || url.strip
end 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 end
+19 -4
ファイルの表示
@@ -1,9 +1,12 @@
class PostCreator class PostCreator
class VideoMsParseError < ArgumentError; end class VideoMsParseError < ArgumentError; end
attr_reader :field_warnings
def initialize actor:, attributes: def initialize actor:, attributes:
@actor = actor @actor = actor
@attributes = attributes.symbolize_keys @attributes = attributes.symbolize_keys
@field_warnings = { }
end end
def create! def create!
@@ -13,8 +16,7 @@ class PostCreator
uploaded_user: @actor, uploaded_user: @actor,
original_created_from: @attributes[:original_created_from].presence, original_created_from: @attributes[:original_created_from].presence,
original_created_before: @attributes[:original_created_before].presence) original_created_before: @attributes[:original_created_before].presence)
thumbnail = @attributes[:thumbnail] attach_thumbnail!(post)
post.thumbnail.attach(Post.resized_thumbnail_attachment(thumbnail)) if thumbnail.present?
ApplicationRecord.transaction do ApplicationRecord.transaction do
post.save! post.save!
@@ -34,6 +36,21 @@ class PostCreator
private 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 tag_names = @attributes[:tags].to_s.split
def parent_post_ids def parent_post_ids
@@ -58,8 +75,6 @@ class PostCreator
duration = @attributes[:duration] duration = @attributes[:duration]
return nil if duration.blank? 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: '動画時間') value = Tag.time_to_ms!(duration.to_s, tag_name: '動画時間')
raise VideoMsParseError unless value.positive? raise VideoMsParseError unless value.positive?
+24 -6
ファイルの表示
@@ -157,7 +157,15 @@ class PostImportPreviewer
def initial_attributes row def initial_attributes row
attributes = row[:attributes]&.stringify_keys || { } 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 end
def initial_provenance row def initial_provenance row
@@ -250,16 +258,26 @@ class PostImportPreviewer
def sanitise_metadata_time value def sanitise_metadata_time value
return nil unless value.is_a?(String) return nil unless value.is_a?(String)
time = Time.iso8601(value) Time.iso8601(value).in_time_zone.change(sec: 0, nsec: 0).iso8601
time.nsec.zero? ? time.iso8601 : time.iso8601(9)
rescue ArgumentError, TypeError rescue ArgumentError, TypeError
nil nil
end end
def sanitise_metadata_duration value 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 end
def preload_metadata! prepared_rows, fetch_metadata, metadata_cache, existing_posts, url_counts def preload_metadata! prepared_rows, fetch_metadata, metadata_cache, existing_posts, url_counts
@@ -460,7 +478,7 @@ class PostImportPreviewer
def parse_duration value, errors def parse_duration value, errors
return nil if value.blank? 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 rescue Tag::SectionLiteralParseError
errors[:video_ms] = ['動画時間の記法が不正です.'] errors[:video_ms] = ['動画時間の記法が不正です.']
nil nil
+2 -1
ファイルの表示
@@ -5,9 +5,10 @@ class PostImportRowNormaliser
'thumbnail_base', 'thumbnail_base',
'original_created_from', 'original_created_from',
'original_created_before', 'original_created_before',
'duration',
'tags', 'tags',
'parent_post_ids'].freeze 'parent_post_ids'].freeze
FLEXIBLE_FIELDS = ['duration', 'video_ms'].freeze FLEXIBLE_FIELDS = ['video_ms'].freeze
ATTRIBUTE_FIELDS = (STRING_FIELDS + FLEXIBLE_FIELDS).freeze ATTRIBUTE_FIELDS = (STRING_FIELDS + FLEXIBLE_FIELDS).freeze
def self.normalise! rows, allow_warning_fields: false def self.normalise! rows, allow_warning_fields: false
+7 -2
ファイルの表示
@@ -36,8 +36,13 @@ class PostImportRunner
attributes['tags'] = preview[:attributes]['tags'] attributes['tags'] = preview[:attributes]['tags']
attributes['url'] = row['url'] attributes['url'] = row['url']
post = PostCreator.new(actor: @actor, attributes:).create! creator = PostCreator.new(actor: @actor, attributes:)
{ source_row: row['source_row'], status: 'created', post: PostRepr.base(post) } 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 rescue ActiveRecord::RecordInvalid => e
existing_post = existing_post_for_race(row, e.record) existing_post = existing_post_for_race(row, e.record)
if existing_post if existing_post
+64 -16
ファイルの表示
@@ -1,4 +1,5 @@
require 'time' require 'time'
require 'date'
class PostMetadataFetcher class PostMetadataFetcher
TIMESTAMP_PATTERN = TIMESTAMP_PATTERN =
@@ -30,7 +31,7 @@ class PostMetadataFetcher
Preview::KnownSiteExtractor.thumbnail_url(uri) || metadata[:image_url], Preview::KnownSiteExtractor.thumbnail_url(uri) || metadata[:image_url],
original_created_from: serialise_time(created_range&.first), original_created_from: serialise_time(created_range&.first),
original_created_before: serialise_time(created_range&.last), original_created_before: serialise_time(created_range&.last),
duration: duration&.to_f&.then { _1.positive? ? (_1 * 1_000).round : nil }, duration: serialise_duration(duration),
tags: platform_tags.join(' ') } tags: platform_tags.join(' ') }
end end
@@ -54,12 +55,16 @@ class PostMetadataFetcher
when /\A(\d{4})-(\d{2})\z/ when /\A(\d{4})-(\d{2})\z/
year = Regexp.last_match(1).to_i year = Regexp.last_match(1).to_i
month = Regexp.last_match(2).to_i month = Regexp.last_match(2).to_i
return nil unless month.between?(1, 12)
from = Time.zone.local(year, month, 1) from = Time.zone.local(year, month, 1)
[from, from + 1.month] [from, from + 1.month]
when /\A(\d{4})-(\d{2})-(\d{2})\z/ when /\A(\d{4})-(\d{2})-(\d{2})\z/
year = Regexp.last_match(1).to_i year = Regexp.last_match(1).to_i
month = Regexp.last_match(2).to_i month = Regexp.last_match(2).to_i
day = Regexp.last_match(3).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 = Time.zone.local(year, month, day)
[from, from + 1.day] [from, from + 1.day]
else else
@@ -84,36 +89,76 @@ class PostMetadataFetcher
second = match[6]&.to_i || 0 second = match[6]&.to_i || 0
fraction = match[7] fraction = match[7]
offset = match[8] offset = match[8]
whole_second = second + fractional_seconds(fraction) return nil unless valid_timestamp_components?(year, month, day, hour, minute, second)
from = return nil unless valid_offset?(offset)
nanoseconds = parse_nanoseconds(fraction)
timestamp =
if offset.present? 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 else
Time.zone.local(year, month, day, hour, minute, whole_second) Time.zone.local(year, month, day, hour, minute, second).change(nsec: nanoseconds)
end end
from = timestamp.change(sec: 0, nsec: 0)
before = before =
if fraction.present? if match[5].nil?
from + (10**(-fraction.length))
elsif match[6].present?
from + 1.second
elsif match[5].present?
from + 1.minute
else
from + 1.hour from + 1.hour
else
from + 1.minute
end end
[from, before] [from, before]
end 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 def self.parse_offset value
return '+00:00' if value == 'Z' return '+00:00' if value == 'Z'
value.match?(/\A[+-]\d{2}:\d{2}\z/) ? value : "#{ value[0, 3] }:#{ value[3, 2] }" value.match?(/\A[+-]\d{2}:\d{2}\z/) ? value : "#{ value[0, 3] }:#{ value[3, 2] }"
end end
def self.fractional_seconds value def self.serialise_duration value
return 0 if value.blank? 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 end
def self.serialise_time value def self.serialise_time value
@@ -125,7 +170,10 @@ class PostMetadataFetcher
private_class_method :platform_tags, private_class_method :platform_tags,
:original_created_range, :original_created_range,
:parse_timestamp_range, :parse_timestamp_range,
:parse_nanoseconds,
:parse_offset, :parse_offset,
:fractional_seconds, :serialise_duration,
:valid_timestamp_components?,
:valid_offset?,
:serialise_time :serialise_time
end end
+2 -6
ファイルの表示
@@ -12,9 +12,7 @@ module Preview
Response = Data.define(:body, :content_type, :url) Response = Data.define(:body, :content_type, :url)
def self.fetch(raw_url, def self.fetch(raw_url, max_bytes: DEFAULT_MAX_BYTES, redirects: MAX_REDIRECTS)
max_bytes: DEFAULT_MAX_BYTES,
redirects: MAX_REDIRECTS)
uri, addresses = UrlSafety.validate(raw_url) uri, addresses = UrlSafety.validate(raw_url)
response = request(uri, addresses.first, max_bytes) 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) log_failure(:timeout, url: uri&.to_s || raw_url, error: e.class.name, message: e.message)
raise FetchTimeout, e.message raise FetchTimeout, e.message
rescue SocketError, SystemCallError, OpenSSL::SSL::SSLError, EOFError => e rescue SocketError, SystemCallError, OpenSSL::SSL::SSLError, EOFError => e
log_failure(:network_error, log_failure(:network_error, url: uri&.to_s || raw_url, error: e.class.name,
url: uri&.to_s || raw_url,
error: e.class.name,
message: e.message) message: e.message)
raise FetchFailed, e.message raise FetchFailed, e.message
end end
+25 -16
ファイルの表示
@@ -25,6 +25,22 @@ module Preview
fetch_image!(metadata[:image_url]) fetch_image!(metadata[:image_url])
end 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) def self.title(raw_url)
uri, = UrlSafety.validate(raw_url) uri, = UrlSafety.validate(raw_url)
HtmlMetadataExtractor.extract( HtmlMetadataExtractor.extract(
@@ -45,18 +61,7 @@ module Preview
end end
def self.fetch_image!(url) def self.fetch_image!(url)
response = HttpFetcher.fetch(url) fetch_image_response(url).body
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, 'サムネール画像を取得できませんでした.'
end end
def self.niconico_thumbnail_url(uri) def self.niconico_thumbnail_url(uri)
@@ -70,16 +75,20 @@ module Preview
xml.at_xpath('//thumbnail_url')&.text&.strip.presence xml.at_xpath('//thumbnail_url')&.text&.strip.presence
rescue HttpFetcher::FetchFailed, HttpFetcher::FetchTimeout => e rescue HttpFetcher::FetchFailed, HttpFetcher::FetchTimeout => e
Rails.logger.info("preview_niconico_getthumbinfo_fallback #{ { url: uri.to_s, payload = {
url: uri.to_s,
video_id:, video_id:,
error: e.class.name, error: e.class.name,
message: e.message }.to_json }") message: e.message }
Rails.logger.info("preview_niconico_getthumbinfo_fallback #{ payload.to_json }")
nil nil
rescue Nokogiri::XML::SyntaxError => e rescue Nokogiri::XML::SyntaxError => e
Rails.logger.info("preview_niconico_getthumbinfo_fallback #{ { url: uri.to_s, payload = {
url: uri.to_s,
video_id:, video_id:,
error: e.class.name, error: e.class.name,
message: e.message }.to_json }") message: e.message }
Rails.logger.info("preview_niconico_getthumbinfo_fallback #{ payload.to_json }")
nil nil
end end
+6 -7
ファイルの表示
@@ -1,4 +1,3 @@
require 'open-uri'
require 'set' require 'set'
require 'time' require 'time'
@@ -127,12 +126,12 @@ module Youtube
return if post.thumbnail.attached? return if post.thumbnail.attached?
return if thumbnail_url.blank? return if thumbnail_url.blank?
post.thumbnail.attach( post.attach_thumbnail_from_url!(thumbnail_url)
io: URI.open(thumbnail_url), rescue Post::RemoteThumbnailFetchFailed => e
filename: File.basename(URI.parse(thumbnail_url).path), Rails.logger.info("youtube_sync_thumbnail_fetch_failed #{ { post_id: post.id,
content_type: 'image/jpeg') thumbnail_url:,
error: e.class.name,
post.resized_thumbnail! message: e.message }.to_json }")
end end
def youtube_url_regexp id def youtube_url_regexp id
+20 -10
ファイルの表示
@@ -70,11 +70,17 @@ namespace :nico do
unless post.thumbnail.attached? unless post.thumbnail.attached?
thumbnail_base = fetch_thumbnail.(post.url) rescue nil thumbnail_base = fetch_thumbnail.(post.url) rescue nil
if thumbnail_base.present? 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 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
end end
@@ -82,7 +88,6 @@ namespace :nico do
post_changed = post.changed? post_changed = post.changed?
if post_changed if post_changed
post.save! post.save!
post.resized_thumbnail! if post.thumbnail.attached?
end end
else else
post_created = true post_created = true
@@ -91,13 +96,18 @@ namespace :nico do
post = Post.new(title:, url:, thumbnail_base:, uploaded_user: nil, post = Post.new(title:, url:, thumbnail_base:, uploaded_user: nil,
original_created_from:, original_created_before:) original_created_from:, original_created_before:)
if thumbnail_base.present? if thumbnail_base.present?
post.thumbnail.attach( begin
io: URI.open(thumbnail_base), post.attach_thumbnail_from_url!(thumbnail_base)
filename: File.basename(URI.parse(thumbnail_base).path), rescue Post::RemoteThumbnailFetchFailed => e
content_type: 'image/jpeg') 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 end
post.save! post.save!
post.resized_thumbnail!
sync_post_tags!(post, [Tag.tagme.id, Tag.bot.id, Tag.niconico.id, Tag.video.id]) sync_post_tags!(post, [Tag.tagme.id, Tag.bot.id, Tag.niconico.id, Tag.video.id])
end end
+293
ファイルの表示
@@ -1,4 +1,5 @@
require 'rails_helper' require 'rails_helper'
require 'tempfile'
RSpec.describe Post, type: :model do RSpec.describe Post, type: :model do
before 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) expect(post.errors.details.fetch(:url)).to include(error: :taken, value: post.url)
end end
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 end
+72
ファイルの表示
@@ -84,9 +84,81 @@ RSpec.describe 'Post imports API', type: :request do
expect(response).to have_http_status(:bad_request) expect(response).to have_http_status(:bad_request)
expect(json.fetch('message')).to eq('取込行の形式が不正です.') expect(json.fetch('message')).to eq('取込行の形式が不正です.')
end 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 end
describe 'POST /posts/import' do 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 it 'returns a formal skipped result for an existing post' do
existing = create(:post, url: 'https://example.com/existing') existing = create(:post, url: 'https://example.com/existing')
sign_in_as(member) sign_in_as(member)
+85
ファイルの表示
@@ -2206,6 +2206,91 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_entity)
end 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 end
describe 'tag versioning from post write actions' do describe 'tag versioning from post write actions' do
+82
ファイルの表示
@@ -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
+2 -2
ファイルの表示
@@ -68,7 +68,7 @@ RSpec.describe PostImportPreviewer do
allow(PostMetadataFetcher).to receive(:fetch).and_return( allow(PostMetadataFetcher).to receive(:fetch).and_return(
title: 'metadata title', title: 'metadata title',
thumbnail_base: 'https://example.com/thumb.jpg', thumbnail_base: 'https://example.com/thumb.jpg',
duration: 2_000, duration: '2',
tags: 'known-tag' tags: 'known-tag'
) )
@@ -79,7 +79,7 @@ RSpec.describe PostImportPreviewer do
expect(result.fetch(:attributes)).to include( expect(result.fetch(:attributes)).to include(
'title' => 'metadata title', 'title' => 'metadata title',
'thumbnail_base' => 'https://example.com/thumb.jpg', 'thumbnail_base' => 'https://example.com/thumb.jpg',
'duration' => 2_000, 'duration' => '2',
'tags' => 'known-tag' 'tags' => 'known-tag'
) )
expect(result.fetch(:field_warnings)).not_to have_key('tags') expect(result.fetch(:field_warnings)).not_to have_key('tags')
+2 -2
ファイルの表示
@@ -6,7 +6,7 @@ RSpec.describe PostImportRowNormaliser do
sourceRow: '1', sourceRow: '1',
url: 'https://example.com/post', url: 'https://example.com/post',
metadataUrl: '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' }, provenance: { url: 'manual', title: 'automatic' },
tagSources: { automatic: 'tag', manual: '' } tagSources: { automatic: 'tag', manual: '' }
}.deep_merge(overrides) }.deep_merge(overrides)
@@ -21,7 +21,7 @@ RSpec.describe PostImportRowNormaliser do
'source_row' => 1, 'source_row' => 1,
'url' => 'https://example.com/post', 'url' => 'https://example.com/post',
'metadata_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' }, 'provenance' => { 'url' => 'manual', 'title' => 'automatic' },
'tag_sources' => { 'automatic' => 'tag', 'manual' => '' } 'tag_sources' => { 'automatic' => 'tag', 'manual' => '' }
} }
+19
ファイルの表示
@@ -130,4 +130,23 @@ RSpec.describe PostImportRunner do
expect(result).to include(status: 'skipped', existing_post_id: existing.id) expect(result).to include(status: 'skipped', existing_post_id: existing.id)
end 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 end
+63 -4
ファイルの表示
@@ -37,16 +37,30 @@ RSpec.describe PostMetadataFetcher do
) )
end 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') result = fetch_with_published_time('2024-02-03T12:34:56.123+02:30')
expect(Time.iso8601(result.fetch(:original_created_from))) 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( expect(
Time.iso8601(result.fetch(:original_created_before)) - Time.iso8601(result.fetch(:original_created_before)) -
Time.iso8601(result.fetch(:original_created_from)) Time.iso8601(result.fetch(:original_created_from))
).to eq(0.001) ).to eq(60)
expect(result.fetch(:original_created_from)).to include('.123000000') 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 end
it 'adds platform tags for known video URLs' do 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_from)).to be_nil
expect(result.fetch(:original_created_before)).to be_nil expect(result.fetch(:original_created_before)).to be_nil
end 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 end
+52
ファイルの表示
@@ -48,4 +48,56 @@ RSpec.describe Preview::ThumbnailFetcher do
expect(described_class.fetch('https://example.com/page')).to eq('jpeg-bytes') expect(described_class.fetch('https://example.com/page')).to eq('jpeg-bytes')
end end
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 end
+32 -1
ファイルの表示
@@ -10,6 +10,37 @@ RSpec.describe Youtube::Sync do
allow(sync).to receive(:attach_thumbnail_if_needed!) allow(sync).to receive(:attach_thumbnail_if_needed!)
end 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 describe '#sync!' do
it 'returns without fetching video details when no video ids are discovered' do it 'returns without fetching video details when no video ids are discovered' do
allow(sync).to receive(:query_terms).and_return([]) allow(sync).to receive(:query_terms).and_return([])
@@ -76,7 +107,7 @@ RSpec.describe Youtube::Sync do
sync.sync! sync.sync!
end 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.tagme
Tag.bot Tag.bot
Tag.youtube Tag.youtube
+52 -2
ファイルの表示
@@ -18,7 +18,11 @@ RSpec.describe "nico:sync" do
it "既存 post を見つけて、nico tag と linked tag を追加し、差分が出たら bot を付ける" do it "既存 post を見つけて、nico tag と linked tag を追加し、差分が出たら bot を付ける" do
# 既存 post(正規表現で拾われるURL) # 既存 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) # 既存の非nicoタグ(kept_non_nico_ids)
kept_general = create_tag!("spec_kept", category: "general") kept_general = create_tag!("spec_kept", category: "general")
@@ -60,8 +64,54 @@ RSpec.describe "nico:sync" do
expect(active_tag_names).to include("bot操作") expect(active_tag_names).to include("bot操作")
end 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 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タグ(今回の同期結果に含まれない) # 旧nicoタグ(今回の同期結果に含まれない)
old_nico = create_tag!("nico:OLD", category: "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. closing `</div>)` forms when nearby code uses them.
- Block bodies for components, functions, callbacks, `if`, `try`, `catch`, - Block bodies for components, functions, callbacks, `if`, `try`, `catch`,
`finally`, loops, and JSX nesting use 2 spaces per level. `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 - Wrapped expressions, arguments, ternary branches, method chains, and object
pairs use 4-space continuation indentation relative to the owning pairs use 4-space continuation indentation relative to the owning
expression. Do not confuse this with 2-space block indentation. expression. Do not confuse this with 2-space block indentation.
@@ -166,6 +173,10 @@ pass or the remaining failure is clearly blocked.
`?` / `:` pairing. `?` / `:` pairing.
- Keep short inline props types local when they remain readable and within the - 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. 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 - 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 the line length allows it; do not mechanically explode short objects into
Prettier-style vertical blocks. 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: - Continuation indentation example:
```ts ```ts
const editingRow = const editingRow =
Number.isFinite (editingSourceRow) Number.isFinite (editingSourceRow)
? rows.find (_1 => _1.sourceRow === editingSourceRow) ?? null ? rows.find (row => row.sourceRow === editingSourceRow) ?? null
: null : null
``` ```
+29 -5
ファイルの表示
@@ -19,8 +19,8 @@ const toastApi = vi.hoisted (() => ({
vi.mock ('@/lib/posts', () => postsApi) vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/lib/api', () => api) vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi) vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('@/components/dialogues/DialogueProvider', () => ({ vi.mock ('@/lib/dialogues/useDialogue', () => ({
useDialogue: () => ({ default: () => ({
choice: vi.fn (), choice: vi.fn (),
}), }),
})) }))
@@ -78,15 +78,39 @@ describe ('PostEditForm', () => {
render (<PostEditForm post={post} onSave={vi.fn ()}/>) 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] const tags = screen.getAllByRole ('textbox')[2]
fireEvent.change (tags, { target: { value: 'general-tag' } }) 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, { fireEvent.change (tags, {
target: { value: '動画 general-tag' }, 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)
}) })
}) })
+29 -40
ファイルの表示
@@ -1,15 +1,16 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import PostFormTagsArea from '@/components/PostFormTagsArea'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import FieldError from '@/components/common/FieldError' import FieldError from '@/components/common/FieldError'
import FormField from '@/components/common/FormField' import PostDurationField from '@/components/posts/PostDurationField'
import { useDialogue } from '@/components/dialogues/DialogueProvider' import PostTagsField from '@/components/posts/PostTagsField'
import PostTextField from '@/components/posts/PostTextField'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { isApiError } from '@/lib/api' import { isApiError } from '@/lib/api'
import useDialogue from '@/lib/dialogues/useDialogue'
import { updatePost } from '@/lib/posts' import { updatePost } from '@/lib/posts'
import { inputClass, msToTime } from '@/lib/utils' import { msToTime } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors' import { useValidationErrors } from '@/lib/useValidationErrors'
import type { FC, FormEvent } from 'react' import type { FC, FormEvent } from 'react'
@@ -17,7 +18,11 @@ import type { FC, FormEvent } from 'react'
import type { Post, TagWithSections } from '@/types' import type { Post, TagWithSections } from '@/types'
type PostFormField = 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 => const videoMsToDurationValue = (videoMs: number | null): string =>
videoMs == null ? '' : String (videoMs / 1_000) 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"> <form onSubmit={handleSubmit} className="max-w-xl pt-2 space-y-4">
<FieldError messages={baseErrors}/> <FieldError messages={baseErrors}/>
{/* タイトル */} <PostTextField
<FormField label="タイトル"> label="タイトル"
{({ invalid }) => (
<input
type="text"
disabled={disabled}
className={inputClass (invalid)}
value={title ?? ''} value={title ?? ''}
onChange={e => setTitle (e.target.value)}/>)}
</FormField>
{/* 親投稿 */}
<FormField label="親投稿" messages={fieldErrors.parentPostIds}>
{({ describedBy, invalid }) => (
<input
type="text"
disabled={disabled} disabled={disabled}
value={parentPostIds} onChange={setTitle}/>
onChange={e => setParentPostIds (e.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>
{/* タグ */} <PostTextField
<PostFormTagsArea label="親投稿"
value={parentPostIds}
disabled={disabled}
errors={fieldErrors.parentPostIds}
onChange={setParentPostIds}/>
<PostTagsField
disabled={disabled} disabled={disabled}
tags={tags} tags={tags}
setTags={setTags} setTags={setTags}
@@ -195,21 +188,17 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
setOriginalCreatedFrom={setOriginalCreatedFrom} setOriginalCreatedFrom={setOriginalCreatedFrom}
originalCreatedBefore={originalCreatedBefore} originalCreatedBefore={originalCreatedBefore}
setOriginalCreatedBefore={setOriginalCreatedBefore} setOriginalCreatedBefore={setOriginalCreatedBefore}
errors={fieldErrors.originalCreatedAt}/> errors={groupedMessages (
fieldErrors.originalCreatedAt,
fieldErrors.originalCreatedFrom,
fieldErrors.originalCreatedBefore)}/>
{/* 動画時間 */}
{videoFlg && ( {videoFlg && (
<FormField label="動画時間" messages={fieldErrors.videoMs}> <PostDurationField
{({ invalid }) => (
<input
type="number"
min="0.001"
step="0.001"
disabled={disabled}
className={inputClass (invalid)}
value={duration} value={duration}
onChange={e => setDuration (e.target.value)}/>)} disabled={disabled}
</FormField>)} errors={fieldErrors.videoMs}
onChange={setDuration}/>)}
{/* 送信 */} {/* 送信 */}
<Button type="submit" disabled={disabled}> <Button type="submit" disabled={disabled}>
+41 -3
ファイルの表示
@@ -20,8 +20,10 @@ describe ('PostOriginalCreatedTimeField', () => {
fireEvent.change (inputs[0], { target: { value: '2026-01-02T03:04' } }) fireEvent.change (inputs[0], { target: { value: '2026-01-02T03:04' } })
fireEvent.change (inputs[1], { target: { value: '2026-01-03T03:04' } }) fireEvent.change (inputs[1], { target: { value: '2026-01-03T03:04' } })
expect (setFrom).toHaveBeenCalledWith (expect.any (String)) expect (setFrom).toHaveBeenCalledWith (expect.stringMatching (
expect (setBefore).toHaveBeenCalledWith (expect.any (String)) /^\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', () => { it ('infers an exclusive before value on blur', () => {
@@ -38,7 +40,43 @@ describe ('PostOriginalCreatedTimeField', () => {
const input = screen.getAllByDisplayValue ('')[0] const input = screen.getAllByDisplayValue ('')[0]
fireEvent.blur (input, { target: { value: '2026-01-02T03:04' } }) 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', () => { it ('resets both values', () => {
+5 -3
ファイルの表示
@@ -1,4 +1,4 @@
import DateTimeField from '@/components/common/DateTimeField' import DateTimeField, { toMinutePrecisionIsoUtc } from '@/components/common/DateTimeField'
import FormField from '@/components/common/FormField' import FormField from '@/components/common/FormField'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -19,7 +19,8 @@ const PostOriginalCreatedTimeField: FC<Props> = (
setOriginalCreatedFrom, setOriginalCreatedFrom,
originalCreatedBefore, originalCreatedBefore,
setOriginalCreatedBefore, setOriginalCreatedBefore,
errors }: Props) => ( errors }: Props) => {
return (
<FormField label="オリジナルの作成日時" messages={errors}> <FormField label="オリジナルの作成日時" messages={errors}>
{({ describedBy, invalid }) => ( {({ describedBy, invalid }) => (
<> <>
@@ -43,7 +44,7 @@ const PostOriginalCreatedTimeField: FC<Props> = (
d.setDate (d.getDate () + 1) d.setDate (d.getDate () + 1)
else else
d.setMinutes (d.getMinutes () + 1) d.setMinutes (d.getMinutes () + 1)
setOriginalCreatedBefore (d.toISOString ()) setOriginalCreatedBefore (toMinutePrecisionIsoUtc (d.toISOString ()))
}}/> }}/>
</div> </div>
@@ -86,5 +87,6 @@ const PostOriginalCreatedTimeField: FC<Props> = (
</div> </div>
</>)} </>)}
</FormField>) </FormField>)
}
export default PostOriginalCreatedTimeField export default PostOriginalCreatedTimeField
+2 -1
ファイルの表示
@@ -8,7 +8,8 @@ const submenuItem = (role: 'guest' | 'member' | 'admin', section: string, item:
user: buildUser ({ role }), user: buildUser ({ role }),
wikiId: section === 'Wiki' ? 10 : null, wikiId: section === 'Wiki' ? 10 : null,
pathName: section === 'Wiki' ? '/wiki/page' : '/posts' }) 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', () => { describe ('menuOutline', () => {
+1 -1
ファイルの表示
@@ -19,7 +19,7 @@ import type { FC, MouseEvent } from 'react'
import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types' import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types'
type Props = { user: User | null, } type Props = { user: User | null }
export const menuOutline = ( export const menuOutline = (
+2 -1
ファイルの表示
@@ -21,7 +21,8 @@ describe ('DateTimeField', () => {
fireEvent.change (input, { target: { value: '' } }) fireEvent.change (input, { target: { value: '' } })
const first = handleChange.mock.calls[0]?.[0] 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) expect (handleChange).toHaveBeenLastCalledWith (null)
}) })
}) })
+24 -8
ファイルの表示
@@ -8,13 +8,27 @@ import type { ComponentPropsWithoutRef, FC, FocusEvent } from 'react'
const pad = (n: number): string => n.toString ().padStart (2, '0') const pad = (n: number): string => n.toString ().padStart (2, '0')
const toDateTimeLocalValue = (d: Date) => { const toDateTimeLocalValue = (value: Date) => {
const y = d.getFullYear () const y = value.getFullYear ()
const m = pad (d.getMonth () + 1) const m = pad (value.getMonth () + 1)
const day = pad (d.getDate ()) const day = pad (value.getDate ())
const h = pad (d.getHours ()) const h = pad (value.getHours ())
const min = pad (d.getMinutes ()) const min = pad (value.getMinutes ())
return `${ y }-${ m }-${ day }T${ h }:${ min }:00` 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']), 'focus:ring-2 focus:ring-blue-200']),
className)} className)}
type="datetime-local" type="datetime-local"
step={60}
value={local} value={local}
aria-invalid={invalid} aria-invalid={invalid}
onChange={ev => { onChange={ev => {
const v = ev.target.value const v = ev.target.value
setLocal (v) setLocal (v)
onChange?.(v ? (new Date (v)).toISOString () : null) onChange?.(v ? toMinutePrecisionIsoUtc (v) : null)
}} }}
onBlur={onBlur}/>) onBlur={onBlur}/>)
} }
export default DateTimeField export default DateTimeField
export { toMinutePrecisionIsoUtc }
+2 -2
ファイルの表示
@@ -86,7 +86,7 @@ const DialogueProvider: FC<Props> = ({ children }) => {
return current.filter (request => request.id !== id) return current.filter (request => request.id !== id)
}) })
setPendingIds (current => current.filter (_1 => _1 !== id)) setPendingIds (current => current.filter (pendingId => pendingId !== id))
setFormActions (current => { setFormActions (current => {
const { [id]: _, ...rest } = current const { [id]: _, ...rest } = current
return rest return rest
@@ -147,7 +147,7 @@ const DialogueProvider: FC<Props> = ({ children }) => {
} }
finally finally
{ {
setPendingIds (current => current.filter (_1 => _1 !== id)) setPendingIds (current => current.filter (pendingId => pendingId !== id))
} }
}, },
[closeRequest, pendingIds]) [closeRequest, pendingIds])
+28
ファイルの表示
@@ -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
+29
ファイルの表示
@@ -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
+49
ファイルの表示
@@ -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
+12
ファイルの表示
@@ -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')
})
})
+50
ファイルの表示
@@ -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
+175 -5
ファイルの表示
@@ -1,12 +1,25 @@
import { act, render, screen, waitFor } from '@testing-library/react' 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 PostImportRowForm from '@/components/posts/import/PostImportRowForm'
import { buildPostImportRow } from '@/test/postImportFactories' import { buildPostImportRow } from '@/test/postImportFactories'
import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue' import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
}))
vi.mock ('@/lib/api', () => api)
describe ('PostImportRowForm', () => { 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 () => { it ('resets only the draft, then saves with resetRequested', async () => {
const row = buildPostImportRow () const row = buildPostImportRow ()
row.attributes.title = 'manual title' row.attributes.title = 'manual title'
@@ -24,7 +37,7 @@ describe ('PostImportRowForm', () => {
const titleInput = screen.getByDisplayValue ('manual title') const titleInput = screen.getByDisplayValue ('manual title')
await waitFor (() => expect (actions.at (-1)?.length).toBe (2)) 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 }) expect (reset).toMatchObject ({ placement: 'start', variant: 'danger', disabled: false })
await act (async () => { await act (async () => {
await reset?.onSelect () await reset?.onSelect ()
@@ -34,7 +47,7 @@ describe ('PostImportRowForm', () => {
expect (titleInput).toHaveValue ('') expect (titleInput).toHaveValue ('')
expect (onSave).not.toHaveBeenCalled () 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 act (async () => {
await save?.onSelect () await save?.onSelect ()
}) })
@@ -63,7 +76,7 @@ describe ('PostImportRowForm', () => {
await waitFor (() => expect (actions.length).toBe (2)) await waitFor (() => expect (actions.length).toBe (2))
await act (async () => { await act (async () => {
await actions.find (_1 => _1.label === '変更をリセット')?.onSelect () await actions.find (action => action.label === '変更をリセット')?.onSelect ()
}) })
expect (screen.getByDisplayValue ('manual title')).toBeInTheDocument () expect (screen.getByDisplayValue ('manual title')).toBeInTheDocument ()
@@ -86,6 +99,163 @@ describe ('PostImportRowForm', () => {
expect (screen.getByText ('duration error')).toBeInTheDocument () expect (screen.getByText ('duration error')).toBeInTheDocument ()
expect (screen.getByText ('title warning')).toBeInTheDocument () expect (screen.getByText ('title warning')).toBeInTheDocument ()
expect (screen.getAllByRole ('textbox').filter ( 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 ()
}) })
}) })
+43 -70
ファイルの表示
@@ -3,25 +3,17 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import FieldError from '@/components/common/FieldError' import FieldError from '@/components/common/FieldError'
import FieldWarning from '@/components/common/FieldWarning' import FieldWarning from '@/components/common/FieldWarning'
import FormField from '@/components/common/FormField' import PostDurationField from '@/components/posts/PostDurationField'
import TextArea from '@/components/common/TextArea' import PostTagsField from '@/components/posts/PostTagsField'
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview' import PostTextField from '@/components/posts/PostTextField'
import { inputClass } from '@/lib/utils' import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
import type { FC } from 'react' 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' import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
type Draft = { type Draft = PostImportEditableDraft
url: string
title: string
thumbnailBase: string
originalCreatedFrom: string
originalCreatedBefore: string
duration: string
tags: string
parentPostIds: string }
type Props = { type Props = {
row: PostImportRow row: PostImportRow
@@ -52,7 +44,7 @@ const buildResetDraft = (row: PostImportRow): Draft => ({
parentPostIds: String (row.resetSnapshot.attributes.parentPostIds ?? '') }) parentPostIds: String (row.resetSnapshot.attributes.parentPostIds ?? '') })
const groupedMessages = (...values: (string[] | undefined)[]): string[] => const groupedMessages = (...values: (string[] | undefined)[]): string[] =>
values.flatMap (value => value ?? []) [...new Set (values.flatMap (value => value ?? []))]
const sameDraft = (left: Draft, right: Draft): boolean => const sameDraft = (left: Draft, right: Draft): boolean =>
left.url === right.url left.url === right.url
@@ -64,6 +56,19 @@ const sameDraft = (left: Draft, right: Draft): boolean =>
&& left.tags === right.tags && left.tags === right.tags
&& left.parentPostIds === right.parentPostIds && 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> = ( const PostImportRowForm: FC<Props> = (
{ row, { row,
@@ -86,7 +91,12 @@ const PostImportRowForm: FC<Props> = (
const resetDraft = useMemo ( const resetDraft = useMemo (
() => buildResetDraft (row), () => buildResetDraft (row),
[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,> ( const update = <Key extends keyof Draft,> (
key: Key, key: Key,
@@ -153,37 +163,41 @@ const PostImportRowForm: FC<Props> = (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid gap-6 md:grid-cols-[7rem_minmax(0,1fr)]"> <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"> <div className="space-y-3 md:sticky md:top-0 md:self-start">
<ThumbnailPreview <PostImportThumbnailPreview
url={draft.thumbnailBase} url={draft.thumbnailBase}
className="h-28 w-28"/> className="h-28 w-28"/>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<PostImportTextField <PostTextField
label="URL" label="URL"
value={draft.url} value={draft.url}
disabled={saving}
warnings={displayRow.fieldWarnings.url} warnings={displayRow.fieldWarnings.url}
errors={groupedMessages ( errors={groupedMessages (
displayRow.validationErrors.url, displayRow.validationErrors.url,
displayRow.importErrors?.url)} displayRow.importErrors?.url)}
onChange={value => update ('url', value)}/> onChange={value => update ('url', value)}/>
<PostImportTextField <PostTextField
label="タイトル" label="タイトル"
value={draft.title} value={draft.title}
disabled={saving}
warnings={displayRow.fieldWarnings.title} warnings={displayRow.fieldWarnings.title}
errors={groupedMessages ( errors={groupedMessages (
displayRow.validationErrors.title, displayRow.validationErrors.title,
displayRow.importErrors?.title)} displayRow.importErrors?.title)}
onChange={value => update ('title', value)}/> onChange={value => update ('title', value)}/>
<PostImportTextField <PostTextField
label="サムネール基底 URL" label="サムネール基底 URL"
value={draft.thumbnailBase} value={draft.thumbnailBase}
disabled={saving}
warnings={displayRow.fieldWarnings.thumbnailBase} warnings={displayRow.fieldWarnings.thumbnailBase}
errors={groupedMessages ( errors={groupedMessages (
displayRow.validationErrors.thumbnailBase, displayRow.validationErrors.thumbnailBase,
displayRow.importErrors?.thumbnailBase)} displayRow.importErrors?.thumbnailBase)}
onChange={value => update ('thumbnailBase', value)}/> onChange={value => update ('thumbnailBase', value)}/>
<PostOriginalCreatedTimeField <PostOriginalCreatedTimeField
disabled={saving}
originalCreatedFrom={draft.originalCreatedFrom || null} originalCreatedFrom={draft.originalCreatedFrom || null}
setOriginalCreatedFrom={value => update ('originalCreatedFrom', value ?? '')} setOriginalCreatedFrom={value => update ('originalCreatedFrom', value ?? '')}
originalCreatedBefore={draft.originalCreatedBefore || null} originalCreatedBefore={draft.originalCreatedBefore || null}
@@ -195,26 +209,28 @@ const PostImportRowForm: FC<Props> = (
displayRow.importErrors?.originalCreatedAt, displayRow.importErrors?.originalCreatedAt,
displayRow.importErrors?.originalCreatedFrom, displayRow.importErrors?.originalCreatedFrom,
displayRow.importErrors?.originalCreatedBefore)}/> displayRow.importErrors?.originalCreatedBefore)}/>
<PostImportTextField <PostDurationField
label="動画時間"
value={draft.duration} value={draft.duration}
disabled={saving}
errors={groupedMessages ( errors={groupedMessages (
displayRow.validationErrors.duration, displayRow.validationErrors.duration,
displayRow.validationErrors.videoMs, displayRow.validationErrors.videoMs,
displayRow.importErrors?.duration, displayRow.importErrors?.duration,
displayRow.importErrors?.videoMs)} displayRow.importErrors?.videoMs)}
onChange={value => update ('duration', value)}/> onChange={value => update ('duration', value)}/>
<PostImportAreaField <PostTagsField
label="タグ" tags={draft.tags}
value={draft.tags} disabled={saving}
setTags={value => update ('tags', value)}
warnings={displayRow.fieldWarnings.tags} warnings={displayRow.fieldWarnings.tags}
errors={groupedMessages ( errors={groupedMessages (
displayRow.validationErrors.tags, displayRow.validationErrors.tags,
displayRow.importErrors?.tags)} displayRow.importErrors?.tags)}
onChange={value => update ('tags', value)}/> rows={4}/>
<PostImportTextField <PostTextField
label="親投稿" label="親投稿"
value={draft.parentPostIds} value={draft.parentPostIds}
disabled={saving}
errors={groupedMessages ( errors={groupedMessages (
displayRow.validationErrors.parentPostIds, displayRow.validationErrors.parentPostIds,
displayRow.importErrors?.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 default PostImportRowForm
export { buildDraft } export { buildDraft }
export type { Draft as PostImportRowDraft } export type { Draft as PostImportRowDraft }
+21 -13
ファイルの表示
@@ -1,9 +1,9 @@
import { Button } from '@/components/ui/button' 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 PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview'
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus' 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 { FC } from 'react'
@@ -11,7 +11,8 @@ import type { PostImportRow } from '@/lib/postImportSession'
type Props = { type Props = {
row: PostImportRow row: PostImportRow
onEdit: () => void } onEdit: () => void
editDisabled?: boolean }
const summaryWarning = (row: PostImportRow): string | null => const summaryWarning = (row: PostImportRow): string | null =>
Object.values (row.fieldWarnings ?? { }).flat ()[0] Object.values (row.fieldWarnings ?? { }).flat ()[0]
@@ -19,12 +20,12 @@ const summaryWarning = (row: PostImportRow): string | null =>
?? null ?? null
const summaryDate = (row: PostImportRow): string => const summaryDate = (row: PostImportRow): string =>
[row.attributes.originalCreatedFrom, row.attributes.originalCreatedBefore] originalCreatedAtString (
.filter (_1 => typeof _1 === 'string' && _1 !== '') row.attributes.originalCreatedFrom?.toString () ?? null,
.join (' ~ ') row.attributes.originalCreatedBefore?.toString () ?? null)
const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => { const PostImportRowSummary: FC<Props> = ({ row, onEdit, editDisabled }) => {
const warning = summaryWarning (row) const warning = summaryWarning (row)
const displayStatus = displayPostImportStatus (row) const displayStatus = displayPostImportStatus (row)
@@ -38,7 +39,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
<div className="space-y-1"> <div className="space-y-1">
<div className="text-sm font-medium">#{row.sourceRow}</div> <div className="text-sm font-medium">#{row.sourceRow}</div>
</div> </div>
<ThumbnailPreview <PostImportThumbnailPreview
url={String (row.attributes.thumbnailBase ?? '')} url={String (row.attributes.thumbnailBase ?? '')}
className="h-16 w-16"/> className="h-16 w-16"/>
<div className="min-w-0 space-y-1"> <div className="min-w-0 space-y-1">
@@ -52,7 +53,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
{String (row.attributes.tags ?? '') || 'タグなし'} {String (row.attributes.tags ?? '') || 'タグなし'}
</div> </div>
<div className="text-xs text-neutral-500 dark:text-neutral-400"> <div className="text-xs text-neutral-500 dark:text-neutral-400">
{summaryDate (row) || '日時未取得'} {summaryDate (row)}
{row.attributes.duration ? ` / ${ row.attributes.duration }` : ''} {row.attributes.duration ? ` / ${ row.attributes.duration }` : ''}
</div> </div>
{warning && ( {warning && (
@@ -68,7 +69,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
type="button" type="button"
variant="outline" variant="outline"
onClick={onEdit} onClick={onEdit}
disabled={row.importStatus === 'created'}> disabled={!(canEditReviewRow (row)) || editDisabled === true}>
</Button> </Button>
</div> </div>
@@ -79,7 +80,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
'space-y-3 rounded-lg border p-4 md:hidden', 'space-y-3 rounded-lg border p-4 md:hidden',
'transition-shadow hover:shadow-sm')}> 'transition-shadow hover:shadow-sm')}>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<ThumbnailPreview <PostImportThumbnailPreview
url={String (row.attributes.thumbnailBase ?? '')} url={String (row.attributes.thumbnailBase ?? '')}
className="h-20 w-20 shrink-0"/> className="h-20 w-20 shrink-0"/>
<div className="min-w-0 flex-1 space-y-2"> <div className="min-w-0 flex-1 space-y-2">
@@ -92,6 +93,13 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>} {displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
</div> </div>
<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 && ( {warning && (
<div className="text-xs text-amber-700 dark:text-amber-200"> <div className="text-xs text-amber-700 dark:text-amber-200">
{warning} {warning}
@@ -102,7 +110,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
type="button" type="button"
variant="outline" variant="outline"
onClick={onEdit} onClick={onEdit}
disabled={row.importStatus === 'created'}> disabled={!(canEditReviewRow (row)) || editDisabled === true}>
</Button> </Button>
</div> </div>
+78
ファイルの表示
@@ -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')
})
})
+89
ファイルの表示
@@ -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
-49
ファイルの表示
@@ -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
+1 -1
ファイルの表示
@@ -5,7 +5,7 @@ export type PostImportDisplayStatus = 'ready' | 'skipped' | 'warning'
export type PostImportBadgeValue = PostImportDisplayStatus export type PostImportBadgeValue = PostImportDisplayStatus
const hasWarnings = (row: PostImportRow): boolean => 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 || row.baseWarnings.length > 0
export const displayPostImportStatus = ( export const displayPostImportStatus = (
+145 -4
ファイルの表示
@@ -1,10 +1,19 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { creatableImportRows, import { creatableImportRows,
buildNextEditedRow,
canEditResultRow,
canEditReviewRow,
canRetryResultRow,
hasExactSourceRows,
initialisePreviewRows, initialisePreviewRows,
mergeImportResults, mergeImportResults,
mergeValidatedImportRow,
mergeValidatedImportRows, mergeValidatedImportRows,
processableImportRows, processableImportRows,
replaceImportRow,
resultRepairMode,
resultRowMessages,
resultSummaryCounts, resultSummaryCounts,
retryImportRow, retryImportRow,
reviewSummaryCounts } from '@/lib/postImportSession' reviewSummaryCounts } from '@/lib/postImportSession'
@@ -82,13 +91,16 @@ describe ('post import row state', () => {
it ('merges result states and clears incompatible post identifiers', () => { it ('merges result states and clears incompatible post identifiers', () => {
const created = mergeImportResults ([buildPostImportRow ({ const created = mergeImportResults ([buildPostImportRow ({
skipReason: 'existing', skipReason: 'existing',
existingPostId: 2 })], [{ existingPostId: 2,
recoverable: true,
importStatus: 'pending' })], [{
sourceRow: 1, sourceRow: 1,
status: 'created', status: 'created',
post: { id: 3 } }])[0] post: { id: 3 } }])[0]
const skipped = mergeImportResults ([buildPostImportRow ({ const skipped = mergeImportResults ([buildPostImportRow ({
createdPostId: 3, createdPostId: 3,
importStatus: 'created' })], [{ importStatus: 'created',
recoverable: true })], [{
sourceRow: 1, sourceRow: 1,
status: 'skipped', status: 'skipped',
existingPostId: 4 }])[0] existingPostId: 4 }])[0]
@@ -97,20 +109,24 @@ describe ('post import row state', () => {
existingPostId: 4 })], [{ existingPostId: 4 })], [{
sourceRow: 1, sourceRow: 1,
status: 'failed', status: 'failed',
recoverable: true,
errors: { base: ['failure'] } }])[0] errors: { base: ['failure'] } }])[0]
expect (created).toMatchObject ({ expect (created).toMatchObject ({
importStatus: 'created', importStatus: 'created',
createdPostId: 3, createdPostId: 3,
existingPostId: undefined, existingPostId: undefined,
recoverable: undefined,
skipReason: undefined }) skipReason: undefined })
expect (skipped).toMatchObject ({ expect (skipped).toMatchObject ({
importStatus: 'skipped', importStatus: 'skipped',
existingPostId: 4, existingPostId: 4,
createdPostId: undefined, createdPostId: undefined,
recoverable: undefined,
skipReason: 'existing' }) skipReason: 'existing' })
expect (failed).toMatchObject ({ expect (failed).toMatchObject ({
importStatus: 'failed', importStatus: 'failed',
recoverable: true,
createdPostId: undefined, createdPostId: undefined,
existingPostId: undefined, existingPostId: undefined,
skipReason: undefined, skipReason: undefined,
@@ -123,12 +139,103 @@ describe ('post import row state', () => {
buildPostImportRow ({ sourceRow: 2, importStatus: 'skipped', buildPostImportRow ({ sourceRow: 2, importStatus: 'skipped',
existingPostId: 2, skipReason: 'existing' }), existingPostId: 2, skipReason: 'existing' }),
buildPostImportRow ({ sourceRow: 3, importStatus: 'failed', 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 ({ expect (retryImportRow (rows, 3)[2]).toMatchObject ({
importStatus: 'pending', importStatus: 'pending',
importErrors: undefined }) 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', () => { 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.attributes.title).toBe ('')
expect (initialised.resetSnapshot.fieldWarnings.title).toEqual (['warning']) 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')
})
}) })
+142 -3
ファイルの表示
@@ -1,4 +1,5 @@
import type { PostImportResultRow, import type { PostImportEditableDraft,
PostImportResultRow,
PostImportRow } from '@/lib/postImportTypes' PostImportRow } from '@/lib/postImportTypes'
const hasSkipReason = (row: PostImportRow): boolean => const hasSkipReason = (row: PostImportRow): boolean =>
@@ -7,6 +8,13 @@ const hasSkipReason = (row: PostImportRow): boolean =>
const hasValidationErrors = (row: PostImportRow): boolean => const hasValidationErrors = (row: PostImportRow): boolean =>
Object.keys (row.validationErrors ?? { }).length > 0 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) => ({ const buildResetSnapshot = (row: PostImportRow) => ({
url: row.url, url: row.url,
attributes: { ...row.attributes }, attributes: { ...row.attributes },
@@ -60,13 +68,133 @@ export const resultSummaryCounts = (rows: PostImportRow[]) =>
++counts.skipped ++counts.skipped
return counts return counts
} }
if (row.importStatus === 'failed') if (row.importStatus === 'failed'
|| (row.recoverable === true && row.importStatus === 'pending'))
++counts.failed ++counts.failed
return counts return counts
}, },
{ created: 0, skipped: 0, failed: 0 }) { 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 = ( export const mergeValidatedImportRows = (
current: PostImportRow[], current: PostImportRow[],
validated: PostImportRow[], validated: PostImportRow[],
@@ -133,25 +261,34 @@ export const mergeImportResults = (
return { return {
...row, ...row,
importStatus: 'created', importStatus: 'created',
recoverable: undefined,
skipReason: undefined, skipReason: undefined,
createdPostId: result.post.id, createdPostId: result.post.id,
existingPostId: undefined, existingPostId: undefined,
fieldWarnings: result.fieldWarnings ?? row.fieldWarnings,
baseWarnings: result.baseWarnings ?? row.baseWarnings,
importErrors: result.errors } importErrors: result.errors }
case 'skipped': case 'skipped':
return { return {
...row, ...row,
importStatus: 'skipped', importStatus: 'skipped',
recoverable: undefined,
skipReason: 'existing', skipReason: 'existing',
createdPostId: undefined, createdPostId: undefined,
existingPostId: result.existingPostId, existingPostId: result.existingPostId,
fieldWarnings: result.fieldWarnings ?? row.fieldWarnings,
baseWarnings: result.baseWarnings ?? row.baseWarnings,
importErrors: result.errors } importErrors: result.errors }
case 'failed': case 'failed':
return { return {
...row, ...row,
importStatus: 'failed', importStatus: 'failed',
recoverable: result.recoverable === true ? true : undefined,
skipReason: undefined, skipReason: undefined,
createdPostId: undefined, createdPostId: undefined,
existingPostId: undefined, existingPostId: undefined,
fieldWarnings: result.fieldWarnings ?? row.fieldWarnings,
baseWarnings: result.baseWarnings ?? row.baseWarnings,
importErrors: result.errors } importErrors: result.errors }
} }
}) })
@@ -163,7 +300,9 @@ export const retryImportRow = (
sourceRow: number, sourceRow: number,
): PostImportRow[] => ): PostImportRow[] =>
rows.map (row => rows.map (row =>
row.sourceRow === sourceRow && row.importStatus === 'failed' row.sourceRow === sourceRow
&& row.importStatus === 'failed'
&& row.recoverable === true
? { ...row, importStatus: 'pending', importErrors: undefined } ? { ...row, importStatus: 'pending', importErrors: undefined }
: row) : row)
+2 -2
ファイルの表示
@@ -40,8 +40,8 @@ const normaliseImportUrl = (value: string): string | null => {
export const countImportSourceLines = (source: string): number => export const countImportSourceLines = (source: string): number =>
source source
.split (/\r\n|\n|\r/) .split (/\r\n|\n|\r/)
.map (_1 => _1.trim ()) .map (line => line.trim ())
.filter (_1 => _1 !== '') .filter (line => line !== '')
.length .length
+16 -4
ファイルの表示
@@ -16,18 +16,27 @@ describe ('post import storage', () => {
it ('round-trips a valid session and source draft', () => { it ('round-trips a valid session and source draft', () => {
const row = buildPostImportRow ({ const row = buildPostImportRow ({
attributes: { duration: '2.5' },
recoverable: true,
importStatus: 'pending',
validationErrors: { title: ['invalid'] } })
const skipped = buildPostImportRow ({
importStatus: 'skipped', importStatus: 'skipped',
skipReason: 'existing', skipReason: 'existing',
existingPostId: 10 }) existingPostId: 10 })
expect (savePostImportSession ('session', { expect (savePostImportSession ('session', {
source: row.url, source: skipped.url,
rows: [row], rows: [row, skipped],
repairMode: 'all' })).toBe (true) repairMode: 'all' })).toBe (true)
expect (loadPostImportSession ('session')).toMatchObject ({ expect (loadPostImportSession ('session')).toMatchObject ({
version: 2, version: 2,
source: row.url, source: skipped.url,
rows: [{ rows: [{
attributes: { duration: '2.5' },
recoverable: true,
importStatus: 'pending' },
{
importStatus: 'skipped', importStatus: 'skipped',
skipReason: 'existing', skipReason: 'existing',
existingPostId: 10 }] }) existingPostId: 10 }] })
@@ -49,7 +58,10 @@ describe ('post import storage', () => {
{ ...session.rows[0], skipReason: 'existing', existingPostId: undefined }, { ...session.rows[0], skipReason: 'existing', existingPostId: undefined },
{ ...session.rows[0], existingPostId: 2, skipReason: undefined }, { ...session.rows[0], existingPostId: 2, skipReason: undefined },
{ ...session.rows[0], importStatus: 'created', createdPostId: 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 ()) for (const [index, row] of invalidRows.entries ())
{ {
+17 -7
ファイルの表示
@@ -96,7 +96,7 @@ const ensureStringListRecord = (value: unknown): Record<string, string[]> | null
const result: Record<string, string[]> = { } const result: Record<string, string[]> = { }
for (const [key, entry] of Object.entries (value)) for (const [key, entry] of Object.entries (value))
{ {
if (!(Array.isArray (entry)) || !(entry.every (_1 => typeof _1 === 'string'))) if (!(Array.isArray (entry)) || !(entry.every (item => typeof item === 'string')))
return null return null
result[key] = entry result[key] = entry
} }
@@ -161,7 +161,7 @@ const sanitiseResetSnapshot = (value: unknown): PostImportResetSnapshot | null =
return null return null
if (!(hasOnlyKeys (value.tagSources, TAG_SOURCE_KEYS))) if (!(hasOnlyKeys (value.tagSources, TAG_SOURCE_KEYS)))
return null return null
if (!(Object.values (value.tagSources).every (_1 => typeof _1 === 'string'))) if (!(Object.values (value.tagSources).every (entry => typeof entry === 'string')))
return null return null
const fieldWarnings = ensureStringListRecord (value.fieldWarnings) const fieldWarnings = ensureStringListRecord (value.fieldWarnings)
if (fieldWarnings == null) if (fieldWarnings == null)
@@ -169,7 +169,7 @@ const sanitiseResetSnapshot = (value: unknown): PostImportResetSnapshot | null =
if (!(hasOnlyKeys (fieldWarnings, WARNING_KEYS))) if (!(hasOnlyKeys (fieldWarnings, WARNING_KEYS)))
return null return null
if (!(Array.isArray (value.baseWarnings)) if (!(Array.isArray (value.baseWarnings))
|| !(value.baseWarnings.every (_1 => typeof _1 === 'string'))) || !(value.baseWarnings.every (warning => typeof warning === 'string')))
return null return null
if (value.metadataUrl != null && typeof value.metadataUrl !== 'string') if (value.metadataUrl != null && typeof value.metadataUrl !== 'string')
return null return null
@@ -202,6 +202,8 @@ const sanitiseRow = (value: unknown): PostImportRow | null => {
return null return null
if (value.skipReason != null && !(isValidSkipReason (value.skipReason))) if (value.skipReason != null && !(isValidSkipReason (value.skipReason)))
return null return null
if (value.recoverable != null && value.recoverable !== true)
return null
if (value.skipReason === 'existing' && !(isPositiveInteger (value.existingPostId))) if (value.skipReason === 'existing' && !(isPositiveInteger (value.existingPostId)))
return null return null
if (value.skipReason !== 'existing' && value.existingPostId != null) if (value.skipReason !== 'existing' && value.existingPostId != null)
@@ -210,6 +212,13 @@ const sanitiseRow = (value: unknown): PostImportRow | null => {
return null return null
if (value.importStatus !== 'created' && value.createdPostId != null) if (value.importStatus !== 'created' && value.createdPostId != null)
return 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 validationErrors = ensureStringListRecord (value.validationErrors)
const fieldWarnings = ensureStringListRecord (value.fieldWarnings) const fieldWarnings = ensureStringListRecord (value.fieldWarnings)
@@ -226,7 +235,7 @@ const sanitiseRow = (value: unknown): PostImportRow | null => {
if (importErrors === null) if (importErrors === null)
return null return null
if (!(Array.isArray (value.baseWarnings)) if (!(Array.isArray (value.baseWarnings))
|| !(value.baseWarnings.every (_1 => typeof _1 === 'string'))) || !(value.baseWarnings.every (warning => typeof warning === 'string')))
return null return null
const provenanceEntries = Object.entries (value.provenance) const provenanceEntries = Object.entries (value.provenance)
@@ -246,7 +255,7 @@ const sanitiseRow = (value: unknown): PostImportRow | null => {
return null return null
if (!(hasOnlyKeys (value.tagSources, TAG_SOURCE_KEYS))) if (!(hasOnlyKeys (value.tagSources, TAG_SOURCE_KEYS)))
return null return null
if (!(Object.values (value.tagSources).every (_1 => typeof _1 === 'string'))) if (!(Object.values (value.tagSources).every (entry => typeof entry === 'string')))
return null return null
} }
@@ -268,7 +277,8 @@ const sanitiseRow = (value: unknown): PostImportRow | null => {
resetSnapshot, resetSnapshot,
createdPostId: createdPostId:
isPositiveInteger (value.createdPostId) ? Number (value.createdPostId) : undefined, isPositiveInteger (value.createdPostId) ? Number (value.createdPostId) : undefined,
importStatus: value.importStatus ?? undefined } importStatus: value.importStatus ?? undefined,
recoverable: value.recoverable === true ? true : undefined }
} }
@@ -391,7 +401,7 @@ export const loadPostImportSession = (
} }
const rows = value.rows.map (sanitiseRow) const rows = value.rows.map (sanitiseRow)
if (rows.some (_1 => _1 == null)) if (rows.some (row => row == null))
return null return null
return { return {
+18 -1
ファイルの表示
@@ -34,22 +34,29 @@ export type PostImportRow = {
metadataUrl?: string metadataUrl?: string
resetSnapshot: PostImportResetSnapshot resetSnapshot: PostImportResetSnapshot
createdPostId?: number createdPostId?: number
importStatus?: PostImportStatus } importStatus?: PostImportStatus
recoverable?: boolean }
export type PostImportResultRow = export type PostImportResultRow =
| { | {
sourceRow: number sourceRow: number
status: 'created' status: 'created'
post: { id: number } post: { id: number }
fieldWarnings?: Record<string, string[]>
baseWarnings?: string[]
errors?: Record<string, string[]> } errors?: Record<string, string[]> }
| { | {
sourceRow: number sourceRow: number
status: 'skipped' status: 'skipped'
existingPostId: number existingPostId: number
fieldWarnings?: Record<string, string[]>
baseWarnings?: string[]
errors?: Record<string, string[]> } errors?: Record<string, string[]> }
| { | {
sourceRow: number sourceRow: number
status: 'failed' status: 'failed'
fieldWarnings?: Record<string, string[]>
baseWarnings?: string[]
errors?: Record<string, string[]> errors?: Record<string, string[]>
recoverable?: boolean } recoverable?: boolean }
@@ -65,4 +72,14 @@ export type PostImportSourceIssue = {
message: string message: string
url: 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 export type StorageErrorHandler = (message: string) => void
+381 -61
ファイルの表示
@@ -1,82 +1,402 @@
import { fireEvent, screen } from '@testing-library/react' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { Route, Routes, useLocation } from 'react-router-dom' import { HelmetProvider } from 'react-helmet-async'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest' 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 PostImportResultPage from '@/pages/posts/PostImportResultPage'
import { buildUser } from '@/test/factories' import { buildUser } from '@/test/factories'
import { buildPostImportRow } from '@/test/postImportFactories' import { buildPostImportRow } from '@/test/postImportFactories'
import { renderWithProviders } from '@/test/render' import { originalCreatedAtString } from '@/lib/utils'
const api = vi.hoisted (() => ({ apiPost: vi.fn () })) import type { PostImportRow } from '@/lib/postImportSession'
const toastApi = vi.hoisted (() => ({ toast: vi.fn () })) 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) vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('@/lib/api', () => api)
const ReviewLocation = () => { vi.mock ('@/lib/dialogues/useDialogue', () => ({
const location = useLocation () default: () => dialogue,
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' })
describe ('PostImportResultPage', () => { describe ('PostImportResultPage', () => {
beforeEach (() => { beforeEach (() => {
sessionStorage.clear () sessionStorage.clear ()
vi.clearAllMocks () 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 () => { it ('shows edit and retry only for recoverable failures and shows warnings', () => {
const rows = [ savePostImportSession ('result-session', {
buildPostImportRow ({ sourceRow: 1, importStatus: 'created', createdPostId: 11 }), source: '',
buildPostImportRow ({ sourceRow: 2, importStatus: 'skipped', repairMode: 'failed',
skipReason: 'existing', existingPostId: 22 }), rows: [
buildPostImportRow ({ sourceRow: 3, importStatus: 'failed', buildPostImportRow ({
importErrors: { base: ['登録中に失敗しました.'] } })] sourceRow: 1,
savePostImportSession ('session', { source: '', rows, repairMode: 'all' }) attributes: { title: 'recoverable row' },
renderPage ()
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 ()
})
it ('returns a retry validation error to the review dialogue route', async () => {
const failed = buildPostImportRow ({
importStatus: 'failed', importStatus: 'failed',
importErrors: { base: ['old error'] } }) recoverable: true,
const invalid = buildPostImportRow ({ importErrors: { base: ['recoverable failed'] } }),
importStatus: 'pending', buildPostImportRow ({
status: 'error', sourceRow: 2,
validationErrors: { title: ['タイトルを確認してください.'] } }) attributes: { title: 'hard failed row' },
savePostImportSession ('session', { importStatus: 'failed',
source: failed.url, importErrors: { base: ['hard failed'] } }),
rows: [failed], buildPostImportRow ({
repairMode: 'all' }) sourceRow: 3,
api.apiPost.mockResolvedValue ({ rows: [invalid] }) attributes: {
title: 'created row',
originalCreatedFrom: '2024-01-01T00:00:00Z',
originalCreatedBefore: '2024-01-02T00:00:00Z' },
importStatus: 'created',
createdPostId: 3,
fieldWarnings: {
thumbnailBase: ['サムネール画像を取得できませんでした.'] } })] })
renderPage () render (
fireEvent.click (await screen.findByRole ('button', { name: '再試行' })) <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 ('review route ?edit=1')).toBeInTheDocument () expect (screen.getAllByRole ('button', { name: '編輯' })).toHaveLength (1)
expect (api.apiPost).toHaveBeenCalledTimes (1) expect (screen.getAllByRole ('button', { name: '再試行' })).toHaveLength (1)
const saved = JSON.parse ( expect (screen.getByText ('サムネール画像を取得できませんでした.'))
sessionStorage.getItem ('post-import-session:session') ?? '{}') .toBeInTheDocument ()
expect (saved.rows[0]).toMatchObject ({ expect (screen.getByText (
originalCreatedAtString ('2024-01-01T00:00:00Z', '2024-01-02T00:00:00Z'),
)).toBeInTheDocument ()
})
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 } })] })
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 (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', importStatus: 'pending',
validationErrors: { title: ['タイトルを確認してください.'] } }) 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: '登録結果が不完全でした' }))
})
}) })
}) })
+293 -39
ファイルの表示
@@ -1,48 +1,61 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import FieldError from '@/components/common/FieldError' import FieldError from '@/components/common/FieldError'
import FieldWarning from '@/components/common/FieldWarning'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import PostImportRowForm from '@/components/posts/import/PostImportRowForm'
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge' import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus' import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { apiPost } from '@/lib/api' import { apiPost } from '@/lib/api'
import useDialogue from '@/lib/dialogues/useDialogue'
import { canEditContent } from '@/lib/users' import { canEditContent } from '@/lib/users'
import { clearPostImportSourceDraft, import { clearPostImportSourceDraft,
buildNextEditedRow,
canEditResultRow,
canRetryResultRow,
hasExactSourceRows,
initialisePreviewRows, initialisePreviewRows,
loadPostImportSession, loadPostImportSession,
mergeImportResults, mergeImportResults,
mergeValidatedImportRows, mergeValidatedImportRow,
replaceImportRow,
resultRepairMode,
resultRowMessages,
resultRowWarnings,
resultSummaryCounts, resultSummaryCounts,
retryImportRow, retryImportRow,
savePostImportSession } from '@/lib/postImportSession' savePostImportSession } from '@/lib/postImportSession'
import { originalCreatedAtString } from '@/lib/utils'
import Forbidden from '@/pages/Forbidden' import Forbidden from '@/pages/Forbidden'
import type { FC } from 'react' import type { FC } from 'react'
import type { PostImportRowDraft } from '@/components/posts/import/PostImportRowForm'
import type { PostImportResultRow, PostImportRow } from '@/lib/postImportSession' import type { PostImportResultRow, PostImportRow } from '@/lib/postImportSession'
import type { PostImportSession } from '@/lib/postImportSession' import type { PostImportSession } from '@/lib/postImportSession'
import type { User } from '@/types' import type { User } from '@/types'
type Props = { user: User | null } type Props = { user: User | null }
const rowMessages = (row: PostImportRow): string[] =>
Object.values (row.importErrors ?? { }).flat ()
const PostImportResultPage: FC<Props> = ({ user }) => { const PostImportResultPage: FC<Props> = ({ user }) => {
const editable = canEditContent (user) const editable = canEditContent (user)
const dialogue = useDialogue ()
const navigate = useNavigate () const navigate = useNavigate ()
const { sessionId } = useParams () const { sessionId } = useParams ()
const [session, setSession] = useState<PostImportSession | null> (null) const [session, setSession] = useState<PostImportSession | null> (null)
const [missing, setMissing] = useState (false) const [missing, setMissing] = useState (false)
const [loadingRow, setLoadingRow] = useState<number | null> (null) const [loadingRow, setLoadingRow] = useState<number | null> (null)
const [, setEditingRow] = useState<PostImportRow | null> (null)
const sessionRef = useRef<PostImportSession | null> (null)
useEffect (() => { useEffect (() => {
if (sessionId == null) if (sessionId == null)
@@ -62,23 +75,170 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
toast ({ title: '取込状態を保存できませんでした', description: message })) toast ({ title: '取込状態を保存できませんでした', description: message }))
}, [session, sessionId]) }, [session, sessionId])
useEffect (() => {
sessionRef.current = session
}, [session])
const counts = useMemo ( const counts = useMemo (
() => resultSummaryCounts (session?.rows ?? []), () => resultSummaryCounts (session?.rows ?? []),
[session]) [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) => { 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 return
setLoadingRow (sourceRow) setLoadingRow (sourceRow)
const originalRow = initialSession.rows.find (row => row.sourceRow === sourceRow)
if (originalRow == null)
{
setLoadingRow (null)
return
}
try try
{ {
const pendingRows = retryImportRow (session.rows, sourceRow) const pendingRows = retryImportRow (initialSession.rows, sourceRow)
setSession ({ ...session, rows: pendingRows }) 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', { const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
rows: pendingRows rows: requestedRows.map (row => ({
.filter (row => row.importStatus !== 'created')
.map (row => ({
sourceRow: row.sourceRow, sourceRow: row.sourceRow,
url: row.url, url: row.url,
attributes: row.attributes, attributes: row.attributes,
@@ -86,23 +246,68 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
tagSources: row.tagSources, tagSources: row.tagSources,
metadataUrl: row.metadataUrl })), metadataUrl: row.metadataUrl })),
changed_row: -1 }) changed_row: -1 })
const validatedRows = mergeValidatedImportRows ( const validatedRows = initialisePreviewRows (validated.rows)
pendingRows, if (!(hasExactSourceRows (requestedRows.map (row => row.sourceRow), validatedRows)))
initialisePreviewRows (validated.rows)) {
const nextSession = { const latest = sessionRef.current ?? pendingSession
...session, const restoredRows = replaceImportRow (latest.rows, originalRow)
rows: validatedRows, const restoredSession = {
repairMode: 'failed' as const } ...latest,
setSession (nextSession) rows: restoredRows,
const target = validatedRows.find (_1 => _1.sourceRow === sourceRow) repairMode: resultRepairMode (restoredRows) }
if (target == null) sessionRef.current = restoredSession
setSession (restoredSession)
persistSession (restoredSession)
toast ({ title: '再検証結果が不完全でした' })
return 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) if (Object.keys (target.validationErrors ?? { }).length > 0)
{ {
const saved = savePostImportSession (sessionId, nextSession, message => const saved = savePostImportSession (sessionId, nextSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message })) toast ({ title: '取込状態を保存できませんでした', description: message }))
if (saved) if (saved)
navigate (`/posts/import/${ sessionId }/review?edit=${ sourceRow }`) void editRow (target)
return return
} }
@@ -118,40 +323,70 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
provenance: target.provenance, provenance: target.provenance,
tagSources: target.tagSources, tagSources: target.tagSources,
metadataUrl: target.metadataUrl }] }) metadataUrl: target.metadataUrl }] })
const mergedRows = mergeImportResults (nextSession.rows, result.rows) 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 => const recoverableRows = result.rows.filter (row =>
row.status === 'failed' row.status === 'failed'
&& row.recoverable && row.recoverable
&& Object.keys (row.errors ?? { }).length > 0) && Object.keys (row.errors ?? { }).length > 0)
const nextRows = mergedRows.map ((row): PostImportRow => { const nextRows = mergedRows.map ((row): PostImportRow => {
const recoverable = recoverableRows.find (_1 => _1.sourceRow === row.sourceRow) const recoverable = recoverableRows.find (
recoverableRow => recoverableRow.sourceRow === row.sourceRow)
if (recoverable == null) if (recoverable == null)
return row return row
return { return {
...row, ...row,
importStatus: 'pending', importStatus: 'pending',
recoverable: true,
validationErrors: recoverable.errors ?? { }, validationErrors: recoverable.errors ?? { },
importErrors: undefined } importErrors: undefined }
}) })
const recoverableTarget = nextRows.find (_1 => _1.sourceRow === sourceRow) const recoverableTarget = nextRows.find (row => row.sourceRow === sourceRow)
const resultSession = { const resultSession = {
...nextSession, ...nextSession,
rows: nextRows, rows: nextRows,
repairMode: recoverableTarget == null ? 'all' as const : 'failed' as const } repairMode: resultRepairMode (nextRows) }
sessionRef.current = resultSession
setSession (resultSession) setSession (resultSession)
persistSession (resultSession)
if (recoverableTarget != null if (recoverableTarget != null
&& Object.keys (recoverableTarget.validationErrors).length > 0) && Object.keys (recoverableTarget.validationErrors).length > 0)
{ {
const saved = savePostImportSession (sessionId, resultSession, message => const saved = savePostImportSession (sessionId, resultSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message })) toast ({ title: '取込状態を保存できませんでした', description: message }))
if (saved) if (saved)
navigate (`/posts/import/${ sessionId }/review?edit=${ sourceRow }`) void editRow (recoverableTarget)
return return
} }
} }
catch catch
{ {
setSession (session) 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: '再試行に失敗しました' }) toast ({ title: '再試行に失敗しました' })
} }
finally finally
@@ -161,16 +396,20 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
} }
const openRepair = (sourceRow: number) => { const openRepair = (sourceRow: number) => {
if (session == null || sessionId == null) const currentSession = sessionRef.current
if (currentSession == null || sessionId == null || loadingRow != null)
return return
const nextSession = { ...session, repairMode: 'failed' as const } const nextSession = { ...currentSession, repairMode: 'failed' as const }
sessionRef.current = nextSession
setSession (nextSession) setSession (nextSession)
const saved = savePostImportSession (sessionId, nextSession, message => const saved = savePostImportSession (sessionId, nextSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message })) toast ({ title: '取込状態を保存できませんでした', description: message }))
if (!(saved)) if (!(saved))
return 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)) if (!(editable))
@@ -206,10 +445,8 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
<div className="space-y-3"> <div className="space-y-3">
{session.rows.map (row => { {session.rows.map (row => {
const displayStatus = displayPostImportStatus (row) const displayStatus = displayPostImportStatus (row)
const canEdit = row.importStatus === 'failed' const canEdit = canEditResultRow (row)
const canRetry = const canRetry = canRetryResultRow (row)
row.importStatus === 'failed'
&& Object.keys (row.validationErrors).length === 0
return ( return (
<div <div
key={row.sourceRow} key={row.sourceRow}
@@ -227,7 +464,17 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
<div className="text-xs text-neutral-500 dark:text-neutral-400"> <div className="text-xs text-neutral-500 dark:text-neutral-400">
{row.url} {row.url}
</div> </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>
<div className="flex flex-col gap-2 sm:flex-row"> <div className="flex flex-col gap-2 sm:flex-row">
@@ -242,14 +489,15 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={() => openRepair (row.sourceRow)}> onClick={() => openRepair (row.sourceRow)}
disabled={busy}>
</Button>)} </Button>)}
{canRetry && ( {canRetry && (
<Button <Button
type="button" type="button"
onClick={() => retry (row.sourceRow)} onClick={() => retry (row.sourceRow)}
disabled={loadingRow === row.sourceRow}> disabled={busy}>
</Button>)} </Button>)}
</div> </div>
@@ -261,8 +509,13 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
disabled={busy}
onClick={() => { 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) setSession (nextSession)
const saved = savePostImportSession (sessionId, nextSession, message => const saved = savePostImportSession (sessionId, nextSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message })) toast ({ title: '取込状態を保存できませんでした', description: message }))
@@ -275,6 +528,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
disabled={busy}
onClick={() => { onClick={() => {
clearPostImportSourceDraft (message => clearPostImportSourceDraft (message =>
toast ({ title: '入力内容を削除できませんでした', description: message })) toast ({ title: '入力内容を削除できませんでした', description: message }))
+270 -53
ファイルの表示
@@ -1,78 +1,295 @@
import { fireEvent, screen, waitFor } from '@testing-library/react' import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { Route, Routes } from 'react-router-dom' import { HelmetProvider } from 'react-helmet-async'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest' 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 PostImportReviewPage from '@/pages/posts/PostImportReviewPage'
import { buildUser } from '@/test/factories' import { buildUser } from '@/test/factories'
import { buildPostImportRow } from '@/test/postImportFactories' import { buildPostImportRow } from '@/test/postImportFactories'
import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({ apiPost: vi.fn () })) import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue'
const toastApi = vi.hoisted (() => ({ toast: vi.fn () })) 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 ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi) vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('@/lib/dialogues/useDialogue', () => ({
const renderPage = () => renderWithProviders ( default: () => dialogue,
<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' })
describe ('PostImportReviewPage', () => { describe ('PostImportReviewPage', () => {
beforeEach (() => { beforeEach (() => {
sessionStorage.clear () sessionStorage.clear ()
vi.clearAllMocks () 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 () => { it ('navigates to the result route after an initial recoverable failure', async () => {
const row = buildPostImportRow ({ attributes: { title: 'title' } }) savePostImportSession ('review-session', {
savePostImportSession ('session', { source: row.url, rows: [row], repairMode: 'all' }) source: 'https://example.com/post',
api.apiPost.mockResolvedValue ({ rows: [buildPostImportRow ({ repairMode: 'all',
attributes: { title: 'title' }, rows: [buildPostImportRow ({ sourceRow: 1 })] })
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' })
api.apiPost api.apiPost
.mockResolvedValueOnce ({ rows: [row] }) .mockResolvedValueOnce ({
rows: [buildPostImportRow ({ sourceRow: 1 })] })
.mockResolvedValueOnce ({ .mockResolvedValueOnce ({
created: 0, created: 0,
skipped: 1, skipped: 0,
failed: 0, failed: 1,
rows: [{ sourceRow: 1, status: 'skipped', existingPostId: 10 }] }) rows: [{
sourceRow: 1,
status: 'failed',
recoverable: true,
errors: { title: ['invalid'] } }] })
renderPage () render (
fireEvent.click (await screen.findByRole ('button', { name: '取込実行' })) <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 (() => { await waitFor (() => {
const saved = JSON.parse ( expect (screen.getByText ('RESULT ROUTE')).toBeInTheDocument ()
sessionStorage.getItem ('post-import-session:session') ?? '{}') })
expect (saved.rows[0]).toMatchObject ({ expect (dialogue.form).not.toHaveBeenCalled ()
importStatus: 'skipped', })
existingPostId: 10,
skipReason: 'existing' }) 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')
})
}) })
+132 -124
ファイルの表示
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async' 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 PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
@@ -13,11 +13,17 @@ import { apiPost } from '@/lib/api'
import useDialogue from '@/lib/dialogues/useDialogue' import useDialogue from '@/lib/dialogues/useDialogue'
import { canEditContent } from '@/lib/users' import { canEditContent } from '@/lib/users'
import { loadPostImportSession, import { loadPostImportSession,
buildNextEditedRow,
canEditReviewRow,
creatableImportRows, creatableImportRows,
hasExactSourceRows,
initialisePreviewRows, initialisePreviewRows,
mergeImportResults, mergeImportResults,
mergeValidatedImportRow,
mergeValidatedImportRows, mergeValidatedImportRows,
processableImportRows, processableImportRows,
replaceImportRow,
resultRepairMode,
reviewSummaryCounts, reviewSummaryCounts,
savePostImportSession } from '@/lib/postImportSession' savePostImportSession } from '@/lib/postImportSession'
import Forbidden from '@/pages/Forbidden' import Forbidden from '@/pages/Forbidden'
@@ -32,17 +38,24 @@ import type { User } from '@/types'
type Props = { user: User | null } 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 PostImportReviewPage: FC<Props> = ({ user }) => {
const editable = canEditContent (user) const editable = canEditContent (user)
const dialogue = useDialogue () const dialogue = useDialogue ()
const navigate = useNavigate () const navigate = useNavigate ()
const { sessionId } = useParams () const { sessionId } = useParams ()
const [searchParams, setSearchParams] = useSearchParams ()
const [session, setSession] = useState<PostImportSession | null> (null) const [session, setSession] = useState<PostImportSession | null> (null)
const [loading, setLoading] = useState (false) const [loading, setLoading] = useState (false)
const [missing, setMissing] = useState (false) const [missing, setMissing] = useState (false)
const [editingRow, setEditingRow] = useState<PostImportRow | null> (null)
const sessionRef = useRef<PostImportSession | null> (null)
useEffect (() => { useEffect (() => {
if (sessionId == null) if (sessionId == null)
@@ -62,12 +75,11 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
toast ({ title: '取込状態を保存できませんでした', description: message })) toast ({ title: '取込状態を保存できませんでした', description: message }))
}, [session, sessionId]) }, [session, sessionId])
useEffect (() => {
sessionRef.current = session
}, [session])
const rows = session?.rows ?? [] 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 counts = useMemo (() => reviewSummaryCounts (rows), [rows])
const processable = useMemo ( const processable = useMemo (
() => processableImportRows (rows), () => processableImportRows (rows),
@@ -79,9 +91,9 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
session?.repairMode === 'failed' session?.repairMode === 'failed'
? ( ? (
[...rows].sort ((a, b) => { [...rows].sort ((a, b) => {
const aFailed = a.importStatus === 'failed' ? 0 : 1 const aRepair = isRepairRow (a) ? 0 : 1
const bFailed = b.importStatus === 'failed' ? 0 : 1 const bRepair = isRepairRow (b) ? 0 : 1
return aFailed - bFailed || a.sourceRow - b.sourceRow return aRepair - bRepair || a.sourceRow - b.sourceRow
})) }))
: rows : rows
@@ -93,41 +105,37 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
element?.scrollIntoView ({ block: 'center', behavior: 'smooth' }) element?.scrollIntoView ({ block: 'center', behavior: 'smooth' })
}, [editingRow, session?.repairMode]) }, [editingRow, session?.repairMode])
const updateSessionRows = (nextRows: PostImportRow[]) =>
setSession (current =>
current != null ? { ...current, rows: nextRows } : current)
const saveDraft = async ( const saveDraft = async (
{ draft, resetRequested, resetSnapshot }: { row: PostImportRow,
{ draft, resetRequested }: {
draft: PostImportRowDraft draft: PostImportRowDraft
resetRequested: boolean resetRequested: boolean },
resetSnapshot: PostImportRow['resetSnapshot'] },
): Promise<{ saved: boolean ): Promise<{ saved: boolean
row: PostImportRow | null }> => { row: PostImportRow | null }> => {
if (session == null) const currentSession = sessionRef.current
if (currentSession == null)
return { saved: false, row: null } return { saved: false, row: null }
if (!(canEditReviewRow (row)))
if (editingRow == null)
return { saved: false, row: null } return { saved: false, row: null }
const baseRow = const baseRow =
resetRequested resetRequested
? { ...editingRow, ? { ...row,
url: resetSnapshot.url, url: row.resetSnapshot.url,
attributes: { ...resetSnapshot.attributes }, attributes: { ...row.resetSnapshot.attributes },
provenance: { ...resetSnapshot.provenance }, provenance: { ...row.resetSnapshot.provenance },
tagSources: { ...resetSnapshot.tagSources }, tagSources: { ...row.resetSnapshot.tagSources },
fieldWarnings: Object.fromEntries ( fieldWarnings: Object.fromEntries (
Object.entries (resetSnapshot.fieldWarnings) Object.entries (row.resetSnapshot.fieldWarnings)
.map (([key, values]) => [key, [...values]])), .map (([key, values]) => [key, [...values]])),
baseWarnings: [...resetSnapshot.baseWarnings], baseWarnings: [...row.resetSnapshot.baseWarnings],
metadataUrl: resetSnapshot.metadataUrl } metadataUrl: row.resetSnapshot.metadataUrl }
: editingRow : row
const urlChanged = draft.url !== baseRow.url const urlChanged = draft.url !== baseRow.url
const nextRow = buildNextEditedRow (baseRow, draft, urlChanged) const nextRow = buildNextEditedRow (baseRow, draft, urlChanged)
const nextRows = session.rows.map (row => const nextRows = currentSession.rows.map (row =>
row.sourceRow === editingRow.sourceRow row.sourceRow === baseRow.sourceRow
? nextRow ? nextRow
: row) : row)
try try
@@ -142,13 +150,38 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
provenance: row.provenance, provenance: row.provenance,
tagSources: row.tagSources, tagSources: row.tagSources,
metadataUrl: row.metadataUrl })), metadataUrl: row.metadataUrl })),
changed_row: urlChanged ? editingRow.sourceRow : -1 }) changed_row: urlChanged ? baseRow.sourceRow : -1 })
const validatedRows = initialisePreviewRows (validated.rows) const validatedRows = initialisePreviewRows (validated.rows)
const target = validatedRows.find (row => row.sourceRow === editingRow.sourceRow) const target = validatedRows.find (
if (target != null && Object.keys (target.validationErrors).length > 0) validatedRow => validatedRow.sourceRow === baseRow.sourceRow)
return { saved: false, row: target } const latestSession = sessionRef.current
updateSessionRows (mergeValidatedImportRows (nextRows, validatedRows)) if (latestSession == null)
setSearchParams ({ }) 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 } return { saved: true, row: null }
} }
catch catch
@@ -164,10 +197,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
draft: PostImportRowDraft draft: PostImportRowDraft
resetRequested: boolean }, resetRequested: boolean },
) => ) =>
saveDraft ({ saveDraft (row, { draft, resetRequested })
draft,
resetRequested,
resetSnapshot: row.resetSnapshot })
await dialogue.form ({ await dialogue.form ({
title: '投稿を編輯', title: '投稿を編輯',
@@ -179,28 +209,36 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
row={row} row={row}
controls={controls} controls={controls}
onSave={saveRowDraft}/>) }) onSave={saveRowDraft}/>) })
setSearchParams ({ })
} }
useEffect (() => { const editRow = async (row: PostImportRow) => {
if (editingRow == null) setEditingRow (row)
return try
{
void openEditingDialogue (editingRow) await openEditingDialogue (row)
}, [editingRow]) }
finally
{
setEditingRow (current =>
current?.sourceRow === row.sourceRow
? null
: current)
}
}
const submit = async () => { const submit = async () => {
if (sessionId == null || session == null || processable.length === 0) const currentSession = sessionRef.current
if (sessionId == null || currentSession == null || processable.length === 0)
return return
setLoading (true) setLoading (true)
try try
{ {
const validatableRows =
currentSession.rows.filter (row => row.importStatus !== 'created')
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', { const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
rows: rows:
session.rows validatableRows.map (row => ({ sourceRow: row.sourceRow,
.filter (row => row.importStatus !== 'created')
.map (row => ({ sourceRow: row.sourceRow,
url: row.url, url: row.url,
attributes: row.attributes, attributes: row.attributes,
provenance: row.provenance, provenance: row.provenance,
@@ -208,14 +246,36 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
metadataUrl: row.metadataUrl })), metadataUrl: row.metadataUrl })),
changed_row: -1 }) changed_row: -1 })
const validatedRows = initialisePreviewRows (validated.rows) 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) const firstInvalid = mergedRows.find (row => Object.keys (row.validationErrors).length > 0)
if (firstInvalid != null) if (firstInvalid != null)
{ {
setSession ({ ...session, rows: mergedRows }) const nextSession = {
setSearchParams ({ edit: String (firstInvalid.sourceRow) }) ...latestAfterValidate,
rows: mergedRows,
repairMode: resultRepairMode (mergedRows) }
sessionRef.current = nextSession
setSession (nextSession)
void editRow (firstInvalid)
return 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<{ const result = await apiPost<{
created: number created: number
@@ -229,7 +289,14 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
provenance: row.provenance, provenance: row.provenance,
tagSources: row.tagSources, tagSources: row.tagSources,
metadataUrl: row.metadataUrl })) }) 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 => const recoverableRows = result.rows.filter (row =>
row.status === 'failed' row.status === 'failed'
&& row.recoverable && row.recoverable
@@ -241,25 +308,15 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
return { return {
...row, ...row,
importStatus: 'pending', importStatus: 'pending',
recoverable: true,
validationErrors: recoverable.errors ?? { }, validationErrors: recoverable.errors ?? { },
importErrors: undefined } importErrors: undefined }
}) })
const firstRecoverable = nextRows.find (row => const nextSession = {
Object.keys (row.validationErrors).length > 0 ...latestAfterImport,
&& row.importStatus !== 'created')
if (firstRecoverable != null)
{
const repairSession = { ...session,
rows: nextRows, rows: nextRows,
repairMode: 'failed' as const } repairMode: resultRepairMode (nextRows) }
setSession (repairSession) sessionRef.current = nextSession
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 }
setSession (nextSession) setSession (nextSession)
const saved = savePostImportSession (sessionId, nextSession, message => const saved = savePostImportSession (sessionId, nextSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message })) toast ({ title: '取込状態を保存できませんでした', description: message }))
@@ -309,9 +366,8 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
<div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}> <div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}>
<PostImportRowSummary <PostImportRowSummary
row={row} row={row}
onEdit={() => { editDisabled={loading}
setSearchParams ({ edit: String (row.sourceRow) }) onEdit={() => void editRow (row)}/>
}}/>
</div>))} </div>))}
</div> </div>
</div> </div>
@@ -351,7 +407,7 @@ const PostImportFooter = (
<span> {skipPlannedCount}</span> <span> {skipPlannedCount}</span>
</div> </div>
<div className="flex flex-col gap-2 sm:flex-row"> <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 URL
</Button> </Button>
<Button <Button
@@ -364,52 +420,4 @@ const PostImportFooter = (
</div> </div>
</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 export default PostImportReviewPage
+2 -1
ファイルの表示
@@ -86,7 +86,8 @@ describe ('PostImportSourcePage', () => {
expect.stringMatching (/^\/posts\/import\/[^/]+\/review$/)) expect.stringMatching (/^\/posts\/import\/[^/]+\/review$/))
}) })
const sessionKeys = Array.from ({ length: sessionStorage.length }, (_, index) => 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) expect (sessionKeys).toHaveLength (1)
}) })
}) })
+1 -1
ファイルの表示
@@ -60,7 +60,7 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
const sourceDescribedBy = const sourceDescribedBy =
[sourceError != null ? SOURCE_ERROR_ID : null, [sourceError != null ? SOURCE_ERROR_ID : null,
sourceIssues.length > 0 ? SOURCE_ISSUES_ID : null] sourceIssues.length > 0 ? SOURCE_ISSUES_ID : null]
.filter (_1 => _1 != null) .filter (value => value != null)
.join (' ') .join (' ')
useEffect (() => { useEffect (() => {
+64 -8
ファイルの表示
@@ -1,6 +1,7 @@
import { fireEvent, screen, waitFor } from '@testing-library/react' import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { toMinutePrecisionIsoUtc } from '@/components/common/DateTimeField'
import PostNewPage from '@/pages/posts/PostNewPage' import PostNewPage from '@/pages/posts/PostNewPage'
import { buildUser } from '@/test/factories' import { buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render' import { renderWithProviders } from '@/test/render'
@@ -39,8 +40,8 @@ describe ('PostNewPage', () => {
const textboxes = screen.getAllByRole ('textbox') const textboxes = screen.getAllByRole ('textbox')
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } }) fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } }) fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } })
fireEvent.change (textboxes[2], { target: { value: '1 2' } }) fireEvent.change (textboxes[3], { target: { value: '1 2' } })
fireEvent.change (textboxes[3], { target: { value: 'tag1 tag2' } }) fireEvent.change (textboxes[4], { target: { value: 'tag1 tag2' } })
fireEvent.click (screen.getByRole ('button', { name: '追加' })) fireEvent.click (screen.getByRole ('button', { name: '追加' }))
await waitFor (() => { await waitFor (() => {
@@ -63,17 +64,19 @@ describe ('PostNewPage', () => {
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>) 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 (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' } }) 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, { fireEvent.change (tags, {
target: { value: '動画 general-tag' }, 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 () => { it ('shows 422 validation errors for post fields', async () => {
@@ -96,11 +99,64 @@ describe ('PostNewPage', () => {
const textboxes = screen.getAllByRole ('textbox') const textboxes = screen.getAllByRole ('textbox')
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } }) fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } }) 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: '追加' })) fireEvent.click (screen.getByRole ('button', { name: '追加' }))
expect (await screen.findByText ('投稿内容を確認してください.')).toBeInTheDocument () expect (await screen.findByText ('投稿内容を確認してください.')).toBeInTheDocument ()
expect (screen.getByText ('ニコニコ・タグは直接指定できません.')).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)
}) })
}) })
+50 -66
ファイルの表示
@@ -2,19 +2,20 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import PostFormTagsArea from '@/components/PostFormTagsArea'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import FieldError from '@/components/common/FieldError' import FieldError from '@/components/common/FieldError'
import Form from '@/components/common/Form' import Form from '@/components/common/Form'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea' 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 { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { apiGet, apiPost } from '@/lib/api' import { apiGet, apiPost } from '@/lib/api'
import { canEditContent } from '@/lib/users' import { canEditContent } from '@/lib/users'
import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors' import { useValidationErrors } from '@/lib/useValidationErrors'
import Forbidden from '@/pages/Forbidden' import Forbidden from '@/pages/Forbidden'
@@ -26,7 +27,11 @@ type Props = { user: User | null }
type PostFormField = type PostFormField =
'url' | 'title' | 'tags' | 'parentPostIds' '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 }) => { const PostNewPage: FC<Props> = ({ user }) => {
@@ -138,30 +143,22 @@ const PostNewPage: FC<Props> = ({ user }) => {
<PageTitle>稿</PageTitle> <PageTitle>稿</PageTitle>
<FieldError messages={baseErrors}/> <FieldError messages={baseErrors}/>
{/* URL */} <PostTextField
<FormField label="URL" messages={fieldErrors.url}> label="URL"
{({ describedBy, invalid }) => ( type="url"
<input type="url"
placeholder="例:https://www.nicovideo.jp/watch/..."
value={url} value={url}
onChange={e => setURL (e.target.value)} errors={fieldErrors.url}
aria-describedby={describedBy} placeholder="例:https://www.nicovideo.jp/watch/..."
aria-invalid={invalid} onChange={setURL}/>
className={inputClass (invalid)}/>)}
</FormField>
{/* タイトル */} <PostTextField
<FormField label="タイトル" messages={fieldErrors.title}> label="タイトル"
{({ describedBy, invalid }) => (
<div className="space-y-2">
<input type="text"
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}
value={title} value={title}
placeholder={titleLoading ? 'Loading...' : ''} errors={fieldErrors.title}
onChange={ev => setTitle (ev.target.value)} disabled={titleLoading}
disabled={titleLoading}/> placeholder={titleLoading ? 'Loading...' : undefined}
onChange={setTitle}
after={
<div className="flex flex-wrap items-center gap-2 text-sm"> <div className="flex flex-wrap items-center gap-2 text-sm">
<span> URL </span> <span> URL </span>
<Button <Button
@@ -171,15 +168,10 @@ const PostNewPage: FC<Props> = ({ user }) => {
disabled={!(url) || titleLoading}> disabled={!(url) || titleLoading}>
</Button> </Button>
</div> </div>}/>
</div>)}
</FormField>
{/* サムネール */} <div className="space-y-2">
<FormField label="サムネール" messages={fieldErrors.thumbnail}> <div className="flex flex-wrap items-center gap-2 text-sm">
{({ describedBy, invalid }) => (
<>
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
<span> URL </span> <span> URL </span>
<Button <Button
type="button" type="button"
@@ -191,10 +183,15 @@ const PostNewPage: FC<Props> = ({ user }) => {
</div> </div>
{thumbnailLoading && ( {thumbnailLoading && (
<p className="text-gray-500 text-sm">Loading...</p>)} <p className="text-gray-500 text-sm">Loading...</p>)}
<input type="file" <PostTextField
label="サムネール"
value={thumbnailFile?.name ?? ''}
errors={fieldErrors.thumbnail}
disabled
onChange={() => {}}/>
<input
type="file"
accept="image/*" accept="image/*"
aria-describedby={describedBy}
aria-invalid={invalid}
onChange={e => { onChange={e => {
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (file) if (file)
@@ -203,27 +200,19 @@ const PostNewPage: FC<Props> = ({ user }) => {
setThumbnailPreview (URL.createObjectURL (file)) setThumbnailPreview (URL.createObjectURL (file))
} }
}}/> }}/>
{thumbnailPreview && ( <PostThumbnailPreview
<img src={thumbnailPreview} url={thumbnailPreview}
alt="preview" alt="preview"
className="mt-2 max-h-48 rounded border"/>)} className="h-28 w-28"/>
</>)} </div>
</FormField>
{/* 親投稿 */} <PostTextField
<FormField label="親投稿" messages={fieldErrors.parentPostIds}> label="親投稿"
{({ describedBy, invalid }) => (
<input
type="text"
value={parentPostIds} value={parentPostIds}
onChange={e => setParentPostIds (e.target.value)} errors={fieldErrors.parentPostIds}
aria-describedby={describedBy} onChange={setParentPostIds}/>
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>
{/* タグ */} <PostTagsField tags={tags} setTags={setTags} errors={fieldErrors.tags}/>
<PostFormTagsArea tags={tags} setTags={setTags} errors={fieldErrors.tags}/>
{/* オリジナルの作成日時 */} {/* オリジナルの作成日時 */}
<PostOriginalCreatedTimeField <PostOriginalCreatedTimeField
@@ -231,21 +220,16 @@ const PostNewPage: FC<Props> = ({ user }) => {
setOriginalCreatedFrom={setOriginalCreatedFrom} setOriginalCreatedFrom={setOriginalCreatedFrom}
originalCreatedBefore={originalCreatedBefore} originalCreatedBefore={originalCreatedBefore}
setOriginalCreatedBefore={setOriginalCreatedBefore} setOriginalCreatedBefore={setOriginalCreatedBefore}
errors={fieldErrors.originalCreatedAt}/> errors={groupedMessages (
fieldErrors.originalCreatedAt,
fieldErrors.originalCreatedFrom,
fieldErrors.originalCreatedBefore)}/>
{/* 動画時間 */} {videoFlg && (
{(videoFlg && <PostDurationField
<FormField label="動画時間" messages={fieldErrors.videoMs}>
{({ invalid }) => (
<input
type="number"
min="0.001"
step="0.001"
value={duration} value={duration}
onChange={e => setDuration (e.target.value)} errors={fieldErrors.videoMs}
aria-invalid={invalid} onChange={setDuration}/>)}
className={inputClass (invalid)}/>)}
</FormField>)}
{/* 送信 */} {/* 送信 */}
<Button onClick={handleSubmit} <Button onClick={handleSubmit}
+1
ファイルの表示
@@ -40,6 +40,7 @@ export const buildPostImportRow = (
provenance, provenance,
tagSources, tagSources,
status: overrides.status ?? 'ready', status: overrides.status ?? 'ready',
recoverable: overrides.recoverable,
resetSnapshot: overrides.resetSnapshot ?? { resetSnapshot: overrides.resetSnapshot ?? {
url, url,
attributes: { ...attributes }, attributes: { ...attributes },