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 = ({ user }) => { const editable = canEditContent (user) const dialogue = useDialogue () const navigate = useNavigate () const { sessionId } = useParams () const [session, setSession] = useState (null) const [loading, setLoading] = useState (false) const [missing, setMissing] = useState (false) const [editingRow, setEditingRow] = useState (null) const sessionRef = useRef (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 => ( ) }) } 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 if (missing || sessionId == null || session == null) { return (
投稿インポート
取込状態が見つかりません.
) } return ( <> {`投稿インポート確認 | ${ SITE_TITLE }`}
投稿情報の確認
{reviewRows.map (row => (
void editRow (row)}/>
))}
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 }, ) => (
処理対象 {processableCount}件 登録対象 {creatableCount}件 スキップ予定 {skipPlannedCount}件
) 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