このコミットが含まれているのは:
@@ -75,11 +75,6 @@ const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
|
|||||||
<Route path="/" element={<Navigate to="/posts" replace/>}/>
|
<Route path="/" element={<Navigate to="/posts" replace/>}/>
|
||||||
<Route path="/posts" element={<PostListPage/>}/>
|
<Route path="/posts" element={<PostListPage/>}/>
|
||||||
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
|
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
|
||||||
<Route path="/posts/import" element={<Navigate to="/posts/new" replace/>}/>
|
|
||||||
<Route path="/posts/new/review" element={<Navigate to="/posts/new" replace/>}/>
|
|
||||||
<Route path="/posts/import/:sessionId/review" element={<Navigate to="/posts/new" replace/>}/>
|
|
||||||
<Route path="/posts/import/:sessionId/result" element={<Navigate to="/posts" replace/>}/>
|
|
||||||
<Route path="/posts/new/:sessionId/result" element={<Navigate to="/posts" replace/>}/>
|
|
||||||
<Route path="/posts/search" element={<PostSearchPage/>}/>
|
<Route path="/posts/search" element={<PostSearchPage/>}/>
|
||||||
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
|
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
|
||||||
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
|
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
|
||||||
@@ -118,11 +113,6 @@ const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
|
|||||||
<Route path="/" element={<Navigate to="/posts" replace/>}/>
|
<Route path="/" element={<Navigate to="/posts" replace/>}/>
|
||||||
<Route path="/posts" element={<PostListPage/>}/>
|
<Route path="/posts" element={<PostListPage/>}/>
|
||||||
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
|
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
|
||||||
<Route path="/posts/import" element={<Navigate to="/posts/new" replace/>}/>
|
|
||||||
<Route path="/posts/new/review" element={<Navigate to="/posts/new" replace/>}/>
|
|
||||||
<Route path="/posts/import/:sessionId/review" element={<Navigate to="/posts/new" replace/>}/>
|
|
||||||
<Route path="/posts/import/:sessionId/result" element={<Navigate to="/posts" replace/>}/>
|
|
||||||
<Route path="/posts/new/:sessionId/result" element={<Navigate to="/posts" replace/>}/>
|
|
||||||
<Route path="/posts/search" element={<PostSearchPage/>}/>
|
<Route path="/posts/search" element={<PostSearchPage/>}/>
|
||||||
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
|
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
|
||||||
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
|
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type { PostImportRow } from '@/lib/postImportSession'
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
row: PostImportRow
|
row: PostImportRow
|
||||||
|
displayNumber?: number
|
||||||
onEdit?: () => void
|
onEdit?: () => void
|
||||||
onRetry?: () => void
|
onRetry?: () => void
|
||||||
onToggleSkip?: (checked: boolean) => void
|
onToggleSkip?: (checked: boolean) => void
|
||||||
@@ -35,6 +36,7 @@ const summaryDate = (row: PostImportRow): string =>
|
|||||||
|
|
||||||
const PostImportRowSummary: FC<Props> = (
|
const PostImportRowSummary: FC<Props> = (
|
||||||
{ row,
|
{ row,
|
||||||
|
displayNumber,
|
||||||
onEdit,
|
onEdit,
|
||||||
onRetry,
|
onRetry,
|
||||||
onToggleSkip,
|
onToggleSkip,
|
||||||
@@ -47,9 +49,11 @@ const PostImportRowSummary: FC<Props> = (
|
|||||||
) => {
|
) => {
|
||||||
const warning = summaryWarning (row)
|
const warning = summaryWarning (row)
|
||||||
const displayStatus = displayPostImportStatus (row)
|
const displayStatus = displayPostImportStatus (row)
|
||||||
const editAllowed = onEdit != null && canEditReviewRow (row)
|
const editVisible = onEdit != null
|
||||||
|
const editAllowed = editVisible && canEditReviewRow (row)
|
||||||
const retryAllowed = onRetry != null && canRetryResultRow (row)
|
const retryAllowed = onRetry != null && canRetryResultRow (row)
|
||||||
const skipChecked = row.skipReason === 'manual'
|
const skipChecked = row.skipReason === 'manual'
|
||||||
|
const rowNumber = displayNumber ?? row.sourceRow
|
||||||
const skipControl = showSkipToggle
|
const skipControl = showSkipToggle
|
||||||
? (
|
? (
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<label className="flex items-center gap-2 text-sm">
|
||||||
@@ -70,7 +74,7 @@ const PostImportRowSummary: FC<Props> = (
|
|||||||
'md:grid-cols-[4rem_5rem_minmax(0,1fr)_auto_auto]',
|
'md:grid-cols-[4rem_5rem_minmax(0,1fr)_auto_auto]',
|
||||||
'transition-shadow hover:shadow-sm')}>
|
'transition-shadow hover:shadow-sm')}>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-sm font-medium">#{row.sourceRow}</div>
|
<div className="text-sm font-medium">#{rowNumber}</div>
|
||||||
</div>
|
</div>
|
||||||
<PostImportThumbnailPreview
|
<PostImportThumbnailPreview
|
||||||
url={String (row.attributes.thumbnailBase ?? '')}
|
url={String (row.attributes.thumbnailBase ?? '')}
|
||||||
@@ -100,12 +104,12 @@ const PostImportRowSummary: FC<Props> = (
|
|||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{skipControl}
|
{skipControl}
|
||||||
{showActions && editAllowed && (
|
{showActions && editVisible && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onEdit}
|
onClick={onEdit}
|
||||||
disabled={editDisabled === true}>
|
disabled={editDisabled === true || !(editAllowed)}>
|
||||||
編輯
|
編輯
|
||||||
</Button>)}
|
</Button>)}
|
||||||
{showActions && retryAllowed && (
|
{showActions && retryAllowed && (
|
||||||
@@ -124,6 +128,7 @@ const PostImportRowSummary: FC<Props> = (
|
|||||||
className={cn (
|
className={cn (
|
||||||
'space-y-3 rounded-lg border p-4 md:hidden',
|
'space-y-3 rounded-lg border p-4 md:hidden',
|
||||||
'transition-shadow hover:shadow-sm')}>
|
'transition-shadow hover:shadow-sm')}>
|
||||||
|
<div className="text-sm font-medium">#{rowNumber}</div>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<PostImportThumbnailPreview
|
<PostImportThumbnailPreview
|
||||||
url={String (row.attributes.thumbnailBase ?? '')}
|
url={String (row.attributes.thumbnailBase ?? '')}
|
||||||
@@ -152,14 +157,14 @@ const PostImportRowSummary: FC<Props> = (
|
|||||||
{skipControl}
|
{skipControl}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showActions && (editAllowed || retryAllowed) && (
|
{showActions && (editVisible || retryAllowed) && (
|
||||||
<div className="flex flex-col gap-2 sm:flex-row">
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
{editAllowed && (
|
{editVisible && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onEdit}
|
onClick={onEdit}
|
||||||
disabled={editDisabled === true}>
|
disabled={editDisabled === true || !(editAllowed)}>
|
||||||
編輯
|
編輯
|
||||||
</Button>)}
|
</Button>)}
|
||||||
{retryAllowed && (
|
{retryAllowed && (
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import type {
|
|||||||
} from '@/lib/postImportTypes'
|
} from '@/lib/postImportTypes'
|
||||||
|
|
||||||
type QueryRowState = {
|
type QueryRowState = {
|
||||||
url: string
|
|
||||||
title: string
|
title: string
|
||||||
thumbnail_base: string
|
thumbnail_base: string
|
||||||
original_created_from: string
|
original_created_from: string
|
||||||
@@ -23,20 +22,45 @@ type QueryRowState = {
|
|||||||
created_post_id: number | null
|
created_post_id: number | null
|
||||||
recoverable: boolean | null }
|
recoverable: boolean | null }
|
||||||
|
|
||||||
|
type FullRowState = QueryRowState & { url: string }
|
||||||
|
|
||||||
type MultiRowMetaState = {
|
type MultiRowMetaState = {
|
||||||
v: 1
|
v: 1
|
||||||
rows: QueryRowState[] }
|
rows: EncodedRowState[] }
|
||||||
|
|
||||||
type FullState = {
|
type EncodedSkip = 'e' | 'm'
|
||||||
|
type EncodedImportStatus = 'p' | 'c' | 's' | 'f'
|
||||||
|
|
||||||
|
type EncodedRowState = {
|
||||||
|
u?: string
|
||||||
|
t?: string
|
||||||
|
h?: string
|
||||||
|
f?: string
|
||||||
|
b?: string
|
||||||
|
d?: string
|
||||||
|
g?: string
|
||||||
|
p?: string
|
||||||
|
m?: string[]
|
||||||
|
s?: EncodedSkip
|
||||||
|
e?: number
|
||||||
|
i?: EncodedImportStatus
|
||||||
|
c?: number
|
||||||
|
r?: boolean }
|
||||||
|
|
||||||
|
type EncodedState = {
|
||||||
v: 1
|
v: 1
|
||||||
rows: Array<QueryRowState & { url: string }> }
|
rows: EncodedRowState[] }
|
||||||
|
|
||||||
type ParsedPostNewState = {
|
export type ParsedPostNewState = {
|
||||||
rows: PostImportRow[]
|
rows: PostImportRow[]
|
||||||
source: string
|
source: string
|
||||||
repairMode: PostImportRepairMode
|
repairMode: PostImportRepairMode
|
||||||
shortcut: boolean }
|
shortcut: boolean }
|
||||||
|
|
||||||
|
export type SerialisedPostNewState = {
|
||||||
|
path: string
|
||||||
|
rows: PostImportRow[] }
|
||||||
|
|
||||||
const BASIC_KEYS = [
|
const BASIC_KEYS = [
|
||||||
'url',
|
'url',
|
||||||
'title',
|
'title',
|
||||||
@@ -55,6 +79,8 @@ const STATE_KEYS = [
|
|||||||
'recoverable'] as const
|
'recoverable'] as const
|
||||||
const SINGLE_ROW_KEYS = [...BASIC_KEYS, ...STATE_KEYS] as const
|
const SINGLE_ROW_KEYS = [...BASIC_KEYS, ...STATE_KEYS] as const
|
||||||
const MAX_REGULAR_URL_LENGTH = 768
|
const MAX_REGULAR_URL_LENGTH = 768
|
||||||
|
const MAX_COMPRESSED_STATE_LENGTH = 767
|
||||||
|
const COMPRESSED_STATE_PREFIX = 'z1.'
|
||||||
|
|
||||||
const textEncoder = new TextEncoder ()
|
const textEncoder = new TextEncoder ()
|
||||||
const textDecoder = new TextDecoder ()
|
const textDecoder = new TextDecoder ()
|
||||||
@@ -72,6 +98,60 @@ const isValidImportStatus = (
|
|||||||
|| value === 'failed'
|
|| value === 'failed'
|
||||||
|
|
||||||
|
|
||||||
|
const encodeSkip = (
|
||||||
|
value: PostImportSkipReason | null,
|
||||||
|
): EncodedSkip | undefined =>
|
||||||
|
value === 'existing'
|
||||||
|
? 'e'
|
||||||
|
: value === 'manual'
|
||||||
|
? 'm'
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
|
||||||
|
const decodeSkip = (
|
||||||
|
value: unknown,
|
||||||
|
): PostImportSkipReason | null => {
|
||||||
|
if (value == null)
|
||||||
|
return null
|
||||||
|
if (value === 'e')
|
||||||
|
return 'existing'
|
||||||
|
if (value === 'm')
|
||||||
|
return 'manual'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const encodeImportStatus = (
|
||||||
|
value: PostImportRow['importStatus'] | null,
|
||||||
|
): EncodedImportStatus | undefined =>
|
||||||
|
value === 'pending'
|
||||||
|
? 'p'
|
||||||
|
: value === 'created'
|
||||||
|
? 'c'
|
||||||
|
: value === 'skipped'
|
||||||
|
? 's'
|
||||||
|
: value === 'failed'
|
||||||
|
? 'f'
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
|
||||||
|
const decodeImportStatus = (
|
||||||
|
value: unknown,
|
||||||
|
): PostImportRow['importStatus'] | null => {
|
||||||
|
if (value == null)
|
||||||
|
return null
|
||||||
|
if (value === 'p')
|
||||||
|
return 'pending'
|
||||||
|
if (value === 'c')
|
||||||
|
return 'created'
|
||||||
|
if (value === 's')
|
||||||
|
return 'skipped'
|
||||||
|
if (value === 'f')
|
||||||
|
return 'failed'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const parsePositiveInt = (value: string | null): number | null => {
|
const parsePositiveInt = (value: string | null): number | null => {
|
||||||
if (value == null || value === '')
|
if (value == null || value === '')
|
||||||
return null
|
return null
|
||||||
@@ -81,7 +161,7 @@ const parsePositiveInt = (value: string | null): number | null => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const decodeBase64Url = (value: string): string | null => {
|
const decodeBase64UrlBytes = (value: string): Uint8Array | null => {
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const padded = value.replaceAll ('-', '+').replaceAll ('_', '/')
|
const padded = value.replaceAll ('-', '+').replaceAll ('_', '/')
|
||||||
@@ -91,8 +171,7 @@ const decodeBase64Url = (value: string): string | null => {
|
|||||||
? padded
|
? padded
|
||||||
: `${ padded }${ '='.repeat (4 - remainder) }`
|
: `${ padded }${ '='.repeat (4 - remainder) }`
|
||||||
const binary = window.atob (base64)
|
const binary = window.atob (base64)
|
||||||
const bytes = Uint8Array.from (binary, char => char.charCodeAt (0))
|
return Uint8Array.from (binary, char => char.charCodeAt (0))
|
||||||
return textDecoder.decode (bytes)
|
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -101,13 +180,52 @@ const decodeBase64Url = (value: string): string | null => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const encodeBase64Url = (value: string): string => {
|
const encodeBase64UrlBytes = (value: Uint8Array): string => {
|
||||||
const bytes = textEncoder.encode (value)
|
const binary = Array.from (value, byte => String.fromCharCode (byte)).join ('')
|
||||||
const binary = Array.from (bytes, byte => String.fromCharCode (byte)).join ('')
|
|
||||||
return window.btoa (binary).replaceAll ('+', '-').replaceAll ('/', '_').replaceAll ('=', '')
|
return window.btoa (binary).replaceAll ('+', '-').replaceAll ('/', '_').replaceAll ('=', '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const compressBytes = async (value: Uint8Array): Promise<Uint8Array> => {
|
||||||
|
const stream =
|
||||||
|
new Blob ([value]).stream ().pipeThrough (new CompressionStream ('gzip'))
|
||||||
|
return new Uint8Array (await new Response (stream).arrayBuffer ())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const decompressBytes = async (value: Uint8Array): Promise<Uint8Array> => {
|
||||||
|
const stream =
|
||||||
|
new Blob ([value]).stream ().pipeThrough (new DecompressionStream ('gzip'))
|
||||||
|
return new Uint8Array (await new Response (stream).arrayBuffer ())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const decodeCompressedState = async (value: string): Promise<string | null> => {
|
||||||
|
if (!(value.startsWith (COMPRESSED_STATE_PREFIX)))
|
||||||
|
return null
|
||||||
|
|
||||||
|
const encoded = value.slice (COMPRESSED_STATE_PREFIX.length)
|
||||||
|
const compressed = decodeBase64UrlBytes (encoded)
|
||||||
|
if (compressed == null)
|
||||||
|
return null
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return textDecoder.decode (await decompressBytes (compressed))
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const encodeCompressedState = async (value: string): Promise<string> => {
|
||||||
|
const compressed = await compressBytes (textEncoder.encode (value))
|
||||||
|
return `${ COMPRESSED_STATE_PREFIX }${ encodeBase64UrlBytes (compressed) }`
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const manualFields = (row: PostImportRow): string[] =>
|
const manualFields = (row: PostImportRow): string[] =>
|
||||||
['title',
|
['title',
|
||||||
'thumbnailBase',
|
'thumbnailBase',
|
||||||
@@ -119,7 +237,6 @@ const manualFields = (row: PostImportRow): string[] =>
|
|||||||
|
|
||||||
|
|
||||||
const baseRowState = (row: PostImportRow): QueryRowState => ({
|
const baseRowState = (row: PostImportRow): QueryRowState => ({
|
||||||
url: row.url,
|
|
||||||
title: String (row.attributes.title ?? ''),
|
title: String (row.attributes.title ?? ''),
|
||||||
thumbnail_base: String (row.attributes.thumbnailBase ?? ''),
|
thumbnail_base: String (row.attributes.thumbnailBase ?? ''),
|
||||||
original_created_from: String (row.attributes.originalCreatedFrom ?? ''),
|
original_created_from: String (row.attributes.originalCreatedFrom ?? ''),
|
||||||
@@ -137,7 +254,7 @@ const baseRowState = (row: PostImportRow): QueryRowState => ({
|
|||||||
|
|
||||||
const buildRow = (
|
const buildRow = (
|
||||||
sourceRow: number,
|
sourceRow: number,
|
||||||
state: QueryRowState & { url: string },
|
state: FullRowState,
|
||||||
): PostImportRow => {
|
): PostImportRow => {
|
||||||
const manual = new Set (state.manual)
|
const manual = new Set (state.manual)
|
||||||
const provenance: Record<string, PostImportOrigin> = {
|
const provenance: Record<string, PostImportOrigin> = {
|
||||||
@@ -149,9 +266,9 @@ const buildRow = (
|
|||||||
duration: 'automatic',
|
duration: 'automatic',
|
||||||
tags: manual.has ('tags') ? 'manual' : 'automatic',
|
tags: manual.has ('tags') ? 'manual' : 'automatic',
|
||||||
parentPostIds: manual.has ('parentPostIds') ? 'manual' : 'automatic' }
|
parentPostIds: manual.has ('parentPostIds') ? 'manual' : 'automatic' }
|
||||||
const tagSources = {
|
const tagSources = provenance.tags === 'manual'
|
||||||
automatic: '',
|
? { automatic: '', manual: state.tags }
|
||||||
manual: provenance.tags === 'manual' ? state.tags : '' }
|
: { automatic: state.tags, manual: '' }
|
||||||
const attributes = {
|
const attributes = {
|
||||||
title: state.title,
|
title: state.title,
|
||||||
thumbnailBase: state.thumbnail_base,
|
thumbnailBase: state.thumbnail_base,
|
||||||
@@ -193,6 +310,90 @@ const parseManual = (value: string | null): string[] =>
|
|||||||
.filter (entry => entry !== '')
|
.filter (entry => entry !== '')
|
||||||
|
|
||||||
|
|
||||||
|
const isStringArray = (value: unknown): value is string[] =>
|
||||||
|
Array.isArray (value) && value.every (entry => typeof entry === 'string')
|
||||||
|
|
||||||
|
|
||||||
|
const parseEncodedRow = (
|
||||||
|
value: unknown,
|
||||||
|
requireUrl: boolean,
|
||||||
|
): FullRowState | null => {
|
||||||
|
if (typeof value !== 'object' || value == null || Array.isArray (value))
|
||||||
|
return null
|
||||||
|
|
||||||
|
const row = value as Record<string, unknown>
|
||||||
|
const url = row['u']
|
||||||
|
const manual = row['m']
|
||||||
|
const skip = decodeSkip (row['s'])
|
||||||
|
const importStatus = decodeImportStatus (row['i'])
|
||||||
|
const existingPostId = row['e']
|
||||||
|
const createdPostId = row['c']
|
||||||
|
const recoverable = row['r']
|
||||||
|
|
||||||
|
if (requireUrl && typeof url !== 'string')
|
||||||
|
return null
|
||||||
|
if (manual != null && !(isStringArray (manual)))
|
||||||
|
return null
|
||||||
|
if (row['s'] != null && skip == null)
|
||||||
|
return null
|
||||||
|
if (row['i'] != null && importStatus == null)
|
||||||
|
return null
|
||||||
|
if (existingPostId != null
|
||||||
|
&& !(typeof existingPostId === 'number'
|
||||||
|
&& Number.isInteger (existingPostId)
|
||||||
|
&& existingPostId > 0))
|
||||||
|
return null
|
||||||
|
if (createdPostId != null
|
||||||
|
&& !(typeof createdPostId === 'number'
|
||||||
|
&& Number.isInteger (createdPostId)
|
||||||
|
&& createdPostId > 0))
|
||||||
|
return null
|
||||||
|
if (recoverable != null && typeof recoverable !== 'boolean')
|
||||||
|
return null
|
||||||
|
|
||||||
|
const stringFields = [
|
||||||
|
['t', 'title'],
|
||||||
|
['h', 'thumbnail_base'],
|
||||||
|
['f', 'original_created_from'],
|
||||||
|
['b', 'original_created_before'],
|
||||||
|
['d', 'duration'],
|
||||||
|
['g', 'tags'],
|
||||||
|
['p', 'parent_post_ids']] as const
|
||||||
|
let parsedStrings: Record<(typeof stringFields)[number][1], string>
|
||||||
|
try
|
||||||
|
{
|
||||||
|
parsedStrings = Object.fromEntries (
|
||||||
|
stringFields.map (([key, target]) => {
|
||||||
|
const fieldValue = row[key]
|
||||||
|
if (fieldValue != null && typeof fieldValue !== 'string')
|
||||||
|
throw new Error ('invalid row')
|
||||||
|
return [target, fieldValue ?? '']
|
||||||
|
}),
|
||||||
|
) as Record<(typeof stringFields)[number][1], string>
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: typeof url === 'string' ? url : '',
|
||||||
|
title: parsedStrings.title,
|
||||||
|
thumbnail_base: parsedStrings.thumbnail_base,
|
||||||
|
original_created_from: parsedStrings.original_created_from,
|
||||||
|
original_created_before: parsedStrings.original_created_before,
|
||||||
|
duration: parsedStrings.duration,
|
||||||
|
tags: parsedStrings.tags,
|
||||||
|
parent_post_ids: parsedStrings.parent_post_ids,
|
||||||
|
manual: manual ?? [],
|
||||||
|
skip,
|
||||||
|
existing_post_id: existingPostId ?? null,
|
||||||
|
import_status: importStatus,
|
||||||
|
created_post_id: createdPostId ?? null,
|
||||||
|
recoverable: recoverable ?? null }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const parseSingleRow = (params: URLSearchParams): ParsedPostNewState | null => {
|
const parseSingleRow = (params: URLSearchParams): ParsedPostNewState | null => {
|
||||||
const url = params.get ('url')
|
const url = params.get ('url')
|
||||||
if (url == null || url === '')
|
if (url == null || url === '')
|
||||||
@@ -265,14 +466,14 @@ const parseMetaRows = (
|
|||||||
urlsValue: string,
|
urlsValue: string,
|
||||||
metaValue: string,
|
metaValue: string,
|
||||||
): ParsedPostNewState | null => {
|
): ParsedPostNewState | null => {
|
||||||
const decoded = decodeBase64Url (metaValue)
|
const decodedBytes = decodeBase64UrlBytes (metaValue)
|
||||||
if (decoded == null)
|
if (decodedBytes == null)
|
||||||
return null
|
return null
|
||||||
|
|
||||||
let parsed: MultiRowMetaState
|
let parsed: MultiRowMetaState
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
parsed = JSON.parse (decoded) as MultiRowMetaState
|
parsed = JSON.parse (textDecoder.decode (decodedBytes)) as MultiRowMetaState
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -286,10 +487,16 @@ const parseMetaRows = (
|
|||||||
if (urls.length < 2 || urls.length !== parsed.rows.length)
|
if (urls.length < 2 || urls.length !== parsed.rows.length)
|
||||||
return null
|
return null
|
||||||
|
|
||||||
const rows = parsed.rows.map ((row, index) => buildRow (index + 1, {
|
const fullRows = parsed.rows.map ((row, index) => {
|
||||||
...row,
|
const parsedRow = parseEncodedRow (row, false)
|
||||||
url: urls[index] ?? '' }))
|
return parsedRow == null
|
||||||
|
? null
|
||||||
|
: { ...parsedRow, url: urls[index] ?? '' }
|
||||||
|
})
|
||||||
|
if (fullRows.some (row => row == null))
|
||||||
|
return null
|
||||||
|
|
||||||
|
const rows = fullRows.map ((row, index) => buildRow (index + 1, row as FullRowState))
|
||||||
return {
|
return {
|
||||||
rows,
|
rows,
|
||||||
source: urls.join ('\n'),
|
source: urls.join ('\n'),
|
||||||
@@ -298,25 +505,29 @@ const parseMetaRows = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const parseFullState = (stateValue: string): ParsedPostNewState | null => {
|
const parseFullState = async (stateValue: string): Promise<ParsedPostNewState | null> => {
|
||||||
const decoded = decodeBase64Url (stateValue)
|
const decoded = await decodeCompressedState (stateValue)
|
||||||
if (decoded == null)
|
if (decoded == null)
|
||||||
return null
|
return null
|
||||||
|
|
||||||
let parsed: FullState
|
let parsed: EncodedState
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
parsed = JSON.parse (decoded) as FullState
|
parsed = JSON.parse (decoded) as EncodedState
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.v !== 1 || !(Array.isArray (parsed.rows)))
|
if (parsed.v !== 1 || !(Array.isArray (parsed.rows)) || parsed.rows.length === 0)
|
||||||
return null
|
return null
|
||||||
|
|
||||||
const rows = parsed.rows.map ((row, index) => buildRow (index + 1, row))
|
const fullRows = parsed.rows.map (row => parseEncodedRow (row, true))
|
||||||
|
if (fullRows.some (row => row == null))
|
||||||
|
return null
|
||||||
|
|
||||||
|
const rows = fullRows.map ((row, index) => buildRow (index + 1, row as FullRowState))
|
||||||
return {
|
return {
|
||||||
rows,
|
rows,
|
||||||
source: rows.map (row => row.url).join ('\n'),
|
source: rows.map (row => row.url).join ('\n'),
|
||||||
@@ -354,18 +565,58 @@ const regularQuery = (rows: PostImportRow[]): URLSearchParams => {
|
|||||||
return params
|
return params
|
||||||
}
|
}
|
||||||
|
|
||||||
const urls = rows.map (row => row.url).join (' ')
|
|
||||||
const metaRows: QueryRowState[] = rows.map (row => baseRowState (row))
|
|
||||||
const meta = encodeBase64Url (JSON.stringify ({
|
|
||||||
v: 1,
|
|
||||||
rows: metaRows }))
|
|
||||||
const params = new URLSearchParams ()
|
const params = new URLSearchParams ()
|
||||||
params.set ('urls', urls)
|
params.set ('urls', rows.map (row => row.url).join (' '))
|
||||||
params.set ('meta', meta)
|
params.set ('meta', encodeBase64UrlBytes (textEncoder.encode (JSON.stringify ({
|
||||||
|
v: 1,
|
||||||
|
rows: rows.map (row => encodeRowState (row, false)) }))))
|
||||||
return params
|
return params
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const encodeRowState = (
|
||||||
|
row: PostImportRow,
|
||||||
|
includeUrl: boolean,
|
||||||
|
): EncodedRowState => {
|
||||||
|
const base = baseRowState (row)
|
||||||
|
const encoded: EncodedRowState = includeUrl ? { u: row.url } : { }
|
||||||
|
|
||||||
|
if (base.title !== '')
|
||||||
|
encoded.t = base.title
|
||||||
|
if (base.thumbnail_base !== '')
|
||||||
|
encoded.h = base.thumbnail_base
|
||||||
|
if (base.original_created_from !== '')
|
||||||
|
encoded.f = base.original_created_from
|
||||||
|
if (base.original_created_before !== '')
|
||||||
|
encoded.b = base.original_created_before
|
||||||
|
if (base.duration !== '')
|
||||||
|
encoded.d = base.duration
|
||||||
|
if (base.tags !== '')
|
||||||
|
encoded.g = base.tags
|
||||||
|
if (base.parent_post_ids !== '')
|
||||||
|
encoded.p = base.parent_post_ids
|
||||||
|
if (base.manual.length > 0)
|
||||||
|
encoded.m = [...base.manual]
|
||||||
|
if (base.skip != null)
|
||||||
|
encoded.s = encodeSkip (base.skip)
|
||||||
|
if (base.existing_post_id != null)
|
||||||
|
encoded.e = base.existing_post_id
|
||||||
|
if (base.import_status != null)
|
||||||
|
encoded.i = encodeImportStatus (base.import_status)
|
||||||
|
if (base.created_post_id != null)
|
||||||
|
encoded.c = base.created_post_id
|
||||||
|
if (base.recoverable != null)
|
||||||
|
encoded.r = base.recoverable
|
||||||
|
|
||||||
|
return encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const encodeStateRows = (rows: PostImportRow[]): EncodedState => ({
|
||||||
|
v: 1,
|
||||||
|
rows: rows.map (row => encodeRowState (row, true)) })
|
||||||
|
|
||||||
|
|
||||||
export const hasPostNewReviewState = (search: string): boolean => {
|
export const hasPostNewReviewState = (search: string): boolean => {
|
||||||
const params = new URLSearchParams (search)
|
const params = new URLSearchParams (search)
|
||||||
return params.has ('state')
|
return params.has ('state')
|
||||||
@@ -375,11 +626,13 @@ export const hasPostNewReviewState = (search: string): boolean => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const parsePostNewState = (search: string): ParsedPostNewState | null => {
|
export const parsePostNewState = async (
|
||||||
|
search: string,
|
||||||
|
): Promise<ParsedPostNewState | null> => {
|
||||||
const params = new URLSearchParams (search)
|
const params = new URLSearchParams (search)
|
||||||
const stateValue = params.get ('state')
|
const stateValue = params.get ('state')
|
||||||
if (stateValue != null)
|
if (stateValue != null)
|
||||||
return parseFullState (stateValue)
|
return await parseFullState (stateValue)
|
||||||
|
|
||||||
const urlsValue = params.get ('urls')
|
const urlsValue = params.get ('urls')
|
||||||
const metaValue = params.get ('meta')
|
const metaValue = params.get ('meta')
|
||||||
@@ -394,17 +647,40 @@ export const parsePostNewState = (search: string): ParsedPostNewState | null =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const serialisePostNewState = (rows: PostImportRow[]): string => {
|
const serialiseCompressedRows = async (
|
||||||
|
rows: PostImportRow[],
|
||||||
|
): Promise<SerialisedPostNewState | null> => {
|
||||||
|
try
|
||||||
|
{
|
||||||
|
for (let count = rows.length; count > 0; --count)
|
||||||
|
{
|
||||||
|
const nextRows = rows.slice (0, count)
|
||||||
|
const encoded = encodeStateRows (nextRows)
|
||||||
|
const state = await encodeCompressedState (JSON.stringify (encoded))
|
||||||
|
const payload = state.slice (COMPRESSED_STATE_PREFIX.length)
|
||||||
|
if (payload.length <= MAX_COMPRESSED_STATE_LENGTH)
|
||||||
|
return {
|
||||||
|
path: `/posts/new?state=${ state }`,
|
||||||
|
rows: nextRows }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const serialisePostNewState = async (
|
||||||
|
rows: PostImportRow[],
|
||||||
|
): Promise<SerialisedPostNewState | null> => {
|
||||||
const regular = regularQuery (rows).toString ()
|
const regular = regularQuery (rows).toString ()
|
||||||
const regularPath = `/posts/new?${ regular }`
|
const regularPath = `/posts/new?${ regular }`
|
||||||
if (regularPath.length < MAX_REGULAR_URL_LENGTH)
|
if (regularPath.length < MAX_REGULAR_URL_LENGTH)
|
||||||
return `/posts/new?${ regular }`
|
return {
|
||||||
|
path: regularPath,
|
||||||
|
rows }
|
||||||
|
|
||||||
const stateRows: FullState['rows'] = rows.map (row => ({
|
return await serialiseCompressedRows (rows)
|
||||||
...baseRowState (row),
|
|
||||||
url: row.url }))
|
|
||||||
const state = encodeBase64Url (JSON.stringify ({
|
|
||||||
v: 1,
|
|
||||||
rows: stateRows }))
|
|
||||||
return `/posts/new?state=${ state }`
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
import { useQueries } from '@tanstack/react-query'
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
import PageTitle from '@/components/common/PageTitle'
|
import PageTitle from '@/components/common/PageTitle'
|
||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
|
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
|
||||||
import PostImportRowForm from '@/components/posts/import/PostImportRowForm'
|
import PostImportRowForm from '@/components/posts/import/PostImportRowForm'
|
||||||
import PostImportRowSummary from '@/components/posts/import/PostImportRowSummary'
|
import PostImportRowSummary from '@/components/posts/import/PostImportRowSummary'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -11,39 +15,53 @@ import { toast } from '@/components/ui/use-toast'
|
|||||||
import { SITE_TITLE } from '@/config'
|
import { SITE_TITLE } from '@/config'
|
||||||
import { apiPost } from '@/lib/api'
|
import { apiPost } from '@/lib/api'
|
||||||
import useDialogue from '@/lib/dialogues/useDialogue'
|
import useDialogue from '@/lib/dialogues/useDialogue'
|
||||||
import { parsePostNewState, serialisePostNewState } from '@/lib/postNewQueryState'
|
import { fetchPost } from '@/lib/posts'
|
||||||
import { clearPostImportSourceDraft,
|
import {
|
||||||
buildNextEditedRow,
|
parsePostNewState,
|
||||||
canEditReviewRow,
|
serialisePostNewState,
|
||||||
canRetryResultRow,
|
} from '@/lib/postNewQueryState'
|
||||||
hasExactSourceRows,
|
import {
|
||||||
initialisePreviewRows,
|
buildNextEditedRow,
|
||||||
isCompletedReviewRow,
|
canEditReviewRow,
|
||||||
isExistingSkipRow,
|
canRetryResultRow,
|
||||||
isNonRecoverableFailedRow,
|
clearPostImportSourceDraft,
|
||||||
mergeImportResults,
|
hasExactSourceRows,
|
||||||
mergeValidatedImportRow,
|
initialisePreviewRows,
|
||||||
mergeValidatedImportRows,
|
isCompletedReviewRow,
|
||||||
processableImportRows,
|
isExistingSkipRow,
|
||||||
replaceImportRow,
|
isNonRecoverableFailedRow,
|
||||||
resultRepairMode,
|
mergeImportResults,
|
||||||
resultRowMessages,
|
mergeValidatedImportRow,
|
||||||
reviewSummaryCounts,
|
mergeValidatedImportRows,
|
||||||
retryImportRow,
|
processableImportRows,
|
||||||
validatableImportRows } from '@/lib/postImportSession'
|
replaceImportRow,
|
||||||
|
resultRepairMode,
|
||||||
|
resultRowMessages,
|
||||||
|
reviewSummaryCounts,
|
||||||
|
retryImportRow,
|
||||||
|
validatableImportRows,
|
||||||
|
} from '@/lib/postImportSession'
|
||||||
|
import { postsKeys } from '@/lib/queryKeys'
|
||||||
|
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||||
import { canEditContent } from '@/lib/users'
|
import { canEditContent } from '@/lib/users'
|
||||||
import Forbidden from '@/pages/Forbidden'
|
import Forbidden from '@/pages/Forbidden'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
import type { PostImportRowDraft } from '@/components/posts/import/PostImportRowForm'
|
import type { PostImportRowDraft } from '@/components/posts/import/PostImportRowForm'
|
||||||
import type { PostImportResultRow,
|
import type {
|
||||||
PostImportRow,
|
PostImportResultRow,
|
||||||
PostImportSession } from '@/lib/postImportSession'
|
PostImportRow,
|
||||||
import type { User } from '@/types'
|
PostImportSession,
|
||||||
|
} from '@/lib/postImportSession'
|
||||||
|
import type { Post, User } from '@/types'
|
||||||
|
|
||||||
type Props = { user: User | null }
|
type Props = { user: User | null }
|
||||||
|
|
||||||
|
type ExistingSkippedRowsProps = {
|
||||||
|
open: boolean
|
||||||
|
rows: PostImportRow[] }
|
||||||
|
|
||||||
const isRepairRow = (row: PostImportRow): boolean =>
|
const isRepairRow = (row: PostImportRow): boolean =>
|
||||||
row.recoverable === true
|
row.recoverable === true
|
||||||
&& (row.importStatus === 'failed'
|
&& (row.importStatus === 'failed'
|
||||||
@@ -51,11 +69,87 @@ const isRepairRow = (row: PostImportRow): boolean =>
|
|||||||
&& Object.keys (row.validationErrors).length > 0))
|
&& Object.keys (row.validationErrors).length > 0))
|
||||||
|
|
||||||
|
|
||||||
|
const buildSession = (rows: PostImportRow[]): PostImportSession => ({
|
||||||
|
version: 2,
|
||||||
|
savedAt: new Date ().toISOString (),
|
||||||
|
source: rows.map (row => row.url).join ('\n'),
|
||||||
|
rows,
|
||||||
|
repairMode: resultRepairMode (rows) })
|
||||||
|
|
||||||
|
|
||||||
|
const ExistingSkippedRows: FC<ExistingSkippedRowsProps> = ({ open, rows }) => {
|
||||||
|
const existingPostIds = useMemo (
|
||||||
|
() =>
|
||||||
|
open
|
||||||
|
? Array.from (
|
||||||
|
new Set (
|
||||||
|
rows
|
||||||
|
.map (row => row.existingPostId)
|
||||||
|
.filter ((postId): postId is number => postId != null),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
[open, rows],
|
||||||
|
)
|
||||||
|
const posts = useQueries ({
|
||||||
|
queries: existingPostIds.map (postId => ({
|
||||||
|
enabled: open,
|
||||||
|
queryKey: postsKeys.show (String (postId)),
|
||||||
|
queryFn: () => fetchPost (String (postId)) })),
|
||||||
|
})
|
||||||
|
const postsById = new Map<number, Post> (
|
||||||
|
posts
|
||||||
|
.map (query => query.data)
|
||||||
|
.filter ((post): post is Post => post != null)
|
||||||
|
.map (post => [post.id, post]),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 max-h-72 overflow-y-auto rounded border">
|
||||||
|
<div className="divide-y">
|
||||||
|
{existingPostIds.map (postId => {
|
||||||
|
const post = postsById.get (postId)
|
||||||
|
if (post == null)
|
||||||
|
return <div key={postId} className="h-14"/>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PrefetchLink
|
||||||
|
key={postId}
|
||||||
|
to={`/posts/${ post.id }`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-3 p-3">
|
||||||
|
<PostThumbnailPreview
|
||||||
|
url={post.thumbnail ?? ''}
|
||||||
|
className="h-10 w-10 shrink-0"/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-sm font-medium">
|
||||||
|
{post.title ?? ''}
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
|
||||||
|
{post.url}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PrefetchLink>)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const PostImportReviewPage: FC<Props> = ({ user }) => {
|
const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||||
const editable = canEditContent (user)
|
const editable = canEditContent (user)
|
||||||
const dialogue = useDialogue ()
|
const dialogue = useDialogue ()
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
const navigate = useNavigate ()
|
const navigate = useNavigate ()
|
||||||
|
const behaviourSettings = useClientBehaviourSettings ()
|
||||||
|
const animationMode = behaviourSettings.animation ?? 'normal'
|
||||||
|
const existingRowsTransition =
|
||||||
|
animationMode === 'off'
|
||||||
|
? { duration: 0 }
|
||||||
|
: animationMode === 'reduced'
|
||||||
|
? { duration: .08, ease: 'linear' as const }
|
||||||
|
: { duration: .2, ease: 'easeOut' as const }
|
||||||
|
|
||||||
const [session, setSession] = useState<PostImportSession | null> (null)
|
const [session, setSession] = useState<PostImportSession | null> (null)
|
||||||
const [loading, setLoading] = useState (false)
|
const [loading, setLoading] = useState (false)
|
||||||
@@ -64,45 +158,72 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
const [showExistingRows, setShowExistingRows] = useState (false)
|
const [showExistingRows, setShowExistingRows] = useState (false)
|
||||||
const sessionRef = useRef<PostImportSession | null> (null)
|
const sessionRef = useRef<PostImportSession | null> (null)
|
||||||
const persistedSearchRef = useRef<string | null> (null)
|
const persistedSearchRef = useRef<string | null> (null)
|
||||||
|
const persistSequenceRef = useRef (0)
|
||||||
|
|
||||||
|
const persistSession = async (
|
||||||
|
nextSession: PostImportSession,
|
||||||
|
): Promise<PostImportSession | null> => {
|
||||||
|
const sequence = ++persistSequenceRef.current
|
||||||
|
const serialised = await serialisePostNewState (nextSession.rows)
|
||||||
|
if (serialised == null)
|
||||||
|
return null
|
||||||
|
if (persistSequenceRef.current !== sequence)
|
||||||
|
return sessionRef.current
|
||||||
|
|
||||||
|
const persistedSession = buildSession (serialised.rows)
|
||||||
|
persistedSearchRef.current =
|
||||||
|
(new URL (serialised.path, window.location.origin)).search
|
||||||
|
sessionRef.current = persistedSession
|
||||||
|
setSession (persistedSession)
|
||||||
|
navigate (serialised.path, { replace: true })
|
||||||
|
return persistedSession
|
||||||
|
}
|
||||||
|
|
||||||
|
const finishImport = () => {
|
||||||
|
clearPostImportSourceDraft (message =>
|
||||||
|
toast ({ title: '入力内容を削除できませんでした', description: message }))
|
||||||
|
navigate ('/posts')
|
||||||
|
}
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
if (sessionRef.current != null && persistedSearchRef.current === location.search)
|
let active = true
|
||||||
return
|
|
||||||
|
|
||||||
const parsed = parsePostNewState (location.search)
|
if (sessionRef.current != null && persistedSearchRef.current === location.search)
|
||||||
if (parsed == null)
|
return () => {
|
||||||
{
|
|
||||||
navigate ('/posts/new', { replace: true })
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
const loaded = {
|
|
||||||
version: 2,
|
|
||||||
savedAt: new Date ().toISOString (),
|
|
||||||
source: parsed.source,
|
|
||||||
rows: parsed.rows,
|
|
||||||
repairMode: parsed.repairMode }
|
|
||||||
persistedSearchRef.current = location.search
|
|
||||||
sessionRef.current = loaded
|
|
||||||
setSession (loaded)
|
|
||||||
|
|
||||||
const hydrate = async () => {
|
const hydrate = async () => {
|
||||||
|
const parsed = await parsePostNewState (location.search)
|
||||||
|
if (!(active))
|
||||||
|
return
|
||||||
|
if (parsed == null)
|
||||||
|
{
|
||||||
|
navigate ('/posts/new', { replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const loaded = buildSession (parsed.rows)
|
||||||
|
persistedSearchRef.current = location.search
|
||||||
|
sessionRef.current = loaded
|
||||||
|
setSession (loaded)
|
||||||
|
|
||||||
if (parsed.shortcut)
|
if (parsed.shortcut)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const preview = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/preview', {
|
const preview = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/preview', {
|
||||||
source: parsed.source })
|
source: parsed.source })
|
||||||
|
if (!(active))
|
||||||
|
return
|
||||||
const rows = initialisePreviewRows (preview.rows)
|
const rows = initialisePreviewRows (preview.rows)
|
||||||
const nextSession = {
|
const persisted = await persistSession (buildSession (rows))
|
||||||
...loaded,
|
if (persisted == null)
|
||||||
source: parsed.source,
|
return
|
||||||
rows,
|
|
||||||
repairMode: resultRepairMode (rows) }
|
|
||||||
persistSession (nextSession)
|
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
navigate ('/posts/new', { replace: true })
|
if (active)
|
||||||
|
navigate ('/posts/new', { replace: true })
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -122,17 +243,18 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
tagSources: row.tagSources,
|
tagSources: row.tagSources,
|
||||||
metadataUrl: row.metadataUrl })),
|
metadataUrl: row.metadataUrl })),
|
||||||
changed_row: 'all' })
|
changed_row: 'all' })
|
||||||
|
if (!(active))
|
||||||
|
return
|
||||||
|
|
||||||
const validatedRows = initialisePreviewRows (validated.rows)
|
const validatedRows = initialisePreviewRows (validated.rows)
|
||||||
if (!(hasExactSourceRows (requestedRows.map (row => row.sourceRow), validatedRows)))
|
if (!(hasExactSourceRows (requestedRows.map (row => row.sourceRow), validatedRows)))
|
||||||
return
|
return
|
||||||
|
|
||||||
const latest = sessionRef.current ?? loaded
|
const latest = sessionRef.current ?? loaded
|
||||||
const rows = mergeValidatedImportRows (latest.rows, validatedRows)
|
const rows = mergeValidatedImportRows (latest.rows, validatedRows)
|
||||||
const nextSession = {
|
const persisted = await persistSession (buildSession (rows))
|
||||||
...latest,
|
if (persisted == null)
|
||||||
rows,
|
return
|
||||||
repairMode: resultRepairMode (rows) }
|
|
||||||
sessionRef.current = nextSession
|
|
||||||
setSession (nextSession)
|
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -140,12 +262,32 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void hydrate ()
|
void hydrate ()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
}, [location.search, navigate])
|
}, [location.search, navigate])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
sessionRef.current = session
|
if (session != null)
|
||||||
|
sessionRef.current = session
|
||||||
}, [session])
|
}, [session])
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
if (session == null || session.rows.length === 0)
|
||||||
|
return
|
||||||
|
if (session.rows.every (row => isCompletedReviewRow (row)))
|
||||||
|
finishImport ()
|
||||||
|
}, [session])
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
if (editingRow == null || session?.repairMode !== 'failed')
|
||||||
|
return
|
||||||
|
|
||||||
|
const element = document.getElementById (`post-import-row-${ editingRow.sourceRow }`)
|
||||||
|
element?.scrollIntoView ({ block: 'center', behavior: 'smooth' })
|
||||||
|
}, [editingRow, session?.repairMode])
|
||||||
|
|
||||||
const rows = session?.rows ?? []
|
const rows = session?.rows ?? []
|
||||||
const counts = useMemo (() => reviewSummaryCounts (rows), [rows])
|
const counts = useMemo (() => reviewSummaryCounts (rows), [rows])
|
||||||
const sortedRows =
|
const sortedRows =
|
||||||
@@ -161,25 +303,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
const reviewRows = sortedRows.filter (row => !(isExistingSkipRow (row)))
|
const reviewRows = sortedRows.filter (row => !(isExistingSkipRow (row)))
|
||||||
const busy = loading || loadingRow != null
|
const busy = loading || loadingRow != null
|
||||||
|
|
||||||
const persistSession = (nextSession: PostImportSession) => {
|
const toggleManualSkip = async (sourceRow: number, checked: boolean) => {
|
||||||
const source = nextSession.rows.map (row => row.url).join ('\n')
|
|
||||||
const persistedSession = {
|
|
||||||
...nextSession,
|
|
||||||
source }
|
|
||||||
const nextPath = serialisePostNewState (persistedSession.rows)
|
|
||||||
persistedSearchRef.current = nextPath.replace ('/posts/new', '')
|
|
||||||
sessionRef.current = persistedSession
|
|
||||||
setSession (persistedSession)
|
|
||||||
navigate (nextPath, { replace: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
const finishImport = () => {
|
|
||||||
clearPostImportSourceDraft (message =>
|
|
||||||
toast ({ title: '入力内容を削除できませんでした', description: message }))
|
|
||||||
navigate ('/posts')
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleManualSkip = (sourceRow: number, checked: boolean) => {
|
|
||||||
if (busy)
|
if (busy)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -189,33 +313,19 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
|
|
||||||
const nextRows = currentSession.rows.map (row => {
|
const nextRows = currentSession.rows.map (row => {
|
||||||
if (row.sourceRow !== sourceRow)
|
if (row.sourceRow !== sourceRow)
|
||||||
return row
|
return row
|
||||||
if (isExistingSkipRow (row)
|
if (isExistingSkipRow (row)
|
||||||
|| row.importStatus === 'created'
|
|| row.importStatus === 'created'
|
||||||
|| isNonRecoverableFailedRow (row))
|
|| isNonRecoverableFailedRow (row))
|
||||||
return row
|
return row
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
skipReason: checked ? 'manual' : undefined,
|
skipReason: checked ? 'manual' : undefined,
|
||||||
existingPostId: checked ? undefined : row.existingPostId }
|
existingPostId: checked ? undefined : row.existingPostId }
|
||||||
})
|
})
|
||||||
const nextSession = {
|
await persistSession (buildSession (nextRows))
|
||||||
...currentSession,
|
|
||||||
rows: nextRows,
|
|
||||||
repairMode: resultRepairMode (nextRows) }
|
|
||||||
persistSession (nextSession)
|
|
||||||
if (nextRows.every (row => isCompletedReviewRow (row)))
|
|
||||||
finishImport ()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect (() => {
|
|
||||||
if (editingRow == null || session?.repairMode !== 'failed')
|
|
||||||
return
|
|
||||||
|
|
||||||
const element = document.getElementById (`post-import-row-${ editingRow.sourceRow }`)
|
|
||||||
element?.scrollIntoView ({ block: 'center', behavior: 'smooth' })
|
|
||||||
}, [editingRow, session?.repairMode])
|
|
||||||
|
|
||||||
const saveDraft = async (
|
const saveDraft = async (
|
||||||
row: PostImportRow,
|
row: PostImportRow,
|
||||||
{ draft, resetRequested }: {
|
{ draft, resetRequested }: {
|
||||||
@@ -270,22 +380,18 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
if (target == null)
|
if (target == null)
|
||||||
{
|
{
|
||||||
const restoredRows = replaceImportRow (latestSession.rows, row)
|
const restoredRows = replaceImportRow (latestSession.rows, row)
|
||||||
persistSession ({
|
await persistSession (buildSession (restoredRows))
|
||||||
...latestSession,
|
|
||||||
rows: restoredRows,
|
|
||||||
repairMode: resultRepairMode (restoredRows) })
|
|
||||||
toast ({ title: '行の再検証結果が不完全でした' })
|
toast ({ title: '行の再検証結果が不完全でした' })
|
||||||
return { saved: false, row: null }
|
return { saved: false, row: null }
|
||||||
}
|
}
|
||||||
|
|
||||||
const editedRows = replaceImportRow (latestSession.rows, nextRow)
|
const editedRows = replaceImportRow (latestSession.rows, nextRow)
|
||||||
const mergedRows = mergeValidatedImportRow (editedRows, target)
|
const mergedRows = mergeValidatedImportRow (editedRows, target)
|
||||||
const nextSession = {
|
const nextSession = await persistSession (buildSession (mergedRows))
|
||||||
...latestSession,
|
if (nextSession == null)
|
||||||
rows: mergedRows,
|
return { saved: false, row: null }
|
||||||
repairMode: resultRepairMode (mergedRows) }
|
|
||||||
persistSession (nextSession)
|
|
||||||
const mergedTarget =
|
const mergedTarget =
|
||||||
mergedRows.find (mergedRow => mergedRow.sourceRow === baseRow.sourceRow)
|
nextSession.rows.find (mergedRow => mergedRow.sourceRow === baseRow.sourceRow)
|
||||||
?? target
|
?? target
|
||||||
if (Object.keys (mergedTarget.validationErrors).length > 0)
|
if (Object.keys (mergedTarget.validationErrors).length > 0)
|
||||||
return { saved: false, row: mergedTarget }
|
return { saved: false, row: mergedTarget }
|
||||||
@@ -351,9 +457,11 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
const pendingRows = retryImportRow (initialSession.rows, sourceRow)
|
const pendingRows = retryImportRow (initialSession.rows, sourceRow)
|
||||||
const pendingSession = { ...initialSession, rows: pendingRows }
|
const pendingSession = await persistSession (buildSession (pendingRows))
|
||||||
persistSession (pendingSession)
|
if (pendingSession == null)
|
||||||
const requestedRows = validatableImportRows (pendingRows)
|
return
|
||||||
|
|
||||||
|
const requestedRows = validatableImportRows (pendingSession.rows)
|
||||||
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
|
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
|
||||||
rows: requestedRows.map (row => ({
|
rows: requestedRows.map (row => ({
|
||||||
sourceRow: row.sourceRow,
|
sourceRow: row.sourceRow,
|
||||||
@@ -368,48 +476,39 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
{
|
{
|
||||||
const latest = sessionRef.current ?? pendingSession
|
const latest = sessionRef.current ?? pendingSession
|
||||||
const restoredRows = replaceImportRow (latest.rows, originalRow)
|
const restoredRows = replaceImportRow (latest.rows, originalRow)
|
||||||
persistSession ({
|
await persistSession (buildSession (restoredRows))
|
||||||
...latest,
|
|
||||||
rows: restoredRows,
|
|
||||||
repairMode: resultRepairMode (restoredRows) })
|
|
||||||
toast ({ title: '再検証結果が不完全でした' })
|
toast ({ title: '再検証結果が不完全でした' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const validatedTarget = validatedRows.find (row => row.sourceRow === sourceRow)
|
const validatedTarget = validatedRows.find (row => row.sourceRow === sourceRow)
|
||||||
if (validatedTarget == null)
|
if (validatedTarget == null)
|
||||||
{
|
{
|
||||||
const latest = sessionRef.current ?? pendingSession
|
const latest = sessionRef.current ?? pendingSession
|
||||||
const restoredRows = replaceImportRow (latest.rows, originalRow)
|
const restoredRows = replaceImportRow (latest.rows, originalRow)
|
||||||
persistSession ({
|
await persistSession (buildSession (restoredRows))
|
||||||
...latest,
|
|
||||||
rows: restoredRows,
|
|
||||||
repairMode: resultRepairMode (restoredRows) })
|
|
||||||
toast ({ title: '再検証結果が不完全でした' })
|
toast ({ title: '再検証結果が不完全でした' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const latestAfterValidate = sessionRef.current ?? pendingSession
|
const latestAfterValidate = sessionRef.current ?? pendingSession
|
||||||
const mergedValidatedRows = mergeValidatedImportRow (
|
const mergedValidatedRows = mergeValidatedImportRow (
|
||||||
latestAfterValidate.rows,
|
latestAfterValidate.rows,
|
||||||
validatedTarget)
|
validatedTarget)
|
||||||
const nextSession = {
|
const nextSession = await persistSession (buildSession (mergedValidatedRows))
|
||||||
...latestAfterValidate,
|
if (nextSession == null)
|
||||||
rows: mergedValidatedRows,
|
return
|
||||||
repairMode: resultRepairMode (mergedValidatedRows) }
|
const target = nextSession.rows.find (row => row.sourceRow === sourceRow)
|
||||||
persistSession (nextSession)
|
|
||||||
const target = mergedValidatedRows.find (row => row.sourceRow === sourceRow)
|
|
||||||
if (target == null)
|
if (target == null)
|
||||||
{
|
{
|
||||||
const restoredRows = replaceImportRow (latestAfterValidate.rows, originalRow)
|
const restoredRows = replaceImportRow (latestAfterValidate.rows, originalRow)
|
||||||
persistSession ({
|
await persistSession (buildSession (restoredRows))
|
||||||
...latestAfterValidate,
|
|
||||||
rows: restoredRows,
|
|
||||||
repairMode: resultRepairMode (restoredRows) })
|
|
||||||
toast ({ title: '再検証結果が不完全でした' })
|
toast ({ title: '再検証結果が不完全でした' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (Object.keys (target.validationErrors ?? { }).length > 0)
|
if (Object.keys (target.validationErrors ?? { }).length > 0)
|
||||||
{
|
{
|
||||||
void editRow (target)
|
editRow (target)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,13 +528,11 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
{
|
{
|
||||||
const latest = sessionRef.current ?? nextSession
|
const latest = sessionRef.current ?? nextSession
|
||||||
const restoredRows = replaceImportRow (latest.rows, originalRow)
|
const restoredRows = replaceImportRow (latest.rows, originalRow)
|
||||||
persistSession ({
|
await persistSession (buildSession (restoredRows))
|
||||||
...latest,
|
|
||||||
rows: restoredRows,
|
|
||||||
repairMode: resultRepairMode (restoredRows) })
|
|
||||||
toast ({ title: '登録結果が不完全でした' })
|
toast ({ title: '登録結果が不完全でした' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const latestAfterImport = sessionRef.current ?? nextSession
|
const latestAfterImport = sessionRef.current ?? nextSession
|
||||||
const mergedRows = mergeImportResults (latestAfterImport.rows, result.rows)
|
const mergedRows = mergeImportResults (latestAfterImport.rows, result.rows)
|
||||||
const recoverableRows = result.rows.filter (row =>
|
const recoverableRows = result.rows.filter (row =>
|
||||||
@@ -454,25 +551,13 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
validationErrors: recoverable.errors ?? { },
|
validationErrors: recoverable.errors ?? { },
|
||||||
importErrors: undefined }
|
importErrors: undefined }
|
||||||
})
|
})
|
||||||
const resultSession = {
|
await persistSession (buildSession (nextRows))
|
||||||
...latestAfterImport,
|
|
||||||
rows: nextRows,
|
|
||||||
repairMode: resultRepairMode (nextRows) }
|
|
||||||
persistSession (resultSession)
|
|
||||||
if (nextRows.every (row => isCompletedReviewRow (row)))
|
|
||||||
{
|
|
||||||
finishImport ()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
const latest = sessionRef.current ?? initialSession
|
const latest = sessionRef.current ?? initialSession
|
||||||
const restoredRows = replaceImportRow (latest.rows, originalRow)
|
const restoredRows = replaceImportRow (latest.rows, originalRow)
|
||||||
persistSession ({
|
await persistSession (buildSession (restoredRows))
|
||||||
...latest,
|
|
||||||
rows: restoredRows,
|
|
||||||
repairMode: resultRepairMode (restoredRows) })
|
|
||||||
toast ({ title: '再試行に失敗しました' })
|
toast ({ title: '再試行に失敗しました' })
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -485,7 +570,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
const currentSession = sessionRef.current
|
const currentSession = sessionRef.current
|
||||||
if (currentSession == null || busy)
|
if (currentSession == null || busy)
|
||||||
return
|
return
|
||||||
if (rows.every (row => isCompletedReviewRow (row)))
|
if (currentSession.rows.every (row => isCompletedReviewRow (row)))
|
||||||
{
|
{
|
||||||
finishImport ()
|
finishImport ()
|
||||||
return
|
return
|
||||||
@@ -517,50 +602,49 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
toast ({ title: '再検証結果が不完全でした' })
|
toast ({ title: '再検証結果が不完全でした' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const latestAfterValidate = sessionRef.current ?? currentSession
|
const latestAfterValidate = sessionRef.current ?? currentSession
|
||||||
const mergedRows = mergeValidatedImportRows (latestAfterValidate.rows, validatedRows)
|
const mergedRows = mergeValidatedImportRows (latestAfterValidate.rows, validatedRows)
|
||||||
const firstInvalid = mergedRows.find (row => Object.keys (row.validationErrors).length > 0)
|
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)
|
if (firstInvalid != null)
|
||||||
{
|
{
|
||||||
persistSession ({
|
editRow (firstInvalid)
|
||||||
...latestAfterValidate,
|
|
||||||
rows: mergedRows,
|
|
||||||
repairMode: resultRepairMode (mergedRows) })
|
|
||||||
void editRow (firstInvalid)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const validatedSession = {
|
|
||||||
...latestAfterValidate,
|
|
||||||
rows: mergedRows,
|
|
||||||
repairMode: resultRepairMode (mergedRows) }
|
|
||||||
persistSession (validatedSession)
|
|
||||||
|
|
||||||
const result = await apiPost<{
|
const result = await apiPost<{
|
||||||
created: number
|
created: number
|
||||||
skipped: number
|
skipped: number
|
||||||
failed: number
|
failed: number
|
||||||
rows: PostImportResultRow[] }> ('/posts/import', {
|
rows: PostImportResultRow[] }> ('/posts/import', {
|
||||||
rows: processableImportRows (mergedRows).map (row => ({
|
rows: processableImportRows (currentRows).map (row => ({
|
||||||
sourceRow: row.sourceRow,
|
sourceRow: row.sourceRow,
|
||||||
url: row.url,
|
url: row.url,
|
||||||
attributes: row.attributes,
|
attributes: row.attributes,
|
||||||
provenance: row.provenance,
|
provenance: row.provenance,
|
||||||
tagSources: row.tagSources,
|
tagSources: row.tagSources,
|
||||||
metadataUrl: row.metadataUrl })) })
|
metadataUrl: row.metadataUrl })) })
|
||||||
const expectedImportRows = processableImportRows (mergedRows).map (row => row.sourceRow)
|
const expectedImportRows = processableImportRows (currentRows).map (row => row.sourceRow)
|
||||||
if (!(hasExactSourceRows (expectedImportRows, result.rows)))
|
if (!(hasExactSourceRows (expectedImportRows, result.rows)))
|
||||||
{
|
{
|
||||||
toast ({ title: '登録結果が不完全でした' })
|
toast ({ title: '登録結果が不完全でした' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const latestAfterImport = sessionRef.current ?? validatedSession
|
|
||||||
|
const latestAfterImport = sessionRef.current ?? buildSession (currentRows)
|
||||||
const mergedResults = mergeImportResults (latestAfterImport.rows, result.rows)
|
const mergedResults = mergeImportResults (latestAfterImport.rows, result.rows)
|
||||||
const recoverableRows = result.rows.filter (row =>
|
const recoverableRows = result.rows.filter (row =>
|
||||||
row.status === 'failed'
|
row.status === 'failed'
|
||||||
&& row.recoverable
|
&& row.recoverable
|
||||||
&& Object.keys (row.errors ?? { }).length > 0)
|
&& Object.keys (row.errors ?? { }).length > 0)
|
||||||
const nextRows = mergedResults.map ((row): PostImportRow => {
|
const nextRows = mergedResults.map ((row): PostImportRow => {
|
||||||
const recoverable = recoverableRows.find (failedRow => failedRow.sourceRow === row.sourceRow)
|
const recoverable = recoverableRows.find (
|
||||||
|
failedRow => failedRow.sourceRow === row.sourceRow)
|
||||||
if (recoverable == null)
|
if (recoverable == null)
|
||||||
return row
|
return row
|
||||||
return {
|
return {
|
||||||
@@ -570,15 +654,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
validationErrors: recoverable.errors ?? { },
|
validationErrors: recoverable.errors ?? { },
|
||||||
importErrors: undefined }
|
importErrors: undefined }
|
||||||
})
|
})
|
||||||
const nextSession = {
|
await persistSession (buildSession (nextRows))
|
||||||
...latestAfterImport,
|
|
||||||
rows: nextRows,
|
|
||||||
repairMode: resultRepairMode (nextRows) }
|
|
||||||
persistSession (nextSession)
|
|
||||||
if (nextRows.every (row => isCompletedReviewRow (row)))
|
|
||||||
{
|
|
||||||
finishImport ()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -614,25 +690,30 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
onClick={() => setShowExistingRows (current => !(current))}>
|
onClick={() => setShowExistingRows (current => !(current))}>
|
||||||
既存投稿による自動スキップ {counts.existingSkipped}件
|
既存投稿による自動スキップ {counts.existingSkipped}件
|
||||||
</button>
|
</button>
|
||||||
{showExistingRows && (
|
<AnimatePresence initial={false}>
|
||||||
<div className="mt-3 space-y-3">
|
{showExistingRows && (
|
||||||
{existingRows.map (row => (
|
<motion.div
|
||||||
<div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}>
|
initial={
|
||||||
<PostImportRowSummary
|
animationMode === 'off'
|
||||||
row={row}
|
? false
|
||||||
showActions={false}
|
: { height: 0, opacity: 0 }
|
||||||
showSkipToggle={false}
|
}
|
||||||
rowMessages={resultRowMessages (row)}/>
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
</div>))}
|
exit={{ height: 0, opacity: 0 }}
|
||||||
</div>)}
|
transition={existingRowsTransition}
|
||||||
|
className="overflow-hidden">
|
||||||
|
<ExistingSkippedRows open={showExistingRows} rows={existingRows}/>
|
||||||
|
</motion.div>)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{reviewRows.map (row => (
|
{reviewRows.map ((row, index) => (
|
||||||
<div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}>
|
<div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}>
|
||||||
<PostImportRowSummary
|
<PostImportRowSummary
|
||||||
row={row}
|
row={row}
|
||||||
showSkipToggle={!(isExistingSkipRow (row))}
|
displayNumber={index + 1}
|
||||||
|
showSkipToggle={true}
|
||||||
editDisabled={busy}
|
editDisabled={busy}
|
||||||
retryDisabled={busy}
|
retryDisabled={busy}
|
||||||
skipDisabled={
|
skipDisabled={
|
||||||
@@ -640,9 +721,9 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
|| isExistingSkipRow (row)
|
|| isExistingSkipRow (row)
|
||||||
|| row.importStatus === 'created'
|
|| row.importStatus === 'created'
|
||||||
|| isNonRecoverableFailedRow (row)}
|
|| isNonRecoverableFailedRow (row)}
|
||||||
onEdit={() => void editRow (row)}
|
onEdit={() => editRow (row)}
|
||||||
onToggleSkip={checked => toggleManualSkip (row.sourceRow, checked)}
|
onToggleSkip={checked => toggleManualSkip (row.sourceRow, checked)}
|
||||||
onRetry={canRetryResultRow (row) ? () => void retry (row.sourceRow) : undefined}
|
onRetry={canRetryResultRow (row) ? () => retry (row.sourceRow) : undefined}
|
||||||
rowMessages={resultRowMessages (row)}/>
|
rowMessages={resultRowMessages (row)}/>
|
||||||
</div>))}
|
</div>))}
|
||||||
</div>
|
</div>
|
||||||
@@ -656,7 +737,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
existingSkippedCount={counts.existingSkipped}
|
existingSkippedCount={counts.existingSkipped}
|
||||||
pendingOrErrorCount={counts.pendingOrError}
|
pendingOrErrorCount={counts.pendingOrError}
|
||||||
onBack={() => navigate (-1)}
|
onBack={() => navigate (-1)}
|
||||||
onSubmit={submit}/>
|
onSubmit={() => submit ()}/>
|
||||||
</>)
|
</>)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -667,13 +748,13 @@ const PostImportFooter = (
|
|||||||
existingSkippedCount,
|
existingSkippedCount,
|
||||||
pendingOrErrorCount,
|
pendingOrErrorCount,
|
||||||
onBack,
|
onBack,
|
||||||
onSubmit }: { loading: boolean
|
onSubmit }: { loading: boolean
|
||||||
creatableCount: number
|
creatableCount: number
|
||||||
manualSkippedCount: number
|
manualSkippedCount: number
|
||||||
existingSkippedCount: number
|
existingSkippedCount: number
|
||||||
pendingOrErrorCount: number
|
pendingOrErrorCount: number
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
onSubmit: () => void },
|
onSubmit: () => void },
|
||||||
) => (
|
) => (
|
||||||
<div
|
<div
|
||||||
className="shrink-0 border-t bg-white/95 p-4 backdrop-blur
|
className="shrink-0 border-t bg-white/95 p-4 backdrop-blur
|
||||||
|
|||||||
@@ -122,7 +122,9 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const nextRows = initialisePreviewRows (data.rows)
|
const nextRows = initialisePreviewRows (data.rows)
|
||||||
navigate (serialisePostNewState (nextRows))
|
const serialised = await serialisePostNewState (nextRows)
|
||||||
|
if (serialised != null)
|
||||||
|
navigate (serialised.path)
|
||||||
}
|
}
|
||||||
catch (requestError)
|
catch (requestError)
|
||||||
{
|
{
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする