463 行
15 KiB
TypeScript
463 行
15 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react'
|
||
import { Helmet } from 'react-helmet-async'
|
||
import { useNavigate, useParams } from 'react-router-dom'
|
||
|
||
import PageTitle from '@/components/common/PageTitle'
|
||
import MainArea from '@/components/layout/MainArea'
|
||
import PostImportRowForm from '@/components/posts/import/PostImportRowForm'
|
||
import PostImportRowSummary from '@/components/posts/import/PostImportRowSummary'
|
||
import { Button } from '@/components/ui/button'
|
||
import { toast } from '@/components/ui/use-toast'
|
||
import { SITE_TITLE } from '@/config'
|
||
import { apiPost } from '@/lib/api'
|
||
import useDialogue from '@/lib/dialogues/useDialogue'
|
||
import { canEditContent } from '@/lib/users'
|
||
import { loadPostImportSession,
|
||
canEditReviewRow,
|
||
creatableImportRows,
|
||
hasExactSourceRows,
|
||
initialisePreviewRows,
|
||
mergeImportResults,
|
||
mergeValidatedImportRow,
|
||
mergeValidatedImportRows,
|
||
processableImportRows,
|
||
replaceImportRow,
|
||
resultRepairMode,
|
||
reviewSummaryCounts,
|
||
savePostImportSession } from '@/lib/postImportSession'
|
||
import Forbidden from '@/pages/Forbidden'
|
||
|
||
import type { FC } from 'react'
|
||
|
||
import type { PostImportRowDraft } from '@/components/posts/import/PostImportRowForm'
|
||
import type { PostImportResultRow,
|
||
PostImportRow,
|
||
PostImportSession } from '@/lib/postImportSession'
|
||
import type { User } from '@/types'
|
||
|
||
type Props = { user: User | null }
|
||
|
||
|
||
const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||
const editable = canEditContent (user)
|
||
const dialogue = useDialogue ()
|
||
const navigate = useNavigate ()
|
||
const { sessionId } = useParams ()
|
||
|
||
const [session, setSession] = useState<PostImportSession | null> (null)
|
||
const [loading, setLoading] = useState (false)
|
||
const [missing, setMissing] = useState (false)
|
||
const [editingRow, setEditingRow] = useState<PostImportRow | null> (null)
|
||
const sessionRef = useRef<PostImportSession | null> (null)
|
||
|
||
useEffect (() => {
|
||
if (sessionId == null)
|
||
return
|
||
|
||
const loaded = loadPostImportSession (sessionId, message =>
|
||
toast ({ title: '取込状態を復元できませんでした', description: message }))
|
||
setSession (loaded)
|
||
setMissing (loaded == null)
|
||
}, [sessionId])
|
||
|
||
useEffect (() => {
|
||
if (sessionId == null || session == null)
|
||
return
|
||
|
||
savePostImportSession (sessionId, session, message =>
|
||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||
}, [session, sessionId])
|
||
|
||
useEffect (() => {
|
||
sessionRef.current = session
|
||
}, [session])
|
||
|
||
const rows = session?.rows ?? []
|
||
const counts = useMemo (() => reviewSummaryCounts (rows), [rows])
|
||
const processable = useMemo (
|
||
() => processableImportRows (rows),
|
||
[rows])
|
||
const creatable = useMemo (
|
||
() => creatableImportRows (rows),
|
||
[rows])
|
||
const reviewRows =
|
||
session?.repairMode === 'failed'
|
||
? (
|
||
[...rows].sort ((a, b) => {
|
||
const aFailed = a.importStatus === 'failed' ? 0 : 1
|
||
const bFailed = b.importStatus === 'failed' ? 0 : 1
|
||
return aFailed - bFailed || a.sourceRow - b.sourceRow
|
||
}))
|
||
: rows
|
||
|
||
useEffect (() => {
|
||
if (editingRow == null || session?.repairMode !== 'failed')
|
||
return
|
||
|
||
const element = document.getElementById (`post-import-row-${ editingRow.sourceRow }`)
|
||
element?.scrollIntoView ({ block: 'center', behavior: 'smooth' })
|
||
}, [editingRow, session?.repairMode])
|
||
|
||
const saveDraft = async (
|
||
row: PostImportRow,
|
||
{ draft, resetRequested }: {
|
||
draft: PostImportRowDraft
|
||
resetRequested: boolean },
|
||
): Promise<{ saved: boolean
|
||
row: PostImportRow | null }> => {
|
||
const currentSession = sessionRef.current
|
||
if (currentSession == null)
|
||
return { saved: false, row: null }
|
||
if (!(canEditReviewRow (row)))
|
||
return { saved: false, row: null }
|
||
|
||
const baseRow =
|
||
resetRequested
|
||
? { ...row,
|
||
url: row.resetSnapshot.url,
|
||
attributes: { ...row.resetSnapshot.attributes },
|
||
provenance: { ...row.resetSnapshot.provenance },
|
||
tagSources: { ...row.resetSnapshot.tagSources },
|
||
fieldWarnings: Object.fromEntries (
|
||
Object.entries (row.resetSnapshot.fieldWarnings)
|
||
.map (([key, values]) => [key, [...values]])),
|
||
baseWarnings: [...row.resetSnapshot.baseWarnings],
|
||
metadataUrl: row.resetSnapshot.metadataUrl }
|
||
: row
|
||
const urlChanged = draft.url !== baseRow.url
|
||
const nextRow = buildNextEditedRow (baseRow, draft, urlChanged)
|
||
|
||
const nextRows = currentSession.rows.map (row =>
|
||
row.sourceRow === baseRow.sourceRow
|
||
? nextRow
|
||
: row)
|
||
try
|
||
{
|
||
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
|
||
rows:
|
||
nextRows
|
||
.filter (row => row.importStatus !== 'created')
|
||
.map (row => ({ sourceRow: row.sourceRow,
|
||
url: row.url,
|
||
attributes: row.attributes,
|
||
provenance: row.provenance,
|
||
tagSources: row.tagSources,
|
||
metadataUrl: row.metadataUrl })),
|
||
changed_row: urlChanged ? baseRow.sourceRow : -1 })
|
||
const validatedRows = initialisePreviewRows (validated.rows)
|
||
const target = validatedRows.find (_1 => _1.sourceRow === baseRow.sourceRow)
|
||
const latestSession = sessionRef.current
|
||
if (latestSession == null)
|
||
return { saved: false, row: null }
|
||
if (target == null)
|
||
{
|
||
const restoredRows = replaceImportRow (latestSession.rows, row)
|
||
const restoredSession = {
|
||
...latestSession,
|
||
rows: restoredRows,
|
||
repairMode: resultRepairMode (restoredRows) }
|
||
sessionRef.current = restoredSession
|
||
setSession (restoredSession)
|
||
toast ({ title: '行の再検証結果が不完全でした' })
|
||
return { saved: false, row: null }
|
||
}
|
||
const rows = mergeValidatedImportRow (latestSession.rows, target)
|
||
const nextSession = {
|
||
...latestSession,
|
||
rows,
|
||
repairMode: resultRepairMode (rows) }
|
||
sessionRef.current = nextSession
|
||
setSession (nextSession)
|
||
const mergedTarget = rows.find (_1 => _1.sourceRow === baseRow.sourceRow) ?? target
|
||
if (Object.keys (mergedTarget.validationErrors).length > 0)
|
||
return { saved: false, row: mergedTarget }
|
||
return { saved: true, row: null }
|
||
}
|
||
catch
|
||
{
|
||
toast ({ title: '行の再検証に失敗しました' })
|
||
return { saved: false, row: null }
|
||
}
|
||
}
|
||
|
||
const openEditingDialogue = async (row: PostImportRow) => {
|
||
const saveRowDraft = (
|
||
{ draft, resetRequested }: {
|
||
draft: PostImportRowDraft
|
||
resetRequested: boolean },
|
||
) =>
|
||
saveDraft (row, { draft, resetRequested })
|
||
|
||
await dialogue.form ({
|
||
title: '投稿を編輯',
|
||
description: `投稿 ${ row.sourceRow } の内容を確認し、必要な項目を編輯してください.`,
|
||
cancelText: '取消',
|
||
size: 'large',
|
||
body: controls => (
|
||
<PostImportRowForm
|
||
row={row}
|
||
controls={controls}
|
||
onSave={saveRowDraft}/>) })
|
||
}
|
||
|
||
const editRow = async (row: PostImportRow) => {
|
||
setEditingRow (row)
|
||
try
|
||
{
|
||
await openEditingDialogue (row)
|
||
}
|
||
finally
|
||
{
|
||
setEditingRow (current =>
|
||
current?.sourceRow === row.sourceRow
|
||
? null
|
||
: current)
|
||
}
|
||
}
|
||
|
||
const submit = async () => {
|
||
const currentSession = sessionRef.current
|
||
if (sessionId == null || currentSession == null || processable.length === 0)
|
||
return
|
||
|
||
setLoading (true)
|
||
try
|
||
{
|
||
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
|
||
rows:
|
||
currentSession.rows
|
||
.filter (row => row.importStatus !== 'created')
|
||
.map (row => ({ sourceRow: row.sourceRow,
|
||
url: row.url,
|
||
attributes: row.attributes,
|
||
provenance: row.provenance,
|
||
tagSources: row.tagSources,
|
||
metadataUrl: row.metadataUrl })),
|
||
changed_row: -1 })
|
||
const validatedRows = initialisePreviewRows (validated.rows)
|
||
const expectedSourceRows = currentSession.rows
|
||
.filter (row => row.importStatus !== 'created')
|
||
.map (row => row.sourceRow)
|
||
if (!(hasExactSourceRows (expectedSourceRows, validatedRows)))
|
||
{
|
||
toast ({ title: '再検証結果が不完全でした' })
|
||
return
|
||
}
|
||
const latestAfterValidate = sessionRef.current ?? currentSession
|
||
const mergedRows = mergeValidatedImportRows (latestAfterValidate.rows, validatedRows)
|
||
const firstInvalid = mergedRows.find (row => Object.keys (row.validationErrors).length > 0)
|
||
if (firstInvalid != null)
|
||
{
|
||
const nextSession = {
|
||
...latestAfterValidate,
|
||
rows: mergedRows,
|
||
repairMode: resultRepairMode (mergedRows) }
|
||
sessionRef.current = nextSession
|
||
setSession (nextSession)
|
||
void editRow (firstInvalid)
|
||
return
|
||
}
|
||
const validatedSession = {
|
||
...latestAfterValidate,
|
||
rows: mergedRows,
|
||
repairMode: resultRepairMode (mergedRows) }
|
||
sessionRef.current = validatedSession
|
||
setSession (validatedSession)
|
||
const savedValidated = savePostImportSession (sessionId, validatedSession, message =>
|
||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||
if (!(savedValidated))
|
||
return
|
||
|
||
const result = await apiPost<{
|
||
created: number
|
||
skipped: number
|
||
failed: number
|
||
rows: PostImportResultRow[] }> ('/posts/import', {
|
||
rows: processableImportRows (mergedRows).map (row => ({
|
||
sourceRow: row.sourceRow,
|
||
url: row.url,
|
||
attributes: row.attributes,
|
||
provenance: row.provenance,
|
||
tagSources: row.tagSources,
|
||
metadataUrl: row.metadataUrl })) })
|
||
const expectedImportRows = processableImportRows (mergedRows).map (row => row.sourceRow)
|
||
if (!(hasExactSourceRows (expectedImportRows, result.rows)))
|
||
{
|
||
toast ({ title: '登録結果が不完全でした' })
|
||
return
|
||
}
|
||
const latestAfterImport = sessionRef.current ?? validatedSession
|
||
const mergedResults = mergeImportResults (latestAfterImport.rows, result.rows)
|
||
const recoverableRows = result.rows.filter (row =>
|
||
row.status === 'failed'
|
||
&& row.recoverable
|
||
&& Object.keys (row.errors ?? { }).length > 0)
|
||
const nextRows = mergedResults.map ((row): PostImportRow => {
|
||
const recoverable = recoverableRows.find (rr => rr.sourceRow === row.sourceRow)
|
||
if (recoverable == null)
|
||
return row
|
||
return {
|
||
...row,
|
||
importStatus: 'pending',
|
||
recoverable: true,
|
||
validationErrors: recoverable.errors ?? { },
|
||
importErrors: undefined }
|
||
})
|
||
const nextSession = {
|
||
...latestAfterImport,
|
||
rows: nextRows,
|
||
repairMode: resultRepairMode (nextRows) }
|
||
sessionRef.current = nextSession
|
||
setSession (nextSession)
|
||
const saved = savePostImportSession (sessionId, nextSession, message =>
|
||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||
if (!(saved))
|
||
return
|
||
navigate (`/posts/import/${ sessionId }/result`)
|
||
}
|
||
catch
|
||
{
|
||
toast ({ title: '登録に失敗しました' })
|
||
}
|
||
finally
|
||
{
|
||
setLoading (false)
|
||
}
|
||
}
|
||
|
||
if (!(editable))
|
||
return <Forbidden/>
|
||
|
||
if (missing || sessionId == null || session == null)
|
||
{
|
||
return (
|
||
<MainArea>
|
||
<div className="mx-auto max-w-4xl space-y-4 p-4">
|
||
<PageTitle>投稿インポート</PageTitle>
|
||
<div className="text-red-700 dark:text-red-300">取込状態が見つかりません.</div>
|
||
<Button type="button" onClick={() => navigate ('/posts/import')}>
|
||
URL リスト入力へ戻る
|
||
</Button>
|
||
</div>
|
||
</MainArea>)
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<Helmet>
|
||
<title>{`投稿インポート確認 | ${ SITE_TITLE }`}</title>
|
||
</Helmet>
|
||
|
||
<MainArea className="min-h-0">
|
||
<div className="mx-auto max-w-6xl space-y-4 p-4">
|
||
<PageTitle>投稿情報の確認</PageTitle>
|
||
|
||
<div className="space-y-3">
|
||
{reviewRows.map (row => (
|
||
<div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}>
|
||
<PostImportRowSummary
|
||
row={row}
|
||
editDisabled={loading}
|
||
onEdit={() => void editRow (row)}/>
|
||
</div>))}
|
||
</div>
|
||
</div>
|
||
</MainArea>
|
||
|
||
<PostImportFooter
|
||
loading={loading}
|
||
processableCount={processable.length}
|
||
creatableCount={creatable.length}
|
||
skipPlannedCount={counts.skipPlanned}
|
||
onBack={() => navigate ('/posts/import')}
|
||
onSubmit={submit}/>
|
||
</>)
|
||
}
|
||
|
||
const PostImportFooter = (
|
||
{ loading,
|
||
processableCount,
|
||
creatableCount,
|
||
skipPlannedCount,
|
||
onBack,
|
||
onSubmit }: { loading: boolean
|
||
processableCount: number
|
||
creatableCount: number
|
||
skipPlannedCount: number
|
||
onBack: () => void
|
||
onSubmit: () => void },
|
||
) => (
|
||
<div
|
||
className="shrink-0 border-t bg-white/95 p-4 backdrop-blur
|
||
dark:border-neutral-700 dark:bg-neutral-950/95">
|
||
<div className="mx-auto flex max-w-6xl flex-col gap-3 md:flex-row
|
||
md:items-center md:justify-between">
|
||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||
<span>処理対象 {processableCount}件</span>
|
||
<span>登録対象 {creatableCount}件</span>
|
||
<span>スキップ予定 {skipPlannedCount}件</span>
|
||
</div>
|
||
<div className="flex flex-col gap-2 sm:flex-row">
|
||
<Button type="button" variant="outline" onClick={onBack} disabled={loading}>
|
||
URL リスト入力へ戻る
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
onClick={onSubmit}
|
||
disabled={loading || processableCount === 0}>
|
||
取込実行
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>)
|
||
|
||
const buildNextEditedRow = (
|
||
editingRow: PostImportRow,
|
||
draft: PostImportRowDraft,
|
||
urlChanged: boolean,
|
||
): PostImportRow => {
|
||
const nextProvenance = { ...editingRow.provenance }
|
||
const nextAttributes = { ...editingRow.attributes }
|
||
const nextTagSources = {
|
||
automatic: editingRow.tagSources?.automatic ?? '',
|
||
manual: editingRow.tagSources?.manual ?? '' }
|
||
const draftFields = [
|
||
['title', draft.title],
|
||
['thumbnailBase', draft.thumbnailBase],
|
||
['originalCreatedFrom', draft.originalCreatedFrom],
|
||
['originalCreatedBefore', draft.originalCreatedBefore],
|
||
['duration', draft.duration],
|
||
['parentPostIds', draft.parentPostIds]] as const
|
||
draftFields.forEach (([field, value]) => {
|
||
nextAttributes[field] = value
|
||
nextProvenance[field] =
|
||
value !== String (editingRow.attributes[field] ?? '')
|
||
? 'manual'
|
||
: (editingRow.provenance[field] ?? 'automatic')
|
||
})
|
||
nextAttributes.tags = draft.tags
|
||
if (draft.tags !== String (editingRow.attributes.tags ?? ''))
|
||
{
|
||
nextProvenance.tags = 'manual'
|
||
nextTagSources.manual = draft.tags
|
||
}
|
||
else
|
||
{
|
||
nextProvenance.tags = editingRow.provenance.tags ?? 'automatic'
|
||
nextTagSources.manual = editingRow.tagSources?.manual ?? ''
|
||
}
|
||
|
||
return {
|
||
...editingRow,
|
||
url: draft.url,
|
||
attributes: nextAttributes,
|
||
provenance: {
|
||
...nextProvenance,
|
||
url: urlChanged ? 'manual' : (editingRow.provenance.url ?? 'manual') },
|
||
tagSources: nextTagSources,
|
||
importStatus: editingRow.importStatus === 'created' ? 'created' : 'pending',
|
||
importErrors: undefined }
|
||
}
|
||
|
||
export default PostImportReviewPage
|