このコミットが含まれているのは:
2026-07-16 23:39:13 +09:00
コミット dd2d199d04
5個のファイルの変更605行の追加251行の削除
-10
ファイルの表示
@@ -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 -7
ファイルの表示
@@ -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 && (
+322 -46
ファイルの表示
@@ -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 }`
} }
+233 -152
ファイルの表示
@@ -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,11 +15,16 @@ 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 {
parsePostNewState,
serialisePostNewState,
} from '@/lib/postNewQueryState'
import {
buildNextEditedRow, buildNextEditedRow,
canEditReviewRow, canEditReviewRow,
canRetryResultRow, canRetryResultRow,
clearPostImportSourceDraft,
hasExactSourceRows, hasExactSourceRows,
initialisePreviewRows, initialisePreviewRows,
isCompletedReviewRow, isCompletedReviewRow,
@@ -30,20 +39,29 @@ import { clearPostImportSourceDraft,
resultRowMessages, resultRowMessages,
reviewSummaryCounts, reviewSummaryCounts,
retryImportRow, retryImportRow,
validatableImportRows } from '@/lib/postImportSession' 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 {
PostImportResultRow,
PostImportRow, PostImportRow,
PostImportSession } from '@/lib/postImportSession' PostImportSession,
import type { User } from '@/types' } 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,44 +158,71 @@ 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)
return () => {
}
const hydrate = async () => {
const parsed = await parsePostNewState (location.search)
if (!(active))
return
if (parsed == null) if (parsed == null)
{ {
navigate ('/posts/new', { replace: true }) navigate ('/posts/new', { replace: true })
return return
} }
const loaded = {
version: 2, const loaded = buildSession (parsed.rows)
savedAt: new Date ().toISOString (),
source: parsed.source,
rows: parsed.rows,
repairMode: parsed.repairMode }
persistedSearchRef.current = location.search persistedSearchRef.current = location.search
sessionRef.current = loaded sessionRef.current = loaded
setSession (loaded) setSession (loaded)
const hydrate = async () => {
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
{ {
if (active)
navigate ('/posts/new', { replace: true }) 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 (() => {
if (session != null)
sessionRef.current = session 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
@@ -199,23 +323,9 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
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>
<AnimatePresence initial={false}>
{showExistingRows && ( {showExistingRows && (
<div className="mt-3 space-y-3"> <motion.div
{existingRows.map (row => ( initial={
<div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}> animationMode === 'off'
<PostImportRowSummary ? false
row={row} : { height: 0, opacity: 0 }
showActions={false} }
showSkipToggle={false} animate={{ height: 'auto', opacity: 1 }}
rowMessages={resultRowMessages (row)}/> exit={{ height: 0, opacity: 0 }}
</div>))} transition={existingRowsTransition}
</div>)} 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 ()}/>
</>) </>)
} }
+3 -1
ファイルの表示
@@ -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)
{ {