import { useQueries } from '@tanstack/react-query' import { AnimatePresence, motion } from 'framer-motion' import { useEffect, useMemo, useRef, useState } from 'react' import { Helmet } from 'react-helmet-async' import { useLocation, useNavigate } from 'react-router-dom' import PageTitle from '@/components/common/PageTitle' import MainArea from '@/components/layout/MainArea' import PrefetchLink from '@/components/PrefetchLink' import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview' 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 { apiGet, apiPost, isApiError } from '@/lib/api' import useDialogue from '@/lib/dialogues/useDialogue' import { fetchPost } from '@/lib/posts' import { appendPostNewSessionId, parsePostNewState, parsePostNewSessionId, serialisePostNewState, } from '@/lib/postNewQueryState' import { buildNextEditedRow, canEditReviewRow, canRetryResultRow, clearPostImportSession, clearPostImportSourceDraft, generatePostImportSessionId, initialisePreviewRows, isCompletedReviewRow, isExistingSkipRow, isNonRecoverableFailedRow, mergeImportResults, processableImportRows, replaceImportRow, resultRepairMode, resultRowMessages, reviewSummaryCounts, retryImportRow, savePostImportSession, loadPostImportSession, } from '@/lib/postImportSession' import { postsKeys } from '@/lib/queryKeys' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { canEditContent } from '@/lib/users' 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 { Post, User } from '@/types' type Props = { user: User | null } type PreviewResponse = { url: string title?: string thumbnailBase?: string originalCreatedFrom?: string originalCreatedBefore?: string duration?: string videoMs?: number tags?: string fieldWarnings?: Record baseWarnings?: string[] existingPost?: { id: number } | null } type BulkApiRow = { status: 'created' | 'skipped' | 'failed' post?: { id: number } existingPost?: { id: number } | null fieldWarnings?: Record baseWarnings?: string[] errors?: Record baseErrors?: string[] recoverable?: boolean } type ExistingSkippedRowsProps = { open: boolean rows: PostImportRow[] } const isRepairRow = (row: PostImportRow): boolean => row.recoverable === true && (row.importStatus === 'failed' || (row.importStatus === 'pending' && Object.keys (row.validationErrors).length > 0)) const buildSession = (rows: PostImportRow[]): PostImportSession => ({ version: 3, expiresAt: '', source: rows.map (row => row.url).join ('\n'), rows, repairMode: resultRepairMode (rows) }) const buildDryRunFormData = (row: PostImportRow): FormData => { const formData = new FormData () formData.append ('url', row.url) formData.append ('title', String (row.attributes.title ?? '')) formData.append ('thumbnail_base', String (row.attributes.thumbnailBase ?? '')) formData.append ('tags', String (row.attributes.tags ?? '')) formData.append ('parent_post_ids', String (row.attributes.parentPostIds ?? '')) formData.append ( 'original_created_from', String (row.attributes.originalCreatedFrom ?? '')) formData.append ( 'original_created_before', String (row.attributes.originalCreatedBefore ?? '')) formData.append ('video_ms', String (row.attributes.videoMs ?? '')) formData.append ('duration', String (row.attributes.duration ?? '')) return formData } const mergeDryRunRow = ( currentRow: PostImportRow, result: PreviewResponse, ): PostImportRow => initialisePreviewRows ([{ ...currentRow, url: result.url, attributes: { ...currentRow.attributes, title: result.title ?? '', thumbnailBase: result.thumbnailBase ?? '', originalCreatedFrom: result.originalCreatedFrom ?? '', originalCreatedBefore: result.originalCreatedBefore ?? '', duration: result.duration ?? '', videoMs: result.videoMs ?? '', tags: result.tags ?? '', parentPostIds: String (currentRow.attributes.parentPostIds ?? '') }, fieldWarnings: result.fieldWarnings ?? { }, baseWarnings: result.baseWarnings ?? [], validationErrors: { }, importErrors: undefined, status: Object.keys (result.fieldWarnings ?? { }).length > 0 || (result.baseWarnings?.length ?? 0) > 0 ? 'warning' : 'ready', skipReason: result.existingPost?.id != null ? 'existing' : undefined, existingPostId: result.existingPost?.id, importStatus: currentRow.importStatus === 'created' ? 'created' : 'pending', recoverable: undefined }])[0] const indexedBulkResults = ( rows: PostImportRow[], results: BulkApiRow[], ): PostImportResultRow[] => rows.map ((row, index) => { const result = results[index] if (result?.status === 'created' && result.post != null) { return { sourceRow: row.sourceRow, status: 'created', post: result.post, fieldWarnings: result.fieldWarnings, baseWarnings: result.baseWarnings, errors: result.errors } } if (result?.status === 'skipped' && result.existingPost != null) { return { sourceRow: row.sourceRow, status: 'skipped', existingPostId: result.existingPost.id, fieldWarnings: result.fieldWarnings, baseWarnings: result.baseWarnings, errors: result.errors } } return { sourceRow: row.sourceRow, status: 'failed', fieldWarnings: result?.fieldWarnings, baseWarnings: result?.baseWarnings, errors: result?.errors, recoverable: result?.recoverable } }) const buildBulkFormData = (rows: PostImportRow[]): FormData => { const formData = new FormData () formData.append ( 'posts', JSON.stringify ( rows.map (row => ({ url: row.url, title: row.attributes.title ?? '', thumbnail_base: row.attributes.thumbnailBase ?? '', tags: row.attributes.tags ?? '', parent_post_ids: row.attributes.parentPostIds ?? '', original_created_from: row.attributes.originalCreatedFrom ?? '', original_created_before: row.attributes.originalCreatedBefore ?? '', video_ms: row.attributes.videoMs ?? '', duration: row.attributes.duration ?? '' })))) return formData } const ExistingSkippedRows: FC = ({ open, rows }) => { const existingPostIds = useMemo ( () => open ? Array.from ( new Set ( rows .map (row => row.existingPostId) .filter ((postId): postId is number => postId != null), ), ) : [], [open, rows], ) const posts = useQueries ({ queries: existingPostIds.map (postId => ({ enabled: open, queryKey: postsKeys.show (String (postId)), queryFn: () => fetchPost (String (postId)) })), }) const postsById = new Map ( posts .map (query => query.data) .filter ((post): post is Post => post != null) .map (post => [post.id, post]), ) return (
{existingPostIds.map (postId => { const post = postsById.get (postId) if (post == null) return
return (
{post.title ?? ''}
{post.url}
) })}
) } const PostImportReviewPage: FC = ({ user }) => { const editable = canEditContent (user) const dialogue = useDialogue () const location = useLocation () const navigate = useNavigate () const behaviourSettings = useClientBehaviourSettings () const animationMode = behaviourSettings.animation ?? 'normal' const existingRowsTransition = animationMode === 'off' ? { duration: 0 } : animationMode === 'reduced' ? { duration: .08, ease: 'linear' as const } : { duration: .2, ease: 'easeOut' as const } const [session, setSession] = useState (null) const [loading, setLoading] = useState (false) const [loadingRow, setLoadingRow] = useState (null) const [editingRow, setEditingRow] = useState (null) const [showExistingRows, setShowExistingRows] = useState (false) const sessionRef = useRef (null) const sessionIdRef = useRef (null) const persistedSearchRef = useRef (null) const persistSequenceRef = useRef (0) const persistSession = async ( nextSession: PostImportSession, ): Promise => { const sessionId = sessionIdRef.current ?? generatePostImportSessionId () sessionIdRef.current = sessionId const sequence = ++persistSequenceRef.current const serialised = await serialisePostNewState (nextSession.rows) if (serialised == null) return null if (persistSequenceRef.current !== sequence) return sessionRef.current savePostImportSession (sessionId, nextSession) const loadedSession = loadPostImportSession (sessionId) if (!(serialised.complete) && loadedSession?.rows.length !== nextSession.rows.length) return null const persistedSession = buildSession (nextSession.rows) const path = appendPostNewSessionId (serialised.path, sessionId) persistedSearchRef.current = (new URL (path, window.location.origin)).search sessionRef.current = persistedSession setSession (persistedSession) navigate (path, { replace: true }) return persistedSession } const finishImport = () => { const sessionId = sessionIdRef.current if (sessionId != null) clearPostImportSession (sessionId) clearPostImportSourceDraft (message => toast ({ title: '入力内容を削除できませんでした', description: message })) navigate ('/posts') } useEffect (() => { let active = true if (sessionRef.current != null && persistedSearchRef.current === location.search) return () => { } const hydrate = async () => { const parsed = await parsePostNewState (location.search) if (!(active)) return if (parsed == null) { navigate ('/posts/new', { replace: true }) return } const parsedSessionId = parsePostNewSessionId (location.search) const sessionId = parsedSessionId ?? generatePostImportSessionId () sessionIdRef.current = sessionId const loaded = loadPostImportSession (sessionId) ?? buildSession (parsed.rows) persistedSearchRef.current = location.search sessionRef.current = loaded setSession (loaded) if (parsed.shortcut) { try { const preview = await apiGet ('/posts/preview', { params: { url: parsed.source } }) if (!(active)) return const rows = initialisePreviewRows ([{ sourceRow: 1, url: preview.url, attributes: { title: preview.title ?? '', thumbnailBase: preview.thumbnailBase ?? '', originalCreatedFrom: preview.originalCreatedFrom ?? '', originalCreatedBefore: preview.originalCreatedBefore ?? '', duration: preview.duration ?? '', videoMs: preview.videoMs ?? '', tags: preview.tags ?? '', parentPostIds: '' }, fieldWarnings: preview.fieldWarnings ?? { }, baseWarnings: preview.baseWarnings ?? [], validationErrors: { }, provenance: { url: 'manual', title: 'automatic', thumbnailBase: 'automatic', originalCreatedFrom: 'automatic', originalCreatedBefore: 'automatic', duration: 'automatic', videoMs: 'automatic', tags: 'automatic', parentPostIds: 'automatic' }, tagSources: { automatic: preview.tags ?? '', manual: '' }, status: Object.keys (preview.fieldWarnings ?? { }).length > 0 || (preview.baseWarnings?.length ?? 0) > 0 ? 'warning' : 'ready', skipReason: preview.existingPost?.id != null ? 'existing' : undefined, existingPostId: preview.existingPost?.id, resetSnapshot: { url: preview.url, attributes: { title: preview.title ?? '', thumbnailBase: preview.thumbnailBase ?? '', originalCreatedFrom: preview.originalCreatedFrom ?? '', originalCreatedBefore: preview.originalCreatedBefore ?? '', duration: preview.duration ?? '', videoMs: preview.videoMs ?? '', tags: preview.tags ?? '', parentPostIds: '' }, provenance: { url: 'manual', title: 'automatic', thumbnailBase: 'automatic', originalCreatedFrom: 'automatic', originalCreatedBefore: 'automatic', duration: 'automatic', videoMs: 'automatic', tags: 'automatic', parentPostIds: 'automatic' }, tagSources: { automatic: preview.tags ?? '', manual: '' }, fieldWarnings: preview.fieldWarnings ?? { }, baseWarnings: preview.baseWarnings ?? [] } }]) const persisted = await persistSession (buildSession (rows)) if (persisted == null) return } catch { if (active) navigate ('/posts/new', { replace: true }) } return } if (parsedSessionId == null) { await persistSession (loaded) } } void hydrate () return () => { active = false } }, [location.search, navigate]) useEffect (() => { if (session != null) sessionRef.current = session }, [session]) useEffect (() => { if (session == null || session.rows.length === 0) return if (session.rows.every (row => isCompletedReviewRow (row))) finishImport () }, [session]) 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 rows = session?.rows ?? [] const counts = useMemo (() => reviewSummaryCounts (rows), [rows]) const sortedRows = session?.repairMode === 'failed' ? ( [...rows].sort ((a, b) => { const aRepair = isRepairRow (a) ? 0 : 1 const bRepair = isRepairRow (b) ? 0 : 1 return aRepair - bRepair || a.sourceRow - b.sourceRow })) : rows const existingRows = sortedRows.filter (row => isExistingSkipRow (row)) const reviewRows = sortedRows.filter (row => !(isExistingSkipRow (row))) const busy = loading || loadingRow != null const toggleManualSkip = async (sourceRow: number, checked: boolean) => { if (busy) return const currentSession = sessionRef.current if (currentSession == null) return const nextRows = currentSession.rows.map (row => { if (row.sourceRow !== sourceRow) return row if (isExistingSkipRow (row) || row.importStatus === 'created' || isNonRecoverableFailedRow (row)) return row return { ...row, skipReason: checked ? 'manual' : undefined, existingPostId: checked ? undefined : row.existingPostId } }) await persistSession (buildSession (nextRows)) } 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) try { const dryRun = await apiPost ( '/posts?dry=1', buildDryRunFormData (nextRow)) const latestSession = sessionRef.current if (latestSession == null) return { saved: false, row: null } const mergedRow = mergeDryRunRow (nextRow, dryRun) const mergedRows = replaceImportRow (latestSession.rows, mergedRow) const nextSession = await persistSession (buildSession (mergedRows)) if (nextSession == null) return { saved: false, row: null } const mergedTarget = nextSession.rows.find ( currentRow => currentRow.sourceRow === baseRow.sourceRow) if (mergedTarget == null) return { saved: false, row: null } if (Object.keys (mergedTarget.validationErrors).length > 0) return { saved: false, row: mergedTarget } return { saved: true, row: null } } catch (requestError) { if (isApiError<{ errors?: Record baseErrors?: string[] }> (requestError) && requestError.response?.status === 422) { const errorRow = { ...nextRow, validationErrors: requestError.response.data.errors ?? { }, baseWarnings: [], fieldWarnings: { }, status: 'error' as const } setMessageRow (errorRow) return { saved: false, row: errorRow } } 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 retry = async (sourceRow: number) => { if (busy) return const initialSession = sessionRef.current if (initialSession == null) return setLoadingRow (sourceRow) const originalRow = initialSession.rows.find (row => row.sourceRow === sourceRow) if (originalRow == null) { setLoadingRow (null) return } try { const pendingRows = retryImportRow (initialSession.rows, sourceRow) const pendingSession = await persistSession (buildSession (pendingRows)) if (pendingSession == null) return const target = pendingSession.rows.find (row => row.sourceRow === sourceRow) if (target == null) return const result = await apiPost<{ results: BulkApiRow[] }>( '/posts/bulk', buildBulkFormData ([target])) if (result.results.length !== 1) { const latest = sessionRef.current ?? pendingSession const restoredRows = replaceImportRow (latest.rows, originalRow) await persistSession (buildSession (restoredRows)) toast ({ title: '登録結果が不完全でした' }) return } const indexedResults = indexedBulkResults ([target], result.results) const latestAfterImport = sessionRef.current ?? pendingSession const mergedRows = mergeImportResults (latestAfterImport.rows, indexedResults) const recoverableRows = indexedResults.filter (row => row.status === 'failed' && row.recoverable && Object.keys (row.errors ?? { }).length > 0) const nextRows = mergedRows.map ((row): PostImportRow => { const recoverable = recoverableRows.find ( failedRow => failedRow.sourceRow === row.sourceRow) if (recoverable == null) return row return { ...row, importStatus: 'pending', recoverable: true, validationErrors: recoverable.errors ?? { }, importErrors: undefined } }) await persistSession (buildSession (nextRows)) } catch { const latest = sessionRef.current ?? initialSession const restoredRows = replaceImportRow (latest.rows, originalRow) await persistSession (buildSession (restoredRows)) toast ({ title: '再試行に失敗しました' }) } finally { setLoadingRow (null) } } const submit = async () => { const currentSession = sessionRef.current if (currentSession == null || busy) return if (currentSession.rows.every (row => isCompletedReviewRow (row))) { finishImport () return } setLoading (true) try { const processingRows = processableImportRows (currentSession.rows) if (processingRows.length === 0) { finishImport () return } const result = await apiPost<{ results: BulkApiRow[] }>( '/posts/bulk', buildBulkFormData (processingRows)) if (result.results.length !== processingRows.length) { toast ({ title: '登録結果が不完全でした' }) return } const indexedResults = indexedBulkResults (processingRows, result.results) const latestAfterImport = sessionRef.current ?? buildSession (currentSession.rows) const mergedResults = mergeImportResults (latestAfterImport.rows, indexedResults) const recoverableRows = indexedResults.filter (row => row.status === 'failed' && row.recoverable && Object.keys (row.errors ?? { }).length > 0) const nextRows = mergedResults.map ((row): PostImportRow => { const recoverable = recoverableRows.find ( failedRow => failedRow.sourceRow === row.sourceRow) if (recoverable == null) return row return { ...row, importStatus: 'pending', recoverable: true, validationErrors: recoverable.errors ?? { }, importErrors: undefined } }) await persistSession (buildSession (nextRows)) } catch { toast ({ title: '登録に失敗しました' }) } finally { setLoading (false) } } if (!(editable)) return if (session == null) return null return ( <> {`追加内容確認 | ${ SITE_TITLE }`}
追加内容確認 {counts.existingSkipped > 0 && (
{showExistingRows && ( )}
)}
{reviewRows.map ((row, index) => (
editRow (row)} onToggleSkip={checked => toggleManualSkip (row.sourceRow, checked)} onRetry={canRetryResultRow (row) ? () => retry (row.sourceRow) : undefined} rowMessages={resultRowMessages (row)}/>
))}
navigate (-1)} onSubmit={() => submit ()}/> ) } const PostImportFooter = ( { loading, creatableCount, manualSkippedCount, existingSkippedCount, pendingOrErrorCount, onBack, onSubmit }: { loading: boolean creatableCount: number manualSkippedCount: number existingSkippedCount: number pendingOrErrorCount: number onBack: () => void onSubmit: () => void }, ) => (
作成対象 {creatableCount}件 手動スキップ {manualSkippedCount}件 既存投稿による自動スキップ {existingSkippedCount}件 error/未処理 {pendingOrErrorCount}件
) export default PostImportReviewPage