ファイル
btrc-hub/frontend/src/lib/postImportRows.ts
T
みてるぞ f1181e8510 広場投稿追加画面の刷新 (#399) (#413)
Reviewed-on: #413
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-07-19 00:03:10 +09:00

422 行
14 KiB
TypeScript

import type { PostImportEditableDraft,
PostImportResultRow,
PostImportRow } from '@/lib/postImportTypes'
const THUMBNAIL_MISSING_WARNING = 'サムネールなし'
export const hasThumbnailBaseValue = (value: unknown): boolean =>
typeof value === 'string' && value.trim () !== ''
export const hasVideoTag = (value: unknown): boolean =>
typeof value === 'string'
&& value.split (/\s+/).includes ('動画')
export const isExistingSkipRow = (row: PostImportRow): boolean =>
row.skipReason === 'existing'
export const isManualSkipRow = (row: PostImportRow): boolean =>
row.skipReason === 'manual'
const hasSkipReason = (row: PostImportRow): boolean =>
row.skipReason != null
export const compactMessageRecord = (
messages: Record<string, string[]>,
): Record<string, string[]> =>
Object.fromEntries (
Object.entries (messages).filter (([, values]) => values.length > 0))
export const hasErrorMessages = (
messages: Record<string, string[]>,
): boolean =>
Object.values (messages).some (values => values.length > 0)
const hasValidationErrors = (row: PostImportRow): boolean =>
hasErrorMessages (row.validationErrors ?? { })
const isRecoverableRow = (row: PostImportRow): boolean =>
row.recoverable === true
const isRepairableImportStatus = (row: PostImportRow): boolean =>
isRecoverableRow (row)
&& (row.importStatus === 'failed' || row.importStatus === 'pending')
export const isNonRecoverableFailedRow = (row: PostImportRow): boolean =>
row.importStatus === 'failed' && row.recoverable !== true
export const isTerminalRow = (row: PostImportRow): boolean =>
row.importStatus === 'created'
|| row.importStatus === 'skipped'
|| isNonRecoverableFailedRow (row)
export const isCompletedReviewRow = (row: PostImportRow): boolean =>
row.importStatus === 'created'
|| row.importStatus === 'skipped'
|| hasSkipReason (row)
export const validatableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
rows.filter (row => !(hasSkipReason (row)) && !(isTerminalRow (row)))
const buildResetSnapshot = (row: PostImportRow) => ({
url: row.url,
attributes: { ...row.attributes },
displayTags: row.displayTags?.map (tag => ({
name: tag.name,
category: tag.category,
sectionLiterals: tag.sectionLiterals == null ? undefined : [...tag.sectionLiterals] })) ?? [],
provenance: { ...row.provenance },
tagSources: {
automatic: row.tagSources?.automatic ?? '',
manual: row.tagSources?.manual ?? '' },
fieldWarnings: Object.fromEntries (
Object.entries (row.fieldWarnings).map (([key, values]) => [key, [...values]])),
baseWarnings: [...row.baseWarnings],
metadataUrl: row.metadataUrl })
const deduped = (values: string[]): string[] =>
[...new Set (values)]
const thumbnailWarnings = (row: PostImportRow): string[] => {
const current = row.fieldWarnings.thumbnailBase ?? []
const others = current.filter (message => message !== THUMBNAIL_MISSING_WARNING)
return hasThumbnailBaseValue (row.attributes.thumbnailBase) || row.thumbnailFile != null
? others
: deduped ([...others, THUMBNAIL_MISSING_WARNING])
}
export const applyThumbnailWarning = (row: PostImportRow): PostImportRow => ({
...row,
fieldWarnings: compactMessageRecord ({
...row.fieldWarnings,
thumbnailBase: thumbnailWarnings (row) }) })
export const applyThumbnailWarnings = (rows: PostImportRow[]): PostImportRow[] =>
rows.map (row => applyThumbnailWarning (row))
export const processableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
validatableImportRows (rows).filter (row => {
if (row.status === 'pending')
return false
if (row.importStatus === 'created')
return false
if (row.importStatus === 'skipped')
return false
if (row.importStatus === 'failed')
return false
if (hasValidationErrors (row))
return false
return row.importStatus == null || row.importStatus === 'pending'
})
export const creatableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
processableImportRows (rows).filter (row => !(hasSkipReason (row)))
export const reviewSummaryCounts = (rows: PostImportRow[]) => ({
creatable: rows.filter (row => creatableImportRows ([row]).length > 0).length,
manualSkipped: rows.filter (row => isManualSkipRow (row)).length,
existingSkipped: rows.filter (row => isExistingSkipRow (row)).length,
pendingOrError: rows.filter (row =>
!(isCompletedReviewRow (row))
&& creatableImportRows ([row]).length === 0).length })
export const resultSummaryCounts = (rows: PostImportRow[]) =>
rows.reduce (
(counts, row) => {
if (row.importStatus === 'created')
{
++counts.created
return counts
}
if (row.importStatus === 'skipped')
{
++counts.skipped
return counts
}
if (row.importStatus === 'failed'
|| (row.recoverable === true && row.importStatus === 'pending'))
++counts.failed
return counts
},
{ created: 0, skipped: 0, failed: 0 })
export const resultRepairMode = (
rows: PostImportRow[],
): 'all' | 'failed' =>
rows.some (row => hasValidationErrors (row) || isRepairableImportStatus (row))
? 'failed'
: 'all'
export const canEditReviewRow = (row: PostImportRow): boolean =>
!(hasSkipReason (row)
|| row.importStatus === 'created'
|| row.importStatus === 'skipped'
|| (row.importStatus === 'failed' && row.recoverable !== true))
export const canEditResultRow = (row: PostImportRow): boolean =>
row.skipReason == null
&& row.recoverable === true
&& (row.importStatus === 'failed'
|| (row.importStatus === 'pending' && hasValidationErrors (row)))
export const canRetryResultRow = (row: PostImportRow): boolean =>
row.skipReason == null
&& row.recoverable === true
&& (row.importStatus === 'failed'
|| (row.importStatus === 'pending' && !(hasValidationErrors (row))))
export const resultRowMessages = (row: PostImportRow): string[] =>
[...new Set ([
...Object.values (row.validationErrors ?? { }).flat (),
...Object.values (row.importErrors ?? { }).flat ()])]
export const resultRowWarnings = (row: PostImportRow): string[] =>
[...new Set ([
...Object.values (row.fieldWarnings ?? { }).flat (),
...row.baseWarnings])]
const isManualChange = (
current: unknown,
next: string,
): boolean =>
next !== String (current ?? '')
export const buildNextEditedRow = (
editingRow: PostImportRow,
draft: PostImportEditableDraft,
urlChanged: boolean,
): PostImportRow => {
const nextProvenance = { ...editingRow.provenance }
const nextAttributes = { ...editingRow.attributes }
const nextTagSources = {
automatic: editingRow.tagSources?.automatic ?? '',
manual: editingRow.tagSources?.manual ?? '' }
const draftFields = [
['title', draft.title],
['thumbnailBase', draft.thumbnailBase],
['originalCreatedFrom', draft.originalCreatedFrom],
['originalCreatedBefore', draft.originalCreatedBefore],
['duration', draft.duration],
['parentPostIds', draft.parentPostIds]] as const
draftFields.forEach (([field, value]) => {
nextAttributes[field] = value
nextProvenance[field] =
isManualChange (editingRow.attributes[field], value)
? 'manual'
: (editingRow.provenance[field] ?? 'automatic')
})
nextAttributes.tags = draft.tags
if (isManualChange (editingRow.attributes.tags, draft.tags))
{
nextProvenance.tags = 'manual'
nextTagSources.manual = draft.tags
}
else
{
nextProvenance.tags = editingRow.provenance.tags ?? 'automatic'
nextTagSources.manual = editingRow.tagSources?.manual ?? ''
}
return {
...editingRow,
url: draft.url,
attributes: nextAttributes,
displayTags:
editingRow.displayTags?.map (tag => ({
name: tag.name,
category: tag.category,
sectionLiterals: tag.sectionLiterals == null ? undefined : [...tag.sectionLiterals] })),
thumbnailFile: draft.thumbnailFile,
provenance: {
...nextProvenance,
url: urlChanged ? 'manual' : (editingRow.provenance.url ?? 'manual') },
tagSources: nextTagSources,
importStatus: editingRow.importStatus === 'created' ? 'created' : 'pending',
importErrors: undefined }
}
export const hasExactSourceRows = (
expected: number[],
actual: Array<{ sourceRow: number }>,
): boolean => {
if (expected.length !== actual.length)
return false
const expectedSorted = [...expected].sort ((a, b) => a - b)
const actualSorted = actual.map (row => row.sourceRow).sort ((a, b) => a - b)
return expectedSorted.every ((value, index) => value === actualSorted[index])
}
export const replaceImportRow = (
rows: PostImportRow[],
nextRow: PostImportRow,
): PostImportRow[] =>
rows.map (row => row.sourceRow === nextRow.sourceRow ? nextRow : row)
export const mergeValidatedImportRow = (
rows: PostImportRow[],
validated: PostImportRow,
): PostImportRow[] => {
const current = rows.find (row => row.sourceRow === validated.sourceRow)
if (current == null)
return rows
const [merged] = mergeValidatedImportRows ([current], [validated])
return merged == null ? rows : replaceImportRow (rows, merged)
}
export const mergeValidatedImportRows = (
current: PostImportRow[],
validated: PostImportRow[],
): PostImportRow[] => {
const validatedMap = new Map (validated.map (row => [row.sourceRow, row]))
return current.map (previous => {
if (
previous.importStatus === 'created'
|| previous.importStatus === 'skipped'
|| previous.importStatus === 'failed')
return previous
const row = validatedMap.get (previous.sourceRow)
if (row == null)
return previous
const fieldWarnings =
hasErrorMessages (row.fieldWarnings) || row.metadataUrl !== previous.metadataUrl
? { ...row.fieldWarnings }
: { ...previous.fieldWarnings }
for (const [field, origin] of Object.entries (row.provenance))
{
if (origin === 'manual')
delete fieldWarnings[field]
}
return {
...previous,
url: row.url,
attributes: row.attributes,
displayTags:
row.displayTags?.map (tag => ({
name: tag.name,
category: tag.category,
sectionLiterals:
tag.sectionLiterals == null ? undefined : [...tag.sectionLiterals] })),
provenance: row.provenance,
tagSources: row.tagSources,
skipReason: row.skipReason,
existingPostId: row.existingPostId,
existingPost: row.existingPost,
fieldWarnings,
baseWarnings:
row.baseWarnings.length > 0 || row.metadataUrl !== previous.metadataUrl
? row.baseWarnings
: previous.baseWarnings,
validationErrors: row.validationErrors,
status: row.status,
metadataUrl: row.metadataUrl,
resetSnapshot:
row.metadataUrl !== previous.metadataUrl
? buildResetSnapshot (row)
: previous.resetSnapshot }
})
}
export const mergeImportResults = (
rows: PostImportRow[],
results: PostImportResultRow[],
): PostImportRow[] => {
const resultMap = new Map (results.map (row => [row.sourceRow, row]))
return rows.map (row => {
const result = resultMap.get (row.sourceRow)
if (result == null)
return row
switch (result.status)
{
case 'created':
return {
...row,
importStatus: 'created',
recoverable: undefined,
skipReason: undefined,
createdPostId: result.post.id,
existingPostId: undefined,
existingPost: undefined,
fieldWarnings: compactMessageRecord (
result.fieldWarnings ?? row.fieldWarnings),
baseWarnings: result.baseWarnings ?? row.baseWarnings,
importErrors: compactMessageRecord (result.errors ?? { }) }
case 'skipped':
return {
...row,
importStatus: 'skipped',
recoverable: undefined,
skipReason: 'existing',
createdPostId: undefined,
existingPostId: result.existingPostId,
existingPost: result.existingPost ?? row.existingPost,
fieldWarnings: compactMessageRecord (
result.fieldWarnings ?? row.fieldWarnings),
baseWarnings: result.baseWarnings ?? row.baseWarnings,
importErrors: compactMessageRecord (result.errors ?? { }) }
case 'failed':
return {
...row,
importStatus: 'failed',
recoverable: result.recoverable === true ? true : undefined,
skipReason: undefined,
createdPostId: undefined,
existingPostId: undefined,
existingPost: undefined,
fieldWarnings: compactMessageRecord (
result.fieldWarnings ?? row.fieldWarnings),
baseWarnings: result.baseWarnings ?? row.baseWarnings,
importErrors: compactMessageRecord (result.errors ?? { }) }
}
})
}
export const retryImportRow = (
rows: PostImportRow[],
sourceRow: number,
): PostImportRow[] =>
rows.map (row =>
row.sourceRow === sourceRow
&& row.importStatus === 'failed'
&& row.recoverable === true
? { ...row, importStatus: 'pending', importErrors: undefined }
: row)
export const initialisePreviewRows = (rows: PostImportRow[]): PostImportRow[] =>
applyThumbnailWarnings (
rows.map (row => ({
...row,
resetSnapshot: buildResetSnapshot (row) })))