このコミットが含まれているのは:
@@ -12,23 +12,14 @@ const bytesize = (value: string): number =>
|
|||||||
new TextEncoder ().encode (value).length
|
new TextEncoder ().encode (value).length
|
||||||
|
|
||||||
|
|
||||||
const normaliseImportUrl = (value: string): string | null => {
|
const parseImportUrl = (value: string): URL | null => {
|
||||||
const trimmed = value.trim ()
|
const trimmed = value.trim ()
|
||||||
if (!(trimmed))
|
if (!(trimmed))
|
||||||
return null
|
return null
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const url = new URL (trimmed)
|
return 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 ()
|
|
||||||
}
|
}
|
||||||
catch
|
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
|
source
|
||||||
.split (/\r\n|\n|\r/)
|
.split (/\r\n|\n|\r/)
|
||||||
.map (line => line.trim ())
|
.map (line => line.trim ())
|
||||||
.filter (line => line !== '')
|
.filter (line => line !== '')
|
||||||
.length
|
|
||||||
|
|
||||||
|
export const countImportSourceLines = (source: string): number =>
|
||||||
|
extractImportSourceUrls (source).length
|
||||||
|
|
||||||
|
|
||||||
export const validateImportSource = (
|
export const validateImportSource = (
|
||||||
@@ -61,7 +63,6 @@ export const validateImportSource = (
|
|||||||
++count
|
++count
|
||||||
const sourceRow = index + 1
|
const sourceRow = index + 1
|
||||||
const displayUrl = truncateUrl (value)
|
const displayUrl = truncateUrl (value)
|
||||||
const normalised = normaliseImportUrl (value)
|
|
||||||
if (count > MAX_ROWS)
|
if (count > MAX_ROWS)
|
||||||
{
|
{
|
||||||
issues.push ({
|
issues.push ({
|
||||||
@@ -70,14 +71,6 @@ export const validateImportSource = (
|
|||||||
url: displayUrl })
|
url: displayUrl })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!(value.startsWith ('http://') || value.startsWith ('https://')))
|
|
||||||
{
|
|
||||||
issues.push ({
|
|
||||||
sourceRow,
|
|
||||||
message: 'HTTP または HTTPS の URL ではありません.',
|
|
||||||
url: displayUrl })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (bytesize (value) > MAX_URL_BYTES)
|
if (bytesize (value) > MAX_URL_BYTES)
|
||||||
{
|
{
|
||||||
issues.push ({
|
issues.push ({
|
||||||
@@ -86,7 +79,8 @@ export const validateImportSource = (
|
|||||||
url: displayUrl })
|
url: displayUrl })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (normalised == null)
|
const parsed = parseImportUrl (value)
|
||||||
|
if (parsed == null)
|
||||||
{
|
{
|
||||||
issues.push ({
|
issues.push ({
|
||||||
sourceRow,
|
sourceRow,
|
||||||
@@ -94,6 +88,23 @@ export const validateImportSource = (
|
|||||||
url: displayUrl })
|
url: displayUrl })
|
||||||
return
|
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)
|
const duplicateRow = seen.get (normalised)
|
||||||
if (duplicateRow != null)
|
if (duplicateRow != null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,107 +1,6 @@
|
|||||||
import type { PostImportOrigin,
|
import type { StorageErrorHandler } from '@/lib/postImportTypes'
|
||||||
PostImportDisplayTag,
|
|
||||||
PostImportExistingPost,
|
|
||||||
PostImportResetSnapshot,
|
|
||||||
PostImportRow,
|
|
||||||
PostImportSession,
|
|
||||||
PostImportStatus,
|
|
||||||
PostImportSkipReason,
|
|
||||||
StorageErrorHandler } from '@/lib/postImportTypes'
|
|
||||||
|
|
||||||
const SESSION_VERSION = 3
|
|
||||||
const SESSION_PREFIX = 'post-import-session:'
|
|
||||||
const SOURCE_DRAFT_KEY = 'post-import-source-draft'
|
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 = (
|
const readStorage = (
|
||||||
@@ -162,531 +61,6 @@ const removeStorage = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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?.('保存済みデータを整理できませんでした.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const loadPostImportSourceDraft = (
|
export const loadPostImportSourceDraft = (
|
||||||
onError?: StorageErrorHandler,
|
onError?: StorageErrorHandler,
|
||||||
): { source: string } => {
|
): { source: string } => {
|
||||||
@@ -718,71 +92,3 @@ export const clearPostImportSourceDraft = (
|
|||||||
) => {
|
) => {
|
||||||
removeStorage (SOURCE_DRAFT_KEY, onError)
|
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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -79,13 +79,6 @@ export type PostImportResultRow =
|
|||||||
errors?: Record<string, string[]>
|
errors?: Record<string, string[]>
|
||||||
recoverable?: boolean }
|
recoverable?: boolean }
|
||||||
|
|
||||||
export type PostImportSession = {
|
|
||||||
version: number
|
|
||||||
expiresAt: string
|
|
||||||
source: string
|
|
||||||
rows: PostImportRow[]
|
|
||||||
repairMode: PostImportRepairMode }
|
|
||||||
|
|
||||||
export type PostImportSourceIssue = {
|
export type PostImportSourceIssue = {
|
||||||
sourceRow: number
|
sourceRow: number
|
||||||
message: string
|
message: string
|
||||||
|
|||||||
+31
-1088
ファイル差分が大きすぎるため省略します
差分を読込み
@@ -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'
|
import type { FC, PropsWithChildren } from 'react'
|
||||||
|
|
||||||
@@ -8,12 +17,17 @@ type UnsavedChangesSource = {
|
|||||||
dirty: boolean
|
dirty: boolean
|
||||||
discard: () => void | Promise<void> }
|
discard: () => void | Promise<void> }
|
||||||
|
|
||||||
|
type UseUnsavedChangesGuardOptions = {
|
||||||
|
dirty: boolean
|
||||||
|
onDiscard?: () => void | Promise<void> }
|
||||||
|
|
||||||
type UnsavedChangesGuardContextValue = {
|
type UnsavedChangesGuardContextValue = {
|
||||||
hasUnsavedChanges: boolean
|
hasUnsavedChanges: boolean
|
||||||
registerUnsavedChangesSource: (
|
registerUnsavedChangesSource: (
|
||||||
source: UnsavedChangesSource | null,
|
source: UnsavedChangesSource | null,
|
||||||
) => void
|
) => void
|
||||||
confirmDiscardNavigation: () => Promise<boolean> }
|
confirmDiscardNavigation: () => Promise<boolean>
|
||||||
|
allowNextNavigation: () => void }
|
||||||
|
|
||||||
const UnsavedChangesGuardContext =
|
const UnsavedChangesGuardContext =
|
||||||
createContext<UnsavedChangesGuardContextValue | null> (null)
|
createContext<UnsavedChangesGuardContextValue | null> (null)
|
||||||
@@ -21,7 +35,9 @@ const UnsavedChangesGuardContext =
|
|||||||
|
|
||||||
export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children }) => {
|
export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||||
const dialogue = useDialogue ()
|
const dialogue = useDialogue ()
|
||||||
|
const location = useLocation ()
|
||||||
const [source, setSource] = useState<UnsavedChangesSource | null> (null)
|
const [source, setSource] = useState<UnsavedChangesSource | null> (null)
|
||||||
|
const bypassNextNavigationRef = useRef (false)
|
||||||
|
|
||||||
const registerUnsavedChangesSource = useCallback ((
|
const registerUnsavedChangesSource = useCallback ((
|
||||||
nextSource: UnsavedChangesSource | null,
|
nextSource: UnsavedChangesSource | null,
|
||||||
@@ -29,28 +45,81 @@ export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children })
|
|||||||
setSource (nextSource)
|
setSource (nextSource)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const allowNextNavigation = useCallback (() => {
|
||||||
|
bypassNextNavigationRef.current = true
|
||||||
|
}, [])
|
||||||
|
|
||||||
const confirmDiscardNavigation = useCallback (async (): Promise<boolean> => {
|
const confirmDiscardNavigation = useCallback (async (): Promise<boolean> => {
|
||||||
if (!(source?.dirty))
|
if (!(source?.dirty))
|
||||||
return true
|
return true
|
||||||
|
|
||||||
const confirmed = await dialogue.confirm ({
|
const confirmed = await dialogue.confirm ({
|
||||||
title: '未保存の変更があります',
|
title: '変更が破棄してページ移動しますか?',
|
||||||
description: 'このまま移動すると、保存していない変更は失われます。',
|
|
||||||
cancelText: 'このページに残る',
|
|
||||||
confirmText: '変更を破棄して移動',
|
confirmText: '変更を破棄して移動',
|
||||||
variant: 'danger' })
|
variant: 'danger' })
|
||||||
if (!(confirmed))
|
if (!(confirmed))
|
||||||
return false
|
return false
|
||||||
|
|
||||||
|
bypassNextNavigationRef.current = true
|
||||||
await source.discard ()
|
await source.discard ()
|
||||||
return true
|
return true
|
||||||
}, [dialogue, source])
|
}, [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> (() => ({
|
const value = useMemo<UnsavedChangesGuardContextValue> (() => ({
|
||||||
hasUnsavedChanges: source?.dirty === true,
|
hasUnsavedChanges: source?.dirty === true,
|
||||||
registerUnsavedChangesSource,
|
registerUnsavedChangesSource,
|
||||||
confirmDiscardNavigation,
|
confirmDiscardNavigation,
|
||||||
}), [confirmDiscardNavigation, registerUnsavedChangesSource, source?.dirty])
|
allowNextNavigation,
|
||||||
|
}), [
|
||||||
|
allowNextNavigation,
|
||||||
|
confirmDiscardNavigation,
|
||||||
|
registerUnsavedChangesSource,
|
||||||
|
source?.dirty,
|
||||||
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UnsavedChangesGuardContext.Provider value={value}>
|
<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)
|
const context = useContext (UnsavedChangesGuardContext)
|
||||||
|
|
||||||
if (context == null)
|
if (context == null)
|
||||||
throw new Error ('UnsavedChangesGuardProvider が必要です.')
|
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
|
return context
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { AnimatePresence, motion } from 'framer-motion'
|
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 { Helmet } from 'react-helmet-async'
|
||||||
import { ChevronRight } from 'lucide-react'
|
import { ChevronRight } from 'lucide-react'
|
||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
@@ -15,22 +15,16 @@ import { toast } from '@/components/ui/use-toast'
|
|||||||
import { SITE_TITLE } from '@/config'
|
import { SITE_TITLE } from '@/config'
|
||||||
import { apiGet, apiPost, isApiError } from '@/lib/api'
|
import { apiGet, apiPost, isApiError } from '@/lib/api'
|
||||||
import useDialogue from '@/lib/dialogues/useDialogue'
|
import useDialogue from '@/lib/dialogues/useDialogue'
|
||||||
import {
|
import { parsePostNewReviewUrls } from '@/lib/postNewQueryState'
|
||||||
appendPostNewSessionId,
|
import { validateImportSource } from '@/lib/postImportSourceValidation'
|
||||||
parsePostNewState,
|
|
||||||
parsePostNewSessionId,
|
|
||||||
serialisePostNewState,
|
|
||||||
} from '@/lib/postNewQueryState'
|
|
||||||
import {
|
import {
|
||||||
applyThumbnailWarning,
|
applyThumbnailWarning,
|
||||||
buildNextEditedRow,
|
buildNextEditedRow,
|
||||||
canEditReviewRow,
|
canEditReviewRow,
|
||||||
canRetryResultRow,
|
canRetryResultRow,
|
||||||
clearPostImportSession,
|
compactMessageRecord,
|
||||||
clearPostImportSourceDraft,
|
|
||||||
generatePostImportSessionId,
|
|
||||||
hasThumbnailBaseValue,
|
|
||||||
hasErrorMessages,
|
hasErrorMessages,
|
||||||
|
hasThumbnailBaseValue,
|
||||||
isCompletedReviewRow,
|
isCompletedReviewRow,
|
||||||
isExistingSkipRow,
|
isExistingSkipRow,
|
||||||
isManualSkipRow,
|
isManualSkipRow,
|
||||||
@@ -42,24 +36,23 @@ import {
|
|||||||
resultRowMessages,
|
resultRowMessages,
|
||||||
reviewSummaryCounts,
|
reviewSummaryCounts,
|
||||||
retryImportRow,
|
retryImportRow,
|
||||||
loadPostImportSession,
|
} from '@/lib/postImportRows'
|
||||||
compactMessageRecord,
|
import { clearPostImportSourceDraft } from '@/lib/postImportStorage'
|
||||||
serialisedPostImportRowsEqual,
|
|
||||||
savePostImportSession,
|
|
||||||
} from '@/lib/postImportSession'
|
|
||||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||||
|
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { canEditContent } from '@/lib/users'
|
import { canEditContent } from '@/lib/users'
|
||||||
import Forbidden from '@/pages/Forbidden'
|
import Forbidden from '@/pages/Forbidden'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
import type { PostImportRowDraft } from '@/components/posts/import/PostImportRowForm'
|
|
||||||
import type {
|
import type {
|
||||||
|
PostImportDisplayTag,
|
||||||
|
PostImportEditableDraft,
|
||||||
|
PostImportResetSnapshot,
|
||||||
PostImportResultRow,
|
PostImportResultRow,
|
||||||
PostImportRow,
|
PostImportRow,
|
||||||
PostImportSession,
|
} from '@/lib/postImportTypes'
|
||||||
} from '@/lib/postImportSession'
|
|
||||||
import type { User } from '@/types'
|
import type { User } from '@/types'
|
||||||
|
|
||||||
type Props = { user: User | null }
|
type Props = { user: User | null }
|
||||||
@@ -74,7 +67,7 @@ type PostMetadataResponse = {
|
|||||||
duration?: string
|
duration?: string
|
||||||
videoMs?: number
|
videoMs?: number
|
||||||
tags?: string
|
tags?: string
|
||||||
displayTags?: PostImportRow['displayTags']
|
displayTags?: PostImportDisplayTag[]
|
||||||
fieldWarnings?: Record<string, string[]>
|
fieldWarnings?: Record<string, string[]>
|
||||||
baseWarnings?: string[]
|
baseWarnings?: string[]
|
||||||
validationErrors?: Record<string, string[]>
|
validationErrors?: Record<string, string[]>
|
||||||
@@ -106,18 +99,101 @@ type ExistingSkippedRowsProps = {
|
|||||||
id: string
|
id: string
|
||||||
rows: PostImportRow[] }
|
rows: PostImportRow[] }
|
||||||
|
|
||||||
const isRepairRow = (row: PostImportRow): boolean =>
|
|
||||||
row.recoverable === true
|
|
||||||
&& (row.importStatus === 'failed'
|
|
||||||
|| (row.importStatus === 'pending'
|
|
||||||
&& hasErrorMessages (row.validationErrors)))
|
|
||||||
|
|
||||||
const DUPLICATE_URL_MESSAGE = 'URL が重複しています.'
|
const DUPLICATE_URL_MESSAGE = 'URL が重複しています.'
|
||||||
const BULK_PROCESSING_FAILED_MESSAGE = '登録中にエラーが発生しました.'
|
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<string, string[]>,
|
||||||
|
): Record<string, string[]> =>
|
||||||
|
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<Record<number, string[]>> ((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 =>
|
const rowMetadataPending = (row: PostImportRow): boolean =>
|
||||||
row.status === 'pending'
|
row.status === 'pending'
|
||||||
|
|
||||||
|
|
||||||
const rowOperationBusy = (
|
const rowOperationBusy = (
|
||||||
row: PostImportRow,
|
row: PostImportRow,
|
||||||
submitting: boolean,
|
submitting: boolean,
|
||||||
@@ -137,6 +213,7 @@ const rowSkipBusy = (
|
|||||||
|| busyRowIds.has (row.sourceRow)
|
|| busyRowIds.has (row.sourceRow)
|
||||||
|| (rowMetadataPending (row) && !(isManualSkipRow (row)))
|
|| (rowMetadataPending (row) && !(isManualSkipRow (row)))
|
||||||
|
|
||||||
|
|
||||||
const shouldFetchMetadata = (row: PostImportRow): boolean =>
|
const shouldFetchMetadata = (row: PostImportRow): boolean =>
|
||||||
row.status === 'pending'
|
row.status === 'pending'
|
||||||
&& row.skipReason !== 'manual'
|
&& row.skipReason !== 'manual'
|
||||||
@@ -145,6 +222,7 @@ const shouldFetchMetadata = (row: PostImportRow): boolean =>
|
|||||||
&& !(isNonRecoverableFailedRow (row))
|
&& !(isNonRecoverableFailedRow (row))
|
||||||
&& row.metadataUrl == null
|
&& row.metadataUrl == null
|
||||||
|
|
||||||
|
|
||||||
const shouldHydrateExistingPost = (row: PostImportRow): boolean =>
|
const shouldHydrateExistingPost = (row: PostImportRow): boolean =>
|
||||||
row.skipReason === 'existing'
|
row.skipReason === 'existing'
|
||||||
&& row.existingPostId != null
|
&& row.existingPostId != null
|
||||||
@@ -152,9 +230,11 @@ const shouldHydrateExistingPost = (row: PostImportRow): boolean =>
|
|||||||
&& row.importStatus !== 'created'
|
&& row.importStatus !== 'created'
|
||||||
&& row.importStatus !== 'skipped'
|
&& row.importStatus !== 'skipped'
|
||||||
|
|
||||||
|
|
||||||
const shouldFetchReviewRow = (row: PostImportRow): boolean =>
|
const shouldFetchReviewRow = (row: PostImportRow): boolean =>
|
||||||
shouldFetchMetadata (row) || shouldHydrateExistingPost (row)
|
shouldFetchMetadata (row) || shouldHydrateExistingPost (row)
|
||||||
|
|
||||||
|
|
||||||
const applyDuplicateUrlErrors = (
|
const applyDuplicateUrlErrors = (
|
||||||
rows: PostImportRow[],
|
rows: PostImportRow[],
|
||||||
): 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 buildDryRunFormData = (row: PostImportRow): FormData => {
|
||||||
const formData = new FormData ()
|
const formData = new FormData ()
|
||||||
formData.append ('url', row.url)
|
formData.append ('url', row.url)
|
||||||
@@ -238,6 +310,7 @@ const mergeDryRunRow = (
|
|||||||
const hasWarnings =
|
const hasWarnings =
|
||||||
Object.values (fieldWarnings).some (messages => messages.length > 0)
|
Object.values (fieldWarnings).some (messages => messages.length > 0)
|
||||||
|| (result.baseWarnings?.length ?? 0) > 0
|
|| (result.baseWarnings?.length ?? 0) > 0
|
||||||
|
|
||||||
return applyThumbnailWarning ({
|
return applyThumbnailWarning ({
|
||||||
...currentRow,
|
...currentRow,
|
||||||
url: result.url,
|
url: result.url,
|
||||||
@@ -250,23 +323,14 @@ const mergeDryRunRow = (
|
|||||||
duration: result.duration ?? '',
|
duration: result.duration ?? '',
|
||||||
videoMs: result.videoMs ?? '',
|
videoMs: result.videoMs ?? '',
|
||||||
tags: result.tags ?? '',
|
tags: result.tags ?? '',
|
||||||
parentPostIds: String (result.parentPostIds ?? currentRow.attributes.parentPostIds ?? '') },
|
parentPostIds: String (
|
||||||
displayTags:
|
result.parentPostIds ?? currentRow.attributes.parentPostIds ?? '') },
|
||||||
result.displayTags?.map (tag => ({
|
displayTags: cloneDisplayTags (result.displayTags),
|
||||||
name: tag.name,
|
|
||||||
category: tag.category,
|
|
||||||
sectionLiterals:
|
|
||||||
tag.sectionLiterals == null
|
|
||||||
? undefined
|
|
||||||
: [...tag.sectionLiterals] })) ?? [],
|
|
||||||
fieldWarnings,
|
fieldWarnings,
|
||||||
baseWarnings: result.baseWarnings ?? [],
|
baseWarnings: result.baseWarnings ?? [],
|
||||||
validationErrors: { },
|
validationErrors: { },
|
||||||
importErrors: undefined,
|
importErrors: undefined,
|
||||||
status:
|
status: hasWarnings ? 'warning' : 'ready',
|
||||||
hasWarnings
|
|
||||||
? 'warning'
|
|
||||||
: 'ready',
|
|
||||||
skipReason:
|
skipReason:
|
||||||
result.existingPostId != null || result.existingPost?.id != null
|
result.existingPostId != null || result.existingPost?.id != null
|
||||||
? 'existing'
|
? 'existing'
|
||||||
@@ -294,24 +358,8 @@ const buildPreviewResetSnapshot = (
|
|||||||
videoMs: preview.videoMs ?? '',
|
videoMs: preview.videoMs ?? '',
|
||||||
tags: preview.tags ?? '',
|
tags: preview.tags ?? '',
|
||||||
parentPostIds: String (preview.parentPostIds ?? '') },
|
parentPostIds: String (preview.parentPostIds ?? '') },
|
||||||
displayTags:
|
displayTags: cloneDisplayTags (preview.displayTags),
|
||||||
preview.displayTags?.map (tag => ({
|
provenance: emptyProvenance (),
|
||||||
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' },
|
|
||||||
tagSources: {
|
tagSources: {
|
||||||
automatic: preview.tags ?? '',
|
automatic: preview.tags ?? '',
|
||||||
manual: '' },
|
manual: '' },
|
||||||
@@ -358,8 +406,7 @@ const indexedBulkResults = (
|
|||||||
errors: {
|
errors: {
|
||||||
...(errors ?? { }),
|
...(errors ?? { }),
|
||||||
base: [...new Set ([...(errors?.base ?? []),
|
base: [...new Set ([...(errors?.base ?? []),
|
||||||
BULK_PROCESSING_FAILED_MESSAGE])],
|
BULK_PROCESSING_FAILED_MESSAGE])] },
|
||||||
},
|
|
||||||
recoverable: false }
|
recoverable: false }
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -380,6 +427,7 @@ const indexedBulkResults = (
|
|||||||
recoverable: result?.recoverable }
|
recoverable: result?.recoverable }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
const mergePreviewRow = (
|
const mergePreviewRow = (
|
||||||
currentRow: PostImportRow,
|
currentRow: PostImportRow,
|
||||||
preview: PostMetadataResponse,
|
preview: PostMetadataResponse,
|
||||||
@@ -397,14 +445,7 @@ const mergePreviewRow = (
|
|||||||
...currentRow,
|
...currentRow,
|
||||||
attributes: { ...currentRow.attributes },
|
attributes: { ...currentRow.attributes },
|
||||||
provenance: { ...currentRow.provenance },
|
provenance: { ...currentRow.provenance },
|
||||||
displayTags:
|
displayTags: cloneDisplayTags (currentRow.displayTags),
|
||||||
currentRow.displayTags?.map (tag => ({
|
|
||||||
name: tag.name,
|
|
||||||
category: tag.category,
|
|
||||||
sectionLiterals:
|
|
||||||
tag.sectionLiterals == null
|
|
||||||
? undefined
|
|
||||||
: [...tag.sectionLiterals] })) ?? [],
|
|
||||||
tagSources: {
|
tagSources: {
|
||||||
automatic: currentRow.tagSources?.automatic ?? '',
|
automatic: currentRow.tagSources?.automatic ?? '',
|
||||||
manual: currentRow.tagSources?.manual ?? '' },
|
manual: currentRow.tagSources?.manual ?? '' },
|
||||||
@@ -443,14 +484,7 @@ const mergePreviewRow = (
|
|||||||
if (currentRow.provenance.tags !== 'manual')
|
if (currentRow.provenance.tags !== 'manual')
|
||||||
{
|
{
|
||||||
nextRow.attributes.tags = preview.tags ?? ''
|
nextRow.attributes.tags = preview.tags ?? ''
|
||||||
nextRow.displayTags =
|
nextRow.displayTags = cloneDisplayTags (preview.displayTags)
|
||||||
preview.displayTags?.map (tag => ({
|
|
||||||
name: tag.name,
|
|
||||||
category: tag.category,
|
|
||||||
sectionLiterals:
|
|
||||||
tag.sectionLiterals == null
|
|
||||||
? undefined
|
|
||||||
: [...tag.sectionLiterals] })) ?? []
|
|
||||||
}
|
}
|
||||||
nextRow.tagSources.automatic = preview.tags ?? ''
|
nextRow.tagSources.automatic = preview.tags ?? ''
|
||||||
if (currentRow.provenance.parentPostIds !== 'manual')
|
if (currentRow.provenance.parentPostIds !== 'manual')
|
||||||
@@ -547,6 +581,26 @@ const ExistingSkippedRows: FC<ExistingSkippedRowsProps> = ({ 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<Props> = ({ user }) => {
|
const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||||
const editable = canEditContent (user)
|
const editable = canEditContent (user)
|
||||||
const dialogue = useDialogue ()
|
const dialogue = useDialogue ()
|
||||||
@@ -561,146 +615,101 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
? { duration: .08, ease: 'linear' as const }
|
? { duration: .08, ease: 'linear' as const }
|
||||||
: { duration: .2, ease: 'easeOut' as const }
|
: { duration: .2, ease: 'easeOut' as const }
|
||||||
|
|
||||||
const [session, setSession] = useState<PostImportSession | null> (null)
|
const [rows, setRows] = useState<PostImportRow[] | null> (null)
|
||||||
const [metadataLoading, setMetadataLoading] = useState (false)
|
const [metadataLoading, setMetadataLoading] = useState (false)
|
||||||
const [submitting, setSubmitting] = useState (false)
|
const [submitting, setSubmitting] = useState (false)
|
||||||
const [busyRowIds, setBusyRowIds] = useState<Set<number>> (new Set ())
|
const [busyRowIds, setBusyRowIds] = useState<Set<number>> (new Set ())
|
||||||
const [editingRow, setEditingRow] = useState<PostImportRow | null> (null)
|
const [editingRow, setEditingRow] = useState<PostImportRow | null> (null)
|
||||||
const [showExistingRows, setShowExistingRows] = useState (false)
|
const [showExistingRows, setShowExistingRows] = useState (false)
|
||||||
const sessionRef = useRef<PostImportSession | null> (null)
|
const rowsRef = useRef<PostImportRow[]> ([])
|
||||||
const sessionIdRef = useRef<string | null> (null)
|
|
||||||
const persistedSearchRef = useRef<string | null> (null)
|
|
||||||
const persistSequenceRef = useRef (0)
|
|
||||||
const previewSequenceRef = useRef (0)
|
|
||||||
const metadataSequenceRef = useRef (0)
|
const metadataSequenceRef = useRef (0)
|
||||||
|
const discardChanges = useCallback (() => undefined, [])
|
||||||
|
|
||||||
const showStorageError = (message: string) =>
|
const dirty = useMemo (
|
||||||
toast ({ description: message })
|
() => rows?.some (row => editableRowDirty (row)) ?? false,
|
||||||
|
[rows],
|
||||||
|
)
|
||||||
|
const { allowNextNavigation } = useUnsavedChangesGuard ({
|
||||||
|
dirty,
|
||||||
|
onDiscard: discardChanges,
|
||||||
|
})
|
||||||
|
|
||||||
const commitSessionState = (
|
const commitRows = useCallback ((nextRows: PostImportRow[]) => {
|
||||||
nextSession: PostImportSession,
|
rowsRef.current = nextRows
|
||||||
): {
|
setRows (nextRows)
|
||||||
session: PostImportSession
|
return nextRows
|
||||||
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 syncSessionUrl = async (
|
const updateRows = useCallback ((
|
||||||
nextSession: PostImportSession,
|
updater: (currentRows: PostImportRow[]) => PostImportRow[],
|
||||||
): Promise<PostImportSession | null> => {
|
) => {
|
||||||
const sessionId =
|
const nextRows = applyDuplicateUrlErrors (updater (rowsRef.current))
|
||||||
sessionIdRef.current ?? generatePostImportSessionId ()
|
return commitRows (nextRows)
|
||||||
sessionIdRef.current = sessionId
|
}, [commitRows])
|
||||||
const sequence = ++persistSequenceRef.current
|
|
||||||
const serialised = await serialisePostNewState (nextSession.rows)
|
|
||||||
if (serialised == null)
|
|
||||||
return null
|
|
||||||
if (persistSequenceRef.current !== sequence)
|
|
||||||
return null
|
|
||||||
|
|
||||||
const { session: persistedSession,
|
const currentRowBySource = useCallback ((
|
||||||
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 = (
|
|
||||||
sourceRow: number,
|
sourceRow: number,
|
||||||
fallback?: PostImportSession,
|
|
||||||
): PostImportRow | null =>
|
): PostImportRow | null =>
|
||||||
(sessionRef.current?.rows ?? fallback?.rows ?? []).find (
|
rowsRef.current.find (row => row.sourceRow === sourceRow) ?? null,
|
||||||
row => row.sourceRow === sourceRow) ?? null
|
[])
|
||||||
|
|
||||||
const finishImport = () => {
|
const finishImport = useCallback (() => {
|
||||||
const sessionId = sessionIdRef.current
|
allowNextNavigation ()
|
||||||
if (sessionId != null)
|
|
||||||
clearPostImportSession (sessionId)
|
|
||||||
clearPostImportSourceDraft (message =>
|
clearPostImportSourceDraft (message =>
|
||||||
toast ({ title: '入力内容を削除できませんでした', description: message }))
|
toast ({ title: '入力内容を削除できませんでした', description: message }))
|
||||||
navigate ('/posts')
|
navigate ('/posts')
|
||||||
}
|
}, [allowNextNavigation, navigate])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
|
const urls = parsePostNewReviewUrls (location.search)
|
||||||
|
const initialRows = buildInitialRows (urls)
|
||||||
|
commitRows (initialRows)
|
||||||
|
|
||||||
let active = true
|
let active = true
|
||||||
const previewSequence = ++previewSequenceRef.current
|
const sequence = ++metadataSequenceRef.current
|
||||||
metadataSequenceRef.current = previewSequence
|
|
||||||
const controllers = new Set<AbortController> ()
|
const controllers = new Set<AbortController> ()
|
||||||
|
|
||||||
if (sessionRef.current != null
|
|
||||||
&& persistedSearchRef.current === location.search
|
|
||||||
&& !(sessionRef.current.rows.some (row => shouldFetchReviewRow (row))))
|
|
||||||
return () => {
|
|
||||||
previewSequenceRef.current = previewSequence
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshRows = async (baseSession: PostImportSession) => {
|
|
||||||
const blocksSubmit = baseSession.rows.some (row => shouldFetchMetadata (row))
|
|
||||||
if (active && blocksSubmit)
|
|
||||||
setMetadataLoading (true)
|
|
||||||
try
|
|
||||||
{
|
|
||||||
let nextIndex = 0
|
let nextIndex = 0
|
||||||
|
const targetSourceRows = initialRows
|
||||||
|
.filter (row => shouldFetchReviewRow (row))
|
||||||
|
.map (row => row.sourceRow)
|
||||||
|
|
||||||
|
setMetadataLoading (targetSourceRows.length > 0)
|
||||||
|
|
||||||
const mergeCurrentRow = (
|
const mergeCurrentRow = (
|
||||||
sourceRow: number,
|
sourceRow: number,
|
||||||
updater: (row: PostImportRow) => PostImportRow,
|
updater: (row: PostImportRow) => PostImportRow,
|
||||||
) => {
|
) => {
|
||||||
const latestRows = sessionRef.current?.rows ?? baseSession.rows
|
updateRows (currentRows =>
|
||||||
const nextRows = applyDuplicateUrlErrors (latestRows.map (row =>
|
currentRows.map (row =>
|
||||||
row.sourceRow === sourceRow
|
row.sourceRow === sourceRow
|
||||||
? updater (row)
|
? updater (row)
|
||||||
: row))
|
: row))
|
||||||
commitSessionState (buildSession (nextRows))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const worker = async () => {
|
const worker = async () => {
|
||||||
while (active)
|
while (active)
|
||||||
{
|
{
|
||||||
const index = nextIndex
|
const sourceRow = targetSourceRows[nextIndex]
|
||||||
++nextIndex
|
++nextIndex
|
||||||
if (index >= baseSession.rows.length)
|
if (sourceRow == null)
|
||||||
return
|
return
|
||||||
|
|
||||||
const baseRow = baseSession.rows[index]
|
const currentRow = currentRowBySource (sourceRow)
|
||||||
const currentRow = currentRowBySource (baseRow.sourceRow, baseSession)
|
|
||||||
if (currentRow == null || !(shouldFetchReviewRow (currentRow)))
|
if (currentRow == null || !(shouldFetchReviewRow (currentRow)))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
const controller = new AbortController ()
|
const controller = new AbortController ()
|
||||||
controllers.add (controller)
|
controllers.add (controller)
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const preview = await apiGet<PostMetadataResponse> ('/posts/metadata', {
|
const preview = await apiGet<PostMetadataResponse> ('/posts/metadata', {
|
||||||
params: { url: currentRow.url },
|
params: { url: currentRow.url },
|
||||||
signal: controller.signal })
|
signal: controller.signal })
|
||||||
if (!(active) || previewSequenceRef.current !== previewSequence)
|
if (!(active) || metadataSequenceRef.current !== sequence)
|
||||||
return
|
return
|
||||||
mergeCurrentRow (currentRow.sourceRow, row =>
|
|
||||||
|
mergeCurrentRow (sourceRow, row =>
|
||||||
row.importStatus === 'created'
|
row.importStatus === 'created'
|
||||||
|| row.importStatus === 'skipped'
|
|| row.importStatus === 'skipped'
|
||||||
|| isNonRecoverableFailedRow (row)
|
|| isNonRecoverableFailedRow (row)
|
||||||
@@ -714,18 +723,20 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
{
|
{
|
||||||
if (controller.signal.aborted)
|
if (controller.signal.aborted)
|
||||||
return
|
return
|
||||||
if (!(active) || previewSequenceRef.current !== previewSequence)
|
if (!(active) || metadataSequenceRef.current !== sequence)
|
||||||
return
|
return
|
||||||
|
|
||||||
if (!(isApiError<{
|
if (!(isApiError<{
|
||||||
errors?: Record<string, string[]>
|
errors?: Record<string, string[]>
|
||||||
baseErrors?: string[]
|
baseErrors?: string[]
|
||||||
}> (requestError)))
|
}> (requestError)))
|
||||||
{
|
{
|
||||||
mergeCurrentRow (currentRow.sourceRow, row => ({
|
mergeCurrentRow (sourceRow, row => ({
|
||||||
...row,
|
...row,
|
||||||
status: 'error' }))
|
status: 'error' }))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requestError.response?.status === 422)
|
if (requestError.response?.status === 422)
|
||||||
{
|
{
|
||||||
const rowErrors = compactMessageRecord ({
|
const rowErrors = compactMessageRecord ({
|
||||||
@@ -734,16 +745,18 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
? {
|
? {
|
||||||
base: requestError.response.data.baseErrors }
|
base: requestError.response.data.baseErrors }
|
||||||
: { }) })
|
: { }) })
|
||||||
mergeCurrentRow (currentRow.sourceRow, row => ({
|
mergeCurrentRow (sourceRow, row => ({
|
||||||
...row,
|
...row,
|
||||||
validationErrors: rowErrors,
|
validationErrors: rowErrors,
|
||||||
status: 'error' }))
|
status: 'error' }))
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
mergeCurrentRow (currentRow.sourceRow, row => ({
|
{
|
||||||
|
mergeCurrentRow (sourceRow, row => ({
|
||||||
...row,
|
...row,
|
||||||
status: 'error' }))
|
status: 'error' }))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
controllers.delete (controller)
|
controllers.delete (controller)
|
||||||
@@ -751,99 +764,71 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all (
|
void Promise.all (
|
||||||
Array.from ({ length: Math.min (4, baseSession.rows.length) }, () => worker ()))
|
Array.from (
|
||||||
if (!(active) || previewSequenceRef.current !== previewSequence)
|
{ length: Math.min (4, targetSourceRows.length) },
|
||||||
return
|
() => worker (),
|
||||||
|
)).finally (() => {
|
||||||
const latestRows = sessionRef.current?.rows ?? baseSession.rows
|
if (active && metadataSequenceRef.current === sequence)
|
||||||
const nextRows = applyDuplicateUrlErrors (latestRows)
|
|
||||||
await syncSessionUrl (buildSession (nextRows))
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
if (active
|
|
||||||
&& blocksSubmit
|
|
||||||
&& metadataSequenceRef.current === previewSequence)
|
|
||||||
setMetadataLoading (false)
|
setMetadataLoading (false)
|
||||||
}
|
})
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
{
|
|
||||||
setMetadataLoading (false)
|
|
||||||
navigate ('/posts/new', { replace: true })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionIdRef.current = sessionId
|
|
||||||
const loaded = loadedSession ?? buildSession (parsed?.rows ?? [])
|
|
||||||
persistedSearchRef.current = location.search
|
|
||||||
sessionRef.current = loaded
|
|
||||||
setSession (loaded)
|
|
||||||
|
|
||||||
void refreshRows (loaded)
|
|
||||||
}
|
|
||||||
|
|
||||||
void hydrate ()
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
active = false
|
active = false
|
||||||
controllers.forEach (controller => controller.abort ())
|
controllers.forEach (controller => controller.abort ())
|
||||||
}
|
}
|
||||||
}, [location.search, navigate])
|
}, [commitRows, currentRowBySource, location.search, updateRows])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
if (session != null)
|
if (editingRow == null || resultRepairMode (rowsRef.current) !== 'failed')
|
||||||
sessionRef.current = session
|
|
||||||
}, [session])
|
|
||||||
|
|
||||||
useEffect (() => {
|
|
||||||
if (editingRow == null || session?.repairMode !== 'failed')
|
|
||||||
return
|
return
|
||||||
|
|
||||||
const element = document.getElementById (`post-import-row-${ editingRow.sourceRow }`)
|
const element = document.getElementById (`post-import-row-${ editingRow.sourceRow }`)
|
||||||
element?.scrollIntoView ({ block: 'center', behavior: 'smooth' })
|
element?.scrollIntoView ({ block: 'center', behavior: 'smooth' })
|
||||||
}, [editingRow, session?.repairMode])
|
}, [editingRow, rows])
|
||||||
|
|
||||||
const rows = session?.rows ?? []
|
const reviewRowsState = rows ?? []
|
||||||
const counts = useMemo (() => reviewSummaryCounts (rows), [rows])
|
const counts = useMemo (
|
||||||
|
() => reviewSummaryCounts (reviewRowsState),
|
||||||
|
[reviewRowsState],
|
||||||
|
)
|
||||||
const sortedRows =
|
const sortedRows =
|
||||||
session?.repairMode === 'failed'
|
resultRepairMode (reviewRowsState) === 'failed'
|
||||||
? (
|
? [...reviewRowsState].sort ((a, b) => {
|
||||||
[...rows].sort ((a, b) => {
|
const aRepair =
|
||||||
const aRepair = isRepairRow (a) ? 0 : 1
|
a.recoverable === true
|
||||||
const bRepair = isRepairRow (b) ? 0 : 1
|
&& (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
|
return aRepair - bRepair || a.sourceRow - b.sourceRow
|
||||||
}))
|
})
|
||||||
: rows
|
: reviewRowsState
|
||||||
const existingRows = sortedRows.filter (row => isExistingSkipRow (row))
|
const existingRows = sortedRows.filter (row => isExistingSkipRow (row))
|
||||||
const reviewRows = 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 canSubmit = processingRows.length > 0
|
||||||
|
|
||||||
const toggleManualSkip = async (sourceRow: number, checked: boolean) => {
|
const toggleManualSkip = async (sourceRow: number, checked: boolean) => {
|
||||||
const currentSession = sessionRef.current
|
const currentRow = currentRowBySource (sourceRow)
|
||||||
if (currentSession == null)
|
|
||||||
return
|
|
||||||
const currentRow = currentRowBySource (sourceRow, currentSession)
|
|
||||||
if (currentRow == null)
|
if (currentRow == null)
|
||||||
return
|
return
|
||||||
if (rowSkipBusy (currentRow, submitting, busyRowIds))
|
if (rowSkipBusy (currentRow, submitting, busyRowIds))
|
||||||
return
|
return
|
||||||
|
|
||||||
const nextRows = currentSession.rows.map (row => {
|
updateRows (currentRows =>
|
||||||
|
currentRows.map (row => {
|
||||||
if (row.sourceRow !== sourceRow)
|
if (row.sourceRow !== sourceRow)
|
||||||
return row
|
return row
|
||||||
if (isExistingSkipRow (row)
|
if (isExistingSkipRow (row) || row.importStatus === 'created')
|
||||||
|| row.importStatus === 'created')
|
|
||||||
return row
|
return row
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
@@ -853,27 +838,17 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
: row.existingPostId != null
|
: row.existingPostId != null
|
||||||
? 'existing'
|
? 'existing'
|
||||||
: undefined }
|
: undefined }
|
||||||
})
|
}))
|
||||||
const nextSession = buildSession (applyDuplicateUrlErrors (nextRows))
|
|
||||||
if (metadataLoading)
|
|
||||||
{
|
|
||||||
commitSessionState (nextSession)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await syncSessionUrl (nextSession)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveDraft = async (
|
const saveDraft = async (
|
||||||
row: PostImportRow,
|
row: PostImportRow,
|
||||||
{ draft, resetRequested }: {
|
{ draft, resetRequested }: {
|
||||||
draft: PostImportRowDraft
|
draft: PostImportEditableDraft
|
||||||
resetRequested: boolean },
|
resetRequested: boolean },
|
||||||
): Promise<{ saved: boolean
|
): Promise<{ saved: boolean
|
||||||
row: PostImportRow | null }> => {
|
row: PostImportRow | null }> => {
|
||||||
const currentSession = sessionRef.current
|
const currentRow = currentRowBySource (row.sourceRow)
|
||||||
if (currentSession == null)
|
|
||||||
return { saved: false, row: null }
|
|
||||||
const currentRow = currentRowBySource (row.sourceRow, currentSession)
|
|
||||||
if (currentRow == null)
|
if (currentRow == null)
|
||||||
return { saved: false, row: null }
|
return { saved: false, row: null }
|
||||||
if (!(canEditReviewRow (currentRow)))
|
if (!(canEditReviewRow (currentRow)))
|
||||||
@@ -881,22 +856,14 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
|
|
||||||
const baseRow =
|
const baseRow =
|
||||||
resetRequested
|
resetRequested
|
||||||
? { ...currentRow,
|
? {
|
||||||
|
...currentRow,
|
||||||
url: currentRow.resetSnapshot.url,
|
url: currentRow.resetSnapshot.url,
|
||||||
attributes: { ...currentRow.resetSnapshot.attributes },
|
attributes: { ...currentRow.resetSnapshot.attributes },
|
||||||
displayTags:
|
displayTags: cloneDisplayTags (currentRow.resetSnapshot.displayTags),
|
||||||
currentRow.resetSnapshot.displayTags.map (tag => ({
|
|
||||||
name: tag.name,
|
|
||||||
category: tag.category,
|
|
||||||
sectionLiterals:
|
|
||||||
tag.sectionLiterals == null
|
|
||||||
? undefined
|
|
||||||
: [...tag.sectionLiterals] })),
|
|
||||||
provenance: { ...currentRow.resetSnapshot.provenance },
|
provenance: { ...currentRow.resetSnapshot.provenance },
|
||||||
tagSources: { ...currentRow.resetSnapshot.tagSources },
|
tagSources: { ...currentRow.resetSnapshot.tagSources },
|
||||||
fieldWarnings: Object.fromEntries (
|
fieldWarnings: cloneMessageRecord (currentRow.resetSnapshot.fieldWarnings),
|
||||||
Object.entries (currentRow.resetSnapshot.fieldWarnings)
|
|
||||||
.map (([key, values]) => [key, [...values]])),
|
|
||||||
baseWarnings: [...currentRow.resetSnapshot.baseWarnings],
|
baseWarnings: [...currentRow.resetSnapshot.baseWarnings],
|
||||||
metadataUrl: currentRow.resetSnapshot.metadataUrl,
|
metadataUrl: currentRow.resetSnapshot.metadataUrl,
|
||||||
thumbnailFile: undefined }
|
thumbnailFile: undefined }
|
||||||
@@ -904,6 +871,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
const urlChanged = draft.url !== baseRow.url
|
const urlChanged = draft.url !== baseRow.url
|
||||||
const nextRow = buildNextEditedRow (baseRow, draft, urlChanged)
|
const nextRow = buildNextEditedRow (baseRow, draft, urlChanged)
|
||||||
let candidateRow = nextRow
|
let candidateRow = nextRow
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
candidateRow =
|
candidateRow =
|
||||||
@@ -916,21 +884,11 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
const dryRun = await apiPost<PostMetadataResponse> (
|
const dryRun = await apiPost<PostMetadataResponse> (
|
||||||
'/posts?dry=1',
|
'/posts?dry=1',
|
||||||
buildDryRunFormData (candidateRow))
|
buildDryRunFormData (candidateRow))
|
||||||
const latestSession = sessionRef.current
|
|
||||||
if (latestSession == null)
|
|
||||||
return { saved: false, row: null }
|
|
||||||
|
|
||||||
const mergedRow = mergeDryRunRow (candidateRow, dryRun)
|
const mergedRow = mergeDryRunRow (candidateRow, dryRun)
|
||||||
const mergedRows = applyDuplicateUrlErrors (
|
const mergedRows = updateRows (currentRows =>
|
||||||
replaceImportRow (latestSession.rows, mergedRow))
|
replaceImportRow (currentRows, mergedRow))
|
||||||
const nextSession =
|
const mergedTarget = mergedRows.find (
|
||||||
metadataLoading
|
current => current.sourceRow === baseRow.sourceRow)
|
||||||
? 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)
|
|
||||||
if (mergedTarget == null)
|
if (mergedTarget == null)
|
||||||
return { saved: false, row: null }
|
return { saved: false, row: null }
|
||||||
if (hasErrorMessages (mergedTarget.validationErrors))
|
if (hasErrorMessages (mergedTarget.validationErrors))
|
||||||
@@ -967,7 +925,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
const openEditingDialogue = async (row: PostImportRow) => {
|
const openEditingDialogue = async (row: PostImportRow) => {
|
||||||
const saveRowDraft = (
|
const saveRowDraft = (
|
||||||
{ draft, resetRequested }: {
|
{ draft, resetRequested }: {
|
||||||
draft: PostImportRowDraft
|
draft: PostImportEditableDraft
|
||||||
resetRequested: boolean },
|
resetRequested: boolean },
|
||||||
) =>
|
) =>
|
||||||
saveDraft (row, { draft, resetRequested })
|
saveDraft (row, { draft, resetRequested })
|
||||||
@@ -984,8 +942,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const editRow = async (row: PostImportRow) => {
|
const editRow = async (row: PostImportRow) => {
|
||||||
const currentRow = sessionRef.current?.rows.find (
|
const currentRow = currentRowBySource (row.sourceRow)
|
||||||
current => current.sourceRow === row.sourceRow)
|
|
||||||
if (currentRow == null)
|
if (currentRow == null)
|
||||||
return
|
return
|
||||||
if (!(canEditReviewRow (currentRow)))
|
if (!(canEditReviewRow (currentRow)))
|
||||||
@@ -1008,11 +965,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const retry = async (sourceRow: number) => {
|
const retry = async (sourceRow: number) => {
|
||||||
const initialSession = sessionRef.current
|
const originalRow = currentRowBySource (sourceRow)
|
||||||
if (initialSession == null)
|
|
||||||
return
|
|
||||||
|
|
||||||
const originalRow = initialSession.rows.find (row => row.sourceRow === sourceRow)
|
|
||||||
if (originalRow == null)
|
if (originalRow == null)
|
||||||
return
|
return
|
||||||
if (rowOperationBusy (originalRow, submitting, busyRowIds))
|
if (rowOperationBusy (originalRow, submitting, busyRowIds))
|
||||||
@@ -1021,15 +974,8 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
setBusyRowIds (current => new Set ([...current, sourceRow]))
|
setBusyRowIds (current => new Set ([...current, sourceRow]))
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const pendingRows = retryImportRow (initialSession.rows, sourceRow)
|
updateRows (currentRows => retryImportRow (currentRows, sourceRow))
|
||||||
const pendingSession =
|
const target = currentRowBySource (sourceRow)
|
||||||
metadataLoading
|
|
||||||
? commitSessionState (buildSession (pendingRows)).session
|
|
||||||
: await syncSessionUrl (buildSession (pendingRows))
|
|
||||||
if (pendingSession == null)
|
|
||||||
return
|
|
||||||
|
|
||||||
const target = pendingSession.rows.find (row => row.sourceRow === sourceRow)
|
|
||||||
if (target == null)
|
if (target == null)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1038,37 +984,22 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
buildBulkFormData ([target]))
|
buildBulkFormData ([target]))
|
||||||
if (result.results.length !== 1)
|
if (result.results.length !== 1)
|
||||||
{
|
{
|
||||||
const latest = sessionRef.current ?? pendingSession
|
updateRows (currentRows => replaceImportRow (currentRows, originalRow))
|
||||||
const restoredRows = replaceImportRow (latest.rows, originalRow)
|
|
||||||
if (metadataLoading)
|
|
||||||
commitSessionState (buildSession (restoredRows))
|
|
||||||
else
|
|
||||||
await syncSessionUrl (buildSession (restoredRows))
|
|
||||||
toast ({ title: '登録結果が不完全でした' })
|
toast ({ title: '登録結果が不完全でした' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const indexedResults = indexedBulkResults ([target], result.results)
|
const indexedResults = indexedBulkResults ([target], result.results)
|
||||||
const latestAfterImport = sessionRef.current ?? pendingSession
|
|
||||||
const nextRows = applyDuplicateUrlErrors (
|
const nextRows = applyDuplicateUrlErrors (
|
||||||
mergeImportResults (latestAfterImport.rows, indexedResults))
|
mergeImportResults (rowsRef.current, indexedResults))
|
||||||
.map (row => applyThumbnailWarning (row))
|
.map (row => applyThumbnailWarning (row))
|
||||||
const persisted =
|
commitRows (nextRows)
|
||||||
metadataLoading
|
if (nextRows.every (row => isCompletedReviewRow (row)))
|
||||||
? commitSessionState (buildSession (nextRows)).session
|
|
||||||
: await syncSessionUrl (buildSession (nextRows))
|
|
||||||
if (persisted != null
|
|
||||||
&& persisted.rows.every (row => isCompletedReviewRow (row)))
|
|
||||||
finishImport ()
|
finishImport ()
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
const latest = sessionRef.current ?? initialSession
|
updateRows (currentRows => replaceImportRow (currentRows, originalRow))
|
||||||
const restoredRows = replaceImportRow (latest.rows, originalRow)
|
|
||||||
if (metadataLoading)
|
|
||||||
commitSessionState (buildSession (restoredRows))
|
|
||||||
else
|
|
||||||
await syncSessionUrl (buildSession (restoredRows))
|
|
||||||
toast ({ title: '再試行に失敗しました' })
|
toast ({ title: '再試行に失敗しました' })
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -1082,14 +1013,11 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
const currentSession = sessionRef.current
|
if (metadataLoading || submitting || busyRowIds.size > 0)
|
||||||
if (currentSession == null
|
|
||||||
|| metadataLoading
|
|
||||||
|| submitting
|
|
||||||
|| busyRowIds.size > 0)
|
|
||||||
return
|
return
|
||||||
const processingRows = processableImportRows (currentSession.rows)
|
|
||||||
if (processingRows.length === 0)
|
const latestProcessingRows = processableImportRows (rowsRef.current)
|
||||||
|
if (latestProcessingRows.length === 0)
|
||||||
return
|
return
|
||||||
|
|
||||||
setSubmitting (true)
|
setSubmitting (true)
|
||||||
@@ -1097,21 +1025,19 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
{
|
{
|
||||||
const result = await apiPost<{ results: BulkApiRow[] }>(
|
const result = await apiPost<{ results: BulkApiRow[] }>(
|
||||||
'/posts/bulk',
|
'/posts/bulk',
|
||||||
buildBulkFormData (processingRows))
|
buildBulkFormData (latestProcessingRows))
|
||||||
if (result.results.length !== processingRows.length)
|
if (result.results.length !== latestProcessingRows.length)
|
||||||
{
|
{
|
||||||
toast ({ title: '登録結果が不完全でした' })
|
toast ({ title: '登録結果が不完全でした' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const indexedResults = indexedBulkResults (processingRows, result.results)
|
const indexedResults = indexedBulkResults (latestProcessingRows, result.results)
|
||||||
const latestAfterImport = sessionRef.current ?? buildSession (currentSession.rows)
|
|
||||||
const nextRows = applyDuplicateUrlErrors (
|
const nextRows = applyDuplicateUrlErrors (
|
||||||
mergeImportResults (latestAfterImport.rows, indexedResults))
|
mergeImportResults (rowsRef.current, indexedResults))
|
||||||
.map (row => applyThumbnailWarning (row))
|
.map (row => applyThumbnailWarning (row))
|
||||||
const persisted = await syncSessionUrl (buildSession (nextRows))
|
commitRows (nextRows)
|
||||||
if (persisted != null
|
if (nextRows.every (row => isCompletedReviewRow (row)))
|
||||||
&& persisted.rows.every (row => isCompletedReviewRow (row)))
|
|
||||||
finishImport ()
|
finishImport ()
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -1127,7 +1053,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
if (!(editable))
|
if (!(editable))
|
||||||
return <Forbidden/>
|
return <Forbidden/>
|
||||||
|
|
||||||
if (session == null)
|
if (rows == null)
|
||||||
return null
|
return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1136,11 +1062,11 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
<title>{`追加内容確認 | ${ SITE_TITLE }`}</title>
|
<title>{`追加内容確認 | ${ SITE_TITLE }`}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
|
||||||
<MainArea className="min-h-0">
|
<MainArea className="pb-40">
|
||||||
<div className="mx-auto max-w-6xl space-y-4 p-4">
|
<div className="mx-auto max-w-6xl space-y-6">
|
||||||
<PageTitle>追加内容確認</PageTitle>
|
<PageTitle>追加内容確認</PageTitle>
|
||||||
|
|
||||||
{counts.existingSkipped > 0 && (
|
{existingRows.length > 0 && (
|
||||||
<div className="rounded-lg border p-4">
|
<div className="rounded-lg border p-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1209,13 +1135,12 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
manualSkippedCount={counts.manualSkipped}
|
manualSkippedCount={counts.manualSkipped}
|
||||||
existingSkippedCount={counts.existingSkipped}
|
existingSkippedCount={counts.existingSkipped}
|
||||||
pendingOrErrorCount={counts.pendingOrError}
|
pendingOrErrorCount={counts.pendingOrError}
|
||||||
onBack={() => navigate (location.state?.from === '/posts/new'
|
onBack={() => navigate ('/posts/new')}
|
||||||
? '/posts/new'
|
|
||||||
: (-1))}
|
|
||||||
onSubmit={() => submit ()}/>
|
onSubmit={() => submit ()}/>
|
||||||
</>)
|
</>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const PostImportFooter = (
|
const PostImportFooter = (
|
||||||
{ metadataLoading,
|
{ metadataLoading,
|
||||||
submitting,
|
submitting,
|
||||||
|
|||||||
@@ -12,26 +12,23 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { toast } from '@/components/ui/use-toast'
|
import { toast } from '@/components/ui/use-toast'
|
||||||
import { SITE_TITLE } from '@/config'
|
import { SITE_TITLE } from '@/config'
|
||||||
import {
|
import {
|
||||||
appendPostNewSessionId,
|
buildPostNewReviewPath,
|
||||||
serialisePostNewState,
|
isPostNewReviewPathWithinLimit,
|
||||||
} from '@/lib/postNewQueryState'
|
} from '@/lib/postNewQueryState'
|
||||||
import { canEditContent } from '@/lib/users'
|
import {
|
||||||
import { countImportSourceLines,
|
countImportSourceLines,
|
||||||
generatePostImportSessionId,
|
extractImportSourceUrls,
|
||||||
cleanupExpiredPostImportSessions,
|
validateImportSource,
|
||||||
initialisePreviewRows,
|
} from '@/lib/postImportSourceValidation'
|
||||||
loadPostImportSession,
|
import {
|
||||||
loadPostImportSourceDraft,
|
loadPostImportSourceDraft,
|
||||||
resultRepairMode,
|
|
||||||
serialisedPostImportRowsEqual,
|
|
||||||
savePostImportSession,
|
|
||||||
savePostImportSourceDraft,
|
savePostImportSourceDraft,
|
||||||
validateImportSource } from '@/lib/postImportSession'
|
} from '@/lib/postImportStorage'
|
||||||
|
import { canEditContent } from '@/lib/users'
|
||||||
import Forbidden from '@/pages/Forbidden'
|
import Forbidden from '@/pages/Forbidden'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
import type { PostImportRow } from '@/lib/postImportSession'
|
|
||||||
import type { User } from '@/types'
|
import type { User } from '@/types'
|
||||||
|
|
||||||
type Props = { user: User | null }
|
type Props = { user: User | null }
|
||||||
@@ -40,71 +37,6 @@ const MAX_ROWS = 100
|
|||||||
const SOURCE_ERROR_ID = 'post-import-source-error'
|
const SOURCE_ERROR_ID = 'post-import-source-error'
|
||||||
const SOURCE_ISSUES_ID = 'post-import-source-issues'
|
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 PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||||
const editable = canEditContent (user)
|
const editable = canEditContent (user)
|
||||||
@@ -117,6 +49,8 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
|||||||
const editedRef = useRef (false)
|
const editedRef = useRef (false)
|
||||||
|
|
||||||
const lineCount = countImportSourceLines (source)
|
const lineCount = countImportSourceLines (source)
|
||||||
|
const sourceUrls = extractImportSourceUrls (source)
|
||||||
|
const withinPathLimit = isPostNewReviewPathWithinLimit (sourceUrls)
|
||||||
const messages = sourceError != null ? [sourceError] : []
|
const messages = sourceError != null ? [sourceError] : []
|
||||||
const sourceDescribedBy =
|
const sourceDescribedBy =
|
||||||
[sourceError != null ? SOURCE_ERROR_ID : null,
|
[sourceError != null ? SOURCE_ERROR_ID : null,
|
||||||
@@ -124,13 +58,7 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
|||||||
.filter (value => value != null)
|
.filter (value => value != null)
|
||||||
.join (' ')
|
.join (' ')
|
||||||
|
|
||||||
const showStorageError = (message: string) =>
|
|
||||||
toast ({ description: message })
|
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
cleanupExpiredPostImportSessions (message =>
|
|
||||||
toast ({ title: '保存済みデータを整理できませんでした', description: message }))
|
|
||||||
|
|
||||||
const draft = loadPostImportSourceDraft (message =>
|
const draft = loadPostImportSourceDraft (message =>
|
||||||
toast ({ title: '保存済み入力を復元できませんでした', description: message }))
|
toast ({ title: '保存済み入力を復元できませんでした', description: message }))
|
||||||
|
|
||||||
@@ -173,73 +101,15 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
|||||||
setSourceError (null)
|
setSourceError (null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!(withinPathLimit))
|
||||||
|
return
|
||||||
|
|
||||||
setLoading (true)
|
setLoading (true)
|
||||||
setSourceIssues ([])
|
setSourceIssues ([])
|
||||||
setSourceError (null)
|
setSourceError (null)
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const nextRows = initialisePreviewRows (buildInitialRows (source))
|
navigate (buildPostNewReviewPath (sourceUrls))
|
||||||
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 ('ブラウザへ保存できませんでした.')
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -268,7 +138,8 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
|||||||
className="h-80 font-mono text-sm"
|
className="h-80 font-mono text-sm"
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
savePostImportSourceDraft (source, message =>
|
savePostImportSourceDraft (source, message =>
|
||||||
toast ({ title: '入力内容を保存できませんでした',
|
toast ({
|
||||||
|
title: '入力内容を保存できませんでした',
|
||||||
description: message }))
|
description: message }))
|
||||||
}}
|
}}
|
||||||
onChange={ev => {
|
onChange={ev => {
|
||||||
@@ -299,7 +170,7 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
|||||||
type="button"
|
type="button"
|
||||||
className="pointer-events-auto shrink-0"
|
className="pointer-events-auto shrink-0"
|
||||||
onClick={preview}
|
onClick={preview}
|
||||||
disabled={loading}>
|
disabled={loading || !(withinPathLimit)}>
|
||||||
次へ
|
次へ
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする