このコミットが含まれているのは:
2026-07-18 01:12:55 +09:00
コミット 9eca670934
10個のファイルの変更435行の追加402行の削除
+2
ファイルの表示
@@ -90,6 +90,8 @@ export const applyThumbnailWarnings = (rows: PostImportRow[]): PostImportRow[] =
export const processableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
validatableImportRows (rows).filter (row => {
if (row.metadataUrl == null)
return false
if (row.importStatus === 'created')
return false
if (row.importStatus === 'skipped')
+113 -112
ファイルの表示
@@ -30,7 +30,6 @@ import {
clearPostImportSourceDraft,
generatePostImportSessionId,
hasThumbnailBaseValue,
initialisePreviewRows,
isCompletedReviewRow,
isExistingSkipRow,
isNonRecoverableFailedRow,
@@ -62,7 +61,7 @@ import type { User } from '@/types'
type Props = { user: User | null }
type PreviewResponse = {
type PostMetadataResponse = {
url: string
title?: string
thumbnailBase?: string
@@ -105,6 +104,47 @@ const isRepairRow = (row: PostImportRow): boolean =>
|| (row.importStatus === 'pending'
&& Object.keys (row.validationErrors).length > 0))
const DUPLICATE_URL_MESSAGE = 'URL が重複しています.'
const applyDuplicateUrlErrors = (
rows: PostImportRow[],
): PostImportRow[] => {
const counts = rows.reduce<Record<string, number>> ((result, row) => {
if (row.url !== '')
result[row.url] = (result[row.url] ?? 0) + 1
return result
}, { })
return rows.map (row => {
const urlErrors = (row.validationErrors.url ?? []).filter (
message => message !== DUPLICATE_URL_MESSAGE)
if ((counts[row.url] ?? 0) < 2)
{
const nextValidationErrors = {
...row.validationErrors,
url: urlErrors }
const hasErrors = Object.values (nextValidationErrors).some (
messages => messages.length > 0)
return urlErrors.length === (row.validationErrors.url ?? []).length
? row
: {
...row,
validationErrors: nextValidationErrors,
status: hasErrors ? 'error' : row.status }
}
return {
...row,
skipReason: row.skipReason === 'manual' ? 'manual' : undefined,
existingPostId: undefined,
existingPost: undefined,
validationErrors: {
...row.validationErrors,
url: [...new Set ([...urlErrors, DUPLICATE_URL_MESSAGE])] },
status: 'error' }
})
}
const buildSession = (rows: PostImportRow[]): PostImportSession => ({
version: 3,
@@ -137,9 +177,12 @@ const buildDryRunFormData = (row: PostImportRow): FormData => {
const mergeDryRunRow = (
currentRow: PostImportRow,
result: PreviewResponse,
): PostImportRow =>
applyThumbnailWarning ({
result: PostMetadataResponse,
): PostImportRow => {
const hasWarnings =
Object.values (result.fieldWarnings ?? { }).some (messages => messages.length > 0)
|| (result.baseWarnings?.length ?? 0) > 0
return applyThumbnailWarning ({
...currentRow,
url: result.url,
attributes: {
@@ -157,8 +200,7 @@ const mergeDryRunRow = (
validationErrors: { },
importErrors: undefined,
status:
Object.keys (result.fieldWarnings ?? { }).length > 0
|| (result.baseWarnings?.length ?? 0) > 0
hasWarnings
? 'warning'
: 'ready',
skipReason: result.existingPost?.id != null ? 'existing' : undefined,
@@ -169,10 +211,11 @@ const mergeDryRunRow = (
? 'created'
: 'pending',
recoverable: undefined })
}
const buildPreviewResetSnapshot = (
preview: PreviewResponse,
preview: PostMetadataResponse,
): PostImportRow['resetSnapshot'] => ({
url: preview.url,
attributes: {
@@ -246,7 +289,7 @@ const indexedBulkResults = (
const mergePreviewRow = (
currentRow: PostImportRow,
preview: PreviewResponse,
preview: PostMetadataResponse,
): PostImportRow => {
const fieldWarnings = preview.fieldWarnings ?? { }
const baseWarnings = preview.baseWarnings ?? []
@@ -405,7 +448,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
if (serialised == null)
return null
if (persistSequenceRef.current !== sequence)
return sessionRef.current
return null
const saved = savePostImportSession (sessionId, nextSession)
const loadedSession = loadPostImportSession (sessionId)
@@ -447,8 +490,23 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
}
const refreshRows = async (baseSession: PostImportSession) => {
setLoading (true)
let nextIndex = 0
const previews = new Map<number, PreviewResponse> ()
const previewErrors = new Map<number, Record<string, string[]>> ()
let workingRows = [...(sessionRef.current ?? baseSession).rows]
const mergeWorkingRow = (
sourceRow: number,
updater: (row: PostImportRow) => PostImportRow,
) => {
workingRows = workingRows.map (row =>
row.sourceRow === sourceRow
? updater (row)
: row)
const nextSession = buildSession (workingRows)
sessionRef.current = nextSession
setSession (nextSession)
}
const worker = async () => {
while (active)
{
@@ -461,18 +519,19 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
if (baseRow.skipReason === 'manual'
|| baseRow.importStatus === 'created'
|| baseRow.importStatus === 'skipped'
|| isNonRecoverableFailedRow (baseRow))
|| isNonRecoverableFailedRow (baseRow)
|| baseRow.metadataUrl != null)
continue
const controller = new AbortController ()
controllers.add (controller)
try
{
const preview = await apiGet<PreviewResponse> ('/posts/preview', {
const preview = await apiGet<PostMetadataResponse> ('/posts/metadata', {
params: { url: baseRow.url },
signal: controller.signal })
if (!(active) || previewSequenceRef.current !== previewSequence)
return
previews.set (baseRow.sourceRow, preview)
mergeWorkingRow (baseRow.sourceRow, row => mergePreviewRow (row, preview))
}
catch (requestError)
{
@@ -480,8 +539,25 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
return
if (!(active) || previewSequenceRef.current !== previewSequence)
return
if (!(isApiError (requestError)))
if (!(isApiError<{
errors?: Record<string, string[]>
baseErrors?: string[]
}> (requestError)))
continue
if (requestError.response?.status === 422)
{
const rowErrors = {
...(requestError.response.data.errors ?? { }),
...(requestError.response.data.baseErrors?.length
? {
base: requestError.response.data.baseErrors }
: { }) }
previewErrors.set (baseRow.sourceRow, rowErrors)
mergeWorkingRow (baseRow.sourceRow, row => ({
...row,
validationErrors: rowErrors,
status: 'error' }))
}
}
finally
{
@@ -493,20 +569,22 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
await Promise.all (
Array.from ({ length: Math.min (4, baseSession.rows.length) }, () => worker ()))
if (!(active) || previewSequenceRef.current !== previewSequence)
return
{
setLoading (false)
return
}
const latestSession = sessionRef.current ?? baseSession
const nextRows = latestSession.rows.map (row => {
const preview = previews.get (row.sourceRow)
if (preview == null
|| row.skipReason === 'manual'
|| row.importStatus === 'created'
|| row.importStatus === 'skipped'
|| isNonRecoverableFailedRow (row))
const nextRows = applyDuplicateUrlErrors (workingRows.map (row => {
const rowErrors = previewErrors.get (row.sourceRow)
if (rowErrors == null)
return row
return mergePreviewRow (row, preview)
})
return {
...row,
validationErrors: rowErrors,
status: 'error' as const }
}))
await persistSession (buildSession (nextRows))
setLoading (false)
}
const hydrate = async () => {
@@ -528,89 +606,6 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
sessionRef.current = loaded
setSession (loaded)
if (loadedSession == null && parsed?.shortcut)
{
try
{
const preview = await apiGet<PreviewResponse> ('/posts/preview', {
params: { url: parsed.source } })
if (!(active))
return
const rows = [applyThumbnailWarning (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: String (preview.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,
existingPost: preview.existingPost ?? undefined,
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: String (preview.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 ?? [],
metadataUrl: preview.url } }])[0])]
const persisted = await persistSession (buildSession (rows))
if (persisted == null)
return
}
catch
{
if (active)
navigate ('/posts/new', { replace: true })
}
return
}
void refreshRows (loaded)
}
@@ -649,6 +644,9 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
const existingRows = sortedRows.filter (row => isExistingSkipRow (row))
const reviewRows = sortedRows.filter (row => !(isExistingSkipRow (row)))
const busy = loading || loadingRow != null
const canSubmit =
rows.every (row => isCompletedReviewRow (row))
|| processableImportRows (rows).length > 0
const toggleManualSkip = async (sourceRow: number, checked: boolean) => {
if (busy)
@@ -709,10 +707,10 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
urlChanged
? mergePreviewRow (
nextRow,
await apiGet<PreviewResponse> ('/posts/preview', {
await apiGet<PostMetadataResponse> ('/posts/metadata', {
params: { url: nextRow.url } }))
: nextRow
const dryRun = await apiPost<PreviewResponse> (
const dryRun = await apiPost<PostMetadataResponse> (
'/posts?dry=1',
buildDryRunFormData (candidateRow))
const latestSession = sessionRef.current
@@ -971,6 +969,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
<PostImportFooter
loading={busy}
canSubmit={canSubmit}
creatableCount={counts.creatable}
manualSkippedCount={counts.manualSkipped}
existingSkippedCount={counts.existingSkipped}
@@ -982,12 +981,14 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
const PostImportFooter = (
{ loading,
canSubmit,
creatableCount,
manualSkippedCount,
existingSkippedCount,
pendingOrErrorCount,
onBack,
onSubmit }: { loading: boolean
canSubmit: boolean
creatableCount: number
manualSkippedCount: number
existingSkippedCount: number
@@ -1013,7 +1014,7 @@ const PostImportFooter = (
<Button
type="button"
onClick={onSubmit}
disabled={loading}>
disabled={loading || !(canSubmit)}>
{creatableCount > 1 ? '一括追加' : '追加'}
</Button>
</div>
+65 -232
ファイルの表示
@@ -11,7 +11,6 @@ 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 { apiGet, isApiError } from '@/lib/api'
import {
appendPostNewSessionId,
serialisePostNewState,
@@ -37,191 +36,72 @@ 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[]
validationErrors?: Record<string, string[]>
existingPost?: {
id: number
title: string
url: string
thumbnailUrl?: string } | null }
const MAX_ROWS = 100
const SOURCE_ERROR_ID = 'post-import-source-error'
const SOURCE_ISSUES_ID = 'post-import-source-issues'
const urlIssuesFromRows = (rows: PostImportRow[], source: string) => {
const sourceLines = source.split (/\r\n|\n|\r/)
return rows.flatMap (row =>
(row.validationErrors.url ?? []).map (message => ({
sourceRow: row.sourceRow,
message,
url: sourceLines[row.sourceRow - 1]?.trim () ?? row.url })))
}
const previewRowFromResult = (
sourceRow: number,
result: PreviewResponse,
): PostImportRow => {
const fieldWarnings = result.fieldWarnings ?? { }
const baseWarnings = result.baseWarnings ?? []
const validationErrors = result.validationErrors ?? { }
const hasWarnings =
Object.values (fieldWarnings).some (messages => messages.length > 0)
|| baseWarnings.length > 0
const hasErrors =
Object.values (validationErrors).some (messages => messages.length > 0)
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:
hasErrors
? 'error'
: hasWarnings
? 'warning'
: 'ready',
skipReason: result.existingPost?.id != null ? 'existing' : undefined,
existingPostId: result.existingPost?.id,
existingPost: result.existingPost ?? undefined,
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 (
rows: Array<{ sourceRow: number
url: string }>,
signal: AbortSignal,
): Promise<{
rows: PostImportRow[]
issues: Array<{ sourceRow: number
message: string
url: string }>
}> => {
const results: Array<PostImportRow | null> = new Array (rows.length).fill (null)
const issues: Array<{ sourceRow: number
message: string
url: string }> = []
let nextIndex = 0
const previewIssues = (
sourceRow: number,
url: string,
requestError: unknown,
): Array<{ sourceRow: number
message: string
url: string }> => {
if (!(isApiError<{
message?: string
errors?: Record<string, string[]>
baseErrors?: string[]
}> (requestError)))
{
return [{
sourceRow,
message: '入力を確認してください.',
url }]
}
const fieldMessages = Object.values (requestError.response?.data?.errors ?? { }).flat ()
const baseMessages = requestError.response?.data?.baseErrors ?? []
const messages = [...new Set ([...fieldMessages, ...baseMessages])]
return (messages.length > 0 ? messages : [requestError.response?.data?.message ?? '入力を確認してください.'])
.map (message => ({
sourceRow,
message,
url }))
}
const worker = async () => {
while (nextIndex < rows.length)
{
const index = nextIndex
++nextIndex
const row = rows[index]
try
{
const result = await apiGet<PreviewResponse> ('/posts/preview', {
params: { url: row.url },
signal })
results[index] = previewRowFromResult (row.sourceRow, result)
}
catch (requestError)
{
if (signal.aborted)
return
issues.push (...previewIssues (row.sourceRow, row.url, requestError))
}
}
}
await Promise.all (
Array.from ({ length: Math.min (4, rows.length) }, () => worker ()))
return {
rows: results.filter ((row): row is PostImportRow => row != null),
issues }
}
const buildInitialRows = (source: string): PostImportRow[] =>
source
.split (/\r\n|\n|\r/)
.map ((line, index) => ({
sourceRow: index + 1,
url: line.trim () }))
.filter (row => row.url !== '')
.map (row => ({
sourceRow: row.sourceRow,
url: row.url,
attributes: {
title: '',
thumbnailBase: '',
originalCreatedFrom: '',
originalCreatedBefore: '',
duration: '',
videoMs: '',
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: '',
manual: '' },
status: 'ready' as const,
resetSnapshot: {
url: row.url,
attributes: {
title: '',
thumbnailBase: '',
originalCreatedFrom: '',
originalCreatedBefore: '',
duration: '',
videoMs: '',
tags: '',
parentPostIds: '' },
provenance: {
url: 'manual',
title: 'automatic',
thumbnailBase: 'automatic',
originalCreatedFrom: 'automatic',
originalCreatedBefore: 'automatic',
duration: 'automatic',
videoMs: 'automatic',
tags: 'automatic',
parentPostIds: 'automatic' },
tagSources: {
automatic: '',
manual: '' },
fieldWarnings: { },
baseWarnings: [] } }))
const PostImportSourcePage: FC<Props> = ({ user }) => {
@@ -233,7 +113,6 @@ 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] : []
@@ -274,10 +153,6 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
}
}, [source])
useEffect (() => () => {
previewController.current?.abort ()
}, [])
const preview = async () => {
if (lineCount === 0)
{
@@ -299,42 +174,7 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
setSourceError (null)
try
{
previewController.current?.abort ()
const controller = new AbortController ()
previewController.current = controller
const previewed = await fetchPreviewRows (
source
.split (/\r\n|\n|\r/)
.map ((line, index) => ({
sourceRow: index + 1,
url: line.trim () }))
.filter (row => row.url !== ''),
controller.signal)
if (previewed.issues.length > 0)
{
setSourceIssues (previewed.issues)
return
}
const urlIssues = urlIssuesFromRows (previewed.rows, source)
const duplicateIssues = Object.entries (
previewed.rows.reduce<Record<string, PostImportRow[]>> ((acc, row) => {
acc[row.url] ||= []
acc[row.url].push (row)
return acc
}, { }))
.flatMap (([, rows]) =>
rows.length < 2
? []
: rows.map (row => ({
sourceRow: row.sourceRow,
message: 'URL が重複しています.',
url: row.url })))
if (urlIssues.length > 0 || duplicateIssues.length > 0)
{
setSourceIssues ([...urlIssues, ...duplicateIssues])
return
}
const nextRows = initialisePreviewRows (previewed.rows)
const nextRows = initialisePreviewRows (buildInitialRows (source))
const serialised = await serialisePostNewState (nextRows)
if (serialised == null)
return
@@ -355,16 +195,9 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
navigate (appendPostNewSessionId (serialised.path, sessionId))
}
catch (requestError)
catch
{
const message =
isApiError<{ message?: string, baseErrors?: string[] }> (requestError)
? (requestError.response?.data?.message
?? requestError.response?.data?.baseErrors?.[0])
: undefined
setSourceError (message ?? '入力を確認してください.')
toast ({ title: '投稿情報の取得に失敗しました',
description: message ?? '入力を確認してください.' })
return
}
finally
{