このコミットが含まれているのは:
@@ -126,7 +126,7 @@ class PostImportPreviewer
|
|||||||
|
|
||||||
warnings_present = field_warnings.values.any?(&:present?) || base_warnings.present?
|
warnings_present = field_warnings.values.any?(&:present?) || base_warnings.present?
|
||||||
{ source_row: row[:source_row],
|
{ source_row: row[:source_row],
|
||||||
url: normal_url || url,
|
url: validation_errors[:url].present? ? url : (normal_url || url),
|
||||||
attributes:,
|
attributes:,
|
||||||
provenance:,
|
provenance:,
|
||||||
tag_sources:,
|
tag_sources:,
|
||||||
@@ -199,7 +199,7 @@ class PostImportPreviewer
|
|||||||
def fetch_metadata url
|
def fetch_metadata url
|
||||||
return { data: { }, warnings: { 'url' => ['URL が空です.'] } } if url.blank?
|
return { data: { }, warnings: { 'url' => ['URL が空です.'] } } if url.blank?
|
||||||
|
|
||||||
data = PostMetadataFetcher.fetch(url).stringify_keys.compact
|
data = sanitise_metadata(PostMetadataFetcher.fetch(url).stringify_keys.compact)
|
||||||
warnings = { }
|
warnings = { }
|
||||||
add_field_warning!(warnings, 'title', TITLE_FETCH_WARNING) if data['title'].blank?
|
add_field_warning!(warnings, 'title', TITLE_FETCH_WARNING) if data['title'].blank?
|
||||||
if data['thumbnail_base'].blank?
|
if data['thumbnail_base'].blank?
|
||||||
@@ -215,6 +215,40 @@ class PostImportPreviewer
|
|||||||
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] } }
|
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] } }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def sanitise_metadata metadata
|
||||||
|
{
|
||||||
|
'title' => sanitise_metadata_title(metadata['title']),
|
||||||
|
'thumbnail_base' => sanitise_metadata_url(metadata['thumbnail_base']),
|
||||||
|
'original_created_from' => sanitise_metadata_time(metadata['original_created_from']),
|
||||||
|
'original_created_before' => sanitise_metadata_time(metadata['original_created_before']),
|
||||||
|
'duration' => sanitise_metadata_duration(metadata['duration']),
|
||||||
|
'tags' => metadata['tags'].to_s.presence }.compact
|
||||||
|
end
|
||||||
|
|
||||||
|
def sanitise_metadata_title value
|
||||||
|
value.is_a?(String) ? value.presence : nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def sanitise_metadata_url value
|
||||||
|
return nil unless value.is_a?(String)
|
||||||
|
|
||||||
|
PostUrlNormaliser.normalise(value)
|
||||||
|
end
|
||||||
|
|
||||||
|
def sanitise_metadata_time value
|
||||||
|
return nil unless value.is_a?(String)
|
||||||
|
|
||||||
|
Time.zone.parse(value)&.iso8601
|
||||||
|
rescue ArgumentError, TypeError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def sanitise_metadata_duration value
|
||||||
|
return nil unless value.is_a?(Numeric)
|
||||||
|
|
||||||
|
value.positive? ? value.to_i : nil
|
||||||
|
end
|
||||||
|
|
||||||
def preload_metadata! prepared_rows, fetch_metadata, metadata_cache, existing_posts, url_counts
|
def preload_metadata! prepared_rows, fetch_metadata, metadata_cache, existing_posts, url_counts
|
||||||
urls = prepared_rows.filter_map { |row|
|
urls = prepared_rows.filter_map { |row|
|
||||||
next unless row[:normal_url].present?
|
next unless row[:normal_url].present?
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import FieldWarning from '@/components/common/FieldWarning'
|
|||||||
import FormField from '@/components/common/FormField'
|
import FormField from '@/components/common/FormField'
|
||||||
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
|
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
|
||||||
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview'
|
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview'
|
||||||
import { effectivePostImportStatus } from '@/components/posts/import/postImportRowStatus'
|
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Dialog,
|
import { Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -17,8 +17,7 @@ import { inputClass } from '@/lib/utils'
|
|||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import type { PostImportOrigin,
|
import type { PostImportRow } from '@/lib/postImportSession'
|
||||||
PostImportRow } from '@/lib/postImportSession'
|
|
||||||
|
|
||||||
type Draft = {
|
type Draft = {
|
||||||
url: string
|
url: string
|
||||||
@@ -31,41 +30,12 @@ type Draft = {
|
|||||||
parentPostIds: string }
|
parentPostIds: string }
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
open: boolean
|
open: boolean
|
||||||
row: PostImportRow | null
|
row: PostImportRow | null
|
||||||
saving: boolean
|
messageRow?: PostImportRow | null
|
||||||
onOpenChange: (open: boolean) => void
|
saving: boolean
|
||||||
onSave: (draft: Draft) => Promise<boolean> }
|
onOpenChange: (open: boolean) => void
|
||||||
|
onSave: (draft: Draft) => Promise<boolean> }
|
||||||
const originOf = (
|
|
||||||
row: PostImportRow,
|
|
||||||
field: string,
|
|
||||||
): PostImportOrigin =>
|
|
||||||
row.provenance[field] ?? 'automatic'
|
|
||||||
|
|
||||||
const changedOrigin = (
|
|
||||||
changed: boolean,
|
|
||||||
row: PostImportRow,
|
|
||||||
field: string,
|
|
||||||
): PostImportOrigin =>
|
|
||||||
changed ? 'manual' : originOf (row, field)
|
|
||||||
|
|
||||||
const originalCreatedOrigin = (
|
|
||||||
row: PostImportRow,
|
|
||||||
originalDraft: Draft,
|
|
||||||
draft: Draft,
|
|
||||||
): PostImportOrigin => {
|
|
||||||
const changed =
|
|
||||||
originalDraft.originalCreatedFrom !== draft.originalCreatedFrom
|
|
||||||
|| originalDraft.originalCreatedBefore !== draft.originalCreatedBefore
|
|
||||||
if (changed)
|
|
||||||
return 'manual'
|
|
||||||
|
|
||||||
const manualOrigin =
|
|
||||||
originOf (row, 'originalCreatedFrom') === 'manual'
|
|
||||||
|| originOf (row, 'originalCreatedBefore') === 'manual'
|
|
||||||
return manualOrigin ? 'manual' : 'automatic'
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildDraft = (row: PostImportRow): Draft => ({
|
const buildDraft = (row: PostImportRow): Draft => ({
|
||||||
url: row.url,
|
url: row.url,
|
||||||
@@ -77,6 +47,16 @@ const buildDraft = (row: PostImportRow): Draft => ({
|
|||||||
tags: String (row.attributes.tags ?? ''),
|
tags: String (row.attributes.tags ?? ''),
|
||||||
parentPostIds: String (row.attributes.parentPostIds ?? '') })
|
parentPostIds: String (row.attributes.parentPostIds ?? '') })
|
||||||
|
|
||||||
|
const buildResetDraft = (row: PostImportRow): Draft => ({
|
||||||
|
url: row.resetSnapshot.url,
|
||||||
|
title: String (row.resetSnapshot.attributes.title ?? ''),
|
||||||
|
thumbnailBase: String (row.resetSnapshot.attributes.thumbnailBase ?? ''),
|
||||||
|
originalCreatedFrom: String (row.resetSnapshot.attributes.originalCreatedFrom ?? ''),
|
||||||
|
originalCreatedBefore: String (row.resetSnapshot.attributes.originalCreatedBefore ?? ''),
|
||||||
|
duration: String (row.resetSnapshot.attributes.duration ?? ''),
|
||||||
|
tags: String (row.resetSnapshot.attributes.tags ?? ''),
|
||||||
|
parentPostIds: String (row.resetSnapshot.attributes.parentPostIds ?? '') })
|
||||||
|
|
||||||
const groupedMessages = (
|
const groupedMessages = (
|
||||||
...values: Array<string[] | undefined>
|
...values: Array<string[] | undefined>
|
||||||
): string[] =>
|
): string[] =>
|
||||||
@@ -84,7 +64,7 @@ const groupedMessages = (
|
|||||||
|
|
||||||
|
|
||||||
const PostImportRowDialog: FC<Props> = (
|
const PostImportRowDialog: FC<Props> = (
|
||||||
{ open, row, saving, onOpenChange, onSave },
|
{ open, row, messageRow, saving, onOpenChange, onSave },
|
||||||
) => {
|
) => {
|
||||||
const [draft, setDraft] = useState<Draft | null> (null)
|
const [draft, setDraft] = useState<Draft | null> (null)
|
||||||
|
|
||||||
@@ -96,9 +76,7 @@ const PostImportRowDialog: FC<Props> = (
|
|||||||
if (row == null || draft == null)
|
if (row == null || draft == null)
|
||||||
return null
|
return null
|
||||||
|
|
||||||
const originalDraft = buildDraft (row)
|
const displayRow = messageRow ?? row
|
||||||
const fieldOrigin = (field: keyof Draft): PostImportOrigin =>
|
|
||||||
changedOrigin (draft[field] !== originalDraft[field], row, field)
|
|
||||||
|
|
||||||
const update = <Key extends keyof Draft,> (
|
const update = <Key extends keyof Draft,> (
|
||||||
key: Key,
|
key: Key,
|
||||||
@@ -131,7 +109,7 @@ const PostImportRowDialog: FC<Props> = (
|
|||||||
url={draft.thumbnailBase}
|
url={draft.thumbnailBase}
|
||||||
className="h-28 w-28"/>
|
className="h-28 w-28"/>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<PostImportStatusBadge value={effectivePostImportStatus (row)}/>
|
<PostImportStatusBadge value={displayPostImportStatus (displayRow)}/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -139,79 +117,78 @@ const PostImportRowDialog: FC<Props> = (
|
|||||||
<DialogTextField
|
<DialogTextField
|
||||||
label="URL"
|
label="URL"
|
||||||
value={draft.url}
|
value={draft.url}
|
||||||
origin={fieldOrigin ('url')}
|
warnings={displayRow.fieldWarnings.url}
|
||||||
warnings={row.fieldWarnings.url}
|
errors={groupedMessages (displayRow.validationErrors.url,
|
||||||
errors={groupedMessages (row.validationErrors.url, row.importErrors?.url)}
|
displayRow.importErrors?.url)}
|
||||||
onChange={value => update ('url', value)}/>
|
onChange={value => update ('url', value)}/>
|
||||||
<DialogTextField
|
<DialogTextField
|
||||||
label="タイトル"
|
label="タイトル"
|
||||||
value={draft.title}
|
value={draft.title}
|
||||||
origin={fieldOrigin ('title')}
|
warnings={displayRow.fieldWarnings.title}
|
||||||
warnings={row.fieldWarnings.title}
|
errors={groupedMessages (displayRow.validationErrors.title,
|
||||||
errors={groupedMessages (row.validationErrors.title, row.importErrors?.title)}
|
displayRow.importErrors?.title)}
|
||||||
onChange={value => update ('title', value)}/>
|
onChange={value => update ('title', value)}/>
|
||||||
<DialogTextField
|
<DialogTextField
|
||||||
label="サムネール基底 URL"
|
label="サムネール基底 URL"
|
||||||
value={draft.thumbnailBase}
|
value={draft.thumbnailBase}
|
||||||
origin={fieldOrigin ('thumbnailBase')}
|
warnings={displayRow.fieldWarnings.thumbnailBase}
|
||||||
warnings={row.fieldWarnings.thumbnailBase}
|
|
||||||
errors={groupedMessages (
|
errors={groupedMessages (
|
||||||
row.validationErrors.thumbnailBase,
|
displayRow.validationErrors.thumbnailBase,
|
||||||
row.importErrors?.thumbnailBase,
|
displayRow.importErrors?.thumbnailBase,
|
||||||
)}
|
)}
|
||||||
onChange={value => update ('thumbnailBase', value)}/>
|
onChange={value => update ('thumbnailBase', value)}/>
|
||||||
<PostOriginalCreatedTimeField
|
<PostOriginalCreatedTimeField
|
||||||
labelAddon={
|
|
||||||
<PostImportStatusBadge
|
|
||||||
value={originalCreatedOrigin (row, originalDraft, draft)}/>
|
|
||||||
}
|
|
||||||
originalCreatedFrom={draft.originalCreatedFrom || null}
|
originalCreatedFrom={draft.originalCreatedFrom || null}
|
||||||
setOriginalCreatedFrom={value => update ('originalCreatedFrom', value ?? '')}
|
setOriginalCreatedFrom={value => update ('originalCreatedFrom', value ?? '')}
|
||||||
originalCreatedBefore={draft.originalCreatedBefore || null}
|
originalCreatedBefore={draft.originalCreatedBefore || null}
|
||||||
setOriginalCreatedBefore={value => update ('originalCreatedBefore', value ?? '')}
|
setOriginalCreatedBefore={value => update ('originalCreatedBefore', value ?? '')}
|
||||||
errors={groupedMessages (
|
errors={groupedMessages (
|
||||||
row.validationErrors.originalCreatedAt,
|
displayRow.validationErrors.originalCreatedAt,
|
||||||
row.validationErrors.originalCreatedFrom,
|
displayRow.validationErrors.originalCreatedFrom,
|
||||||
row.validationErrors.originalCreatedBefore,
|
displayRow.validationErrors.originalCreatedBefore,
|
||||||
row.importErrors?.originalCreatedAt,
|
displayRow.importErrors?.originalCreatedAt,
|
||||||
row.importErrors?.originalCreatedFrom,
|
displayRow.importErrors?.originalCreatedFrom,
|
||||||
row.importErrors?.originalCreatedBefore,
|
displayRow.importErrors?.originalCreatedBefore,
|
||||||
)}/>
|
)}/>
|
||||||
<DialogTextField
|
<DialogTextField
|
||||||
label="動画時間"
|
label="動画時間"
|
||||||
value={draft.duration}
|
value={draft.duration}
|
||||||
origin={fieldOrigin ('duration')}
|
|
||||||
errors={groupedMessages (
|
errors={groupedMessages (
|
||||||
row.validationErrors.duration,
|
displayRow.validationErrors.duration,
|
||||||
row.validationErrors.videoMs,
|
displayRow.validationErrors.videoMs,
|
||||||
row.importErrors?.duration,
|
displayRow.importErrors?.duration,
|
||||||
row.importErrors?.videoMs,
|
displayRow.importErrors?.videoMs,
|
||||||
)}
|
)}
|
||||||
onChange={value => update ('duration', value)}/>
|
onChange={value => update ('duration', value)}/>
|
||||||
<DialogAreaField
|
<DialogAreaField
|
||||||
label="タグ"
|
label="タグ"
|
||||||
value={draft.tags}
|
value={draft.tags}
|
||||||
origin={fieldOrigin ('tags')}
|
warnings={displayRow.fieldWarnings.tags}
|
||||||
warnings={row.fieldWarnings.tags}
|
errors={groupedMessages (displayRow.validationErrors.tags,
|
||||||
errors={groupedMessages (row.validationErrors.tags, row.importErrors?.tags)}
|
displayRow.importErrors?.tags)}
|
||||||
onChange={value => update ('tags', value)}/>
|
onChange={value => update ('tags', value)}/>
|
||||||
<DialogTextField
|
<DialogTextField
|
||||||
label="親投稿"
|
label="親投稿"
|
||||||
value={draft.parentPostIds}
|
value={draft.parentPostIds}
|
||||||
origin={fieldOrigin ('parentPostIds')}
|
|
||||||
errors={groupedMessages (
|
errors={groupedMessages (
|
||||||
row.validationErrors.parentPostIds,
|
displayRow.validationErrors.parentPostIds,
|
||||||
row.importErrors?.parentPostIds,
|
displayRow.importErrors?.parentPostIds,
|
||||||
)}
|
)}
|
||||||
onChange={value => update ('parentPostIds', value)}/>
|
onChange={value => update ('parentPostIds', value)}/>
|
||||||
<FieldWarning messages={row.baseWarnings}/>
|
<FieldWarning messages={displayRow.baseWarnings}/>
|
||||||
<FieldError messages={row.validationErrors.base}/>
|
<FieldError messages={displayRow.validationErrors.base}/>
|
||||||
<FieldError messages={row.importErrors?.base}/>
|
<FieldError messages={displayRow.importErrors?.base}/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="shrink-0 px-6 pb-6 pt-4">
|
<DialogFooter className="shrink-0 px-6 pb-6 pt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDraft (buildResetDraft (row))}>
|
||||||
|
変更をリセット
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -230,15 +207,14 @@ const PostImportRowDialog: FC<Props> = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DialogTextField = (
|
const DialogTextField = (
|
||||||
{ label, value, origin, warnings, errors, onChange }: {
|
{ label, value, warnings, errors, onChange }: {
|
||||||
label: string
|
label: string
|
||||||
value: string
|
value: string
|
||||||
origin: PostImportOrigin
|
|
||||||
warnings?: string[]
|
warnings?: string[]
|
||||||
errors?: string[]
|
errors?: string[]
|
||||||
onChange: (value: string) => void },
|
onChange: (value: string) => void },
|
||||||
) => (
|
) => (
|
||||||
<FormField label={<DialogLabel label={label} origin={origin}/>} messages={errors}>
|
<FormField label={label} messages={errors}>
|
||||||
{({ describedBy, invalid }) => (
|
{({ describedBy, invalid }) => (
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
@@ -252,15 +228,14 @@ const DialogTextField = (
|
|||||||
</FormField>)
|
</FormField>)
|
||||||
|
|
||||||
const DialogAreaField = (
|
const DialogAreaField = (
|
||||||
{ label, value, origin, warnings, errors, onChange }: {
|
{ label, value, warnings, errors, onChange }: {
|
||||||
label: string
|
label: string
|
||||||
value: string
|
value: string
|
||||||
origin: PostImportOrigin
|
|
||||||
warnings?: string[]
|
warnings?: string[]
|
||||||
errors?: string[]
|
errors?: string[]
|
||||||
onChange: (value: string) => void },
|
onChange: (value: string) => void },
|
||||||
) => (
|
) => (
|
||||||
<FormField label={<DialogLabel label={label} origin={origin}/>} messages={errors}>
|
<FormField label={label} messages={errors}>
|
||||||
{({ describedBy, invalid }) => (
|
{({ describedBy, invalid }) => (
|
||||||
<>
|
<>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -274,14 +249,4 @@ const DialogAreaField = (
|
|||||||
</>)}
|
</>)}
|
||||||
</FormField>)
|
</FormField>)
|
||||||
|
|
||||||
const DialogLabel = (
|
|
||||||
{ label, origin }: {
|
|
||||||
label: string
|
|
||||||
origin: PostImportOrigin },
|
|
||||||
) => (
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<span>{label}</span>
|
|
||||||
<PostImportStatusBadge value={origin}/>
|
|
||||||
</span>)
|
|
||||||
|
|
||||||
export default PostImportRowDialog
|
export default PostImportRowDialog
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { cn } from '@/lib/utils'
|
|||||||
|
|
||||||
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
|
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
|
||||||
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview'
|
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview'
|
||||||
import { effectivePostImportStatus } from '@/components/posts/import/postImportRowStatus'
|
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
@@ -14,22 +14,13 @@ type Props = {
|
|||||||
onEdit: () => void }
|
onEdit: () => void }
|
||||||
|
|
||||||
const toneClass = (row: PostImportRow): string[] => {
|
const toneClass = (row: PostImportRow): string[] => {
|
||||||
const value = effectivePostImportStatus (row)
|
const value = displayPostImportStatus (row)
|
||||||
switch (value)
|
switch (value)
|
||||||
{
|
{
|
||||||
case 'created':
|
|
||||||
return [
|
|
||||||
'border-sky-200 bg-sky-50',
|
|
||||||
'dark:border-sky-900 dark:bg-sky-950/30']
|
|
||||||
case 'skipped':
|
case 'skipped':
|
||||||
return [
|
return [
|
||||||
'border-stone-200 bg-stone-50',
|
'border-stone-200 bg-stone-50',
|
||||||
'dark:border-stone-800 dark:bg-stone-900/60']
|
'dark:border-stone-800 dark:bg-stone-900/60']
|
||||||
case 'failed':
|
|
||||||
case 'error':
|
|
||||||
return [
|
|
||||||
'border-rose-200 bg-rose-50',
|
|
||||||
'dark:border-rose-900 dark:bg-rose-950/30']
|
|
||||||
case 'warning':
|
case 'warning':
|
||||||
return [
|
return [
|
||||||
'border-amber-200 bg-amber-50',
|
'border-amber-200 bg-amber-50',
|
||||||
@@ -63,7 +54,7 @@ const summaryDate = (row: PostImportRow): string =>
|
|||||||
const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
|
const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
|
||||||
const error = summaryError (row)
|
const error = summaryError (row)
|
||||||
const warning = error == null ? summaryWarning (row) : null
|
const warning = error == null ? summaryWarning (row) : null
|
||||||
const effectiveStatus = effectivePostImportStatus (row)
|
const displayStatus = displayPostImportStatus (row)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -104,7 +95,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
|
|||||||
</div>)}
|
</div>)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<PostImportStatusBadge value={effectiveStatus}/>
|
<PostImportStatusBadge value={displayStatus}/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
@@ -134,7 +125,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
|
|||||||
{row.url}
|
{row.url}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<PostImportStatusBadge value={effectiveStatus}/>
|
<PostImportStatusBadge value={displayStatus}/>
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="text-xs text-red-700 dark:text-red-200">
|
<div className="text-xs text-red-700 dark:text-red-200">
|
||||||
|
|||||||
@@ -10,13 +10,7 @@ type Props = {
|
|||||||
const LABELS: Record<PostImportBadgeValue, string> = {
|
const LABELS: Record<PostImportBadgeValue, string> = {
|
||||||
ready: '登録可能',
|
ready: '登録可能',
|
||||||
warning: '警告',
|
warning: '警告',
|
||||||
error: '要修正',
|
skipped: 'スキップ' }
|
||||||
pending: '未登録',
|
|
||||||
created: '登録済み',
|
|
||||||
skipped: 'スキップ',
|
|
||||||
failed: '失敗',
|
|
||||||
automatic: '自動取得',
|
|
||||||
manual: '手修正' }
|
|
||||||
|
|
||||||
const STYLES: Record<PostImportBadgeValue, string[]> = {
|
const STYLES: Record<PostImportBadgeValue, string[]> = {
|
||||||
ready: [
|
ready: [
|
||||||
@@ -25,27 +19,9 @@ const STYLES: Record<PostImportBadgeValue, string[]> = {
|
|||||||
warning: [
|
warning: [
|
||||||
'border-amber-300 bg-amber-50 text-amber-700',
|
'border-amber-300 bg-amber-50 text-amber-700',
|
||||||
'dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200'],
|
'dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200'],
|
||||||
error: [
|
|
||||||
'border-red-300 bg-red-50 text-red-700',
|
|
||||||
'dark:border-red-900 dark:bg-red-950 dark:text-red-200'],
|
|
||||||
pending: [
|
|
||||||
'border-slate-300 bg-slate-50 text-slate-700',
|
|
||||||
'dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200'],
|
|
||||||
created: [
|
|
||||||
'border-sky-300 bg-sky-50 text-sky-700',
|
|
||||||
'dark:border-sky-900 dark:bg-sky-950 dark:text-sky-200'],
|
|
||||||
skipped: [
|
skipped: [
|
||||||
'border-stone-300 bg-stone-50 text-stone-700',
|
'border-stone-300 bg-stone-50 text-stone-700',
|
||||||
'dark:border-stone-700 dark:bg-stone-900 dark:text-stone-200'],
|
'dark:border-stone-700 dark:bg-stone-900 dark:text-stone-200'] }
|
||||||
failed: [
|
|
||||||
'border-rose-300 bg-rose-50 text-rose-700',
|
|
||||||
'dark:border-rose-900 dark:bg-rose-950 dark:text-rose-200'],
|
|
||||||
automatic: [
|
|
||||||
'border-slate-300 bg-slate-50 text-slate-700',
|
|
||||||
'dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200'],
|
|
||||||
manual: [
|
|
||||||
'border-purple-300 bg-purple-50 text-purple-700',
|
|
||||||
'dark:border-purple-900 dark:bg-purple-950 dark:text-purple-200'] }
|
|
||||||
|
|
||||||
|
|
||||||
const PostImportStatusBadge: FC<Props> = ({ value }) => (
|
const PostImportStatusBadge: FC<Props> = ({ value }) => (
|
||||||
|
|||||||
@@ -1,33 +1,18 @@
|
|||||||
import type { PostImportOrigin,
|
import type { PostImportRow } from '@/lib/postImportSession'
|
||||||
PostImportRow } from '@/lib/postImportSession'
|
|
||||||
|
|
||||||
export type PostImportEffectiveStatus =
|
export type PostImportDisplayStatus = 'ready' | 'warning' | 'skipped'
|
||||||
'created'
|
|
||||||
| 'skipped'
|
|
||||||
| 'failed'
|
|
||||||
| 'error'
|
|
||||||
| 'warning'
|
|
||||||
| 'ready'
|
|
||||||
|
|
||||||
export type PostImportBadgeValue =
|
export type PostImportBadgeValue = PostImportDisplayStatus
|
||||||
PostImportEffectiveStatus
|
|
||||||
| 'pending'
|
|
||||||
| PostImportOrigin
|
|
||||||
|
|
||||||
|
const hasWarnings = (row: PostImportRow): boolean =>
|
||||||
|
Object.values (row.fieldWarnings ?? { }).some (_1 => _1.length > 0)
|
||||||
|
|| row.baseWarnings.length > 0
|
||||||
|
|
||||||
export const effectivePostImportStatus = (
|
export const displayPostImportStatus = (
|
||||||
row: PostImportRow,
|
row: PostImportRow,
|
||||||
): PostImportEffectiveStatus => {
|
): PostImportDisplayStatus =>
|
||||||
if (Object.keys (row.validationErrors ?? { }).length > 0)
|
row.skipReason === 'existing' || row.importStatus === 'skipped'
|
||||||
return 'error'
|
? 'skipped'
|
||||||
|
: hasWarnings (row)
|
||||||
switch (row.importStatus)
|
? 'warning'
|
||||||
{
|
: 'ready'
|
||||||
case 'created':
|
|
||||||
case 'skipped':
|
|
||||||
case 'failed':
|
|
||||||
return row.importStatus
|
|
||||||
default:
|
|
||||||
return row.status
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ const rowChanged = (
|
|||||||
|| JSON.stringify (previous.provenance) !== JSON.stringify (next.provenance)
|
|| JSON.stringify (previous.provenance) !== JSON.stringify (next.provenance)
|
||||||
|| JSON.stringify (previous.tagSources ?? { }) !== JSON.stringify (next.tagSources ?? { })
|
|| JSON.stringify (previous.tagSources ?? { }) !== JSON.stringify (next.tagSources ?? { })
|
||||||
|
|
||||||
|
const buildResetSnapshot = (row: PostImportRow) => ({
|
||||||
|
url: row.url,
|
||||||
|
attributes: row.attributes })
|
||||||
|
|
||||||
|
|
||||||
export const processableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
|
export const processableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
|
||||||
rows.filter (row => {
|
rows.filter (row => {
|
||||||
@@ -111,7 +115,11 @@ export const mergeValidatedImportRows = (
|
|||||||
: previous.baseWarnings,
|
: previous.baseWarnings,
|
||||||
validationErrors: row.validationErrors,
|
validationErrors: row.validationErrors,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
metadataUrl: row.metadataUrl }
|
metadataUrl: row.metadataUrl,
|
||||||
|
resetSnapshot:
|
||||||
|
row.metadataUrl !== previous.metadataUrl
|
||||||
|
? buildResetSnapshot (row)
|
||||||
|
: previous.resetSnapshot }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +162,7 @@ export const mergePreviewImportRows = (
|
|||||||
tagSources: mergedTagSources,
|
tagSources: mergedTagSources,
|
||||||
skipReason: row.skipReason,
|
skipReason: row.skipReason,
|
||||||
existingPostId: row.existingPostId,
|
existingPostId: row.existingPostId,
|
||||||
|
resetSnapshot: row.resetSnapshot,
|
||||||
createdPostId: previous.createdPostId,
|
createdPostId: previous.createdPostId,
|
||||||
importStatus: previous.importStatus,
|
importStatus: previous.importStatus,
|
||||||
importErrors: previous.importErrors }
|
importErrors: previous.importErrors }
|
||||||
@@ -214,3 +223,8 @@ export const retryImportRow = (
|
|||||||
row.sourceRow === sourceRow && row.importStatus === 'failed'
|
row.sourceRow === sourceRow && row.importStatus === 'failed'
|
||||||
? { ...row, importStatus: 'pending', importErrors: undefined }
|
? { ...row, importStatus: 'pending', importErrors: undefined }
|
||||||
: row)
|
: row)
|
||||||
|
|
||||||
|
export const initialisePreviewRows = (rows: PostImportRow[]): PostImportRow[] =>
|
||||||
|
rows.map (row => ({
|
||||||
|
...row,
|
||||||
|
resetSnapshot: buildResetSnapshot (row) }))
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { PostImportOrigin,
|
import type { PostImportOrigin,
|
||||||
|
PostImportResetSnapshot,
|
||||||
PostImportRow,
|
PostImportRow,
|
||||||
PostImportSession,
|
PostImportSession,
|
||||||
PostImportStatus,
|
PostImportStatus,
|
||||||
@@ -137,6 +138,24 @@ const hasOnlyKeys = (
|
|||||||
): boolean =>
|
): boolean =>
|
||||||
Object.keys (value).every (key => allowedKeys.includes (key))
|
Object.keys (value).every (key => allowedKeys.includes (key))
|
||||||
|
|
||||||
|
const sanitiseResetSnapshot = (value: unknown): PostImportResetSnapshot | null => {
|
||||||
|
if (!(isPlainObject (value)))
|
||||||
|
return null
|
||||||
|
if (typeof value.url !== 'string')
|
||||||
|
return null
|
||||||
|
if (!(isPlainObject (value.attributes)))
|
||||||
|
return null
|
||||||
|
if (!(hasOnlyKeys (value.attributes, ATTRIBUTE_KEYS)))
|
||||||
|
return null
|
||||||
|
if (!(Object.values (value.attributes).every (entry =>
|
||||||
|
typeof entry === 'string' || typeof entry === 'number')))
|
||||||
|
return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: value.url,
|
||||||
|
attributes: value.attributes as Record<string, string | number> }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const sanitiseRow = (value: unknown): PostImportRow | null => {
|
const sanitiseRow = (value: unknown): PostImportRow | null => {
|
||||||
if (!(isPlainObject (value)))
|
if (!(isPlainObject (value)))
|
||||||
@@ -166,8 +185,11 @@ const sanitiseRow = (value: unknown): PostImportRow | null => {
|
|||||||
|
|
||||||
const validationErrors = ensureStringListRecord (value.validationErrors)
|
const validationErrors = ensureStringListRecord (value.validationErrors)
|
||||||
const fieldWarnings = ensureStringListRecord (value.fieldWarnings)
|
const fieldWarnings = ensureStringListRecord (value.fieldWarnings)
|
||||||
|
const resetSnapshot = sanitiseResetSnapshot (value.resetSnapshot)
|
||||||
if (validationErrors == null || fieldWarnings == null)
|
if (validationErrors == null || fieldWarnings == null)
|
||||||
return null
|
return null
|
||||||
|
if (resetSnapshot == null)
|
||||||
|
return null
|
||||||
|
|
||||||
const importErrors =
|
const importErrors =
|
||||||
value.importErrors == null
|
value.importErrors == null
|
||||||
@@ -215,6 +237,7 @@ const sanitiseRow = (value: unknown): PostImportRow | null => {
|
|||||||
existingPostId:
|
existingPostId:
|
||||||
isPositiveInteger (value.existingPostId) ? Number (value.existingPostId) : undefined,
|
isPositiveInteger (value.existingPostId) ? Number (value.existingPostId) : undefined,
|
||||||
metadataUrl: typeof value.metadataUrl === 'string' ? value.metadataUrl : undefined,
|
metadataUrl: typeof value.metadataUrl === 'string' ? value.metadataUrl : undefined,
|
||||||
|
resetSnapshot,
|
||||||
createdPostId:
|
createdPostId:
|
||||||
isPositiveInteger (value.createdPostId) ? Number (value.createdPostId) : undefined,
|
isPositiveInteger (value.createdPostId) ? Number (value.createdPostId) : undefined,
|
||||||
importStatus: value.importStatus }
|
importStatus: value.importStatus }
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ export type PostImportSkipReason = 'existing'
|
|||||||
export type PostImportResultStatus = 'created' | 'skipped' | 'failed'
|
export type PostImportResultStatus = 'created' | 'skipped' | 'failed'
|
||||||
export type PostImportAttributeValue = string | number
|
export type PostImportAttributeValue = string | number
|
||||||
|
|
||||||
|
export type PostImportResetSnapshot = {
|
||||||
|
url: string
|
||||||
|
attributes: Record<string, PostImportAttributeValue> }
|
||||||
|
|
||||||
export type PostImportRow = {
|
export type PostImportRow = {
|
||||||
sourceRow: number
|
sourceRow: number
|
||||||
url: string
|
url: string
|
||||||
@@ -23,6 +27,7 @@ export type PostImportRow = {
|
|||||||
skipReason?: PostImportSkipReason
|
skipReason?: PostImportSkipReason
|
||||||
existingPostId?: number
|
existingPostId?: number
|
||||||
metadataUrl?: string
|
metadataUrl?: string
|
||||||
|
resetSnapshot: PostImportResetSnapshot
|
||||||
createdPostId?: number
|
createdPostId?: number
|
||||||
importStatus?: PostImportStatus }
|
importStatus?: PostImportStatus }
|
||||||
|
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ import PageTitle from '@/components/common/PageTitle'
|
|||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
|
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
|
||||||
import { effectivePostImportStatus } from '@/components/posts/import/postImportRowStatus'
|
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { toast } from '@/components/ui/use-toast'
|
import { toast } from '@/components/ui/use-toast'
|
||||||
import { SITE_TITLE } from '@/config'
|
import { SITE_TITLE } from '@/config'
|
||||||
import { apiPost } from '@/lib/api'
|
import { apiPost } from '@/lib/api'
|
||||||
import { canEditContent } from '@/lib/users'
|
import { canEditContent } from '@/lib/users'
|
||||||
import { clearPostImportSourceDraft,
|
import { clearPostImportSourceDraft,
|
||||||
|
initialisePreviewRows,
|
||||||
loadPostImportSession,
|
loadPostImportSession,
|
||||||
mergeImportResults,
|
mergeImportResults,
|
||||||
mergeValidatedImportRows,
|
mergeValidatedImportRows,
|
||||||
@@ -33,7 +34,7 @@ import type { User } from '@/types'
|
|||||||
type Props = { user: User | null }
|
type Props = { user: User | null }
|
||||||
|
|
||||||
const resultToneClass = (
|
const resultToneClass = (
|
||||||
status: ReturnType<typeof effectivePostImportStatus>,
|
status: 'created' | 'failed' | 'invalid' | 'skipped' | 'warning' | 'ready',
|
||||||
): string[] => {
|
): string[] => {
|
||||||
switch (status)
|
switch (status)
|
||||||
{
|
{
|
||||||
@@ -46,7 +47,10 @@ const resultToneClass = (
|
|||||||
'border-stone-200 bg-stone-50',
|
'border-stone-200 bg-stone-50',
|
||||||
'dark:border-stone-800 dark:bg-stone-900/60']
|
'dark:border-stone-800 dark:bg-stone-900/60']
|
||||||
case 'failed':
|
case 'failed':
|
||||||
case 'error':
|
return [
|
||||||
|
'border-rose-200 bg-rose-50',
|
||||||
|
'dark:border-rose-900 dark:bg-rose-950/30']
|
||||||
|
case 'invalid':
|
||||||
return [
|
return [
|
||||||
'border-rose-200 bg-rose-50',
|
'border-rose-200 bg-rose-50',
|
||||||
'dark:border-rose-900 dark:bg-rose-950/30']
|
'dark:border-rose-900 dark:bg-rose-950/30']
|
||||||
@@ -117,7 +121,9 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
|||||||
tagSources: row.tagSources,
|
tagSources: row.tagSources,
|
||||||
metadataUrl: row.metadataUrl })),
|
metadataUrl: row.metadataUrl })),
|
||||||
changed_row: -1 })
|
changed_row: -1 })
|
||||||
const validatedRows = mergeValidatedImportRows (pendingRows, validated.rows)
|
const validatedRows = mergeValidatedImportRows (
|
||||||
|
pendingRows,
|
||||||
|
initialisePreviewRows (validated.rows))
|
||||||
setSession (current =>
|
setSession (current =>
|
||||||
current
|
current
|
||||||
? { ...current, rows: validatedRows }
|
? { ...current, rows: validatedRows }
|
||||||
@@ -198,28 +204,44 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
|||||||
<div className="rounded-lg border bg-white p-4 dark:border-neutral-700
|
<div className="rounded-lg border bg-white p-4 dark:border-neutral-700
|
||||||
dark:bg-neutral-900">
|
dark:bg-neutral-900">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<SummaryChip label="登録成功" value={counts.created} badge="created"/>
|
<SummaryChip label="登録成功" value={counts.created}/>
|
||||||
<SummaryChip label="スキップ" value={counts.skipped} badge="skipped"/>
|
<SummaryChip label="スキップ" value={counts.skipped} badge="skipped"/>
|
||||||
<SummaryChip label="失敗" value={counts.failed} badge="failed"/>
|
<SummaryChip label="失敗" value={counts.failed}/>
|
||||||
<SummaryChip label="要修正" value={counts.invalid} badge="error"/>
|
<SummaryChip label="要修正" value={counts.invalid}/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{session.rows.map (row => {
|
{session.rows.map (row => {
|
||||||
const status = effectivePostImportStatus (row)
|
const displayStatus =
|
||||||
|
Object.keys (row.validationErrors).length > 0
|
||||||
|
? 'invalid'
|
||||||
|
: row.importStatus === 'created'
|
||||||
|
? 'created'
|
||||||
|
: row.importStatus === 'failed'
|
||||||
|
? 'failed'
|
||||||
|
: displayPostImportStatus (row)
|
||||||
|
const canEdit =
|
||||||
|
Object.keys (row.validationErrors).length > 0
|
||||||
|
|| row.importStatus === 'failed'
|
||||||
|
const canRetry =
|
||||||
|
row.importStatus === 'created'
|
||||||
|
? false
|
||||||
|
: row.importStatus === 'failed'
|
||||||
|
&& Object.keys (row.validationErrors).length === 0
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={row.sourceRow}
|
key={row.sourceRow}
|
||||||
className={[
|
className={[
|
||||||
'rounded-lg border p-4',
|
'rounded-lg border p-4',
|
||||||
...resultToneClass (status)].join (' ')}>
|
...resultToneClass (displayStatus)].join (' ')}>
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-start
|
<div className="flex flex-col gap-3 md:flex-row md:items-start
|
||||||
md:justify-between">
|
md:justify-between">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-sm font-medium">行 {row.sourceRow}</span>
|
<span className="text-sm font-medium">行 {row.sourceRow}</span>
|
||||||
<PostImportStatusBadge value={status}/>
|
{displayStatus === 'skipped' && (
|
||||||
|
<PostImportStatusBadge value="skipped"/>)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-neutral-700 dark:text-neutral-200">
|
<div className="text-sm text-neutral-700 dark:text-neutral-200">
|
||||||
{String (row.attributes.title ?? '') || row.url}
|
{String (row.attributes.title ?? '') || row.url}
|
||||||
@@ -238,14 +260,14 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
|||||||
投稿を開く
|
投稿を開く
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
</Button>)}
|
</Button>)}
|
||||||
{(status === 'error' || row.importStatus === 'failed') && (
|
{canEdit && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => openRepair (row.sourceRow)}>
|
onClick={() => openRepair (row.sourceRow)}>
|
||||||
編輯
|
編輯
|
||||||
</Button>)}
|
</Button>)}
|
||||||
{row.importStatus === 'failed' && status !== 'error' && (
|
{canRetry && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -293,11 +315,11 @@ const SummaryChip = (
|
|||||||
{ label, value, badge }: {
|
{ label, value, badge }: {
|
||||||
label: string
|
label: string
|
||||||
value: number
|
value: number
|
||||||
badge: 'created' | 'skipped' | 'failed' | 'error' },
|
badge?: 'skipped' },
|
||||||
) => (
|
) => (
|
||||||
<div className="flex items-center gap-2 rounded-full border border-border
|
<div className="flex items-center gap-2 rounded-full border border-border
|
||||||
bg-background px-3 py-1 text-sm">
|
bg-background px-3 py-1 text-sm">
|
||||||
<PostImportStatusBadge value={badge}/>
|
{badge && <PostImportStatusBadge value={badge}/>}
|
||||||
<span>{label} {value}</span>
|
<span>{label} {value}</span>
|
||||||
</div>)
|
</div>)
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { apiPost } from '@/lib/api'
|
|||||||
import { canEditContent } from '@/lib/users'
|
import { canEditContent } from '@/lib/users'
|
||||||
import { loadPostImportSession,
|
import { loadPostImportSession,
|
||||||
creatableImportRows,
|
creatableImportRows,
|
||||||
|
initialisePreviewRows,
|
||||||
mergeImportResults,
|
mergeImportResults,
|
||||||
mergeValidatedImportRows,
|
mergeValidatedImportRows,
|
||||||
processableImportRows,
|
processableImportRows,
|
||||||
@@ -53,6 +54,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
const [loading, setLoading] = useState (false)
|
const [loading, setLoading] = useState (false)
|
||||||
const [missing, setMissing] = useState (false)
|
const [missing, setMissing] = useState (false)
|
||||||
const [savingRow, setSavingRow] = useState<number | null> (null)
|
const [savingRow, setSavingRow] = useState<number | null> (null)
|
||||||
|
const [dialogMessageRow, setDialogMessageRow] = useState<PostImportRow | null> (null)
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
if (sessionId == null)
|
if (sessionId == null)
|
||||||
@@ -102,6 +104,16 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
element?.scrollIntoView ({ block: 'center', behavior: 'smooth' })
|
element?.scrollIntoView ({ block: 'center', behavior: 'smooth' })
|
||||||
}, [editingRow, session?.repairMode])
|
}, [editingRow, session?.repairMode])
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
if (editingRow == null)
|
||||||
|
{
|
||||||
|
setDialogMessageRow (null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (dialogMessageRow?.sourceRow !== editingRow.sourceRow)
|
||||||
|
setDialogMessageRow (null)
|
||||||
|
}, [editingRow, dialogMessageRow])
|
||||||
|
|
||||||
const updateSessionRows = (nextRows: PostImportRow[]) =>
|
const updateSessionRows = (nextRows: PostImportRow[]) =>
|
||||||
setSession (current =>
|
setSession (current =>
|
||||||
current ? { ...current, rows: nextRows } : current)
|
current ? { ...current, rows: nextRows } : current)
|
||||||
@@ -171,7 +183,15 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
tagSources: row.tagSources,
|
tagSources: row.tagSources,
|
||||||
metadataUrl: row.metadataUrl })),
|
metadataUrl: row.metadataUrl })),
|
||||||
changed_row: urlChanged ? editingRow.sourceRow : -1 })
|
changed_row: urlChanged ? editingRow.sourceRow : -1 })
|
||||||
updateSessionRows (mergeValidatedImportRows (nextRows, validated.rows))
|
const validatedRows = initialisePreviewRows (validated.rows)
|
||||||
|
const target = validatedRows.find (row => row.sourceRow === editingRow.sourceRow)
|
||||||
|
if (target != null && Object.keys (target.validationErrors).length > 0)
|
||||||
|
{
|
||||||
|
setDialogMessageRow (target)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
updateSessionRows (mergeValidatedImportRows (nextRows, validatedRows))
|
||||||
|
setDialogMessageRow (null)
|
||||||
setSearchParams ({ })
|
setSearchParams ({ })
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -193,19 +213,41 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
setLoading (true)
|
setLoading (true)
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
|
||||||
|
rows: session.rows
|
||||||
|
.filter (row => row.importStatus !== 'created')
|
||||||
|
.map (row => ({
|
||||||
|
sourceRow: row.sourceRow,
|
||||||
|
url: row.url,
|
||||||
|
attributes: row.attributes,
|
||||||
|
provenance: row.provenance,
|
||||||
|
tagSources: row.tagSources,
|
||||||
|
metadataUrl: row.metadataUrl })),
|
||||||
|
changed_row: -1 })
|
||||||
|
const validatedRows = initialisePreviewRows (validated.rows)
|
||||||
|
const mergedRows = mergeValidatedImportRows (session.rows, validatedRows)
|
||||||
|
const firstInvalid = mergedRows.find (row => Object.keys (row.validationErrors).length > 0)
|
||||||
|
if (firstInvalid != null)
|
||||||
|
{
|
||||||
|
setSession ({ ...session, rows: mergedRows })
|
||||||
|
setDialogMessageRow (firstInvalid)
|
||||||
|
setSearchParams ({ edit: String (firstInvalid.sourceRow) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const result = await apiPost<{
|
const result = await apiPost<{
|
||||||
created: number
|
created: number
|
||||||
skipped: number
|
skipped: number
|
||||||
failed: number
|
failed: number
|
||||||
rows: PostImportResultRow[] }> ('/posts/import', {
|
rows: PostImportResultRow[] }> ('/posts/import', {
|
||||||
rows: processable.map (row => ({
|
rows: processableImportRows (mergedRows).map (row => ({
|
||||||
sourceRow: row.sourceRow,
|
sourceRow: row.sourceRow,
|
||||||
url: row.url,
|
url: row.url,
|
||||||
attributes: row.attributes,
|
attributes: row.attributes,
|
||||||
provenance: row.provenance,
|
provenance: row.provenance,
|
||||||
tagSources: row.tagSources,
|
tagSources: row.tagSources,
|
||||||
metadataUrl: row.metadataUrl })) })
|
metadataUrl: row.metadataUrl })) })
|
||||||
const nextRows = mergeImportResults (session.rows, result.rows)
|
const nextRows = mergeImportResults (mergedRows, result.rows)
|
||||||
const nextSession = { ...session, rows: nextRows, repairMode: 'all' as const }
|
const nextSession = { ...session, rows: nextRows, repairMode: 'all' as const }
|
||||||
setSession (nextSession)
|
setSession (nextSession)
|
||||||
const saved = savePostImportSession (sessionId, nextSession, message =>
|
const saved = savePostImportSession (sessionId, nextSession, message =>
|
||||||
@@ -256,10 +298,8 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<SummaryChip label="全件" value={counts.total}/>
|
<SummaryChip label="全件" value={counts.total}/>
|
||||||
<SummaryChip label="登録対象" value={counts.submittable}/>
|
<SummaryChip label="登録対象" value={counts.submittable}/>
|
||||||
<SummaryChip label="要修正" value={counts.invalid}/>
|
<SummaryChip label="要確認" value={counts.invalid}/>
|
||||||
<SummaryChip label="スキップ予定" value={counts.skipPlanned}/>
|
<SummaryChip label="スキップ予定" value={counts.skipPlanned}/>
|
||||||
<SummaryChip label="登録済み" value={counts.created}/>
|
|
||||||
<SummaryChip label="失敗" value={counts.failed}/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -288,9 +328,10 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
<PostImportRowDialog
|
<PostImportRowDialog
|
||||||
open={editingRow != null}
|
open={editingRow != null}
|
||||||
row={editingRow}
|
row={editingRow}
|
||||||
|
messageRow={dialogMessageRow}
|
||||||
saving={savingRow != null}
|
saving={savingRow != null}
|
||||||
onOpenChange={open => {
|
onOpenChange={open => {
|
||||||
if (!(open))
|
if (!open)
|
||||||
setSearchParams ({ })
|
setSearchParams ({ })
|
||||||
}}
|
}}
|
||||||
onSave={saveDraft}/>
|
onSave={saveDraft}/>
|
||||||
@@ -319,14 +360,12 @@ const PostImportFooter = (
|
|||||||
<div className="mx-auto flex max-w-6xl flex-col gap-3 md:flex-row
|
<div className="mx-auto flex max-w-6xl flex-col gap-3 md:flex-row
|
||||||
md:items-center md:justify-between">
|
md:items-center md:justify-between">
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
<PostImportStatusBadge value="pending"/>
|
|
||||||
<span>処理対象 {processableCount} 件</span>
|
<span>処理対象 {processableCount} 件</span>
|
||||||
<PostImportStatusBadge value="ready"/>
|
<PostImportStatusBadge value="ready"/>
|
||||||
<span>登録対象 {creatableCount} 件</span>
|
<span>登録対象 {creatableCount} 件</span>
|
||||||
<PostImportStatusBadge value="skipped"/>
|
<PostImportStatusBadge value="skipped"/>
|
||||||
<span>スキップ予定 {skipPlannedCount} 件</span>
|
<span>スキップ予定 {skipPlannedCount} 件</span>
|
||||||
<PostImportStatusBadge value="error"/>
|
<span>要確認 {invalidCount} 件</span>
|
||||||
<span>要修正 {invalidCount} 件</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row">
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
<Button type="button" variant="outline" onClick={onBack}>
|
<Button type="button" variant="outline" onClick={onBack}>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { inputClass } from '@/lib/utils'
|
|||||||
import { countImportSourceLines,
|
import { countImportSourceLines,
|
||||||
cleanupExpiredPostImportSessions,
|
cleanupExpiredPostImportSessions,
|
||||||
createPostImportSessionId,
|
createPostImportSessionId,
|
||||||
|
initialisePreviewRows,
|
||||||
loadPostImportSourceDraft,
|
loadPostImportSourceDraft,
|
||||||
savePostImportSession,
|
savePostImportSession,
|
||||||
savePostImportSourceDraft,
|
savePostImportSourceDraft,
|
||||||
@@ -115,7 +116,7 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
|||||||
const sessionId = createPostImportSessionId ()
|
const sessionId = createPostImportSessionId ()
|
||||||
const saved = savePostImportSession (sessionId, {
|
const saved = savePostImportSession (sessionId, {
|
||||||
source,
|
source,
|
||||||
rows: data.rows,
|
rows: initialisePreviewRows (data.rows),
|
||||||
repairMode: 'all' }, message =>
|
repairMode: 'all' }, message =>
|
||||||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||||
if (!(saved))
|
if (!(saved))
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする