このコミットが含まれているのは:
@@ -175,15 +175,23 @@ class PostsController < ApplicationController
|
||||
return head :unauthorized unless current_user
|
||||
return head :forbidden unless current_user.gte_member?
|
||||
|
||||
if bool?(:dry)
|
||||
preflight = PostCreatePreflight.new(
|
||||
attributes: post_create_attributes,
|
||||
thumbnail: params[:thumbnail]).run
|
||||
return render json: preflight
|
||||
end
|
||||
preflight = PostCreatePreflight.new(
|
||||
attributes: post_create_attributes,
|
||||
thumbnail: params[:thumbnail]).run
|
||||
return render json: preflight if bool?(:dry)
|
||||
|
||||
post = PostCreator.new(actor: current_user,
|
||||
attributes: post_create_attributes.merge(
|
||||
preflight.slice(
|
||||
:url,
|
||||
:title,
|
||||
:thumbnail_base,
|
||||
:tags,
|
||||
:parent_post_ids,
|
||||
:original_created_from,
|
||||
:original_created_before,
|
||||
:duration,
|
||||
:video_ms).symbolize_keys).merge(
|
||||
thumbnail: params[:thumbnail])).create!
|
||||
|
||||
post.reload
|
||||
@@ -532,6 +540,7 @@ class PostsController < ApplicationController
|
||||
def parse_bulk_posts_manifest
|
||||
manifest = params[:posts]
|
||||
raise ArgumentError, 'posts は必須です.' if manifest.blank?
|
||||
raise ArgumentError, 'posts は JSON 文字列で指定してください.' unless manifest.is_a?(String)
|
||||
|
||||
posts = JSON.parse(manifest)
|
||||
raise ArgumentError, 'posts は配列で指定してください.' unless posts.is_a?(Array)
|
||||
@@ -569,15 +578,7 @@ class PostsController < ApplicationController
|
||||
return nil if post_id.blank?
|
||||
|
||||
post = Post.with_attached_thumbnail.find_by(id: post_id)
|
||||
return nil if post.nil?
|
||||
|
||||
{ id: post.id,
|
||||
title: post.title,
|
||||
url: post.url,
|
||||
thumbnail_url:
|
||||
if post.thumbnail.attached?
|
||||
rails_storage_proxy_url(post.thumbnail, only_path: false)
|
||||
end }
|
||||
PostCompactRepr.base(post)
|
||||
end
|
||||
|
||||
def sync_parent_posts! post, parent_post_ids
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
|
||||
module PostCompactRepr
|
||||
module_function
|
||||
|
||||
def base post
|
||||
return nil if post.nil?
|
||||
|
||||
{
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
url: post.url,
|
||||
thumbnail_url: thumbnail_url(post) }
|
||||
end
|
||||
|
||||
def thumbnail_url post
|
||||
return nil unless post.thumbnail.attached?
|
||||
|
||||
Rails.application.routes.url_helpers.rails_storage_proxy_url(
|
||||
post.thumbnail,
|
||||
only_path: false)
|
||||
rescue
|
||||
nil
|
||||
end
|
||||
end
|
||||
@@ -55,14 +55,21 @@ class PostBulkCreator
|
||||
errors: e.record.errors.to_hash,
|
||||
base_errors: e.record.errors[:base] }
|
||||
rescue ActiveRecord::RecordNotUnique => e
|
||||
raise unless e.message.include?('index_posts_on_url')
|
||||
|
||||
existing_post = existing_post_for_race(attributes)
|
||||
raise if existing_post.nil?
|
||||
if e.message.include?('index_posts_on_url')
|
||||
existing_post = existing_post_for_race(attributes)
|
||||
return {
|
||||
status: 'skipped',
|
||||
existing_post: existing_post } if existing_post.present?
|
||||
end
|
||||
|
||||
Rails.logger.error(
|
||||
"post_bulk_creator_record_not_unique #{ { error: e.class.name,
|
||||
message: e.message }.to_json }")
|
||||
{
|
||||
status: 'skipped',
|
||||
existing_post: existing_post }
|
||||
status: 'failed',
|
||||
recoverable: false,
|
||||
errors: { base: ['登録中にエラーが発生しました.'] },
|
||||
base_errors: [] }
|
||||
rescue Tag::NicoTagNormalisationError
|
||||
{
|
||||
status: 'failed',
|
||||
@@ -134,17 +141,6 @@ class PostBulkCreator
|
||||
end
|
||||
|
||||
def compact_existing_post post
|
||||
return nil if post.nil?
|
||||
|
||||
{
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
url: post.url,
|
||||
thumbnail_url:
|
||||
if post.thumbnail.attached?
|
||||
Rails.application.routes.url_helpers.rails_storage_proxy_url(
|
||||
post.thumbnail,
|
||||
only_path: false)
|
||||
end }
|
||||
PostCompactRepr.base(post)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -88,17 +88,6 @@ class PostCreatePreflight
|
||||
return nil if post_id.blank?
|
||||
|
||||
post = Post.with_attached_thumbnail.find_by(id: post_id)
|
||||
return nil if post.nil?
|
||||
|
||||
{
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
url: post.url,
|
||||
thumbnail_url:
|
||||
if post.thumbnail.attached?
|
||||
Rails.application.routes.url_helpers.rails_storage_proxy_url(
|
||||
post.thumbnail,
|
||||
only_path: false)
|
||||
end }
|
||||
PostCompactRepr.base(post)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -255,7 +255,15 @@ class PostImportPreviewer
|
||||
def sanitise_metadata_url value
|
||||
return nil unless value.is_a?(String)
|
||||
|
||||
PostUrlNormaliser.normalise(value)
|
||||
stripped = value.strip
|
||||
return nil if stripped.blank?
|
||||
|
||||
uri = URI.parse(stripped)
|
||||
return nil unless uri.is_a?(URI::HTTP) && uri.host.present?
|
||||
|
||||
stripped
|
||||
rescue URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
|
||||
def sanitise_metadata_time value
|
||||
|
||||
@@ -17,13 +17,15 @@ type Props = {
|
||||
type?: string
|
||||
placeholder?: string }
|
||||
thumbnailField: ReactNode
|
||||
core: PostCoreDataFieldsProps }
|
||||
core: PostCoreDataFieldsProps
|
||||
extraFields?: ReactNode }
|
||||
|
||||
|
||||
const PostCreationDataFields: FC<Props> = (
|
||||
{ url,
|
||||
thumbnailField,
|
||||
core },
|
||||
core,
|
||||
extraFields },
|
||||
) => (
|
||||
<>
|
||||
<PostTextField
|
||||
@@ -39,6 +41,8 @@ const PostCreationDataFields: FC<Props> = (
|
||||
{thumbnailField}
|
||||
|
||||
<PostCoreDataFields {...core}/>
|
||||
|
||||
{extraFields}
|
||||
</>)
|
||||
|
||||
export default PostCreationDataFields
|
||||
|
||||
@@ -21,8 +21,7 @@ const PostDurationField: FC<Props> = (
|
||||
onChange={onChange}
|
||||
errors={errors}
|
||||
disabled={disabled}
|
||||
type="text"
|
||||
placeholder="例: 2 / 2.5 / 1:23"/>
|
||||
type="text"/>
|
||||
)
|
||||
|
||||
export default PostDurationField
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { FC } from 'react'
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
file?: File
|
||||
alt?: string
|
||||
className?: string
|
||||
referrerPolicy?: 'no-referrer' }
|
||||
@@ -13,17 +14,36 @@ type Props = {
|
||||
|
||||
const PostThumbnailPreview: FC<Props> = (
|
||||
{ url,
|
||||
file,
|
||||
alt = 'サムネール',
|
||||
className = 'h-16 w-16',
|
||||
referrerPolicy },
|
||||
) => {
|
||||
const [failed, setFailed] = useState (false)
|
||||
const [fileUrl, setFileUrl] = useState<string | null> (null)
|
||||
|
||||
useEffect (() => {
|
||||
setFailed (false)
|
||||
}, [url])
|
||||
}, [file, url])
|
||||
|
||||
if (!(url) || failed)
|
||||
useEffect (() => {
|
||||
if (file == null)
|
||||
{
|
||||
setFileUrl (null)
|
||||
return
|
||||
}
|
||||
|
||||
const nextUrl = URL.createObjectURL (file)
|
||||
setFileUrl (nextUrl)
|
||||
|
||||
return () => {
|
||||
URL.revokeObjectURL (nextUrl)
|
||||
}
|
||||
}, [file])
|
||||
|
||||
const resolvedUrl = url.trim () !== '' ? url : (fileUrl ?? '')
|
||||
|
||||
if (resolvedUrl === '' || failed)
|
||||
{
|
||||
return (
|
||||
<div
|
||||
@@ -34,7 +54,7 @@ const PostThumbnailPreview: FC<Props> = (
|
||||
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
src={resolvedUrl}
|
||||
alt={alt}
|
||||
referrerPolicy={referrerPolicy}
|
||||
className={cn (className, 'rounded border border-border object-cover')}
|
||||
|
||||
@@ -3,8 +3,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import FieldError from '@/components/common/FieldError'
|
||||
import FieldWarning from '@/components/common/FieldWarning'
|
||||
import PostCreationDataFields from '@/components/posts/PostCreationDataFields'
|
||||
import PostDurationField from '@/components/posts/PostDurationField'
|
||||
import PostTextField from '@/components/posts/PostTextField'
|
||||
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
|
||||
import { hasThumbnailBaseValue, hasVideoTag } from '@/lib/postImportRows'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
@@ -21,6 +23,8 @@ type Props = {
|
||||
saved: boolean
|
||||
row: PostImportRow | null }> }
|
||||
|
||||
const THUMBNAIL_MISSING_WARNING = 'サムネールなし'
|
||||
|
||||
const buildDraft = (row: PostImportRow): Draft => ({
|
||||
url: row.url,
|
||||
title: String (row.attributes.title ?? ''),
|
||||
@@ -29,6 +33,7 @@ const buildDraft = (row: PostImportRow): Draft => ({
|
||||
originalCreatedBefore: String (row.attributes.originalCreatedBefore ?? ''),
|
||||
tags: String (row.attributes.tags ?? ''),
|
||||
parentPostIds: String (row.attributes.parentPostIds ?? ''),
|
||||
duration: String (row.attributes.duration ?? ''),
|
||||
thumbnailFile: row.thumbnailFile })
|
||||
|
||||
const buildResetDraft = (row: PostImportRow): Draft => ({
|
||||
@@ -39,6 +44,7 @@ const buildResetDraft = (row: PostImportRow): Draft => ({
|
||||
originalCreatedBefore: String (row.resetSnapshot.attributes.originalCreatedBefore ?? ''),
|
||||
tags: String (row.resetSnapshot.attributes.tags ?? ''),
|
||||
parentPostIds: String (row.resetSnapshot.attributes.parentPostIds ?? ''),
|
||||
duration: String (row.resetSnapshot.attributes.duration ?? ''),
|
||||
thumbnailFile: undefined })
|
||||
|
||||
const groupedMessages = (...values: (string[] | undefined)[]): string[] =>
|
||||
@@ -52,6 +58,8 @@ const sameDraft = (left: Draft, right: Draft): boolean =>
|
||||
&& left.originalCreatedBefore === right.originalCreatedBefore
|
||||
&& left.tags === right.tags
|
||||
&& left.parentPostIds === right.parentPostIds
|
||||
&& left.duration === right.duration
|
||||
&& left.thumbnailFile === right.thumbnailFile
|
||||
|
||||
const sameProvenance = (
|
||||
current: PostImportRow['provenance'],
|
||||
@@ -73,6 +81,17 @@ const sameWarnings = (
|
||||
JSON.stringify (current.fieldWarnings) === JSON.stringify (reset.fieldWarnings)
|
||||
&& JSON.stringify (current.baseWarnings) === JSON.stringify (reset.baseWarnings)
|
||||
|
||||
const thumbnailWarnings = (
|
||||
messages: string[] | undefined,
|
||||
thumbnailBase: string,
|
||||
thumbnailFile: File | undefined,
|
||||
): string[] => {
|
||||
const others = (messages ?? []).filter (message => message !== THUMBNAIL_MISSING_WARNING)
|
||||
return hasThumbnailBaseValue (thumbnailBase) || thumbnailFile != null
|
||||
? others
|
||||
: [...new Set ([...others, THUMBNAIL_MISSING_WARNING])]
|
||||
}
|
||||
|
||||
|
||||
const PostImportRowForm: FC<Props> = (
|
||||
{ row,
|
||||
@@ -85,8 +104,6 @@ const PostImportRowForm: FC<Props> = (
|
||||
const [resetRequested, setResetRequested] = useState (false)
|
||||
const [committedThumbnailBase, setCommittedThumbnailBase] = useState (
|
||||
() => String (row.attributes.thumbnailBase ?? ''))
|
||||
const [thumbnailObjectUrl, setThumbnailObjectUrl] = useState<string | undefined> (
|
||||
() => row.thumbnailObjectUrl)
|
||||
|
||||
useEffect (() => {
|
||||
const nextDraft = buildDraft (row)
|
||||
@@ -94,18 +111,17 @@ const PostImportRowForm: FC<Props> = (
|
||||
setMessageRow (null)
|
||||
setResetRequested (false)
|
||||
setCommittedThumbnailBase (String (row.attributes.thumbnailBase ?? ''))
|
||||
setThumbnailObjectUrl (row.thumbnailObjectUrl)
|
||||
}, [row])
|
||||
|
||||
useEffect (() => () => {
|
||||
if (thumbnailObjectUrl != null)
|
||||
URL.revokeObjectURL (thumbnailObjectUrl)
|
||||
}, [thumbnailObjectUrl])
|
||||
|
||||
const displayRow = messageRow ?? row
|
||||
const resetDraft = useMemo (
|
||||
() => buildResetDraft (row),
|
||||
[row])
|
||||
const durationVisible = hasVideoTag (draft.tags)
|
||||
const currentThumbnailWarnings = thumbnailWarnings (
|
||||
displayRow.fieldWarnings.thumbnailBase,
|
||||
draft.thumbnailBase,
|
||||
draft.thumbnailFile)
|
||||
const resetDisabled =
|
||||
saving
|
||||
|| (sameDraft (draft, resetDraft)
|
||||
@@ -129,7 +145,6 @@ const PostImportRowForm: FC<Props> = (
|
||||
|
||||
const confirmed = await controls.confirm ({
|
||||
title: '変更をリセットしますか?',
|
||||
description: '現在の URL に対する自動取得直後の内容へ戻します.',
|
||||
confirmText: 'リセット',
|
||||
cancelText: '取消',
|
||||
variant: 'danger' })
|
||||
@@ -140,11 +155,8 @@ const PostImportRowForm: FC<Props> = (
|
||||
setResetRequested (true)
|
||||
setMessageRow (null)
|
||||
setCommittedThumbnailBase (resetDraft.thumbnailBase)
|
||||
if (thumbnailObjectUrl != null)
|
||||
URL.revokeObjectURL (thumbnailObjectUrl)
|
||||
setThumbnailObjectUrl (undefined)
|
||||
return false
|
||||
}, [controls, resetDisabled, resetDraft, thumbnailObjectUrl])
|
||||
}, [controls, resetDisabled, resetDraft])
|
||||
|
||||
const save = useCallback (async (): Promise<boolean> => {
|
||||
setSaving (true)
|
||||
@@ -184,7 +196,11 @@ const PostImportRowForm: FC<Props> = (
|
||||
<div className="grid gap-6 md:grid-cols-[7rem_minmax(0,1fr)]">
|
||||
<div className="space-y-3 md:sticky md:top-0 md:self-start">
|
||||
<PostImportThumbnailPreview
|
||||
url={committedThumbnailBase || thumbnailObjectUrl || ''}
|
||||
url={committedThumbnailBase}
|
||||
file={
|
||||
hasThumbnailBaseValue (committedThumbnailBase)
|
||||
? undefined
|
||||
: draft.thumbnailFile}
|
||||
className="h-28 w-28"/>
|
||||
</div>
|
||||
|
||||
@@ -204,40 +220,22 @@ const PostImportRowForm: FC<Props> = (
|
||||
label="サムネール"
|
||||
value={draft.thumbnailBase}
|
||||
disabled={saving}
|
||||
warnings={displayRow.fieldWarnings.thumbnailBase}
|
||||
warnings={currentThumbnailWarnings}
|
||||
errors={groupedMessages (
|
||||
displayRow.validationErrors.thumbnailBase,
|
||||
displayRow.importErrors?.thumbnailBase)}
|
||||
onBlur={() => {
|
||||
if (draft.thumbnailBase !== committedThumbnailBase)
|
||||
if (draft.thumbnailBase.trim () !== committedThumbnailBase.trim ())
|
||||
setCommittedThumbnailBase (draft.thumbnailBase)
|
||||
}}
|
||||
onChange={value => {
|
||||
if (value && thumbnailObjectUrl != null)
|
||||
{
|
||||
URL.revokeObjectURL (thumbnailObjectUrl)
|
||||
setThumbnailObjectUrl (undefined)
|
||||
update ('thumbnailFile', undefined)
|
||||
}
|
||||
update ('thumbnailBase', value)
|
||||
}}/>
|
||||
{!(draft.thumbnailBase) && (
|
||||
onChange={value => update ('thumbnailBase', value)}/>
|
||||
{!(hasThumbnailBaseValue (draft.thumbnailBase)) && (
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
disabled={saving}
|
||||
onChange={event => {
|
||||
const file = event.target.files?.[0]
|
||||
if (thumbnailObjectUrl != null)
|
||||
URL.revokeObjectURL (thumbnailObjectUrl)
|
||||
if (file == null)
|
||||
{
|
||||
setThumbnailObjectUrl (undefined)
|
||||
update ('thumbnailFile', undefined)
|
||||
return
|
||||
}
|
||||
const objectUrl = URL.createObjectURL (file)
|
||||
setThumbnailObjectUrl (objectUrl)
|
||||
update ('thumbnailFile', file)
|
||||
}}/>)}
|
||||
</>}
|
||||
@@ -283,7 +281,18 @@ const PostImportRowForm: FC<Props> = (
|
||||
disabled: saving,
|
||||
errors: groupedMessages (
|
||||
displayRow.validationErrors.parentPostIds,
|
||||
displayRow.importErrors?.parentPostIds) } }}/>
|
||||
displayRow.importErrors?.parentPostIds) } }}
|
||||
extraFields={
|
||||
durationVisible
|
||||
? (
|
||||
<PostDurationField
|
||||
value={draft.duration}
|
||||
onChange={value => update ('duration', value)}
|
||||
disabled={saving}
|
||||
errors={groupedMessages (
|
||||
displayRow.validationErrors.videoMs,
|
||||
displayRow.importErrors?.videoMs)}/>)
|
||||
: null}/>
|
||||
<FieldWarning messages={displayRow.baseWarnings}/>
|
||||
<FieldError messages={displayRow.validationErrors.base}/>
|
||||
<FieldError messages={displayRow.importErrors?.base}/>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button'
|
||||
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
|
||||
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
|
||||
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
|
||||
import { hasVideoTag } from '@/lib/postImportSession'
|
||||
import { canEditReviewRow, canRetryResultRow } from '@/lib/postImportSession'
|
||||
import { cn, originalCreatedAtString } from '@/lib/utils'
|
||||
|
||||
@@ -54,6 +55,8 @@ const PostImportRowSummary: FC<Props> = (
|
||||
const retryAllowed = onRetry != null && canRetryResultRow (row)
|
||||
const skipChecked = row.skipReason === 'manual'
|
||||
const rowNumber = displayNumber ?? row.sourceRow
|
||||
const duration = String (row.attributes.duration ?? '')
|
||||
const showDuration = hasVideoTag (row.attributes.tags) && duration !== ''
|
||||
const skipControl = showSkipToggle
|
||||
? (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
@@ -78,10 +81,11 @@ const PostImportRowSummary: FC<Props> = (
|
||||
</div>
|
||||
<PostImportThumbnailPreview
|
||||
url={String (row.attributes.thumbnailBase ?? '')}
|
||||
file={row.thumbnailFile}
|
||||
className="h-16 w-16"/>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="line-clamp-2 text-sm font-medium">
|
||||
{String (row.attributes.title ?? '') || '(タイトル未取得)'}
|
||||
{String (row.attributes.title ?? '')}
|
||||
</div>
|
||||
<div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
|
||||
{row.url}
|
||||
@@ -93,6 +97,10 @@ const PostImportRowSummary: FC<Props> = (
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{summaryDate (row)}
|
||||
</div>
|
||||
{showDuration && (
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
動画時間 {duration}
|
||||
</div>)}
|
||||
{warning && (
|
||||
<div className="text-xs text-amber-700 dark:text-amber-200">
|
||||
{warning}
|
||||
@@ -133,10 +141,11 @@ const PostImportRowSummary: FC<Props> = (
|
||||
<div className="flex items-start gap-3">
|
||||
<PostImportThumbnailPreview
|
||||
url={String (row.attributes.thumbnailBase ?? '')}
|
||||
file={row.thumbnailFile}
|
||||
className="h-20 w-20 shrink-0"/>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="line-clamp-2 text-sm font-medium">
|
||||
{String (row.attributes.title ?? '') || '(タイトル未取得)'}
|
||||
{String (row.attributes.title ?? '')}
|
||||
</div>
|
||||
<div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
|
||||
{row.url}
|
||||
@@ -151,6 +160,10 @@ const PostImportRowSummary: FC<Props> = (
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{summaryDate (row)}
|
||||
</div>
|
||||
{showDuration && (
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
動画時間 {duration}
|
||||
</div>)}
|
||||
{warning && (
|
||||
<div className="text-xs text-amber-700 dark:text-amber-200">
|
||||
{warning}
|
||||
|
||||
@@ -11,12 +11,16 @@ type Props = {
|
||||
const LABELS: Record<PostImportBadgeValue, string> = {
|
||||
ready: '登録可能',
|
||||
warning: '警告',
|
||||
skipped: 'スキップ' }
|
||||
skipped: 'スキップ',
|
||||
created: '登録済み',
|
||||
failed: '登録失敗' }
|
||||
|
||||
const TONES: Record<PostImportBadgeValue, StatusBadgeTone> = {
|
||||
ready: 'success',
|
||||
warning: 'warning',
|
||||
skipped: 'neutral' }
|
||||
skipped: 'neutral',
|
||||
created: 'success',
|
||||
failed: 'warning' }
|
||||
|
||||
|
||||
const PostImportStatusBadge: FC<Props> = ({ value }) => (
|
||||
|
||||
@@ -4,17 +4,20 @@ import type { FC } from 'react'
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
file?: File
|
||||
alt?: string
|
||||
className?: string }
|
||||
|
||||
|
||||
const PostImportThumbnailPreview: FC<Props> = (
|
||||
{ url,
|
||||
file,
|
||||
alt = 'サムネール',
|
||||
className = 'h-16 w-16' },
|
||||
) => (
|
||||
<PostThumbnailPreview
|
||||
url={url}
|
||||
file={file}
|
||||
alt={alt}
|
||||
className={className}
|
||||
referrerPolicy="no-referrer"/>)
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { PostImportRow } from '@/lib/postImportSession'
|
||||
|
||||
export type PostImportDisplayStatus = 'ready' | 'skipped' | 'warning'
|
||||
export type PostImportDisplayStatus =
|
||||
'ready'
|
||||
| 'skipped'
|
||||
| 'warning'
|
||||
| 'created'
|
||||
| 'failed'
|
||||
|
||||
export type PostImportBadgeValue = PostImportDisplayStatus
|
||||
|
||||
@@ -13,10 +18,11 @@ export const displayPostImportStatus = (
|
||||
): PostImportDisplayStatus | null =>
|
||||
(row.skipReason != null || row.importStatus === 'skipped')
|
||||
? 'skipped'
|
||||
: ((Object.keys (row.validationErrors ?? { }).length > 0
|
||||
|| row.importStatus === 'failed'
|
||||
|| row.importStatus === 'created')
|
||||
? null
|
||||
: ((hasWarnings (row) || row.status === 'warning')
|
||||
? 'warning'
|
||||
: 'ready'))
|
||||
: (row.importStatus === 'created')
|
||||
? 'created'
|
||||
: ((Object.keys (row.validationErrors ?? { }).length > 0
|
||||
|| row.importStatus === 'failed')
|
||||
? 'failed'
|
||||
: ((hasWarnings (row) || row.status === 'warning')
|
||||
? 'warning'
|
||||
: 'ready'))
|
||||
|
||||
@@ -4,6 +4,13 @@ import type { PostImportEditableDraft,
|
||||
|
||||
const THUMBNAIL_MISSING_WARNING = 'サムネールなし'
|
||||
|
||||
export const hasThumbnailBaseValue = (value: unknown): boolean =>
|
||||
typeof value === 'string' && value.trim () !== ''
|
||||
|
||||
export const hasVideoTag = (value: unknown): boolean =>
|
||||
typeof value === 'string'
|
||||
&& value.split (/\s+/).includes ('動画')
|
||||
|
||||
export const isExistingSkipRow = (row: PostImportRow): boolean =>
|
||||
row.skipReason === 'existing'
|
||||
|
||||
@@ -64,7 +71,7 @@ const deduped = (values: string[]): string[] =>
|
||||
const thumbnailWarnings = (row: PostImportRow): string[] => {
|
||||
const current = row.fieldWarnings.thumbnailBase ?? []
|
||||
const others = current.filter (message => message !== THUMBNAIL_MISSING_WARNING)
|
||||
return row.attributes.thumbnailBase || row.thumbnailFile != null
|
||||
return hasThumbnailBaseValue (row.attributes.thumbnailBase) || row.thumbnailFile != null
|
||||
? others
|
||||
: deduped ([...others, THUMBNAIL_MISSING_WARNING])
|
||||
}
|
||||
@@ -168,6 +175,14 @@ export const resultRowWarnings = (row: PostImportRow): string[] =>
|
||||
...Object.values (row.fieldWarnings ?? { }).flat (),
|
||||
...row.baseWarnings])]
|
||||
|
||||
|
||||
const isManualChange = (
|
||||
current: unknown,
|
||||
next: string,
|
||||
): boolean =>
|
||||
next !== String (current ?? '')
|
||||
|
||||
|
||||
export const buildNextEditedRow = (
|
||||
editingRow: PostImportRow,
|
||||
draft: PostImportEditableDraft,
|
||||
@@ -183,16 +198,22 @@ export const buildNextEditedRow = (
|
||||
['thumbnailBase', draft.thumbnailBase],
|
||||
['originalCreatedFrom', draft.originalCreatedFrom],
|
||||
['originalCreatedBefore', draft.originalCreatedBefore],
|
||||
['duration', draft.duration],
|
||||
['parentPostIds', draft.parentPostIds]] as const
|
||||
draftFields.forEach (([field, value]) => {
|
||||
nextAttributes[field] = value
|
||||
nextProvenance[field] =
|
||||
value !== String (editingRow.attributes[field] ?? '')
|
||||
isManualChange (editingRow.attributes[field], value)
|
||||
? 'manual'
|
||||
: (editingRow.provenance[field] ?? 'automatic')
|
||||
})
|
||||
if (nextProvenance.duration === 'manual')
|
||||
{
|
||||
nextAttributes.videoMs = ''
|
||||
nextProvenance.videoMs = 'manual'
|
||||
}
|
||||
nextAttributes.tags = draft.tags
|
||||
if (draft.tags !== String (editingRow.attributes.tags ?? ''))
|
||||
if (isManualChange (editingRow.attributes.tags, draft.tags))
|
||||
{
|
||||
nextProvenance.tags = 'manual'
|
||||
nextTagSources.manual = draft.tags
|
||||
|
||||
@@ -25,6 +25,31 @@ const ATTRIBUTE_KEYS = [
|
||||
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',
|
||||
'resetSnapshot',
|
||||
'createdPostId',
|
||||
'importStatus',
|
||||
'recoverable'] as const
|
||||
const SESSION_KEYS = [
|
||||
'version',
|
||||
'expiresAt',
|
||||
'source',
|
||||
'rows',
|
||||
'repairMode'] as const
|
||||
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
||||
@@ -220,10 +245,110 @@ const sanitiseExistingPost = (value: unknown): PostImportExistingPost | undefine
|
||||
thumbnailUrl: value.thumbnailUrl as string | undefined }
|
||||
}
|
||||
|
||||
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,
|
||||
thumbnailUrl: value.thumbnailUrl }
|
||||
|
||||
const serialiseResetSnapshot = (value: PostImportResetSnapshot) => ({
|
||||
url: value.url,
|
||||
attributes: Object.fromEntries (
|
||||
Object.entries (value.attributes).map (([key, entry]) => [key, entry])),
|
||||
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])),
|
||||
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 =>
|
||||
JSON.stringify (serialisePostImportRows (left))
|
||||
=== JSON.stringify (serialisePostImportRows (right))
|
||||
|
||||
|
||||
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')
|
||||
@@ -321,6 +446,8 @@ const sanitiseRow = (value: unknown): PostImportRow | null => {
|
||||
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 (typeof value.expiresAt !== 'string' || isExpiredSession (value.expiresAt))
|
||||
@@ -334,7 +461,7 @@ const sanitiseSession = (value: unknown): PostImportSession | null => {
|
||||
version: SESSION_VERSION,
|
||||
expiresAt: value.expiresAt,
|
||||
source: typeof value.source === 'string' ? value.source : '',
|
||||
rows: rows as PostImportRow[],
|
||||
rows: (rows as PostImportRow[]).map (row => recalculateThumbnailWarning (row)),
|
||||
repairMode: value.repairMode === 'failed' ? 'failed' : 'all' }
|
||||
}
|
||||
|
||||
@@ -438,7 +565,9 @@ export const savePostImportSession = (
|
||||
return writeStorage (
|
||||
sessionKey (validatedId),
|
||||
JSON.stringify ({
|
||||
...session,
|
||||
source: session.source,
|
||||
rows: serialisePostImportRows (session.rows),
|
||||
repairMode: session.repairMode,
|
||||
version: SESSION_VERSION,
|
||||
expiresAt: new Date (Date.now () + SESSION_MAX_AGE_MS).toISOString () }),
|
||||
onError)
|
||||
|
||||
@@ -43,8 +43,7 @@ export type PostImportRow = {
|
||||
createdPostId?: number
|
||||
importStatus?: PostImportStatus
|
||||
recoverable?: boolean
|
||||
thumbnailFile?: File
|
||||
thumbnailObjectUrl?: string }
|
||||
thumbnailFile?: File }
|
||||
|
||||
export type PostImportResultRow =
|
||||
| {
|
||||
@@ -88,6 +87,7 @@ export type PostImportEditableDraft = {
|
||||
thumbnailBase: string
|
||||
originalCreatedFrom: string
|
||||
originalCreatedBefore: string
|
||||
duration: string
|
||||
tags: string
|
||||
parentPostIds: string
|
||||
thumbnailFile?: File }
|
||||
|
||||
@@ -237,6 +237,7 @@ const manualFields = (row: PostImportRow): string[] =>
|
||||
'thumbnailBase',
|
||||
'originalCreatedFrom',
|
||||
'originalCreatedBefore',
|
||||
'duration',
|
||||
'tags',
|
||||
'parentPostIds']
|
||||
.filter (field => row.provenance[field] === 'manual')
|
||||
@@ -268,8 +269,8 @@ const buildRow = (state: FullRowState): PostImportRow => {
|
||||
thumbnailBase: manual.has ('thumbnailBase') ? 'manual' : 'automatic',
|
||||
originalCreatedFrom: manual.has ('originalCreatedFrom') ? 'manual' : 'automatic',
|
||||
originalCreatedBefore: manual.has ('originalCreatedBefore') ? 'manual' : 'automatic',
|
||||
videoMs: 'automatic',
|
||||
duration: 'automatic',
|
||||
videoMs: manual.has ('duration') ? 'manual' : 'automatic',
|
||||
duration: manual.has ('duration') ? 'manual' : 'automatic',
|
||||
tags: manual.has ('tags') ? 'manual' : 'automatic',
|
||||
parentPostIds: manual.has ('parentPostIds') ? 'manual' : 'automatic' }
|
||||
const tagSources = provenance.tags === 'manual'
|
||||
@@ -280,7 +281,7 @@ const buildRow = (state: FullRowState): PostImportRow => {
|
||||
thumbnailBase: state.thumbnail_base,
|
||||
originalCreatedFrom: state.original_created_from,
|
||||
originalCreatedBefore: state.original_created_before,
|
||||
videoMs: state.video_ms,
|
||||
videoMs: manual.has ('duration') ? '' : state.video_ms,
|
||||
duration: state.duration,
|
||||
tags: state.tags,
|
||||
parentPostIds: state.parent_post_ids }
|
||||
@@ -651,6 +652,7 @@ export const hasPostNewReviewState = (search: string): boolean => {
|
||||
|| params.has ('urls')
|
||||
|| params.has ('meta')
|
||||
|| params.has ('url')
|
||||
|| parsePostNewSessionId (search) != null
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
clearPostImportSession,
|
||||
clearPostImportSourceDraft,
|
||||
generatePostImportSessionId,
|
||||
hasThumbnailBaseValue,
|
||||
initialisePreviewRows,
|
||||
isCompletedReviewRow,
|
||||
isExistingSkipRow,
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
reviewSummaryCounts,
|
||||
retryImportRow,
|
||||
loadPostImportSession,
|
||||
serialisedPostImportRowsEqual,
|
||||
savePostImportSession,
|
||||
} from '@/lib/postImportSession'
|
||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||
@@ -125,7 +127,7 @@ const buildDryRunFormData = (row: PostImportRow): FormData => {
|
||||
String (row.attributes.originalCreatedBefore ?? ''))
|
||||
formData.append ('video_ms', String (row.attributes.videoMs ?? ''))
|
||||
formData.append ('duration', String (row.attributes.duration ?? ''))
|
||||
if (!(row.attributes.thumbnailBase) && row.thumbnailFile != null)
|
||||
if (!(hasThumbnailBaseValue (row.attributes.thumbnailBase)) && row.thumbnailFile != null)
|
||||
formData.append ('thumbnail', row.thumbnailFile)
|
||||
return formData
|
||||
}
|
||||
@@ -174,6 +176,12 @@ const indexedBulkResults = (
|
||||
): PostImportResultRow[] =>
|
||||
rows.map ((row, index) => {
|
||||
const result = results[index]
|
||||
const errors =
|
||||
result?.baseErrors?.length
|
||||
? {
|
||||
...(result.errors ?? { }),
|
||||
base: [...new Set ([...(result.errors?.base ?? []), ...result.baseErrors])] }
|
||||
: result?.errors
|
||||
if (result?.status === 'created' && result.post != null)
|
||||
{
|
||||
return {
|
||||
@@ -182,7 +190,7 @@ const indexedBulkResults = (
|
||||
post: result.post,
|
||||
fieldWarnings: result.fieldWarnings,
|
||||
baseWarnings: result.baseWarnings,
|
||||
errors: result.errors }
|
||||
errors }
|
||||
}
|
||||
if (result?.status === 'skipped' && result.existingPost != null)
|
||||
{
|
||||
@@ -193,52 +201,28 @@ const indexedBulkResults = (
|
||||
existingPost: result.existingPost,
|
||||
fieldWarnings: result.fieldWarnings,
|
||||
baseWarnings: result.baseWarnings,
|
||||
errors: result.errors }
|
||||
errors }
|
||||
}
|
||||
return {
|
||||
sourceRow: row.sourceRow,
|
||||
status: 'failed',
|
||||
fieldWarnings: result?.fieldWarnings,
|
||||
baseWarnings: result?.baseWarnings,
|
||||
errors: result?.errors,
|
||||
errors,
|
||||
recoverable: result?.recoverable }
|
||||
})
|
||||
|
||||
const serialisableRowJson = (row: PostImportRow): string =>
|
||||
JSON.stringify ({
|
||||
url: row.url,
|
||||
sourceRow: row.sourceRow,
|
||||
attributes: row.attributes,
|
||||
fieldWarnings: row.fieldWarnings,
|
||||
baseWarnings: row.baseWarnings,
|
||||
validationErrors: row.validationErrors,
|
||||
importErrors: row.importErrors,
|
||||
provenance: row.provenance,
|
||||
tagSources: row.tagSources,
|
||||
status: row.status,
|
||||
skipReason: row.skipReason,
|
||||
existingPostId: row.existingPostId,
|
||||
existingPost: row.existingPost,
|
||||
metadataUrl: row.metadataUrl,
|
||||
resetSnapshot: row.resetSnapshot,
|
||||
createdPostId: row.createdPostId,
|
||||
importStatus: row.importStatus,
|
||||
recoverable: row.recoverable })
|
||||
|
||||
const serialisableRowsEqual = (
|
||||
left: PostImportRow[],
|
||||
right: PostImportRow[],
|
||||
): boolean =>
|
||||
left.length === right.length
|
||||
&& left.every ((row, index) =>
|
||||
serialisableRowJson (row) === serialisableRowJson (right[index]))
|
||||
|
||||
const mergePreviewRow = (
|
||||
currentRow: PostImportRow,
|
||||
preview: PreviewResponse,
|
||||
): PostImportRow => {
|
||||
const nextRow: PostImportRow = {
|
||||
...currentRow,
|
||||
attributes: { ...currentRow.attributes },
|
||||
provenance: { ...currentRow.provenance },
|
||||
tagSources: {
|
||||
automatic: currentRow.tagSources?.automatic ?? '',
|
||||
manual: currentRow.tagSources?.manual ?? '' },
|
||||
url: preview.url,
|
||||
fieldWarnings: preview.fieldWarnings ?? { },
|
||||
baseWarnings: preview.baseWarnings ?? [],
|
||||
@@ -267,9 +251,7 @@ const mergePreviewRow = (
|
||||
if (currentRow.provenance.tags !== 'manual')
|
||||
{
|
||||
nextRow.attributes.tags = preview.tags ?? ''
|
||||
nextRow.tagSources = {
|
||||
automatic: preview.tags ?? '',
|
||||
manual: currentRow.tagSources?.manual ?? '' }
|
||||
nextRow.tagSources.automatic = preview.tags ?? ''
|
||||
}
|
||||
if (currentRow.provenance.videoMs !== 'manual')
|
||||
nextRow.attributes.videoMs = preview.videoMs ?? ''
|
||||
@@ -296,7 +278,7 @@ const buildBulkFormData = (rows: PostImportRow[]): FormData => {
|
||||
video_ms: row.attributes.videoMs ?? '',
|
||||
duration: row.attributes.duration ?? '' }))))
|
||||
rows.forEach ((row, index) => {
|
||||
if (!(row.attributes.thumbnailBase) && row.thumbnailFile != null)
|
||||
if (!(hasThumbnailBaseValue (row.attributes.thumbnailBase)) && row.thumbnailFile != null)
|
||||
formData.append (`thumbnails[${ index }]`, row.thumbnailFile)
|
||||
})
|
||||
return formData
|
||||
@@ -380,10 +362,10 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
const canRecoverFromUrlOnly = serialised.complete
|
||||
if (!(canRecoverFromUrlOnly)
|
||||
&& (!(saved))
|
||||
&& !(serialisableRowsEqual (loadedSession?.rows ?? [], nextSession.rows)))
|
||||
&& !(serialisedPostImportRowsEqual (loadedSession?.rows ?? [], nextSession.rows)))
|
||||
return null
|
||||
if (saved
|
||||
&& !(serialisableRowsEqual (loadedSession?.rows ?? [], nextSession.rows)))
|
||||
&& !(serialisedPostImportRowsEqual (loadedSession?.rows ?? [], nextSession.rows)))
|
||||
return null
|
||||
|
||||
const persistedSession = buildSession (nextSession.rows)
|
||||
@@ -425,6 +407,11 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
return
|
||||
|
||||
const baseRow = baseSession.rows[index]
|
||||
if (baseRow.skipReason === 'manual'
|
||||
|| baseRow.importStatus === 'created'
|
||||
|| baseRow.importStatus === 'skipped'
|
||||
|| isNonRecoverableFailedRow (baseRow))
|
||||
continue
|
||||
const controller = new AbortController ()
|
||||
controllers.add (controller)
|
||||
try
|
||||
@@ -441,10 +428,8 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
if (currentRow == null)
|
||||
continue
|
||||
const mergedRow = mergePreviewRow (currentRow, preview)
|
||||
const nextRows = replaceImportRow (latestSession.rows, mergedRow)
|
||||
const nextSession = buildSession (nextRows)
|
||||
sessionRef.current = nextSession
|
||||
setSession (nextSession)
|
||||
await persistSession (buildSession (
|
||||
replaceImportRow (latestSession.rows, mergedRow)))
|
||||
}
|
||||
catch (requestError)
|
||||
{
|
||||
@@ -457,15 +442,6 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
row => row.sourceRow === baseRow.sourceRow)
|
||||
if (currentRow == null)
|
||||
continue
|
||||
const nextRows = replaceImportRow (
|
||||
latestSession.rows,
|
||||
applyThumbnailWarning ({
|
||||
...currentRow,
|
||||
existingPostId: undefined,
|
||||
existingPost: undefined }))
|
||||
const nextSession = buildSession (nextRows)
|
||||
sessionRef.current = nextSession
|
||||
setSession (nextSession)
|
||||
if (!(isApiError (requestError)))
|
||||
continue
|
||||
}
|
||||
@@ -481,24 +457,25 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
}
|
||||
|
||||
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)
|
||||
if (parsed == null && loadedSession == null)
|
||||
{
|
||||
navigate ('/posts/new', { replace: true })
|
||||
return
|
||||
}
|
||||
|
||||
const parsedSessionId = parsePostNewSessionId (location.search)
|
||||
const sessionId = parsedSessionId ?? generatePostImportSessionId ()
|
||||
sessionIdRef.current = sessionId
|
||||
const loaded = loadPostImportSession (sessionId) ?? buildSession (parsed.rows)
|
||||
const loaded = loadedSession ?? buildSession (parsed?.rows ?? [])
|
||||
persistedSearchRef.current = location.search
|
||||
sessionRef.current = loaded
|
||||
setSession (loaded)
|
||||
|
||||
if (parsed.shortcut)
|
||||
if (loadedSession == null && parsed?.shortcut)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -580,7 +557,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (parsedSessionId == null)
|
||||
if (loadedSession == null)
|
||||
{
|
||||
await persistSession (loaded)
|
||||
}
|
||||
@@ -672,21 +649,28 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
.map (([key, values]) => [key, [...values]])),
|
||||
baseWarnings: [...row.resetSnapshot.baseWarnings],
|
||||
metadataUrl: row.resetSnapshot.metadataUrl,
|
||||
thumbnailFile: undefined,
|
||||
thumbnailObjectUrl: undefined }
|
||||
thumbnailFile: undefined }
|
||||
: row
|
||||
const urlChanged = draft.url !== baseRow.url
|
||||
const nextRow = buildNextEditedRow (baseRow, draft, urlChanged)
|
||||
let candidateRow = nextRow
|
||||
try
|
||||
{
|
||||
candidateRow =
|
||||
urlChanged
|
||||
? mergePreviewRow (
|
||||
nextRow,
|
||||
await apiGet<PreviewResponse> ('/posts/preview', {
|
||||
params: { url: nextRow.url } }))
|
||||
: nextRow
|
||||
const dryRun = await apiPost<PreviewResponse> (
|
||||
'/posts?dry=1',
|
||||
buildDryRunFormData (nextRow))
|
||||
buildDryRunFormData (candidateRow))
|
||||
const latestSession = sessionRef.current
|
||||
if (latestSession == null)
|
||||
return { saved: false, row: null }
|
||||
|
||||
const mergedRow = mergeDryRunRow (nextRow, dryRun)
|
||||
const mergedRow = mergeDryRunRow (candidateRow, dryRun)
|
||||
const mergedRows = replaceImportRow (latestSession.rows, mergedRow)
|
||||
const nextSession = await persistSession (buildSession (mergedRows))
|
||||
if (nextSession == null)
|
||||
@@ -708,7 +692,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
&& requestError.response?.status === 422)
|
||||
{
|
||||
const errorRow = {
|
||||
...nextRow,
|
||||
...candidateRow,
|
||||
validationErrors: requestError.response.data.errors ?? { },
|
||||
baseWarnings: [],
|
||||
fieldWarnings: { },
|
||||
@@ -731,7 +715,6 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
|
||||
await dialogue.form ({
|
||||
title: '投稿を編輯',
|
||||
description: `投稿 ${ row.sourceRow } の内容を確認し、必要な項目を編輯してください.`,
|
||||
cancelText: '取消',
|
||||
size: 'large',
|
||||
body: controls => (
|
||||
|
||||
@@ -24,6 +24,7 @@ import { countImportSourceLines,
|
||||
loadPostImportSession,
|
||||
loadPostImportSourceDraft,
|
||||
resultRepairMode,
|
||||
serialisedPostImportRowsEqual,
|
||||
savePostImportSession,
|
||||
savePostImportSourceDraft,
|
||||
validateImportSource } from '@/lib/postImportSession'
|
||||
@@ -143,8 +144,16 @@ const fetchPreviewRows = async (
|
||||
rows: Array<{ sourceRow: number
|
||||
url: string }>,
|
||||
signal: AbortSignal,
|
||||
): Promise<PostImportRow[]> => {
|
||||
const results: PostImportRow[] = new Array (rows.length)
|
||||
): Promise<{
|
||||
rows: PostImportRow[]
|
||||
issues: Array<{ sourceRow: number
|
||||
message: string
|
||||
url: string }>
|
||||
}> => {
|
||||
const results: Array<PostImportRow | null> = new Array (rows.length).fill (null)
|
||||
const issues: Array<{ sourceRow: number
|
||||
message: string
|
||||
url: string }> = []
|
||||
let nextIndex = 0
|
||||
const worker = async () => {
|
||||
while (nextIndex < rows.length)
|
||||
@@ -152,44 +161,43 @@ const fetchPreviewRows = async (
|
||||
const index = nextIndex
|
||||
++nextIndex
|
||||
const row = rows[index]
|
||||
const result = await apiGet<PreviewResponse> ('/posts/preview', {
|
||||
params: { url: row.url },
|
||||
signal })
|
||||
results[index] = previewRowFromResult (row.sourceRow, result)
|
||||
try
|
||||
{
|
||||
const result = await apiGet<PreviewResponse> ('/posts/preview', {
|
||||
params: { url: row.url },
|
||||
signal })
|
||||
results[index] = previewRowFromResult (row.sourceRow, result)
|
||||
}
|
||||
catch (requestError)
|
||||
{
|
||||
if (signal.aborted)
|
||||
return
|
||||
|
||||
const apiMessage =
|
||||
isApiError<{
|
||||
message?: string
|
||||
errors?: Record<string, string[]>
|
||||
baseErrors?: string[]
|
||||
}> (requestError)
|
||||
? (requestError.response?.data?.errors?.url?.[0]
|
||||
?? requestError.response?.data?.message
|
||||
?? requestError.response?.data?.baseErrors?.[0])
|
||||
: undefined
|
||||
issues.push ({
|
||||
sourceRow: row.sourceRow,
|
||||
message: apiMessage ?? '入力を確認してください.',
|
||||
url: row.url })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all (
|
||||
Array.from ({ length: Math.min (4, rows.length) }, () => worker ()))
|
||||
return results
|
||||
return {
|
||||
rows: results.filter ((row): row is PostImportRow => row != null),
|
||||
issues }
|
||||
}
|
||||
|
||||
const serialisableRowJson = (row: PostImportRow): string =>
|
||||
JSON.stringify ({
|
||||
sourceRow: row.sourceRow,
|
||||
url: row.url,
|
||||
attributes: row.attributes,
|
||||
fieldWarnings: row.fieldWarnings,
|
||||
baseWarnings: row.baseWarnings,
|
||||
validationErrors: row.validationErrors,
|
||||
provenance: row.provenance,
|
||||
tagSources: row.tagSources,
|
||||
status: row.status,
|
||||
skipReason: row.skipReason,
|
||||
existingPostId: row.existingPostId,
|
||||
existingPost: row.existingPost,
|
||||
resetSnapshot: row.resetSnapshot,
|
||||
importStatus: row.importStatus,
|
||||
recoverable: row.recoverable })
|
||||
|
||||
const sameRows = (
|
||||
left: PostImportRow[],
|
||||
right: PostImportRow[],
|
||||
): boolean =>
|
||||
left.length === right.length
|
||||
&& left.every ((row, index) =>
|
||||
serialisableRowJson (row) === serialisableRowJson (right[index]))
|
||||
|
||||
|
||||
const PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||
const editable = canEditContent (user)
|
||||
@@ -269,7 +277,7 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||
previewController.current?.abort ()
|
||||
const controller = new AbortController ()
|
||||
previewController.current = controller
|
||||
const rows = await fetchPreviewRows (
|
||||
const previewed = await fetchPreviewRows (
|
||||
source
|
||||
.split (/\r\n|\n|\r/)
|
||||
.map ((line, index) => ({
|
||||
@@ -277,13 +285,31 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||
url: line.trim () }))
|
||||
.filter (row => row.url !== ''),
|
||||
controller.signal)
|
||||
const urlIssues = urlIssuesFromRows (rows, source)
|
||||
if (urlIssues.length > 0)
|
||||
if (previewed.issues.length > 0)
|
||||
{
|
||||
setSourceIssues (urlIssues)
|
||||
setSourceIssues (previewed.issues)
|
||||
return
|
||||
}
|
||||
const nextRows = initialisePreviewRows (rows)
|
||||
const urlIssues = urlIssuesFromRows (previewed.rows, source)
|
||||
const duplicateIssues = Object.entries (
|
||||
previewed.rows.reduce<Record<string, PostImportRow[]>> ((acc, row) => {
|
||||
acc[row.url] ||= []
|
||||
acc[row.url].push (row)
|
||||
return acc
|
||||
}, { }))
|
||||
.flatMap (([, rows]) =>
|
||||
rows.length < 2
|
||||
? []
|
||||
: rows.map (row => ({
|
||||
sourceRow: row.sourceRow,
|
||||
message: 'URL が重複しています.',
|
||||
url: row.url })))
|
||||
if (urlIssues.length > 0 || duplicateIssues.length > 0)
|
||||
{
|
||||
setSourceIssues ([...urlIssues, ...duplicateIssues])
|
||||
return
|
||||
}
|
||||
const nextRows = initialisePreviewRows (previewed.rows)
|
||||
const serialised = await serialisePostNewState (nextRows)
|
||||
if (serialised == null)
|
||||
return
|
||||
@@ -297,9 +323,9 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||
const sessionLoaded = loadPostImportSession (sessionId)
|
||||
if (!(serialised.complete)
|
||||
&& (!(saved))
|
||||
&& !(sameRows (sessionLoaded?.rows ?? [], nextRows)))
|
||||
&& !(serialisedPostImportRowsEqual (sessionLoaded?.rows ?? [], nextRows)))
|
||||
return
|
||||
if (saved && !(sameRows (sessionLoaded?.rows ?? [], nextRows)))
|
||||
if (saved && !(serialisedPostImportRowsEqual (sessionLoaded?.rows ?? [], nextRows)))
|
||||
return
|
||||
|
||||
navigate (appendPostNewSessionId (serialised.path, sessionId))
|
||||
|
||||
新しい課題から参照
ユーザをブロックする