diff --git a/frontend/src/lib/postImportSourceValidation.ts b/frontend/src/lib/postImportSourceValidation.ts index 812afd9..cc37ee7 100644 --- a/frontend/src/lib/postImportSourceValidation.ts +++ b/frontend/src/lib/postImportSourceValidation.ts @@ -12,23 +12,14 @@ const bytesize = (value: string): number => new TextEncoder ().encode (value).length -const normaliseImportUrl = (value: string): string | null => { +const parseImportUrl = (value: string): URL | null => { const trimmed = value.trim () if (!(trimmed)) return null try { - const url = new URL (trimmed) - if (!(url.protocol === 'http:' || url.protocol === 'https:')) - return null - if (!(url.host)) - return null - - url.hostname = url.hostname.toLowerCase () - if (url.pathname.endsWith ('/')) - url.pathname = url.pathname.replace (/\/+$/, '') - return url.toString () + return new URL (trimmed) } catch { @@ -37,12 +28,23 @@ const normaliseImportUrl = (value: string): string | null => { } -export const countImportSourceLines = (source: string): number => +const normaliseImportUrl = (url: URL): string => { + url.hostname = url.hostname.toLowerCase () + if (url.pathname.endsWith ('/')) + url.pathname = url.pathname.replace (/\/+$/, '') + return url.toString () +} + + +export const extractImportSourceUrls = (source: string): string[] => source .split (/\r\n|\n|\r/) .map (line => line.trim ()) .filter (line => line !== '') - .length + + +export const countImportSourceLines = (source: string): number => + extractImportSourceUrls (source).length export const validateImportSource = ( @@ -61,7 +63,6 @@ export const validateImportSource = ( ++count const sourceRow = index + 1 const displayUrl = truncateUrl (value) - const normalised = normaliseImportUrl (value) if (count > MAX_ROWS) { issues.push ({ @@ -70,14 +71,6 @@ export const validateImportSource = ( url: displayUrl }) return } - if (!(value.startsWith ('http://') || value.startsWith ('https://'))) - { - issues.push ({ - sourceRow, - message: 'HTTP または HTTPS の URL ではありません.', - url: displayUrl }) - return - } if (bytesize (value) > MAX_URL_BYTES) { issues.push ({ @@ -86,7 +79,8 @@ export const validateImportSource = ( url: displayUrl }) return } - if (normalised == null) + const parsed = parseImportUrl (value) + if (parsed == null) { issues.push ({ sourceRow, @@ -94,6 +88,23 @@ export const validateImportSource = ( url: displayUrl }) return } + if (!(parsed.protocol === 'http:' || parsed.protocol === 'https:')) + { + issues.push ({ + sourceRow, + message: 'HTTP または HTTPS の URL ではありません.', + url: displayUrl }) + return + } + if (!(parsed.host)) + { + issues.push ({ + sourceRow, + message: 'URL の形式が不正です.', + url: displayUrl }) + return + } + const normalised = normaliseImportUrl (parsed) const duplicateRow = seen.get (normalised) if (duplicateRow != null) { diff --git a/frontend/src/lib/postImportStorage.ts b/frontend/src/lib/postImportStorage.ts index b339980..ec29cad 100644 --- a/frontend/src/lib/postImportStorage.ts +++ b/frontend/src/lib/postImportStorage.ts @@ -1,125 +1,24 @@ -import type { PostImportOrigin, - PostImportDisplayTag, - PostImportExistingPost, - PostImportResetSnapshot, - PostImportRow, - PostImportSession, - PostImportStatus, - PostImportSkipReason, - StorageErrorHandler } from '@/lib/postImportTypes' +import type { StorageErrorHandler } from '@/lib/postImportTypes' -const SESSION_VERSION = 3 -const SESSION_PREFIX = 'post-import-session:' const SOURCE_DRAFT_KEY = 'post-import-source-draft' -const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000 -const MAX_SESSION_JSON_BYTES = 262_144 -const MAX_SESSION_ROWS = 100 -const MAX_SOURCE_BYTES = 262_144 -const SESSION_ID_PATTERN = - /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i -const ATTRIBUTE_KEYS = [ - 'title', - 'thumbnailBase', - 'originalCreatedFrom', - 'originalCreatedBefore', - 'duration', - 'videoMs', - 'tags', - 'parentPostIds'] as const -const PROVENANCE_KEYS = [...ATTRIBUTE_KEYS, 'url'] as const -const TAG_SOURCE_KEYS = ['automatic', 'manual'] as const -const WARNING_KEYS = [...ATTRIBUTE_KEYS, 'url'] as const -const ROW_KEYS = [ - 'sourceRow', - 'url', - 'attributes', - 'fieldWarnings', - 'baseWarnings', - 'validationErrors', - 'importErrors', - 'provenance', - 'tagSources', - 'status', - 'skipReason', - 'existingPostId', - 'existingPost', - 'metadataUrl', - 'displayTags', - 'resetSnapshot', - 'createdPostId', - 'importStatus', - 'recoverable'] as const -const SESSION_KEYS = [ - 'version', - 'expiresAt', - 'source', - 'rows', - 'repairMode'] as const -const textEncoder = new TextEncoder () - - -const isPlainObject = (value: unknown): value is Record => - typeof value === 'object' && value != null && !(Array.isArray (value)) - -const byteLength = (value: string): number => - textEncoder.encode (value).byteLength - - -export const validatePostImportSessionId = (value: unknown): string | null => { - if (typeof value !== 'string') - return null - if (value.length > 36 || !(SESSION_ID_PATTERN.test (value))) - return null - - return value.toLowerCase () -} - - -const generateUuidV4FromRandomValues = (): string => { - const bytes = new Uint8Array (16) - crypto.getRandomValues (bytes) - bytes[6] = (bytes[6] & 0x0f) | 0x40 - bytes[8] = (bytes[8] & 0x3f) | 0x80 - - const hex = Array.from ( - bytes, - byte => byte.toString (16).padStart (2, '0')) - return [ - hex.slice (0, 4).join (''), - hex.slice (4, 6).join (''), - hex.slice (6, 8).join (''), - hex.slice (8, 10).join (''), - hex.slice (10, 16).join ('')].join ('-') -} - - -export const generatePostImportSessionId = (): string => { - if (typeof crypto.randomUUID === 'function') - return crypto.randomUUID () - - return generateUuidV4FromRandomValues () -} - - -const sessionKey = (sessionId: string): string => `${ SESSION_PREFIX }${ sessionId }` const readStorage = ( key: string, onError?: StorageErrorHandler, ): string | null => { - if (typeof window === 'undefined') - return null + if (typeof window === 'undefined') + return null - try - { - return sessionStorage.getItem (key) - } - catch - { - onError?.('保存済みデータを読み込めませんでした.') - return null - } + try + { + return sessionStorage.getItem (key) + } + catch + { + onError?.('保存済みデータを読み込めませんでした.') + return null + } } @@ -128,19 +27,19 @@ const writeStorage = ( value: string, onError?: StorageErrorHandler, ): boolean => { - if (typeof window === 'undefined') - return false + if (typeof window === 'undefined') + return false - try - { - sessionStorage.setItem (key, value) - return true - } - catch - { - onError?.('ブラウザへ保存できませんでした.') - return false - } + try + { + sessionStorage.setItem (key, value) + return true + } + catch + { + onError?.('ブラウザへ保存できませんでした.') + return false + } } @@ -148,561 +47,36 @@ const removeStorage = ( key: string, onError?: StorageErrorHandler, ) => { - if (typeof window === 'undefined') - return + if (typeof window === 'undefined') + return - try - { - sessionStorage.removeItem (key) - } - catch - { - onError?.('保存済みデータを削除できませんでした.') - } -} - - -const ensureStringListRecord = (value: unknown): Record | null => { - if (!(isPlainObject (value))) - return null - - const result: Record = { } - for (const [key, entry] of Object.entries (value)) - { - if (!(Array.isArray (entry)) || !(entry.every (item => typeof item === 'string'))) - return null - result[key] = entry - } - return result -} - - -const isValidStatus = ( - value: unknown, -): value is PostImportRow['status'] => - value === 'pending' - || value === 'ready' - || value === 'warning' - || value === 'error' - - -const isValidImportStatus = ( - value: unknown, -): value is PostImportStatus => - value === 'pending' - || value === 'created' - || value === 'skipped' - || value === 'failed' - - -const isValidOrigin = ( - value: unknown, -): value is PostImportOrigin => - value === 'automatic' || value === 'manual' - - -const isValidSkipReason = ( - value: unknown, -): value is PostImportSkipReason => - value === 'existing' || value === 'manual' - -const isPositiveInteger = (value: unknown): value is number => - Number.isSafeInteger (value) && Number (value) > 0 - -const isNonEmptyMessageRecord = (value: Record): boolean => - Object.values (value).some (messages => messages.length > 0) - -const hasOnlyKeys = ( - value: Record, - allowedKeys: readonly string[], -): boolean => - Object.keys (value).every (key => allowedKeys.includes (key)) - -const sanitiseDisplayTag = (value: unknown): PostImportDisplayTag | null => { - if (!(isPlainObject (value))) - return null - if (!(hasOnlyKeys (value, ['name', 'category', 'sectionLiterals']))) - return null - if (typeof value.name !== 'string' || value.name === '') - return null - if (typeof value.category !== 'string') - return null - if (!(['deerjikist', - 'meme', - 'character', - 'general', - 'material', - 'meta', - 'nico'] as const).includes (value.category as never)) - return null - if (value.sectionLiterals != null - && (!(Array.isArray (value.sectionLiterals)) - || !(value.sectionLiterals.every (entry => typeof entry === 'string')))) - return null - - return { - name: value.name, - category: value.category, - sectionLiterals: - value.sectionLiterals == null - ? undefined - : [...value.sectionLiterals] } -} - -const sanitiseDisplayTags = (value: unknown): PostImportDisplayTag[] | null => { - if (value == null) - return [] - if (!(Array.isArray (value))) - return null - - const tags = value.map (sanitiseDisplayTag) - return tags.some (tag => tag == null) ? null : (tags as PostImportDisplayTag[]) -} - -const sanitiseResetSnapshot = (value: unknown): PostImportResetSnapshot | null => { - if (!(isPlainObject (value))) - return null - if (!(hasOnlyKeys (value, ['url', - 'attributes', - 'displayTags', - 'provenance', - 'tagSources', - 'fieldWarnings', - 'baseWarnings', - 'metadataUrl']))) - return null - if (typeof value.url !== 'string') - return null - if (!(isPlainObject (value.attributes))) - return null - if (!(hasOnlyKeys (value.attributes, ATTRIBUTE_KEYS))) - return null - if (!(Object.values (value.attributes).every (entry => - typeof entry === 'string' || typeof entry === 'number'))) - return null - if (!(isPlainObject (value.provenance))) - return null - if (!(hasOnlyKeys (value.provenance, PROVENANCE_KEYS))) - return null - if (!(Object.values (value.provenance).every (origin => isValidOrigin (origin)))) - return null - if (!(isPlainObject (value.tagSources))) - return null - if (!(hasOnlyKeys (value.tagSources, TAG_SOURCE_KEYS))) - return null - if (!(Object.values (value.tagSources).every (entry => typeof entry === 'string'))) - return null - const fieldWarnings = ensureStringListRecord (value.fieldWarnings) - if (fieldWarnings == null) - return null - if (!(hasOnlyKeys (fieldWarnings, WARNING_KEYS))) - return null - if (!(Array.isArray (value.baseWarnings)) - || !(value.baseWarnings.every (warning => typeof warning === 'string'))) - return null - if (value.metadataUrl != null && typeof value.metadataUrl !== 'string') - return null - const displayTags = sanitiseDisplayTags (value.displayTags) - if (displayTags == null) - return null - - return { - url: value.url, - attributes: value.attributes as Record, - displayTags, - provenance: value.provenance as Record, - tagSources: value.tagSources as Record, - fieldWarnings, - baseWarnings: value.baseWarnings, - metadataUrl: value.metadataUrl as string | undefined } -} - -const sanitiseExistingPost = (value: unknown): PostImportExistingPost | undefined => { - if (value == null) - return undefined - if (!(isPlainObject (value))) - return undefined - if (!(isPositiveInteger (value.id))) - return undefined - if (typeof value.title !== 'string' || typeof value.url !== 'string') - return undefined - if (value.thumbnail !== undefined - && value.thumbnail !== null - && typeof value.thumbnail !== 'string') - return undefined - if (value.thumbnailBase !== undefined - && value.thumbnailBase !== null - && typeof value.thumbnailBase !== 'string') - return undefined - if (value.thumbnailUrl !== undefined - && value.thumbnailUrl !== null - && typeof value.thumbnailUrl !== 'string') - return undefined - - return { - id: Number (value.id), - title: value.title, - url: value.url, - thumbnail: - value.thumbnail === null - ? null - : typeof value.thumbnail === 'string' - ? value.thumbnail - : value.thumbnailUrl === null - ? null - : typeof value.thumbnailUrl === 'string' - ? value.thumbnailUrl - : undefined, - thumbnailBase: - value.thumbnailBase === null - ? null - : typeof value.thumbnailBase === 'string' - ? value.thumbnailBase - : undefined } -} - -const canonicalisePersistedRow = ( - row: PostImportRow, -): PostImportRow | null => { - const sanitised = sanitiseRow (serialisePostImportRow (row)) - return sanitised == null ? null : recalculateThumbnailWarning (sanitised) -} - -const recalculateThumbnailWarning = (row: PostImportRow): PostImportRow => { - const others = (row.fieldWarnings.thumbnailBase ?? []).filter ( - key => key !== 'サムネールなし') - const thumbnailBasePresent = - typeof row.attributes.thumbnailBase === 'string' - && row.attributes.thumbnailBase.trim () !== '' - - return { - ...row, - fieldWarnings: { - ...row.fieldWarnings, - thumbnailBase: - thumbnailBasePresent - ? others - : [...new Set ([...others, 'サムネールなし'])] } } -} - -const serialiseExistingPost = (value: PostImportExistingPost | undefined) => - value == null - ? undefined - : { - id: value.id, - title: value.title, - url: value.url, - thumbnail: value.thumbnail, - thumbnailBase: value.thumbnailBase } - -const serialiseResetSnapshot = (value: PostImportResetSnapshot) => ({ - url: value.url, - attributes: Object.fromEntries ( - Object.entries (value.attributes).map (([key, entry]) => [key, entry])), - displayTags: value.displayTags.map (tag => ({ - name: tag.name, - category: tag.category, - sectionLiterals: - tag.sectionLiterals == null ? undefined : [...tag.sectionLiterals] })), - provenance: Object.fromEntries ( - Object.entries (value.provenance).map (([key, entry]) => [key, entry])), - tagSources: { - automatic: value.tagSources.automatic, - manual: value.tagSources.manual }, - fieldWarnings: Object.fromEntries ( - Object.entries (value.fieldWarnings).map (([key, entry]) => [key, [...entry]])), - baseWarnings: [...value.baseWarnings], - metadataUrl: value.metadataUrl }) - -export const serialisePostImportRow = (row: PostImportRow) => { - const thumbnailBaseWarnings = (() => { - const others = (row.fieldWarnings.thumbnailBase ?? []).filter ( - message => message !== 'サムネールなし') - const thumbnailBasePresent = - typeof row.attributes.thumbnailBase === 'string' - && row.attributes.thumbnailBase.trim () !== '' - return thumbnailBasePresent - ? others - : [...new Set ([...others, 'サムネールなし'])] - }) () - - return { - sourceRow: row.sourceRow, - url: row.url, - attributes: Object.fromEntries ( - Object.entries (row.attributes).map (([key, entry]) => [key, entry])), - fieldWarnings: Object.fromEntries ( - Object.entries ({ - ...row.fieldWarnings, - thumbnailBase: thumbnailBaseWarnings }).map (([key, entry]) => [key, [...entry]])), - baseWarnings: [...row.baseWarnings], - validationErrors: Object.fromEntries ( - Object.entries (row.validationErrors).map (([key, entry]) => [key, [...entry]])), - importErrors: - row.importErrors == null - ? undefined - : Object.fromEntries ( - Object.entries (row.importErrors).map (([key, entry]) => [key, [...entry]])), - provenance: Object.fromEntries ( - Object.entries (row.provenance).map (([key, entry]) => [key, entry])), - displayTags: - row.displayTags?.map (tag => ({ - name: tag.name, - category: tag.category, - sectionLiterals: - tag.sectionLiterals == null ? undefined : [...tag.sectionLiterals] })) ?? [], - tagSources: - row.tagSources == null - ? undefined - : { - automatic: row.tagSources.automatic, - manual: row.tagSources.manual }, - status: row.status, - skipReason: row.skipReason, - existingPostId: row.existingPostId, - existingPost: serialiseExistingPost (row.existingPost), - metadataUrl: row.metadataUrl, - resetSnapshot: serialiseResetSnapshot (row.resetSnapshot), - createdPostId: row.createdPostId, - importStatus: row.importStatus, - recoverable: row.recoverable } -} - -export const serialisePostImportRows = (rows: PostImportRow[]) => - rows.map (row => serialisePostImportRow (row)) - -export const serialisedPostImportRowsEqual = ( - left: PostImportRow[], - right: PostImportRow[], -): boolean => { - const canonicalLeft = left.map (canonicalisePersistedRow) - const canonicalRight = right.map (canonicalisePersistedRow) - if (canonicalLeft.some (row => row == null) || canonicalRight.some (row => row == null)) - return false - - return JSON.stringify (serialisePostImportRows (canonicalLeft as PostImportRow[])) - === JSON.stringify (serialisePostImportRows (canonicalRight as PostImportRow[])) -} - - -const sanitiseRow = (value: unknown): PostImportRow | null => { - if (!(isPlainObject (value))) - return null - if (!(hasOnlyKeys (value, ROW_KEYS))) - return null - if (!(Number.isInteger (value.sourceRow)) || Number (value.sourceRow) <= 0) - return null - if (typeof value.url !== 'string') - return null - if (byteLength (value.url) > 20_480) - return null - if (!(isPlainObject (value.attributes))) - return null - if (!(isPlainObject (value.provenance))) - return null - if (!(isValidStatus (value.status))) - return null - if (value.importStatus != null && !(isValidImportStatus (value.importStatus))) - return null - if (value.skipReason != null && !(isValidSkipReason (value.skipReason))) - return null - if (value.recoverable != null && value.recoverable !== true) - return null - if (value.skipReason === 'existing' && !(isPositiveInteger (value.existingPostId))) - return null - if (value.importStatus === 'created' && !(isPositiveInteger (value.createdPostId))) - return null - if (value.importStatus !== 'created' && value.createdPostId != null) - return null - if (value.recoverable === true - && value.importStatus !== 'failed' - && value.importStatus !== 'pending') - return null - if ((value.importStatus === 'created' || value.importStatus === 'skipped') - && value.recoverable != null) - return null - - const validationErrors = ensureStringListRecord (value.validationErrors) - const fieldWarnings = ensureStringListRecord (value.fieldWarnings) - const resetSnapshot = sanitiseResetSnapshot (value.resetSnapshot) - if (validationErrors == null || fieldWarnings == null) - return null - if (resetSnapshot == null) - return null - - const importErrors = - value.importErrors == null - ? undefined - : ensureStringListRecord (value.importErrors) - if (importErrors === null) - return null - if (!(Array.isArray (value.baseWarnings)) - || !(value.baseWarnings.every (warning => typeof warning === 'string'))) - return null - - const provenanceEntries = Object.entries (value.provenance) - if (!(hasOnlyKeys (value.attributes, ATTRIBUTE_KEYS))) - return null - if (!(Object.values (value.attributes).every (entry => - typeof entry === 'string' || typeof entry === 'number'))) - return null - if (!(hasOnlyKeys (value.provenance, PROVENANCE_KEYS))) - return null - if (!(provenanceEntries.every (([, origin]) => isValidOrigin (origin)))) - return null - - if (value.tagSources != null) - { - if (!(isPlainObject (value.tagSources))) - return null - if (!(hasOnlyKeys (value.tagSources, TAG_SOURCE_KEYS))) - return null - if (!(Object.values (value.tagSources).every (entry => typeof entry === 'string'))) - return null - } - const displayTags = sanitiseDisplayTags (value.displayTags) - if (displayTags == null) - return null - - const existingPost = sanitiseExistingPost (value.existingPost) - const existingPostId = - isPositiveInteger (value.existingPostId) ? Number (value.existingPostId) : undefined - if (existingPost != null && existingPostId == null) - return null - if (existingPost != null && existingPost.id !== existingPostId) - return null - - return { - sourceRow: Number (value.sourceRow), - url: value.url, - attributes: value.attributes as Record, - fieldWarnings: isNonEmptyMessageRecord (fieldWarnings) ? fieldWarnings : { }, - baseWarnings: value.baseWarnings, - validationErrors: isNonEmptyMessageRecord (validationErrors) ? validationErrors : { }, - importErrors: - importErrors != null && isNonEmptyMessageRecord (importErrors) - ? importErrors - : undefined, - provenance: value.provenance as Record, - displayTags, - tagSources: value.tagSources as Record | undefined, - status: value.status, - skipReason: value.skipReason ?? undefined, - existingPostId, - existingPost, - metadataUrl: typeof value.metadataUrl === 'string' ? value.metadataUrl : undefined, - resetSnapshot, - createdPostId: - isPositiveInteger (value.createdPostId) ? Number (value.createdPostId) : undefined, - importStatus: value.importStatus ?? undefined, - recoverable: value.recoverable === true ? true : undefined } -} - -const sanitiseSession = (value: unknown): PostImportSession | null => { - if (!(isPlainObject (value))) - return null - if (!(hasOnlyKeys (value, SESSION_KEYS))) - return null - if (value.version !== SESSION_VERSION || !(Array.isArray (value.rows))) - return null - if (value.rows.length === 0 || value.rows.length > MAX_SESSION_ROWS) - return null - if (typeof value.expiresAt !== 'string' || isExpiredSession (value.expiresAt)) - return null - if (typeof value.source !== 'string' || byteLength (value.source) > MAX_SOURCE_BYTES) - return null - - const rows = value.rows.map (sanitiseRow) - if (rows.some (row => row == null)) - return null - if ((new Set (rows.map (row => row?.sourceRow))).size !== rows.length) - return null - - return { - version: SESSION_VERSION, - expiresAt: value.expiresAt, - source: value.source, - rows: (rows as PostImportRow[]).map (row => recalculateThumbnailWarning (row)), - repairMode: value.repairMode === 'failed' ? 'failed' : 'all' } -} - - -const isExpiredSession = (expiresAt: string): boolean => { - const value = Date.parse (expiresAt) - return Number.isNaN (value) || value <= Date.now () -} - - -export const cleanupExpiredPostImportSessions = ( - onError?: StorageErrorHandler, -) => { - if (typeof window === 'undefined') - return - - try - { - for (let i = 0; i < sessionStorage.length; ++i) - { - const key = sessionStorage.key (i) - if (key == null || !(key.startsWith (SESSION_PREFIX))) - continue - - const sessionId = validatePostImportSessionId (key.slice (SESSION_PREFIX.length)) - if (sessionId == null) - { - sessionStorage.removeItem (key) - --i - continue - } - - const raw = sessionStorage.getItem (key) - if (raw == null) - continue - if (byteLength (raw) > MAX_SESSION_JSON_BYTES) - { - sessionStorage.removeItem (key) - --i - continue - } - - try - { - if (sanitiseSession (JSON.parse (raw)) == null) - { - sessionStorage.removeItem (key) - --i - } - } - catch - { - sessionStorage.removeItem (key) - --i - } - } - } - catch - { - onError?.('保存済みデータを整理できませんでした.') - } + try + { + sessionStorage.removeItem (key) + } + catch + { + onError?.('保存済みデータを削除できませんでした.') + } } export const loadPostImportSourceDraft = ( onError?: StorageErrorHandler, ): { source: string } => { - const raw = readStorage (SOURCE_DRAFT_KEY, onError) - if (raw == null) - return { source: '' } + const raw = readStorage (SOURCE_DRAFT_KEY, onError) + if (raw == null) + return { source: '' } - try - { - const value = JSON.parse (raw) as { source?: string } - return { source: typeof value.source === 'string' ? value.source : '' } - } - catch - { - return { source: '' } - } + try + { + const value = JSON.parse (raw) as { source?: string } + return { source: typeof value.source === 'string' ? value.source : '' } + } + catch + { + return { source: '' } + } } @@ -716,73 +90,5 @@ export const savePostImportSourceDraft = ( export const clearPostImportSourceDraft = ( onError?: StorageErrorHandler, ) => { - removeStorage (SOURCE_DRAFT_KEY, onError) -} - - -export const savePostImportSession = ( - sessionId: string, - session: Omit, - onError?: StorageErrorHandler, -): boolean => { - const validatedId = validatePostImportSessionId (sessionId) - if (validatedId == null) - return false - - return writeStorage ( - sessionKey (validatedId), - JSON.stringify ({ - source: session.source, - rows: serialisePostImportRows (session.rows), - repairMode: session.repairMode, - version: SESSION_VERSION, - expiresAt: new Date (Date.now () + SESSION_MAX_AGE_MS).toISOString () }), - onError) -} - - -export const loadPostImportSession = ( - sessionId: string, - onError?: StorageErrorHandler, -): PostImportSession | null => { - const validatedId = validatePostImportSessionId (sessionId) - if (validatedId == null) - return null - - const raw = readStorage (sessionKey (validatedId), onError) - if (raw == null) - return null - if (byteLength (raw) > MAX_SESSION_JSON_BYTES) - { - removeStorage (sessionKey (validatedId), onError) - return null - } - - try - { - const session = sanitiseSession (JSON.parse (raw)) - if (session == null) - { - removeStorage (sessionKey (validatedId), onError) - return null - } - return session - } - catch - { - removeStorage (sessionKey (validatedId), onError) - return null - } -} - - -export const clearPostImportSession = ( - sessionId: string, - onError?: StorageErrorHandler, -) => { - const validatedId = validatePostImportSessionId (sessionId) - if (validatedId == null) - return - - removeStorage (sessionKey (validatedId), onError) + removeStorage (SOURCE_DRAFT_KEY, onError) } diff --git a/frontend/src/lib/postImportTypes.ts b/frontend/src/lib/postImportTypes.ts index 9c75eb2..f709cb8 100644 --- a/frontend/src/lib/postImportTypes.ts +++ b/frontend/src/lib/postImportTypes.ts @@ -79,13 +79,6 @@ export type PostImportResultRow = errors?: Record recoverable?: boolean } -export type PostImportSession = { - version: number - expiresAt: string - source: string - rows: PostImportRow[] - repairMode: PostImportRepairMode } - export type PostImportSourceIssue = { sourceRow: number message: string diff --git a/frontend/src/lib/postNewQueryState.ts b/frontend/src/lib/postNewQueryState.ts index 90090a7..f5990cc 100644 --- a/frontend/src/lib/postNewQueryState.ts +++ b/frontend/src/lib/postNewQueryState.ts @@ -1,1115 +1,58 @@ -import { CATEGORIES } from '@/consts' -import { resultRepairMode } from '@/lib/postImportRows' -import { validatePostImportSessionId } from '@/lib/postImportStorage' - -import type { - PostImportDisplayTag, - PostImportOrigin, - PostImportRepairMode, - PostImportRow, - PostImportSkipReason, -} from '@/lib/postImportTypes' - -type QueryRowState = { - source_row: number - title: string - thumbnail_base: string - original_created_from: string - original_created_before: string - video_ms: string - duration: string - tags: string - parent_post_ids: string - display_tags: PostImportDisplayTag[] - manual: string[] - skip: PostImportSkipReason | null - existing_post_id: number | null - existing_post: PostImportRow['existingPost'] | null - import_status: PostImportRow['importStatus'] | null - created_post_id: number | null - recoverable: boolean | null } - -type FullRowState = QueryRowState & { url: string } - -type MultiRowMetaState = { - v: 1 - rows: EncodedRowState[] } - -type EncodedSkip = 'e' | 'm' -type EncodedImportStatus = 'p' | 'c' | 's' | 'f' - -type EncodedRowState = { - n?: number - u?: string - t?: string - h?: string - f?: string - b?: string - x?: string - d?: string - g?: string - p?: string - k?: EncodedDisplayTag[] - m?: string[] - s?: EncodedSkip - e?: number - o?: EncodedExistingPost - i?: EncodedImportStatus - c?: number - r?: boolean } - -type EncodedState = { - v: 1 - rows: EncodedRowState[] } - -type EncodedDisplayTag = { - n: string - c: PostImportDisplayTag['category'] - s?: string[] } - -type EncodedExistingPost = { - i: number - t: string - u: string - h?: string | null - b?: string | null } - -export type ParsedPostNewState = { - rows: PostImportRow[] - source: string - repairMode: PostImportRepairMode - shortcut: boolean } - -export type SerialisedPostNewState = { - path: string - complete: boolean } - -const BASIC_KEYS = [ - 'url', - 'title', - 'thumbnail_base', - 'original_created_from', - 'original_created_before', - 'video_ms', - 'duration', - 'tags', - 'parent_post_ids'] as const -const STATE_KEYS = [ - 'display_tags', - 'manual', - 'skip', - 'existing_post_id', - 'existing_post', - 'import_status', - 'created_post_id', - 'recoverable'] as const -const SINGLE_ROW_KEYS = [...BASIC_KEYS, ...STATE_KEYS, 'session_id'] as const -const ENCODED_ROW_KEYS = [ - 'n', - 'u', - 't', - 'h', - 'f', - 'b', - 'x', - 'd', - 'g', - 'p', - 'k', - 'm', - 's', - 'e', - 'o', - 'i', - 'c', - 'r'] as const -const ENCODED_STATE_KEYS = ['v', 'rows'] as const -const ALLOWED_MANUAL_FIELDS = [ - 'title', - 'thumbnailBase', - 'originalCreatedFrom', - 'originalCreatedBefore', - 'duration', - 'tags', - 'parentPostIds'] as const -const MAX_REGULAR_URL_LENGTH = 768 -const MAX_COMPRESSED_STATE_LENGTH = 767 -const COMPRESSED_STATE_PREFIX = 'z1.' -const MAX_COMPRESSED_STATE_PARAM_LENGTH = 8_192 -const MAX_COMPRESSED_STATE_BYTES = 16_384 -const MAX_DECOMPRESSED_STATE_BYTES = 262_144 -const MAX_STATE_ROWS = 100 -const MAX_QUERY_STRING_BYTES = 20_480 +const POST_NEW_REVIEW_PATH_PREFIX = '/posts/new?urls=' +const MAX_POST_NEW_REVIEW_TARGET_BYTES = 4096 const textEncoder = new TextEncoder () -const textDecoder = new TextDecoder () - -const isPlainObject = (value: unknown): value is Record => - typeof value === 'object' && value != null && !(Array.isArray (value)) - -const hasOnlyKeys = ( - value: Record, - allowedKeys: readonly string[], -): boolean => - Object.keys (value).every (key => allowedKeys.includes (key)) - -const byteLength = (value: string): number => - textEncoder.encode (value).byteLength - -const isValidSkipReason = (value: unknown): value is PostImportSkipReason => - value === 'existing' || value === 'manual' -const isValidImportStatus = ( - value: unknown, -): value is PostImportRow['importStatus'] => - value === 'pending' - || value === 'created' - || value === 'skipped' - || 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) +const rawUrlsParam = (search: string): string | null => { + const query = search.startsWith ('?') ? search.slice (1) : search + if (query === '') return null - if (value === 'e') - return 'existing' - if (value === 'm') - return 'manual' + + for (const segment of query.split ('&')) + { + if (segment === 'urls') + return '' + if (segment.startsWith ('urls=')) + return segment.slice ('urls='.length) + } + return null } -const encodeImportStatus = ( - value: PostImportRow['importStatus'] | null, -): EncodedImportStatus | undefined => - value === 'pending' - ? 'p' - : value === 'created' - ? 'c' - : value === 'skipped' - ? 's' - : value === 'failed' - ? 'f' - : undefined +export const buildPostNewReviewPath = (urls: string[]): string => + `${ POST_NEW_REVIEW_PATH_PREFIX }${ urls.map (url => encodeURIComponent (url)).join ('+') }` -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 -} +export const postNewReviewPathByteLength = (urls: string[]): number => + textEncoder.encode (buildPostNewReviewPath (urls)).byteLength -const sanitiseDisplayTag = (value: unknown): PostImportDisplayTag | null => { - if (!(isPlainObject (value))) - return null - if (!(hasOnlyKeys (value, ['n', 'c', 's']))) - return null - if (typeof value.n !== 'string' || value.n === '') - return null - if (!(CATEGORIES.includes (value.c as never))) - return null - if (value.s != null && !(Array.isArray (value.s)) || !(value.s ?? []).every (entry => typeof entry === 'string')) - return null - return { - name: value.n, - category: value.c, - sectionLiterals: value.s == null ? undefined : [...value.s] } -} +export const isPostNewReviewPathWithinLimit = (urls: string[]): boolean => + postNewReviewPathByteLength (urls) < MAX_POST_NEW_REVIEW_TARGET_BYTES -const sanitiseDisplayTags = ( - value: unknown, -): PostImportDisplayTag[] | null => { - if (value == null) - return [] - if (!(Array.isArray (value))) - return null - const tags = value.map (sanitiseDisplayTag) - return tags.some (tag => tag == null) ? null : (tags as PostImportDisplayTag[]) -} - -const encodeDisplayTags = ( - tags: PostImportDisplayTag[] | undefined, -): EncodedDisplayTag[] | undefined => - tags == null || tags.length === 0 - ? undefined - : tags.map (tag => ({ - n: tag.name, - c: tag.category, - s: - tag.sectionLiterals == null || tag.sectionLiterals.length === 0 - ? undefined - : [...tag.sectionLiterals] })) - -const encodeDisplayTagsParam = ( - tags: PostImportDisplayTag[] | undefined, -): string | null => { - const encodedTags = encodeDisplayTags (tags) - if (encodedTags == null) - return null - - return encodeBase64UrlBytes (textEncoder.encode (JSON.stringify (encodedTags))) -} - -const decodeDisplayTagsParam = ( - value: string | null, -): PostImportDisplayTag[] | null => { - if (value == null || value === '') +export const parsePostNewReviewUrls = (search: string): string[] => { + const raw = rawUrlsParam (search) + if (raw == null) return [] - const bytes = decodeBase64UrlBytes (value) - if (bytes == null || bytes.byteLength > MAX_DECOMPRESSED_STATE_BYTES) - return null - - try - { - return sanitiseDisplayTags (JSON.parse (textDecoder.decode (bytes))) - } - catch - { - return null - } + return raw + .split ('+') + .filter (segment => segment !== '') + .map (segment => { + try + { + return decodeURIComponent (segment) + } + catch + { + return segment + } + }) } -const sanitiseExistingPost = ( - value: unknown, -): PostImportRow['existingPost'] | null => { - if (value == null) - return null - if (!(isPlainObject (value))) - return null - if (!(hasOnlyKeys (value, ['i', 't', 'u', 'h', 'b']))) - return null - if (!(typeof value.i === 'number' - && Number.isSafeInteger (value.i) - && value.i > 0)) - return null - if (typeof value.t !== 'string' || typeof value.u !== 'string') - return null - if (value.h != null && typeof value.h !== 'string') - return null - if (value.b != null && typeof value.b !== 'string') - return null - - return { - id: value.i, - title: value.t, - url: value.u, - thumbnail: value.h, - thumbnailBase: value.b } -} - - -const encodeExistingPost = ( - post: PostImportRow['existingPost'] | undefined, -): EncodedExistingPost | undefined => - post == null - ? undefined - : { - i: post.id, - t: post.title, - u: post.url, - h: post.thumbnail, - b: post.thumbnailBase } - - -const encodeExistingPostParam = ( - post: PostImportRow['existingPost'] | undefined, -): string | null => { - const encodedPost = encodeExistingPost (post) - if (encodedPost == null) - return null - - return encodeBase64UrlBytes (textEncoder.encode (JSON.stringify (encodedPost))) -} - - -const decodeExistingPostParam = ( - value: string | null, -): PostImportRow['existingPost'] | null => { - if (value == null || value === '') - return null - - const bytes = decodeBase64UrlBytes (value) - if (bytes == null || bytes.byteLength > MAX_DECOMPRESSED_STATE_BYTES) - return null - - try - { - return sanitiseExistingPost (JSON.parse (textDecoder.decode (bytes))) - } - catch - { - return null - } -} - - -const isStrictPositiveIntegerString = (value: string): boolean => - /^[1-9][0-9]*$/.test (value) - && Number.isSafeInteger (Number (value)) - - -const parsePositiveInt = (value: string | null): number | null => { - if (value == null || value === '') - return null - - if (!(isStrictPositiveIntegerString (value))) - return null - - return Number (value) -} - - -const decodeBase64UrlBytes = (value: string): Uint8Array | null => { - try - { - const padded = value.replaceAll ('-', '+').replaceAll ('_', '/') - const remainder = padded.length % 4 - const base64 = - remainder === 0 - ? padded - : `${ padded }${ '='.repeat (4 - remainder) }` - const binary = window.atob (base64) - return Uint8Array.from (binary, char => char.charCodeAt (0)) - } - catch - { - return null - } -} - - -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')) - const reader = stream.getReader () - const chunks: Uint8Array[] = [] - let totalBytes = 0 - - try - { - while (true) - { - const result = await reader.read () - if (result.done) - break - totalBytes += result.value.byteLength - if (totalBytes > MAX_DECOMPRESSED_STATE_BYTES) - { - await reader.cancel () - throw new Error ('decompressed state too large') - } - chunks.push (result.value) - } - } - finally - { - reader.releaseLock () - } - - const bytes = new Uint8Array (totalBytes) - let offset = 0 - chunks.forEach (chunk => { - bytes.set (chunk, offset) - offset += chunk.byteLength - }) - return bytes -} - - -const decodeCompressedState = async (value: string): Promise => { - if (!(value.startsWith (COMPRESSED_STATE_PREFIX))) - return null - if (value.length > MAX_COMPRESSED_STATE_PARAM_LENGTH) - return null - - const encoded = value.slice (COMPRESSED_STATE_PREFIX.length) - const compressed = decodeBase64UrlBytes (encoded) - if (compressed == null) - return null - if (compressed.byteLength > MAX_COMPRESSED_STATE_BYTES) - 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', - 'originalCreatedFrom', - 'originalCreatedBefore', - 'duration', - 'tags', - 'parentPostIds'] - .filter (field => row.provenance[field] === 'manual') - - -const baseRowState = (row: PostImportRow): QueryRowState => ({ - source_row: row.sourceRow, - title: String (row.attributes.title ?? ''), - thumbnail_base: String (row.attributes.thumbnailBase ?? ''), - original_created_from: String (row.attributes.originalCreatedFrom ?? ''), - original_created_before: String (row.attributes.originalCreatedBefore ?? ''), - video_ms: String (row.attributes.videoMs ?? ''), - duration: String (row.attributes.duration ?? ''), - tags: String (row.attributes.tags ?? ''), - parent_post_ids: String (row.attributes.parentPostIds ?? ''), - display_tags: row.displayTags ?? [], - manual: manualFields (row), - skip: row.skipReason ?? null, - existing_post_id: row.existingPostId ?? null, - existing_post: row.existingPost ?? null, - import_status: row.importStatus ?? null, - created_post_id: row.createdPostId ?? null, - recoverable: row.recoverable ?? null }) - - -const buildRow = (state: FullRowState): PostImportRow => { - const manual = new Set (state.manual) - const provenance: Record = { - url: 'manual', - title: manual.has ('title') ? 'manual' : 'automatic', - thumbnailBase: manual.has ('thumbnailBase') ? 'manual' : 'automatic', - originalCreatedFrom: manual.has ('originalCreatedFrom') ? 'manual' : 'automatic', - originalCreatedBefore: manual.has ('originalCreatedBefore') ? 'manual' : 'automatic', - videoMs: manual.has ('duration') ? 'manual' : 'automatic', - duration: manual.has ('duration') ? 'manual' : 'automatic', - tags: manual.has ('tags') ? 'manual' : 'automatic', - parentPostIds: manual.has ('parentPostIds') ? 'manual' : 'automatic' } - const tagSources = provenance.tags === 'manual' - ? { automatic: '', manual: state.tags } - : { automatic: state.tags, manual: '' } - const attributes = { - title: state.title, - thumbnailBase: state.thumbnail_base, - originalCreatedFrom: state.original_created_from, - originalCreatedBefore: state.original_created_before, - videoMs: manual.has ('duration') ? '' : state.video_ms, - duration: state.duration, - tags: state.tags, - parentPostIds: state.parent_post_ids } - - return { - sourceRow: state.source_row, - url: state.url, - attributes, - fieldWarnings: { }, - baseWarnings: [], - validationErrors: { }, - provenance, - displayTags: state.display_tags.map (tag => ({ - name: tag.name, - category: tag.category, - sectionLiterals: - tag.sectionLiterals == null - ? undefined - : [...tag.sectionLiterals] })), - tagSources, - status: 'pending', - skipReason: state.skip ?? undefined, - existingPostId: state.existing_post_id ?? undefined, - existingPost: - state.existing_post == null - ? undefined - : { - id: state.existing_post.id, - title: state.existing_post.title, - url: state.existing_post.url, - thumbnail: state.existing_post.thumbnail, - thumbnailBase: state.existing_post.thumbnailBase }, - resetSnapshot: { - url: state.url, - attributes: { ...attributes }, - displayTags: state.display_tags.map (tag => ({ - name: tag.name, - category: tag.category, - sectionLiterals: - tag.sectionLiterals == null - ? undefined - : [...tag.sectionLiterals] })), - provenance: { ...provenance }, - tagSources: { ...tagSources }, - fieldWarnings: { }, - baseWarnings: [] }, - createdPostId: state.created_post_id ?? undefined, - importStatus: state.import_status ?? undefined, - recoverable: state.recoverable ?? undefined } -} - - -const parseManual = (value: string | null): string[] => - (value ?? '') - .split (',') - .map (entry => entry.trim ()) - .filter (entry => entry !== '') - - -const hasOnlyAllowedManualFields = (value: string[]): boolean => - value.every (field => ALLOWED_MANUAL_FIELDS.includes (field as never)) - - -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 (!(isPlainObject (value))) - return null - - const row = value as Record - if (!(hasOnlyKeys (row, ENCODED_ROW_KEYS))) - return null - 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'] - const sourceRow = row['n'] - const displayTags = sanitiseDisplayTags (row['k']) - const existingPost = sanitiseExistingPost (row['o']) - - if (requireUrl && typeof url !== 'string') - return null - if (typeof url === 'string' && byteLength (url) > MAX_QUERY_STRING_BYTES) - return null - if (manual != null && !(isStringArray (manual))) - return null - if ((manual ?? []).some (field => !(ALLOWED_MANUAL_FIELDS.includes (field as never)))) - 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.isSafeInteger (existingPostId) - && existingPostId > 0)) - return null - if (createdPostId != null - && !(typeof createdPostId === 'number' - && Number.isSafeInteger (createdPostId) - && createdPostId > 0)) - return null - if (recoverable != null && typeof recoverable !== 'boolean') - return null - if (displayTags == null) - return null - if (row['o'] != null && existingPost == null) - return null - if (sourceRow != null - && !(typeof sourceRow === 'number' - && Number.isSafeInteger (sourceRow) - && sourceRow > 0)) - return null - if (skip === 'existing' && existingPostId == null) - return null - if (existingPost != null && existingPost.id !== existingPostId) - return null - if (importStatus === 'created' && createdPostId == null) - return null - if (importStatus !== 'created' && createdPostId != null) - return null - if (recoverable === true - && importStatus !== 'failed' - && importStatus !== 'pending') - return null - - const stringFields = [ - ['t', 'title'], - ['h', 'thumbnail_base'], - ['f', 'original_created_from'], - ['b', 'original_created_before'], - ['x', 'video_ms'], - ['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') - if (typeof fieldValue === 'string' && byteLength (fieldValue) > MAX_QUERY_STRING_BYTES) - throw new Error ('invalid row') - return [target, fieldValue ?? ''] - }), - ) as Record<(typeof stringFields)[number][1], string> - } - catch - { - return null - } - - return { - source_row: - typeof sourceRow === 'number' - ? sourceRow - : 1, - 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, - video_ms: parsedStrings.video_ms, - duration: parsedStrings.duration, - tags: parsedStrings.tags, - parent_post_ids: parsedStrings.parent_post_ids, - display_tags: displayTags, - manual: manual ?? [], - skip, - existing_post_id: existingPostId ?? null, - existing_post: existingPost, - 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 === '') - return null - if (byteLength (url) > MAX_QUERY_STRING_BYTES) - return null - - const hasOnlyUrl = Array.from (params.keys ()).every ( - key => key === 'url' || key === 'session_id') - if (hasOnlyUrl) - { - const row = buildRow ({ - source_row: 1, - url, - title: '', - thumbnail_base: '', - original_created_from: '', - original_created_before: '', - video_ms: '', - duration: '', - tags: '', - parent_post_ids: '', - display_tags: [], - manual: [], - skip: null, - existing_post_id: null, - existing_post: null, - import_status: null, - created_post_id: null, - recoverable: null }) - return { - rows: [row], - source: url, - repairMode: 'all', - shortcut: true } - } - - if (!(Array.from (params.keys ()).every (key => SINGLE_ROW_KEYS.includes (key as never)))) - return null - - const skipValue = params.get ('skip') - const importStatus = params.get ('import_status') - const recoverableValue = params.get ('recoverable') - const recoverable = - recoverableValue == null - ? null - : recoverableValue === 'true' - ? true - : recoverableValue === 'false' - ? false - : null - if (skipValue != null && !(isValidSkipReason (skipValue))) - return null - if (importStatus != null && !(isValidImportStatus (importStatus))) - return null - if (recoverableValue != null && recoverable == null) - return null - if (!(hasOnlyAllowedManualFields (parseManual (params.get ('manual'))))) - return null - const existingPostIdValue = params.get ('existing_post_id') - const createdPostIdValue = params.get ('created_post_id') - const displayTags = decodeDisplayTagsParam (params.get ('display_tags')) - const existingPost = decodeExistingPostParam (params.get ('existing_post')) - const existingPostId = parsePositiveInt (existingPostIdValue) - const createdPostId = parsePositiveInt (createdPostIdValue) - if (displayTags == null) - return null - if (params.get ('existing_post') != null && existingPost == null) - return null - if (existingPostIdValue != null && existingPostId == null) - return null - if (createdPostIdValue != null && createdPostId == null) - return null - if (skipValue === 'existing' && existingPostId == null) - return null - if (existingPost != null && existingPost.id !== existingPostId) - return null - if (importStatus === 'created' && createdPostId == null) - return null - if (importStatus !== 'created' && createdPostIdValue != null) - return null - if (recoverable === true - && importStatus !== 'failed' - && importStatus !== 'pending') - return null - - const row = buildRow ({ - source_row: 1, - url, - title: params.get ('title') ?? '', - thumbnail_base: params.get ('thumbnail_base') ?? '', - original_created_from: params.get ('original_created_from') ?? '', - original_created_before: params.get ('original_created_before') ?? '', - video_ms: params.get ('video_ms') ?? '', - duration: params.get ('duration') ?? '', - tags: params.get ('tags') ?? '', - parent_post_ids: params.get ('parent_post_ids') ?? '', - display_tags: displayTags, - manual: parseManual (params.get ('manual')), - skip: skipValue, - existing_post_id: existingPostId, - existing_post: existingPost, - import_status: importStatus, - created_post_id: createdPostId, - recoverable }) - - return { - rows: [row], - source: url, - repairMode: resultRepairMode ([row]), - shortcut: false } -} - - -const parseMetaRows = ( - urlsValue: string, - metaValue: string, -): ParsedPostNewState | null => { - const decodedBytes = decodeBase64UrlBytes (metaValue) - if (decodedBytes == null) - return null - if (decodedBytes.byteLength > MAX_DECOMPRESSED_STATE_BYTES) - return null - - let parsed: MultiRowMetaState - try - { - parsed = JSON.parse (textDecoder.decode (decodedBytes)) as MultiRowMetaState - } - catch - { - return null - } - - if (parsed.v !== 1 || !(Array.isArray (parsed.rows))) - return null - - const urls = urlsValue.split (' ').filter (url => url !== '') - if (urls.length < 2 - || urls.length > MAX_STATE_ROWS - || urls.length !== parsed.rows.length - || parsed.rows.length > MAX_STATE_ROWS) - return null - if (urls.some (url => byteLength (url) > MAX_QUERY_STRING_BYTES)) - return null - - 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 - if ((new Set (fullRows.map (row => (row as FullRowState).source_row))).size !== fullRows.length) - return null - - const rows = fullRows.map (row => buildRow (row as FullRowState)) - return { - rows, - source: urls.join ('\n'), - repairMode: resultRepairMode (rows), - shortcut: false } -} - - -const parseFullState = async (stateValue: string): Promise => { - const decoded = await decodeCompressedState (stateValue) - if (decoded == null) - return null - - let parsed: EncodedState - try - { - parsed = JSON.parse (decoded) as EncodedState - } - catch - { - return null - } - - if (parsed.v !== 1 - || !(isPlainObject (parsed)) - || !(hasOnlyKeys (parsed as Record, ENCODED_STATE_KEYS)) - || !(Array.isArray (parsed.rows)) - || parsed.rows.length === 0 - || parsed.rows.length > MAX_STATE_ROWS) - return null - - const fullRows = parsed.rows.map (row => parseEncodedRow (row, true)) - if (fullRows.some (row => row == null)) - return null - if ((new Set (fullRows.map (row => (row as FullRowState).source_row))).size !== fullRows.length) - return null - - const rows = fullRows.map (row => buildRow (row as FullRowState)) - return { - rows, - source: rows.map (row => row.url).join ('\n'), - repairMode: resultRepairMode (rows), - shortcut: false } -} - - -const regularQuery = (rows: PostImportRow[]): URLSearchParams => { - if (rows.length === 1) - { - const row = rows[0] - const params = new URLSearchParams () - params.set ('url', row?.url ?? '') - params.set ('title', String (row?.attributes.title ?? '')) - params.set ('thumbnail_base', String (row?.attributes.thumbnailBase ?? '')) - params.set ('original_created_from', String (row?.attributes.originalCreatedFrom ?? '')) - params.set ('original_created_before', String (row?.attributes.originalCreatedBefore ?? '')) - params.set ('video_ms', String (row?.attributes.videoMs ?? '')) - params.set ('duration', String (row?.attributes.duration ?? '')) - params.set ('tags', String (row?.attributes.tags ?? '')) - params.set ('parent_post_ids', String (row?.attributes.parentPostIds ?? '')) - const displayTags = encodeDisplayTagsParam (row?.displayTags) - if (displayTags != null) - params.set ('display_tags', displayTags) - const existingPost = encodeExistingPostParam (row?.existingPost) - if (existingPost != null) - params.set ('existing_post', existingPost) - const manual = manualFields (row).join (',') - if (manual !== '') - params.set ('manual', manual) - if (row.skipReason != null) - params.set ('skip', row.skipReason) - if (row.existingPostId != null) - params.set ('existing_post_id', String (row.existingPostId)) - if (row.importStatus != null) - params.set ('import_status', row.importStatus) - if (row.createdPostId != null) - params.set ('created_post_id', String (row.createdPostId)) - if (row.recoverable != null) - params.set ('recoverable', row.recoverable ? 'true' : 'false') - return params - } - - const params = new URLSearchParams () - 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 } : { } - - encoded.n = base.source_row - 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.video_ms !== '') - encoded.x = base.video_ms - 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.display_tags.length > 0) - encoded.k = encodeDisplayTags (base.display_tags) - 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.existing_post != null) - encoded.o = encodeExistingPost (base.existing_post) - 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') - || params.has ('urls') - || params.has ('meta') - || params.has ('url') - || parsePostNewSessionId (search) != null -} - - -export const parsePostNewSessionId = (search: string): string | null => - validatePostImportSessionId ( - new URLSearchParams (search).get ('session_id')) - - -export const appendPostNewSessionId = ( - path: string, - sessionId: string, -): string => { - const validatedId = validatePostImportSessionId (sessionId) - if (validatedId == null) - return path - - const separator = path.includes ('?') ? '&' : '?' - return `${ path }${ separator }session_id=${ validatedId }` -} - - -export const parsePostNewState = async ( - search: string, -): Promise => { - if (byteLength (search) > MAX_QUERY_STRING_BYTES) - return null - - const params = new URLSearchParams (search) - const stateValue = params.get ('state') - if (stateValue != null) - return await parseFullState (stateValue) - - const urlsValue = params.get ('urls') - const metaValue = params.get ('meta') - if (urlsValue != null || metaValue != null) - { - if (urlsValue == null || metaValue == null) - return null - return parseMetaRows (urlsValue, metaValue) - } - - return parseSingleRow (params) -} - - -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 path = `/posts/new?state=${ state }` - if (path.length <= MAX_COMPRESSED_STATE_LENGTH) - return { - path, - complete: count === rows.length } - } - } - 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 { - path: regularPath, - complete: true } - - return await serialiseCompressedRows (rows) -} +export const hasPostNewReviewState = (search: string): boolean => + rawUrlsParam (search) != null diff --git a/frontend/src/lib/useUnsavedChangesGuard.tsx b/frontend/src/lib/useUnsavedChangesGuard.tsx index c007812..089eb5d 100644 --- a/frontend/src/lib/useUnsavedChangesGuard.tsx +++ b/frontend/src/lib/useUnsavedChangesGuard.tsx @@ -1,6 +1,15 @@ -import { createContext, useCallback, useContext, useMemo, useState } from 'react' +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { useBlocker, useLocation } from 'react-router-dom' -import { useDialogue } from '@/components/dialogues/DialogueProvider' +import { useDialogue } from '@/lib/dialogues/useDialogue' import type { FC, PropsWithChildren } from 'react' @@ -8,12 +17,17 @@ type UnsavedChangesSource = { dirty: boolean discard: () => void | Promise } +type UseUnsavedChangesGuardOptions = { + dirty: boolean + onDiscard?: () => void | Promise } + type UnsavedChangesGuardContextValue = { hasUnsavedChanges: boolean registerUnsavedChangesSource: ( source: UnsavedChangesSource | null, ) => void - confirmDiscardNavigation: () => Promise } + confirmDiscardNavigation: () => Promise + allowNextNavigation: () => void } const UnsavedChangesGuardContext = createContext (null) @@ -21,7 +35,9 @@ const UnsavedChangesGuardContext = export const UnsavedChangesGuardProvider: FC = ({ children }) => { const dialogue = useDialogue () + const location = useLocation () const [source, setSource] = useState (null) + const bypassNextNavigationRef = useRef (false) const registerUnsavedChangesSource = useCallback (( nextSource: UnsavedChangesSource | null, @@ -29,28 +45,81 @@ export const UnsavedChangesGuardProvider: FC = ({ children }) setSource (nextSource) }, []) + const allowNextNavigation = useCallback (() => { + bypassNextNavigationRef.current = true + }, []) + const confirmDiscardNavigation = useCallback (async (): Promise => { if (!(source?.dirty)) return true const confirmed = await dialogue.confirm ({ - title: '未保存の変更があります', - description: 'このまま移動すると、保存していない変更は失われます。', - cancelText: 'このページに残る', + title: '変更が破棄してページ移動しますか?', confirmText: '変更を破棄して移動', variant: 'danger' }) if (!(confirmed)) return false + bypassNextNavigationRef.current = true await source.discard () return true }, [dialogue, source]) + const blocker = useBlocker (() => + source?.dirty === true && !(bypassNextNavigationRef.current)) + + useEffect (() => { + if (blocker.state !== 'blocked') + return + + let cancelled = false + + void (async () => { + const confirmed = await confirmDiscardNavigation () + if (cancelled) + return + + if (confirmed) + blocker.proceed () + else + blocker.reset () + }) () + + return () => { + cancelled = true + } + }, [blocker, confirmDiscardNavigation]) + + useEffect (() => { + bypassNextNavigationRef.current = false + }, [location.hash, location.pathname, location.search]) + + useEffect (() => { + if (!(source?.dirty)) + return + + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + event.preventDefault () + event.returnValue = '' + } + + window.addEventListener ('beforeunload', handleBeforeUnload) + return () => { + window.removeEventListener ('beforeunload', handleBeforeUnload) + } + }, [source?.dirty]) + const value = useMemo (() => ({ hasUnsavedChanges: source?.dirty === true, registerUnsavedChangesSource, confirmDiscardNavigation, - }), [confirmDiscardNavigation, registerUnsavedChangesSource, source?.dirty]) + allowNextNavigation, + }), [ + allowNextNavigation, + confirmDiscardNavigation, + registerUnsavedChangesSource, + source?.dirty, + ]) return ( @@ -59,11 +128,26 @@ export const UnsavedChangesGuardProvider: FC = ({ children }) } -export const useUnsavedChangesGuard = (): UnsavedChangesGuardContextValue => { +export const useUnsavedChangesGuard = ( + options?: UseUnsavedChangesGuardOptions, +): UnsavedChangesGuardContextValue => { const context = useContext (UnsavedChangesGuardContext) if (context == null) throw new Error ('UnsavedChangesGuardProvider が必要です.') + useEffect (() => { + if (options == null) + return + + context.registerUnsavedChangesSource ({ + dirty: options.dirty, + discard: options.onDiscard ?? (() => undefined) }) + + return () => { + context.registerUnsavedChangesSource (null) + } + }, [context, options?.dirty, options?.onDiscard]) + return context } diff --git a/frontend/src/pages/posts/PostImportReviewPage.tsx b/frontend/src/pages/posts/PostImportReviewPage.tsx index 41b8eb1..882b5af 100644 --- a/frontend/src/pages/posts/PostImportReviewPage.tsx +++ b/frontend/src/pages/posts/PostImportReviewPage.tsx @@ -1,5 +1,5 @@ import { AnimatePresence, motion } from 'framer-motion' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Helmet } from 'react-helmet-async' import { ChevronRight } from 'lucide-react' import { useLocation, useNavigate } from 'react-router-dom' @@ -15,22 +15,16 @@ import { toast } from '@/components/ui/use-toast' import { SITE_TITLE } from '@/config' import { apiGet, apiPost, isApiError } from '@/lib/api' import useDialogue from '@/lib/dialogues/useDialogue' -import { - appendPostNewSessionId, - parsePostNewState, - parsePostNewSessionId, - serialisePostNewState, -} from '@/lib/postNewQueryState' +import { parsePostNewReviewUrls } from '@/lib/postNewQueryState' +import { validateImportSource } from '@/lib/postImportSourceValidation' import { applyThumbnailWarning, buildNextEditedRow, canEditReviewRow, canRetryResultRow, - clearPostImportSession, - clearPostImportSourceDraft, - generatePostImportSessionId, - hasThumbnailBaseValue, + compactMessageRecord, hasErrorMessages, + hasThumbnailBaseValue, isCompletedReviewRow, isExistingSkipRow, isManualSkipRow, @@ -42,82 +36,164 @@ import { resultRowMessages, reviewSummaryCounts, retryImportRow, - loadPostImportSession, - compactMessageRecord, - serialisedPostImportRowsEqual, - savePostImportSession, -} from '@/lib/postImportSession' +} from '@/lib/postImportRows' +import { clearPostImportSourceDraft } from '@/lib/postImportStorage' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' +import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard' import { cn } from '@/lib/utils' 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 { + PostImportDisplayTag, + PostImportEditableDraft, + PostImportResetSnapshot, PostImportResultRow, PostImportRow, - PostImportSession, -} from '@/lib/postImportSession' +} from '@/lib/postImportTypes' import type { User } from '@/types' type Props = { user: User | null } type PostMetadataResponse = { - url: string - title?: string - thumbnailBase?: string - originalCreatedFrom?: string + url: string + title?: string + thumbnailBase?: string + originalCreatedFrom?: string originalCreatedBefore?: string - parentPostIds?: string - duration?: string - videoMs?: number - tags?: string - displayTags?: PostImportRow['displayTags'] - fieldWarnings?: Record - baseWarnings?: string[] - validationErrors?: Record - existingPostId?: number - existingPost?: { - id: number - title: string - url: string - thumbnail?: string | null + parentPostIds?: string + duration?: string + videoMs?: number + tags?: string + displayTags?: PostImportDisplayTag[] + fieldWarnings?: Record + baseWarnings?: string[] + validationErrors?: Record + existingPostId?: number + existingPost?: { + id: number + title: string + url: string + thumbnail?: string | null thumbnailBase?: string | null } | null } type BulkApiRow = { - status: 'created' | 'skipped' | 'failed' - post?: { id: number } + status: 'created' | 'skipped' | 'failed' + post?: { id: number } existingPostId?: number - existingPost?: { - id: number - title: string - url: string - thumbnail?: string | null + existingPost?: { + id: number + title: string + url: string + thumbnail?: string | null thumbnailBase?: string | null } | null fieldWarnings?: Record - baseWarnings?: string[] - errors?: Record - baseErrors?: string[] - recoverable?: boolean } + baseWarnings?: string[] + errors?: Record + baseErrors?: string[] + recoverable?: boolean } type ExistingSkippedRowsProps = { - id: string - rows: PostImportRow[] } - -const isRepairRow = (row: PostImportRow): boolean => - row.recoverable === true - && (row.importStatus === 'failed' - || (row.importStatus === 'pending' - && hasErrorMessages (row.validationErrors))) + id: string + rows: PostImportRow[] } const DUPLICATE_URL_MESSAGE = 'URL が重複しています.' const BULK_PROCESSING_FAILED_MESSAGE = '登録中にエラーが発生しました.' +const emptyAttributes = () => ({ + title: '', + thumbnailBase: '', + originalCreatedFrom: '', + originalCreatedBefore: '', + duration: '', + videoMs: '', + tags: '', + parentPostIds: '' }) + + +const emptyProvenance = () => ({ + url: 'manual', + title: 'automatic', + thumbnailBase: 'automatic', + originalCreatedFrom: 'automatic', + originalCreatedBefore: 'automatic', + duration: 'automatic', + videoMs: 'automatic', + tags: 'automatic', + parentPostIds: 'automatic' }) as const + + +const emptyTagSources = () => ({ + automatic: '', + manual: '' }) + + +const cloneDisplayTags = ( + tags: PostImportDisplayTag[] | undefined, +): PostImportDisplayTag[] => + tags?.map (tag => ({ + name: tag.name, + category: tag.category, + sectionLiterals: + tag.sectionLiterals == null + ? undefined + : [...tag.sectionLiterals] })) ?? [] + + +const cloneMessageRecord = ( + record: Record, +): Record => + Object.fromEntries ( + Object.entries (record).map (([key, messages]) => [key, [...messages]])) + + +const buildEmptyResetSnapshot = (url: string): PostImportResetSnapshot => ({ + url, + attributes: emptyAttributes (), + displayTags: [], + provenance: emptyProvenance (), + tagSources: emptyTagSources (), + fieldWarnings: { }, + baseWarnings: [] }) + + +const buildInitialRows = (urls: string[]): PostImportRow[] => { + const source = urls.join ('\n') + const issues = validateImportSource (source) + const issuesByRow = issues.reduce> ((result, issue) => { + result[issue.sourceRow] = [...(result[issue.sourceRow] ?? []), issue.message] + return result + }, { }) + + return applyDuplicateUrlErrors ( + urls.map ((url, index) => { + const sourceRow = index + 1 + const rowIssues = issuesByRow[sourceRow] ?? [] + return { + sourceRow, + url, + attributes: emptyAttributes (), + fieldWarnings: { }, + baseWarnings: [], + validationErrors: + rowIssues.length > 0 + ? { url: [...rowIssues] } + : { }, + provenance: emptyProvenance (), + tagSources: emptyTagSources (), + status: rowIssues.length > 0 ? 'error' : 'pending', + displayTags: [], + resetSnapshot: buildEmptyResetSnapshot (url) } + })) +} + + const rowMetadataPending = (row: PostImportRow): boolean => row.status === 'pending' + const rowOperationBusy = ( row: PostImportRow, submitting: boolean, @@ -137,6 +213,7 @@ const rowSkipBusy = ( || busyRowIds.has (row.sourceRow) || (rowMetadataPending (row) && !(isManualSkipRow (row))) + const shouldFetchMetadata = (row: PostImportRow): boolean => row.status === 'pending' && row.skipReason !== 'manual' @@ -145,6 +222,7 @@ const shouldFetchMetadata = (row: PostImportRow): boolean => && !(isNonRecoverableFailedRow (row)) && row.metadataUrl == null + const shouldHydrateExistingPost = (row: PostImportRow): boolean => row.skipReason === 'existing' && row.existingPostId != null @@ -152,9 +230,11 @@ const shouldHydrateExistingPost = (row: PostImportRow): boolean => && row.importStatus !== 'created' && row.importStatus !== 'skipped' + const shouldFetchReviewRow = (row: PostImportRow): boolean => shouldFetchMetadata (row) || shouldHydrateExistingPost (row) + const applyDuplicateUrlErrors = ( rows: PostImportRow[], ): PostImportRow[] => { @@ -201,14 +281,6 @@ const applyDuplicateUrlErrors = ( } -const buildSession = (rows: PostImportRow[]): PostImportSession => ({ - version: 3, - expiresAt: '', - source: rows.map (row => row.url).join ('\n'), - rows, - repairMode: resultRepairMode (rows) }) - - const buildDryRunFormData = (row: PostImportRow): FormData => { const formData = new FormData () formData.append ('url', row.url) @@ -234,50 +306,42 @@ const mergeDryRunRow = ( currentRow: PostImportRow, result: PostMetadataResponse, ): PostImportRow => { - const fieldWarnings = compactMessageRecord (result.fieldWarnings ?? { }) - const hasWarnings = - Object.values (fieldWarnings).some (messages => messages.length > 0) - || (result.baseWarnings?.length ?? 0) > 0 - return applyThumbnailWarning ({ - ...currentRow, - url: result.url, - attributes: { - ...currentRow.attributes, - title: result.title ?? '', - thumbnailBase: result.thumbnailBase ?? '', - originalCreatedFrom: result.originalCreatedFrom ?? '', - originalCreatedBefore: result.originalCreatedBefore ?? '', - duration: result.duration ?? '', - videoMs: result.videoMs ?? '', - tags: result.tags ?? '', - parentPostIds: String (result.parentPostIds ?? currentRow.attributes.parentPostIds ?? '') }, - displayTags: - result.displayTags?.map (tag => ({ - name: tag.name, - category: tag.category, - sectionLiterals: - tag.sectionLiterals == null - ? undefined - : [...tag.sectionLiterals] })) ?? [], - fieldWarnings, - baseWarnings: result.baseWarnings ?? [], - validationErrors: { }, - importErrors: undefined, - status: - hasWarnings - ? 'warning' - : 'ready', - skipReason: - result.existingPostId != null || result.existingPost?.id != null - ? 'existing' - : undefined, - existingPostId: result.existingPostId ?? result.existingPost?.id, - existingPost: result.existingPost ?? undefined, - importStatus: - currentRow.importStatus === 'created' - ? 'created' - : 'pending', - recoverable: undefined }) + const fieldWarnings = compactMessageRecord (result.fieldWarnings ?? { }) + const hasWarnings = + Object.values (fieldWarnings).some (messages => messages.length > 0) + || (result.baseWarnings?.length ?? 0) > 0 + + return applyThumbnailWarning ({ + ...currentRow, + url: result.url, + attributes: { + ...currentRow.attributes, + title: result.title ?? '', + thumbnailBase: result.thumbnailBase ?? '', + originalCreatedFrom: result.originalCreatedFrom ?? '', + originalCreatedBefore: result.originalCreatedBefore ?? '', + duration: result.duration ?? '', + videoMs: result.videoMs ?? '', + tags: result.tags ?? '', + parentPostIds: String ( + result.parentPostIds ?? currentRow.attributes.parentPostIds ?? '') }, + displayTags: cloneDisplayTags (result.displayTags), + fieldWarnings, + baseWarnings: result.baseWarnings ?? [], + validationErrors: { }, + importErrors: undefined, + status: hasWarnings ? 'warning' : 'ready', + skipReason: + result.existingPostId != null || result.existingPost?.id != null + ? 'existing' + : undefined, + existingPostId: result.existingPostId ?? result.existingPost?.id, + existingPost: result.existingPost ?? undefined, + importStatus: + currentRow.importStatus === 'created' + ? 'created' + : 'pending', + recoverable: undefined }) } @@ -294,24 +358,8 @@ const buildPreviewResetSnapshot = ( videoMs: preview.videoMs ?? '', tags: preview.tags ?? '', parentPostIds: String (preview.parentPostIds ?? '') }, - displayTags: - preview.displayTags?.map (tag => ({ - name: tag.name, - category: tag.category, - sectionLiterals: - tag.sectionLiterals == null - ? undefined - : [...tag.sectionLiterals] })) ?? [], - provenance: { - url: 'manual', - title: 'automatic', - thumbnailBase: 'automatic', - originalCreatedFrom: 'automatic', - originalCreatedBefore: 'automatic', - duration: 'automatic', - videoMs: 'automatic', - tags: 'automatic', - parentPostIds: 'automatic' }, + displayTags: cloneDisplayTags (preview.displayTags), + provenance: emptyProvenance (), tagSources: { automatic: preview.tags ?? '', manual: '' }, @@ -358,8 +406,7 @@ const indexedBulkResults = ( errors: { ...(errors ?? { }), base: [...new Set ([...(errors?.base ?? []), - BULK_PROCESSING_FAILED_MESSAGE])], - }, + BULK_PROCESSING_FAILED_MESSAGE])] }, recoverable: false } } return { @@ -380,6 +427,7 @@ const indexedBulkResults = ( recoverable: result?.recoverable } }) + const mergePreviewRow = ( currentRow: PostImportRow, preview: PostMetadataResponse, @@ -397,14 +445,7 @@ const mergePreviewRow = ( ...currentRow, attributes: { ...currentRow.attributes }, provenance: { ...currentRow.provenance }, - displayTags: - currentRow.displayTags?.map (tag => ({ - name: tag.name, - category: tag.category, - sectionLiterals: - tag.sectionLiterals == null - ? undefined - : [...tag.sectionLiterals] })) ?? [], + displayTags: cloneDisplayTags (currentRow.displayTags), tagSources: { automatic: currentRow.tagSources?.automatic ?? '', manual: currentRow.tagSources?.manual ?? '' }, @@ -415,22 +456,22 @@ const mergePreviewRow = ( existingPostId: preview.existingPostId ?? preview.existingPost?.id, existingPost: preview.existingPost ?? undefined, skipReason: - preview.existingPostId != null || preview.existingPost?.id != null - ? 'existing' - : currentRow.skipReason === 'manual' - ? 'manual' - : undefined, + preview.existingPostId != null || preview.existingPost?.id != null + ? 'existing' + : currentRow.skipReason === 'manual' + ? 'manual' + : undefined, metadataUrl: preview.url, resetSnapshot: - metadataChanged - ? buildPreviewResetSnapshot (preview) - : currentRow.resetSnapshot, + metadataChanged + ? buildPreviewResetSnapshot (preview) + : currentRow.resetSnapshot, status: - hasErrors - ? 'error' - : hasWarnings - ? 'warning' - : 'ready' } + hasErrors + ? 'error' + : hasWarnings + ? 'warning' + : 'ready' } if (currentRow.provenance.title !== 'manual') nextRow.attributes.title = preview.title ?? '' @@ -443,14 +484,7 @@ const mergePreviewRow = ( if (currentRow.provenance.tags !== 'manual') { nextRow.attributes.tags = preview.tags ?? '' - nextRow.displayTags = - preview.displayTags?.map (tag => ({ - name: tag.name, - category: tag.category, - sectionLiterals: - tag.sectionLiterals == null - ? undefined - : [...tag.sectionLiterals] })) ?? [] + nextRow.displayTags = cloneDisplayTags (preview.displayTags) } nextRow.tagSources.automatic = preview.tags ?? '' if (currentRow.provenance.parentPostIds !== 'manual') @@ -484,7 +518,7 @@ const buildBulkFormData = (rows: PostImportRow[]): FormData => { formData.append ( 'posts', JSON.stringify ( - rows.map (row => ({ + rows.map (row => ({ url: row.url, title: row.attributes.title ?? '', thumbnail_base: row.attributes.thumbnailBase ?? '', @@ -547,6 +581,26 @@ const ExistingSkippedRows: FC = ({ id, rows }) => { } +const editableRowDirty = (row: PostImportRow): boolean => + row.skipReason === 'manual' + || row.url !== row.resetSnapshot.url + || String (row.attributes.title ?? '') + !== String (row.resetSnapshot.attributes.title ?? '') + || String (row.attributes.thumbnailBase ?? '') + !== String (row.resetSnapshot.attributes.thumbnailBase ?? '') + || String (row.attributes.originalCreatedFrom ?? '') + !== String (row.resetSnapshot.attributes.originalCreatedFrom ?? '') + || String (row.attributes.originalCreatedBefore ?? '') + !== String (row.resetSnapshot.attributes.originalCreatedBefore ?? '') + || String (row.attributes.duration ?? '') + !== String (row.resetSnapshot.attributes.duration ?? '') + || String (row.attributes.tags ?? '') + !== String (row.resetSnapshot.attributes.tags ?? '') + || String (row.attributes.parentPostIds ?? '') + !== String (row.resetSnapshot.attributes.parentPostIds ?? '') + || row.thumbnailFile != null + + const PostImportReviewPage: FC = ({ user }) => { const editable = canEditContent (user) const dialogue = useDialogue () @@ -561,319 +615,240 @@ const PostImportReviewPage: FC = ({ user }) => { ? { duration: .08, ease: 'linear' as const } : { duration: .2, ease: 'easeOut' as const } - const [session, setSession] = useState (null) + const [rows, setRows] = useState (null) const [metadataLoading, setMetadataLoading] = useState (false) const [submitting, setSubmitting] = useState (false) const [busyRowIds, setBusyRowIds] = useState> (new Set ()) const [editingRow, setEditingRow] = useState (null) const [showExistingRows, setShowExistingRows] = useState (false) - const sessionRef = useRef (null) - const sessionIdRef = useRef (null) - const persistedSearchRef = useRef (null) - const persistSequenceRef = useRef (0) - const previewSequenceRef = useRef (0) + const rowsRef = useRef ([]) const metadataSequenceRef = useRef (0) + const discardChanges = useCallback (() => undefined, []) - const showStorageError = (message: string) => - toast ({ description: message }) + const dirty = useMemo ( + () => rows?.some (row => editableRowDirty (row)) ?? false, + [rows], + ) + const { allowNextNavigation } = useUnsavedChangesGuard ({ + dirty, + onDiscard: discardChanges, + }) - const commitSessionState = ( - nextSession: PostImportSession, - ): { - session: PostImportSession - sessionRoundTripSucceeded: boolean } => { - const sessionId = - sessionIdRef.current ?? generatePostImportSessionId () - sessionIdRef.current = sessionId - const persistedSession = buildSession (nextSession.rows) - const saved = savePostImportSession (sessionId, persistedSession, showStorageError) - const loadedSession = - saved - ? loadPostImportSession (sessionId, showStorageError) - : null - sessionRef.current = persistedSession - setSession (persistedSession) - return { - session: persistedSession, - sessionRoundTripSucceeded: - saved - && serialisedPostImportRowsEqual ( - loadedSession?.rows ?? [], - persistedSession.rows) } - } + const commitRows = useCallback ((nextRows: PostImportRow[]) => { + rowsRef.current = nextRows + setRows (nextRows) + return nextRows + }, []) - const syncSessionUrl = async ( - nextSession: PostImportSession, - ): Promise => { - const sessionId = - sessionIdRef.current ?? generatePostImportSessionId () - sessionIdRef.current = sessionId - const sequence = ++persistSequenceRef.current - const serialised = await serialisePostNewState (nextSession.rows) - if (serialised == null) - return null - if (persistSequenceRef.current !== sequence) - return null + const updateRows = useCallback (( + updater: (currentRows: PostImportRow[]) => PostImportRow[], + ) => { + const nextRows = applyDuplicateUrlErrors (updater (rowsRef.current)) + return commitRows (nextRows) + }, [commitRows]) - const { session: persistedSession, - sessionRoundTripSucceeded } = commitSessionState (nextSession) - const canNavigate = - serialised.complete - || sessionRoundTripSucceeded - if (!(canNavigate)) - { - showStorageError ('ブラウザへ保存できませんでした.') - return null - } - - const path = appendPostNewSessionId (serialised.path, sessionId) - persistedSearchRef.current = (new URL (path, window.location.origin)).search - navigate (path, { replace: true }) - return persistedSession - } - - const currentRowBySource = ( + const currentRowBySource = useCallback (( sourceRow: number, - fallback?: PostImportSession, ): PostImportRow | null => - (sessionRef.current?.rows ?? fallback?.rows ?? []).find ( - row => row.sourceRow === sourceRow) ?? null + rowsRef.current.find (row => row.sourceRow === sourceRow) ?? null, + []) - const finishImport = () => { - const sessionId = sessionIdRef.current - if (sessionId != null) - clearPostImportSession (sessionId) + const finishImport = useCallback (() => { + allowNextNavigation () clearPostImportSourceDraft (message => toast ({ title: '入力内容を削除できませんでした', description: message })) navigate ('/posts') - } + }, [allowNextNavigation, navigate]) useEffect (() => { + const urls = parsePostNewReviewUrls (location.search) + const initialRows = buildInitialRows (urls) + commitRows (initialRows) + let active = true - const previewSequence = ++previewSequenceRef.current - metadataSequenceRef.current = previewSequence + const sequence = ++metadataSequenceRef.current const controllers = new Set () + let nextIndex = 0 + const targetSourceRows = initialRows + .filter (row => shouldFetchReviewRow (row)) + .map (row => row.sourceRow) - if (sessionRef.current != null - && persistedSearchRef.current === location.search - && !(sessionRef.current.rows.some (row => shouldFetchReviewRow (row)))) - return () => { - previewSequenceRef.current = previewSequence - } + setMetadataLoading (targetSourceRows.length > 0) - const refreshRows = async (baseSession: PostImportSession) => { - const blocksSubmit = baseSession.rows.some (row => shouldFetchMetadata (row)) - if (active && blocksSubmit) - setMetadataLoading (true) - try - { - let nextIndex = 0 - const mergeCurrentRow = ( - sourceRow: number, - updater: (row: PostImportRow) => PostImportRow, - ) => { - const latestRows = sessionRef.current?.rows ?? baseSession.rows - const nextRows = applyDuplicateUrlErrors (latestRows.map (row => - row.sourceRow === sourceRow - ? updater (row) - : row)) - commitSessionState (buildSession (nextRows)) - } - const worker = async () => { - while (active) - { - const index = nextIndex - ++nextIndex - if (index >= baseSession.rows.length) - return - - const baseRow = baseSession.rows[index] - const currentRow = currentRowBySource (baseRow.sourceRow, baseSession) - if (currentRow == null || !(shouldFetchReviewRow (currentRow))) - continue - const controller = new AbortController () - controllers.add (controller) - try - { - const preview = await apiGet ('/posts/metadata', { - params: { url: currentRow.url }, - signal: controller.signal }) - if (!(active) || previewSequenceRef.current !== previewSequence) - return - mergeCurrentRow (currentRow.sourceRow, row => - row.importStatus === 'created' - || row.importStatus === 'skipped' - || isNonRecoverableFailedRow (row) - || row.skipReason === 'manual' - ? row - : (shouldFetchMetadata (row) - ? mergePreviewRow (row, preview) - : mergeExistingSkippedRow (row, preview))) - } - catch (requestError) - { - if (controller.signal.aborted) - return - if (!(active) || previewSequenceRef.current !== previewSequence) - return - if (!(isApiError<{ - errors?: Record - baseErrors?: string[] - }> (requestError))) - { - mergeCurrentRow (currentRow.sourceRow, row => ({ - ...row, - status: 'error' })) - continue - } - if (requestError.response?.status === 422) - { - const rowErrors = compactMessageRecord ({ - ...(requestError.response.data.errors ?? { }), - ...(requestError.response.data.baseErrors?.length - ? { - base: requestError.response.data.baseErrors } - : { }) }) - mergeCurrentRow (currentRow.sourceRow, row => ({ - ...row, - validationErrors: rowErrors, - status: 'error' })) - } - else - mergeCurrentRow (currentRow.sourceRow, row => ({ - ...row, - status: 'error' })) - } - finally - { - controllers.delete (controller) - } - } - } - - await Promise.all ( - Array.from ({ length: Math.min (4, baseSession.rows.length) }, () => worker ())) - if (!(active) || previewSequenceRef.current !== previewSequence) - return - - const latestRows = sessionRef.current?.rows ?? baseSession.rows - const nextRows = applyDuplicateUrlErrors (latestRows) - await syncSessionUrl (buildSession (nextRows)) - } - finally - { - if (active - && blocksSubmit - && metadataSequenceRef.current === previewSequence) - setMetadataLoading (false) - } + const mergeCurrentRow = ( + sourceRow: number, + updater: (row: PostImportRow) => PostImportRow, + ) => { + updateRows (currentRows => + currentRows.map (row => + row.sourceRow === sourceRow + ? updater (row) + : row)) } - const hydrate = async () => { - const parsedSessionId = parsePostNewSessionId (location.search) - const sessionId = parsedSessionId ?? generatePostImportSessionId () - const loadedSession = loadPostImportSession (sessionId) - const parsed = await parsePostNewState (location.search) - if (!(active)) - return - if (parsed == null && loadedSession == null) + const worker = async () => { + while (active) { - setMetadataLoading (false) - navigate ('/posts/new', { replace: true }) - return + const sourceRow = targetSourceRows[nextIndex] + ++nextIndex + if (sourceRow == null) + return + + const currentRow = currentRowBySource (sourceRow) + if (currentRow == null || !(shouldFetchReviewRow (currentRow))) + continue + + const controller = new AbortController () + controllers.add (controller) + + try + { + const preview = await apiGet ('/posts/metadata', { + params: { url: currentRow.url }, + signal: controller.signal }) + if (!(active) || metadataSequenceRef.current !== sequence) + return + + mergeCurrentRow (sourceRow, row => + row.importStatus === 'created' + || row.importStatus === 'skipped' + || isNonRecoverableFailedRow (row) + || row.skipReason === 'manual' + ? row + : (shouldFetchMetadata (row) + ? mergePreviewRow (row, preview) + : mergeExistingSkippedRow (row, preview))) + } + catch (requestError) + { + if (controller.signal.aborted) + return + if (!(active) || metadataSequenceRef.current !== sequence) + return + + if (!(isApiError<{ + errors?: Record + baseErrors?: string[] + }> (requestError))) + { + mergeCurrentRow (sourceRow, row => ({ + ...row, + status: 'error' })) + continue + } + + if (requestError.response?.status === 422) + { + const rowErrors = compactMessageRecord ({ + ...(requestError.response.data.errors ?? { }), + ...(requestError.response.data.baseErrors?.length + ? { + base: requestError.response.data.baseErrors } + : { }) }) + mergeCurrentRow (sourceRow, row => ({ + ...row, + validationErrors: rowErrors, + status: 'error' })) + } + else + { + mergeCurrentRow (sourceRow, row => ({ + ...row, + status: 'error' })) + } + } + finally + { + controllers.delete (controller) + } } - - sessionIdRef.current = sessionId - const loaded = loadedSession ?? buildSession (parsed?.rows ?? []) - persistedSearchRef.current = location.search - sessionRef.current = loaded - setSession (loaded) - - void refreshRows (loaded) } - void hydrate () + void Promise.all ( + Array.from ( + { length: Math.min (4, targetSourceRows.length) }, + () => worker (), + )).finally (() => { + if (active && metadataSequenceRef.current === sequence) + setMetadataLoading (false) + }) return () => { active = false controllers.forEach (controller => controller.abort ()) } - }, [location.search, navigate]) + }, [commitRows, currentRowBySource, location.search, updateRows]) useEffect (() => { - if (session != null) - sessionRef.current = session - }, [session]) - - useEffect (() => { - if (editingRow == null || session?.repairMode !== 'failed') + if (editingRow == null || resultRepairMode (rowsRef.current) !== 'failed') return const element = document.getElementById (`post-import-row-${ editingRow.sourceRow }`) element?.scrollIntoView ({ block: 'center', behavior: 'smooth' }) - }, [editingRow, session?.repairMode]) + }, [editingRow, rows]) - const rows = session?.rows ?? [] - const counts = useMemo (() => reviewSummaryCounts (rows), [rows]) + const reviewRowsState = rows ?? [] + const counts = useMemo ( + () => reviewSummaryCounts (reviewRowsState), + [reviewRowsState], + ) const sortedRows = - session?.repairMode === 'failed' - ? ( - [...rows].sort ((a, b) => { - const aRepair = isRepairRow (a) ? 0 : 1 - const bRepair = isRepairRow (b) ? 0 : 1 - return aRepair - bRepair || a.sourceRow - b.sourceRow - })) - : rows + resultRepairMode (reviewRowsState) === 'failed' + ? [...reviewRowsState].sort ((a, b) => { + const aRepair = + a.recoverable === true + && (a.importStatus === 'failed' + || (a.importStatus === 'pending' + && hasErrorMessages (a.validationErrors))) + ? 0 + : 1 + const bRepair = + b.recoverable === true + && (b.importStatus === 'failed' + || (b.importStatus === 'pending' + && hasErrorMessages (b.validationErrors))) + ? 0 + : 1 + return aRepair - bRepair || a.sourceRow - b.sourceRow + }) + : reviewRowsState const existingRows = sortedRows.filter (row => isExistingSkipRow (row)) const reviewRows = sortedRows.filter (row => !(isExistingSkipRow (row))) - const processingRows = processableImportRows (rows) + const processingRows = processableImportRows (reviewRowsState) const canSubmit = processingRows.length > 0 const toggleManualSkip = async (sourceRow: number, checked: boolean) => { - const currentSession = sessionRef.current - if (currentSession == null) - return - const currentRow = currentRowBySource (sourceRow, currentSession) + const currentRow = currentRowBySource (sourceRow) if (currentRow == null) return if (rowSkipBusy (currentRow, submitting, busyRowIds)) return - const nextRows = currentSession.rows.map (row => { - if (row.sourceRow !== sourceRow) - return row - if (isExistingSkipRow (row) - || row.importStatus === 'created') - return row - return { - ...row, - skipReason: - checked - ? 'manual' - : row.existingPostId != null - ? 'existing' - : undefined } - }) - const nextSession = buildSession (applyDuplicateUrlErrors (nextRows)) - if (metadataLoading) - { - commitSessionState (nextSession) - return - } - await syncSessionUrl (nextSession) + updateRows (currentRows => + currentRows.map (row => { + if (row.sourceRow !== sourceRow) + return row + if (isExistingSkipRow (row) || row.importStatus === 'created') + return row + return { + ...row, + skipReason: + checked + ? 'manual' + : row.existingPostId != null + ? 'existing' + : undefined } + })) } const saveDraft = async ( row: PostImportRow, { draft, resetRequested }: { - draft: PostImportRowDraft + draft: PostImportEditableDraft resetRequested: boolean }, ): Promise<{ saved: boolean row: PostImportRow | null }> => { - const currentSession = sessionRef.current - if (currentSession == null) - return { saved: false, row: null } - const currentRow = currentRowBySource (row.sourceRow, currentSession) + const currentRow = currentRowBySource (row.sourceRow) if (currentRow == null) return { saved: false, row: null } if (!(canEditReviewRow (currentRow))) @@ -881,22 +856,14 @@ const PostImportReviewPage: FC = ({ user }) => { const baseRow = resetRequested - ? { ...currentRow, + ? { + ...currentRow, url: currentRow.resetSnapshot.url, attributes: { ...currentRow.resetSnapshot.attributes }, - displayTags: - currentRow.resetSnapshot.displayTags.map (tag => ({ - name: tag.name, - category: tag.category, - sectionLiterals: - tag.sectionLiterals == null - ? undefined - : [...tag.sectionLiterals] })), + displayTags: cloneDisplayTags (currentRow.resetSnapshot.displayTags), provenance: { ...currentRow.resetSnapshot.provenance }, tagSources: { ...currentRow.resetSnapshot.tagSources }, - fieldWarnings: Object.fromEntries ( - Object.entries (currentRow.resetSnapshot.fieldWarnings) - .map (([key, values]) => [key, [...values]])), + fieldWarnings: cloneMessageRecord (currentRow.resetSnapshot.fieldWarnings), baseWarnings: [...currentRow.resetSnapshot.baseWarnings], metadataUrl: currentRow.resetSnapshot.metadataUrl, thumbnailFile: undefined } @@ -904,6 +871,7 @@ const PostImportReviewPage: FC = ({ user }) => { const urlChanged = draft.url !== baseRow.url const nextRow = buildNextEditedRow (baseRow, draft, urlChanged) let candidateRow = nextRow + try { candidateRow = @@ -916,21 +884,11 @@ const PostImportReviewPage: FC = ({ user }) => { const dryRun = await apiPost ( '/posts?dry=1', buildDryRunFormData (candidateRow)) - const latestSession = sessionRef.current - if (latestSession == null) - return { saved: false, row: null } - const mergedRow = mergeDryRunRow (candidateRow, dryRun) - const mergedRows = applyDuplicateUrlErrors ( - replaceImportRow (latestSession.rows, mergedRow)) - const nextSession = - metadataLoading - ? commitSessionState (buildSession (mergedRows)).session - : await syncSessionUrl (buildSession (mergedRows)) - if (nextSession == null) - return { saved: false, row: null } - const mergedTarget = nextSession.rows.find ( - currentRow => currentRow.sourceRow === baseRow.sourceRow) + const mergedRows = updateRows (currentRows => + replaceImportRow (currentRows, mergedRow)) + const mergedTarget = mergedRows.find ( + current => current.sourceRow === baseRow.sourceRow) if (mergedTarget == null) return { saved: false, row: null } if (hasErrorMessages (mergedTarget.validationErrors)) @@ -940,8 +898,8 @@ const PostImportReviewPage: FC = ({ user }) => { catch (requestError) { if (isApiError<{ - errors?: Record - baseErrors?: string[] + errors?: Record + baseErrors?: string[] }> (requestError) && requestError.response?.status === 422) { @@ -967,7 +925,7 @@ const PostImportReviewPage: FC = ({ user }) => { const openEditingDialogue = async (row: PostImportRow) => { const saveRowDraft = ( { draft, resetRequested }: { - draft: PostImportRowDraft + draft: PostImportEditableDraft resetRequested: boolean }, ) => saveDraft (row, { draft, resetRequested }) @@ -984,8 +942,7 @@ const PostImportReviewPage: FC = ({ user }) => { } const editRow = async (row: PostImportRow) => { - const currentRow = sessionRef.current?.rows.find ( - current => current.sourceRow === row.sourceRow) + const currentRow = currentRowBySource (row.sourceRow) if (currentRow == null) return if (!(canEditReviewRow (currentRow))) @@ -1008,11 +965,7 @@ const PostImportReviewPage: FC = ({ user }) => { } const retry = async (sourceRow: number) => { - const initialSession = sessionRef.current - if (initialSession == null) - return - - const originalRow = initialSession.rows.find (row => row.sourceRow === sourceRow) + const originalRow = currentRowBySource (sourceRow) if (originalRow == null) return if (rowOperationBusy (originalRow, submitting, busyRowIds)) @@ -1021,15 +974,8 @@ const PostImportReviewPage: FC = ({ user }) => { setBusyRowIds (current => new Set ([...current, sourceRow])) try { - const pendingRows = retryImportRow (initialSession.rows, sourceRow) - const pendingSession = - metadataLoading - ? commitSessionState (buildSession (pendingRows)).session - : await syncSessionUrl (buildSession (pendingRows)) - if (pendingSession == null) - return - - const target = pendingSession.rows.find (row => row.sourceRow === sourceRow) + updateRows (currentRows => retryImportRow (currentRows, sourceRow)) + const target = currentRowBySource (sourceRow) if (target == null) return @@ -1038,37 +984,22 @@ const PostImportReviewPage: FC = ({ user }) => { buildBulkFormData ([target])) if (result.results.length !== 1) { - const latest = sessionRef.current ?? pendingSession - const restoredRows = replaceImportRow (latest.rows, originalRow) - if (metadataLoading) - commitSessionState (buildSession (restoredRows)) - else - await syncSessionUrl (buildSession (restoredRows)) + updateRows (currentRows => replaceImportRow (currentRows, originalRow)) toast ({ title: '登録結果が不完全でした' }) return } const indexedResults = indexedBulkResults ([target], result.results) - const latestAfterImport = sessionRef.current ?? pendingSession const nextRows = applyDuplicateUrlErrors ( - mergeImportResults (latestAfterImport.rows, indexedResults)) - .map (row => applyThumbnailWarning (row)) - const persisted = - metadataLoading - ? commitSessionState (buildSession (nextRows)).session - : await syncSessionUrl (buildSession (nextRows)) - if (persisted != null - && persisted.rows.every (row => isCompletedReviewRow (row))) + mergeImportResults (rowsRef.current, indexedResults)) + .map (row => applyThumbnailWarning (row)) + commitRows (nextRows) + if (nextRows.every (row => isCompletedReviewRow (row))) finishImport () } catch { - const latest = sessionRef.current ?? initialSession - const restoredRows = replaceImportRow (latest.rows, originalRow) - if (metadataLoading) - commitSessionState (buildSession (restoredRows)) - else - await syncSessionUrl (buildSession (restoredRows)) + updateRows (currentRows => replaceImportRow (currentRows, originalRow)) toast ({ title: '再試行に失敗しました' }) } finally @@ -1082,14 +1013,11 @@ const PostImportReviewPage: FC = ({ user }) => { } const submit = async () => { - const currentSession = sessionRef.current - if (currentSession == null - || metadataLoading - || submitting - || busyRowIds.size > 0) + if (metadataLoading || submitting || busyRowIds.size > 0) return - const processingRows = processableImportRows (currentSession.rows) - if (processingRows.length === 0) + + const latestProcessingRows = processableImportRows (rowsRef.current) + if (latestProcessingRows.length === 0) return setSubmitting (true) @@ -1097,21 +1025,19 @@ const PostImportReviewPage: FC = ({ user }) => { { const result = await apiPost<{ results: BulkApiRow[] }>( '/posts/bulk', - buildBulkFormData (processingRows)) - if (result.results.length !== processingRows.length) + buildBulkFormData (latestProcessingRows)) + if (result.results.length !== latestProcessingRows.length) { toast ({ title: '登録結果が不完全でした' }) return } - const indexedResults = indexedBulkResults (processingRows, result.results) - const latestAfterImport = sessionRef.current ?? buildSession (currentSession.rows) + const indexedResults = indexedBulkResults (latestProcessingRows, result.results) const nextRows = applyDuplicateUrlErrors ( - mergeImportResults (latestAfterImport.rows, indexedResults)) - .map (row => applyThumbnailWarning (row)) - const persisted = await syncSessionUrl (buildSession (nextRows)) - if (persisted != null - && persisted.rows.every (row => isCompletedReviewRow (row))) + mergeImportResults (rowsRef.current, indexedResults)) + .map (row => applyThumbnailWarning (row)) + commitRows (nextRows) + if (nextRows.every (row => isCompletedReviewRow (row))) finishImport () } catch @@ -1127,7 +1053,7 @@ const PostImportReviewPage: FC = ({ user }) => { if (!(editable)) return - if (session == null) + if (rows == null) return null return ( @@ -1136,11 +1062,11 @@ const PostImportReviewPage: FC = ({ user }) => { {`追加内容確認 | ${ SITE_TITLE }`} - -
+ +
追加内容確認 - {counts.existingSkipped > 0 && ( + {existingRows.length > 0 && (