From 55c69866a76fb235a7d6151e0dec19ab570d5087 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Wed, 15 Jul 2026 21:37:03 +0900 Subject: [PATCH] #399 --- .../src/pages/posts/PostImportReviewPage.tsx | 568 +++++++++--------- .../src/pages/posts/PostImportSourcePage.tsx | 311 +++++----- 2 files changed, 438 insertions(+), 441 deletions(-) diff --git a/frontend/src/pages/posts/PostImportReviewPage.tsx b/frontend/src/pages/posts/PostImportReviewPage.tsx index f21821d..7c221c0 100644 --- a/frontend/src/pages/posts/PostImportReviewPage.tsx +++ b/frontend/src/pages/posts/PostImportReviewPage.tsx @@ -1,8 +1,6 @@ import { useEffect, useMemo, useState } from 'react' import { Helmet } from 'react-helmet-async' -import { useNavigate, - useParams, - useSearchParams } from 'react-router-dom' +import { useNavigate, useParams, useSearchParams } from 'react-router-dom' import PageTitle from '@/components/common/PageTitle' import MainArea from '@/components/layout/MainArea' @@ -20,14 +18,14 @@ import { loadPostImportSession, mergeValidatedImportRows, processableImportRows, reviewSummaryCounts, - savePostImportSession, - type PostImportResultRow, - type PostImportRow, - type PostImportSession } from '@/lib/postImportSession' + savePostImportSession } from '@/lib/postImportSession' import Forbidden from '@/pages/Forbidden' import type { FC } from 'react' +import type { PostImportResultRow, + PostImportRow, + PostImportSession } from '@/lib/postImportSession' import type { User } from '@/types' type Props = { user: User | null } @@ -44,297 +42,298 @@ type Draft = { const PostImportReviewPage: FC = ({ user }) => { - const editable = canEditContent (user) - const navigate = useNavigate () - const { sessionId } = useParams () - const [searchParams, setSearchParams] = useSearchParams () + const editable = canEditContent (user) + 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 [dialogMessageRow, setDialogMessageRow] = useState (null) + const [session, setSession] = useState (null) + const [loading, setLoading] = useState (false) + const [missing, setMissing] = useState (false) + const [savingRow, setSavingRow] = useState (null) + const [dialogMessageRow, setDialogMessageRow] = useState (null) - useEffect (() => { - if (sessionId == null) - return + useEffect (() => { + if (sessionId == null) + return - const loaded = loadPostImportSession (sessionId, message => - toast ({ title: '取込状態を復元できませんでした', description: message })) - setSession (loaded) - setMissing (loaded == null) - }, [sessionId]) + const loaded = loadPostImportSession (sessionId, message => + toast ({ title: '取込状態を復元できませんでした', description: message })) + setSession (loaded) + setMissing (loaded == null) + }, [sessionId]) - useEffect (() => { - if (sessionId == null || session == null) - return + useEffect (() => { + if (sessionId == null || session == null) + return - savePostImportSession (sessionId, session, message => - toast ({ title: '取込状態を保存できませんでした', description: message })) - }, [session, sessionId]) + savePostImportSession (sessionId, session, message => + toast ({ title: '取込状態を保存できませんでした', description: message })) + }, [session, sessionId]) - 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 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 + })) + : rows - useEffect (() => { - if (editingRow == null || session?.repairMode !== 'failed') - return + 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 element = document.getElementById (`post-import-row-${ editingRow.sourceRow }`) + element?.scrollIntoView ({ block: 'center', behavior: 'smooth' }) + }, [editingRow, session?.repairMode]) - useEffect (() => { - if (editingRow == null) - { - setDialogMessageRow (null) - return - } - if (dialogMessageRow?.sourceRow !== editingRow.sourceRow) - setDialogMessageRow (null) - }, [editingRow, dialogMessageRow]) - - const updateSessionRows = (nextRows: PostImportRow[]) => - setSession (current => - current != null ? { ...current, rows: nextRows } : current) - - const saveDraft = async ( - { draft, resetRequested, resetSnapshot }: { - draft: Draft - resetRequested: boolean - resetSnapshot: PostImportRow['resetSnapshot'] }, - ): Promise => { - if (session == null) - return false - if (editingRow == null) - return false - - 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) - { - setDialogMessageRow (target) - return false - } - updateSessionRows (mergeValidatedImportRows (nextRows, validatedRows)) - setDialogMessageRow (null) - setSearchParams ({ }) - return true - } - catch - { - toast ({ title: '行の再検証に失敗しました' }) - return false - } - finally - { - setSavingRow (null) - } - } - - 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 }) - setDialogMessageRow (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 (_1 => _1.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) - setDialogMessageRow (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) + useEffect (() => { + if (editingRow == null) { - return ( - -
- 投稿インポート -
取込状態が見つかりません.
- -
-
) + setDialogMessageRow (null) + return } + if (dialogMessageRow?.sourceRow !== editingRow.sourceRow) + setDialogMessageRow (null) + }, [editingRow, dialogMessageRow]) - return ( - <> - - {`投稿インポート確認 | ${ SITE_TITLE }`} - + const updateSessionRows = (nextRows: PostImportRow[]) => + setSession (current => + current != null ? { ...current, rows: nextRows } : current) - -
- 投稿情報の確認・編輯 + const saveDraft = async ( + { draft, resetRequested, resetSnapshot }: { + draft: Draft + resetRequested: boolean + resetSnapshot: PostImportRow['resetSnapshot'] }, + ): Promise => { + if (session == null) + return false -
- {reviewRows.map (row => ( -
- { - setSearchParams ({ edit: String (row.sourceRow) }) - }}/> -
))} -
+ if (editingRow == null) + return false + + 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) + { + setDialogMessageRow (target) + return false + } + updateSessionRows (mergeValidatedImportRows (nextRows, validatedRows)) + setDialogMessageRow (null) + setSearchParams ({ }) + return true + } + catch + { + toast ({ title: '行の再検証に失敗しました' }) + return false + } + finally + { + setSavingRow (null) + } + } + + 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 }) + setDialogMessageRow (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) + setDialogMessageRow (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 ( + +
+ 投稿インポート +
取込状態が見つかりません.
+
-
+ ) + } - navigate ('/posts/import')} - onSubmit={submit}/> + return ( + <> + + {`投稿インポート確認 | ${ SITE_TITLE }`} + - { - if (!open) - setSearchParams ({ }) - }} - onSave={saveDraft}/> - ) + +
+ 投稿情報の確認・編輯 + +
+ {reviewRows.map (row => ( +
+ { + setSearchParams ({ edit: String (row.sourceRow) }) + }}/> +
))} +
+
+
+ + navigate ('/posts/import')} + onSubmit={submit}/> + + { + if (!open) + setSearchParams ({ }) + }} + onSave={saveDraft}/> + ) } const PostImportFooter = ( @@ -343,13 +342,12 @@ const PostImportFooter = ( creatableCount, skipPlannedCount, onBack, - onSubmit }: { - loading: boolean - processableCount: number - creatableCount: number - skipPlannedCount: number - onBack: () => void - onSubmit: () => void }, + onSubmit }: { loading: boolean + processableCount: number + creatableCount: number + skipPlannedCount: number + onBack: () => void + onSubmit: () => void }, ) => (
- 投稿インポート - - {() => ( -