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