このコミットが含まれているのは:
@@ -79,7 +79,7 @@ class PostImportsController < ApplicationController
|
|||||||
raise ArgumentError, '取込件数が多すぎます.' if rows.length > PostImportSourceParser::MAX_ROWS
|
raise ArgumentError, '取込件数が多すぎます.' if rows.length > PostImportSourceParser::MAX_ROWS
|
||||||
|
|
||||||
rows.map do |row|
|
rows.map do |row|
|
||||||
value = ActionController::Parameters.new(row).permit(:source_row, :sourceRow, :url,
|
value = ActionController::Parameters.new(row).permit(:source_row, :sourceRow, :url, :metadata_url, :metadataUrl,
|
||||||
attributes: {}, provenance: {}, tag_sources: {},
|
attributes: {}, provenance: {}, tag_sources: {},
|
||||||
fetch_warnings: [], validation_warnings: [])
|
fetch_warnings: [], validation_warnings: [])
|
||||||
normalised = value.to_h.deep_transform_keys { _1.to_s.underscore }
|
normalised = value.to_h.deep_transform_keys { _1.to_s.underscore }
|
||||||
@@ -100,6 +100,12 @@ class PostImportsController < ApplicationController
|
|||||||
(normalised['tag_sources'].keys - ['automatic', 'mapped', 'fixed', 'manual']).present?)
|
(normalised['tag_sources'].keys - ['automatic', 'mapped', 'fixed', 'manual']).present?)
|
||||||
raise ArgumentError, 'タグ由来の形式が不正です.'
|
raise ArgumentError, 'タグ由来の形式が不正です.'
|
||||||
end
|
end
|
||||||
|
if normalised['tag_sources'].present? &&
|
||||||
|
(!normalised['tag_sources'].values.all?(String) ||
|
||||||
|
normalised['tag_sources'].values.any? { _1.bytesize > PostImportSourceParser::MAX_CELL_BYTES } ||
|
||||||
|
normalised['tag_sources'].values.sum(&:bytesize) > PostImportSourceParser::MAX_CELL_BYTES)
|
||||||
|
raise ArgumentError, 'タグ由来が大きすぎます.'
|
||||||
|
end
|
||||||
|
|
||||||
normalised.symbolize_keys
|
normalised.symbolize_keys
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ class PostImportPreviewer
|
|||||||
tag_sources = row[:tag_sources]&.stringify_keys || initial_tag_sources(attributes, provenance)
|
tag_sources = row[:tag_sources]&.stringify_keys || initial_tag_sources(attributes, provenance)
|
||||||
tag_sources['manual'] = attributes['tags'].to_s if provenance['tags'] == 'manual'
|
tag_sources['manual'] = attributes['tags'].to_s if provenance['tags'] == 'manual'
|
||||||
url = row[:url].presence || values[@url_column].to_s.strip
|
url = row[:url].presence || values[@url_column].to_s.strip
|
||||||
|
url_changed = row[:metadata_url].present? && row[:metadata_url] != url
|
||||||
|
clear_automatic_values!(attributes, provenance, tag_sources) if url_changed
|
||||||
provenance['url'] ||= row[:url].present? ? 'manual' : 'mapped'
|
provenance['url'] ||= row[:url].present? ? 'manual' : 'mapped'
|
||||||
attributes['url'] = url
|
attributes['url'] = url
|
||||||
fetch_warnings = Array(row[:fetch_warnings]).dup
|
fetch_warnings = Array(row[:fetch_warnings]).dup
|
||||||
@@ -49,11 +51,12 @@ class PostImportPreviewer
|
|||||||
@existing == 'error' ? errors[:url] = ['既存投稿です.'] : validation_warnings << '既存投稿のためスキップします.'
|
@existing == 'error' ? errors[:url] = ['既存投稿です.'] : validation_warnings << '既存投稿のためスキップします.'
|
||||||
end
|
end
|
||||||
validate_basic_data(attributes, errors)
|
validate_basic_data(attributes, errors)
|
||||||
validate_preview_tags(merged_tags(tag_sources), errors, validation_warnings)
|
validate_preview_tags(merged_tags(tag_sources, provenance['tags']), errors, validation_warnings)
|
||||||
validate_parents(attributes['parent_post_ids'], errors)
|
validate_parents(attributes['parent_post_ids'], errors)
|
||||||
attributes.delete('url')
|
attributes.delete('url')
|
||||||
attributes['tags'] = merged_tags(tag_sources)
|
attributes['tags'] = merged_tags(tag_sources, provenance['tags'])
|
||||||
{ source_row: row[:source_row], url: normal_url || url, attributes:, provenance:, tag_sources:,
|
{ source_row: row[:source_row], url: normal_url || url, attributes:, provenance:, tag_sources:,
|
||||||
|
metadata_url: url,
|
||||||
fetch_warnings:, validation_warnings:, warnings: fetch_warnings + validation_warnings, errors:,
|
fetch_warnings:, validation_warnings:, warnings: fetch_warnings + validation_warnings, errors:,
|
||||||
status: errors.present? ? 'error' : ((fetch_warnings + validation_warnings).present? ? 'warning' : 'ready'),
|
status: errors.present? ? 'error' : ((fetch_warnings + validation_warnings).present? ? 'warning' : 'ready'),
|
||||||
existing: normal_url.present? && Post.exists?(url: normal_url) }
|
existing: normal_url.present? && Post.exists?(url: normal_url) }
|
||||||
@@ -97,10 +100,19 @@ class PostImportPreviewer
|
|||||||
'fixed' => origin == 'fixed' ? attributes['tags'].to_s : '', 'manual' => '' }
|
'fixed' => origin == 'fixed' ? attributes['tags'].to_s : '', 'manual' => '' }
|
||||||
end
|
end
|
||||||
|
|
||||||
def merged_tags sources
|
def merged_tags sources, origin = nil
|
||||||
|
return sources['manual'].to_s if origin == 'manual'
|
||||||
|
|
||||||
%w[automatic mapped fixed manual].flat_map { sources[_1].to_s.split }.uniq.join(' ')
|
%w[automatic mapped fixed manual].flat_map { sources[_1].to_s.split }.uniq.join(' ')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def clear_automatic_values! attributes, provenance, tag_sources
|
||||||
|
%w[title thumbnail_base original_created_from original_created_before duration].each do |field|
|
||||||
|
attributes[field] = '' if provenance[field] == 'automatic'
|
||||||
|
end
|
||||||
|
tag_sources['automatic'] = ''
|
||||||
|
end
|
||||||
|
|
||||||
def fetch_metadata url
|
def fetch_metadata url
|
||||||
return [{ }, ['URL が空です.']] if url.blank?
|
return [{ }, ['URL が空です.']] if url.blank?
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class PostImportRunner
|
|||||||
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 }
|
||||||
raise ArgumentError, 'URL が長すぎます.' if row['url'].to_s.bytesize > PostImportSourceParser::MAX_CELL_BYTES
|
raise ArgumentError, 'URL が長すぎます.' if row['url'].to_s.bytesize > PostImportSourceParser::MAX_CELL_BYTES
|
||||||
|
validate_tag_sources!(row['tag_sources'])
|
||||||
preview = PostImportPreviewer.new(parsed: { columns: [], rows: [] }, url_column: 0,
|
preview = PostImportPreviewer.new(parsed: { columns: [], rows: [] }, url_column: 0,
|
||||||
mappings: {}, existing: @existing).preview_rows(
|
mappings: {}, existing: @existing).preview_rows(
|
||||||
rows: [{ source_row: row['source_row'], url: row['url'],
|
rows: [{ source_row: row['source_row'], url: row['url'],
|
||||||
@@ -46,4 +47,14 @@ class PostImportRunner
|
|||||||
Rails.logger.error("post_import_runner_failure #{ { error: e.class.name, message: e.message }.to_json }")
|
Rails.logger.error("post_import_runner_failure #{ { error: e.class.name, message: e.message }.to_json }")
|
||||||
{ source_row: row['source_row'], status: 'failed', errors: { base: ['登録中にエラーが発生しました.'] } }
|
{ source_row: row['source_row'], status: 'failed', errors: { base: ['登録中にエラーが発生しました.'] } }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def validate_tag_sources! sources
|
||||||
|
return if sources.blank?
|
||||||
|
|
||||||
|
sources = sources.to_h
|
||||||
|
raise ArgumentError unless (sources.keys - %w[automatic mapped fixed manual]).empty?
|
||||||
|
raise ArgumentError unless sources.values.all?(String)
|
||||||
|
raise ArgumentError if sources.values.any? { _1.bytesize > PostImportSourceParser::MAX_CELL_BYTES }
|
||||||
|
raise ArgumentError if sources.values.sum(&:bytesize) > PostImportSourceParser::MAX_CELL_BYTES
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ class PostMetadataFetcher
|
|||||||
when /\A\d{4}-\d{2}-\d{2}T\d{2}#{ zone }\z/ then from + 1.hour
|
when /\A\d{4}-\d{2}-\d{2}T\d{2}#{ zone }\z/ then from + 1.hour
|
||||||
when /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}#{ zone }\z/ then from + 1.minute
|
when /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}#{ zone }\z/ then from + 1.minute
|
||||||
when /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}#{ zone }\z/ then from + 1.second
|
when /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}#{ zone }\z/ then from + 1.second
|
||||||
when /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+#{ zone }\z/ then from + 0.001
|
when /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.(\d+)#{ zone }\z/
|
||||||
|
from + (10**(-Regexp.last_match(1).length))
|
||||||
else return nil
|
else return nil
|
||||||
end
|
end
|
||||||
[from, before]
|
[from, before]
|
||||||
|
|||||||
@@ -31,9 +31,11 @@ type ImportRow = {
|
|||||||
tagSources?: Record<'automatic' | 'mapped' | 'fixed' | 'manual', string>
|
tagSources?: Record<'automatic' | 'mapped' | 'fixed' | 'manual', string>
|
||||||
status: 'ready' | 'warning' | 'error'
|
status: 'ready' | 'warning' | 'error'
|
||||||
existing: boolean
|
existing: boolean
|
||||||
|
metadataUrl?: string
|
||||||
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'
|
||||||
|
type ImportResultStatus = 'created' | 'skipped' | 'failed'
|
||||||
|
|
||||||
|
|
||||||
const detectFormat = (source: string): 'csv' | 'tsv' =>
|
const detectFormat = (source: string): 'csv' | 'tsv' =>
|
||||||
@@ -54,7 +56,7 @@ const PostImportPage: FC<Props> = ({ user }) => {
|
|||||||
const [parsed, setParsed] = useState<ParsedImport | null> (null)
|
const [parsed, setParsed] = useState<ParsedImport | null> (null)
|
||||||
const [columns, setColumns] = useState<Column[]> ([])
|
const [columns, setColumns] = useState<Column[]> ([])
|
||||||
const [rows, setRows] = useState<ImportRow[]> ([])
|
const [rows, setRows] = useState<ImportRow[]> ([])
|
||||||
const [results, setResults] = useState<{ sourceRow: number, status: string, post?: { id: number }, errors?: Record<string, string[]> }[]> ([])
|
const [results, setResults] = useState<{ sourceRow: number, status: ImportResultStatus, post?: { id: number }, errors?: Record<string, string[]> }[]> ([])
|
||||||
const [mappings, setMappings] = useState<Record<string, { columns: string[] }>> ({ })
|
const [mappings, setMappings] = useState<Record<string, { columns: string[] }>> ({ })
|
||||||
const [existing, setExisting] = useState<'skip' | 'error'> ('skip')
|
const [existing, setExisting] = useState<'skip' | 'error'> ('skip')
|
||||||
const [loading, setLoading] = useState (false)
|
const [loading, setLoading] = useState (false)
|
||||||
@@ -181,7 +183,9 @@ const PostImportPage: FC<Props> = ({ user }) => {
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
const result = await apiPost<{ created: number, skipped: number, failed: number,
|
const result = await apiPost<{ created: number, skipped: number, failed: number,
|
||||||
rows: { sourceRow: number, status: string, 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, existing })
|
||||||
|
if (result.rows.some (row => !(['created', 'skipped', 'failed'] as string[]).includes (row.status)))
|
||||||
|
throw new Error ('不正な登録結果です。')
|
||||||
toast ({ title: `登録完了: ${result.created} 件`,
|
toast ({ title: `登録完了: ${result.created} 件`,
|
||||||
description: `スキップ ${result.skipped} 件、失敗 ${result.failed} 件` })
|
description: `スキップ ${result.skipped} 件、失敗 ${result.failed} 件` })
|
||||||
setPhase ('result')
|
setPhase ('result')
|
||||||
@@ -295,15 +299,17 @@ const PostImportPage: FC<Props> = ({ user }) => {
|
|||||||
const RowCard = ({ row, index, update, updateURL }: { row: ImportRow, index: number, update: (index: number, field: string, value: string) => void, updateURL: (index: number, value: string) => void }) => (
|
const RowCard = ({ row, index, update, updateURL }: { row: ImportRow, index: number, update: (index: number, field: string, value: string) => void, updateURL: (index: number, value: string) => void }) => (
|
||||||
<article className="space-y-2 rounded border bg-white p-3 text-neutral-900 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100">
|
<article className="space-y-2 rounded border bg-white p-3 text-neutral-900 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100">
|
||||||
<p>行 {row.sourceRow}・{row.status}</p>
|
<p>行 {row.sourceRow}・{row.status}</p>
|
||||||
|
{row.importStatus === 'created' ? <p className="rounded bg-green-100 p-2 dark:bg-green-900">登録済み。この行は編集・再登録できません。</p> : <>
|
||||||
<EditableValue label="URL" value={row.url} origin={row.provenance.url} onCommit={value => updateURL (index, value)}/>
|
<EditableValue label="URL" value={row.url} origin={row.provenance.url} onCommit={value => updateURL (index, value)}/>
|
||||||
<EditableValue label="タイトル" value={row.attributes.title ?? ''} origin={row.provenance.title} onCommit={value => update (index, 'title', value)}/>
|
<EditableValue label="タイトル" value={row.attributes.title ?? ''} origin={row.provenance.title} onCommit={value => update (index, 'title', value)}/>
|
||||||
<EditableValue label="サムネール基底 URL" value={row.attributes.thumbnailBase ?? ''} origin={row.provenance.thumbnailBase} onCommit={value => update (index, 'thumbnailBase', value)}/>
|
<EditableValue label="サムネール基底 URL" value={row.attributes.thumbnailBase ?? ''} origin={row.provenance.thumbnailBase} onCommit={value => update (index, 'thumbnailBase', value)}/>
|
||||||
{row.attributes.thumbnailBase && <img src={row.attributes.thumbnailBase} alt="サムネール" className="max-h-32" onError={ev => { ev.currentTarget.hidden = true }}/>}
|
<ThumbnailPreview url={row.attributes.thumbnailBase ?? ''}/>
|
||||||
<EditableValue label="作成日時 From" value={row.attributes.originalCreatedFrom ?? ''} origin={row.provenance.originalCreatedFrom} onCommit={value => update (index, 'originalCreatedFrom', value)}/>
|
<EditableValue label="作成日時 From" value={row.attributes.originalCreatedFrom ?? ''} origin={row.provenance.originalCreatedFrom} onCommit={value => update (index, 'originalCreatedFrom', value)}/>
|
||||||
<EditableValue label="作成日時 Before" value={row.attributes.originalCreatedBefore ?? ''} origin={row.provenance.originalCreatedBefore} onCommit={value => update (index, 'originalCreatedBefore', value)}/>
|
<EditableValue label="作成日時 Before" value={row.attributes.originalCreatedBefore ?? ''} origin={row.provenance.originalCreatedBefore} onCommit={value => update (index, 'originalCreatedBefore', value)}/>
|
||||||
<EditableValue label="動画時間" value={String (row.attributes.duration ?? '')} origin={row.provenance.duration} onCommit={value => update (index, 'duration', value)}/>
|
<EditableValue label="動画時間" value={String (row.attributes.duration ?? '')} origin={row.provenance.duration} onCommit={value => update (index, 'duration', value)}/>
|
||||||
<EditableValue label="タグ" value={row.attributes.tags ?? ''} origin={row.provenance.tags} onCommit={value => update (index, 'tags', value)}/>
|
<EditableValue label="タグ" value={row.attributes.tags ?? ''} origin={row.provenance.tags} onCommit={value => update (index, 'tags', value)}/>
|
||||||
<EditableValue label="親投稿" value={row.attributes.parentPostIds ?? ''} origin={row.provenance.parentPostIds} onCommit={value => update (index, 'parentPostIds', value)}/>
|
<EditableValue label="親投稿" value={row.attributes.parentPostIds ?? ''} origin={row.provenance.parentPostIds} onCommit={value => update (index, 'parentPostIds', value)}/>
|
||||||
|
</>}
|
||||||
<FieldError messages={[...row.warnings, ...Object.values (row.errors).flat ()]}/>
|
<FieldError messages={[...row.warnings, ...Object.values (row.errors).flat ()]}/>
|
||||||
</article>)
|
</article>)
|
||||||
|
|
||||||
@@ -324,4 +330,14 @@ const EditableValue = ({ label, value, origin, onCommit }: { label: string, valu
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ThumbnailPreview = ({ url }: { url: string }) => {
|
||||||
|
const [failed, setFailed] = useState (false)
|
||||||
|
useEffect (() => setFailed (false), [url])
|
||||||
|
if (!url)
|
||||||
|
return <p className="text-sm">サムネール未指定</p>
|
||||||
|
if (failed)
|
||||||
|
return <p className="text-sm text-red-600 dark:text-red-300">サムネールを表示できません</p>
|
||||||
|
return <img src={url} alt="サムネール" className="max-h-32" onError={() => setFailed (true)}/>
|
||||||
|
}
|
||||||
|
|
||||||
export default PostImportPage
|
export default PostImportPage
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする