このコミットが含まれているのは:
2026-07-12 12:43:31 +09:00
コミット 769966648b
4個のファイルの変更354行の追加209行の削除
+166 -21
ファイルの表示
@@ -61,6 +61,7 @@ type ImportResultRow = {
errors?: Record<string, string[]> }
type Mapping = { columns: string[] }
type RepairMode = 'all' | 'failed'
const MAPPING_FIELDS = [
'title',
@@ -72,6 +73,7 @@ const MAPPING_FIELDS = [
'parent_post_ids'] as const
const RESULT_STATUSES: ImportResultStatus[] = ['created', 'skipped', 'failed']
const MERGE_FIELDS = ['url', 'attributes', 'provenance', 'tagSources'] as const
const ORIGIN_LABELS: Record<Origin, string> = {
automatic: '自動取得',
@@ -116,6 +118,67 @@ const mergeValidatedRows = (
!(current.some (_1 => _1.sourceRow === row.sourceRow))))
const mergePreviewRows = (
current: ImportRow[],
previewed: ImportRow[],
): ImportRow[] => {
const currentMap = new Map (current.map (row => [row.sourceRow, row]))
const mergedRows = previewed.map (previewedRow => {
const previous = currentMap.get (previewedRow.sourceRow)
if (previous == null)
return previewedRow
if (previous.importStatus === 'created')
return previous
const merged = {
...previewedRow,
attributes: { ...previewedRow.attributes },
provenance: { ...previewedRow.provenance },
tagSources: { ...(previewedRow.tagSources ?? { }) } }
Object.entries (previous.provenance).forEach (([field, origin]) => {
if (origin !== 'manual')
return
if (field === 'url')
{
merged.url = previous.url
merged.metadataUrl = previous.metadataUrl
merged.provenance.url = 'manual'
return
}
if (field === 'tags')
{
merged.attributes.tags = previous.attributes.tags ?? ''
merged.provenance.tags = 'manual'
merged.tagSources = {
...merged.tagSources,
manual: previous.tagSources?.manual ?? previous.attributes.tags ?? '' }
return
}
merged.attributes[field] = previous.attributes[field] ?? ''
merged.provenance[field] = 'manual'
})
const changed = MERGE_FIELDS.some (field =>
JSON.stringify (previous[field]) !== JSON.stringify (merged[field]))
return {
...merged,
importStatus: changed ? 'pending' : previous.importStatus,
createdPostId: previous.createdPostId,
importErrors: changed ? undefined : previous.importErrors }
})
return mergedRows.concat (
current.filter (row =>
row.importStatus === 'created'
&& !(previewed.some (_1 => _1.sourceRow === row.sourceRow))))
}
const rowMessages = (row: ImportRow): string[] => [
...row.warnings,
...Object.values (row.validationErrors ?? { }).flat (),
@@ -159,12 +222,13 @@ 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<ImportResultRow[]> ([])
const [mappings, setMappings] = useState<Record<string, Mapping>> ({ })
const [existing, setExisting] = useState<'skip' | 'error'> ('skip')
const [loading, setLoading] = useState (false)
const [repairMode, setRepairMode] = useState<RepairMode> ('all')
const validationGeneration = useRef (0)
const rowRefs = useRef<Record<number, HTMLElement | null>> ({ })
const parseSource = async () => {
setLoading (true)
@@ -180,7 +244,7 @@ const PostImportPage: FC<Props> = ({ user }) => {
setParsed (data)
setMappings ({ })
setRows ([])
setResults ([])
setRepairMode ('all')
if (data.columns.length === 1)
{
setUrlColumn ('0')
@@ -220,8 +284,10 @@ const PostImportPage: FC<Props> = ({ user }) => {
url_column: urlColumn,
mappings,
existing })
setRows (current => mergeValidatedRows (current, data.rows))
const mergedRows = mergePreviewRows (rows, data.rows)
setRows (mergedRows)
setPhase ('preview')
await validateRows (mergedRows, -1)
}
catch (error)
{
@@ -364,8 +430,8 @@ const PostImportPage: FC<Props> = ({ user }) => {
toast ({
title: `登録完了: ${ result.created }`,
description: `スキップ ${ result.skipped } 件、失敗 ${ result.failed }` })
setRepairMode ('all')
setPhase ('result')
setResults (result.rows)
setRows (current => current.map (row => {
const resultRow = result.rows.find (_1 => _1.sourceRow === row.sourceRow)
return resultRow
@@ -393,6 +459,42 @@ const PostImportPage: FC<Props> = ({ user }) => {
...current,
[field]: { columns: value === '' ? [] : [value] } }))
const retryRow = (sourceRow: number) =>
setRows (current => current.map (row =>
row.sourceRow === sourceRow && row.importStatus === 'failed'
? { ...row, importStatus: 'pending', importErrors: undefined }
: row))
const retryRowFromResult = (sourceRow: number) => {
retryRow (sourceRow)
setRepairMode ('failed')
setPhase ('preview')
}
const resultRows = rows.filter (row =>
row.importStatus != null && RESULT_STATUSES.includes (row.importStatus))
const failedCount = rows.filter (row => row.importStatus === 'failed').length
const previewRows =
repairMode === 'failed'
? rows.filter (row =>
row.importStatus == null
|| row.importStatus === 'pending'
|| row.importStatus === 'failed')
: rows
useEffect (() => {
if (phase !== 'preview' || repairMode !== 'failed')
return
const failedRow = previewRows.find (row => row.importStatus === 'failed')
if (failedRow == null)
return
rowRefs.current[failedRow.sourceRow]?.scrollIntoView ({
block: 'start',
behaviour: 'smooth' })
}, [phase, previewRows, repairMode])
if (!(editable))
return <Forbidden/>
@@ -587,19 +689,30 @@ const PostImportPage: FC<Props> = ({ user }) => {
</Button>
</div>
{repairMode === 'failed' && (
<p className="text-sm text-neutral-600 dark:text-neutral-300">
{failedCount}
</p>)}
<div className="grid gap-3 md:grid-cols-2">
{rows.map ((row, index) => (
{previewRows.map (row => (
<RowCard
key={row.sourceRow}
row={row}
index={index}
index={rows.findIndex (_1 => _1.sourceRow === row.sourceRow)}
setRef={node => {
rowRefs.current[row.sourceRow] = node
}}
update={updateAttribute}
updateURL={updateURL}/>))}
updateURL={updateURL}
retryRow={retryRow}/>))}
</div>
<Button
type="button"
variant="outline"
onClick={() => setPhase ('mapping')}>
onClick={() => {
setRepairMode ('all')
setPhase ('mapping')
}}>
</Button>
<Button
@@ -612,32 +725,54 @@ const PostImportPage: FC<Props> = ({ user }) => {
: (
<div className="space-y-3">
<p></p>
{results.map (result => (
{resultRows.map (row => (
<div
key={result.sourceRow}
key={row.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 ()}/>
{row.sourceRow}{row.importStatus}
{row.createdPostId && (
<>
{' '}
<PrefetchLink to={`/posts/${ row.createdPostId }`}>
稿
</PrefetchLink>
</>)}
{row.importStatus === 'failed' && (
<>
{' '}
<Button
type="button"
variant="outline"
onClick={() => retryRowFromResult (row.sourceRow)}>
</Button>
</>)}
<FieldError messages={Object.values (row.importErrors ?? { }).flat ()}/>
</div>))}
<Button
type="button"
onClick={() => setPhase ('preview')}>
onClick={() => {
setRepairMode ('all')
setPhase ('preview')
}}>
</Button>
<Button
type="button"
variant="outline"
onClick={() => setPhase ('preview')}>
onClick={() => {
setRepairMode ('failed')
setPhase ('preview')
}}>
</Button>
<Button
type="button"
variant="outline"
onClick={() => setPhase ('source')}>
onClick={() => {
setRepairMode ('all')
setPhase ('source')
}}>
</Button>
</div>)}
@@ -646,13 +781,16 @@ const PostImportPage: FC<Props> = ({ user }) => {
const RowCard = (
{ row, index, update, updateURL }: {
{ row, index, setRef, update, updateURL, retryRow }: {
row: ImportRow
index: number
setRef: (node: HTMLElement | null) => void
update: (index: number, field: string, value: string) => void
updateURL: (index: number, value: string) => void },
updateURL: (index: number, value: string) => void
retryRow: (sourceRow: number) => void },
) => (
<article
ref={setRef}
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>
@@ -710,6 +848,13 @@ const RowCard = (
value={row.attributes.parentPostIds ?? ''}
origin={row.provenance.parentPostIds}
onCommit={value => update (index, 'parentPostIds', value)}/>
{row.importStatus === 'failed' && (
<Button
type="button"
variant="outline"
onClick={() => retryRow (row.sourceRow)}>
</Button>)}
</>)}
<FieldError messages={rowMessages (row)}/>
</article>)