このコミットが含まれているのは:
2026-07-12 13:14:36 +09:00
コミット df17f20907
9個のファイルの変更304行の追加942行の削除
+142 -475
ファイルの表示
@@ -21,28 +21,17 @@ import type { User } from '@/types'
type Props = { user: User | null }
type Column = {
key: string
label: string }
type ParsedRow = {
sourceRow: number
values: string[] }
type ParsedImport = {
columns: Column[]
rows: ParsedRow[] }
type Origin = 'automatic' | 'mapped' | 'fixed' | 'manual'
type Origin = 'automatic' | 'manual'
type Phase = 'source' | 'preview' | 'result'
type RepairMode = 'all' | 'failed'
type ImportStatus = 'pending' | 'created' | 'skipped' | 'failed'
type ImportResultStatus = 'created' | 'skipped' | 'failed'
type Phase = 'source' | 'mapping' | 'preview' | 'result'
type AttributeValue = string | number
type ImportRow = {
sourceRow: number
url: string
attributes: Record<string, string>
attributes: Record<string, AttributeValue>
warnings: string[]
validationErrors: Record<string, string[]>
importErrors?: Record<string, string[]>
@@ -60,41 +49,13 @@ type ImportResultRow = {
post?: { id: number }
errors?: Record<string, string[]> }
type Mapping = { columns: string[] }
type RepairMode = 'all' | 'failed'
const MAPPING_FIELDS = [
'title',
'thumbnail_base',
'original_created_from',
'original_created_before',
'duration',
'tags',
'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: '自動取得',
mapped: '入力データ',
fixed: '固定値',
manual: '手修正' }
const detectFormat = (source: string): 'csv' | 'tsv' =>
source.includes ('\t')
? 'tsv'
: source.includes (',')
? 'csv'
: 'tsv'
const publicGoogleSheetURL = (source: string): boolean =>
/^https:\/\/docs\.google\.com\/spreadsheets\/d\/e\/[A-Za-z0-9_-]+\/pubhtml#gid=\d+$/.test (
source.trim ())
const mergeValidatedRows = (
current: ImportRow[],
validated: ImportRow[],
@@ -118,111 +79,18 @@ 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 (),
...Object.values (row.importErrors ?? { }).flat ()]
const sampleURLCount = (rows: ParsedRow[], index: number): number =>
rows.filter (row => /^https?:\/\//.test (row.values[index] ?? '')).length
const renderMappingSelect = (
columns: Column[],
field: string,
value: string,
update: (field: string, value: string) => void,
) => (
<label key={field}>
{field}
<select
value={value}
onChange={ev => update (field, ev.target.value)}>
<option value=""></option>
{columns.map ((column, index) => (
<option key={column.key} value={index}>
{column.label}
</option>))}
</select>
</label>)
const PostImportPage: FC<Props> = ({ user }) => {
const editable = canEditContent (user)
const [source, setSource] = useState ('')
const [phase, setPhase] = useState<Phase> ('source')
const [format, setFormat] =
useState<'csv' | 'tsv' | 'json' | 'google_sheets'> ('tsv')
const [hasHeader, setHasHeader] = useState (false)
const [jsonPath, setJsonPath] = useState ('')
const [urlColumn, setUrlColumn] = useState ('')
const [parsed, setParsed] = useState<ParsedImport | null> (null)
const [columns, setColumns] = useState<Column[]> ([])
const [rows, setRows] = useState<ImportRow[]> ([])
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')
@@ -230,64 +98,16 @@ const PostImportPage: FC<Props> = ({ user }) => {
const validationGeneration = useRef (0)
const rowRefs = useRef<Record<number, HTMLElement | null>> ({ })
const parseSource = async () => {
setLoading (true)
try
{
const data = await apiPost<ParsedImport> ('/posts/import/parse', {
source,
format,
has_header: hasHeader,
json_path: jsonPath,
existing })
setColumns (data.columns)
setParsed (data)
setMappings ({ })
setRows ([])
setRepairMode ('all')
if (data.columns.length === 1)
{
setUrlColumn ('0')
}
else
{
const candidate = data.columns
.map ((_, index) => ({
index,
count: sampleURLCount (data.rows, index) }))
.sort ((a, b) => b.count - a.count)[0]
setUrlColumn (candidate?.count ? String (candidate.index) : '')
}
setPhase ('mapping')
}
catch
{
toast ({
title: '読込みに失敗しました',
description: '入力形式を確認してください.' })
}
finally
{
setLoading (false)
}
}
const preview = async () => {
if (!parsed || urlColumn === '')
return
setLoading (true)
try
{
const data = await apiPost<{ rows: ImportRow[] }> ('/posts/import/preview', {
parsed: { columns: parsed.columns, rows: parsed.rows },
url_column: urlColumn,
mappings,
source,
existing })
const mergedRows = mergePreviewRows (rows, data.rows)
setRows (mergedRows)
setRows (data.rows)
setRepairMode ('all')
setPhase ('preview')
await validateRows (mergedRows, -1)
}
catch (error)
{
@@ -297,7 +117,7 @@ const PostImportPage: FC<Props> = ({ user }) => {
?? error.response?.data?.baseErrors?.[0]
: undefined
toast ({
title: '投稿プレビューに失敗しました',
title: '投稿情報の取得に失敗しました',
description: message ?? '入力を確認してください.' })
}
finally
@@ -309,6 +129,7 @@ const PostImportPage: FC<Props> = ({ user }) => {
const validateRows = async (
nextRows: ImportRow[],
changedRow: number,
existingValue = existing,
) => {
const generation = ++validationGeneration.current
setLoading (true)
@@ -316,7 +137,7 @@ const PostImportPage: FC<Props> = ({ user }) => {
{
const validated = await apiPost<{ rows: ImportRow[] }> ('/posts/import/validate', {
rows: nextRows.filter (row => row.importStatus !== 'created'),
existing,
existing: existingValue,
changed_row: changedRow })
if (generation === validationGeneration.current)
setRows (current => mergeValidatedRows (current, validated.rows))
@@ -337,7 +158,7 @@ const PostImportPage: FC<Props> = ({ user }) => {
const next = {
...row,
attributes: { ...row.attributes, [field]: value },
provenance: { ...row.provenance, [field]: 'manual' },
provenance: { ...row.provenance, [field]: 'manual' as const },
importStatus: 'pending' as const,
importErrors: undefined }
const nextRows = rows.map ((currentRow, rowIndex) =>
@@ -358,7 +179,7 @@ const PostImportPage: FC<Props> = ({ user }) => {
const next = {
...row,
url: value,
provenance: { ...row.provenance, url: 'manual' },
provenance: { ...row.provenance, url: 'manual' as const },
importStatus: 'pending' as const,
importErrors: undefined }
const nextRows = rows.map ((currentRow, rowIndex) =>
@@ -386,22 +207,26 @@ const PostImportPage: FC<Props> = ({ user }) => {
setRows (pendingRows)
try
{
const generation = ++validationGeneration.current
setLoading (true)
const validated = await apiPost<{ rows: ImportRow[] }> ('/posts/import/validate', {
rows: pendingRows.filter (row => row.importStatus !== 'created'),
existing: value,
changed_row: -1 })
if (generation === validationGeneration.current)
setRows (current => mergeValidatedRows (current, validated.rows))
await validateRows (pendingRows, -1, value)
}
finally
catch
{
if (validationGeneration.current > 0)
setLoading (false)
toast ({ title: '既存投稿の再検証に失敗しました' })
}
}
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 submit = async () => {
const targets = rows.filter (row =>
row.importStatus == null || row.importStatus === 'pending')
@@ -454,23 +279,6 @@ const PostImportPage: FC<Props> = ({ user }) => {
}
}
const updateMapping = (field: string, value: string) =>
setMappings (current => ({
...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
@@ -508,274 +316,133 @@ const PostImportPage: FC<Props> = ({ user }) => {
? (
<Form>
<p className="text-sm text-neutral-600 dark:text-neutral-300">
URL
CSVTSVJSON
URL
</p>
{publicGoogleSheetURL (source) && (
<fieldset className="space-y-1 rounded border p-3 dark:border-neutral-700">
<legend> Google URL</legend>
<label className="mr-3">
<input
type="radio"
checked={format === 'google_sheets'}
onChange={() => setFormat ('google_sheets')}/>
</label>
<label>
<input
type="radio"
checked={format !== 'google_sheets'}
onChange={() => setFormat ('tsv')}/>
URL 稿
</label>
</fieldset>)}
<FormField label="入力形式">
{() => (
<select
value={format}
onChange={ev => setFormat (ev.target.value as typeof format)}
className={inputClass (false)}>
<option value="tsv">TSV</option>
<option value="csv">CSV</option>
<option value="json">JSON</option>
<option value="google_sheets">
Google
</option>
</select>)}
</FormField>
<FormField label="入力データ">
<FormField label="URL リスト">
{() => (
<textarea
value={source}
rows={12}
className={inputClass (false)}
onChange={ev => {
setSource (ev.target.value)
if (format !== 'json' && format !== 'google_sheets')
setFormat (detectFormat (ev.target.value))
}}/>)}
onChange={ev => setSource (ev.target.value)}/>)}
</FormField>
<input
type="file"
accept=".txt,.csv,.tsv,.json"
onChange={ev => {
const file = ev.target.files?.[0]
if (file)
{
file.text ().then (text => {
setSource (text)
setFormat (
file.name.endsWith ('.json')
? 'json'
: detectFormat (text))
})
}
}}/>
{format !== 'json' && (
<label className="flex gap-2">
<input
type="checkbox"
checked={hasHeader}
onChange={ev => setHasHeader (ev.target.checked)}/>
使
</label>)}
{format === 'json' && (
<FormField label="投稿候補の位置(例: data.posts)">
{() => (
<input
value={jsonPath}
onChange={ev => setJsonPath (ev.target.value)}
className={inputClass (false)}/>)}
</FormField>)}
{format === 'google_sheets' && (
<p className="text-sm text-neutral-600 dark:text-neutral-300">
URL 使CSV
</p>)}
<label>
稿
<select
value={existing}
onChange={ev => setExisting (ev.target.value as typeof existing)}>
<option value="skip"></option>
<option value="error"></option>
</select>
</label>
<Button
type="button"
onClick={parseSource}
disabled={loading || !(source)}>
onClick={preview}
disabled={loading || !(source.trim ())}>
稿
</Button>
</Form>)
: phase === 'mapping' && parsed
: phase === 'preview'
? (
<Form>
<PageTitle></PageTitle>
<p>URL 稿</p>
<label>
URL
<select
value={urlColumn}
onChange={ev => setUrlColumn (ev.target.value)}>
<option value=""></option>
{columns.map ((column, index) => (
<option key={column.key} value={index}>
{column.label}
</option>))}
</select>
</label>
{MAPPING_FIELDS.map (field =>
renderMappingSelect (columns,
field,
mappings[field]?.columns?.[0] ?? '',
updateMapping))}
<div className="overflow-x-auto">
<table className="text-sm">
<thead>
<tr>
{columns.map (column => (
<th key={column.key}>{column.label}</th>))}
</tr>
</thead>
<tbody>
{parsed.rows.slice (0, 5).map (row => (
<tr key={row.sourceRow}>
{row.values.map ((value, index) => (
<td key={index}>{value}</td>))}
</tr>))}
</tbody>
</table>
<div className="space-y-4">
<div className="flex flex-wrap gap-3 rounded border p-3 dark:border-neutral-700">
<label>
稿
<select
value={existing}
onChange={ev => updateExisting (ev.target.value as typeof existing)}>
<option value="skip"></option>
<option value="error"></option>
</select>
</label>
</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">
{previewRows.map (row => (
<RowCard
key={row.sourceRow}
row={row}
index={rows.findIndex (_1 => _1.sourceRow === row.sourceRow)}
setRef={node => {
rowRefs.current[row.sourceRow] = node
}}
update={updateAttribute}
updateURL={updateURL}
retryRow={retryRow}/>))}
</div>
<Button
type="button"
variant="outline"
onClick={() => setPhase ('source')}>
onClick={() => {
setRepairMode ('all')
setPhase ('source')
}}>
URL
</Button>
<Button
type="button"
onClick={preview}
disabled={loading || urlColumn === ''}>
稿
onClick={submit}
disabled={loading}>
稿
</Button>
</Form>)
: phase === 'preview'
? (
<div className="space-y-4">
<div className="flex flex-wrap gap-3 rounded border p-3 dark:border-neutral-700">
<label>
URL
<select
value={urlColumn}
onChange={ev => setUrlColumn (ev.target.value)}>
{columns.map ((column, index) => (
<option key={column.key} value={index}>
{column.label}
</option>))}
</select>
</label>
<label>
稿
<select
value={existing}
onChange={ev => updateExisting (ev.target.value as typeof existing)}>
<option value="skip"></option>
<option value="error"></option>
</select>
</label>
{MAPPING_FIELDS.map (field =>
renderMappingSelect (columns,
field,
mappings[field]?.columns?.[0] ?? '',
updateMapping))}
<Button
type="button"
variant="outline"
onClick={preview}
disabled={loading}>
</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">
{previewRows.map (row => (
<RowCard
key={row.sourceRow}
row={row}
index={rows.findIndex (_1 => _1.sourceRow === row.sourceRow)}
setRef={node => {
rowRefs.current[row.sourceRow] = node
}}
update={updateAttribute}
updateURL={updateURL}
retryRow={retryRow}/>))}
</div>
<Button
type="button"
variant="outline"
onClick={() => {
setRepairMode ('all')
setPhase ('mapping')
}}>
</Button>
<Button
type="button"
onClick={submit}
disabled={loading}>
稿
</Button>
</div>)
: (
<div className="space-y-3">
<p></p>
{resultRows.map (row => (
<div
key={row.sourceRow}
className="rounded border p-2 dark:border-neutral-700">
{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={() => {
setRepairMode ('all')
setPhase ('preview')
}}>
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
setRepairMode ('failed')
setPhase ('preview')
}}>
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
setRepairMode ('all')
setPhase ('source')
}}>
</Button>
</div>)}
</div>)
: (
<div className="space-y-3">
<p></p>
{resultRows.map (row => (
<div
key={row.sourceRow}
className="rounded border p-2 dark:border-neutral-700">
{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={() => {
setRepairMode ('all')
setPhase ('preview')
}}>
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
setRepairMode ('failed')
setPhase ('preview')
}}>
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
setRepairMode ('all')
setPhase ('source')
}}>
URL
</Button>
</div>)}
</MainArea>)
}
@@ -814,23 +481,23 @@ const RowCard = (
onCommit={value => updateURL (index, value)}/>
<EditableValue
label="タイトル"
value={row.attributes.title ?? ''}
value={String (row.attributes.title ?? '')}
origin={row.provenance.title}
onCommit={value => update (index, 'title', value)}/>
<EditableValue
label="サムネール基底 URL"
value={row.attributes.thumbnailBase ?? ''}
value={String (row.attributes.thumbnailBase ?? '')}
origin={row.provenance.thumbnailBase}
onCommit={value => update (index, 'thumbnailBase', value)}/>
<ThumbnailPreview url={row.attributes.thumbnailBase ?? ''}/>
<ThumbnailPreview url={String (row.attributes.thumbnailBase ?? '')}/>
<EditableValue
label="作成日時 From"
value={row.attributes.originalCreatedFrom ?? ''}
value={String (row.attributes.originalCreatedFrom ?? '')}
origin={row.provenance.originalCreatedFrom}
onCommit={value => update (index, 'originalCreatedFrom', value)}/>
<EditableValue
label="作成日時 Before"
value={row.attributes.originalCreatedBefore ?? ''}
value={String (row.attributes.originalCreatedBefore ?? '')}
origin={row.provenance.originalCreatedBefore}
onCommit={value => update (index, 'originalCreatedBefore', value)}/>
<EditableValue
@@ -840,12 +507,12 @@ const RowCard = (
onCommit={value => update (index, 'duration', value)}/>
<EditableValue
label="タグ"
value={row.attributes.tags ?? ''}
value={String (row.attributes.tags ?? '')}
origin={row.provenance.tags}
onCommit={value => update (index, 'tags', value)}/>
<EditableValue
label="親投稿"
value={row.attributes.parentPostIds ?? ''}
value={String (row.attributes.parentPostIds ?? '')}
origin={row.provenance.parentPostIds}
onCommit={value => update (index, 'parentPostIds', value)}/>
{row.importStatus === 'failed' && (