このコミットが含まれているのは:
@@ -1,544 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
|
||||
import FieldError from '@/components/common/FieldError'
|
||||
import FieldWarning from '@/components/common/FieldWarning'
|
||||
import PageTitle from '@/components/common/PageTitle'
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import PostImportRowForm from '@/components/posts/import/PostImportRowForm'
|
||||
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
|
||||
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
|
||||
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 { clearPostImportSourceDraft,
|
||||
buildNextEditedRow,
|
||||
canEditResultRow,
|
||||
canRetryResultRow,
|
||||
hasExactSourceRows,
|
||||
initialisePreviewRows,
|
||||
loadPostImportSession,
|
||||
mergeImportResults,
|
||||
mergeValidatedImportRow,
|
||||
replaceImportRow,
|
||||
resultRepairMode,
|
||||
resultRowMessages,
|
||||
resultRowWarnings,
|
||||
resultSummaryCounts,
|
||||
retryImportRow,
|
||||
savePostImportSession } from '@/lib/postImportSession'
|
||||
import { originalCreatedAtString } from '@/lib/utils'
|
||||
import Forbidden from '@/pages/Forbidden'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { PostImportRowDraft } from '@/components/posts/import/PostImportRowForm'
|
||||
import type { PostImportResultRow, PostImportRow } from '@/lib/postImportSession'
|
||||
import type { PostImportSession } from '@/lib/postImportSession'
|
||||
import type { User } from '@/types'
|
||||
|
||||
type Props = { user: User | null }
|
||||
|
||||
|
||||
const PostImportResultPage: FC<Props> = ({ user }) => {
|
||||
const editable = canEditContent (user)
|
||||
const dialogue = useDialogue ()
|
||||
const navigate = useNavigate ()
|
||||
const { sessionId } = useParams ()
|
||||
|
||||
const [session, setSession] = useState<PostImportSession | null> (null)
|
||||
const [missing, setMissing] = useState (false)
|
||||
const [loadingRow, setLoadingRow] = useState<number | null> (null)
|
||||
const [, setEditingRow] = useState<PostImportRow | null> (null)
|
||||
const sessionRef = useRef<PostImportSession | null> (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 counts = useMemo (
|
||||
() => resultSummaryCounts (session?.rows ?? []),
|
||||
[session])
|
||||
const busy = loadingRow != null
|
||||
|
||||
const persistSession = (nextSession: PostImportSession) => {
|
||||
if (sessionId == null)
|
||||
return
|
||||
|
||||
savePostImportSession (sessionId, nextSession, message =>
|
||||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||
}
|
||||
|
||||
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 (!(canEditResultRow (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 (
|
||||
validatedRow => validatedRow.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 editedRows = replaceImportRow (latestSession.rows, nextRow)
|
||||
const rows = mergeValidatedImportRow (editedRows, target)
|
||||
const nextSession = {
|
||||
...latestSession,
|
||||
rows,
|
||||
repairMode: resultRepairMode (rows) }
|
||||
sessionRef.current = nextSession
|
||||
setSession (nextSession)
|
||||
persistSession (nextSession)
|
||||
const mergedTarget =
|
||||
rows.find (mergedRow => mergedRow.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 => (
|
||||
<PostImportRowForm
|
||||
row={row}
|
||||
controls={controls}
|
||||
onSave={saveRowDraft}/>) })
|
||||
}
|
||||
|
||||
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 (session == null || sessionId == null || loadingRow != null)
|
||||
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 = { ...initialSession, rows: pendingRows }
|
||||
sessionRef.current = pendingSession
|
||||
setSession (pendingSession)
|
||||
persistSession (pendingSession)
|
||||
const requestedRows = pendingRows.filter (row => row.importStatus !== 'created')
|
||||
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
|
||||
rows: requestedRows.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)
|
||||
if (!(hasExactSourceRows (requestedRows.map (row => row.sourceRow), validatedRows)))
|
||||
{
|
||||
const latest = sessionRef.current ?? pendingSession
|
||||
const restoredRows = replaceImportRow (latest.rows, originalRow)
|
||||
const restoredSession = {
|
||||
...latest,
|
||||
rows: restoredRows,
|
||||
repairMode: resultRepairMode (restoredRows) }
|
||||
sessionRef.current = restoredSession
|
||||
setSession (restoredSession)
|
||||
persistSession (restoredSession)
|
||||
toast ({ title: '再検証結果が不完全でした' })
|
||||
return
|
||||
}
|
||||
const validatedTarget = validatedRows.find (
|
||||
row => row.sourceRow === sourceRow)
|
||||
if (validatedTarget == null)
|
||||
{
|
||||
const latest = sessionRef.current ?? pendingSession
|
||||
const restoredRows = replaceImportRow (latest.rows, originalRow)
|
||||
const restoredSession = {
|
||||
...latest,
|
||||
rows: restoredRows,
|
||||
repairMode: resultRepairMode (restoredRows) }
|
||||
sessionRef.current = restoredSession
|
||||
setSession (restoredSession)
|
||||
persistSession (restoredSession)
|
||||
toast ({ title: '再検証結果が不完全でした' })
|
||||
return
|
||||
}
|
||||
const latestAfterValidate = sessionRef.current ?? pendingSession
|
||||
const mergedValidatedRows = mergeValidatedImportRow (
|
||||
latestAfterValidate.rows,
|
||||
validatedTarget)
|
||||
const nextSession = {
|
||||
...latestAfterValidate,
|
||||
rows: mergedValidatedRows,
|
||||
repairMode: resultRepairMode (mergedValidatedRows) }
|
||||
sessionRef.current = nextSession
|
||||
setSession (nextSession)
|
||||
persistSession (nextSession)
|
||||
const target = mergedValidatedRows.find (row => row.sourceRow === sourceRow)
|
||||
if (target == null)
|
||||
{
|
||||
const restoredRows = replaceImportRow (latestAfterValidate.rows, originalRow)
|
||||
const restoredSession = {
|
||||
...latestAfterValidate,
|
||||
rows: restoredRows,
|
||||
repairMode: resultRepairMode (restoredRows) }
|
||||
sessionRef.current = restoredSession
|
||||
setSession (restoredSession)
|
||||
persistSession (restoredSession)
|
||||
toast ({ title: '再検証結果が不完全でした' })
|
||||
return
|
||||
}
|
||||
if (Object.keys (target.validationErrors ?? { }).length > 0)
|
||||
{
|
||||
const saved = savePostImportSession (sessionId, nextSession, message =>
|
||||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||
if (saved)
|
||||
void editRow (target)
|
||||
return
|
||||
}
|
||||
|
||||
const result = await apiPost<{
|
||||
created: number
|
||||
skipped: number
|
||||
failed: number
|
||||
rows: PostImportResultRow[] }> ('/posts/import', {
|
||||
rows: [{
|
||||
sourceRow: target.sourceRow,
|
||||
url: target.url,
|
||||
attributes: target.attributes,
|
||||
provenance: target.provenance,
|
||||
tagSources: target.tagSources,
|
||||
metadataUrl: target.metadataUrl }] })
|
||||
if (!(hasExactSourceRows ([sourceRow], result.rows)))
|
||||
{
|
||||
const latest = sessionRef.current ?? nextSession
|
||||
const restoredRows = replaceImportRow (latest.rows, originalRow)
|
||||
const restoredSession = {
|
||||
...latest,
|
||||
rows: restoredRows,
|
||||
repairMode: resultRepairMode (restoredRows) }
|
||||
sessionRef.current = restoredSession
|
||||
setSession (restoredSession)
|
||||
persistSession (restoredSession)
|
||||
toast ({ title: '登録結果が不完全でした' })
|
||||
return
|
||||
}
|
||||
const latestAfterImport = sessionRef.current ?? nextSession
|
||||
const mergedRows = mergeImportResults (latestAfterImport.rows, result.rows)
|
||||
const recoverableRows = result.rows.filter (row =>
|
||||
row.status === 'failed'
|
||||
&& row.recoverable
|
||||
&& Object.keys (row.errors ?? { }).length > 0)
|
||||
const nextRows = mergedRows.map ((row): PostImportRow => {
|
||||
const recoverable = recoverableRows.find (
|
||||
recoverableRow => recoverableRow.sourceRow === row.sourceRow)
|
||||
if (recoverable == null)
|
||||
return row
|
||||
return {
|
||||
...row,
|
||||
importStatus: 'pending',
|
||||
recoverable: true,
|
||||
validationErrors: recoverable.errors ?? { },
|
||||
importErrors: undefined }
|
||||
})
|
||||
const recoverableTarget = nextRows.find (row => row.sourceRow === sourceRow)
|
||||
const resultSession = {
|
||||
...nextSession,
|
||||
rows: nextRows,
|
||||
repairMode: resultRepairMode (nextRows) }
|
||||
sessionRef.current = resultSession
|
||||
setSession (resultSession)
|
||||
persistSession (resultSession)
|
||||
if (recoverableTarget != null
|
||||
&& Object.keys (recoverableTarget.validationErrors).length > 0)
|
||||
{
|
||||
const saved = savePostImportSession (sessionId, resultSession, message =>
|
||||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||
if (saved)
|
||||
void editRow (recoverableTarget)
|
||||
return
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
const latest = sessionRef.current
|
||||
if (latest != null)
|
||||
{
|
||||
const restoredRows = replaceImportRow (latest.rows, originalRow)
|
||||
const restoredSession = {
|
||||
...latest,
|
||||
rows: restoredRows,
|
||||
repairMode: resultRepairMode (restoredRows) }
|
||||
sessionRef.current = restoredSession
|
||||
setSession (restoredSession)
|
||||
persistSession (restoredSession)
|
||||
}
|
||||
toast ({ title: '再試行に失敗しました' })
|
||||
}
|
||||
finally
|
||||
{
|
||||
setLoadingRow (null)
|
||||
}
|
||||
}
|
||||
|
||||
const openRepair = (sourceRow: number) => {
|
||||
const currentSession = sessionRef.current
|
||||
if (currentSession == null || sessionId == null || loadingRow != null)
|
||||
return
|
||||
|
||||
const nextSession = { ...currentSession, repairMode: 'failed' as const }
|
||||
sessionRef.current = nextSession
|
||||
setSession (nextSession)
|
||||
const saved = savePostImportSession (sessionId, nextSession, message =>
|
||||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||
if (!(saved))
|
||||
return
|
||||
const row = nextSession.rows.find (rowValue => rowValue.sourceRow === sourceRow)
|
||||
if (row != null)
|
||||
void editRow (row)
|
||||
}
|
||||
|
||||
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>
|
||||
<FieldError messages={['取込状態が見つかりません.']}/>
|
||||
<Button type="button" onClick={() => navigate ('/posts/import')}>
|
||||
URL リスト入力へ戻る
|
||||
</Button>
|
||||
</div>
|
||||
</MainArea>)
|
||||
}
|
||||
|
||||
return (
|
||||
<MainArea>
|
||||
<Helmet>
|
||||
<title>{`投稿インポート結果 | ${ SITE_TITLE }`}</title>
|
||||
</Helmet>
|
||||
|
||||
<div className="mx-auto max-w-5xl space-y-4 p-4">
|
||||
<PageTitle>登録結果</PageTitle>
|
||||
|
||||
<div className="text-sm text-neutral-700 dark:text-neutral-200">
|
||||
登録成功 {counts.created}件 スキップ {counts.skipped}件 失敗 {counts.failed}件
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{session.rows.map (row => {
|
||||
const displayStatus = displayPostImportStatus (row)
|
||||
const canEdit = canEditResultRow (row)
|
||||
const canRetry = canRetryResultRow (row)
|
||||
return (
|
||||
<div
|
||||
key={row.sourceRow}
|
||||
className="rounded-lg border p-4 transition-shadow hover:shadow-sm">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start
|
||||
md:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium">行 {row.sourceRow}</span>
|
||||
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
|
||||
</div>
|
||||
<div className="text-sm text-neutral-700 dark:text-neutral-200">
|
||||
{String (row.attributes.title ?? '') || row.url}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{row.url}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{String (row.attributes.tags ?? '') || 'タグなし'}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{originalCreatedAtString (
|
||||
row.attributes.originalCreatedFrom?.toString () ?? null,
|
||||
row.attributes.originalCreatedBefore?.toString () ?? null)}
|
||||
{row.attributes.duration ? ` / ${ row.attributes.duration }` : ''}
|
||||
</div>
|
||||
<FieldWarning messages={resultRowWarnings (row)}/>
|
||||
<FieldError messages={resultRowMessages (row)}/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
{(row.createdPostId != null || row.existingPostId != null) && (
|
||||
<Button type="button" variant="outline" asChild>
|
||||
<PrefetchLink
|
||||
to={`/posts/${ row.createdPostId ?? row.existingPostId }`}>
|
||||
投稿を開く
|
||||
</PrefetchLink>
|
||||
</Button>)}
|
||||
{canEdit && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => openRepair (row.sourceRow)}
|
||||
disabled={busy}>
|
||||
編輯
|
||||
</Button>)}
|
||||
{canRetry && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => retry (row.sourceRow)}
|
||||
disabled={busy}>
|
||||
再試行
|
||||
</Button>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>)})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
const currentSession = sessionRef.current
|
||||
if (currentSession == null)
|
||||
return
|
||||
const nextSession = { ...currentSession, repairMode: 'all' as const }
|
||||
sessionRef.current = nextSession
|
||||
setSession (nextSession)
|
||||
const saved = savePostImportSession (sessionId, nextSession, message =>
|
||||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||
if (!(saved))
|
||||
return
|
||||
navigate (`/posts/import/${ sessionId }/review`)
|
||||
}}>
|
||||
確認画面へ戻る
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
clearPostImportSourceDraft (message =>
|
||||
toast ({ title: '入力内容を削除できませんでした', description: message }))
|
||||
navigate ('/posts/import')
|
||||
}}>
|
||||
新しい URL リストを入力
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</MainArea>)
|
||||
}
|
||||
|
||||
export default PostImportResultPage
|
||||
@@ -13,33 +13,35 @@ 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 { 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,
|
||||
hasExactSourceRows,
|
||||
generatePostImportSessionId,
|
||||
initialisePreviewRows,
|
||||
isCompletedReviewRow,
|
||||
isExistingSkipRow,
|
||||
isNonRecoverableFailedRow,
|
||||
mergeImportResults,
|
||||
mergeValidatedImportRow,
|
||||
mergeValidatedImportRows,
|
||||
processableImportRows,
|
||||
replaceImportRow,
|
||||
resultRepairMode,
|
||||
resultRowMessages,
|
||||
reviewSummaryCounts,
|
||||
retryImportRow,
|
||||
validatableImportRows,
|
||||
savePostImportSession,
|
||||
loadPostImportSession,
|
||||
} from '@/lib/postImportSession'
|
||||
import { postsKeys } from '@/lib/queryKeys'
|
||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||
@@ -58,6 +60,29 @@ 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<string, string[]>
|
||||
baseWarnings?: string[]
|
||||
existingPost?: { id: number } | null }
|
||||
|
||||
type BulkApiRow = {
|
||||
status: 'created' | 'skipped' | 'failed'
|
||||
post?: { id: number }
|
||||
existingPost?: { id: number } | null
|
||||
fieldWarnings?: Record<string, string[]>
|
||||
baseWarnings?: string[]
|
||||
errors?: Record<string, string[]>
|
||||
baseErrors?: string[]
|
||||
recoverable?: boolean }
|
||||
|
||||
type ExistingSkippedRowsProps = {
|
||||
open: boolean
|
||||
rows: PostImportRow[] }
|
||||
@@ -70,13 +95,122 @@ const isRepairRow = (row: PostImportRow): boolean =>
|
||||
|
||||
|
||||
const buildSession = (rows: PostImportRow[]): PostImportSession => ({
|
||||
version: 2,
|
||||
savedAt: new Date ().toISOString (),
|
||||
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<ExistingSkippedRowsProps> = ({ open, rows }) => {
|
||||
const existingPostIds = useMemo (
|
||||
() =>
|
||||
@@ -157,12 +291,16 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
const [editingRow, setEditingRow] = useState<PostImportRow | null> (null)
|
||||
const [showExistingRows, setShowExistingRows] = useState (false)
|
||||
const sessionRef = useRef<PostImportSession | null> (null)
|
||||
const sessionIdRef = useRef<string | null> (null)
|
||||
const persistedSearchRef = useRef<string | null> (null)
|
||||
const persistSequenceRef = useRef (0)
|
||||
|
||||
const persistSession = async (
|
||||
nextSession: PostImportSession,
|
||||
): Promise<PostImportSession | null> => {
|
||||
const sessionId =
|
||||
sessionIdRef.current ?? generatePostImportSessionId ()
|
||||
sessionIdRef.current = sessionId
|
||||
const sequence = ++persistSequenceRef.current
|
||||
const serialised = await serialisePostNewState (nextSession.rows)
|
||||
if (serialised == null)
|
||||
@@ -170,16 +308,25 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
if (persistSequenceRef.current !== sequence)
|
||||
return sessionRef.current
|
||||
|
||||
const persistedSession = buildSession (serialised.rows)
|
||||
persistedSearchRef.current =
|
||||
(new URL (serialised.path, window.location.origin)).search
|
||||
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 (serialised.path, { replace: true })
|
||||
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')
|
||||
@@ -202,7 +349,10 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
return
|
||||
}
|
||||
|
||||
const loaded = buildSession (parsed.rows)
|
||||
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)
|
||||
@@ -211,11 +361,71 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
{
|
||||
try
|
||||
{
|
||||
const preview = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/preview', {
|
||||
source: parsed.source })
|
||||
const preview = await apiGet<PreviewResponse> ('/posts/preview', {
|
||||
params: { url: parsed.source } })
|
||||
if (!(active))
|
||||
return
|
||||
const rows = initialisePreviewRows (preview.rows)
|
||||
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
|
||||
@@ -228,37 +438,10 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
return
|
||||
}
|
||||
|
||||
const requestedRows = validatableImportRows (parsed.rows)
|
||||
if (requestedRows.length === 0)
|
||||
return
|
||||
|
||||
try
|
||||
{
|
||||
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
|
||||
rows: requestedRows.map (row => ({
|
||||
sourceRow: row.sourceRow,
|
||||
url: row.url,
|
||||
attributes: row.attributes,
|
||||
provenance: row.provenance,
|
||||
tagSources: row.tagSources,
|
||||
metadataUrl: row.metadataUrl })),
|
||||
changed_row: 'all' })
|
||||
if (!(active))
|
||||
return
|
||||
|
||||
const validatedRows = initialisePreviewRows (validated.rows)
|
||||
if (!(hasExactSourceRows (requestedRows.map (row => row.sourceRow), validatedRows)))
|
||||
return
|
||||
|
||||
const latest = sessionRef.current ?? loaded
|
||||
const rows = mergeValidatedImportRows (latest.rows, validatedRows)
|
||||
const persisted = await persistSession (buildSession (rows))
|
||||
if (persisted == null)
|
||||
return
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
if (parsedSessionId == null)
|
||||
{
|
||||
await persistSession (loaded)
|
||||
}
|
||||
}
|
||||
|
||||
void hydrate ()
|
||||
@@ -354,51 +537,46 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
: row
|
||||
const urlChanged = draft.url !== baseRow.url
|
||||
const nextRow = buildNextEditedRow (baseRow, draft, urlChanged)
|
||||
const nextRows = currentSession.rows.map (currentRow =>
|
||||
currentRow.sourceRow === baseRow.sourceRow
|
||||
? nextRow
|
||||
: currentRow)
|
||||
try
|
||||
{
|
||||
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
|
||||
rows:
|
||||
validatableImportRows (nextRows)
|
||||
.map (currentRow => ({
|
||||
sourceRow: currentRow.sourceRow,
|
||||
url: currentRow.url,
|
||||
attributes: currentRow.attributes,
|
||||
provenance: currentRow.provenance,
|
||||
tagSources: currentRow.tagSources,
|
||||
metadataUrl: currentRow.metadataUrl })),
|
||||
changed_row: urlChanged ? baseRow.sourceRow : -1 })
|
||||
const validatedRows = initialisePreviewRows (validated.rows)
|
||||
const target = validatedRows.find (
|
||||
validatedRow => validatedRow.sourceRow === baseRow.sourceRow)
|
||||
const dryRun = await apiPost<PreviewResponse> (
|
||||
'/posts?dry=1',
|
||||
buildDryRunFormData (nextRow))
|
||||
const latestSession = sessionRef.current
|
||||
if (latestSession == null)
|
||||
return { saved: false, row: null }
|
||||
if (target == null)
|
||||
{
|
||||
const restoredRows = replaceImportRow (latestSession.rows, row)
|
||||
await persistSession (buildSession (restoredRows))
|
||||
toast ({ title: '行の再検証結果が不完全でした' })
|
||||
return { saved: false, row: null }
|
||||
}
|
||||
|
||||
const editedRows = replaceImportRow (latestSession.rows, nextRow)
|
||||
const mergedRows = mergeValidatedImportRow (editedRows, target)
|
||||
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 (mergedRow => mergedRow.sourceRow === baseRow.sourceRow)
|
||||
?? target
|
||||
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
|
||||
catch (requestError)
|
||||
{
|
||||
if (isApiError<{
|
||||
errors?: Record<string, string[]>
|
||||
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 }
|
||||
}
|
||||
@@ -461,81 +639,26 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
if (pendingSession == null)
|
||||
return
|
||||
|
||||
const requestedRows = validatableImportRows (pendingSession.rows)
|
||||
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
|
||||
rows: requestedRows.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)
|
||||
if (!(hasExactSourceRows (requestedRows.map (row => row.sourceRow), validatedRows)))
|
||||
{
|
||||
const latest = sessionRef.current ?? pendingSession
|
||||
const restoredRows = replaceImportRow (latest.rows, originalRow)
|
||||
await persistSession (buildSession (restoredRows))
|
||||
toast ({ title: '再検証結果が不完全でした' })
|
||||
return
|
||||
}
|
||||
|
||||
const validatedTarget = validatedRows.find (row => row.sourceRow === sourceRow)
|
||||
if (validatedTarget == null)
|
||||
{
|
||||
const latest = sessionRef.current ?? pendingSession
|
||||
const restoredRows = replaceImportRow (latest.rows, originalRow)
|
||||
await persistSession (buildSession (restoredRows))
|
||||
toast ({ title: '再検証結果が不完全でした' })
|
||||
return
|
||||
}
|
||||
|
||||
const latestAfterValidate = sessionRef.current ?? pendingSession
|
||||
const mergedValidatedRows = mergeValidatedImportRow (
|
||||
latestAfterValidate.rows,
|
||||
validatedTarget)
|
||||
const nextSession = await persistSession (buildSession (mergedValidatedRows))
|
||||
if (nextSession == null)
|
||||
return
|
||||
const target = nextSession.rows.find (row => row.sourceRow === sourceRow)
|
||||
const target = pendingSession.rows.find (row => row.sourceRow === sourceRow)
|
||||
if (target == null)
|
||||
{
|
||||
const restoredRows = replaceImportRow (latestAfterValidate.rows, originalRow)
|
||||
await persistSession (buildSession (restoredRows))
|
||||
toast ({ title: '再検証結果が不完全でした' })
|
||||
return
|
||||
}
|
||||
if (Object.keys (target.validationErrors ?? { }).length > 0)
|
||||
{
|
||||
editRow (target)
|
||||
return
|
||||
}
|
||||
return
|
||||
|
||||
const result = await apiPost<{
|
||||
created: number
|
||||
skipped: number
|
||||
failed: number
|
||||
rows: PostImportResultRow[] }> ('/posts/import', {
|
||||
rows: [{
|
||||
sourceRow: target.sourceRow,
|
||||
url: target.url,
|
||||
attributes: target.attributes,
|
||||
provenance: target.provenance,
|
||||
tagSources: target.tagSources,
|
||||
metadataUrl: target.metadataUrl }] })
|
||||
if (!(hasExactSourceRows ([sourceRow], result.rows)))
|
||||
const result = await apiPost<{ results: BulkApiRow[] }>(
|
||||
'/posts/bulk',
|
||||
buildBulkFormData ([target]))
|
||||
if (result.results.length !== 1)
|
||||
{
|
||||
const latest = sessionRef.current ?? nextSession
|
||||
const latest = sessionRef.current ?? pendingSession
|
||||
const restoredRows = replaceImportRow (latest.rows, originalRow)
|
||||
await persistSession (buildSession (restoredRows))
|
||||
toast ({ title: '登録結果が不完全でした' })
|
||||
return
|
||||
}
|
||||
|
||||
const latestAfterImport = sessionRef.current ?? nextSession
|
||||
const mergedRows = mergeImportResults (latestAfterImport.rows, result.rows)
|
||||
const recoverableRows = result.rows.filter (row =>
|
||||
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)
|
||||
@@ -579,66 +702,26 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
setLoading (true)
|
||||
try
|
||||
{
|
||||
const validatableRows = validatableImportRows (currentSession.rows)
|
||||
if (validatableRows.length === 0)
|
||||
const processingRows = processableImportRows (currentSession.rows)
|
||||
if (processingRows.length === 0)
|
||||
{
|
||||
finishImport ()
|
||||
return
|
||||
}
|
||||
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
|
||||
rows:
|
||||
validatableRows.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 = validatableRows.map (row => row.sourceRow)
|
||||
if (!(hasExactSourceRows (expectedSourceRows, validatedRows)))
|
||||
{
|
||||
toast ({ title: '再検証結果が不完全でした' })
|
||||
return
|
||||
}
|
||||
|
||||
const latestAfterValidate = sessionRef.current ?? currentSession
|
||||
const mergedRows = mergeValidatedImportRows (latestAfterValidate.rows, validatedRows)
|
||||
const persistedValidated = await persistSession (buildSession (mergedRows))
|
||||
if (persistedValidated == null)
|
||||
return
|
||||
const currentRows = persistedValidated.rows
|
||||
const firstInvalid = currentRows.find (
|
||||
row => Object.keys (row.validationErrors).length > 0)
|
||||
if (firstInvalid != null)
|
||||
{
|
||||
editRow (firstInvalid)
|
||||
return
|
||||
}
|
||||
|
||||
const result = await apiPost<{
|
||||
created: number
|
||||
skipped: number
|
||||
failed: number
|
||||
rows: PostImportResultRow[] }> ('/posts/import', {
|
||||
rows: processableImportRows (currentRows).map (row => ({
|
||||
sourceRow: row.sourceRow,
|
||||
url: row.url,
|
||||
attributes: row.attributes,
|
||||
provenance: row.provenance,
|
||||
tagSources: row.tagSources,
|
||||
metadataUrl: row.metadataUrl })) })
|
||||
const expectedImportRows = processableImportRows (currentRows).map (row => row.sourceRow)
|
||||
if (!(hasExactSourceRows (expectedImportRows, result.rows)))
|
||||
const result = await apiPost<{ results: BulkApiRow[] }>(
|
||||
'/posts/bulk',
|
||||
buildBulkFormData (processingRows))
|
||||
if (result.results.length !== processingRows.length)
|
||||
{
|
||||
toast ({ title: '登録結果が不完全でした' })
|
||||
return
|
||||
}
|
||||
|
||||
const latestAfterImport = sessionRef.current ?? buildSession (currentRows)
|
||||
const mergedResults = mergeImportResults (latestAfterImport.rows, result.rows)
|
||||
const recoverableRows = result.rows.filter (row =>
|
||||
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)
|
||||
|
||||
@@ -11,13 +11,20 @@ import MainArea from '@/components/layout/MainArea'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { toast } from '@/components/ui/use-toast'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { apiPost, isApiError } from '@/lib/api'
|
||||
import { serialisePostNewState } from '@/lib/postNewQueryState'
|
||||
import { apiGet, isApiError } from '@/lib/api'
|
||||
import {
|
||||
appendPostNewSessionId,
|
||||
serialisePostNewState,
|
||||
} from '@/lib/postNewQueryState'
|
||||
import { canEditContent } from '@/lib/users'
|
||||
import { countImportSourceLines,
|
||||
generatePostImportSessionId,
|
||||
cleanupExpiredPostImportSessions,
|
||||
initialisePreviewRows,
|
||||
loadPostImportSession,
|
||||
loadPostImportSourceDraft,
|
||||
resultRepairMode,
|
||||
savePostImportSession,
|
||||
savePostImportSourceDraft,
|
||||
validateImportSource } from '@/lib/postImportSession'
|
||||
import Forbidden from '@/pages/Forbidden'
|
||||
@@ -29,6 +36,19 @@ import type { 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<string, string[]>
|
||||
baseWarnings?: string[]
|
||||
existingPost?: { id: number } | null }
|
||||
|
||||
const MAX_ROWS = 100
|
||||
const SOURCE_ERROR_ID = 'post-import-source-error'
|
||||
const SOURCE_ISSUES_ID = 'post-import-source-issues'
|
||||
@@ -44,6 +64,96 @@ const urlIssuesFromRows = (rows: PostImportRow[], source: string) => {
|
||||
}
|
||||
|
||||
|
||||
const previewRowFromResult = (
|
||||
sourceRow: number,
|
||||
result: PreviewResponse,
|
||||
): PostImportRow => {
|
||||
const fieldWarnings = result.fieldWarnings ?? { }
|
||||
const baseWarnings = result.baseWarnings ?? []
|
||||
const row: PostImportRow = {
|
||||
sourceRow,
|
||||
url: result.url,
|
||||
attributes: {
|
||||
title: result.title ?? '',
|
||||
thumbnailBase: result.thumbnailBase ?? '',
|
||||
originalCreatedFrom: result.originalCreatedFrom ?? '',
|
||||
originalCreatedBefore: result.originalCreatedBefore ?? '',
|
||||
duration: result.duration ?? '',
|
||||
videoMs: result.videoMs ?? '',
|
||||
tags: result.tags ?? '',
|
||||
parentPostIds: '' },
|
||||
fieldWarnings,
|
||||
baseWarnings,
|
||||
validationErrors: { },
|
||||
provenance: {
|
||||
url: 'manual',
|
||||
title: 'automatic',
|
||||
thumbnailBase: 'automatic',
|
||||
originalCreatedFrom: 'automatic',
|
||||
originalCreatedBefore: 'automatic',
|
||||
duration: 'automatic',
|
||||
videoMs: 'automatic',
|
||||
tags: 'automatic',
|
||||
parentPostIds: 'automatic' },
|
||||
tagSources: {
|
||||
automatic: result.tags ?? '',
|
||||
manual: '' },
|
||||
status:
|
||||
Object.keys (fieldWarnings).length > 0 || baseWarnings.length > 0
|
||||
? 'warning'
|
||||
: 'ready',
|
||||
skipReason: result.existingPost?.id != null ? 'existing' : undefined,
|
||||
existingPostId: result.existingPost?.id,
|
||||
resetSnapshot: {
|
||||
url: result.url,
|
||||
attributes: {
|
||||
title: result.title ?? '',
|
||||
thumbnailBase: result.thumbnailBase ?? '',
|
||||
originalCreatedFrom: result.originalCreatedFrom ?? '',
|
||||
originalCreatedBefore: result.originalCreatedBefore ?? '',
|
||||
duration: result.duration ?? '',
|
||||
videoMs: result.videoMs ?? '',
|
||||
tags: result.tags ?? '',
|
||||
parentPostIds: '' },
|
||||
provenance: {
|
||||
url: 'manual',
|
||||
title: 'automatic',
|
||||
thumbnailBase: 'automatic',
|
||||
originalCreatedFrom: 'automatic',
|
||||
originalCreatedBefore: 'automatic',
|
||||
duration: 'automatic',
|
||||
videoMs: 'automatic',
|
||||
tags: 'automatic',
|
||||
parentPostIds: 'automatic' },
|
||||
tagSources: {
|
||||
automatic: result.tags ?? '',
|
||||
manual: '' },
|
||||
fieldWarnings,
|
||||
baseWarnings } }
|
||||
return row
|
||||
}
|
||||
|
||||
|
||||
const fetchPreviewRows = async (urls: string[]): Promise<PostImportRow[]> => {
|
||||
const results: PostImportRow[] = new Array (urls.length)
|
||||
let nextIndex = 0
|
||||
const worker = async () => {
|
||||
while (nextIndex < urls.length)
|
||||
{
|
||||
const index = nextIndex
|
||||
++nextIndex
|
||||
const result = await apiGet<PreviewResponse> ('/posts/preview', {
|
||||
params: { url: urls[index] } })
|
||||
results[index] = previewRowFromResult (index + 1, result)
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all (
|
||||
Array.from ({ length: Math.min (4, urls.length) }, () => worker ()))
|
||||
return results
|
||||
}
|
||||
|
||||
|
||||
const PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||
const editable = canEditContent (user)
|
||||
const navigate = useNavigate ()
|
||||
@@ -114,17 +224,30 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||
setSourceError (null)
|
||||
try
|
||||
{
|
||||
const data = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/preview', { source })
|
||||
const urlIssues = urlIssuesFromRows (data.rows, source)
|
||||
const urls = source.split (/\r\n|\n|\r/).map (line => line.trim ()).filter (line => line !== '')
|
||||
const rows = await fetchPreviewRows (urls)
|
||||
const urlIssues = urlIssuesFromRows (rows, source)
|
||||
if (urlIssues.length > 0)
|
||||
{
|
||||
setSourceIssues (urlIssues)
|
||||
return
|
||||
}
|
||||
const nextRows = initialisePreviewRows (data.rows)
|
||||
const nextRows = initialisePreviewRows (rows)
|
||||
const serialised = await serialisePostNewState (nextRows)
|
||||
if (serialised != null)
|
||||
navigate (serialised.path)
|
||||
if (serialised == null)
|
||||
return
|
||||
|
||||
const sessionId = generatePostImportSessionId ()
|
||||
const session = {
|
||||
source,
|
||||
rows: nextRows,
|
||||
repairMode: resultRepairMode (nextRows) }
|
||||
savePostImportSession (sessionId, session)
|
||||
const sessionLoaded = loadPostImportSession (sessionId)
|
||||
if (!(serialised.complete) && sessionLoaded?.rows.length !== nextRows.length)
|
||||
return
|
||||
|
||||
navigate (appendPostNewSessionId (serialised.path, sessionId))
|
||||
}
|
||||
catch (requestError)
|
||||
{
|
||||
|
||||
新しい課題から参照
ユーザをブロックする