このコミットが含まれているのは:
@@ -17,9 +17,17 @@ class PostImportRunner
|
||||
private
|
||||
|
||||
def run_row row
|
||||
return row.slice('source_row').merge(status: 'skipped') if row['status'] == 'skipped' || row['existing']
|
||||
attributes = row.fetch('attributes', { }).to_h.transform_keys { _1.to_s.underscore }
|
||||
raise ArgumentError, '取込項目が不正です.' unless (attributes.keys - ATTRIBUTE_KEYS).empty?
|
||||
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
|
||||
preview = PostImportPreviewer.new(parsed: { columns: [], rows: [] }, url_column: 0,
|
||||
mappings: {}, existing: 'error').preview_rows(
|
||||
rows: [{ source_row: row['source_row'], url: row['url'],
|
||||
attributes:, provenance: row['provenance'] }],
|
||||
fetch_metadata: false).first
|
||||
return { source_row: row['source_row'], status: 'failed', errors: preview[:errors] } if preview[:errors].present?
|
||||
return row.slice('source_row').merge(status: 'skipped') if row['status'] == 'skipped' || preview[:existing]
|
||||
attributes['url'] = row['url']
|
||||
post = PostCreator.new(actor: @actor, attributes:).create!
|
||||
{ source_row: row['source_row'], status: 'created', post: PostRepr.base(post) }
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useRef, useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
|
||||
import FieldError from '@/components/common/FieldError'
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import Form from '@/components/common/Form'
|
||||
import FormField from '@/components/common/FormField'
|
||||
import PageTitle from '@/components/common/PageTitle'
|
||||
@@ -51,6 +52,7 @@ const PostImportPage: FC<Props> = ({ user }) => {
|
||||
const [parsed, setParsed] = useState<ParsedImport | null> (null)
|
||||
const [columns, setColumns] = useState<Column[]> ([])
|
||||
const [rows, setRows] = useState<ImportRow[]> ([])
|
||||
const [results, setResults] = useState<{ sourceRow: number, status: string, post?: { id: number }, errors?: Record<string, string[]> }[]> ([])
|
||||
const [mappings, setMappings] = useState<Record<string, { columns: string[] }>> ({ })
|
||||
const [existing, setExisting] = useState<'skip' | 'error'> ('skip')
|
||||
const [loading, setLoading] = useState (false)
|
||||
@@ -176,11 +178,12 @@ const PostImportPage: FC<Props> = ({ user }) => {
|
||||
setLoading (true)
|
||||
try
|
||||
{
|
||||
const result = await apiPost<{ created: number, skipped: number, failed: number }> ('/posts/import',
|
||||
{ rows: targets })
|
||||
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 })
|
||||
toast ({ title: `登録完了: ${result.created} 件`,
|
||||
description: `スキップ ${result.skipped} 件、失敗 ${result.failed} 件` })
|
||||
setPhase ('result')
|
||||
setResults (result.rows)
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -270,13 +273,12 @@ const PostImportPage: FC<Props> = ({ user }) => {
|
||||
</select></label>)}
|
||||
<Button type="button" variant="outline" onClick={preview} disabled={loading}>自動取得を更新</Button>
|
||||
</div>
|
||||
<div className="space-y-3 md:hidden">{rows.map ((row, index) => <RowCard key={row.sourceRow} row={row} index={index} update={updateAttribute}/>)}</div>
|
||||
<div className="hidden overflow-x-auto md:block"><table className="w-full text-sm"><thead><tr><th>行</th><th>URL</th><th>タイトル</th><th>サムネール</th><th>日時</th><th>動画時間</th><th>タグ</th><th>親投稿</th><th>状態</th></tr></thead>
|
||||
<tbody>{rows.map ((row, index) => <tr key={row.sourceRow} className="border-t dark:border-neutral-700"><td>{row.sourceRow}</td><td><input value={row.url} onChange={ev => updateURL (index, ev.target.value)}/></td><td><input value={row.attributes.title ?? ''} onChange={ev => updateAttribute (index, 'title', ev.target.value)}/></td><td><input value={row.attributes.thumbnailBase ?? ''} onChange={ev => updateAttribute (index, 'thumbnailBase', ev.target.value)}/></td><td><input value={row.attributes.originalCreatedFrom ?? ''} onChange={ev => updateAttribute (index, 'originalCreatedFrom', ev.target.value)}/><input value={row.attributes.originalCreatedBefore ?? ''} onChange={ev => updateAttribute (index, 'originalCreatedBefore', ev.target.value)}/></td><td><input value={String (row.attributes.duration ?? '')} onChange={ev => updateAttribute (index, 'duration', ev.target.value)}/></td><td><input value={row.attributes.tags ?? ''} onChange={ev => updateAttribute (index, 'tags', ev.target.value)}/></td><td><input value={row.attributes.parentPostIds ?? ''} onChange={ev => updateAttribute (index, 'parentPostIds', ev.target.value)}/></td><td>{row.status}<FieldError messages={[...row.warnings, ...Object.values (row.errors).flat ()]}/></td></tr>)}</tbody></table></div>
|
||||
<div className="grid gap-3 md:grid-cols-2">{rows.map ((row, index) => <RowCard key={row.sourceRow} row={row} index={index} update={updateAttribute} updateURL={updateURL}/>)}</div>
|
||||
<Button type="button" variant="outline" onClick={() => setPhase ('mapping')}>列割当てへ戻る</Button>
|
||||
<Button type="button" onClick={submit} disabled={loading}>有効な投稿を一括登録</Button>
|
||||
</div>) : (
|
||||
<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>)}
|
||||
<Button type="button" onClick={() => setPhase ('preview')}>結果を確認</Button>
|
||||
<Button type="button" variant="outline" onClick={() => setPhase ('source')}>新しい入力元を選ぶ</Button>
|
||||
</div>)}
|
||||
@@ -284,12 +286,33 @@ const PostImportPage: FC<Props> = ({ user }) => {
|
||||
}
|
||||
|
||||
|
||||
const RowCard = ({ row, index, update }: { row: ImportRow, index: number, update: (index: number, field: string, 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">
|
||||
<p>行 {row.sourceRow}・{row.status}</p><input value={row.url} readOnly className={inputClass (false)}/>
|
||||
<label>タイトル<input value={row.attributes.title ?? ''} onChange={ev => update (index, 'title', ev.target.value)} className={inputClass (false)}/></label>
|
||||
<label>タグ<input value={row.attributes.tags ?? ''} onChange={ev => update (index, 'tags', ev.target.value)} className={inputClass (false)}/></label>
|
||||
<p>行 {row.sourceRow}・{row.status}</p>
|
||||
<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="サムネール基底 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 }}/>}
|
||||
<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="動画時間" 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.parentPostIds ?? ''} origin={row.provenance.parentPostIds} onCommit={value => update (index, 'parentPostIds', value)}/>
|
||||
<FieldError messages={[...row.warnings, ...Object.values (row.errors).flat ()]}/>
|
||||
</article>)
|
||||
|
||||
const EditableValue = ({ label, value, origin, onCommit }: { label: string, value: string, origin: string | undefined, onCommit: (value: string) => void }) => {
|
||||
const [editing, setEditing] = useState (false)
|
||||
const [draft, setDraft] = useState (value)
|
||||
const originLabel = { automatic: '自動取得', mapped: '入力データ', fixed: '固定値', manual: '手修正' }[origin ?? 'automatic']
|
||||
const commit = () => { setEditing (false); if (draft !== value) onCommit (draft) }
|
||||
const cancel = () => { setDraft (value); setEditing (false) }
|
||||
return <div className={origin === 'manual' ? 'rounded bg-amber-100 p-1 dark:bg-amber-900' : 'p-1'}>
|
||||
<span className="text-xs">{label}・{originLabel}</span>
|
||||
{editing ? <input autoFocus value={draft} onChange={ev => setDraft (ev.target.value)} onBlur={commit}
|
||||
onKeyDown={ev => { if (ev.key === 'Escape') cancel (); if (ev.key === 'Enter') commit () } } className={inputClass (false)}/>
|
||||
: <button type="button" className="block w-full text-left" onDoubleClick={() => setEditing (true)} onClick={() => setEditing (true)}>{value || '(未指定)'} 編輯</button>}
|
||||
</div>
|
||||
}
|
||||
|
||||
export default PostImportPage
|
||||
|
||||
新しい課題から参照
ユーザをブロックする