473 行
15 KiB
TypeScript
473 行
15 KiB
TypeScript
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<Props> = ({ user }) => {
|
||
const editable = canEditContent (user)
|
||
const dialogue = useDialogue ()
|
||
const navigate = useNavigate ()
|
||
const { sessionId } = useParams ()
|
||
const [searchParams, setSearchParams] = useSearchParams ()
|
||
|
||
const [session, setSession] = useState<PostImportSession | null> (null)
|
||
const [loading, setLoading] = useState (false)
|
||
const [missing, setMissing] = useState (false)
|
||
const [savingRow, setSavingRow] = useState<number | null> (null)
|
||
const [dialogueMessageRow, setDialogueMessageRow] = useState<PostImportRow | null> (null)
|
||
const [dialogueDraft, setDialogueDraft] = useState<PostImportRowDraft | null> (null)
|
||
const [dialogueResetRequested, setDialogueResetRequested] = useState (false)
|
||
const dialogueDraftRef = useRef<PostImportRowDraft | null> (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: <PostImportRowForm
|
||
row={currentRow}
|
||
messageRow={dialogueMessageRow?.sourceRow === currentRow.sourceRow
|
||
? dialogueMessageRow
|
||
: null}
|
||
saving={savingRow === currentRow.sourceRow}
|
||
onDraftChange={setDialogueDraft}
|
||
onResetRequestedChange={setDialogueResetRequested}/>,
|
||
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 <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}
|
||
onEdit={() => {
|
||
setSearchParams ({ edit: String (row.sourceRow) })
|
||
}}/>
|
||
</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}>
|
||
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
|