コミットを比較
5 コミット
f636d2a177
...
155edfe018
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| 155edfe018 | |||
| 23f1ffb04b | |||
| ccfe65a6a4 | |||
| 06d6e512e4 | |||
| 5bd097bcfe |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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} 件`,
|
||||
|
||||
新しい課題から参照
ユーザをブロックする