このコミットが含まれているのは:
2026-07-17 21:06:26 +09:00
コミット 3820d3d4d5
21個のファイルの変更949行の追加1270行の削除
+276 -193
ファイルの表示
@@ -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)