このコミットが含まれているのは:
2026-07-15 19:33:18 +09:00
コミット eebbd5d069
11個のファイルの変更244行の追加189行の削除
+36 -2
ファイルの表示
@@ -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?
+58 -93
ファイルの表示
@@ -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
+5 -14
ファイルの表示
@@ -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">
+2 -26
ファイルの表示
@@ -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 }) => (
+13 -28
ファイルの表示
@@ -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
}
}
+15 -1
ファイルの表示
@@ -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) }))
+23
ファイルの表示
@@ -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 }
+5
ファイルの表示
@@ -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 }
+36 -14
ファイルの表示
@@ -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>)
+49 -10
ファイルの表示
@@ -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}>
+2 -1
ファイルの表示
@@ -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))