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