このコミットが含まれているのは:
@@ -1,8 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
import { useNavigate,
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||||
useParams,
|
|
||||||
useSearchParams } from 'react-router-dom'
|
|
||||||
|
|
||||||
import PageTitle from '@/components/common/PageTitle'
|
import PageTitle from '@/components/common/PageTitle'
|
||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
@@ -20,14 +18,14 @@ import { loadPostImportSession,
|
|||||||
mergeValidatedImportRows,
|
mergeValidatedImportRows,
|
||||||
processableImportRows,
|
processableImportRows,
|
||||||
reviewSummaryCounts,
|
reviewSummaryCounts,
|
||||||
savePostImportSession,
|
savePostImportSession } from '@/lib/postImportSession'
|
||||||
type PostImportResultRow,
|
|
||||||
type PostImportRow,
|
|
||||||
type PostImportSession } from '@/lib/postImportSession'
|
|
||||||
import Forbidden from '@/pages/Forbidden'
|
import Forbidden from '@/pages/Forbidden'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
|
import type { PostImportResultRow,
|
||||||
|
PostImportRow,
|
||||||
|
PostImportSession } from '@/lib/postImportSession'
|
||||||
import type { User } from '@/types'
|
import type { User } from '@/types'
|
||||||
|
|
||||||
type Props = { user: User | null }
|
type Props = { user: User | null }
|
||||||
@@ -44,297 +42,298 @@ type Draft = {
|
|||||||
|
|
||||||
|
|
||||||
const PostImportReviewPage: FC<Props> = ({ user }) => {
|
const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||||
const editable = canEditContent (user)
|
const editable = canEditContent (user)
|
||||||
const navigate = useNavigate ()
|
const navigate = useNavigate ()
|
||||||
const { sessionId } = useParams ()
|
const { sessionId } = useParams ()
|
||||||
const [searchParams, setSearchParams] = useSearchParams ()
|
const [searchParams, setSearchParams] = useSearchParams ()
|
||||||
|
|
||||||
const [session, setSession] = useState<PostImportSession | null> (null)
|
const [session, setSession] = useState<PostImportSession | null> (null)
|
||||||
const [loading, setLoading] = useState (false)
|
const [loading, setLoading] = useState (false)
|
||||||
const [missing, setMissing] = useState (false)
|
const [missing, setMissing] = useState (false)
|
||||||
const [savingRow, setSavingRow] = useState<number | null> (null)
|
const [savingRow, setSavingRow] = useState<number | null> (null)
|
||||||
const [dialogMessageRow, setDialogMessageRow] = useState<PostImportRow | null> (null)
|
const [dialogMessageRow, setDialogMessageRow] = useState<PostImportRow | null> (null)
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
if (sessionId == null)
|
if (sessionId == null)
|
||||||
return
|
return
|
||||||
|
|
||||||
const loaded = loadPostImportSession (sessionId, message =>
|
const loaded = loadPostImportSession (sessionId, message =>
|
||||||
toast ({ title: '取込状態を復元できませんでした', description: message }))
|
toast ({ title: '取込状態を復元できませんでした', description: message }))
|
||||||
setSession (loaded)
|
setSession (loaded)
|
||||||
setMissing (loaded == null)
|
setMissing (loaded == null)
|
||||||
}, [sessionId])
|
}, [sessionId])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
if (sessionId == null || session == null)
|
if (sessionId == null || session == null)
|
||||||
return
|
return
|
||||||
|
|
||||||
savePostImportSession (sessionId, session, message =>
|
savePostImportSession (sessionId, session, message =>
|
||||||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||||
}, [session, sessionId])
|
}, [session, sessionId])
|
||||||
|
|
||||||
const rows = session?.rows ?? []
|
const rows = session?.rows ?? []
|
||||||
const editingSourceRow = Number (searchParams.get ('edit') ?? '')
|
const editingSourceRow = Number (searchParams.get ('edit') ?? '')
|
||||||
const editingRow =
|
const editingRow =
|
||||||
Number.isFinite (editingSourceRow)
|
Number.isFinite (editingSourceRow)
|
||||||
? rows.find (_1 => _1.sourceRow === editingSourceRow) ?? null
|
? rows.find (_1 => _1.sourceRow === editingSourceRow) ?? null
|
||||||
: null
|
: null
|
||||||
const counts = useMemo (() => reviewSummaryCounts (rows), [rows])
|
const counts = useMemo (() => reviewSummaryCounts (rows), [rows])
|
||||||
const processable = useMemo (
|
const processable = useMemo (
|
||||||
() => processableImportRows (rows),
|
() => processableImportRows (rows),
|
||||||
[rows])
|
[rows])
|
||||||
const creatable = useMemo (
|
const creatable = useMemo (
|
||||||
() => creatableImportRows (rows),
|
() => creatableImportRows (rows),
|
||||||
[rows])
|
[rows])
|
||||||
const reviewRows =
|
const reviewRows =
|
||||||
session?.repairMode === 'failed'
|
session?.repairMode === 'failed'
|
||||||
? [...rows].sort ((a, b) => {
|
? (
|
||||||
|
[...rows].sort ((a, b) => {
|
||||||
const aFailed = a.importStatus === 'failed' ? 0 : 1
|
const aFailed = a.importStatus === 'failed' ? 0 : 1
|
||||||
const bFailed = b.importStatus === 'failed' ? 0 : 1
|
const bFailed = b.importStatus === 'failed' ? 0 : 1
|
||||||
return aFailed - bFailed || a.sourceRow - b.sourceRow
|
return aFailed - bFailed || a.sourceRow - b.sourceRow
|
||||||
})
|
}))
|
||||||
: rows
|
: rows
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
if (editingRow == null || session?.repairMode !== 'failed')
|
if (editingRow == null || session?.repairMode !== 'failed')
|
||||||
return
|
return
|
||||||
|
|
||||||
const element = document.getElementById (`post-import-row-${ editingRow.sourceRow }`)
|
const element = document.getElementById (`post-import-row-${ editingRow.sourceRow }`)
|
||||||
element?.scrollIntoView ({ block: 'center', behavior: 'smooth' })
|
element?.scrollIntoView ({ block: 'center', behavior: 'smooth' })
|
||||||
}, [editingRow, session?.repairMode])
|
}, [editingRow, session?.repairMode])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
if (editingRow == null)
|
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<boolean> => {
|
|
||||||
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 <Forbidden/>
|
|
||||||
|
|
||||||
if (missing || sessionId == null || session == null)
|
|
||||||
{
|
{
|
||||||
return (
|
setDialogMessageRow (null)
|
||||||
<MainArea>
|
return
|
||||||
<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>)
|
|
||||||
}
|
}
|
||||||
|
if (dialogMessageRow?.sourceRow !== editingRow.sourceRow)
|
||||||
|
setDialogMessageRow (null)
|
||||||
|
}, [editingRow, dialogMessageRow])
|
||||||
|
|
||||||
return (
|
const updateSessionRows = (nextRows: PostImportRow[]) =>
|
||||||
<>
|
setSession (current =>
|
||||||
<Helmet>
|
current != null ? { ...current, rows: nextRows } : current)
|
||||||
<title>{`投稿インポート確認 | ${ SITE_TITLE }`}</title>
|
|
||||||
</Helmet>
|
|
||||||
|
|
||||||
<MainArea className="min-h-0">
|
const saveDraft = async (
|
||||||
<div className="mx-auto max-w-6xl space-y-4 p-4">
|
{ draft, resetRequested, resetSnapshot }: {
|
||||||
<PageTitle>投稿情報の確認・編輯</PageTitle>
|
draft: Draft
|
||||||
|
resetRequested: boolean
|
||||||
|
resetSnapshot: PostImportRow['resetSnapshot'] },
|
||||||
|
): Promise<boolean> => {
|
||||||
|
if (session == null)
|
||||||
|
return false
|
||||||
|
|
||||||
<div className="space-y-3">
|
if (editingRow == null)
|
||||||
{reviewRows.map (row => (
|
return false
|
||||||
<div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}>
|
|
||||||
<PostImportRowSummary
|
const baseRow =
|
||||||
row={row}
|
resetRequested
|
||||||
onEdit={() => {
|
? { ...editingRow,
|
||||||
setSearchParams ({ edit: String (row.sourceRow) })
|
url: resetSnapshot.url,
|
||||||
}}/>
|
attributes: { ...resetSnapshot.attributes },
|
||||||
</div>))}
|
provenance: { ...resetSnapshot.provenance },
|
||||||
</div>
|
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 <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>
|
</div>
|
||||||
</MainArea>
|
</MainArea>)
|
||||||
|
}
|
||||||
|
|
||||||
<PostImportFooter
|
return (
|
||||||
loading={loading}
|
<>
|
||||||
processableCount={processable.length}
|
<Helmet>
|
||||||
creatableCount={creatable.length}
|
<title>{`投稿インポート確認 | ${ SITE_TITLE }`}</title>
|
||||||
skipPlannedCount={counts.skipPlanned}
|
</Helmet>
|
||||||
onBack={() => navigate ('/posts/import')}
|
|
||||||
onSubmit={submit}/>
|
|
||||||
|
|
||||||
<PostImportRowDialog
|
<MainArea className="min-h-0">
|
||||||
open={editingRow != null}
|
<div className="mx-auto max-w-6xl space-y-4 p-4">
|
||||||
row={editingRow}
|
<PageTitle>投稿情報の確認・編輯</PageTitle>
|
||||||
messageRow={dialogMessageRow}
|
|
||||||
saving={savingRow != null}
|
<div className="space-y-3">
|
||||||
onOpenChange={open => {
|
{reviewRows.map (row => (
|
||||||
if (!open)
|
<div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}>
|
||||||
setSearchParams ({ })
|
<PostImportRowSummary
|
||||||
}}
|
row={row}
|
||||||
onSave={saveDraft}/>
|
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}/>
|
||||||
|
|
||||||
|
<PostImportRowDialog
|
||||||
|
open={editingRow != null}
|
||||||
|
row={editingRow}
|
||||||
|
messageRow={dialogMessageRow}
|
||||||
|
saving={savingRow != null}
|
||||||
|
onOpenChange={open => {
|
||||||
|
if (!open)
|
||||||
|
setSearchParams ({ })
|
||||||
|
}}
|
||||||
|
onSave={saveDraft}/>
|
||||||
|
</>)
|
||||||
}
|
}
|
||||||
|
|
||||||
const PostImportFooter = (
|
const PostImportFooter = (
|
||||||
@@ -343,13 +342,12 @@ const PostImportFooter = (
|
|||||||
creatableCount,
|
creatableCount,
|
||||||
skipPlannedCount,
|
skipPlannedCount,
|
||||||
onBack,
|
onBack,
|
||||||
onSubmit }: {
|
onSubmit }: { loading: boolean
|
||||||
loading: boolean
|
processableCount: number
|
||||||
processableCount: number
|
creatableCount: number
|
||||||
creatableCount: number
|
skipPlannedCount: number
|
||||||
skipPlannedCount: number
|
onBack: () => void
|
||||||
onBack: () => void
|
onSubmit: () => void },
|
||||||
onSubmit: () => void },
|
|
||||||
) => (
|
) => (
|
||||||
<div
|
<div
|
||||||
className="shrink-0 border-t bg-white/95 p-4 backdrop-blur
|
className="shrink-0 border-t bg-white/95 p-4 backdrop-blur
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ import { countImportSourceLines,
|
|||||||
loadPostImportSourceDraft,
|
loadPostImportSourceDraft,
|
||||||
savePostImportSession,
|
savePostImportSession,
|
||||||
savePostImportSourceDraft,
|
savePostImportSourceDraft,
|
||||||
validateImportSource,
|
validateImportSource } from '@/lib/postImportSession'
|
||||||
type PostImportRow } from '@/lib/postImportSession'
|
|
||||||
import Forbidden from '@/pages/Forbidden'
|
import Forbidden from '@/pages/Forbidden'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
|
import type { PostImportRow } from '@/lib/postImportSeession'
|
||||||
import type { User } from '@/types'
|
import type { User } from '@/types'
|
||||||
|
|
||||||
type Props = { user: User | null }
|
type Props = { user: User | null }
|
||||||
@@ -35,177 +35,176 @@ const SOURCE_ERROR_ID = 'post-import-source-error'
|
|||||||
const SOURCE_ISSUES_ID = 'post-import-source-issues'
|
const SOURCE_ISSUES_ID = 'post-import-source-issues'
|
||||||
|
|
||||||
const urlIssuesFromRows = (rows: PostImportRow[], source: string) => {
|
const urlIssuesFromRows = (rows: PostImportRow[], source: string) => {
|
||||||
const sourceLines = source.split (/\r\n|\n|\r/)
|
const sourceLines = source.split (/\r\n|\n|\r/)
|
||||||
|
|
||||||
return rows.flatMap (row =>
|
return rows.flatMap (row =>
|
||||||
(row.validationErrors.url ?? []).map (message => ({
|
(row.validationErrors.url ?? []).map (message => ({
|
||||||
sourceRow: row.sourceRow,
|
sourceRow: row.sourceRow,
|
||||||
message,
|
message,
|
||||||
url: sourceLines[row.sourceRow - 1]?.trim () ?? row.url })))
|
url: sourceLines[row.sourceRow - 1]?.trim () ?? row.url })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const PostImportSourcePage: FC<Props> = ({ user }) => {
|
const PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||||
const editable = canEditContent (user)
|
const editable = canEditContent (user)
|
||||||
const navigate = useNavigate ()
|
const navigate = useNavigate ()
|
||||||
const [source, setSource] = useState ('')
|
const [source, setSource] = useState ('')
|
||||||
const [loading, setLoading] = useState (false)
|
const [loading, setLoading] = useState (false)
|
||||||
const [sourceIssues, setSourceIssues] = useState<ReturnType<typeof validateImportSource>> ([])
|
const [sourceIssues, setSourceIssues] = useState<ReturnType<typeof validateImportSource>> ([])
|
||||||
const [sourceError, setSourceError] = useState<string | null> (null)
|
const [sourceError, setSourceError] = useState<string | null> (null)
|
||||||
const saveTimer = useRef<number | null> (null)
|
const saveTimer = useRef<number | null> (null)
|
||||||
const editedRef = useRef (false)
|
const editedRef = useRef (false)
|
||||||
|
|
||||||
const lineCount = countImportSourceLines (source)
|
const lineCount = countImportSourceLines (source)
|
||||||
const messages = sourceError != null ? [sourceError] : []
|
const messages = sourceError != null ? [sourceError] : []
|
||||||
const sourceDescribedBy = [
|
const sourceDescribedBy =
|
||||||
sourceError != null ? SOURCE_ERROR_ID : null,
|
[sourceError != null ? SOURCE_ERROR_ID : null,
|
||||||
sourceIssues.length > 0 ? SOURCE_ISSUES_ID : null]
|
sourceIssues.length > 0 ? SOURCE_ISSUES_ID : null]
|
||||||
.filter (_1 => _1 != null)
|
.filter (_1 => _1 != null)
|
||||||
.join (' ')
|
.join (' ')
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
cleanupExpiredPostImportSessions (message =>
|
cleanupExpiredPostImportSessions (message =>
|
||||||
toast ({ title: '保存済みデータを整理できませんでした', description: message }))
|
toast ({ title: '保存済みデータを整理できませんでした', description: message }))
|
||||||
|
|
||||||
const draft = loadPostImportSourceDraft (message =>
|
const draft = loadPostImportSourceDraft (message =>
|
||||||
toast ({ title: '保存済み入力を復元できませんでした', description: message }))
|
toast ({ title: '保存済み入力を復元できませんでした', description: message }))
|
||||||
if (!(editedRef.current))
|
|
||||||
{
|
|
||||||
setSource (current => current === '' ? draft.source : current)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect (() => {
|
if (!(editedRef.current))
|
||||||
if (saveTimer.current != null)
|
setSource (current => current === '' ? draft.source : current)
|
||||||
window.clearTimeout (saveTimer.current)
|
}, [])
|
||||||
saveTimer.current = window.setTimeout (() => {
|
|
||||||
savePostImportSourceDraft (source, message =>
|
|
||||||
toast ({ title: '入力内容を保存できませんでした', description: message }))
|
|
||||||
}, 300)
|
|
||||||
return () => {
|
|
||||||
if (saveTimer.current != null)
|
|
||||||
window.clearTimeout (saveTimer.current)
|
|
||||||
}
|
|
||||||
}, [source])
|
|
||||||
|
|
||||||
const preview = async () => {
|
useEffect (() => {
|
||||||
if (lineCount === 0)
|
if (saveTimer.current != null)
|
||||||
{
|
window.clearTimeout (saveTimer.current)
|
||||||
setSourceIssues ([])
|
|
||||||
setSourceError ('URL を入力してください.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const issues = validateImportSource (source)
|
saveTimer.current = window.setTimeout (() => {
|
||||||
if (issues.length > 0)
|
savePostImportSourceDraft (source, message =>
|
||||||
{
|
toast ({ title: '入力内容を保存できませんでした', description: message }))
|
||||||
setSourceIssues (issues)
|
}, 300)
|
||||||
setSourceError (null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading (true)
|
return () => {
|
||||||
setSourceIssues ([])
|
if (saveTimer.current != null)
|
||||||
setSourceError (null)
|
window.clearTimeout (saveTimer.current)
|
||||||
try
|
|
||||||
{
|
|
||||||
const data = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/preview', {
|
|
||||||
source })
|
|
||||||
const urlIssues = urlIssuesFromRows (data.rows, source)
|
|
||||||
if (urlIssues.length > 0)
|
|
||||||
{
|
|
||||||
setSourceIssues (urlIssues)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const sessionId = createPostImportSessionId ()
|
|
||||||
const saved = savePostImportSession (sessionId, {
|
|
||||||
source,
|
|
||||||
rows: initialisePreviewRows (data.rows),
|
|
||||||
repairMode: 'all' }, message =>
|
|
||||||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
|
||||||
if (!(saved))
|
|
||||||
return
|
|
||||||
navigate (`/posts/import/${ sessionId }/review`)
|
|
||||||
}
|
|
||||||
catch (requestError)
|
|
||||||
{
|
|
||||||
const message =
|
|
||||||
isApiError<{ message?: string, baseErrors?: string[] }> (requestError)
|
|
||||||
? requestError.response?.data?.message
|
|
||||||
?? requestError.response?.data?.baseErrors?.[0]
|
|
||||||
: undefined
|
|
||||||
setSourceError (message ?? '入力を確認してください.')
|
|
||||||
toast ({
|
|
||||||
title: '投稿情報の取得に失敗しました',
|
|
||||||
description: message ?? '入力を確認してください.' })
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
setLoading (false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}, [source])
|
||||||
|
|
||||||
if (!(editable))
|
const preview = async () => {
|
||||||
return <Forbidden/>
|
if (lineCount === 0)
|
||||||
|
{
|
||||||
|
setSourceIssues ([])
|
||||||
|
setSourceError ('URL を入力してください.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
const issues = validateImportSource (source)
|
||||||
<MainArea>
|
if (issues.length > 0)
|
||||||
<Helmet>
|
{
|
||||||
<title>{`投稿インポート | ${ SITE_TITLE }`}</title>
|
setSourceIssues (issues)
|
||||||
</Helmet>
|
setSourceError (null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
<Form className="max-w-4xl">
|
setLoading (true)
|
||||||
<PageTitle>投稿インポート</PageTitle>
|
setSourceIssues ([])
|
||||||
<FormField label="URL リスト">
|
setSourceError (null)
|
||||||
{() => (
|
try
|
||||||
<textarea
|
{
|
||||||
value={source}
|
const data = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/preview', { source })
|
||||||
rows={14}
|
const urlIssues = urlIssuesFromRows (data.rows, source)
|
||||||
aria-invalid={messages.length > 0 || sourceIssues.length > 0}
|
if (urlIssues.length > 0)
|
||||||
aria-describedby={sourceDescribedBy || undefined}
|
{
|
||||||
className={inputClass (
|
setSourceIssues (urlIssues)
|
||||||
messages.length > 0 || sourceIssues.length > 0,
|
return
|
||||||
'font-mono text-sm')}
|
}
|
||||||
onBlur={() => {
|
const sessionId = createPostImportSessionId ()
|
||||||
savePostImportSourceDraft (source, message =>
|
const saved = savePostImportSession (
|
||||||
toast ({
|
sessionId,
|
||||||
title: '入力内容を保存できませんでした',
|
{ source,
|
||||||
description: message }))
|
rows: initialisePreviewRows (data.rows),
|
||||||
}}
|
repairMode: 'all' },
|
||||||
onChange={ev => {
|
message => toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||||
editedRef.current = true
|
if (!(saved))
|
||||||
setSource (ev.target.value)
|
return
|
||||||
setSourceError (null)
|
navigate (`/posts/import/${ sessionId }/review`)
|
||||||
setSourceIssues ([])
|
}
|
||||||
}}/>)}
|
catch (requestError)
|
||||||
</FormField>
|
{
|
||||||
<div id={messages.length > 0 ? SOURCE_ERROR_ID : undefined}>
|
const message =
|
||||||
<FieldError messages={messages}/>
|
isApiError<{ message?: string, baseErrors?: string[] }> (requestError)
|
||||||
|
? (requestError.response?.data?.message
|
||||||
|
?? requestError.response?.data?.baseErrors?.[0])
|
||||||
|
: undefined
|
||||||
|
setSourceError (message ?? '入力を確認してください.')
|
||||||
|
toast ({ title: '投稿情報の取得に失敗しました',
|
||||||
|
description: message ?? '入力を確認してください.' })
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
setLoading (false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(editable))
|
||||||
|
return <Forbidden/>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MainArea>
|
||||||
|
<Helmet>
|
||||||
|
<title>{`投稿インポート | ${ SITE_TITLE }`}</title>
|
||||||
|
</Helmet>
|
||||||
|
|
||||||
|
<Form className="max-w-4xl">
|
||||||
|
<PageTitle>投稿インポート</PageTitle>
|
||||||
|
<FormField label="URL リスト">
|
||||||
|
{() => (
|
||||||
|
<textarea
|
||||||
|
value={source}
|
||||||
|
rows={14}
|
||||||
|
aria-invalid={messages.length > 0 || sourceIssues.length > 0}
|
||||||
|
aria-describedby={sourceDescribedBy || undefined}
|
||||||
|
className={inputClass (
|
||||||
|
messages.length > 0 || sourceIssues.length > 0,
|
||||||
|
'font-mono text-sm')}
|
||||||
|
onBlur={() => {
|
||||||
|
savePostImportSourceDraft (source, message =>
|
||||||
|
toast ({ title: '入力内容を保存できませんでした',
|
||||||
|
description: message }))
|
||||||
|
}}
|
||||||
|
onChange={ev => {
|
||||||
|
editedRef.current = true
|
||||||
|
setSource (ev.target.value)
|
||||||
|
setSourceError (null)
|
||||||
|
setSourceIssues ([])
|
||||||
|
}}/>)}
|
||||||
|
</FormField>
|
||||||
|
<div id={messages.length > 0 ? SOURCE_ERROR_ID : undefined}>
|
||||||
|
<FieldError messages={messages}/>
|
||||||
|
</div>
|
||||||
|
<ul
|
||||||
|
id={SOURCE_ISSUES_ID}
|
||||||
|
className="space-y-2 text-sm text-red-700 dark:text-red-300">
|
||||||
|
{sourceIssues.map (issue => (
|
||||||
|
<li key={`${ issue.sourceRow }-${ issue.message }-${ issue.url }`}>
|
||||||
|
<div>{issue.sourceRow} 行目: {issue.message}</div>
|
||||||
|
<div className="break-all font-mono text-xs">{issue.url}</div>
|
||||||
|
</li>))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||||
|
入力行数 {lineCount} / {MAX_ROWS}
|
||||||
</div>
|
</div>
|
||||||
<ul
|
<Button
|
||||||
id={SOURCE_ISSUES_ID}
|
type="button"
|
||||||
className="space-y-2 text-sm text-red-700 dark:text-red-300">
|
className="shrink-0"
|
||||||
{sourceIssues.map (issue => (
|
onClick={preview}
|
||||||
<li key={`${ issue.sourceRow }-${ issue.message }-${ issue.url }`}>
|
disabled={loading}>
|
||||||
<div>{issue.sourceRow} 行目: {issue.message}</div>
|
次へ
|
||||||
<div className="break-all font-mono text-xs">{issue.url}</div>
|
</Button>
|
||||||
</li>))}
|
</div>
|
||||||
</ul>
|
</Form>
|
||||||
|
</MainArea>)
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div className="text-sm text-neutral-600 dark:text-neutral-300">
|
|
||||||
入力行数 {lineCount} / {MAX_ROWS}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="shrink-0"
|
|
||||||
onClick={preview}
|
|
||||||
disabled={loading}>
|
|
||||||
次へ
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</MainArea>)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PostImportSourcePage
|
export default PostImportSourcePage
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする