広場投稿追加画面の刷新 (#399) #413
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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<string, unknown> =>
|
||||
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<string, string[]> | null => {
|
||||
if (!(isPlainObject (value)))
|
||||
return null
|
||||
|
||||
const result: Record<string, string[]> = { }
|
||||
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<string, string[]>): boolean =>
|
||||
Object.values (value).some (messages => messages.length > 0)
|
||||
|
||||
const hasOnlyKeys = (
|
||||
value: Record<string, unknown>,
|
||||
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<string, string | number>,
|
||||
displayTags,
|
||||
provenance: value.provenance as Record<string, PostImportOrigin>,
|
||||
tagSources: value.tagSources as Record<PostImportOrigin, string>,
|
||||
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<string, string | number>,
|
||||
fieldWarnings: isNonEmptyMessageRecord (fieldWarnings) ? fieldWarnings : { },
|
||||
baseWarnings: value.baseWarnings,
|
||||
validationErrors: isNonEmptyMessageRecord (validationErrors) ? validationErrors : { },
|
||||
importErrors:
|
||||
importErrors != null && isNonEmptyMessageRecord (importErrors)
|
||||
? importErrors
|
||||
: undefined,
|
||||
provenance: value.provenance as Record<string, PostImportOrigin>,
|
||||
displayTags,
|
||||
tagSources: value.tagSources as Record<PostImportOrigin, string> | 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<PostImportSession, 'version' | 'expiresAt'>,
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -79,13 +79,6 @@ export type PostImportResultRow =
|
||||
errors?: Record<string, string[]>
|
||||
recoverable?: boolean }
|
||||
|
||||
export type PostImportSession = {
|
||||
version: number
|
||||
expiresAt: string
|
||||
source: string
|
||||
rows: PostImportRow[]
|
||||
repairMode: PostImportRepairMode }
|
||||
|
||||
export type PostImportSourceIssue = {
|
||||
sourceRow: number
|
||||
message: string
|
||||
|
||||
+38
-1095
ファイル差分が大きすぎるため省略します
差分を読込み
@@ -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<void> }
|
||||
|
||||
type UseUnsavedChangesGuardOptions = {
|
||||
dirty: boolean
|
||||
onDiscard?: () => void | Promise<void> }
|
||||
|
||||
type UnsavedChangesGuardContextValue = {
|
||||
hasUnsavedChanges: boolean
|
||||
registerUnsavedChangesSource: (
|
||||
source: UnsavedChangesSource | null,
|
||||
) => void
|
||||
confirmDiscardNavigation: () => Promise<boolean> }
|
||||
confirmDiscardNavigation: () => Promise<boolean>
|
||||
allowNextNavigation: () => void }
|
||||
|
||||
const UnsavedChangesGuardContext =
|
||||
createContext<UnsavedChangesGuardContextValue | null> (null)
|
||||
@@ -21,7 +35,9 @@ const UnsavedChangesGuardContext =
|
||||
|
||||
export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
const dialogue = useDialogue ()
|
||||
const location = useLocation ()
|
||||
const [source, setSource] = useState<UnsavedChangesSource | null> (null)
|
||||
const bypassNextNavigationRef = useRef (false)
|
||||
|
||||
const registerUnsavedChangesSource = useCallback ((
|
||||
nextSource: UnsavedChangesSource | null,
|
||||
@@ -29,28 +45,81 @@ export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children })
|
||||
setSource (nextSource)
|
||||
}, [])
|
||||
|
||||
const allowNextNavigation = useCallback (() => {
|
||||
bypassNextNavigationRef.current = true
|
||||
}, [])
|
||||
|
||||
const confirmDiscardNavigation = useCallback (async (): Promise<boolean> => {
|
||||
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<UnsavedChangesGuardContextValue> (() => ({
|
||||
hasUnsavedChanges: source?.dirty === true,
|
||||
registerUnsavedChangesSource,
|
||||
confirmDiscardNavigation,
|
||||
}), [confirmDiscardNavigation, registerUnsavedChangesSource, source?.dirty])
|
||||
allowNextNavigation,
|
||||
}), [
|
||||
allowNextNavigation,
|
||||
confirmDiscardNavigation,
|
||||
registerUnsavedChangesSource,
|
||||
source?.dirty,
|
||||
])
|
||||
|
||||
return (
|
||||
<UnsavedChangesGuardContext.Provider value={value}>
|
||||
@@ -59,11 +128,26 @@ export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ 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
|
||||
}
|
||||
|
||||
ファイル差分が大きすぎるため省略します
差分を読込み
@@ -12,26 +12,23 @@ import { Button } from '@/components/ui/button'
|
||||
import { toast } from '@/components/ui/use-toast'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import {
|
||||
appendPostNewSessionId,
|
||||
serialisePostNewState,
|
||||
buildPostNewReviewPath,
|
||||
isPostNewReviewPathWithinLimit,
|
||||
} from '@/lib/postNewQueryState'
|
||||
import {
|
||||
countImportSourceLines,
|
||||
extractImportSourceUrls,
|
||||
validateImportSource,
|
||||
} from '@/lib/postImportSourceValidation'
|
||||
import {
|
||||
loadPostImportSourceDraft,
|
||||
savePostImportSourceDraft,
|
||||
} from '@/lib/postImportStorage'
|
||||
import { canEditContent } from '@/lib/users'
|
||||
import { countImportSourceLines,
|
||||
generatePostImportSessionId,
|
||||
cleanupExpiredPostImportSessions,
|
||||
initialisePreviewRows,
|
||||
loadPostImportSession,
|
||||
loadPostImportSourceDraft,
|
||||
resultRepairMode,
|
||||
serialisedPostImportRowsEqual,
|
||||
savePostImportSession,
|
||||
savePostImportSourceDraft,
|
||||
validateImportSource } from '@/lib/postImportSession'
|
||||
import Forbidden from '@/pages/Forbidden'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { PostImportRow } from '@/lib/postImportSession'
|
||||
import type { User } from '@/types'
|
||||
|
||||
type Props = { user: User | null }
|
||||
@@ -40,71 +37,6 @@ const MAX_ROWS = 100
|
||||
const SOURCE_ERROR_ID = 'post-import-source-error'
|
||||
const SOURCE_ISSUES_ID = 'post-import-source-issues'
|
||||
|
||||
const buildInitialRows = (source: string): PostImportRow[] =>
|
||||
source
|
||||
.split (/\r\n|\n|\r/)
|
||||
.map ((line, index) => ({
|
||||
sourceRow: index + 1,
|
||||
url: line.trim () }))
|
||||
.filter (row => row.url !== '')
|
||||
.map (row => ({
|
||||
sourceRow: row.sourceRow,
|
||||
url: row.url,
|
||||
attributes: {
|
||||
title: '',
|
||||
thumbnailBase: '',
|
||||
originalCreatedFrom: '',
|
||||
originalCreatedBefore: '',
|
||||
duration: '',
|
||||
videoMs: '',
|
||||
tags: '',
|
||||
parentPostIds: '' },
|
||||
fieldWarnings: { },
|
||||
baseWarnings: [],
|
||||
validationErrors: { },
|
||||
provenance: {
|
||||
url: 'manual',
|
||||
title: 'automatic',
|
||||
thumbnailBase: 'automatic',
|
||||
originalCreatedFrom: 'automatic',
|
||||
originalCreatedBefore: 'automatic',
|
||||
duration: 'automatic',
|
||||
videoMs: 'automatic',
|
||||
tags: 'automatic',
|
||||
parentPostIds: 'automatic' },
|
||||
tagSources: {
|
||||
automatic: '',
|
||||
manual: '' },
|
||||
status: 'pending' as const,
|
||||
displayTags: [],
|
||||
resetSnapshot: {
|
||||
url: row.url,
|
||||
attributes: {
|
||||
title: '',
|
||||
thumbnailBase: '',
|
||||
originalCreatedFrom: '',
|
||||
originalCreatedBefore: '',
|
||||
duration: '',
|
||||
videoMs: '',
|
||||
tags: '',
|
||||
parentPostIds: '' },
|
||||
provenance: {
|
||||
url: 'manual',
|
||||
title: 'automatic',
|
||||
thumbnailBase: 'automatic',
|
||||
originalCreatedFrom: 'automatic',
|
||||
originalCreatedBefore: 'automatic',
|
||||
duration: 'automatic',
|
||||
videoMs: 'automatic',
|
||||
tags: 'automatic',
|
||||
parentPostIds: 'automatic' },
|
||||
tagSources: {
|
||||
automatic: '',
|
||||
manual: '' },
|
||||
displayTags: [],
|
||||
fieldWarnings: { },
|
||||
baseWarnings: [] } }))
|
||||
|
||||
|
||||
const PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||
const editable = canEditContent (user)
|
||||
@@ -117,6 +49,8 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||
const editedRef = useRef (false)
|
||||
|
||||
const lineCount = countImportSourceLines (source)
|
||||
const sourceUrls = extractImportSourceUrls (source)
|
||||
const withinPathLimit = isPostNewReviewPathWithinLimit (sourceUrls)
|
||||
const messages = sourceError != null ? [sourceError] : []
|
||||
const sourceDescribedBy =
|
||||
[sourceError != null ? SOURCE_ERROR_ID : null,
|
||||
@@ -124,13 +58,7 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||
.filter (value => value != null)
|
||||
.join (' ')
|
||||
|
||||
const showStorageError = (message: string) =>
|
||||
toast ({ description: message })
|
||||
|
||||
useEffect (() => {
|
||||
cleanupExpiredPostImportSessions (message =>
|
||||
toast ({ title: '保存済みデータを整理できませんでした', description: message }))
|
||||
|
||||
const draft = loadPostImportSourceDraft (message =>
|
||||
toast ({ title: '保存済み入力を復元できませんでした', description: message }))
|
||||
|
||||
@@ -173,73 +101,15 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||
setSourceError (null)
|
||||
return
|
||||
}
|
||||
if (!(withinPathLimit))
|
||||
return
|
||||
|
||||
setLoading (true)
|
||||
setSourceIssues ([])
|
||||
setSourceError (null)
|
||||
try
|
||||
{
|
||||
const nextRows = initialisePreviewRows (buildInitialRows (source))
|
||||
const serialised = await serialisePostNewState (nextRows)
|
||||
const sessionId = (() => {
|
||||
try
|
||||
{
|
||||
return generatePostImportSessionId ()
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null
|
||||
}
|
||||
}) ()
|
||||
const session = {
|
||||
source,
|
||||
rows: nextRows,
|
||||
repairMode: resultRepairMode (nextRows) }
|
||||
const saved =
|
||||
sessionId == null
|
||||
? false
|
||||
: savePostImportSession (sessionId, session, showStorageError)
|
||||
const loadedSession =
|
||||
sessionId == null
|
||||
? null
|
||||
: loadPostImportSession (sessionId, showStorageError)
|
||||
const sessionRoundTripSucceeded =
|
||||
saved
|
||||
&& serialisedPostImportRowsEqual (
|
||||
loadedSession?.rows ?? [],
|
||||
nextRows)
|
||||
const canNavigate =
|
||||
serialised?.complete === true
|
||||
|| sessionRoundTripSucceeded
|
||||
if (!(canNavigate))
|
||||
{
|
||||
showStorageError ('ブラウザへ保存できませんでした.')
|
||||
return
|
||||
}
|
||||
|
||||
const nextPath = (() => {
|
||||
if (serialised != null)
|
||||
{
|
||||
return sessionId == null
|
||||
? serialised.path
|
||||
: appendPostNewSessionId (serialised.path, sessionId)
|
||||
}
|
||||
if (sessionRoundTripSucceeded && sessionId != null)
|
||||
return `/posts/new?session_id=${ sessionId }`
|
||||
return null
|
||||
}) ()
|
||||
if (nextPath == null)
|
||||
{
|
||||
showStorageError ('ブラウザへ保存できませんでした.')
|
||||
return
|
||||
}
|
||||
|
||||
navigate (nextPath)
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error (error)
|
||||
showStorageError ('ブラウザへ保存できませんでした.')
|
||||
navigate (buildPostNewReviewPath (sourceUrls))
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -268,8 +138,9 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||
className="h-80 font-mono text-sm"
|
||||
onBlur={() => {
|
||||
savePostImportSourceDraft (source, message =>
|
||||
toast ({ title: '入力内容を保存できませんでした',
|
||||
description: message }))
|
||||
toast ({
|
||||
title: '入力内容を保存できませんでした',
|
||||
description: message }))
|
||||
}}
|
||||
onChange={ev => {
|
||||
editedRef.current = true
|
||||
@@ -299,7 +170,7 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||
type="button"
|
||||
className="pointer-events-auto shrink-0"
|
||||
onClick={preview}
|
||||
disabled={loading}>
|
||||
disabled={loading || !(withinPathLimit)}>
|
||||
次へ
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
新しい課題から参照
ユーザをブロックする