{skipControl}
- {showActions && editAllowed && (
+ {showActions && editVisible && (
)}
{showActions && retryAllowed && (
@@ -124,6 +128,7 @@ const PostImportRowSummary: FC
= (
className={cn (
'space-y-3 rounded-lg border p-4 md:hidden',
'transition-shadow hover:shadow-sm')}>
+ #{rowNumber}
- {showActions && (editAllowed || retryAllowed) && (
+ {showActions && (editVisible || retryAllowed) && (
- {editAllowed && (
+ {editVisible && (
)}
{retryAllowed && (
diff --git a/frontend/src/lib/postNewQueryState.ts b/frontend/src/lib/postNewQueryState.ts
index bd5a034..c07a3cb 100644
--- a/frontend/src/lib/postNewQueryState.ts
+++ b/frontend/src/lib/postNewQueryState.ts
@@ -8,7 +8,6 @@ import type {
} from '@/lib/postImportTypes'
type QueryRowState = {
- url: string
title: string
thumbnail_base: string
original_created_from: string
@@ -23,20 +22,45 @@ type QueryRowState = {
created_post_id: number | null
recoverable: boolean | null }
+type FullRowState = QueryRowState & { url: string }
+
type MultiRowMetaState = {
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
- rows: Array
}
+ rows: EncodedRowState[] }
-type ParsedPostNewState = {
+export type ParsedPostNewState = {
rows: PostImportRow[]
source: string
repairMode: PostImportRepairMode
shortcut: boolean }
+export type SerialisedPostNewState = {
+ path: string
+ rows: PostImportRow[] }
+
const BASIC_KEYS = [
'url',
'title',
@@ -55,6 +79,8 @@ const STATE_KEYS = [
'recoverable'] as const
const SINGLE_ROW_KEYS = [...BASIC_KEYS, ...STATE_KEYS] as const
const MAX_REGULAR_URL_LENGTH = 768
+const MAX_COMPRESSED_STATE_LENGTH = 767
+const COMPRESSED_STATE_PREFIX = 'z1.'
const textEncoder = new TextEncoder ()
const textDecoder = new TextDecoder ()
@@ -72,6 +98,60 @@ const isValidImportStatus = (
|| 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 => {
if (value == null || value === '')
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
{
const padded = value.replaceAll ('-', '+').replaceAll ('_', '/')
@@ -91,8 +171,7 @@ const decodeBase64Url = (value: string): string | null => {
? padded
: `${ padded }${ '='.repeat (4 - remainder) }`
const binary = window.atob (base64)
- const bytes = Uint8Array.from (binary, char => char.charCodeAt (0))
- return textDecoder.decode (bytes)
+ return Uint8Array.from (binary, char => char.charCodeAt (0))
}
catch
{
@@ -101,13 +180,52 @@ const decodeBase64Url = (value: string): string | null => {
}
-const encodeBase64Url = (value: string): string => {
- const bytes = textEncoder.encode (value)
- const binary = Array.from (bytes, byte => String.fromCharCode (byte)).join ('')
+const encodeBase64UrlBytes = (value: Uint8Array): string => {
+ const binary = Array.from (value, byte => String.fromCharCode (byte)).join ('')
return window.btoa (binary).replaceAll ('+', '-').replaceAll ('/', '_').replaceAll ('=', '')
}
+const compressBytes = async (value: Uint8Array): Promise => {
+ const stream =
+ new Blob ([value]).stream ().pipeThrough (new CompressionStream ('gzip'))
+ return new Uint8Array (await new Response (stream).arrayBuffer ())
+}
+
+
+const decompressBytes = async (value: Uint8Array): Promise => {
+ const stream =
+ new Blob ([value]).stream ().pipeThrough (new DecompressionStream ('gzip'))
+ return new Uint8Array (await new Response (stream).arrayBuffer ())
+}
+
+
+const decodeCompressedState = async (value: string): Promise => {
+ 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 => {
+ const compressed = await compressBytes (textEncoder.encode (value))
+ return `${ COMPRESSED_STATE_PREFIX }${ encodeBase64UrlBytes (compressed) }`
+}
+
+
const manualFields = (row: PostImportRow): string[] =>
['title',
'thumbnailBase',
@@ -119,7 +237,6 @@ const manualFields = (row: PostImportRow): string[] =>
const baseRowState = (row: PostImportRow): QueryRowState => ({
- url: row.url,
title: String (row.attributes.title ?? ''),
thumbnail_base: String (row.attributes.thumbnailBase ?? ''),
original_created_from: String (row.attributes.originalCreatedFrom ?? ''),
@@ -137,7 +254,7 @@ const baseRowState = (row: PostImportRow): QueryRowState => ({
const buildRow = (
sourceRow: number,
- state: QueryRowState & { url: string },
+ state: FullRowState,
): PostImportRow => {
const manual = new Set (state.manual)
const provenance: Record = {
@@ -149,9 +266,9 @@ const buildRow = (
duration: 'automatic',
tags: manual.has ('tags') ? 'manual' : 'automatic',
parentPostIds: manual.has ('parentPostIds') ? 'manual' : 'automatic' }
- const tagSources = {
- automatic: '',
- manual: provenance.tags === 'manual' ? state.tags : '' }
+ const tagSources = provenance.tags === 'manual'
+ ? { automatic: '', manual: state.tags }
+ : { automatic: state.tags, manual: '' }
const attributes = {
title: state.title,
thumbnailBase: state.thumbnail_base,
@@ -193,6 +310,90 @@ const parseManual = (value: string | null): string[] =>
.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
+ 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 url = params.get ('url')
if (url == null || url === '')
@@ -265,14 +466,14 @@ const parseMetaRows = (
urlsValue: string,
metaValue: string,
): ParsedPostNewState | null => {
- const decoded = decodeBase64Url (metaValue)
- if (decoded == null)
+ const decodedBytes = decodeBase64UrlBytes (metaValue)
+ if (decodedBytes == null)
return null
let parsed: MultiRowMetaState
try
{
- parsed = JSON.parse (decoded) as MultiRowMetaState
+ parsed = JSON.parse (textDecoder.decode (decodedBytes)) as MultiRowMetaState
}
catch
{
@@ -286,10 +487,16 @@ const parseMetaRows = (
if (urls.length < 2 || urls.length !== parsed.rows.length)
return null
- const rows = parsed.rows.map ((row, index) => buildRow (index + 1, {
- ...row,
- url: urls[index] ?? '' }))
+ const fullRows = parsed.rows.map ((row, index) => {
+ const parsedRow = parseEncodedRow (row, false)
+ 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 {
rows,
source: urls.join ('\n'),
@@ -298,25 +505,29 @@ const parseMetaRows = (
}
-const parseFullState = (stateValue: string): ParsedPostNewState | null => {
- const decoded = decodeBase64Url (stateValue)
+const parseFullState = async (stateValue: string): Promise => {
+ const decoded = await decodeCompressedState (stateValue)
if (decoded == null)
return null
- let parsed: FullState
+ let parsed: EncodedState
try
{
- parsed = JSON.parse (decoded) as FullState
+ parsed = JSON.parse (decoded) as EncodedState
}
catch
{
return null
}
- if (parsed.v !== 1 || !(Array.isArray (parsed.rows)))
+ if (parsed.v !== 1 || !(Array.isArray (parsed.rows)) || parsed.rows.length === 0)
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 {
rows,
source: rows.map (row => row.url).join ('\n'),
@@ -354,18 +565,58 @@ const regularQuery = (rows: PostImportRow[]): URLSearchParams => {
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 ()
- params.set ('urls', urls)
- params.set ('meta', meta)
+ params.set ('urls', rows.map (row => row.url).join (' '))
+ params.set ('meta', encodeBase64UrlBytes (textEncoder.encode (JSON.stringify ({
+ v: 1,
+ rows: rows.map (row => encodeRowState (row, false)) }))))
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 => {
const params = new URLSearchParams (search)
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 => {
const params = new URLSearchParams (search)
const stateValue = params.get ('state')
if (stateValue != null)
- return parseFullState (stateValue)
+ return await parseFullState (stateValue)
const urlsValue = params.get ('urls')
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 => {
+ 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 => {
const regular = regularQuery (rows).toString ()
const regularPath = `/posts/new?${ regular }`
if (regularPath.length < MAX_REGULAR_URL_LENGTH)
- return `/posts/new?${ regular }`
+ return {
+ path: regularPath,
+ rows }
- const stateRows: FullState['rows'] = rows.map (row => ({
- ...baseRowState (row),
- url: row.url }))
- const state = encodeBase64Url (JSON.stringify ({
- v: 1,
- rows: stateRows }))
- return `/posts/new?state=${ state }`
+ return await serialiseCompressedRows (rows)
}
diff --git a/frontend/src/pages/posts/PostImportReviewPage.tsx b/frontend/src/pages/posts/PostImportReviewPage.tsx
index 138c5ea..ef24c8a 100644
--- a/frontend/src/pages/posts/PostImportReviewPage.tsx
+++ b/frontend/src/pages/posts/PostImportReviewPage.tsx
@@ -1,9 +1,13 @@
+import { useQueries } from '@tanstack/react-query'
+import { AnimatePresence, motion } from 'framer-motion'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate } from 'react-router-dom'
import PageTitle from '@/components/common/PageTitle'
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 PostImportRowSummary from '@/components/posts/import/PostImportRowSummary'
import { Button } from '@/components/ui/button'
@@ -11,39 +15,53 @@ import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiPost } from '@/lib/api'
import useDialogue from '@/lib/dialogues/useDialogue'
-import { parsePostNewState, serialisePostNewState } from '@/lib/postNewQueryState'
-import { clearPostImportSourceDraft,
- buildNextEditedRow,
- canEditReviewRow,
- canRetryResultRow,
- hasExactSourceRows,
- initialisePreviewRows,
- isCompletedReviewRow,
- isExistingSkipRow,
- isNonRecoverableFailedRow,
- mergeImportResults,
- mergeValidatedImportRow,
- mergeValidatedImportRows,
- processableImportRows,
- replaceImportRow,
- resultRepairMode,
- resultRowMessages,
- reviewSummaryCounts,
- retryImportRow,
- validatableImportRows } from '@/lib/postImportSession'
+import { fetchPost } from '@/lib/posts'
+import {
+ parsePostNewState,
+ serialisePostNewState,
+} from '@/lib/postNewQueryState'
+import {
+ buildNextEditedRow,
+ canEditReviewRow,
+ canRetryResultRow,
+ clearPostImportSourceDraft,
+ hasExactSourceRows,
+ initialisePreviewRows,
+ isCompletedReviewRow,
+ isExistingSkipRow,
+ isNonRecoverableFailedRow,
+ mergeImportResults,
+ mergeValidatedImportRow,
+ mergeValidatedImportRows,
+ processableImportRows,
+ 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 Forbidden from '@/pages/Forbidden'
import type { FC } from 'react'
import type { PostImportRowDraft } from '@/components/posts/import/PostImportRowForm'
-import type { PostImportResultRow,
- PostImportRow,
- PostImportSession } from '@/lib/postImportSession'
-import type { User } from '@/types'
+import type {
+ PostImportResultRow,
+ PostImportRow,
+ PostImportSession,
+} from '@/lib/postImportSession'
+import type { Post, User } from '@/types'
type Props = { user: User | null }
+type ExistingSkippedRowsProps = {
+ open: boolean
+ rows: PostImportRow[] }
+
const isRepairRow = (row: PostImportRow): boolean =>
row.recoverable === true
&& (row.importStatus === 'failed'
@@ -51,11 +69,87 @@ const isRepairRow = (row: PostImportRow): boolean =>
&& 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 = ({ 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 (
+ posts
+ .map (query => query.data)
+ .filter ((post): post is Post => post != null)
+ .map (post => [post.id, post]),
+ )
+
+ return (
+
+
+ {existingPostIds.map (postId => {
+ const post = postsById.get (postId)
+ if (post == null)
+ return
+
+ return (
+
+
+
+
+ {post.title ?? ''}
+
+
+ {post.url}
+
+
+ )
+ })}
+
+
)
+}
+
+
const PostImportReviewPage: FC = ({ user }) => {
const editable = canEditContent (user)
const dialogue = useDialogue ()
const location = useLocation ()
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 (null)
const [loading, setLoading] = useState (false)
@@ -64,45 +158,72 @@ const PostImportReviewPage: FC = ({ user }) => {
const [showExistingRows, setShowExistingRows] = useState (false)
const sessionRef = useRef (null)
const persistedSearchRef = useRef (null)
+ const persistSequenceRef = useRef (0)
+
+ const persistSession = async (
+ nextSession: PostImportSession,
+ ): Promise => {
+ 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 (() => {
- if (sessionRef.current != null && persistedSearchRef.current === location.search)
- return
+ let active = true
- const parsed = parsePostNewState (location.search)
- if (parsed == null)
- {
- navigate ('/posts/new', { replace: true })
- return
+ if (sessionRef.current != null && persistedSearchRef.current === location.search)
+ 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 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)
{
try
{
const preview = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/preview', {
source: parsed.source })
+ if (!(active))
+ return
const rows = initialisePreviewRows (preview.rows)
- const nextSession = {
- ...loaded,
- source: parsed.source,
- rows,
- repairMode: resultRepairMode (rows) }
- persistSession (nextSession)
+ const persisted = await persistSession (buildSession (rows))
+ if (persisted == null)
+ return
}
catch
{
- navigate ('/posts/new', { replace: true })
+ if (active)
+ navigate ('/posts/new', { replace: true })
}
return
}
@@ -122,17 +243,18 @@ const PostImportReviewPage: FC = ({ user }) => {
tagSources: row.tagSources,
metadataUrl: row.metadataUrl })),
changed_row: 'all' })
+ if (!(active))
+ return
+
const validatedRows = initialisePreviewRows (validated.rows)
if (!(hasExactSourceRows (requestedRows.map (row => row.sourceRow), validatedRows)))
return
+
const latest = sessionRef.current ?? loaded
const rows = mergeValidatedImportRows (latest.rows, validatedRows)
- const nextSession = {
- ...latest,
- rows,
- repairMode: resultRepairMode (rows) }
- sessionRef.current = nextSession
- setSession (nextSession)
+ const persisted = await persistSession (buildSession (rows))
+ if (persisted == null)
+ return
}
catch
{
@@ -140,12 +262,32 @@ const PostImportReviewPage: FC = ({ user }) => {
}
void hydrate ()
+
+ return () => {
+ active = false
+ }
}, [location.search, navigate])
useEffect (() => {
- sessionRef.current = session
+ if (session != null)
+ sessionRef.current = 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 counts = useMemo (() => reviewSummaryCounts (rows), [rows])
const sortedRows =
@@ -161,25 +303,7 @@ const PostImportReviewPage: FC = ({ user }) => {
const reviewRows = sortedRows.filter (row => !(isExistingSkipRow (row)))
const busy = loading || loadingRow != null
- const persistSession = (nextSession: PostImportSession) => {
- 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) => {
+ const toggleManualSkip = async (sourceRow: number, checked: boolean) => {
if (busy)
return
@@ -189,33 +313,19 @@ const PostImportReviewPage: FC = ({ user }) => {
const nextRows = currentSession.rows.map (row => {
if (row.sourceRow !== sourceRow)
- return row
+ return row
if (isExistingSkipRow (row)
|| row.importStatus === 'created'
|| isNonRecoverableFailedRow (row))
- return row
+ return row
return {
...row,
skipReason: checked ? 'manual' : undefined,
existingPostId: checked ? undefined : row.existingPostId }
})
- const nextSession = {
- ...currentSession,
- rows: nextRows,
- repairMode: resultRepairMode (nextRows) }
- persistSession (nextSession)
- if (nextRows.every (row => isCompletedReviewRow (row)))
- finishImport ()
+ await persistSession (buildSession (nextRows))
}
- 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 (
row: PostImportRow,
{ draft, resetRequested }: {
@@ -270,22 +380,18 @@ const PostImportReviewPage: FC = ({ user }) => {
if (target == null)
{
const restoredRows = replaceImportRow (latestSession.rows, row)
- persistSession ({
- ...latestSession,
- rows: restoredRows,
- repairMode: resultRepairMode (restoredRows) })
+ await persistSession (buildSession (restoredRows))
toast ({ title: '行の再検証結果が不完全でした' })
return { saved: false, row: null }
}
+
const editedRows = replaceImportRow (latestSession.rows, nextRow)
const mergedRows = mergeValidatedImportRow (editedRows, target)
- const nextSession = {
- ...latestSession,
- rows: mergedRows,
- repairMode: resultRepairMode (mergedRows) }
- persistSession (nextSession)
+ const nextSession = await persistSession (buildSession (mergedRows))
+ if (nextSession == null)
+ return { saved: false, row: null }
const mergedTarget =
- mergedRows.find (mergedRow => mergedRow.sourceRow === baseRow.sourceRow)
+ nextSession.rows.find (mergedRow => mergedRow.sourceRow === baseRow.sourceRow)
?? target
if (Object.keys (mergedTarget.validationErrors).length > 0)
return { saved: false, row: mergedTarget }
@@ -351,9 +457,11 @@ const PostImportReviewPage: FC = ({ user }) => {
try
{
const pendingRows = retryImportRow (initialSession.rows, sourceRow)
- const pendingSession = { ...initialSession, rows: pendingRows }
- persistSession (pendingSession)
- const requestedRows = validatableImportRows (pendingRows)
+ const pendingSession = await persistSession (buildSession (pendingRows))
+ if (pendingSession == null)
+ return
+
+ const requestedRows = validatableImportRows (pendingSession.rows)
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
rows: requestedRows.map (row => ({
sourceRow: row.sourceRow,
@@ -368,48 +476,39 @@ const PostImportReviewPage: FC = ({ user }) => {
{
const latest = sessionRef.current ?? pendingSession
const restoredRows = replaceImportRow (latest.rows, originalRow)
- persistSession ({
- ...latest,
- rows: restoredRows,
- repairMode: resultRepairMode (restoredRows) })
+ await persistSession (buildSession (restoredRows))
toast ({ title: '再検証結果が不完全でした' })
return
}
+
const validatedTarget = validatedRows.find (row => row.sourceRow === sourceRow)
if (validatedTarget == null)
{
const latest = sessionRef.current ?? pendingSession
const restoredRows = replaceImportRow (latest.rows, originalRow)
- persistSession ({
- ...latest,
- rows: restoredRows,
- repairMode: resultRepairMode (restoredRows) })
+ await persistSession (buildSession (restoredRows))
toast ({ title: '再検証結果が不完全でした' })
return
}
+
const latestAfterValidate = sessionRef.current ?? pendingSession
const mergedValidatedRows = mergeValidatedImportRow (
latestAfterValidate.rows,
validatedTarget)
- const nextSession = {
- ...latestAfterValidate,
- rows: mergedValidatedRows,
- repairMode: resultRepairMode (mergedValidatedRows) }
- persistSession (nextSession)
- const target = mergedValidatedRows.find (row => row.sourceRow === sourceRow)
+ const nextSession = await persistSession (buildSession (mergedValidatedRows))
+ if (nextSession == null)
+ return
+ const target = nextSession.rows.find (row => row.sourceRow === sourceRow)
if (target == null)
{
const restoredRows = replaceImportRow (latestAfterValidate.rows, originalRow)
- persistSession ({
- ...latestAfterValidate,
- rows: restoredRows,
- repairMode: resultRepairMode (restoredRows) })
+ await persistSession (buildSession (restoredRows))
toast ({ title: '再検証結果が不完全でした' })
return
}
if (Object.keys (target.validationErrors ?? { }).length > 0)
{
- void editRow (target)
+ editRow (target)
return
}
@@ -429,13 +528,11 @@ const PostImportReviewPage: FC = ({ user }) => {
{
const latest = sessionRef.current ?? nextSession
const restoredRows = replaceImportRow (latest.rows, originalRow)
- persistSession ({
- ...latest,
- rows: restoredRows,
- repairMode: resultRepairMode (restoredRows) })
+ await persistSession (buildSession (restoredRows))
toast ({ title: '登録結果が不完全でした' })
return
}
+
const latestAfterImport = sessionRef.current ?? nextSession
const mergedRows = mergeImportResults (latestAfterImport.rows, result.rows)
const recoverableRows = result.rows.filter (row =>
@@ -454,25 +551,13 @@ const PostImportReviewPage: FC = ({ user }) => {
validationErrors: recoverable.errors ?? { },
importErrors: undefined }
})
- const resultSession = {
- ...latestAfterImport,
- rows: nextRows,
- repairMode: resultRepairMode (nextRows) }
- persistSession (resultSession)
- if (nextRows.every (row => isCompletedReviewRow (row)))
- {
- finishImport ()
- return
- }
+ await persistSession (buildSession (nextRows))
}
catch
{
const latest = sessionRef.current ?? initialSession
const restoredRows = replaceImportRow (latest.rows, originalRow)
- persistSession ({
- ...latest,
- rows: restoredRows,
- repairMode: resultRepairMode (restoredRows) })
+ await persistSession (buildSession (restoredRows))
toast ({ title: '再試行に失敗しました' })
}
finally
@@ -485,7 +570,7 @@ const PostImportReviewPage: FC = ({ user }) => {
const currentSession = sessionRef.current
if (currentSession == null || busy)
return
- if (rows.every (row => isCompletedReviewRow (row)))
+ if (currentSession.rows.every (row => isCompletedReviewRow (row)))
{
finishImport ()
return
@@ -517,50 +602,49 @@ const PostImportReviewPage: FC = ({ user }) => {
toast ({ title: '再検証結果が不完全でした' })
return
}
+
const latestAfterValidate = sessionRef.current ?? currentSession
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)
{
- persistSession ({
- ...latestAfterValidate,
- rows: mergedRows,
- repairMode: resultRepairMode (mergedRows) })
- void editRow (firstInvalid)
+ editRow (firstInvalid)
return
}
- const validatedSession = {
- ...latestAfterValidate,
- rows: mergedRows,
- repairMode: resultRepairMode (mergedRows) }
- persistSession (validatedSession)
const result = await apiPost<{
created: number
skipped: number
failed: number
rows: PostImportResultRow[] }> ('/posts/import', {
- rows: processableImportRows (mergedRows).map (row => ({
+ rows: processableImportRows (currentRows).map (row => ({
sourceRow: row.sourceRow,
url: row.url,
attributes: row.attributes,
provenance: row.provenance,
tagSources: row.tagSources,
metadataUrl: row.metadataUrl })) })
- const expectedImportRows = processableImportRows (mergedRows).map (row => row.sourceRow)
+ const expectedImportRows = processableImportRows (currentRows).map (row => row.sourceRow)
if (!(hasExactSourceRows (expectedImportRows, result.rows)))
{
toast ({ title: '登録結果が不完全でした' })
return
}
- const latestAfterImport = sessionRef.current ?? validatedSession
+
+ const latestAfterImport = sessionRef.current ?? buildSession (currentRows)
const mergedResults = mergeImportResults (latestAfterImport.rows, result.rows)
const recoverableRows = result.rows.filter (row =>
row.status === 'failed'
&& row.recoverable
&& Object.keys (row.errors ?? { }).length > 0)
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)
return row
return {
@@ -570,15 +654,7 @@ const PostImportReviewPage: FC = ({ user }) => {
validationErrors: recoverable.errors ?? { },
importErrors: undefined }
})
- const nextSession = {
- ...latestAfterImport,
- rows: nextRows,
- repairMode: resultRepairMode (nextRows) }
- persistSession (nextSession)
- if (nextRows.every (row => isCompletedReviewRow (row)))
- {
- finishImport ()
- }
+ await persistSession (buildSession (nextRows))
}
catch
{
@@ -614,25 +690,30 @@ const PostImportReviewPage: FC = ({ user }) => {
onClick={() => setShowExistingRows (current => !(current))}>
既存投稿による自動スキップ {counts.existingSkipped}件
- {showExistingRows && (
-
- {existingRows.map (row => (
-
))}
-
)}
+
+ {showExistingRows && (
+
+
+ )}
+
)}
- {reviewRows.map (row => (
+ {reviewRows.map ((row, index) => (
= ({ user }) => {
|| isExistingSkipRow (row)
|| row.importStatus === 'created'
|| isNonRecoverableFailedRow (row)}
- onEdit={() => void editRow (row)}
+ onEdit={() => editRow (row)}
onToggleSkip={checked => toggleManualSkip (row.sourceRow, checked)}
- onRetry={canRetryResultRow (row) ? () => void retry (row.sourceRow) : undefined}
+ onRetry={canRetryResultRow (row) ? () => retry (row.sourceRow) : undefined}
rowMessages={resultRowMessages (row)}/>
))}
@@ -656,7 +737,7 @@ const PostImportReviewPage: FC
= ({ user }) => {
existingSkippedCount={counts.existingSkipped}
pendingOrErrorCount={counts.pendingOrError}
onBack={() => navigate (-1)}
- onSubmit={submit}/>
+ onSubmit={() => submit ()}/>
>)
}
@@ -667,13 +748,13 @@ const PostImportFooter = (
existingSkippedCount,
pendingOrErrorCount,
onBack,
- onSubmit }: { loading: boolean
- creatableCount: number
- manualSkippedCount: number
+ onSubmit }: { loading: boolean
+ creatableCount: number
+ manualSkippedCount: number
existingSkippedCount: number
- pendingOrErrorCount: number
- onBack: () => void
- onSubmit: () => void },
+ pendingOrErrorCount: number
+ onBack: () => void
+ onSubmit: () => void },
) => (