このコミットが含まれているのは:
2026-07-11 23:08:03 +09:00
コミット cb33d9ca25
2個のファイルの変更41行の追加10行の削除
+9 -1
ファイルの表示
@@ -17,9 +17,17 @@ class PostImportRunner
private private
def run_row row 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 } 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, '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'] attributes['url'] = row['url']
post = PostCreator.new(actor: @actor, attributes:).create! post = PostCreator.new(actor: @actor, attributes:).create!
{ source_row: row['source_row'], status: 'created', post: PostRepr.base(post) } { source_row: row['source_row'], status: 'created', post: PostRepr.base(post) }
+32 -9
ファイルの表示
@@ -2,6 +2,7 @@ import { useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import FieldError from '@/components/common/FieldError' import FieldError from '@/components/common/FieldError'
import PrefetchLink from '@/components/PrefetchLink'
import Form from '@/components/common/Form' import Form from '@/components/common/Form'
import FormField from '@/components/common/FormField' import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
@@ -51,6 +52,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 [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)
@@ -176,11 +178,12 @@ const PostImportPage: FC<Props> = ({ user }) => {
setLoading (true) setLoading (true)
try try
{ {
const result = await apiPost<{ created: number, skipped: number, failed: number }> ('/posts/import', const result = await apiPost<{ created: number, skipped: number, failed: number,
{ rows: targets }) rows: { sourceRow: number, status: string, post?: { id: number }, errors?: Record<string, string[]> }[] }> ('/posts/import', { rows: targets })
toast ({ title: `登録完了: ${result.created}`, toast ({ title: `登録完了: ${result.created}`,
description: `スキップ ${result.skipped} 件、失敗 ${result.failed}` }) description: `スキップ ${result.skipped} 件、失敗 ${result.failed}` })
setPhase ('result') setPhase ('result')
setResults (result.rows)
} }
catch catch
{ {
@@ -270,13 +273,12 @@ const PostImportPage: FC<Props> = ({ user }) => {
</select></label>)} </select></label>)}
<Button type="button" variant="outline" onClick={preview} disabled={loading}></Button> <Button type="button" variant="outline" onClick={preview} disabled={loading}></Button>
</div> </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="grid gap-3 md:grid-cols-2">{rows.map ((row, index) => <RowCard key={row.sourceRow} row={row} index={index} update={updateAttribute} updateURL={updateURL}/>)}</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>
<Button type="button" variant="outline" onClick={() => setPhase ('mapping')}></Button> <Button type="button" variant="outline" onClick={() => setPhase ('mapping')}></Button>
<Button type="button" onClick={submit} disabled={loading}>稿</Button> <Button type="button" onClick={submit} disabled={loading}>稿</Button>
</div>) : ( </div>) : (
<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>)}
<Button type="button" onClick={() => setPhase ('preview')}></Button> <Button type="button" onClick={() => setPhase ('preview')}></Button>
<Button type="button" variant="outline" onClick={() => setPhase ('source')}></Button> <Button type="button" variant="outline" onClick={() => setPhase ('source')}></Button>
</div>)} </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"> <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)}/> <p> {row.sourceRow}{row.status}</p>
<label><input value={row.attributes.title ?? ''} onChange={ev => update (index, 'title', ev.target.value)} className={inputClass (false)}/></label> <EditableValue label="URL" value={row.url} origin={row.provenance.url} onCommit={value => updateURL (index, value)}/>
<label><input value={row.attributes.tags ?? ''} onChange={ev => update (index, 'tags', ev.target.value)} className={inputClass (false)}/></label> <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 ()]}/> <FieldError messages={[...row.warnings, ...Object.values (row.errors).flat ()]}/>
</article>) </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 export default PostImportPage