このコミットが含まれているのは:
2026-07-17 21:06:26 +09:00
コミット 3820d3d4d5
21個のファイルの変更949行の追加1270行の削除
+6 -6
ファイルの表示
@@ -24,10 +24,10 @@ const PostOriginalCreatedTimeField: FC<Props> = (
<FormField label="オリジナルの作成日時" messages={errors}>
{({ describedBy, invalid }) => (
<>
<div className="my-1 flex flex-col gap-2 sm:flex-row sm:items-start">
<div className="min-w-0 flex-1">
<div className="my-1 flex">
<div className="w-80">
<DateTimeField
className="w-full"
className="mr-2"
disabled={disabled ?? false}
aria-describedby={describedBy}
aria-invalid={invalid}
@@ -61,10 +61,10 @@ const PostOriginalCreatedTimeField: FC<Props> = (
</div>
</div>
<div className="my-1 flex flex-col gap-2 sm:flex-row sm:items-start">
<div className="min-w-0 flex-1">
<div className="my-1 flex">
<div className="w-80">
<DateTimeField
className="w-full"
className="mr-2"
disabled={disabled}
aria-describedby={describedBy}
aria-invalid={invalid}
+7 -2
ファイルの表示
@@ -7,11 +7,15 @@ import type { FC } from 'react'
type Props = {
url: string
alt?: string
className?: string }
className?: string
referrerPolicy?: 'no-referrer' }
const PostThumbnailPreview: FC<Props> = (
{ url, alt = 'サムネール', className = 'h-16 w-16' },
{ url,
alt = 'サムネール',
className = 'h-16 w-16',
referrerPolicy },
) => {
const [failed, setFailed] = useState (false)
@@ -32,6 +36,7 @@ const PostThumbnailPreview: FC<Props> = (
<img
src={url}
alt={alt}
referrerPolicy={referrerPolicy}
className={cn (className, 'rounded border border-border object-cover')}
onError={() => setFailed (true)}/>)
}
+6 -59
ファイルの表示
@@ -1,7 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
import { apiGet } from '@/lib/api'
import type { FC } from 'react'
@@ -15,61 +12,11 @@ const PostImportThumbnailPreview: FC<Props> = (
{ url,
alt = 'サムネール',
className = 'h-16 w-16' },
) => {
const [previewUrl, setPreviewUrl] = useState ('')
const previewUrlRef = useRef ('')
useEffect (() => {
if (previewUrlRef.current)
{
URL.revokeObjectURL (previewUrlRef.current)
previewUrlRef.current = ''
}
setPreviewUrl ('')
if (!(url))
return
const controller = new AbortController ()
const loadPreview = async () => {
try
{
const blob = await apiGet<Blob> ('/preview/image', {
params: { url },
signal: controller.signal,
responseType: 'blob' })
if (controller.signal.aborted)
return
const nextPreviewUrl = URL.createObjectURL (blob)
previewUrlRef.current = nextPreviewUrl
setPreviewUrl (nextPreviewUrl)
}
catch
{
if (!(controller.signal.aborted))
setPreviewUrl ('')
}
}
void loadPreview ()
return () => {
controller.abort ()
if (previewUrlRef.current)
{
URL.revokeObjectURL (previewUrlRef.current)
previewUrlRef.current = ''
}
}
}, [url])
return (
<PostThumbnailPreview
url={previewUrl}
alt={alt}
className={className}/>)
}
) => (
<PostThumbnailPreview
url={url}
alt={alt}
className={className}
referrerPolicy="no-referrer"/>)
export default PostImportThumbnailPreview
+56 -36
ファイルの表示
@@ -6,11 +6,12 @@ import type { PostImportOrigin,
PostImportSkipReason,
StorageErrorHandler } from '@/lib/postImportTypes'
const SESSION_VERSION = 2
const SESSION_VERSION = 3
const SESSION_PREFIX = 'post-import-session:'
const CURRENT_SESSION_KEY = `${ SESSION_PREFIX }current`
const SOURCE_DRAFT_KEY = 'post-import-source-draft'
const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000
const SESSION_ID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
const ATTRIBUTE_KEYS = [
'title',
'thumbnailBase',
@@ -29,7 +30,21 @@ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value != null && !(Array.isArray (value))
const sessionKey = (): string => CURRENT_SESSION_KEY
export const validatePostImportSessionId = (value: unknown): string | null => {
if (typeof value !== 'string')
return null
if (value.length > 36 || !(SESSION_ID_PATTERN.test (value)))
return null
return value.toLowerCase ()
}
export const generatePostImportSessionId = (): string =>
crypto.randomUUID ()
const sessionKey = (sessionId: string): string => `${ SESSION_PREFIX }${ sessionId }`
const readStorage = (
@@ -283,9 +298,9 @@ const sanitiseRow = (value: unknown): PostImportRow | null => {
}
const isExpiredSession = (savedAt: string): boolean => {
const value = Date.parse (savedAt)
return Number.isNaN (value) || Date.now () - value > SESSION_MAX_AGE_MS
const isExpiredSession = (expiresAt: string): boolean => {
const value = Date.parse (expiresAt)
return Number.isNaN (value) || value <= Date.now ()
}
@@ -303,20 +318,25 @@ export const cleanupExpiredPostImportSessions = (
if (key == null || !(key.startsWith (SESSION_PREFIX)))
continue
const raw = sessionStorage.getItem (key)
if (raw == null)
continue
if (key !== CURRENT_SESSION_KEY)
const sessionId = validatePostImportSessionId (key.slice (SESSION_PREFIX.length))
if (sessionId == null)
{
sessionStorage.removeItem (key)
--i
continue
}
const raw = sessionStorage.getItem (key)
if (raw == null)
continue
try
{
const value = JSON.parse (raw) as { savedAt?: string }
if (typeof value.savedAt !== 'string' || isExpiredSession (value.savedAt))
const value = JSON.parse (raw) as { expiresAt?: string
rows?: unknown[] }
if (typeof value.expiresAt !== 'string'
|| isExpiredSession (value.expiresAt)
|| !(Array.isArray (value.rows)))
{
sessionStorage.removeItem (key)
--i
@@ -370,38 +390,33 @@ export const clearPostImportSourceDraft = (
export const savePostImportSession = (
sessionOrLegacyId: string | Omit<PostImportSession, 'version' | 'savedAt'>,
sessionOrOnError?: Omit<PostImportSession, 'version' | 'savedAt'> | StorageErrorHandler,
sessionId: string,
session: Omit<PostImportSession, 'version' | 'expiresAt'>,
onError?: StorageErrorHandler,
): boolean => {
const session =
typeof sessionOrLegacyId === 'string'
? sessionOrOnError as Omit<PostImportSession, 'version' | 'savedAt'>
: sessionOrLegacyId
const errorHandler =
typeof sessionOrLegacyId === 'string'
? onError
: sessionOrOnError as StorageErrorHandler | undefined
const validatedId = validatePostImportSessionId (sessionId)
if (validatedId == null)
return false
return writeStorage (
sessionKey (),
sessionKey (validatedId),
JSON.stringify ({
...session,
version: SESSION_VERSION,
savedAt: new Date ().toISOString () }),
errorHandler)
expiresAt: new Date (Date.now () + SESSION_MAX_AGE_MS).toISOString () }),
onError)
}
export const loadPostImportSession = (
legacyIdOrOnError?: string | StorageErrorHandler,
sessionId: string,
onError?: StorageErrorHandler,
): PostImportSession | null => {
const errorHandler =
typeof legacyIdOrOnError === 'string'
? onError
: legacyIdOrOnError
const raw = readStorage (sessionKey (), errorHandler)
const validatedId = validatePostImportSessionId (sessionId)
if (validatedId == null)
return null
const raw = readStorage (sessionKey (validatedId), onError)
if (raw == null)
return null
@@ -410,9 +425,9 @@ export const loadPostImportSession = (
const value = JSON.parse (raw) as Partial<PostImportSession>
if (value.version !== SESSION_VERSION || !(Array.isArray (value.rows)))
return null
if (typeof value.savedAt !== 'string' || isExpiredSession (value.savedAt))
if (typeof value.expiresAt !== 'string' || isExpiredSession (value.expiresAt))
{
removeStorage (sessionKey (), errorHandler)
removeStorage (sessionKey (validatedId), onError)
return null
}
@@ -422,7 +437,7 @@ export const loadPostImportSession = (
return {
version: SESSION_VERSION,
savedAt: value.savedAt,
expiresAt: value.expiresAt,
source: typeof value.source === 'string' ? value.source : '',
rows: rows as PostImportRow[],
repairMode: value.repairMode === 'failed' ? 'failed' : 'all' }
@@ -435,7 +450,12 @@ export const loadPostImportSession = (
export const clearPostImportSession = (
sessionId: string,
onError?: StorageErrorHandler,
) => {
removeStorage (sessionKey (), onError)
const validatedId = validatePostImportSessionId (sessionId)
if (validatedId == null)
return
removeStorage (sessionKey (validatedId), onError)
}
+8 -6
ファイルの表示
@@ -35,7 +35,9 @@ export type PostImportRow = {
resetSnapshot: PostImportResetSnapshot
createdPostId?: number
importStatus?: PostImportStatus
recoverable?: boolean }
recoverable?: boolean
thumbnailFile?: File
thumbnailObjectUrl?: string }
export type PostImportResultRow =
| {
@@ -61,11 +63,11 @@ export type PostImportResultRow =
recoverable?: boolean }
export type PostImportSession = {
version: number
savedAt: string
source: string
rows: PostImportRow[]
repairMode: PostImportRepairMode }
version: number
expiresAt: string
source: string
rows: PostImportRow[]
repairMode: PostImportRepairMode }
export type PostImportSourceIssue = {
sourceRow: number
+39 -6
ファイルの表示
@@ -1,4 +1,5 @@
import { resultRepairMode } from '@/lib/postImportRows'
import { validatePostImportSessionId } from '@/lib/postImportStorage'
import type {
PostImportOrigin,
@@ -12,6 +13,7 @@ type QueryRowState = {
thumbnail_base: string
original_created_from: string
original_created_before: string
video_ms: string
duration: string
tags: string
parent_post_ids: string
@@ -37,6 +39,7 @@ type EncodedRowState = {
h?: string
f?: string
b?: string
x?: string
d?: string
g?: string
p?: string
@@ -58,8 +61,8 @@ export type ParsedPostNewState = {
shortcut: boolean }
export type SerialisedPostNewState = {
path: string
rows: PostImportRow[] }
path: string
complete: boolean }
const BASIC_KEYS = [
'url',
@@ -67,6 +70,7 @@ const BASIC_KEYS = [
'thumbnail_base',
'original_created_from',
'original_created_before',
'video_ms',
'duration',
'tags',
'parent_post_ids'] as const
@@ -77,7 +81,7 @@ const STATE_KEYS = [
'import_status',
'created_post_id',
'recoverable'] as const
const SINGLE_ROW_KEYS = [...BASIC_KEYS, ...STATE_KEYS] as const
const SINGLE_ROW_KEYS = [...BASIC_KEYS, ...STATE_KEYS, 'session_id'] as const
const MAX_REGULAR_URL_LENGTH = 768
const MAX_COMPRESSED_STATE_LENGTH = 767
const COMPRESSED_STATE_PREFIX = 'z1.'
@@ -241,6 +245,7 @@ const baseRowState = (row: PostImportRow): QueryRowState => ({
thumbnail_base: String (row.attributes.thumbnailBase ?? ''),
original_created_from: String (row.attributes.originalCreatedFrom ?? ''),
original_created_before: String (row.attributes.originalCreatedBefore ?? ''),
video_ms: String (row.attributes.videoMs ?? ''),
duration: String (row.attributes.duration ?? ''),
tags: String (row.attributes.tags ?? ''),
parent_post_ids: String (row.attributes.parentPostIds ?? ''),
@@ -263,6 +268,7 @@ const buildRow = (
thumbnailBase: manual.has ('thumbnailBase') ? 'manual' : 'automatic',
originalCreatedFrom: manual.has ('originalCreatedFrom') ? 'manual' : 'automatic',
originalCreatedBefore: manual.has ('originalCreatedBefore') ? 'manual' : 'automatic',
videoMs: 'automatic',
duration: 'automatic',
tags: manual.has ('tags') ? 'manual' : 'automatic',
parentPostIds: manual.has ('parentPostIds') ? 'manual' : 'automatic' }
@@ -274,6 +280,7 @@ const buildRow = (
thumbnailBase: state.thumbnail_base,
originalCreatedFrom: state.original_created_from,
originalCreatedBefore: state.original_created_before,
videoMs: state.video_ms,
duration: state.duration,
tags: state.tags,
parentPostIds: state.parent_post_ids }
@@ -356,6 +363,7 @@ const parseEncodedRow = (
['h', 'thumbnail_base'],
['f', 'original_created_from'],
['b', 'original_created_before'],
['x', 'video_ms'],
['d', 'duration'],
['g', 'tags'],
['p', 'parent_post_ids']] as const
@@ -382,6 +390,7 @@ const parseEncodedRow = (
thumbnail_base: parsedStrings.thumbnail_base,
original_created_from: parsedStrings.original_created_from,
original_created_before: parsedStrings.original_created_before,
video_ms: parsedStrings.video_ms,
duration: parsedStrings.duration,
tags: parsedStrings.tags,
parent_post_ids: parsedStrings.parent_post_ids,
@@ -399,7 +408,8 @@ const parseSingleRow = (params: URLSearchParams): ParsedPostNewState | null => {
if (url == null || url === '')
return null
const hasOnlyUrl = Array.from (params.keys ()).every (key => key === 'url')
const hasOnlyUrl = Array.from (params.keys ()).every (
key => key === 'url' || key === 'session_id')
if (hasOnlyUrl)
{
const row = buildRow (1, {
@@ -408,6 +418,7 @@ const parseSingleRow = (params: URLSearchParams): ParsedPostNewState | null => {
thumbnail_base: '',
original_created_from: '',
original_created_before: '',
video_ms: '',
duration: '',
tags: '',
parent_post_ids: '',
@@ -444,6 +455,7 @@ const parseSingleRow = (params: URLSearchParams): ParsedPostNewState | null => {
thumbnail_base: params.get ('thumbnail_base') ?? '',
original_created_from: params.get ('original_created_from') ?? '',
original_created_before: params.get ('original_created_before') ?? '',
video_ms: params.get ('video_ms') ?? '',
duration: params.get ('duration') ?? '',
tags: params.get ('tags') ?? '',
parent_post_ids: params.get ('parent_post_ids') ?? '',
@@ -546,6 +558,7 @@ const regularQuery = (rows: PostImportRow[]): URLSearchParams => {
params.set ('thumbnail_base', String (row?.attributes.thumbnailBase ?? ''))
params.set ('original_created_from', String (row?.attributes.originalCreatedFrom ?? ''))
params.set ('original_created_before', String (row?.attributes.originalCreatedBefore ?? ''))
params.set ('video_ms', String (row?.attributes.videoMs ?? ''))
params.set ('duration', String (row?.attributes.duration ?? ''))
params.set ('tags', String (row?.attributes.tags ?? ''))
params.set ('parent_post_ids', String (row?.attributes.parentPostIds ?? ''))
@@ -589,6 +602,8 @@ const encodeRowState = (
encoded.f = base.original_created_from
if (base.original_created_before !== '')
encoded.b = base.original_created_before
if (base.video_ms !== '')
encoded.x = base.video_ms
if (base.duration !== '')
encoded.d = base.duration
if (base.tags !== '')
@@ -626,6 +641,24 @@ export const hasPostNewReviewState = (search: string): boolean => {
}
export const parsePostNewSessionId = (search: string): string | null =>
validatePostImportSessionId (
new URLSearchParams (search).get ('session_id'))
export const appendPostNewSessionId = (
path: string,
sessionId: string,
): string => {
const validatedId = validatePostImportSessionId (sessionId)
if (validatedId == null)
return path
const separator = path.includes ('?') ? '&' : '?'
return `${ path }${ separator }session_id=${ validatedId }`
}
export const parsePostNewState = async (
search: string,
): Promise<ParsedPostNewState | null> => {
@@ -661,7 +694,7 @@ const serialiseCompressedRows = async (
if (payload.length <= MAX_COMPRESSED_STATE_LENGTH)
return {
path: `/posts/new?state=${ state }`,
rows: nextRows }
complete: count === rows.length }
}
}
catch
@@ -680,7 +713,7 @@ export const serialisePostNewState = async (
if (regularPath.length < MAX_REGULAR_URL_LENGTH)
return {
path: regularPath,
rows }
complete: true }
return await serialiseCompressedRows (rows)
}
-544
ファイルの表示
@@ -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
+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)
+130 -7
ファイルの表示
@@ -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)
{