このコミットが含まれているのは:
@@ -266,6 +266,44 @@ const value =
|
|||||||
value selection. Do not replace a clear ternary with `if` statements, and do
|
value selection. Do not replace a clear ternary with `if` statements, and do
|
||||||
not introduce immediately invoked functions just to avoid or reformat a
|
not introduce immediately invoked functions just to avoid or reformat a
|
||||||
ternary expression.
|
ternary expression.
|
||||||
|
- In TypeScript and TSX, multi-stage ternary expressions must make branch
|
||||||
|
boundaries explicit. Wrap each condition group in parentheses instead of
|
||||||
|
relying on indentation alone to show which `?` matches which `:`.
|
||||||
|
- In TypeScript and TSX, wrap nested ternary expressions in parentheses. Do
|
||||||
|
not write flat vertical chains of `?` and `:` without explicit grouping.
|
||||||
|
- In TypeScript and TSX, when a ternary condition contains `&&` or `||`, wrap
|
||||||
|
the whole condition in parentheses before `?`.
|
||||||
|
- In TypeScript and TSX, if a ternary reaches three or more stages and still
|
||||||
|
reads poorly after explicit grouping, extract a helper function or use `if`
|
||||||
|
statements instead of keeping a flat multi-stage ternary.
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
row.skipReason === 'existing' || row.importStatus === 'skipped'
|
||||||
|
? 'skipped'
|
||||||
|
: Object.keys (row.validationErrors ?? { }).length > 0
|
||||||
|
|| row.importStatus === 'failed'
|
||||||
|
|| row.importStatus === 'created'
|
||||||
|
? null
|
||||||
|
: hasWarnings (row) || row.status === 'warning'
|
||||||
|
? 'warning'
|
||||||
|
: 'ready'
|
||||||
|
```
|
||||||
|
|
||||||
|
Good:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
(row.skipReason === 'existing' || row.importStatus === 'skipped')
|
||||||
|
? 'skipped'
|
||||||
|
: ((Object.keys (row.validationErrors ?? { }).length > 0
|
||||||
|
|| row.importStatus === 'failed'
|
||||||
|
|| row.importStatus === 'created')
|
||||||
|
? null
|
||||||
|
: ((hasWarnings (row) || row.status === 'warning')
|
||||||
|
? 'warning'
|
||||||
|
: 'ready'))
|
||||||
|
```
|
||||||
- In TypeScript and TSX, do not write `let` followed by later `if` assignments
|
- In TypeScript and TSX, do not write `let` followed by later `if` assignments
|
||||||
when the value can be expressed as a single `const` initializer. Prefer
|
when the value can be expressed as a single `const` initializer. Prefer
|
||||||
`const` because it prevents accidental later reassignment.
|
`const` because it prevents accidental later reassignment.
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ class PostImportPreviewer
|
|||||||
source = row.symbolize_keys
|
source = row.symbolize_keys
|
||||||
url = source[:url].to_s.strip
|
url = source[:url].to_s.strip
|
||||||
normal_url = normalised_url(url)
|
normal_url = normalised_url(url)
|
||||||
source.merge(url_text: url, normal_url:)
|
source.merge(url_text: url, normal_url:, url_error: validate_url_safety(normal_url))
|
||||||
end
|
end
|
||||||
|
|
||||||
def preview_row row,
|
def preview_row row,
|
||||||
@@ -78,6 +78,9 @@ class PostImportPreviewer
|
|||||||
|
|
||||||
validation_errors = {}
|
validation_errors = {}
|
||||||
validation_errors[:url] = ['URL が不正です.'] if normal_url.blank?
|
validation_errors[:url] = ['URL が不正です.'] if normal_url.blank?
|
||||||
|
if row[:url_error].present?
|
||||||
|
validation_errors[:url] = [row[:url_error]]
|
||||||
|
end
|
||||||
if normal_url.present? && url_counts[normal_url].to_i > 1
|
if normal_url.present? && url_counts[normal_url].to_i > 1
|
||||||
validation_errors[:url] = ['URL が重複しています.']
|
validation_errors[:url] = ['URL が重複しています.']
|
||||||
end
|
end
|
||||||
@@ -206,8 +209,7 @@ class PostImportPreviewer
|
|||||||
add_field_warning!(warnings, 'thumbnail_base', THUMBNAIL_FETCH_WARNING)
|
add_field_warning!(warnings, 'thumbnail_base', THUMBNAIL_FETCH_WARNING)
|
||||||
end
|
end
|
||||||
{ data:, warnings: }
|
{ data:, warnings: }
|
||||||
rescue Preview::UrlSafety::UnsafeUrl,
|
rescue Preview::HttpFetcher::FetchFailed,
|
||||||
Preview::HttpFetcher::FetchFailed,
|
|
||||||
Preview::HttpFetcher::ResponseTooLarge => e
|
Preview::HttpFetcher::ResponseTooLarge => e
|
||||||
Rails.logger.info(
|
Rails.logger.info(
|
||||||
"post_import_metadata_fetch_failure "\
|
"post_import_metadata_fetch_failure "\
|
||||||
@@ -252,6 +254,7 @@ class PostImportPreviewer
|
|||||||
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?
|
||||||
|
next if row[:url_error].present?
|
||||||
next unless should_fetch_metadata?(fetch_metadata, row[:source_row].to_i)
|
next unless should_fetch_metadata?(fetch_metadata, row[:source_row].to_i)
|
||||||
next if url_counts[row[:normal_url]].to_i > 1
|
next if url_counts[row[:normal_url]].to_i > 1
|
||||||
next if existing_posts.key?(row[:normal_url])
|
next if existing_posts.key?(row[:normal_url])
|
||||||
@@ -339,12 +342,22 @@ class PostImportPreviewer
|
|||||||
def should_apply_metadata_to_row? row, fetch_metadata, existing_posts, url_counts
|
def should_apply_metadata_to_row? row, fetch_metadata, existing_posts, url_counts
|
||||||
normal_url = row[:normal_url]
|
normal_url = row[:normal_url]
|
||||||
return false if normal_url.blank?
|
return false if normal_url.blank?
|
||||||
|
return false if row[:url_error].present?
|
||||||
return false if url_counts[normal_url].to_i > 1
|
return false if url_counts[normal_url].to_i > 1
|
||||||
return false if existing_posts.key?(normal_url)
|
return false if existing_posts.key?(normal_url)
|
||||||
|
|
||||||
should_fetch_metadata?(fetch_metadata, row[:source_row].to_i)
|
should_fetch_metadata?(fetch_metadata, row[:source_row].to_i)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def validate_url_safety normal_url
|
||||||
|
return nil if normal_url.blank?
|
||||||
|
|
||||||
|
Preview::UrlSafety.validate(normal_url)
|
||||||
|
nil
|
||||||
|
rescue Preview::UrlSafety::UnsafeUrl => e
|
||||||
|
e.message
|
||||||
|
end
|
||||||
|
|
||||||
def apply_metadata! attributes, provenance, tag_sources, metadata
|
def apply_metadata! attributes, provenance, tag_sources, metadata
|
||||||
metadata.each do |field, value|
|
metadata.each do |field, value|
|
||||||
if field == 'tags'
|
if field == 'tags'
|
||||||
|
|||||||
@@ -46,6 +46,13 @@ class PostImportRunner
|
|||||||
end
|
end
|
||||||
|
|
||||||
{ source_row: row['source_row'], status: 'failed', errors: e.record.errors.to_hash }
|
{ source_row: row['source_row'], status: 'failed', errors: e.record.errors.to_hash }
|
||||||
|
rescue ActiveRecord::RecordNotUnique
|
||||||
|
existing_post = existing_post_for_race(row)
|
||||||
|
raise unless existing_post
|
||||||
|
|
||||||
|
{ source_row: row['source_row'],
|
||||||
|
status: 'skipped',
|
||||||
|
existing_post_id: existing_post.id }
|
||||||
rescue Tag::NicoTagNormalisationError
|
rescue Tag::NicoTagNormalisationError
|
||||||
{ source_row: row['source_row'],
|
{ source_row: row['source_row'],
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
@@ -66,8 +73,10 @@ class PostImportRunner
|
|||||||
errors: { base: ['登録中にエラーが発生しました.'] } }
|
errors: { base: ['登録中にエラーが発生しました.'] } }
|
||||||
end
|
end
|
||||||
|
|
||||||
def existing_post_for_race row, record
|
def existing_post_for_race row, record = nil
|
||||||
return nil unless record.errors.of_kind?(:url, :taken)
|
if record && !(record.errors.of_kind?(:url, :taken))
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
normal_url = PostUrlNormaliser.normalise(row['url'])
|
normal_url = PostUrlNormaliser.normalise(row['url'])
|
||||||
return nil if normal_url.blank?
|
return nil if normal_url.blank?
|
||||||
|
|||||||
@@ -129,6 +129,10 @@ pass or the remaining failure is clearly blocked.
|
|||||||
it is JSX- or React-specific.
|
it is JSX- or React-specific.
|
||||||
- Preserve compact TSX expression shapes such as inline ternary branches and
|
- Preserve compact TSX expression shapes such as inline ternary branches and
|
||||||
closing `</div>)` forms when nearby code uses them.
|
closing `</div>)` forms when nearby code uses them.
|
||||||
|
- Multi-stage ternary expressions must use explicit parentheses for each
|
||||||
|
condition group and nested ternary branch. Do not rely on indentation alone
|
||||||
|
to show `?` / `:` pairing, and extract a helper or `if` when a three-stage
|
||||||
|
ternary still reads poorly after grouping.
|
||||||
- Treat TypeScript and TSX formatting rules as hard constraints, not
|
- Treat TypeScript and TSX formatting rules as hard constraints, not
|
||||||
preferences. Before finishing a TypeScript or TSX edit, inspect the edited
|
preferences. Before finishing a TypeScript or TSX edit, inspect the edited
|
||||||
hunks for closing `)`, `]`, and `}` placement and fix violations instead of
|
hunks for closing `)`, `]`, and `}` placement and fix violations instead of
|
||||||
|
|||||||
@@ -2,11 +2,10 @@ import DateTimeField from '@/components/common/DateTimeField'
|
|||||||
import FormField from '@/components/common/FormField'
|
import FormField from '@/components/common/FormField'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
import type { FC, ReactNode } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
labelAddon?: ReactNode
|
|
||||||
originalCreatedFrom: string | null
|
originalCreatedFrom: string | null
|
||||||
setOriginalCreatedFrom: (x: string | null) => void
|
setOriginalCreatedFrom: (x: string | null) => void
|
||||||
originalCreatedBefore: string | null
|
originalCreatedBefore: string | null
|
||||||
@@ -16,19 +15,12 @@ type Props = {
|
|||||||
|
|
||||||
const PostOriginalCreatedTimeField: FC<Props> = (
|
const PostOriginalCreatedTimeField: FC<Props> = (
|
||||||
{ disabled,
|
{ disabled,
|
||||||
labelAddon,
|
|
||||||
originalCreatedFrom,
|
originalCreatedFrom,
|
||||||
setOriginalCreatedFrom,
|
setOriginalCreatedFrom,
|
||||||
originalCreatedBefore,
|
originalCreatedBefore,
|
||||||
setOriginalCreatedBefore,
|
setOriginalCreatedBefore,
|
||||||
errors }: Props) => (
|
errors }: Props) => (
|
||||||
<FormField
|
<FormField label="オリジナルの作成日時" messages={errors}>
|
||||||
label={
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<span>オリジナルの作成日時</span>
|
|
||||||
{labelAddon}
|
|
||||||
</span>}
|
|
||||||
messages={errors}>
|
|
||||||
{({ describedBy, invalid }) => (
|
{({ describedBy, invalid }) => (
|
||||||
<>
|
<>
|
||||||
<div className="my-1 flex flex-col gap-2 sm:flex-row sm:items-start">
|
<div className="my-1 flex flex-col gap-2 sm:flex-row sm:items-start">
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import type { FC, ReactNode } from 'react'
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
summary: ReactNode
|
|
||||||
actions: ReactNode }
|
|
||||||
|
|
||||||
const PageActionFooter: FC<Props> = ({ summary, actions }) => (
|
|
||||||
<div
|
|
||||||
className="shrink-0 border-t bg-white/95 p-4 backdrop-blur
|
|
||||||
dark:border-neutral-700 dark:bg-neutral-950/95">
|
|
||||||
<div className="mx-auto flex max-w-6xl flex-col gap-3 md:flex-row
|
|
||||||
md:items-center md:justify-between">
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
|
||||||
{summary}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row">
|
|
||||||
{actions}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>)
|
|
||||||
|
|
||||||
export default PageActionFooter
|
|
||||||
@@ -2,9 +2,7 @@ import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeFi
|
|||||||
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 FormField from '@/components/common/FormField'
|
import FormField from '@/components/common/FormField'
|
||||||
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 { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Dialog,
|
import { Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -17,7 +15,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 { PostImportResetSnapshot,
|
||||||
PostImportRow } from '@/lib/postImportSession'
|
PostImportRow } from '@/lib/postImportSession'
|
||||||
|
|
||||||
type Draft = {
|
type Draft = {
|
||||||
@@ -36,37 +34,11 @@ type Props = {
|
|||||||
messageRow?: PostImportRow | null
|
messageRow?: PostImportRow | null
|
||||||
saving: boolean
|
saving: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
onSave: (draft: Draft) => Promise<boolean> }
|
onSave: (
|
||||||
|
payload: { draft: Draft
|
||||||
const originOf = (
|
resetRequested: boolean
|
||||||
row: PostImportRow,
|
resetSnapshot: PostImportResetSnapshot },
|
||||||
field: string,
|
) => Promise<boolean> }
|
||||||
): 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,
|
||||||
@@ -108,9 +80,6 @@ const PostImportRowDialog: FC<Props> = (
|
|||||||
return null
|
return null
|
||||||
|
|
||||||
const displayRow = messageRow ?? row
|
const displayRow = messageRow ?? row
|
||||||
const originalDraft = buildDraft (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,
|
||||||
@@ -121,7 +90,12 @@ const PostImportRowDialog: FC<Props> = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
if (await onSave (draft))
|
if (
|
||||||
|
await onSave ({
|
||||||
|
draft,
|
||||||
|
resetRequested: false,
|
||||||
|
resetSnapshot: row.resetSnapshot })
|
||||||
|
)
|
||||||
onOpenChange (false)
|
onOpenChange (false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,16 +116,12 @@ const PostImportRowDialog: FC<Props> = (
|
|||||||
<ThumbnailPreview
|
<ThumbnailPreview
|
||||||
url={draft.thumbnailBase}
|
url={draft.thumbnailBase}
|
||||||
className="h-28 w-28"/>
|
className="h-28 w-28"/>
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<PostImportStatusBadge value={effectivePostImportStatus (displayRow)}/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<DialogTextField
|
<DialogTextField
|
||||||
label="URL"
|
label="URL"
|
||||||
value={draft.url}
|
value={draft.url}
|
||||||
origin={fieldOrigin ('url')}
|
|
||||||
warnings={displayRow.fieldWarnings.url}
|
warnings={displayRow.fieldWarnings.url}
|
||||||
errors={groupedMessages (displayRow.validationErrors.url,
|
errors={groupedMessages (displayRow.validationErrors.url,
|
||||||
displayRow.importErrors?.url)}
|
displayRow.importErrors?.url)}
|
||||||
@@ -159,7 +129,6 @@ const PostImportRowDialog: FC<Props> = (
|
|||||||
<DialogTextField
|
<DialogTextField
|
||||||
label="タイトル"
|
label="タイトル"
|
||||||
value={draft.title}
|
value={draft.title}
|
||||||
origin={fieldOrigin ('title')}
|
|
||||||
warnings={displayRow.fieldWarnings.title}
|
warnings={displayRow.fieldWarnings.title}
|
||||||
errors={groupedMessages (displayRow.validationErrors.title,
|
errors={groupedMessages (displayRow.validationErrors.title,
|
||||||
displayRow.importErrors?.title)}
|
displayRow.importErrors?.title)}
|
||||||
@@ -167,7 +136,6 @@ const PostImportRowDialog: FC<Props> = (
|
|||||||
<DialogTextField
|
<DialogTextField
|
||||||
label="サムネール基底 URL"
|
label="サムネール基底 URL"
|
||||||
value={draft.thumbnailBase}
|
value={draft.thumbnailBase}
|
||||||
origin={fieldOrigin ('thumbnailBase')}
|
|
||||||
warnings={displayRow.fieldWarnings.thumbnailBase}
|
warnings={displayRow.fieldWarnings.thumbnailBase}
|
||||||
errors={groupedMessages (
|
errors={groupedMessages (
|
||||||
displayRow.validationErrors.thumbnailBase,
|
displayRow.validationErrors.thumbnailBase,
|
||||||
@@ -175,10 +143,6 @@ const PostImportRowDialog: FC<Props> = (
|
|||||||
)}
|
)}
|
||||||
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}
|
||||||
@@ -194,7 +158,6 @@ const PostImportRowDialog: FC<Props> = (
|
|||||||
<DialogTextField
|
<DialogTextField
|
||||||
label="動画時間"
|
label="動画時間"
|
||||||
value={draft.duration}
|
value={draft.duration}
|
||||||
origin={fieldOrigin ('duration')}
|
|
||||||
errors={groupedMessages (
|
errors={groupedMessages (
|
||||||
displayRow.validationErrors.duration,
|
displayRow.validationErrors.duration,
|
||||||
displayRow.validationErrors.videoMs,
|
displayRow.validationErrors.videoMs,
|
||||||
@@ -205,7 +168,6 @@ const PostImportRowDialog: FC<Props> = (
|
|||||||
<DialogAreaField
|
<DialogAreaField
|
||||||
label="タグ"
|
label="タグ"
|
||||||
value={draft.tags}
|
value={draft.tags}
|
||||||
origin={fieldOrigin ('tags')}
|
|
||||||
warnings={displayRow.fieldWarnings.tags}
|
warnings={displayRow.fieldWarnings.tags}
|
||||||
errors={groupedMessages (displayRow.validationErrors.tags,
|
errors={groupedMessages (displayRow.validationErrors.tags,
|
||||||
displayRow.importErrors?.tags)}
|
displayRow.importErrors?.tags)}
|
||||||
@@ -213,7 +175,6 @@ const PostImportRowDialog: FC<Props> = (
|
|||||||
<DialogTextField
|
<DialogTextField
|
||||||
label="親投稿"
|
label="親投稿"
|
||||||
value={draft.parentPostIds}
|
value={draft.parentPostIds}
|
||||||
origin={fieldOrigin ('parentPostIds')}
|
|
||||||
errors={groupedMessages (
|
errors={groupedMessages (
|
||||||
displayRow.validationErrors.parentPostIds,
|
displayRow.validationErrors.parentPostIds,
|
||||||
displayRow.importErrors?.parentPostIds,
|
displayRow.importErrors?.parentPostIds,
|
||||||
@@ -230,7 +191,15 @@ const PostImportRowDialog: FC<Props> = (
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setDraft (buildResetDraft (row))}>
|
onClick={async () => {
|
||||||
|
const resetDraft = buildResetDraft (row)
|
||||||
|
setDraft (resetDraft)
|
||||||
|
if (await onSave ({
|
||||||
|
draft: resetDraft,
|
||||||
|
resetRequested: true,
|
||||||
|
resetSnapshot: row.resetSnapshot }))
|
||||||
|
onOpenChange (false)
|
||||||
|
}}>
|
||||||
変更をリセット
|
変更をリセット
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -251,15 +220,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
|
||||||
@@ -273,15 +241,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
|
||||||
@@ -295,14 +262,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 { importPanelToneForRow } from '@/components/posts/import/postImportTone'
|
import { importPanelToneForRow } from '@/components/posts/import/postImportTone'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
@@ -14,14 +14,6 @@ type Props = {
|
|||||||
row: PostImportRow
|
row: PostImportRow
|
||||||
onEdit: () => void }
|
onEdit: () => void }
|
||||||
|
|
||||||
const summaryError = (row: PostImportRow): string | null => {
|
|
||||||
const validation = Object.values (row.validationErrors ?? { }).flat ()[0]
|
|
||||||
if (validation)
|
|
||||||
return validation
|
|
||||||
|
|
||||||
return Object.values (row.importErrors ?? { }).flat ()[0] ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
const summaryWarning = (row: PostImportRow): string | null =>
|
const summaryWarning = (row: PostImportRow): string | null =>
|
||||||
Object.values (row.fieldWarnings ?? { }).flat ()[0]
|
Object.values (row.fieldWarnings ?? { }).flat ()[0]
|
||||||
?? row.baseWarnings?.[0]
|
?? row.baseWarnings?.[0]
|
||||||
@@ -34,9 +26,8 @@ const summaryDate = (row: PostImportRow): string =>
|
|||||||
|
|
||||||
|
|
||||||
const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
|
const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
|
||||||
const error = summaryError (row)
|
const warning = summaryWarning (row)
|
||||||
const warning = error == null ? summaryWarning (row) : null
|
const displayStatus = displayPostImportStatus (row)
|
||||||
const displayStatus = effectivePostImportStatus (row)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -71,13 +62,9 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
|
|||||||
<div className="text-xs text-amber-700 dark:text-amber-200">
|
<div className="text-xs text-amber-700 dark:text-amber-200">
|
||||||
{warning}
|
{warning}
|
||||||
</div>)}
|
</div>)}
|
||||||
{error && (
|
|
||||||
<div className="text-xs text-red-700 dark:text-red-200">
|
|
||||||
{error}
|
|
||||||
</div>)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<PostImportStatusBadge value={displayStatus}/>
|
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
@@ -107,12 +94,8 @@ 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={displayStatus}/>
|
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
|
||||||
<div className="text-xs text-red-700 dark:text-red-200">
|
|
||||||
{error}
|
|
||||||
</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,22 +11,12 @@ type Props = {
|
|||||||
const LABELS: Record<PostImportBadgeValue, string> = {
|
const LABELS: Record<PostImportBadgeValue, string> = {
|
||||||
ready: '登録可能',
|
ready: '登録可能',
|
||||||
warning: '警告',
|
warning: '警告',
|
||||||
error: '要修正',
|
skipped: 'スキップ' }
|
||||||
created: '登録済み',
|
|
||||||
skipped: 'スキップ',
|
|
||||||
failed: '失敗',
|
|
||||||
automatic: '自動取得',
|
|
||||||
manual: '手修正' }
|
|
||||||
|
|
||||||
const TONES: Record<PostImportBadgeValue, StatusBadgeTone> = {
|
const TONES: Record<PostImportBadgeValue, StatusBadgeTone> = {
|
||||||
ready: 'ready',
|
ready: 'ready',
|
||||||
warning: 'warning',
|
warning: 'warning',
|
||||||
error: 'danger',
|
skipped: 'skipped' }
|
||||||
created: 'info',
|
|
||||||
skipped: 'skipped',
|
|
||||||
failed: 'danger',
|
|
||||||
automatic: 'default',
|
|
||||||
manual: 'manual' }
|
|
||||||
|
|
||||||
|
|
||||||
const PostImportStatusBadge: FC<Props> = ({ value }) => (
|
const PostImportStatusBadge: FC<Props> = ({ value }) => (
|
||||||
|
|||||||
@@ -1,37 +1,29 @@
|
|||||||
import type { PostImportRow } from '@/lib/postImportSession'
|
import type { PostImportRow } from '@/lib/postImportSession'
|
||||||
|
|
||||||
export type PostImportEffectiveStatus =
|
export type PostImportDisplayStatus = 'ready' | 'skipped' | 'warning'
|
||||||
'created'
|
|
||||||
| 'error'
|
|
||||||
| 'failed'
|
|
||||||
| 'ready'
|
|
||||||
| 'skipped'
|
|
||||||
| 'warning'
|
|
||||||
|
|
||||||
export type PostImportBadgeValue =
|
export type PostImportBadgeValue = PostImportDisplayStatus
|
||||||
PostImportEffectiveStatus
|
|
||||||
| 'automatic'
|
|
||||||
| 'manual'
|
|
||||||
|
|
||||||
const hasWarnings = (row: PostImportRow): boolean =>
|
const hasWarnings = (row: PostImportRow): boolean =>
|
||||||
Object.values (row.fieldWarnings ?? { }).some (_1 => _1.length > 0)
|
Object.values (row.fieldWarnings ?? { }).some (_1 => _1.length > 0)
|
||||||
|| row.baseWarnings.length > 0
|
|| row.baseWarnings.length > 0
|
||||||
|
|
||||||
export const effectivePostImportStatus = (
|
export const displayPostImportStatus = (
|
||||||
row: PostImportRow,
|
row: PostImportRow,
|
||||||
): PostImportEffectiveStatus => {
|
): PostImportDisplayStatus | null => {
|
||||||
if (Object.keys (row.validationErrors ?? { }).length > 0 || row.status === 'error')
|
const skipped =
|
||||||
return 'error'
|
row.skipReason === 'existing'
|
||||||
|
|| row.importStatus === 'skipped'
|
||||||
|
const hidden =
|
||||||
|
Object.keys (row.validationErrors ?? { }).length > 0
|
||||||
|
|| row.importStatus === 'failed'
|
||||||
|
|| row.importStatus === 'created'
|
||||||
|
|
||||||
switch (row.importStatus)
|
if (skipped)
|
||||||
{
|
return 'skipped'
|
||||||
case 'created':
|
if (hidden)
|
||||||
case 'skipped':
|
return null
|
||||||
case 'failed':
|
|
||||||
return row.importStatus
|
|
||||||
default:
|
|
||||||
return hasWarnings (row) || row.status === 'warning'
|
return hasWarnings (row) || row.status === 'warning'
|
||||||
? 'warning'
|
? 'warning'
|
||||||
: 'ready'
|
: 'ready'
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { effectivePostImportStatus } from '@/components/posts/import/postImportRowStatus'
|
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
|
||||||
|
|
||||||
import type { PostImportRow } from '@/lib/postImportSession'
|
import type { PostImportRow } from '@/lib/postImportSession'
|
||||||
|
|
||||||
@@ -33,4 +33,4 @@ export const importPanelToneClass = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const importPanelToneForRow = (row: PostImportRow): string[] =>
|
export const importPanelToneForRow = (row: PostImportRow): string[] =>
|
||||||
importPanelToneClass (effectivePostImportStatus (row))
|
importPanelToneClass (displayPostImportStatus (row) ?? 'ready')
|
||||||
|
|||||||
@@ -7,18 +7,14 @@ const hasSkipReason = (row: PostImportRow): boolean =>
|
|||||||
const hasValidationErrors = (row: PostImportRow): boolean =>
|
const hasValidationErrors = (row: PostImportRow): boolean =>
|
||||||
Object.keys (row.validationErrors ?? { }).length > 0
|
Object.keys (row.validationErrors ?? { }).length > 0
|
||||||
|
|
||||||
const rowChanged = (
|
|
||||||
previous: PostImportRow,
|
|
||||||
next: PostImportRow,
|
|
||||||
): boolean =>
|
|
||||||
previous.url !== next.url
|
|
||||||
|| JSON.stringify (previous.attributes) !== JSON.stringify (next.attributes)
|
|
||||||
|| JSON.stringify (previous.provenance) !== JSON.stringify (next.provenance)
|
|
||||||
|| JSON.stringify (previous.tagSources ?? { }) !== JSON.stringify (next.tagSources ?? { })
|
|
||||||
|
|
||||||
const buildResetSnapshot = (row: PostImportRow) => ({
|
const buildResetSnapshot = (row: PostImportRow) => ({
|
||||||
url: row.url,
|
url: row.url,
|
||||||
attributes: row.attributes })
|
attributes: { ...row.attributes },
|
||||||
|
provenance: { ...row.provenance },
|
||||||
|
tagSources: {
|
||||||
|
automatic: row.tagSources?.automatic ?? '',
|
||||||
|
manual: row.tagSources?.manual ?? '' },
|
||||||
|
metadataUrl: row.metadataUrl })
|
||||||
|
|
||||||
|
|
||||||
export const processableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
|
export const processableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
|
||||||
@@ -43,23 +39,14 @@ export const reviewSummaryCounts = (rows: PostImportRow[]) => ({
|
|||||||
submittable: rows.filter (row =>
|
submittable: rows.filter (row =>
|
||||||
creatableImportRows ([row]).length > 0
|
creatableImportRows ([row]).length > 0
|
||||||
&& !(hasValidationErrors (row))).length,
|
&& !(hasValidationErrors (row))).length,
|
||||||
invalid: rows.filter (row => hasValidationErrors (row)).length,
|
|
||||||
skipPlanned: rows.filter (row =>
|
skipPlanned: rows.filter (row =>
|
||||||
hasSkipReason (row)
|
processableImportRows ([row]).length > 0
|
||||||
&& !(hasValidationErrors (row))
|
&& hasSkipReason (row)).length })
|
||||||
&& row.importStatus !== 'created').length,
|
|
||||||
created: rows.filter (row => row.importStatus === 'created').length,
|
|
||||||
failed: rows.filter (row => row.importStatus === 'failed').length })
|
|
||||||
|
|
||||||
|
|
||||||
export const resultSummaryCounts = (rows: PostImportRow[]) =>
|
export const resultSummaryCounts = (rows: PostImportRow[]) =>
|
||||||
rows.reduce (
|
rows.reduce (
|
||||||
(counts, row) => {
|
(counts, row) => {
|
||||||
if (hasValidationErrors (row))
|
|
||||||
{
|
|
||||||
++counts.invalid
|
|
||||||
return counts
|
|
||||||
}
|
|
||||||
if (row.importStatus === 'created')
|
if (row.importStatus === 'created')
|
||||||
{
|
{
|
||||||
++counts.created
|
++counts.created
|
||||||
@@ -74,7 +61,7 @@ export const resultSummaryCounts = (rows: PostImportRow[]) =>
|
|||||||
++counts.failed
|
++counts.failed
|
||||||
return counts
|
return counts
|
||||||
},
|
},
|
||||||
{ created: 0, skipped: 0, failed: 0, invalid: 0 })
|
{ created: 0, skipped: 0, failed: 0 })
|
||||||
|
|
||||||
|
|
||||||
export const mergeValidatedImportRows = (
|
export const mergeValidatedImportRows = (
|
||||||
@@ -83,7 +70,11 @@ export const mergeValidatedImportRows = (
|
|||||||
): PostImportRow[] => {
|
): PostImportRow[] => {
|
||||||
const validatedMap = new Map (validated.map (row => [row.sourceRow, row]))
|
const validatedMap = new Map (validated.map (row => [row.sourceRow, row]))
|
||||||
return current.map (previous => {
|
return current.map (previous => {
|
||||||
if (previous.importStatus === 'created')
|
if (
|
||||||
|
previous.importStatus === 'created'
|
||||||
|
|| previous.importStatus === 'skipped'
|
||||||
|
|| previous.importStatus === 'failed'
|
||||||
|
)
|
||||||
return previous
|
return previous
|
||||||
|
|
||||||
const row = validatedMap.get (previous.sourceRow)
|
const row = validatedMap.get (previous.sourceRow)
|
||||||
@@ -124,59 +115,6 @@ export const mergeValidatedImportRows = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const mergePreviewImportRows = (
|
|
||||||
current: PostImportRow[],
|
|
||||||
preview: PostImportRow[],
|
|
||||||
): PostImportRow[] => {
|
|
||||||
const currentMap = new Map (current.map (row => [row.sourceRow, row]))
|
|
||||||
return preview.map (row => {
|
|
||||||
const previous = currentMap.get (row.sourceRow)
|
|
||||||
if (previous == null)
|
|
||||||
return row
|
|
||||||
if (previous.importStatus === 'created')
|
|
||||||
return previous
|
|
||||||
|
|
||||||
const mergedAttributes = { ...row.attributes }
|
|
||||||
const mergedProvenance = { ...row.provenance }
|
|
||||||
|
|
||||||
for (const [field, origin] of Object.entries (previous.provenance))
|
|
||||||
{
|
|
||||||
if (origin !== 'manual' || field === 'url')
|
|
||||||
continue
|
|
||||||
if (field in previous.attributes)
|
|
||||||
mergedAttributes[field] = previous.attributes[field]
|
|
||||||
mergedProvenance[field] = 'manual'
|
|
||||||
}
|
|
||||||
|
|
||||||
const mergedTagSources =
|
|
||||||
previous.provenance.tags === 'manual'
|
|
||||||
? {
|
|
||||||
automatic: row.tagSources?.automatic ?? '',
|
|
||||||
manual: previous.tagSources?.manual ?? String (previous.attributes.tags ?? '') }
|
|
||||||
: row.tagSources
|
|
||||||
|
|
||||||
const nextRow = {
|
|
||||||
...row,
|
|
||||||
attributes: mergedAttributes,
|
|
||||||
provenance: mergedProvenance,
|
|
||||||
tagSources: mergedTagSources,
|
|
||||||
skipReason: row.skipReason,
|
|
||||||
existingPostId: row.existingPostId,
|
|
||||||
resetSnapshot: row.resetSnapshot,
|
|
||||||
createdPostId: previous.createdPostId,
|
|
||||||
importStatus: previous.importStatus,
|
|
||||||
importErrors: previous.importErrors }
|
|
||||||
|
|
||||||
if (rowChanged (previous, nextRow))
|
|
||||||
{
|
|
||||||
nextRow.importStatus = 'pending'
|
|
||||||
nextRow.importErrors = undefined
|
|
||||||
}
|
|
||||||
return nextRow
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const mergeImportResults = (
|
export const mergeImportResults = (
|
||||||
rows: PostImportRow[],
|
rows: PostImportRow[],
|
||||||
results: PostImportResultRow[],
|
results: PostImportResultRow[],
|
||||||
@@ -193,13 +131,15 @@ export const mergeImportResults = (
|
|||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
importStatus: 'created',
|
importStatus: 'created',
|
||||||
createdPostId: result.post?.id,
|
skipReason: undefined,
|
||||||
|
createdPostId: result.post.id,
|
||||||
existingPostId: undefined,
|
existingPostId: undefined,
|
||||||
importErrors: result.errors }
|
importErrors: result.errors }
|
||||||
case 'skipped':
|
case 'skipped':
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
importStatus: 'skipped',
|
importStatus: 'skipped',
|
||||||
|
skipReason: 'existing',
|
||||||
createdPostId: undefined,
|
createdPostId: undefined,
|
||||||
existingPostId: result.existingPostId,
|
existingPostId: result.existingPostId,
|
||||||
importErrors: result.errors }
|
importErrors: result.errors }
|
||||||
@@ -207,6 +147,7 @@ export const mergeImportResults = (
|
|||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
importStatus: 'failed',
|
importStatus: 'failed',
|
||||||
|
skipReason: undefined,
|
||||||
createdPostId: undefined,
|
createdPostId: undefined,
|
||||||
existingPostId: undefined,
|
existingPostId: undefined,
|
||||||
importErrors: result.errors }
|
importErrors: result.errors }
|
||||||
|
|||||||
@@ -150,10 +150,27 @@ const sanitiseResetSnapshot = (value: unknown): PostImportResetSnapshot | null =
|
|||||||
if (!(Object.values (value.attributes).every (entry =>
|
if (!(Object.values (value.attributes).every (entry =>
|
||||||
typeof entry === 'string' || typeof entry === 'number')))
|
typeof entry === 'string' || typeof entry === 'number')))
|
||||||
return null
|
return null
|
||||||
|
if (!(isPlainObject (value.provenance)))
|
||||||
|
return null
|
||||||
|
if (!(hasOnlyKeys (value.provenance, PROVENANCE_KEYS)))
|
||||||
|
return null
|
||||||
|
if (!(Object.values (value.provenance).every (origin => isValidOrigin (origin))))
|
||||||
|
return null
|
||||||
|
if (!(isPlainObject (value.tagSources)))
|
||||||
|
return null
|
||||||
|
if (!(hasOnlyKeys (value.tagSources, TAG_SOURCE_KEYS)))
|
||||||
|
return null
|
||||||
|
if (!(Object.values (value.tagSources).every (_1 => typeof _1 === 'string')))
|
||||||
|
return null
|
||||||
|
if (value.metadataUrl != null && typeof value.metadataUrl !== 'string')
|
||||||
|
return null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url: value.url,
|
url: value.url,
|
||||||
attributes: value.attributes as Record<string, string | number> }
|
attributes: value.attributes as Record<string, string | number>,
|
||||||
|
provenance: value.provenance as Record<string, PostImportOrigin>,
|
||||||
|
tagSources: value.tagSources as Record<PostImportOrigin, string>,
|
||||||
|
metadataUrl: value.metadataUrl as string | undefined }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ export type PostImportAttributeValue = string | number
|
|||||||
|
|
||||||
export type PostImportResetSnapshot = {
|
export type PostImportResetSnapshot = {
|
||||||
url: string
|
url: string
|
||||||
attributes: Record<string, PostImportAttributeValue> }
|
attributes: Record<string, PostImportAttributeValue>
|
||||||
|
provenance: Record<string, PostImportOrigin>
|
||||||
|
tagSources: Record<PostImportOrigin, string>
|
||||||
|
metadataUrl?: string }
|
||||||
|
|
||||||
export type PostImportRow = {
|
export type PostImportRow = {
|
||||||
sourceRow: number
|
sourceRow: number
|
||||||
@@ -31,11 +34,20 @@ export type PostImportRow = {
|
|||||||
createdPostId?: number
|
createdPostId?: number
|
||||||
importStatus?: PostImportStatus }
|
importStatus?: PostImportStatus }
|
||||||
|
|
||||||
export type PostImportResultRow = {
|
export type PostImportResultRow =
|
||||||
|
| {
|
||||||
sourceRow: number
|
sourceRow: number
|
||||||
status: PostImportResultStatus
|
status: 'created'
|
||||||
post?: { id: number }
|
post: { id: number }
|
||||||
existingPostId?: number
|
errors?: Record<string, string[]> }
|
||||||
|
| {
|
||||||
|
sourceRow: number
|
||||||
|
status: 'skipped'
|
||||||
|
existingPostId: number
|
||||||
|
errors?: Record<string, string[]> }
|
||||||
|
| {
|
||||||
|
sourceRow: number
|
||||||
|
status: 'failed'
|
||||||
errors?: Record<string, string[]> }
|
errors?: Record<string, string[]> }
|
||||||
|
|
||||||
export type PostImportSession = {
|
export type PostImportSession = {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ 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 { importPanelToneClass } from '@/components/posts/import/postImportTone'
|
import { importPanelToneClass } from '@/components/posts/import/postImportTone'
|
||||||
import PostImportSummaryChip from '@/components/posts/import/PostImportSummaryChip'
|
import PostImportSummaryChip from '@/components/posts/import/PostImportSummaryChip'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -35,9 +35,8 @@ import type { User } from '@/types'
|
|||||||
|
|
||||||
type Props = { user: User | null }
|
type Props = { user: User | null }
|
||||||
|
|
||||||
const rowMessages = (row: PostImportRow): string[] => [
|
const rowMessages = (row: PostImportRow): string[] =>
|
||||||
...Object.values (row.validationErrors ?? { }).flat (),
|
Object.values (row.importErrors ?? { }).flat ()
|
||||||
...Object.values (row.importErrors ?? { }).flat ()]
|
|
||||||
|
|
||||||
|
|
||||||
const PostImportResultPage: FC<Props> = ({ user }) => {
|
const PostImportResultPage: FC<Props> = ({ user }) => {
|
||||||
@@ -94,15 +93,22 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
|||||||
const validatedRows = mergeValidatedImportRows (
|
const validatedRows = mergeValidatedImportRows (
|
||||||
pendingRows,
|
pendingRows,
|
||||||
initialisePreviewRows (validated.rows))
|
initialisePreviewRows (validated.rows))
|
||||||
setSession (current =>
|
const nextSession = {
|
||||||
current
|
...session,
|
||||||
? { ...current, rows: validatedRows }
|
rows: validatedRows,
|
||||||
: current)
|
repairMode: 'failed' as const }
|
||||||
|
setSession (nextSession)
|
||||||
const target = validatedRows.find (_1 => _1.sourceRow === sourceRow)
|
const target = validatedRows.find (_1 => _1.sourceRow === sourceRow)
|
||||||
if (target == null)
|
if (target == null)
|
||||||
return
|
return
|
||||||
if (Object.keys (target.validationErrors ?? { }).length > 0)
|
if (Object.keys (target.validationErrors ?? { }).length > 0)
|
||||||
|
{
|
||||||
|
const saved = savePostImportSession (sessionId, nextSession, message =>
|
||||||
|
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||||
|
if (saved)
|
||||||
|
navigate (`/posts/import/${ sessionId }/review?edit=${ sourceRow }`)
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const result = await apiPost<{
|
const result = await apiPost<{
|
||||||
created: number
|
created: number
|
||||||
@@ -174,36 +180,37 @@ 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">
|
||||||
<PostImportSummaryChip label="登録成功" value={counts.created} badge="created"/>
|
<PostImportSummaryChip label="登録成功" value={counts.created}/>
|
||||||
<PostImportSummaryChip label="スキップ" value={counts.skipped} badge="skipped"/>
|
<PostImportSummaryChip label="スキップ" value={counts.skipped} badge="skipped"/>
|
||||||
<PostImportSummaryChip label="失敗" value={counts.failed} badge="failed"/>
|
<PostImportSummaryChip label="失敗" value={counts.failed}/>
|
||||||
<PostImportSummaryChip label="要修正" value={counts.invalid} badge="error"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{session.rows.map (row => {
|
{session.rows.map (row => {
|
||||||
const displayStatus = effectivePostImportStatus (row)
|
const displayStatus = displayPostImportStatus (row)
|
||||||
const canEdit =
|
const canEdit = row.importStatus === 'failed'
|
||||||
Object.keys (row.validationErrors).length > 0
|
|
||||||
|| row.importStatus === 'failed'
|
|
||||||
const canRetry =
|
const canRetry =
|
||||||
row.importStatus === 'created'
|
row.importStatus === 'failed'
|
||||||
? false
|
|
||||||
: row.importStatus === 'failed'
|
|
||||||
&& Object.keys (row.validationErrors).length === 0
|
&& Object.keys (row.validationErrors).length === 0
|
||||||
|
const panelStatus =
|
||||||
|
row.importStatus === 'created'
|
||||||
|
? 'created'
|
||||||
|
: (row.importStatus === 'failed'
|
||||||
|
? 'failed'
|
||||||
|
: displayStatus ?? 'ready')
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={row.sourceRow}
|
key={row.sourceRow}
|
||||||
className={[
|
className={[
|
||||||
'rounded-lg border p-4',
|
'rounded-lg border p-4',
|
||||||
...importPanelToneClass (displayStatus)].join (' ')}>
|
...importPanelToneClass (panelStatus)].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={displayStatus}/>
|
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
|
||||||
</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}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { useNavigate,
|
|||||||
|
|
||||||
import PageTitle from '@/components/common/PageTitle'
|
import PageTitle from '@/components/common/PageTitle'
|
||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import PageActionFooter from '@/components/posts/import/PageActionFooter'
|
|
||||||
import PostImportRowDialog from '@/components/posts/import/PostImportRowDialog'
|
import PostImportRowDialog from '@/components/posts/import/PostImportRowDialog'
|
||||||
import PostImportRowSummary from '@/components/posts/import/PostImportRowSummary'
|
import PostImportRowSummary from '@/components/posts/import/PostImportRowSummary'
|
||||||
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
|
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
|
||||||
@@ -120,57 +119,35 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
setSession (current =>
|
setSession (current =>
|
||||||
current ? { ...current, rows: nextRows } : current)
|
current ? { ...current, rows: nextRows } : current)
|
||||||
|
|
||||||
const saveDraft = async (draft: Draft): Promise<boolean> => {
|
const saveDraft = async (
|
||||||
|
{ draft, resetRequested, resetSnapshot }: {
|
||||||
|
draft: Draft
|
||||||
|
resetRequested: boolean
|
||||||
|
resetSnapshot: PostImportRow['resetSnapshot'] },
|
||||||
|
): Promise<boolean> => {
|
||||||
if (session == null)
|
if (session == null)
|
||||||
return false
|
return false
|
||||||
if (editingRow == null)
|
if (editingRow == null)
|
||||||
return false
|
return false
|
||||||
|
|
||||||
const urlChanged = draft.url !== editingRow.url
|
const urlChanged = draft.url !== editingRow.url
|
||||||
const nextProvenance = { ...editingRow.provenance }
|
const nextRow =
|
||||||
const nextAttributes = { ...editingRow.attributes }
|
resetRequested
|
||||||
const nextTagSources = {
|
? {
|
||||||
automatic: editingRow.tagSources?.automatic ?? '',
|
...editingRow,
|
||||||
manual: editingRow.tagSources?.manual ?? '' }
|
url: resetSnapshot.url,
|
||||||
const draftFields = [
|
attributes: { ...resetSnapshot.attributes },
|
||||||
['title', draft.title],
|
provenance: { ...resetSnapshot.provenance },
|
||||||
['thumbnailBase', draft.thumbnailBase],
|
tagSources: { ...resetSnapshot.tagSources },
|
||||||
['originalCreatedFrom', draft.originalCreatedFrom],
|
metadataUrl: resetSnapshot.metadataUrl,
|
||||||
['originalCreatedBefore', draft.originalCreatedBefore],
|
importStatus: editingRow.importStatus === 'created' ? 'created' : 'pending',
|
||||||
['duration', draft.duration],
|
importErrors: undefined }
|
||||||
['parentPostIds', draft.parentPostIds]] as const
|
: buildNextEditedRow (editingRow, draft, urlChanged)
|
||||||
draftFields.forEach (([field, value]) => {
|
|
||||||
nextAttributes[field] = value
|
|
||||||
nextProvenance[field] =
|
|
||||||
value !== String (editingRow.attributes[field] ?? '')
|
|
||||||
? 'manual'
|
|
||||||
: (editingRow.provenance[field] ?? 'automatic')
|
|
||||||
})
|
|
||||||
nextAttributes.tags = draft.tags
|
|
||||||
if (draft.tags !== String (editingRow.attributes.tags ?? ''))
|
|
||||||
{
|
|
||||||
nextProvenance.tags = 'manual'
|
|
||||||
nextTagSources.manual = draft.tags
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
nextProvenance.tags = editingRow.provenance.tags ?? 'automatic'
|
|
||||||
nextTagSources.manual = editingRow.tagSources?.manual ?? ''
|
|
||||||
}
|
|
||||||
|
|
||||||
setSavingRow (editingRow.sourceRow)
|
setSavingRow (editingRow.sourceRow)
|
||||||
const nextRows = session.rows.map (row =>
|
const nextRows = session.rows.map (row =>
|
||||||
row.sourceRow === editingRow.sourceRow
|
row.sourceRow === editingRow.sourceRow
|
||||||
? {
|
? nextRow
|
||||||
...row,
|
|
||||||
url: draft.url,
|
|
||||||
attributes: nextAttributes,
|
|
||||||
provenance: {
|
|
||||||
...nextProvenance,
|
|
||||||
url: urlChanged ? 'manual' : (editingRow.provenance.url ?? 'manual') },
|
|
||||||
tagSources: nextTagSources,
|
|
||||||
importStatus: row.importStatus === 'created' ? 'created' : 'pending',
|
|
||||||
importErrors: undefined }
|
|
||||||
: row)
|
: row)
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -300,7 +277,6 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<PostImportSummaryChip label="全件" value={counts.total}/>
|
<PostImportSummaryChip label="全件" value={counts.total}/>
|
||||||
<PostImportSummaryChip label="登録対象" value={counts.submittable} badge="ready"/>
|
<PostImportSummaryChip label="登録対象" value={counts.submittable} badge="ready"/>
|
||||||
<PostImportSummaryChip label="要確認" value={counts.invalid} badge="error"/>
|
|
||||||
<PostImportSummaryChip label="スキップ予定" value={counts.skipPlanned}
|
<PostImportSummaryChip label="スキップ予定" value={counts.skipPlanned}
|
||||||
badge="skipped"/>
|
badge="skipped"/>
|
||||||
</div>
|
</div>
|
||||||
@@ -321,7 +297,6 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
|
|
||||||
<PostImportFooter
|
<PostImportFooter
|
||||||
loading={loading}
|
loading={loading}
|
||||||
invalidCount={counts.invalid}
|
|
||||||
processableCount={processable.length}
|
processableCount={processable.length}
|
||||||
creatableCount={creatable.length}
|
creatableCount={creatable.length}
|
||||||
skipPlannedCount={counts.skipPlanned}
|
skipPlannedCount={counts.skipPlanned}
|
||||||
@@ -343,34 +318,31 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
|
|
||||||
const PostImportFooter = (
|
const PostImportFooter = (
|
||||||
{ loading,
|
{ loading,
|
||||||
invalidCount,
|
|
||||||
processableCount,
|
processableCount,
|
||||||
creatableCount,
|
creatableCount,
|
||||||
skipPlannedCount,
|
skipPlannedCount,
|
||||||
onBack,
|
onBack,
|
||||||
onSubmit }: {
|
onSubmit }: {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
invalidCount: number
|
|
||||||
processableCount: number
|
processableCount: number
|
||||||
creatableCount: number
|
creatableCount: number
|
||||||
skipPlannedCount: number
|
skipPlannedCount: number
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
onSubmit: () => void },
|
onSubmit: () => void },
|
||||||
) => (
|
) => (
|
||||||
<PageActionFooter
|
<div
|
||||||
summary={
|
className="shrink-0 border-t bg-white/95 p-4 backdrop-blur
|
||||||
<>
|
dark:border-neutral-700 dark:bg-neutral-950/95">
|
||||||
|
<div className="mx-auto flex max-w-6xl flex-col gap-3 md:flex-row
|
||||||
|
md:items-center md:justify-between">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
<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"/>
|
</div>
|
||||||
<span>要確認 {invalidCount} 件</span>
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
</>
|
|
||||||
}
|
|
||||||
actions={
|
|
||||||
<>
|
|
||||||
<Button type="button" variant="outline" onClick={onBack}>
|
<Button type="button" variant="outline" onClick={onBack}>
|
||||||
URL リスト入力へ戻る
|
URL リスト入力へ戻る
|
||||||
</Button>
|
</Button>
|
||||||
@@ -380,7 +352,56 @@ const PostImportFooter = (
|
|||||||
disabled={loading || processableCount === 0}>
|
disabled={loading || processableCount === 0}>
|
||||||
取込を実行
|
取込を実行
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</div>
|
||||||
}/>)
|
</div>
|
||||||
|
</div>)
|
||||||
|
|
||||||
|
const buildNextEditedRow = (
|
||||||
|
editingRow: PostImportRow,
|
||||||
|
draft: Draft,
|
||||||
|
urlChanged: boolean,
|
||||||
|
): PostImportRow => {
|
||||||
|
const nextProvenance = { ...editingRow.provenance }
|
||||||
|
const nextAttributes = { ...editingRow.attributes }
|
||||||
|
const nextTagSources = {
|
||||||
|
automatic: editingRow.tagSources?.automatic ?? '',
|
||||||
|
manual: editingRow.tagSources?.manual ?? '' }
|
||||||
|
const draftFields = [
|
||||||
|
['title', draft.title],
|
||||||
|
['thumbnailBase', draft.thumbnailBase],
|
||||||
|
['originalCreatedFrom', draft.originalCreatedFrom],
|
||||||
|
['originalCreatedBefore', draft.originalCreatedBefore],
|
||||||
|
['duration', draft.duration],
|
||||||
|
['parentPostIds', draft.parentPostIds]] as const
|
||||||
|
draftFields.forEach (([field, value]) => {
|
||||||
|
nextAttributes[field] = value
|
||||||
|
nextProvenance[field] =
|
||||||
|
value !== String (editingRow.attributes[field] ?? '')
|
||||||
|
? 'manual'
|
||||||
|
: (editingRow.provenance[field] ?? 'automatic')
|
||||||
|
})
|
||||||
|
nextAttributes.tags = draft.tags
|
||||||
|
if (draft.tags !== String (editingRow.attributes.tags ?? ''))
|
||||||
|
{
|
||||||
|
nextProvenance.tags = 'manual'
|
||||||
|
nextTagSources.manual = draft.tags
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
nextProvenance.tags = editingRow.provenance.tags ?? 'automatic'
|
||||||
|
nextTagSources.manual = editingRow.tagSources?.manual ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...editingRow,
|
||||||
|
url: draft.url,
|
||||||
|
attributes: nextAttributes,
|
||||||
|
provenance: {
|
||||||
|
...nextProvenance,
|
||||||
|
url: urlChanged ? 'manual' : (editingRow.provenance.url ?? 'manual') },
|
||||||
|
tagSources: nextTagSources,
|
||||||
|
importStatus: editingRow.importStatus === 'created' ? 'created' : 'pending',
|
||||||
|
importErrors: undefined }
|
||||||
|
}
|
||||||
|
|
||||||
export default PostImportReviewPage
|
export default PostImportReviewPage
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする