From 155edfe01892e640772ad0aa8717912f1530e582 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 12 Jul 2026 02:22:36 +0900 Subject: [PATCH] #399 --- .../controllers/post_imports_controller.rb | 50 ++++++++++++++++++- backend/app/services/post_import_previewer.rb | 2 +- backend/app/services/post_import_runner.rb | 4 +- frontend/src/pages/posts/PostImportPage.tsx | 21 ++++++-- 4 files changed, 67 insertions(+), 10 deletions(-) diff --git a/backend/app/controllers/post_imports_controller.rb b/backend/app/controllers/post_imports_controller.rb index b66aec2..cc3a220 100644 --- a/backend/app/controllers/post_imports_controller.rb +++ b/backend/app/controllers/post_imports_controller.rb @@ -21,7 +21,7 @@ class PostImportsController < ApplicationController url_column = params[:url_column].presence || (parsed[:columns].length == 1 ? 0 : nil) return render_bad_request('URL 列を選択してください.') if url_column.nil? - rows = PostImportPreviewer.new(parsed:, url_column:, mappings: params[:mappings], + rows = PostImportPreviewer.new(parsed:, url_column:, mappings: mappings_params, existing: params[:existing]).preview render json: parsed.slice(:columns).merge(rows:) rescue ArgumentError => e @@ -41,7 +41,7 @@ class PostImportsController < ApplicationController end def create - result = PostImportRunner.new(actor: current_user, rows: params[:rows].to_a, + result = PostImportRunner.new(actor: current_user, rows: create_rows_params, existing: params[:existing]).run render json: result, status: result[:created].positive? ? :created : :ok rescue ArgumentError => e @@ -81,6 +81,37 @@ class PostImportsController < ApplicationController { columns: columns.map { |column| column.slice('key', 'label') }, rows: parsed_rows } end + def mappings_params + mappings = params[:mappings] + return { } if mappings.blank? + raise ArgumentError, '列割当ての形式が不正です.' unless mappings.is_a?(ActionController::Parameters) + + raw = mappings.to_unsafe_h + unknown_fields = raw.keys - PostImportPreviewer::FIELDS + raise ArgumentError, '列割当て先が不正です.' if unknown_fields.present? + + permitted = mappings.permit(*PostImportPreviewer::FIELDS.map { |field| + { field => [:kind, :value, { columns: [] }] } + }).to_h + + raw.each_with_object({ }) do |(field, value), result| + raise ArgumentError, '列割当ての形式が不正です.' unless value.is_a?(Hash) + raise ArgumentError, '列割当ての項目が不正です.' unless (value.keys - ['kind', 'value', 'columns']).empty? + + value = permitted.fetch(field) + kind = value['kind'].presence || 'columns' + raise ArgumentError, '列割当ての種別が不正です.' unless kind.in?(['columns', 'fixed']) + columns = value['columns'] + unless columns.nil? || (columns.is_a?(Array) && columns.all? { |index| Integer(index, exception: false)&.>= 0 }) + raise ArgumentError, '列番号が不正です.' + end + raise ArgumentError, '固定値が不正です.' if kind == 'fixed' && !value['value'].is_a?(String) + + result[field] = { 'kind' => kind, 'value' => value['value'].to_s, + 'columns' => Array(columns).map { Integer(_1) } } + end + end + def validate_rows_params rows = Array(params[:rows]) raise ArgumentError, '取込件数が多すぎます.' if rows.length > PostImportSourceParser::MAX_ROWS @@ -127,4 +158,19 @@ class PostImportsController < ApplicationController normalised_rows end + + def create_rows_params + rows = params[:rows] + raise ArgumentError, '取込行の形式が不正です.' unless rows.is_a?(Array) + + rows.map do |row| + unless row.is_a?(Hash) || row.is_a?(ActionController::Parameters) + raise ArgumentError, '取込行の形式が不正です.' + end + + parameters = row.is_a?(ActionController::Parameters) ? row : ActionController::Parameters.new(row) + parameters.permit(:source_row, :sourceRow, :url, :metadata_url, :metadataUrl, + attributes: {}, provenance: {}, tag_sources: {}, tagSources: {}).to_h + end + end end diff --git a/backend/app/services/post_import_previewer.rb b/backend/app/services/post_import_previewer.rb index bccd974..93b132d 100644 --- a/backend/app/services/post_import_previewer.rb +++ b/backend/app/services/post_import_previewer.rb @@ -4,7 +4,7 @@ class PostImportPreviewer def initialize parsed:, url_column:, mappings: { }, existing: 'skip' @parsed = parsed @url_column = url_column.to_i - @mappings = mappings.to_h.stringify_keys + @mappings = mappings.stringify_keys @existing = existing == 'error' ? 'error' : 'skip' end diff --git a/backend/app/services/post_import_runner.rb b/backend/app/services/post_import_runner.rb index 9b9a15a..7205db2 100644 --- a/backend/app/services/post_import_runner.rb +++ b/backend/app/services/post_import_runner.rb @@ -86,11 +86,11 @@ class PostImportRunner end def normalise_row row - unless row.is_a?(Hash) || row.is_a?(ActionController::Parameters) + unless row.is_a?(Hash) raise ArgumentError, '取込行の形式が不正です.' end - normalised = row.to_h.deep_transform_keys { _1.to_s.underscore } + normalised = row.deep_transform_keys { _1.to_s.underscore } source_row = Integer(normalised['source_row'], exception: false) raise ArgumentError, '元行番号が不正です.' if source_row.nil? || source_row <= 0 diff --git a/frontend/src/pages/posts/PostImportPage.tsx b/frontend/src/pages/posts/PostImportPage.tsx index 6b2b943..b4346ef 100644 --- a/frontend/src/pages/posts/PostImportPage.tsx +++ b/frontend/src/pages/posts/PostImportPage.tsx @@ -10,7 +10,7 @@ import MainArea from '@/components/layout/MainArea' import { Button } from '@/components/ui/button' import { toast } from '@/components/ui/use-toast' import { SITE_TITLE } from '@/config' -import { apiPost } from '@/lib/api' +import { apiPost, isApiError } from '@/lib/api' import { canEditContent } from '@/lib/users' import { inputClass } from '@/lib/utils' import Forbidden from '@/pages/Forbidden' @@ -117,9 +117,13 @@ const PostImportPage: FC = ({ user }) => { setRows (current => mergeValidatedRows (current, data.rows)) setPhase ('preview') } - catch + catch (error) { - toast ({ title: '投稿プレビューに失敗しました' }) + const message = + isApiError<{ message?: string, baseErrors?: string[] }> (error) + ? error.response?.data?.message ?? error.response?.data?.baseErrors?.[0] + : undefined + toast ({ title: '投稿プレビューに失敗しました', description: message ?? '入力を確認してください。' }) } finally { @@ -218,14 +222,21 @@ const PostImportPage: FC = ({ user }) => { } const submit = async () => { - const targets = rows.filter (row => (row as ImportRow & { importStatus?: string }).importStatus !== 'created') + const targets = rows.filter (row => row.importStatus == null || row.importStatus === 'pending') if (targets.length === 0) return setLoading (true) try { const result = await apiPost<{ created: number, skipped: number, failed: number, - rows: { sourceRow: number, status: ImportResultStatus, post?: { id: number }, errors?: Record }[] }> ('/posts/import', { rows: targets, existing }) + rows: { sourceRow: number, status: ImportResultStatus, post?: { id: number }, errors?: Record }[] }> ('/posts/import', { + rows: targets.map (row => ({ sourceRow: row.sourceRow, + url: row.url, + attributes: row.attributes, + provenance: row.provenance, + tagSources: row.tagSources, + metadataUrl: row.metadataUrl })), + existing }) if (result.rows.some (row => !(['created', 'skipped', 'failed'] as string[]).includes (row.status))) throw new Error ('不正な登録結果です。') toast ({ title: `登録完了: ${result.created} 件`,