このコミットが含まれているのは:
@@ -1,7 +1,7 @@
|
||||
import { useQueries } from '@tanstack/react-query'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import PageTitle from '@/components/common/PageTitle'
|
||||
@@ -15,7 +15,6 @@ import { toast } from '@/components/ui/use-toast'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { apiGet, apiPost, isApiError } from '@/lib/api'
|
||||
import useDialogue from '@/lib/dialogues/useDialogue'
|
||||
import { fetchPost } from '@/lib/posts'
|
||||
import {
|
||||
appendPostNewSessionId,
|
||||
parsePostNewState,
|
||||
@@ -23,6 +22,7 @@ import {
|
||||
serialisePostNewState,
|
||||
} from '@/lib/postNewQueryState'
|
||||
import {
|
||||
applyThumbnailWarning,
|
||||
buildNextEditedRow,
|
||||
canEditReviewRow,
|
||||
canRetryResultRow,
|
||||
@@ -40,11 +40,11 @@ import {
|
||||
resultRowMessages,
|
||||
reviewSummaryCounts,
|
||||
retryImportRow,
|
||||
savePostImportSession,
|
||||
loadPostImportSession,
|
||||
savePostImportSession,
|
||||
} from '@/lib/postImportSession'
|
||||
import { postsKeys } from '@/lib/queryKeys'
|
||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { canEditContent } from '@/lib/users'
|
||||
import Forbidden from '@/pages/Forbidden'
|
||||
|
||||
@@ -56,7 +56,7 @@ import type {
|
||||
PostImportRow,
|
||||
PostImportSession,
|
||||
} from '@/lib/postImportSession'
|
||||
import type { Post, User } from '@/types'
|
||||
import type { User } from '@/types'
|
||||
|
||||
type Props = { user: User | null }
|
||||
|
||||
@@ -71,12 +71,20 @@ type PreviewResponse = {
|
||||
tags?: string
|
||||
fieldWarnings?: Record<string, string[]>
|
||||
baseWarnings?: string[]
|
||||
existingPost?: { id: number } | null }
|
||||
existingPost?: {
|
||||
id: number
|
||||
title: string
|
||||
url: string
|
||||
thumbnailUrl?: string } | null }
|
||||
|
||||
type BulkApiRow = {
|
||||
status: 'created' | 'skipped' | 'failed'
|
||||
post?: { id: number }
|
||||
existingPost?: { id: number } | null
|
||||
existingPost?: {
|
||||
id: number
|
||||
title: string
|
||||
url: string
|
||||
thumbnailUrl?: string } | null
|
||||
fieldWarnings?: Record<string, string[]>
|
||||
baseWarnings?: string[]
|
||||
errors?: Record<string, string[]>
|
||||
@@ -84,7 +92,7 @@ type BulkApiRow = {
|
||||
recoverable?: boolean }
|
||||
|
||||
type ExistingSkippedRowsProps = {
|
||||
open: boolean
|
||||
id: string
|
||||
rows: PostImportRow[] }
|
||||
|
||||
const isRepairRow = (row: PostImportRow): boolean =>
|
||||
@@ -117,6 +125,8 @@ const buildDryRunFormData = (row: PostImportRow): FormData => {
|
||||
String (row.attributes.originalCreatedBefore ?? ''))
|
||||
formData.append ('video_ms', String (row.attributes.videoMs ?? ''))
|
||||
formData.append ('duration', String (row.attributes.duration ?? ''))
|
||||
if (!(row.attributes.thumbnailBase) && row.thumbnailFile != null)
|
||||
formData.append ('thumbnail', row.thumbnailFile)
|
||||
return formData
|
||||
}
|
||||
|
||||
@@ -125,7 +135,8 @@ const mergeDryRunRow = (
|
||||
currentRow: PostImportRow,
|
||||
result: PreviewResponse,
|
||||
): PostImportRow =>
|
||||
initialisePreviewRows ([{
|
||||
applyThumbnailWarning (
|
||||
initialisePreviewRows ([{
|
||||
...currentRow,
|
||||
url: result.url,
|
||||
attributes: {
|
||||
@@ -149,11 +160,12 @@ const mergeDryRunRow = (
|
||||
: 'ready',
|
||||
skipReason: result.existingPost?.id != null ? 'existing' : undefined,
|
||||
existingPostId: result.existingPost?.id,
|
||||
existingPost: result.existingPost ?? undefined,
|
||||
importStatus:
|
||||
currentRow.importStatus === 'created'
|
||||
? 'created'
|
||||
: 'pending',
|
||||
recoverable: undefined }])[0]
|
||||
recoverable: undefined }])[0])
|
||||
|
||||
|
||||
const indexedBulkResults = (
|
||||
@@ -178,6 +190,7 @@ const indexedBulkResults = (
|
||||
sourceRow: row.sourceRow,
|
||||
status: 'skipped',
|
||||
existingPostId: result.existingPost.id,
|
||||
existingPost: result.existingPost,
|
||||
fieldWarnings: result.fieldWarnings,
|
||||
baseWarnings: result.baseWarnings,
|
||||
errors: result.errors }
|
||||
@@ -191,6 +204,81 @@ const indexedBulkResults = (
|
||||
recoverable: result?.recoverable }
|
||||
})
|
||||
|
||||
const serialisableRowJson = (row: PostImportRow): string =>
|
||||
JSON.stringify ({
|
||||
url: row.url,
|
||||
sourceRow: row.sourceRow,
|
||||
attributes: row.attributes,
|
||||
fieldWarnings: row.fieldWarnings,
|
||||
baseWarnings: row.baseWarnings,
|
||||
validationErrors: row.validationErrors,
|
||||
importErrors: row.importErrors,
|
||||
provenance: row.provenance,
|
||||
tagSources: row.tagSources,
|
||||
status: row.status,
|
||||
skipReason: row.skipReason,
|
||||
existingPostId: row.existingPostId,
|
||||
existingPost: row.existingPost,
|
||||
metadataUrl: row.metadataUrl,
|
||||
resetSnapshot: row.resetSnapshot,
|
||||
createdPostId: row.createdPostId,
|
||||
importStatus: row.importStatus,
|
||||
recoverable: row.recoverable })
|
||||
|
||||
const serialisableRowsEqual = (
|
||||
left: PostImportRow[],
|
||||
right: PostImportRow[],
|
||||
): boolean =>
|
||||
left.length === right.length
|
||||
&& left.every ((row, index) =>
|
||||
serialisableRowJson (row) === serialisableRowJson (right[index]))
|
||||
|
||||
const mergePreviewRow = (
|
||||
currentRow: PostImportRow,
|
||||
preview: PreviewResponse,
|
||||
): PostImportRow => {
|
||||
const nextRow: PostImportRow = {
|
||||
...currentRow,
|
||||
url: preview.url,
|
||||
fieldWarnings: preview.fieldWarnings ?? { },
|
||||
baseWarnings: preview.baseWarnings ?? [],
|
||||
existingPostId: preview.existingPost?.id,
|
||||
existingPost: preview.existingPost ?? undefined,
|
||||
skipReason:
|
||||
preview.existingPost?.id != null
|
||||
? 'existing'
|
||||
: currentRow.skipReason === 'manual'
|
||||
? 'manual'
|
||||
: undefined,
|
||||
status:
|
||||
Object.keys (preview.fieldWarnings ?? { }).length > 0
|
||||
|| (preview.baseWarnings?.length ?? 0) > 0
|
||||
? 'warning'
|
||||
: 'ready' }
|
||||
|
||||
if (currentRow.provenance.title !== 'manual')
|
||||
nextRow.attributes.title = preview.title ?? ''
|
||||
if (currentRow.provenance.thumbnailBase !== 'manual')
|
||||
nextRow.attributes.thumbnailBase = preview.thumbnailBase ?? ''
|
||||
if (currentRow.provenance.originalCreatedFrom !== 'manual')
|
||||
nextRow.attributes.originalCreatedFrom = preview.originalCreatedFrom ?? ''
|
||||
if (currentRow.provenance.originalCreatedBefore !== 'manual')
|
||||
nextRow.attributes.originalCreatedBefore = preview.originalCreatedBefore ?? ''
|
||||
if (currentRow.provenance.tags !== 'manual')
|
||||
{
|
||||
nextRow.attributes.tags = preview.tags ?? ''
|
||||
nextRow.tagSources = {
|
||||
automatic: preview.tags ?? '',
|
||||
manual: currentRow.tagSources?.manual ?? '' }
|
||||
}
|
||||
if (currentRow.provenance.videoMs !== 'manual')
|
||||
nextRow.attributes.videoMs = preview.videoMs ?? ''
|
||||
if (currentRow.provenance.duration !== 'manual')
|
||||
nextRow.attributes.duration = preview.duration ?? ''
|
||||
|
||||
return applyThumbnailWarning (nextRow)
|
||||
}
|
||||
|
||||
|
||||
const buildBulkFormData = (rows: PostImportRow[]): FormData => {
|
||||
const formData = new FormData ()
|
||||
@@ -207,58 +295,36 @@ const buildBulkFormData = (rows: PostImportRow[]): FormData => {
|
||||
original_created_before: row.attributes.originalCreatedBefore ?? '',
|
||||
video_ms: row.attributes.videoMs ?? '',
|
||||
duration: row.attributes.duration ?? '' }))))
|
||||
rows.forEach ((row, index) => {
|
||||
if (!(row.attributes.thumbnailBase) && row.thumbnailFile != null)
|
||||
formData.append (`thumbnails[${ index }]`, row.thumbnailFile)
|
||||
})
|
||||
return formData
|
||||
}
|
||||
|
||||
|
||||
const ExistingSkippedRows: FC<ExistingSkippedRowsProps> = ({ open, rows }) => {
|
||||
const existingPostIds = useMemo (
|
||||
() =>
|
||||
open
|
||||
? Array.from (
|
||||
new Set (
|
||||
rows
|
||||
.map (row => row.existingPostId)
|
||||
.filter ((postId): postId is number => postId != null),
|
||||
),
|
||||
)
|
||||
: [],
|
||||
[open, rows],
|
||||
)
|
||||
const posts = useQueries ({
|
||||
queries: existingPostIds.map (postId => ({
|
||||
enabled: open,
|
||||
queryKey: postsKeys.show (String (postId)),
|
||||
queryFn: () => fetchPost (String (postId)) })),
|
||||
})
|
||||
const postsById = new Map<number, Post> (
|
||||
posts
|
||||
.map (query => query.data)
|
||||
.filter ((post): post is Post => post != null)
|
||||
.map (post => [post.id, post]),
|
||||
)
|
||||
const ExistingSkippedRows: FC<ExistingSkippedRowsProps> = ({ id, rows }) => {
|
||||
const posts = rows
|
||||
.map (row => row.existingPost)
|
||||
.filter ((post): post is NonNullable<PostImportRow['existingPost']> => post != null)
|
||||
|
||||
return (
|
||||
<div className="mt-3 max-h-72 overflow-y-auto rounded border">
|
||||
<div id={id} className="mt-3 max-h-72 overflow-y-auto rounded border">
|
||||
<div className="divide-y">
|
||||
{existingPostIds.map (postId => {
|
||||
const post = postsById.get (postId)
|
||||
if (post == null)
|
||||
return <div key={postId} className="h-14"/>
|
||||
|
||||
{posts.map (post => {
|
||||
return (
|
||||
<PrefetchLink
|
||||
key={postId}
|
||||
key={post.id}
|
||||
to={`/posts/${ post.id }`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-3">
|
||||
<PostThumbnailPreview
|
||||
url={post.thumbnail ?? ''}
|
||||
url={post.thumbnailUrl ?? ''}
|
||||
className="h-10 w-10 shrink-0"/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">
|
||||
{post.title ?? ''}
|
||||
{post.title}
|
||||
</div>
|
||||
<div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
|
||||
{post.url}
|
||||
@@ -294,6 +360,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
const sessionIdRef = useRef<string | null> (null)
|
||||
const persistedSearchRef = useRef<string | null> (null)
|
||||
const persistSequenceRef = useRef (0)
|
||||
const previewSequenceRef = useRef (0)
|
||||
|
||||
const persistSession = async (
|
||||
nextSession: PostImportSession,
|
||||
@@ -308,10 +375,15 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
if (persistSequenceRef.current !== sequence)
|
||||
return sessionRef.current
|
||||
|
||||
savePostImportSession (sessionId, nextSession)
|
||||
const saved = savePostImportSession (sessionId, nextSession)
|
||||
const loadedSession = loadPostImportSession (sessionId)
|
||||
if (!(serialised.complete)
|
||||
&& loadedSession?.rows.length !== nextSession.rows.length)
|
||||
const canRecoverFromUrlOnly = serialised.complete
|
||||
if (!(canRecoverFromUrlOnly)
|
||||
&& (!(saved))
|
||||
&& !(serialisableRowsEqual (loadedSession?.rows ?? [], nextSession.rows)))
|
||||
return null
|
||||
if (saved
|
||||
&& !(serialisableRowsEqual (loadedSession?.rows ?? [], nextSession.rows)))
|
||||
return null
|
||||
|
||||
const persistedSession = buildSession (nextSession.rows)
|
||||
@@ -334,11 +406,80 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
|
||||
useEffect (() => {
|
||||
let active = true
|
||||
const previewSequence = ++previewSequenceRef.current
|
||||
const controllers = new Set<AbortController> ()
|
||||
|
||||
if (sessionRef.current != null && persistedSearchRef.current === location.search)
|
||||
return () => {
|
||||
previewSequenceRef.current = previewSequence
|
||||
}
|
||||
|
||||
const refreshRows = async (baseSession: PostImportSession) => {
|
||||
let nextIndex = 0
|
||||
const worker = async () => {
|
||||
while (active)
|
||||
{
|
||||
const index = nextIndex
|
||||
++nextIndex
|
||||
if (index >= baseSession.rows.length)
|
||||
return
|
||||
|
||||
const baseRow = baseSession.rows[index]
|
||||
const controller = new AbortController ()
|
||||
controllers.add (controller)
|
||||
try
|
||||
{
|
||||
const preview = await apiGet<PreviewResponse> ('/posts/preview', {
|
||||
params: { url: baseRow.url },
|
||||
signal: controller.signal })
|
||||
if (!(active) || previewSequenceRef.current !== previewSequence)
|
||||
return
|
||||
|
||||
const latestSession = sessionRef.current ?? baseSession
|
||||
const currentRow = latestSession.rows.find (
|
||||
row => row.sourceRow === baseRow.sourceRow)
|
||||
if (currentRow == null)
|
||||
continue
|
||||
const mergedRow = mergePreviewRow (currentRow, preview)
|
||||
const nextRows = replaceImportRow (latestSession.rows, mergedRow)
|
||||
const nextSession = buildSession (nextRows)
|
||||
sessionRef.current = nextSession
|
||||
setSession (nextSession)
|
||||
}
|
||||
catch (requestError)
|
||||
{
|
||||
if (controller.signal.aborted)
|
||||
return
|
||||
if (!(active) || previewSequenceRef.current !== previewSequence)
|
||||
return
|
||||
const latestSession = sessionRef.current ?? baseSession
|
||||
const currentRow = latestSession.rows.find (
|
||||
row => row.sourceRow === baseRow.sourceRow)
|
||||
if (currentRow == null)
|
||||
continue
|
||||
const nextRows = replaceImportRow (
|
||||
latestSession.rows,
|
||||
applyThumbnailWarning ({
|
||||
...currentRow,
|
||||
existingPostId: undefined,
|
||||
existingPost: undefined }))
|
||||
const nextSession = buildSession (nextRows)
|
||||
sessionRef.current = nextSession
|
||||
setSession (nextSession)
|
||||
if (!(isApiError (requestError)))
|
||||
continue
|
||||
}
|
||||
finally
|
||||
{
|
||||
controllers.delete (controller)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all (
|
||||
Array.from ({ length: Math.min (4, baseSession.rows.length) }, () => worker ()))
|
||||
}
|
||||
|
||||
const hydrate = async () => {
|
||||
const parsed = await parsePostNewState (location.search)
|
||||
if (!(active))
|
||||
@@ -365,7 +506,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
params: { url: parsed.source } })
|
||||
if (!(active))
|
||||
return
|
||||
const rows = initialisePreviewRows ([{
|
||||
const rows = [applyThumbnailWarning (initialisePreviewRows ([{
|
||||
sourceRow: 1,
|
||||
url: preview.url,
|
||||
attributes: {
|
||||
@@ -400,6 +541,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
: 'ready',
|
||||
skipReason: preview.existingPost?.id != null ? 'existing' : undefined,
|
||||
existingPostId: preview.existingPost?.id,
|
||||
existingPost: preview.existingPost ?? undefined,
|
||||
resetSnapshot: {
|
||||
url: preview.url,
|
||||
attributes: {
|
||||
@@ -425,7 +567,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
automatic: preview.tags ?? '',
|
||||
manual: '' },
|
||||
fieldWarnings: preview.fieldWarnings ?? { },
|
||||
baseWarnings: preview.baseWarnings ?? [] } }])
|
||||
baseWarnings: preview.baseWarnings ?? [] } }])[0])]
|
||||
const persisted = await persistSession (buildSession (rows))
|
||||
if (persisted == null)
|
||||
return
|
||||
@@ -442,12 +584,14 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
{
|
||||
await persistSession (loaded)
|
||||
}
|
||||
void refreshRows (loaded)
|
||||
}
|
||||
|
||||
void hydrate ()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
controllers.forEach (controller => controller.abort ())
|
||||
}
|
||||
}, [location.search, navigate])
|
||||
|
||||
@@ -456,13 +600,6 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
sessionRef.current = session
|
||||
}, [session])
|
||||
|
||||
useEffect (() => {
|
||||
if (session == null || session.rows.length === 0)
|
||||
return
|
||||
if (session.rows.every (row => isCompletedReviewRow (row)))
|
||||
finishImport ()
|
||||
}, [session])
|
||||
|
||||
useEffect (() => {
|
||||
if (editingRow == null || session?.repairMode !== 'failed')
|
||||
return
|
||||
@@ -504,7 +641,8 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
return {
|
||||
...row,
|
||||
skipReason: checked ? 'manual' : undefined,
|
||||
existingPostId: checked ? undefined : row.existingPostId }
|
||||
existingPostId: checked ? undefined : row.existingPostId,
|
||||
existingPost: checked ? undefined : row.existingPost }
|
||||
})
|
||||
await persistSession (buildSession (nextRows))
|
||||
}
|
||||
@@ -533,7 +671,9 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
Object.entries (row.resetSnapshot.fieldWarnings)
|
||||
.map (([key, values]) => [key, [...values]])),
|
||||
baseWarnings: [...row.resetSnapshot.baseWarnings],
|
||||
metadataUrl: row.resetSnapshot.metadataUrl }
|
||||
metadataUrl: row.resetSnapshot.metadataUrl,
|
||||
thumbnailFile: undefined,
|
||||
thumbnailObjectUrl: undefined }
|
||||
: row
|
||||
const urlChanged = draft.url !== baseRow.url
|
||||
const nextRow = buildNextEditedRow (baseRow, draft, urlChanged)
|
||||
@@ -573,8 +713,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
baseWarnings: [],
|
||||
fieldWarnings: { },
|
||||
status: 'error' as const }
|
||||
setMessageRow (errorRow)
|
||||
return { saved: false, row: errorRow }
|
||||
return { saved: false, row: applyThumbnailWarning (errorRow) }
|
||||
}
|
||||
|
||||
toast ({ title: '行の再検証に失敗しました' })
|
||||
@@ -666,15 +805,17 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
const recoverable = recoverableRows.find (
|
||||
failedRow => failedRow.sourceRow === row.sourceRow)
|
||||
if (recoverable == null)
|
||||
return row
|
||||
return {
|
||||
return applyThumbnailWarning (row)
|
||||
return applyThumbnailWarning ({
|
||||
...row,
|
||||
importStatus: 'pending',
|
||||
recoverable: true,
|
||||
validationErrors: recoverable.errors ?? { },
|
||||
importErrors: undefined }
|
||||
importErrors: undefined })
|
||||
})
|
||||
await persistSession (buildSession (nextRows))
|
||||
const persisted = await persistSession (buildSession (nextRows))
|
||||
if (persisted != null && processableImportRows (persisted.rows).length === 0)
|
||||
finishImport ()
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -729,15 +870,17 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
const recoverable = recoverableRows.find (
|
||||
failedRow => failedRow.sourceRow === row.sourceRow)
|
||||
if (recoverable == null)
|
||||
return row
|
||||
return {
|
||||
return applyThumbnailWarning (row)
|
||||
return applyThumbnailWarning ({
|
||||
...row,
|
||||
importStatus: 'pending',
|
||||
recoverable: true,
|
||||
validationErrors: recoverable.errors ?? { },
|
||||
importErrors: undefined }
|
||||
importErrors: undefined })
|
||||
})
|
||||
await persistSession (buildSession (nextRows))
|
||||
const persisted = await persistSession (buildSession (nextRows))
|
||||
if (persisted != null && processableImportRows (persisted.rows).length === 0)
|
||||
finishImport ()
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -769,8 +912,19 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
<div className="rounded-lg border p-4">
|
||||
<button
|
||||
type="button"
|
||||
className="text-left text-sm font-medium"
|
||||
aria-expanded={showExistingRows}
|
||||
aria-controls="post-import-existing-skips"
|
||||
className="flex items-center gap-2 text-left text-sm font-medium"
|
||||
onClick={() => setShowExistingRows (current => !(current))}>
|
||||
<span className="inline-flex">
|
||||
<ChevronRight
|
||||
className={cn (
|
||||
'h-4 w-4',
|
||||
showExistingRows && 'rotate-90',
|
||||
animationMode === 'off'
|
||||
? ''
|
||||
: 'transition-transform')}/>
|
||||
</span>
|
||||
既存投稿による自動スキップ {counts.existingSkipped}件
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
@@ -785,7 +939,9 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={existingRowsTransition}
|
||||
className="overflow-hidden">
|
||||
<ExistingSkippedRows open={showExistingRows} rows={existingRows}/>
|
||||
<ExistingSkippedRows
|
||||
id="post-import-existing-skips"
|
||||
rows={existingRows}/>
|
||||
</motion.div>)}
|
||||
</AnimatePresence>
|
||||
</div>)}
|
||||
@@ -848,7 +1004,7 @@ const PostImportFooter = (
|
||||
<span>作成対象 {creatableCount}件</span>
|
||||
<span>手動スキップ {manualSkippedCount}件</span>
|
||||
<span>既存投稿による自動スキップ {existingSkippedCount}件</span>
|
||||
<span>error/未処理 {pendingOrErrorCount}件</span>
|
||||
<span>登録不可/未処理 {pendingOrErrorCount}件</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button type="button" variant="outline" onClick={onBack} disabled={loading}>
|
||||
|
||||
@@ -47,7 +47,11 @@ type PreviewResponse = {
|
||||
tags?: string
|
||||
fieldWarnings?: Record<string, string[]>
|
||||
baseWarnings?: string[]
|
||||
existingPost?: { id: number } | null }
|
||||
existingPost?: {
|
||||
id: number
|
||||
title: string
|
||||
url: string
|
||||
thumbnailUrl?: string } | null }
|
||||
|
||||
const MAX_ROWS = 100
|
||||
const SOURCE_ERROR_ID = 'post-import-source-error'
|
||||
@@ -104,6 +108,7 @@ const previewRowFromResult = (
|
||||
: 'ready',
|
||||
skipReason: result.existingPost?.id != null ? 'existing' : undefined,
|
||||
existingPostId: result.existingPost?.id,
|
||||
existingPost: result.existingPost ?? undefined,
|
||||
resetSnapshot: {
|
||||
url: result.url,
|
||||
attributes: {
|
||||
@@ -134,25 +139,57 @@ const previewRowFromResult = (
|
||||
}
|
||||
|
||||
|
||||
const fetchPreviewRows = async (urls: string[]): Promise<PostImportRow[]> => {
|
||||
const results: PostImportRow[] = new Array (urls.length)
|
||||
const fetchPreviewRows = async (
|
||||
rows: Array<{ sourceRow: number
|
||||
url: string }>,
|
||||
signal: AbortSignal,
|
||||
): Promise<PostImportRow[]> => {
|
||||
const results: PostImportRow[] = new Array (rows.length)
|
||||
let nextIndex = 0
|
||||
const worker = async () => {
|
||||
while (nextIndex < urls.length)
|
||||
while (nextIndex < rows.length)
|
||||
{
|
||||
const index = nextIndex
|
||||
++nextIndex
|
||||
const row = rows[index]
|
||||
const result = await apiGet<PreviewResponse> ('/posts/preview', {
|
||||
params: { url: urls[index] } })
|
||||
results[index] = previewRowFromResult (index + 1, result)
|
||||
params: { url: row.url },
|
||||
signal })
|
||||
results[index] = previewRowFromResult (row.sourceRow, result)
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all (
|
||||
Array.from ({ length: Math.min (4, urls.length) }, () => worker ()))
|
||||
Array.from ({ length: Math.min (4, rows.length) }, () => worker ()))
|
||||
return results
|
||||
}
|
||||
|
||||
const serialisableRowJson = (row: PostImportRow): string =>
|
||||
JSON.stringify ({
|
||||
sourceRow: row.sourceRow,
|
||||
url: row.url,
|
||||
attributes: row.attributes,
|
||||
fieldWarnings: row.fieldWarnings,
|
||||
baseWarnings: row.baseWarnings,
|
||||
validationErrors: row.validationErrors,
|
||||
provenance: row.provenance,
|
||||
tagSources: row.tagSources,
|
||||
status: row.status,
|
||||
skipReason: row.skipReason,
|
||||
existingPostId: row.existingPostId,
|
||||
existingPost: row.existingPost,
|
||||
resetSnapshot: row.resetSnapshot,
|
||||
importStatus: row.importStatus,
|
||||
recoverable: row.recoverable })
|
||||
|
||||
const sameRows = (
|
||||
left: PostImportRow[],
|
||||
right: PostImportRow[],
|
||||
): boolean =>
|
||||
left.length === right.length
|
||||
&& left.every ((row, index) =>
|
||||
serialisableRowJson (row) === serialisableRowJson (right[index]))
|
||||
|
||||
|
||||
const PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||
const editable = canEditContent (user)
|
||||
@@ -163,6 +200,7 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||
const [sourceError, setSourceError] = useState<string | null> (null)
|
||||
const saveTimer = useRef<number | null> (null)
|
||||
const editedRef = useRef (false)
|
||||
const previewController = useRef<AbortController | null> (null)
|
||||
|
||||
const lineCount = countImportSourceLines (source)
|
||||
const messages = sourceError != null ? [sourceError] : []
|
||||
@@ -203,6 +241,10 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||
}
|
||||
}, [source])
|
||||
|
||||
useEffect (() => () => {
|
||||
previewController.current?.abort ()
|
||||
}, [])
|
||||
|
||||
const preview = async () => {
|
||||
if (lineCount === 0)
|
||||
{
|
||||
@@ -224,8 +266,17 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||
setSourceError (null)
|
||||
try
|
||||
{
|
||||
const urls = source.split (/\r\n|\n|\r/).map (line => line.trim ()).filter (line => line !== '')
|
||||
const rows = await fetchPreviewRows (urls)
|
||||
previewController.current?.abort ()
|
||||
const controller = new AbortController ()
|
||||
previewController.current = controller
|
||||
const rows = await fetchPreviewRows (
|
||||
source
|
||||
.split (/\r\n|\n|\r/)
|
||||
.map ((line, index) => ({
|
||||
sourceRow: index + 1,
|
||||
url: line.trim () }))
|
||||
.filter (row => row.url !== ''),
|
||||
controller.signal)
|
||||
const urlIssues = urlIssuesFromRows (rows, source)
|
||||
if (urlIssues.length > 0)
|
||||
{
|
||||
@@ -242,9 +293,13 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||
source,
|
||||
rows: nextRows,
|
||||
repairMode: resultRepairMode (nextRows) }
|
||||
savePostImportSession (sessionId, session)
|
||||
const saved = savePostImportSession (sessionId, session)
|
||||
const sessionLoaded = loadPostImportSession (sessionId)
|
||||
if (!(serialised.complete) && sessionLoaded?.rows.length !== nextRows.length)
|
||||
if (!(serialised.complete)
|
||||
&& (!(saved))
|
||||
&& !(sameRows (sessionLoaded?.rows ?? [], nextRows)))
|
||||
return
|
||||
if (saved && !(sameRows (sessionLoaded?.rows ?? [], nextRows)))
|
||||
return
|
||||
|
||||
navigate (appendPostNewSessionId (serialised.path, sessionId))
|
||||
|
||||
新しい課題から参照
ユーザをブロックする