f1181e8510
Reviewed-on: #413 Co-authored-by: miteruzo <miteruzo@naver.com> Co-committed-by: miteruzo <miteruzo@naver.com>
422 行
14 KiB
TypeScript
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) })))
|