import { useEffect, useMemo, useRef, useState } from 'react' import { Helmet } from 'react-helmet-async' import { useNavigate, useParams, useSearchParams } from 'react-router-dom' import PageTitle from '@/components/common/PageTitle' import MainArea from '@/components/layout/MainArea' import PostImportRowForm, { buildDraft } 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, creatableImportRows, initialisePreviewRows, mergeImportResults, mergeValidatedImportRows, processableImportRows, 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 [searchParams, setSearchParams] = useSearchParams () const [session, setSession] = useState (null) const [loading, setLoading] = useState (false) const [missing, setMissing] = useState (false) const [savingRow, setSavingRow] = useState (null) const [dialogueMessageRow, setDialogueMessageRow] = useState (null) const [dialogueDraft, setDialogueDraft] = useState (null) const [dialogueResetRequested, setDialogueResetRequested] = useState (false) const dialogueDraftRef = useRef (null) const dialogueResetRequestedRef = useRef (false) 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 (() => { dialogueDraftRef.current = dialogueDraft }, [dialogueDraft]) useEffect (() => { dialogueResetRequestedRef.current = dialogueResetRequested }, [dialogueResetRequested]) const rows = session?.rows ?? [] const editingSourceRow = Number (searchParams.get ('edit') ?? '') const editingRow = Number.isFinite (editingSourceRow) ? rows.find (_1 => _1.sourceRow === editingSourceRow) ?? null : null 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]) useEffect (() => { if (editingRow == null) { setDialogueMessageRow (null) setDialogueDraft (null) setDialogueResetRequested (false) return } if (dialogueMessageRow?.sourceRow !== editingRow.sourceRow) setDialogueMessageRow (null) setDialogueDraft (current => current != null ? current : buildDraft (editingRow)) setDialogueResetRequested (false) }, [editingRow, dialogueMessageRow]) const updateSessionRows = (nextRows: PostImportRow[]) => setSession (current => current != null ? { ...current, rows: nextRows } : current) const saveDraft = async ( { draft, resetRequested, resetSnapshot }: { draft: PostImportRowDraft resetRequested: boolean resetSnapshot: PostImportRow['resetSnapshot'] }, ): Promise<{ saved: boolean row: PostImportRow | null }> => { if (session == null) return { saved: false, row: null } if (editingRow == null) return { saved: false, row: null } const baseRow = resetRequested ? { ...editingRow, url: resetSnapshot.url, attributes: { ...resetSnapshot.attributes }, provenance: { ...resetSnapshot.provenance }, tagSources: { ...resetSnapshot.tagSources }, fieldWarnings: Object.fromEntries ( Object.entries (resetSnapshot.fieldWarnings) .map (([key, values]) => [key, [...values]])), baseWarnings: [...resetSnapshot.baseWarnings], metadataUrl: resetSnapshot.metadataUrl } : editingRow const urlChanged = draft.url !== baseRow.url const nextRow = buildNextEditedRow (baseRow, draft, urlChanged) setSavingRow (editingRow.sourceRow) const nextRows = session.rows.map (row => row.sourceRow === editingRow.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 ? editingRow.sourceRow : -1 }) const validatedRows = initialisePreviewRows (validated.rows) const target = validatedRows.find (row => row.sourceRow === editingRow.sourceRow) if (target != null && Object.keys (target.validationErrors).length > 0) { setDialogueMessageRow (target) return { saved: false, row: target } } updateSessionRows (mergeValidatedImportRows (nextRows, validatedRows)) setDialogueMessageRow (null) setSearchParams ({ }) return { saved: true, row: null } } catch { toast ({ title: '行の再検証に失敗しました' }) return { saved: false, row: null } } finally { setSavingRow (null) } } const openEditingDialogue = async (row: PostImportRow) => { let currentRow = row while (true) { const initialDraft = buildDraft (currentRow) setDialogueDraft (current => current != null ? current : initialDraft) setDialogueResetRequested (false) const action = await dialogue.choice ({ title: '投稿を編輯', description: , cancelText: '取消', choices: [{ value: 'save', label: '編輯内容を保存' }] as const }) if (action !== 'save') { setSearchParams ({ }) return } const draft = dialogueDraftRef.current ?? initialDraft const result = await saveDraft ({ draft, resetRequested: dialogueResetRequestedRef.current, resetSnapshot: currentRow.resetSnapshot }) if (result.saved) return currentRow = result.row ?? currentRow } } useEffect (() => { if (editingRow == null) return void openEditingDialogue (editingRow) }, [editingRow]) const submit = async () => { if (sessionId == null || session == null || processable.length === 0) return setLoading (true) try { const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', { rows: session.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 mergedRows = mergeValidatedImportRows (session.rows, validatedRows) const firstInvalid = mergedRows.find (row => Object.keys (row.validationErrors).length > 0) if (firstInvalid != null) { setSession ({ ...session, rows: mergedRows }) setDialogueMessageRow (firstInvalid) setSearchParams ({ edit: String (firstInvalid.sourceRow) }) 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 mergedResults = mergeImportResults (mergedRows, result.rows) const recoverableRows = result.rows.filter (row => row.status === 'failed' && row.recoverable && Object.keys (row.errors ?? { }).length > 0) const nextRows = mergedResults.map (row => { const recoverable = recoverableRows.find (rr => rr.sourceRow === row.sourceRow) if (recoverable == null) return row return { ...row, importStatus: 'pending', validationErrors: recoverable.errors ?? { }, importErrors: undefined } }) const firstRecoverable = nextRows.find (row => Object.keys (row.validationErrors).length > 0 && row.importStatus !== 'created') if (firstRecoverable != null) { const repairSession = { ...session, rows: nextRows, repairMode: 'failed' as const } setSession (repairSession) setDialogueMessageRow (firstRecoverable) const saved = savePostImportSession (sessionId, repairSession, message => toast ({ title: '取込状態を保存できませんでした', description: message })) if (saved) setSearchParams ({ edit: String (firstRecoverable.sourceRow) }) return } const nextSession = { ...session, rows: nextRows, repairMode: 'all' as const } 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 => (
{ setSearchParams ({ edit: String (row.sourceRow) }) }}/>
))}
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