コミットを比較

...

5 コミット

作成者 SHA1 メッセージ 日付
みてるぞ 155edfe018 #399 2026-07-12 02:22:36 +09:00
みてるぞ 23f1ffb04b #399 2026-07-12 02:08:32 +09:00
みてるぞ ccfe65a6a4 #399 2026-07-12 02:01:25 +09:00
みてるぞ 06d6e512e4 #399 2026-07-12 01:56:24 +09:00
みてるぞ 5bd097bcfe #399 2026-07-12 01:47:34 +09:00
8個のファイルの変更160行の追加33行の削除
+68 -5
ファイルの表示
@@ -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
@@ -70,15 +70,53 @@ class PostImportsController < ApplicationController
end
raise ArgumentError, '解析結果が大きすぎます.' if rows.sum { Array(_1['values']).sum(&:bytesize) } > PostImportSourceParser::MAX_BYTES
{ columns: columns.map { |column| column.slice('key', 'label') },
rows: rows.map { |row| { source_row: row['source_row'], values: Array(row['values']) } } }
parsed_rows = rows.map do |row|
source_row = Integer(row['source_row'], exception: false)
raise ArgumentError, '元行番号が不正です.' if source_row.nil? || source_row <= 0
{ source_row:, values: Array(row['values']) }
end
raise ArgumentError, '元行番号が重複しています.' if parsed_rows.map { _1[:source_row] }.uniq.length != parsed_rows.length
{ 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
rows.map do |row|
normalised_rows = rows.map do |row|
value = ActionController::Parameters.new(row).permit(:source_row, :sourceRow, :url, :metadata_url, :metadataUrl,
attributes: {}, provenance: {}, tag_sources: {},
fetch_warnings: [], validation_warnings: [])
@@ -107,7 +145,32 @@ class PostImportsController < ApplicationController
raise ArgumentError, 'タグ由来が大きすぎます.'
end
source_row = Integer(normalised['source_row'], exception: false)
raise ArgumentError, '元行番号が不正です.' if source_row.nil? || source_row <= 0
normalised['source_row'] = source_row
normalised.symbolize_keys
end
source_rows = normalised_rows.map { _1[:source_row] }
if source_rows.uniq.length != source_rows.length
raise ArgumentError, '元行番号が不正です.'
end
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
+1 -10
ファイルの表示
@@ -173,15 +173,6 @@ class Post < ApplicationRecord
def normalise_url
return if url.blank?
self.url = url.strip
u = URI.parse(url)
return unless u in URI::HTTP
u.host = u.host.downcase if u.host
u.path = u.path.sub(/\/\Z/, '') if u.path.present?
self.url = u.to_s
rescue URI::InvalidURIError
;
self.url = PostUrlNormaliser.normalise(url) || url.strip
end
end
+2 -1
ファイルの表示
@@ -24,7 +24,8 @@ class PostImportGoogleSheetsFetcher
raise ArgumentError, 'スプレッドシートを CSV として取得できませんでした.' unless csv?(response)
response.body
rescue Preview::UrlSafety::UnsafeUrl, Preview::HttpFetcher::FetchFailed => e
rescue Preview::UrlSafety::UnsafeUrl, Preview::HttpFetcher::FetchFailed,
Preview::HttpFetcher::ResponseTooLarge => e
Rails.logger.info("post_import_google_sheets_fetch_failure #{ { error: e.class.name, message: e.message }.to_json }")
raise ArgumentError, 'スプレッドシートを取得できませんでした.ウェブ公開 URL を確認するか、ファイル保存又はコピー貼付けを使用してください.'
end
+19 -10
ファイルの表示
@@ -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
@@ -23,6 +23,14 @@ class PostImportPreviewer
tag_sources['manual'] = attributes['tags'].to_s if provenance['tags'] == 'manual'
url = row[:url].presence || values[@url_column].to_s.strip
url_for_metadata = normalised_url(url) || url
existing_post = normalised_url(url).present? && Post.exists?(url: normalised_url(url))
if existing_post && @existing == 'skip'
next { source_row: row[:source_row], url: normalised_url(url) || url, attributes:,
provenance:, tag_sources:, metadata_url: url_for_metadata,
fetch_warnings: [], validation_warnings: ['既存投稿のためスキップします.'],
warnings: ['既存投稿のためスキップします.'], validation_errors: { },
status: 'warning', existing: true }
end
url_changed = row[:metadata_url].present? && row[:metadata_url] != url_for_metadata
clear_automatic_values!(attributes, provenance, tag_sources) if url_changed
provenance['url'] ||= row[:url].present? ? 'manual' : 'mapped'
@@ -47,14 +55,13 @@ class PostImportPreviewer
attributes[field] = value
provenance[field] = 'automatic'
end
post = Post.new(url:)
post.valid?
normal_url = post.url
errors = post.errors.to_hash.transform_values(&:dup)
normal_url = normalised_url(url)
errors = { }
errors[:url] = ['URL が不正です.'] if normal_url.blank?
validation_warnings = []
errors[:url] = ['URL が重複しています.'] if normal_url.present? && urls.key?(normal_url)
urls[normal_url] = true if normal_url.present?
if normal_url.present? && Post.exists?(url: normal_url)
if normal_url.present? && existing_post
@existing == 'error' ? errors[:url] = ['既存投稿です.'] : validation_warnings << '既存投稿のためスキップします.'
end
validate_basic_data(attributes, errors)
@@ -78,9 +85,7 @@ class PostImportPreviewer
end
def normalised_url value
post = Post.new(url: value)
post.valid?
post.url if post.errors[:url].empty?
PostUrlNormaliser.normalise(value)
end
private
@@ -166,7 +171,11 @@ class PostImportPreviewer
original_created_before: attributes['original_created_before'].presence,
video_ms: parse_duration(attributes['duration'], errors))
post.valid?
post.errors.each { |error| (errors[error.attribute] ||= []) << error.message }
post.errors.each do |error|
next if error.attribute == :url && error.type == :taken
(errors[error.attribute] ||= []) << error.message
end
end
def parse_duration value, errors
+25 -1
ファイルの表示
@@ -9,8 +9,13 @@ class PostImportRunner
def run
raise ArgumentError, "取込件数は #{ MAX_ROWS } 件までです." if @rows.length > MAX_ROWS
raise ArgumentError, '取込データが大きすぎます.' if @rows.sum { |row| row.to_json.bytesize } > PostImportSourceParser::MAX_BYTES
results = @rows.map { |row| run_row(row.to_h.deep_transform_keys { _1.to_s.underscore }) }
normalised_rows = @rows.map { |row| normalise_row(row) }
source_rows = normalised_rows.map { _1['source_row'] }
raise ArgumentError, '元行番号が重複しています.' if source_rows.uniq.length != source_rows.length
results = normalised_rows.map { |row| run_row(row) }
{ created: results.count { _1[:status] == 'created' }, skipped: results.count { _1[:status] == 'skipped' },
failed: results.count { _1[:status] == 'failed' }, rows: results }
end
@@ -63,6 +68,10 @@ class PostImportRunner
end
def validate_row_structure! row
raise ArgumentError unless row['source_row'].is_a?(Integer) && row['source_row'].positive?
raise ArgumentError unless row['url'].is_a?(String)
raise ArgumentError unless row['attributes'].is_a?(Hash)
raise ArgumentError unless row['attributes'].values.all? { _1.is_a?(String) || _1.is_a?(Numeric) || _1 == true || _1 == false || _1.nil? }
provenance = row['provenance']
raise ArgumentError unless provenance.is_a?(Hash)
allowed = ATTRIBUTE_KEYS + ['url']
@@ -72,5 +81,20 @@ class PostImportRunner
raise ArgumentError unless metadata_url.nil? || metadata_url.is_a?(String)
raise ArgumentError if metadata_url.to_s.bytesize > PostImportSourceParser::MAX_CELL_BYTES
raise ArgumentError if row.to_json.bytesize > PostImportSourceParser::MAX_BYTES
tag_sources = row['tag_sources']
raise ArgumentError unless tag_sources.nil? || tag_sources.is_a?(Hash)
end
def normalise_row row
unless row.is_a?(Hash)
raise ArgumentError, '取込行の形式が不正です.'
end
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
normalised['source_row'] = source_row
normalised
end
end
+16 -1
ファイルの表示
@@ -39,7 +39,22 @@ class PostImportSourceParser
def parse_json
value = JSON.parse(@source, max_nesting: 20)
@json_path.split('.').reject(&:blank?).each { |part| value = value.is_a?(Array) ? value[Integer(part)] : value.fetch(part) }
@json_path.split('.').reject(&:blank?).each do |part|
value =
case value
when Array
index = Integer(part, exception: false)
raise ArgumentError if index.nil? || index.negative? || index >= value.length
value[index]
when Hash
raise ArgumentError unless value.key?(part) || value.key?(part.to_sym)
value.key?(part) ? value[part] : value[part.to_sym]
else
raise ArgumentError
end
end
raise ArgumentError, 'JSON の投稿候補は配列にしてください.' unless value.is_a?(Array)
ensure_limits!(value)
+13
ファイルの表示
@@ -0,0 +1,13 @@
class PostUrlNormaliser
def self.normalise raw_url
value = raw_url.to_s.strip
uri = URI.parse(value)
return nil unless uri.is_a?(URI::HTTP) && uri.host.present?
uri.host = uri.host.downcase
uri.path = uri.path.sub(/\/\z/, '') if uri.path.present?
uri.to_s
rescue URI::InvalidURIError
nil
end
end
+16 -5
ファイルの表示
@@ -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<Props> = ({ 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<Props> = ({ 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<string, string[]> }[] }> ('/posts/import', { rows: targets, existing })
rows: { sourceRow: number, status: ImportResultStatus, post?: { id: number }, errors?: Record<string, string[]> }[] }> ('/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}`,