このコミットが含まれているのは:
@@ -18,6 +18,7 @@ class PostImportRunner
|
|||||||
private
|
private
|
||||||
|
|
||||||
def run_row row
|
def run_row row
|
||||||
|
validate_row_structure!(row)
|
||||||
attributes = row.fetch('attributes', { }).to_h.transform_keys { _1.to_s.underscore }
|
attributes = row.fetch('attributes', { }).to_h.transform_keys { _1.to_s.underscore }
|
||||||
raise ArgumentError, '取込項目が不正です.' unless (attributes.keys - ATTRIBUTE_KEYS).empty?
|
raise ArgumentError, '取込項目が不正です.' unless (attributes.keys - ATTRIBUTE_KEYS).empty?
|
||||||
raise ArgumentError, '取込項目が大きすぎます.' if attributes.values.any? { _1.to_s.bytesize > PostImportSourceParser::MAX_CELL_BYTES }
|
raise ArgumentError, '取込項目が大きすぎます.' if attributes.values.any? { _1.to_s.bytesize > PostImportSourceParser::MAX_CELL_BYTES }
|
||||||
@@ -57,4 +58,16 @@ class PostImportRunner
|
|||||||
raise ArgumentError if sources.values.any? { _1.bytesize > PostImportSourceParser::MAX_CELL_BYTES }
|
raise ArgumentError if sources.values.any? { _1.bytesize > PostImportSourceParser::MAX_CELL_BYTES }
|
||||||
raise ArgumentError if sources.values.sum(&:bytesize) > PostImportSourceParser::MAX_CELL_BYTES
|
raise ArgumentError if sources.values.sum(&:bytesize) > PostImportSourceParser::MAX_CELL_BYTES
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def validate_row_structure! row
|
||||||
|
provenance = row['provenance']
|
||||||
|
raise ArgumentError unless provenance.is_a?(Hash)
|
||||||
|
allowed = ATTRIBUTE_KEYS + ['url']
|
||||||
|
raise ArgumentError unless (provenance.keys - allowed).empty?
|
||||||
|
raise ArgumentError unless provenance.values.all? { _1.in?(['automatic', 'mapped', 'fixed', 'manual']) }
|
||||||
|
metadata_url = row['metadata_url']
|
||||||
|
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
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class PostImportSourceParser
|
|||||||
keys = value.grep(Hash).flat_map(&:keys).map(&:to_s).uniq
|
keys = value.grep(Hash).flat_map(&:keys).map(&:to_s).uniq
|
||||||
{ columns: keys.each_with_index.map { |key, index| { key:, label: "#{ ('A'.ord + index).chr }:#{ key }" } },
|
{ columns: keys.each_with_index.map { |key, index| { key:, label: "#{ ('A'.ord + index).chr }:#{ key }" } },
|
||||||
rows: value.each_with_index.map { |item, index| { source_row: index + 1,
|
rows: value.each_with_index.map { |item, index| { source_row: index + 1,
|
||||||
values: keys.map { item.is_a?(Hash) ? item[_1] || item[_1.to_sym] : nil } } } }
|
values: keys.map { json_cell(item.is_a?(Hash) ? item[_1] || item[_1.to_sym] : nil) } } } }
|
||||||
rescue JSON::ParserError, KeyError, ArgumentError => e
|
rescue JSON::ParserError, KeyError, ArgumentError => e
|
||||||
raise ArgumentError, "JSON の形式が不正です: #{ e.message }"
|
raise ArgumentError, "JSON の形式が不正です: #{ e.message }"
|
||||||
end
|
end
|
||||||
@@ -53,4 +53,13 @@ class PostImportSourceParser
|
|||||||
raise ArgumentError, "取込件数は #{ MAX_ROWS } 件までです." if rows.length > MAX_ROWS
|
raise ArgumentError, "取込件数は #{ MAX_ROWS } 件までです." if rows.length > MAX_ROWS
|
||||||
raise ArgumentError, 'セルが大きすぎます.' if rows.flatten.any? { _1.to_s.bytesize > MAX_CELL_BYTES }
|
raise ArgumentError, 'セルが大きすぎます.' if rows.flatten.any? { _1.to_s.bytesize > MAX_CELL_BYTES }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def json_cell value
|
||||||
|
case value
|
||||||
|
when nil then ''
|
||||||
|
when String, Numeric, TrueClass, FalseClass then value.to_s
|
||||||
|
when Array, Hash then JSON.generate(value)
|
||||||
|
else raise ArgumentError, 'JSON のセル値が不正です.'
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type ImportRow = {
|
|||||||
status: 'ready' | 'warning' | 'error'
|
status: 'ready' | 'warning' | 'error'
|
||||||
existing: boolean
|
existing: boolean
|
||||||
metadataUrl?: string
|
metadataUrl?: string
|
||||||
|
createdPostId?: number
|
||||||
importStatus?: 'pending' | 'created' | 'skipped' | 'failed' }
|
importStatus?: 'pending' | 'created' | 'skipped' | 'failed' }
|
||||||
type ParsedImport = { columns: Column[], rows: { sourceRow: number, values: string[] }[] }
|
type ParsedImport = { columns: Column[], rows: { sourceRow: number, values: string[] }[] }
|
||||||
type Phase = 'source' | 'mapping' | 'preview' | 'result'
|
type Phase = 'source' | 'mapping' | 'preview' | 'result'
|
||||||
@@ -75,6 +76,10 @@ const PostImportPage: FC<Props> = ({ user }) => {
|
|||||||
existing })
|
existing })
|
||||||
setColumns (data.columns)
|
setColumns (data.columns)
|
||||||
setParsed (data)
|
setParsed (data)
|
||||||
|
setMappings ({ })
|
||||||
|
setRows ([])
|
||||||
|
setResults ([])
|
||||||
|
setUrlColumn ('')
|
||||||
if (data.columns.length === 1)
|
if (data.columns.length === 1)
|
||||||
setUrlColumn ('0')
|
setUrlColumn ('0')
|
||||||
else
|
else
|
||||||
@@ -175,6 +180,24 @@ const PostImportPage: FC<Props> = ({ user }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateExisting = async (value: 'skip' | 'error') => {
|
||||||
|
setExisting (value)
|
||||||
|
const generation = ++validationGeneration.current
|
||||||
|
setLoading (true)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const validated = await apiPost<{ rows: ImportRow[] }> ('/posts/import/validate',
|
||||||
|
{ rows, existing: value, changed_row: -1 })
|
||||||
|
if (generation === validationGeneration.current)
|
||||||
|
setRows (validated.rows)
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (generation === validationGeneration.current)
|
||||||
|
setLoading (false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
const targets = rows.filter (row => (row as ImportRow & { importStatus?: string }).importStatus !== 'created')
|
const targets = rows.filter (row => (row as ImportRow & { importStatus?: string }).importStatus !== 'created')
|
||||||
if (targets.length === 0)
|
if (targets.length === 0)
|
||||||
@@ -192,7 +215,9 @@ const PostImportPage: FC<Props> = ({ user }) => {
|
|||||||
setResults (result.rows)
|
setResults (result.rows)
|
||||||
setRows (current => current.map (row => {
|
setRows (current => current.map (row => {
|
||||||
const resultRow = result.rows.find (_1 => _1.sourceRow === row.sourceRow)
|
const resultRow = result.rows.find (_1 => _1.sourceRow === row.sourceRow)
|
||||||
return resultRow ? { ...row, importStatus: resultRow.status } : row
|
return resultRow ? { ...row, importStatus: resultRow.status,
|
||||||
|
createdPostId: resultRow.post?.id,
|
||||||
|
errors: resultRow.errors ?? row.errors } : row
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -273,7 +298,7 @@ const PostImportPage: FC<Props> = ({ user }) => {
|
|||||||
<label>URL 列 <select value={urlColumn} onChange={ev => setUrlColumn (ev.target.value)}>
|
<label>URL 列 <select value={urlColumn} onChange={ev => setUrlColumn (ev.target.value)}>
|
||||||
{columns.map ((column, index) => <option key={column.key} value={index}>{column.label}</option>)}
|
{columns.map ((column, index) => <option key={column.key} value={index}>{column.label}</option>)}
|
||||||
</select></label>
|
</select></label>
|
||||||
<label>既存投稿 <select value={existing} onChange={ev => setExisting (ev.target.value as typeof existing)}>
|
<label>既存投稿 <select value={existing} onChange={ev => updateExisting (ev.target.value as typeof existing)}>
|
||||||
<option value="skip">スキップ</option><option value="error">エラー</option>
|
<option value="skip">スキップ</option><option value="error">エラー</option>
|
||||||
</select></label>
|
</select></label>
|
||||||
{['title', 'thumbnail_base', 'original_created_from', 'original_created_before', 'duration', 'tags', 'parent_post_ids'].map (field =>
|
{['title', 'thumbnail_base', 'original_created_from', 'original_created_before', 'duration', 'tags', 'parent_post_ids'].map (field =>
|
||||||
@@ -290,6 +315,7 @@ const PostImportPage: FC<Props> = ({ user }) => {
|
|||||||
<div className="space-y-3"><p>インポート処理が完了しました。</p>
|
<div className="space-y-3"><p>インポート処理が完了しました。</p>
|
||||||
{results.map (result => <div key={result.sourceRow} className="rounded border p-2 dark:border-neutral-700">行 {result.sourceRow}:{result.status} {result.post && <PrefetchLink to={`/posts/${result.post.id}`}>投稿を開く</PrefetchLink>}<FieldError messages={Object.values (result.errors ?? { }).flat ()}/></div>)}
|
{results.map (result => <div key={result.sourceRow} className="rounded border p-2 dark:border-neutral-700">行 {result.sourceRow}:{result.status} {result.post && <PrefetchLink to={`/posts/${result.post.id}`}>投稿を開く</PrefetchLink>}<FieldError messages={Object.values (result.errors ?? { }).flat ()}/></div>)}
|
||||||
<Button type="button" onClick={() => setPhase ('preview')}>結果を確認</Button>
|
<Button type="button" onClick={() => setPhase ('preview')}>結果を確認</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setPhase ('preview')}>失敗行を修正</Button>
|
||||||
<Button type="button" variant="outline" onClick={() => setPhase ('source')}>新しい入力元を選ぶ</Button>
|
<Button type="button" variant="outline" onClick={() => setPhase ('source')}>新しい入力元を選ぶ</Button>
|
||||||
</div>)}
|
</div>)}
|
||||||
</MainArea>)
|
</MainArea>)
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする