From f76fbe67116df5336ae3b427c868a3fcc6464d69 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Thu, 16 Jul 2026 18:16:20 +0900 Subject: [PATCH] #399 --- backend/app/models/post.rb | 37 ++++- backend/app/services/post_creator.rb | 21 ++- backend/app/services/post_import_runner.rb | 9 +- .../app/services/preview/thumbnail_fetcher.rb | 49 ++++--- backend/app/services/youtube/sync.rb | 13 +- backend/lib/tasks/sync_nico.rake | 30 ++-- backend/spec/models/post_spec.rb | 130 ++++++++++++++++++ backend/spec/services/post_creator_spec.rb | 82 +++++++++++ .../spec/services/post_import_runner_spec.rb | 19 +++ .../preview/thumbnail_fetcher_spec.rb | 52 +++++++ backend/spec/services/youtube/sync_spec.rb | 15 ++ backend/spec/tasks/nico_sync_spec.rb | 19 +++ frontend/src/lib/postImportRows.ts | 6 + frontend/src/lib/postImportTypes.ts | 6 + 14 files changed, 445 insertions(+), 43 deletions(-) create mode 100644 backend/spec/services/post_creator_spec.rb diff --git a/backend/app/models/post.rb b/backend/app/models/post.rb index 31e3d57..8a18ce9 100644 --- a/backend/app/models/post.rb +++ b/backend/app/models/post.rb @@ -2,6 +2,8 @@ class Post < ApplicationRecord require 'mini_magick' require 'stringio' + class RemoteThumbnailFetchFailed < StandardError; end + ORIGINAL_CREATED_INVALID_MESSAGE = 'オリジナルの作成日時の形式が不正です.'.freeze ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE = 'オリジナルの作成日時は分単位で入力してください.'.freeze @@ -12,7 +14,9 @@ class Post < ApplicationRecord def self.resized_thumbnail_attachment(upload) upload.rewind image = MiniMagick::Image.read(upload.read) - image.resize '180x180' + image.resize '180x180^' + image.gravity 'Center' + image.extent '180x180' image.format 'jpg' { io: StringIO.new(image.to_blob), @@ -125,7 +129,11 @@ class Post < ApplicationRecord '%d:%02d' % [min, s] end - remainder_ms.positive? ? "#{ base }.#{ remainder_ms.to_s.rjust(3, '0') }" : base + if remainder_ms.positive? + "#{ base }.#{ remainder_ms.to_s.rjust(3, '0') }" + else + base + end end def snapshot_parent_post_ids = parents.order(:id).pluck(:id) @@ -147,8 +155,33 @@ class Post < ApplicationRecord thumbnail.attach(self.class.resized_thumbnail_attachment(StringIO.new(thumbnail.download))) end + def attach_thumbnail_from_url! raw_url + response = Preview::ThumbnailFetcher.fetch_image_response(raw_url) + thumbnail.attach( + io: StringIO.new(response.body), + filename: remote_thumbnail_filename(response.url), + content_type: response.content_type) + resized_thumbnail! + rescue Preview::UrlSafety::UnsafeUrl, + Preview::ThumbnailFetcher::GenerationFailed, + Preview::HttpFetcher::FetchFailed, + Preview::HttpFetcher::FetchTimeout, + Preview::HttpFetcher::ResponseTooLarge, + MiniMagick::Error => e + raise RemoteThumbnailFetchFailed, e.message + end + private + def remote_thumbnail_filename url + filename = File.basename(URI.parse(url).path.to_s) + return filename if filename.present? && filename != '/' + + 'thumbnail' + rescue URI::InvalidURIError + 'thumbnail' + end + def validate_original_created_range f = parse_original_created_value(:original_created_from) b = parse_original_created_value(:original_created_before) diff --git a/backend/app/services/post_creator.rb b/backend/app/services/post_creator.rb index 29ab18b..0ef0dde 100644 --- a/backend/app/services/post_creator.rb +++ b/backend/app/services/post_creator.rb @@ -1,9 +1,12 @@ class PostCreator class VideoMsParseError < ArgumentError; end + attr_reader :field_warnings + def initialize actor:, attributes: @actor = actor @attributes = attributes.symbolize_keys + @field_warnings = { } end def create! @@ -13,8 +16,7 @@ class PostCreator uploaded_user: @actor, original_created_from: @attributes[:original_created_from].presence, original_created_before: @attributes[:original_created_before].presence) - thumbnail = @attributes[:thumbnail] - post.thumbnail.attach(Post.resized_thumbnail_attachment(thumbnail)) if thumbnail.present? + attach_thumbnail!(post) ApplicationRecord.transaction do post.save! @@ -34,6 +36,21 @@ class PostCreator private + def attach_thumbnail! post + thumbnail = @attributes[:thumbnail] + if thumbnail.present? + post.thumbnail.attach(Post.resized_thumbnail_attachment(thumbnail)) + return + end + + thumbnail_base = post.thumbnail_base + return if thumbnail_base.blank? + + post.attach_thumbnail_from_url!(thumbnail_base) + rescue Post::RemoteThumbnailFetchFailed => e + @field_warnings[:thumbnail_base] = [e.message] + end + def tag_names = @attributes[:tags].to_s.split def parent_post_ids diff --git a/backend/app/services/post_import_runner.rb b/backend/app/services/post_import_runner.rb index 0e4f69d..1f53c54 100644 --- a/backend/app/services/post_import_runner.rb +++ b/backend/app/services/post_import_runner.rb @@ -36,8 +36,13 @@ class PostImportRunner attributes['tags'] = preview[:attributes]['tags'] attributes['url'] = row['url'] - post = PostCreator.new(actor: @actor, attributes:).create! - { source_row: row['source_row'], status: 'created', post: PostRepr.base(post) } + creator = PostCreator.new(actor: @actor, attributes:) + post = creator.create! + result = { source_row: row['source_row'], status: 'created', post: PostRepr.base(post) } + if creator.field_warnings.present? + result[:field_warnings] = creator.field_warnings + end + result rescue ActiveRecord::RecordInvalid => e existing_post = existing_post_for_race(row, e.record) if existing_post diff --git a/backend/app/services/preview/thumbnail_fetcher.rb b/backend/app/services/preview/thumbnail_fetcher.rb index 746aa13..0be0f98 100644 --- a/backend/app/services/preview/thumbnail_fetcher.rb +++ b/backend/app/services/preview/thumbnail_fetcher.rb @@ -25,6 +25,22 @@ module Preview fetch_image!(metadata[:image_url]) end + def self.fetch_image_response(raw_url) + uri, = UrlSafety.validate(raw_url) + response = HttpFetcher.fetch(uri.to_s) + unless allowed_image_content_type?(response.content_type) + raise GenerationFailed, 'サムネール画像が見つかりませんでした.' + end + + response + rescue HttpFetcher::FetchTimeout + raise + rescue HttpFetcher::ResponseTooLarge + raise + rescue HttpFetcher::FetchFailed + raise GenerationFailed, 'サムネール画像を取得できませんでした.' + end + def self.title(raw_url) uri, = UrlSafety.validate(raw_url) HtmlMetadataExtractor.extract( @@ -45,18 +61,7 @@ module Preview end def self.fetch_image!(url) - response = HttpFetcher.fetch(url) - unless allowed_image_content_type?(response.content_type) - raise GenerationFailed, 'サムネール画像が見つかりませんでした.' - end - - response.body - rescue HttpFetcher::FetchTimeout - raise - rescue HttpFetcher::ResponseTooLarge - raise - rescue HttpFetcher::FetchFailed - raise GenerationFailed, 'サムネール画像を取得できませんでした.' + fetch_image_response(url).body end def self.niconico_thumbnail_url(uri) @@ -70,16 +75,20 @@ module Preview xml.at_xpath('//thumbnail_url')&.text&.strip.presence rescue HttpFetcher::FetchFailed, HttpFetcher::FetchTimeout => e - Rails.logger.info("preview_niconico_getthumbinfo_fallback #{ { url: uri.to_s, - video_id:, - error: e.class.name, - message: e.message }.to_json }") + payload = { + url: uri.to_s, + video_id:, + error: e.class.name, + message: e.message } + Rails.logger.info("preview_niconico_getthumbinfo_fallback #{ payload.to_json }") nil rescue Nokogiri::XML::SyntaxError => e - Rails.logger.info("preview_niconico_getthumbinfo_fallback #{ { url: uri.to_s, - video_id:, - error: e.class.name, - message: e.message }.to_json }") + payload = { + url: uri.to_s, + video_id:, + error: e.class.name, + message: e.message } + Rails.logger.info("preview_niconico_getthumbinfo_fallback #{ payload.to_json }") nil end diff --git a/backend/app/services/youtube/sync.rb b/backend/app/services/youtube/sync.rb index 2056dc2..7fe46f0 100644 --- a/backend/app/services/youtube/sync.rb +++ b/backend/app/services/youtube/sync.rb @@ -1,4 +1,3 @@ -require 'open-uri' require 'set' require 'time' @@ -127,12 +126,12 @@ module Youtube return if post.thumbnail.attached? return if thumbnail_url.blank? - post.thumbnail.attach( - io: URI.open(thumbnail_url), - filename: File.basename(URI.parse(thumbnail_url).path), - content_type: 'image/jpeg') - - post.resized_thumbnail! + post.attach_thumbnail_from_url!(thumbnail_url) + rescue Post::RemoteThumbnailFetchFailed => e + Rails.logger.info("youtube_sync_thumbnail_fetch_failed #{ { post_id: post.id, + thumbnail_url:, + error: e.class.name, + message: e.message }.to_json }") end def youtube_url_regexp id diff --git a/backend/lib/tasks/sync_nico.rake b/backend/lib/tasks/sync_nico.rake index f7c760c..7aca540 100644 --- a/backend/lib/tasks/sync_nico.rake +++ b/backend/lib/tasks/sync_nico.rake @@ -70,11 +70,17 @@ namespace :nico do unless post.thumbnail.attached? thumbnail_base = fetch_thumbnail.(post.url) rescue nil if thumbnail_base.present? - post.thumbnail.attach( - io: URI.open(thumbnail_base), - filename: File.basename(URI.parse(thumbnail_base).path), - content_type: 'image/jpeg') attrs[:thumbnail_base] = thumbnail_base + begin + post.attach_thumbnail_from_url!(thumbnail_base) + rescue Post::RemoteThumbnailFetchFailed => e + payload = { + post_id: post.id, + thumbnail_base:, + error: e.class.name, + message: e.message } + Rails.logger.info("nico_sync_thumbnail_fetch_failed #{ payload.to_json }") + end end end @@ -82,7 +88,6 @@ namespace :nico do post_changed = post.changed? if post_changed post.save! - post.resized_thumbnail! if post.thumbnail.attached? end else post_created = true @@ -91,13 +96,18 @@ namespace :nico do post = Post.new(title:, url:, thumbnail_base:, uploaded_user: nil, original_created_from:, original_created_before:) if thumbnail_base.present? - post.thumbnail.attach( - io: URI.open(thumbnail_base), - filename: File.basename(URI.parse(thumbnail_base).path), - content_type: 'image/jpeg') + begin + post.attach_thumbnail_from_url!(thumbnail_base) + rescue Post::RemoteThumbnailFetchFailed => e + payload = { + post_id: nil, + thumbnail_base:, + error: e.class.name, + message: e.message } + Rails.logger.info("nico_sync_thumbnail_fetch_failed #{ payload.to_json }") + end end post.save! - post.resized_thumbnail! sync_post_tags!(post, [Tag.tagme.id, Tag.bot.id, Tag.niconico.id, Tag.video.id]) end diff --git a/backend/spec/models/post_spec.rb b/backend/spec/models/post_spec.rb index 627b28f..7ef84b4 100644 --- a/backend/spec/models/post_spec.rb +++ b/backend/spec/models/post_spec.rb @@ -1,4 +1,5 @@ require 'rails_helper' +require 'tempfile' RSpec.describe Post, type: :model do before do @@ -65,4 +66,133 @@ RSpec.describe Post, type: :model do expect(post.errors.details.fetch(:url)).to include(error: :taken, value: post.url) end end + + describe 'thumbnail processing' do + def image_blob(width:, height:, background:, draw: nil) + Tempfile.create(['post-thumbnail', '.png']) do |file| + MiniMagick::Tool::Convert.new do |convert| + convert.size "#{ width }x#{ height }" + convert.xc background + draw&.call(convert) + convert << file.path + end + File.binread(file.path) + end + end + + def upload_for(blob) + StringIO.new(blob).tap(&:rewind) + end + + def read_image(attachment) + blob = + attachment.is_a?(Hash) ? attachment.fetch(:io).read : attachment.download + MiniMagick::Image.read(blob) + end + + def colour_at(image, x, y) + image.get_pixels.fetch(y).fetch(x) + end + + def expect_green(pixel) + expect(pixel[1]).to be > pixel[0] + 40 + expect(pixel[1]).to be > pixel[2] + 40 + end + + def expect_red(pixel) + expect(pixel[0]).to be > pixel[1] + 40 + expect(pixel[0]).to be > pixel[2] + 40 + end + + def expect_blue(pixel) + expect(pixel[2]).to be > pixel[0] + 40 + expect(pixel[2]).to be > pixel[1] + 40 + end + + describe '.resized_thumbnail_attachment' do + it 'centre-crops a wide image to 180x180 without distorting it' do + blob = image_blob( + width: 360, + height: 180, + background: 'red', + draw: -> convert { + convert.fill 'green' + convert.draw 'rectangle 90,0 269,179' + }) + + resized = described_class.resized_thumbnail_attachment(upload_for(blob)) + image = read_image(resized) + + expect(image.dimensions).to eq([180, 180]) + expect_green(colour_at(image, 0, 90)) + expect_green(colour_at(image, 90, 90)) + expect_green(colour_at(image, 179, 90)) + end + + it 'centre-crops a tall image to 180x180 without distorting it' do + blob = image_blob( + width: 180, + height: 360, + background: 'red', + draw: -> convert { + convert.fill 'green' + convert.draw 'rectangle 0,90 179,269' + }) + + resized = described_class.resized_thumbnail_attachment(upload_for(blob)) + image = read_image(resized) + + expect(image.dimensions).to eq([180, 180]) + expect_green(colour_at(image, 90, 0)) + expect_green(colour_at(image, 90, 90)) + expect_green(colour_at(image, 90, 179)) + end + + it 'keeps a square image square without distortion' do + blob = image_blob( + width: 180, + height: 180, + background: 'red', + draw: -> convert { + convert.fill 'blue' + convert.draw 'rectangle 90,0 179,179' + }) + + resized = described_class.resized_thumbnail_attachment(upload_for(blob)) + image = read_image(resized) + + expect(image.dimensions).to eq([180, 180]) + expect_red(colour_at(image, 20, 90)) + expect_blue(colour_at(image, 160, 90)) + end + end + + describe '#attach_thumbnail_from_url!' do + it 'attaches a fetched remote image through the common resize path' do + blob = image_blob( + width: 240, + height: 180, + background: 'red', + draw: -> convert { + convert.fill 'green' + convert.draw 'rectangle 30,0 209,179' + }) + response = Preview::HttpFetcher::Response.new( + blob, + 'image/png', + 'https://example.com/thumb.png') + allow(Preview::ThumbnailFetcher).to receive(:fetch_image_response) + .with('https://example.com/thumb.png') + .and_return(response) + + post = described_class.create!(title: 'title', url: 'https://example.com/post') + + 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 + end + end end diff --git a/backend/spec/services/post_creator_spec.rb b/backend/spec/services/post_creator_spec.rb new file mode 100644 index 0000000..29a069d --- /dev/null +++ b/backend/spec/services/post_creator_spec.rb @@ -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 diff --git a/backend/spec/services/post_import_runner_spec.rb b/backend/spec/services/post_import_runner_spec.rb index 235dabf..00828d2 100644 --- a/backend/spec/services/post_import_runner_spec.rb +++ b/backend/spec/services/post_import_runner_spec.rb @@ -130,4 +130,23 @@ RSpec.describe PostImportRunner do expect(result).to include(status: 'skipped', existing_post_id: existing.id) end + + it 'returns thumbnail warnings from PostCreator on a created row' do + previewer = instance_double(PostImportPreviewer) + allow(PostImportPreviewer).to receive(:new).and_return(previewer) + allow(previewer).to receive(:preview_rows).and_return([preview]) + created_post = create(:post) + creator = instance_double( + PostCreator, + create!: created_post, + field_warnings: { thumbnail_base: ['サムネール画像を取得できませんでした.'] }) + allow(PostCreator).to receive(:new).and_return(creator) + + result = described_class.new(actor:, rows: [row]).run.fetch(:rows).first + + expect(result).to include( + status: 'created', + field_warnings: { thumbnail_base: ['サムネール画像を取得できませんでした.'] } + ) + end end diff --git a/backend/spec/services/preview/thumbnail_fetcher_spec.rb b/backend/spec/services/preview/thumbnail_fetcher_spec.rb index 3088e0d..16bb8cc 100644 --- a/backend/spec/services/preview/thumbnail_fetcher_spec.rb +++ b/backend/spec/services/preview/thumbnail_fetcher_spec.rb @@ -48,4 +48,56 @@ RSpec.describe Preview::ThumbnailFetcher do expect(described_class.fetch('https://example.com/page')).to eq('jpeg-bytes') end end + + describe '.fetch_image_response' do + it 'rejects an unsafe input URL before HTTP fetch' do + allow(Preview::UrlSafety).to receive(:validate) + .and_raise(Preview::UrlSafety::UnsafeUrl, '安全でない接続先は使用できません.') + expect(Preview::HttpFetcher).not_to receive(:fetch) + + expect { + described_class.fetch_image_response('https://unsafe.example.com/thumb.jpg') + }.to raise_error(Preview::UrlSafety::UnsafeUrl, '安全でない接続先は使用できません.') + end + + it 'rejects an unsafe redirect target' do + allow(Preview::UrlSafety).to receive(:validate) + .and_return([URI.parse('https://example.com/thumb.jpg'), ['8.8.8.8']]) + allow(Preview::HttpFetcher).to receive(:fetch) + .with('https://example.com/thumb.jpg') + .and_raise(Preview::UrlSafety::UnsafeUrl, '安全でない接続先は使用できません.') + + expect { + described_class.fetch_image_response('https://example.com/thumb.jpg') + }.to raise_error(Preview::UrlSafety::UnsafeUrl, '安全でない接続先は使用できません.') + end + + it 'rejects an oversized image response' do + allow(Preview::UrlSafety).to receive(:validate) + .and_return([URI.parse('https://example.com/thumb.jpg'), ['8.8.8.8']]) + allow(Preview::HttpFetcher).to receive(:fetch) + .with('https://example.com/thumb.jpg') + .and_raise(Preview::HttpFetcher::ResponseTooLarge, 'too large') + + expect { + described_class.fetch_image_response('https://example.com/thumb.jpg') + }.to raise_error(Preview::HttpFetcher::ResponseTooLarge) + end + + it 'rejects a non-image content type' do + allow(Preview::UrlSafety).to receive(:validate) + .and_return([URI.parse('https://example.com/thumb.jpg'), ['8.8.8.8']]) + allow(Preview::HttpFetcher).to receive(:fetch) + .with('https://example.com/thumb.jpg') + .and_return( + Preview::HttpFetcher::Response.new( + '', + '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 diff --git a/backend/spec/services/youtube/sync_spec.rb b/backend/spec/services/youtube/sync_spec.rb index df8009a..20de82d 100644 --- a/backend/spec/services/youtube/sync_spec.rb +++ b/backend/spec/services/youtube/sync_spec.rb @@ -10,6 +10,21 @@ RSpec.describe Youtube::Sync do allow(sync).to receive(:attach_thumbnail_if_needed!) end + describe '#attach_thumbnail_if_needed!' do + it 'uses the common remote thumbnail attach path' do + post = create(:post, thumbnail_base: nil) + allow(sync).to receive(:attach_thumbnail_if_needed!).and_call_original + + expect(post).to receive(:attach_thumbnail_from_url!) + .with('https://example.com/thumb.jpg') + + sync.send( + :attach_thumbnail_if_needed!, + post, + 'https://example.com/thumb.jpg') + end + end + describe '#sync!' do it 'returns without fetching video details when no video ids are discovered' do allow(sync).to receive(:query_terms).and_return([]) diff --git a/backend/spec/tasks/nico_sync_spec.rb b/backend/spec/tasks/nico_sync_spec.rb index 87b27d7..944417c 100644 --- a/backend/spec/tasks/nico_sync_spec.rb +++ b/backend/spec/tasks/nico_sync_spec.rb @@ -60,6 +60,25 @@ RSpec.describe "nico:sync" do expect(active_tag_names).to include("bot操作") end + it '既存 post のサムネール取得に共通 attach 経路を使ふ' do + post = Post.create!( + title: 'old', + url: 'https://www.nicovideo.jp/watch/sm9', + uploaded_user: nil) + Tag.bot + Tag.tagme + + stub_python([{ 'code' => 'sm9', 'title' => 't', 'tags' => [] }]) + allow(URI).to receive(:open) + .and_return( + StringIO.new( + '')) + expect(post).to receive(:attach_thumbnail_from_url!) + .with('https://example.com/thumb.jpg') + + run_rake_task('nico:sync') + end + it "既存 post にあった古い nico tag は active から外され、履歴として discard される" do post = Post.create!(title: "old", url: "https://www.nicovideo.jp/watch/sm9", uploaded_user: nil) diff --git a/frontend/src/lib/postImportRows.ts b/frontend/src/lib/postImportRows.ts index a201077..d66583f 100644 --- a/frontend/src/lib/postImportRows.ts +++ b/frontend/src/lib/postImportRows.ts @@ -136,6 +136,8 @@ export const mergeImportResults = ( skipReason: undefined, createdPostId: result.post.id, existingPostId: undefined, + fieldWarnings: result.fieldWarnings ?? row.fieldWarnings, + baseWarnings: result.baseWarnings ?? row.baseWarnings, importErrors: result.errors } case 'skipped': return { @@ -144,6 +146,8 @@ export const mergeImportResults = ( skipReason: 'existing', createdPostId: undefined, existingPostId: result.existingPostId, + fieldWarnings: result.fieldWarnings ?? row.fieldWarnings, + baseWarnings: result.baseWarnings ?? row.baseWarnings, importErrors: result.errors } case 'failed': return { @@ -152,6 +156,8 @@ export const mergeImportResults = ( skipReason: undefined, createdPostId: undefined, existingPostId: undefined, + fieldWarnings: result.fieldWarnings ?? row.fieldWarnings, + baseWarnings: result.baseWarnings ?? row.baseWarnings, importErrors: result.errors } } }) diff --git a/frontend/src/lib/postImportTypes.ts b/frontend/src/lib/postImportTypes.ts index a138a49..ef67832 100644 --- a/frontend/src/lib/postImportTypes.ts +++ b/frontend/src/lib/postImportTypes.ts @@ -41,15 +41,21 @@ export type PostImportResultRow = sourceRow: number status: 'created' post: { id: number } + fieldWarnings?: Record + baseWarnings?: string[] errors?: Record } | { sourceRow: number status: 'skipped' existingPostId: number + fieldWarnings?: Record + baseWarnings?: string[] errors?: Record } | { sourceRow: number status: 'failed' + fieldWarnings?: Record + baseWarnings?: string[] errors?: Record recoverable?: boolean }