このコミットが含まれているのは:
2026-07-14 18:39:24 +09:00
コミット d035da99ad
14個のファイルの変更188行の追加232行の削除
+56 -124
ファイルの表示
@@ -141,140 +141,72 @@ npm run preview
- Ruby の multi-line hash literal / keyword-like argument hash では、opening - Ruby の multi-line hash literal / keyword-like argument hash では、opening
`{` を最初の pair と同じ行に置き、closing `}` を最後の pair と同じ行に `{` を最初の pair と同じ行に置き、closing `}` を最後の pair と同じ行に
置く。Prettier 的な縦開き・縦閉じをしない。 置く。Prettier 的な縦開き・縦閉じをしない。
- Ruby の `if` / `unless` / `case` 条件で、複数行に分けるだけで安易に - Ruby の guard 条件は、1 行で収まるなら modifier 形式を優先する。
`if ... end` へ展開しない。局所の既存コードが modifier 形式ならそれに 99 文字を超えるなら block 形式へ切り替へるか、message 定数化などで縮める。
そろえる。 - Ruby の method chain や call argument を折り返す際、call-site の `)`
- Ruby の guard 条件は、1 行で収まるなら modifier 形式を優先する。2 行以上に block close のやうに独立させない。
なるなら通常の block 形式へ切り替へてよい。
- Ruby の method chain や call argument を折り返す際、call-site の `)`
直前で行を空けたり、closing delimiter を block のやうに独立させない。
Bad: Bad:
```rb ```rb
source = response = Example.fetch(
if params[:format] == 'google_sheets' value,
PostImportGoogleSheetsFetcher.fetch!(params[:source], option: option,
rate_key: current_user.id)
else
params[:source]
end
parsed = PostImportSourceParser.new(
source:,
format:,
has_header: params[:has_header],
json_path: params[:json_path],
).parse
```
Good:
```rb
source =
if params[:format] == 'google_sheets'
PostImportGoogleSheetsFetcher.fetch!(params[:source],
rate_key: current_user.id)
else
params[:source]
end
parsed = PostImportSourceParser.new(
source:,
format:,
has_header: params[:has_header],
json_path: params[:json_path]).parse
```
Bad:
```rb
result[field] = {
'kind' => kind,
'value' => value['value'].to_s,
'columns' => Array(columns).map { Integer(_1) },
}
```
Good:
```rb
result[field] = {
'kind' => kind,
'value' => value['value'].to_s,
'columns' => Array(columns).map { Integer(_1) } }
```
Bad:
```rb
unless rows.all? { |row|
values = Array(row['values'])
values.length <= columns.length &&
values.all? { |value|
value.is_a?(String) &&
value.bytesize <= PostImportSourceParser::MAX_CELL_BYTES
}
}
raise ArgumentError, '解析結果のセルが不正です.'
end
```
Good:
```rb
raise ArgumentError, '解析結果のセルが不正です.' unless rows.all? { |row|
values = Array(row['values'])
values.length <= columns.length && values.all? { |value|
value.is_a?(String) && value.bytesize <= PostImportSourceParser::MAX_CELL_BYTES
}
}
```
Bad:
```rb
if row['url'].to_s.bytesize > PostImportSourceParser::MAX_CELL_BYTES
raise ArgumentError, 'URL が長すぎます.'
end
```
Good:
```rb
raise ArgumentError, 'URL が長すぎます.' if row['url'].to_s.bytesize > PostImportSourceParser::MAX_CELL_BYTES
```
Bad:
```rb
parameters = row.is_a?(ActionController::Parameters) ? row :
ActionController::Parameters.new(row)
```
Good:
```rb
parameters =
row.is_a?(ActionController::Parameters) ? row : ActionController::Parameters.new(row)
```
Bad:
```rb
PostTagSection.create!(
post_id: post.id,
tag_id:,
begin_ms:,
end_ms:,
) )
``` ```
Good: Good:
```rb ```rb
PostTagSection.create!(post_id: post.id, response = Example.fetch(
tag_id:, value,
begin_ms:, option: option)
end_ms:) ```
Bad:
```rb
payload = {
title: title,
url: url,
}
```
Good:
```rb
payload = {
title: title,
url: url }
```
Bad:
```rb
raise ArgumentError, 'URL が長すぎます.' if url.bytesize > MAX_URL_BYTES && flag.present?
```
Good:
```rb
if url.bytesize > MAX_URL_BYTES && flag.present?
raise ArgumentError, 'URL が長すぎます.'
end
```
Bad:
```rb
records.each {
do_work(_1) }
```
Good:
```rb
records.each {
do_work(_1)
}
``` ```
- TypeScript and Python: use GNU-style spacing before parentheses where - TypeScript and Python: use GNU-style spacing before parentheses where
syntactically valid. syntactically valid.
+4 -4
ファイルの表示
@@ -6,11 +6,11 @@ class PostImportsController < ApplicationController
rows: PostImportUrlListParser.parse(params[:source])) rows: PostImportUrlListParser.parse(params[:source]))
render json: { rows: } render json: { rows: }
rescue ArgumentError => e rescue ArgumentError => e
render_bad_request(e.message) render_bad_request e.message
end end
def validate def validate
rows = normalised_import_rows(allow_warning_fields: true) rows = normalised_import_rows allow_warning_fields: true
changed_row = Integer(params[:changed_row], exception: false) changed_row = Integer(params[:changed_row], exception: false)
result = result =
PostImportPreviewer.new.preview_rows(rows:, PostImportPreviewer.new.preview_rows(rows:,
@@ -18,7 +18,7 @@ class PostImportsController < ApplicationController
metadata_cache: { }) metadata_cache: { })
render json: { rows: result } render json: { rows: result }
rescue ArgumentError => e rescue ArgumentError => e
render_bad_request(e.message) render_bad_request e.message
end end
def create def create
@@ -26,7 +26,7 @@ class PostImportsController < ApplicationController
rows: normalised_import_rows).run rows: normalised_import_rows).run
render json: result, status: result[:created].positive? ? :created : :ok render json: result, status: result[:created].positive? ? :created : :ok
rescue ArgumentError => e rescue ArgumentError => e
render_bad_request(e.message) render_bad_request e.message
end end
private private
-9
ファイルの表示
@@ -174,14 +174,5 @@ class Post < ApplicationRecord
return if url.blank? return if url.blank?
self.url = PostUrlNormaliser.normalise(url) || url.strip self.url = PostUrlNormaliser.normalise(url) || url.strip
u = URI.parse(url)
return unless u in URI::HTTP
u.host = u.host.downcase if u.host
u.path = u.path.sub(/\/\Z/, '') if u.path.present?
self.url = PostUrlSanitisationRule.sanitise(u.to_s)
rescue URI::InvalidURIError
;
end end
end end
+11 -3
ファイルの表示
@@ -38,7 +38,7 @@ class PostImportPreviewer
provenance['url'] = 'manual' provenance['url'] = 'manual'
normal_url = normalised_url(url) normal_url = normalised_url(url)
url_for_metadata = normal_url || url url_for_metadata = normal_url || url
existing_post = normal_url.present? && Post.exists?(url: normal_url) existing_post = normal_url.present? ? Post.find_by(url: normal_url) : nil
validation_errors = {} validation_errors = {}
validation_errors[:url] = ['URL が不正です.'] if normal_url.blank? validation_errors[:url] = ['URL が不正です.'] if normal_url.blank?
@@ -61,6 +61,8 @@ class PostImportPreviewer
provenance:, provenance:,
tag_sources:, tag_sources:,
metadata_url: url_for_metadata, metadata_url: url_for_metadata,
skip_reason: 'existing',
existing_post_id: existing_post.id,
field_warnings:, field_warnings:,
base_warnings:, base_warnings:,
validation_errors:, validation_errors:,
@@ -93,6 +95,8 @@ class PostImportPreviewer
provenance:, provenance:,
tag_sources:, tag_sources:,
metadata_url: url_for_metadata, metadata_url: url_for_metadata,
skip_reason: nil,
existing_post_id: nil,
field_warnings:, field_warnings:,
base_warnings:, base_warnings:,
validation_errors:, validation_errors:,
@@ -129,7 +133,9 @@ class PostImportPreviewer
end end
def initial_field_warnings row def initial_field_warnings row
(row[:field_warnings] || { }).stringify_keys.transform_values { |value| Array(value).map(&:to_s) } (row[:field_warnings] || { })
.stringify_keys
.transform_values { |value| Array(value).map(&:to_s) }
end end
def initial_base_warnings row def initial_base_warnings row
@@ -161,7 +167,9 @@ class PostImportPreviewer
data = PostMetadataFetcher.fetch(url).stringify_keys.compact data = PostMetadataFetcher.fetch(url).stringify_keys.compact
warnings = { } warnings = { }
add_field_warning!(warnings, 'title', TITLE_FETCH_WARNING) if data['title'].blank? add_field_warning!(warnings, 'title', TITLE_FETCH_WARNING) if data['title'].blank?
add_field_warning!(warnings, 'thumbnail_base', THUMBNAIL_FETCH_WARNING) if data['thumbnail_base'].blank? if data['thumbnail_base'].blank?
add_field_warning!(warnings, 'thumbnail_base', THUMBNAIL_FETCH_WARNING)
end
{ data:, warnings: } { data:, warnings: }
rescue Preview::UrlSafety::UnsafeUrl, rescue Preview::UrlSafety::UnsafeUrl,
Preview::HttpFetcher::FetchFailed, Preview::HttpFetcher::FetchFailed,
+3 -1
ファイルの表示
@@ -161,7 +161,9 @@ class PostImportRowNormaliser
unless base_warnings.is_a?(Array) && base_warnings.all? { _1.is_a?(String) } unless base_warnings.is_a?(Array) && base_warnings.all? { _1.is_a?(String) }
raise ArgumentError, '警告の形式が不正です.' raise ArgumentError, '警告の形式が不正です.'
end end
raise ArgumentError, '警告が大きすぎます.' if base_warnings.any? { _1.bytesize > PostImportUrlListParser::MAX_URL_BYTES } if base_warnings.any? { _1.bytesize > PostImportUrlListParser::MAX_URL_BYTES }
raise ArgumentError, '警告が大きすぎます.'
end
end end
private_class_method :normalise_warning_values! private_class_method :normalise_warning_values!
+28 -32
ファイルの表示
@@ -6,40 +6,29 @@ class PostImportRunner
def run def run
normalised_rows = PostImportRowNormaliser.normalise!(@rows) normalised_rows = PostImportRowNormaliser.normalise!(@rows)
previews = PostImportPreviewer.new.preview_rows(rows: normalised_rows,
fetch_metadata: false)
preview_map = previews.index_by { _1[:source_row] }
results = normalised_rows.map { |row| run_row(row) } results = normalised_rows.map do |row|
{ run_row(row, preview_map.fetch(row['source_row']))
created: results.count { _1[:status] == 'created' }, end
{ created: results.count { _1[:status] == 'created' },
skipped: results.count { _1[:status] == 'skipped' }, skipped: results.count { _1[:status] == 'skipped' },
failed: results.count { _1[:status] == 'failed' }, failed: results.count { _1[:status] == 'failed' },
rows: results, rows: results }
}
end end
private private
def run_row row def run_row row, preview
attributes = row.fetch('attributes', { }).transform_keys { _1.to_s.underscore } attributes = row.fetch('attributes', { }).transform_keys { _1.to_s.underscore }
preview = PostImportPreviewer.new.preview_rows( return row.slice('source_row').merge(status: 'skipped') if preview[:skip_reason] == 'existing'
rows: [{
source_row: row['source_row'],
url: row['url'],
attributes:,
provenance: row['provenance'],
tag_sources: row['tag_sources'],
}],
fetch_metadata: false).first
if Array(preview.dig(:field_warnings, 'url')).include?(PostImportPreviewer::EXISTING_SKIP_WARNING)
return row.slice('source_row').merge(status: 'skipped')
end
if preview[:validation_errors].present? return { source_row: row['source_row'],
return { status: 'failed',
source_row: row['source_row'], errors: preview[:validation_errors] } if preview[:validation_errors].present?
status: 'failed',
errors: preview[:validation_errors],
}
end
attributes['tags'] = preview[:attributes]['tags'] attributes['tags'] = preview[:attributes]['tags']
attributes['url'] = row['url'] attributes['url'] = row['url']
@@ -48,15 +37,22 @@ class PostImportRunner
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e
{ 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 Tag::NicoTagNormalisationError rescue Tag::NicoTagNormalisationError
{ source_row: row['source_row'], status: 'failed', errors: { tags: ['ニコニコ・タグは直接指定できません.'] } } { source_row: row['source_row'],
status: 'failed',
errors: { tags: ['ニコニコ・タグは直接指定できません.'] } }
rescue Tag::DeprecatedTagNormalisationError rescue Tag::DeprecatedTagNormalisationError
{ source_row: row['source_row'], status: 'failed', errors: { tags: ['廃止済みタグは付与できません.'] } } { source_row: row['source_row'],
status: 'failed',
errors: { tags: ['廃止済みタグは付与できません.'] } }
rescue ArgumentError, PostCreator::VideoMsParseError rescue ArgumentError, PostCreator::VideoMsParseError
{ source_row: row['source_row'], status: 'failed', errors: { base: ['入力値が不正です.'] } } { source_row: row['source_row'],
status: 'failed',
errors: { base: ['入力値が不正です.'] } }
rescue StandardError => e rescue StandardError => e
Rails.logger.error( Rails.logger.error("post_import_runner_failure #{ { error: e.class.name,
"post_import_runner_failure #{ { error: e.class.name, message: e.message }.to_json }", message: e.message }.to_json }")
) { source_row: row['source_row'],
{ source_row: row['source_row'], status: 'failed', errors: { base: ['登録中にエラーが発生しました.'] } } status: 'failed',
errors: { base: ['登録中にエラーが発生しました.'] } }
end end
end end
+34 -11
ファイルの表示
@@ -43,11 +43,27 @@ const originOf = (
): PostImportOrigin => ): PostImportOrigin =>
row.provenance[field] ?? 'automatic' row.provenance[field] ?? 'automatic'
const originalCreatedOrigin = (row: PostImportRow): PostImportOrigin => const changedOrigin = (
originOf (row, 'originalCreatedFrom') === 'manual' changed: boolean,
|| originOf (row, 'originalCreatedBefore') === 'manual' row: PostImportRow,
field: string,
): PostImportOrigin =>
changed ? 'manual' : originOf (row, field)
const originalCreatedOrigin = (
row: PostImportRow,
originalDraft: Draft,
draft: Draft,
): PostImportOrigin =>
originalDraft.originalCreatedFrom !== draft.originalCreatedFrom
|| originalDraft.originalCreatedBefore !== draft.originalCreatedBefore
? 'manual' ? 'manual'
: 'automatic' : (
originOf (row, 'originalCreatedFrom') === 'manual'
|| originOf (row, 'originalCreatedBefore') === 'manual'
? 'manual'
: 'automatic'
)
const buildDraft = (row: PostImportRow): Draft => ({ const buildDraft = (row: PostImportRow): Draft => ({
url: row.url, url: row.url,
@@ -78,6 +94,10 @@ const PostImportRowDialog: FC<Props> = (
if (!(row) || !(draft)) if (!(row) || !(draft))
return null return null
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,
value: Draft[Key], value: Draft[Key],
@@ -117,21 +137,21 @@ const PostImportRowDialog: FC<Props> = (
<DialogTextField <DialogTextField
label="URL" label="URL"
value={draft.url} value={draft.url}
origin={originOf (row, 'url')} origin={fieldOrigin ('url')}
warnings={row.fieldWarnings.url} warnings={row.fieldWarnings.url}
errors={groupedMessages (row.validationErrors.url, row.importErrors?.url)} errors={groupedMessages (row.validationErrors.url, row.importErrors?.url)}
onChange={value => update ('url', value)}/> onChange={value => update ('url', value)}/>
<DialogTextField <DialogTextField
label="タイトル" label="タイトル"
value={draft.title} value={draft.title}
origin={originOf (row, 'title')} origin={fieldOrigin ('title')}
warnings={row.fieldWarnings.title} warnings={row.fieldWarnings.title}
errors={groupedMessages (row.validationErrors.title, row.importErrors?.title)} errors={groupedMessages (row.validationErrors.title, row.importErrors?.title)}
onChange={value => update ('title', value)}/> onChange={value => update ('title', value)}/>
<DialogTextField <DialogTextField
label="サムネール基底 URL" label="サムネール基底 URL"
value={draft.thumbnailBase} value={draft.thumbnailBase}
origin={originOf (row, 'thumbnailBase')} origin={fieldOrigin ('thumbnailBase')}
warnings={row.fieldWarnings.thumbnailBase} warnings={row.fieldWarnings.thumbnailBase}
errors={groupedMessages ( errors={groupedMessages (
row.validationErrors.thumbnailBase, row.validationErrors.thumbnailBase,
@@ -139,7 +159,10 @@ const PostImportRowDialog: FC<Props> = (
)} )}
onChange={value => update ('thumbnailBase', value)}/> onChange={value => update ('thumbnailBase', value)}/>
<PostOriginalCreatedTimeField <PostOriginalCreatedTimeField
labelAddon={<PostImportStatusBadge value={originalCreatedOrigin (row)}/>} 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}
@@ -155,7 +178,7 @@ const PostImportRowDialog: FC<Props> = (
<DialogTextField <DialogTextField
label="動画時間" label="動画時間"
value={draft.duration} value={draft.duration}
origin={originOf (row, 'duration')} origin={fieldOrigin ('duration')}
errors={groupedMessages ( errors={groupedMessages (
row.validationErrors.duration, row.validationErrors.duration,
row.validationErrors.videoMs, row.validationErrors.videoMs,
@@ -166,14 +189,14 @@ const PostImportRowDialog: FC<Props> = (
<DialogAreaField <DialogAreaField
label="タグ" label="タグ"
value={draft.tags} value={draft.tags}
origin={originOf (row, 'tags')} origin={fieldOrigin ('tags')}
warnings={row.fieldWarnings.tags} warnings={row.fieldWarnings.tags}
errors={groupedMessages (row.validationErrors.tags, row.importErrors?.tags)} errors={groupedMessages (row.validationErrors.tags, row.importErrors?.tags)}
onChange={value => update ('tags', value)}/> onChange={value => update ('tags', value)}/>
<DialogTextField <DialogTextField
label="親投稿" label="親投稿"
value={draft.parentPostIds} value={draft.parentPostIds}
origin={originOf (row, 'parentPostIds')} origin={fieldOrigin ('parentPostIds')}
errors={groupedMessages ( errors={groupedMessages (
row.validationErrors.parentPostIds, row.validationErrors.parentPostIds,
row.importErrors?.parentPostIds, row.importErrors?.parentPostIds,
+2 -18
ファイルの表示
@@ -1,7 +1,6 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import PrefetchLink from '@/components/PrefetchLink'
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 { effectivePostImportStatus } from '@/components/posts/import/postImportRowStatus'
@@ -71,10 +70,9 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
<div <div
className={cn ( className={cn (
'hidden items-center gap-4 rounded-lg border p-4 md:grid', 'hidden items-center gap-4 rounded-lg border p-4 md:grid',
'md:grid-cols-[4rem_5rem_minmax(0,1fr)_auto_auto_auto]', 'md:grid-cols-[4rem_5rem_minmax(0,1fr)_auto_auto]',
toneClass (row), toneClass (row),
'hover:bg-slate-50', 'transition-shadow hover:shadow-sm',
'dark:hover:bg-neutral-800',
)}> )}>
<div className="space-y-1"> <div className="space-y-1">
<div className="text-sm font-medium">#{row.sourceRow}</div> <div className="text-sm font-medium">#{row.sourceRow}</div>
@@ -96,13 +94,6 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
{summaryDate (row) || '日時未取得'} {summaryDate (row) || '日時未取得'}
{row.attributes.duration ? ` / ${ row.attributes.duration }` : ''} {row.attributes.duration ? ` / ${ row.attributes.duration }` : ''}
</div> </div>
{row.createdPostId && (
<PrefetchLink
to={`/posts/${ row.createdPostId }`}
className="inline-block text-xs text-sky-700 underline
dark:text-sky-300">
稿 #{row.createdPostId}
</PrefetchLink>)}
{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}
@@ -145,13 +136,6 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<PostImportStatusBadge value={effectiveStatus}/> <PostImportStatusBadge value={effectiveStatus}/>
</div> </div>
{row.createdPostId && (
<PrefetchLink
to={`/posts/${ row.createdPostId }`}
className="inline-block text-xs text-sky-700 underline
dark:text-sky-300">
稿 #{row.createdPostId}
</PrefetchLink>)}
{error && ( {error && (
<div className="text-xs text-red-700 dark:text-red-200"> <div className="text-xs text-red-700 dark:text-red-200">
{error} {error}
+4 -14
ファイルの表示
@@ -2,22 +2,12 @@ import { cn } from '@/lib/utils'
import type { FC } from 'react' import type { FC } from 'react'
import type { PostImportOrigin } from '@/lib/postImportSession' import type { PostImportBadgeValue } from '@/components/posts/import/postImportRowStatus'
type BadgeValue =
'ready'
| 'warning'
| 'error'
| 'pending'
| 'created'
| 'skipped'
| 'failed'
| PostImportOrigin
type Props = { type Props = {
value: BadgeValue } value: PostImportBadgeValue }
const LABELS: Record<BadgeValue, string> = { const LABELS: Record<PostImportBadgeValue, string> = {
ready: '登録可能', ready: '登録可能',
warning: '警告', warning: '警告',
error: '要修正', error: '要修正',
@@ -28,7 +18,7 @@ const LABELS: Record<BadgeValue, string> = {
automatic: '自動取得', automatic: '自動取得',
manual: '手修正' } manual: '手修正' }
const STYLES: Record<BadgeValue, string[]> = { const STYLES: Record<PostImportBadgeValue, string[]> = {
ready: [ ready: [
'border-emerald-300 bg-emerald-50 text-emerald-700', 'border-emerald-300 bg-emerald-50 text-emerald-700',
'dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-200'], 'dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-200'],
+7 -1
ファイルの表示
@@ -1,4 +1,5 @@
import type { PostImportRow } from '@/lib/postImportSession' import type { PostImportOrigin,
PostImportRow } from '@/lib/postImportSession'
export type PostImportEffectiveStatus = export type PostImportEffectiveStatus =
'created' 'created'
@@ -8,6 +9,11 @@ export type PostImportEffectiveStatus =
| 'warning' | 'warning'
| 'ready' | 'ready'
export type PostImportBadgeValue =
PostImportEffectiveStatus
| 'pending'
| PostImportOrigin
export const effectivePostImportStatus = ( export const effectivePostImportStatus = (
row: PostImportRow, row: PostImportRow,
+22 -4
ファイルの表示
@@ -5,6 +5,7 @@ export type PostImportStatus =
| 'created' | 'created'
| 'skipped' | 'skipped'
| 'failed' | 'failed'
export type PostImportSkipReason = 'existing'
export type PostImportResultStatus = 'created' | 'skipped' | 'failed' export type PostImportResultStatus = 'created' | 'skipped' | 'failed'
export type PostImportAttributeValue = string | number export type PostImportAttributeValue = string | number
@@ -19,6 +20,8 @@ export type PostImportRow = {
provenance: Record<string, PostImportOrigin> provenance: Record<string, PostImportOrigin>
tagSources?: Record<PostImportOrigin, string> tagSources?: Record<PostImportOrigin, string>
status: 'ready' | 'warning' | 'error' status: 'ready' | 'warning' | 'error'
skipReason?: PostImportSkipReason
existingPostId?: number
metadataUrl?: string metadataUrl?: string
createdPostId?: number createdPostId?: number
importStatus?: PostImportStatus } importStatus?: PostImportStatus }
@@ -185,6 +188,12 @@ const isValidOrigin = (
value === 'automatic' || value === 'manual' value === 'automatic' || value === 'manual'
const isValidSkipReason = (
value: unknown,
): value is PostImportSkipReason =>
value === 'existing'
const sanitiseRow = (value: unknown): PostImportRow | null => { const sanitiseRow = (value: unknown): PostImportRow | null => {
if (!(isPlainObject (value))) if (!(isPlainObject (value)))
return null return null
@@ -200,6 +209,8 @@ const sanitiseRow = (value: unknown): PostImportRow | null => {
return null return null
if (value.importStatus != null && !(isValidImportStatus (value.importStatus))) if (value.importStatus != null && !(isValidImportStatus (value.importStatus)))
return null return null
if (value.skipReason != null && !(isValidSkipReason (value.skipReason)))
return null
const validationErrors = ensureStringListRecord (value.validationErrors) const validationErrors = ensureStringListRecord (value.validationErrors)
const fieldWarnings = ensureStringListRecord (value.fieldWarnings) const fieldWarnings = ensureStringListRecord (value.fieldWarnings)
@@ -239,6 +250,9 @@ const sanitiseRow = (value: unknown): PostImportRow | null => {
provenance: value.provenance as Record<string, PostImportOrigin>, provenance: value.provenance as Record<string, PostImportOrigin>,
tagSources: value.tagSources as Record<PostImportOrigin, string> | undefined, tagSources: value.tagSources as Record<PostImportOrigin, string> | undefined,
status: value.status, status: value.status,
skipReason: value.skipReason,
existingPostId:
Number.isInteger (value.existingPostId) ? Number (value.existingPostId) : undefined,
metadataUrl: typeof value.metadataUrl === 'string' ? value.metadataUrl : undefined, metadataUrl: typeof value.metadataUrl === 'string' ? value.metadataUrl : undefined,
createdPostId: createdPostId:
Number.isInteger (value.createdPostId) ? Number (value.createdPostId) : undefined, Number.isInteger (value.createdPostId) ? Number (value.createdPostId) : undefined,
@@ -422,6 +436,8 @@ export const mergeValidatedImportRows = (
attributes: row.attributes, attributes: row.attributes,
provenance: row.provenance, provenance: row.provenance,
tagSources: row.tagSources, tagSources: row.tagSources,
skipReason: row.skipReason,
existingPostId: row.existingPostId,
fieldWarnings, fieldWarnings,
baseWarnings: baseWarnings:
row.baseWarnings.length > 0 || row.metadataUrl !== previous.metadataUrl row.baseWarnings.length > 0 || row.metadataUrl !== previous.metadataUrl
@@ -469,6 +485,8 @@ export const mergePreviewImportRows = (
attributes: mergedAttributes, attributes: mergedAttributes,
provenance: mergedProvenance, provenance: mergedProvenance,
tagSources: mergedTagSources, tagSources: mergedTagSources,
skipReason: row.skipReason,
existingPostId: row.existingPostId,
createdPostId: previous.createdPostId, createdPostId: previous.createdPostId,
importStatus: previous.importStatus, importStatus: previous.importStatus,
importErrors: previous.importErrors } importErrors: previous.importErrors }
@@ -584,8 +602,8 @@ export const validateImportSource = (
} }
const hasSkipWarning = (row: PostImportRow): boolean => const hasSkipReason = (row: PostImportRow): boolean =>
(row.fieldWarnings.url ?? []).includes ('既存投稿のためスキップします.') row.skipReason === 'existing'
export const submittableImportRows = (rows: PostImportRow[]): PostImportRow[] => export const submittableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
@@ -594,7 +612,7 @@ export const submittableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
return false return false
if (row.importStatus === 'skipped') if (row.importStatus === 'skipped')
return false return false
if (hasSkipWarning (row)) if (hasSkipReason (row))
return false return false
if (row.importStatus === 'failed') if (row.importStatus === 'failed')
return false return false
@@ -609,6 +627,6 @@ export const reviewSummaryCounts = (rows: PostImportRow[]) => ({
&& Object.keys (row.validationErrors ?? { }).length === 0).length, && Object.keys (row.validationErrors ?? { }).length === 0).length,
invalid: rows.filter (row => Object.keys (row.validationErrors ?? { }).length > 0).length, invalid: rows.filter (row => Object.keys (row.validationErrors ?? { }).length > 0).length,
skipPlanned: rows.filter (row => skipPlanned: rows.filter (row =>
hasSkipWarning (row) && row.importStatus !== 'created').length, hasSkipReason (row) && row.importStatus !== 'created').length,
created: rows.filter (row => row.importStatus === 'created').length, created: rows.filter (row => row.importStatus === 'created').length,
failed: rows.filter (row => row.importStatus === 'failed').length }) failed: rows.filter (row => row.importStatus === 'failed').length })
-7
ファイルの表示
@@ -192,13 +192,6 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
<div className="text-xs text-neutral-500 dark:text-neutral-400"> <div className="text-xs text-neutral-500 dark:text-neutral-400">
{row.url} {row.url}
</div> </div>
{row.createdPostId && (
<PrefetchLink
to={`/posts/${ row.createdPostId }`}
className="inline-block text-xs text-sky-700 underline
dark:text-sky-300">
稿 #{row.createdPostId}
</PrefetchLink>)}
<FieldError messages={Object.values (row.importErrors ?? { }).flat ()}/> <FieldError messages={Object.values (row.importErrors ?? { }).flat ()}/>
</div> </div>
+15 -2
ファイルの表示
@@ -29,6 +29,8 @@ import type { User } from '@/types'
type Props = { user: User | null } type Props = { user: User | null }
const MAX_ROWS = 100 const MAX_ROWS = 100
const SOURCE_ERROR_ID = 'post-import-source-error'
const SOURCE_ISSUES_ID = 'post-import-source-issues'
const PostImportSourcePage: FC<Props> = ({ user }) => { const PostImportSourcePage: FC<Props> = ({ user }) => {
@@ -45,6 +47,11 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
const lineCount = countImportSourceLines (source) const lineCount = countImportSourceLines (source)
const messages = sourceError ? [sourceError] : [] const messages = sourceError ? [sourceError] : []
const sourceDescribedBy = [
sourceError ? SOURCE_ERROR_ID : null,
sourceIssues.length > 0 ? SOURCE_ISSUES_ID : null]
.filter (_1 => _1 != null)
.join (' ')
useEffect (() => { useEffect (() => {
cleanupExpiredPostImportSessions (message => cleanupExpiredPostImportSessions (message =>
@@ -134,6 +141,8 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
<textarea <textarea
value={source} value={source}
rows={14} rows={14}
aria-invalid={messages.length > 0 || sourceIssues.length > 0}
aria-describedby={sourceDescribedBy || undefined}
className={inputClass ( className={inputClass (
messages.length > 0 || sourceIssues.length > 0, messages.length > 0 || sourceIssues.length > 0,
'font-mono text-sm', 'font-mono text-sm',
@@ -150,8 +159,12 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
setSourceIssues ([]) setSourceIssues ([])
}}/>)} }}/>)}
</FormField> </FormField>
<FieldError messages={messages}/> <div id={messages.length > 0 ? SOURCE_ERROR_ID : undefined}>
<ul className="space-y-2 text-sm text-red-700 dark:text-red-300"> <FieldError messages={messages}/>
</div>
<ul
id={SOURCE_ISSUES_ID}
className="space-y-2 text-sm text-red-700 dark:text-red-300">
{sourceIssues.map (issue => ( {sourceIssues.map (issue => (
<li key={`${ issue.sourceRow }-${ issue.message }-${ issue.url }`}> <li key={`${ issue.sourceRow }-${ issue.message }-${ issue.url }`}>
<div>{issue.sourceRow} : {issue.message}</div> <div>{issue.sourceRow} : {issue.message}</div>
+2 -2
ファイルの表示
@@ -176,7 +176,7 @@ const PostNewPage: FC<Props> = ({ user }) => {
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={fetchTitle} onClick={() => void fetchTitle()}
disabled={!(url) || titleLoading}> disabled={!(url) || titleLoading}>
</Button> </Button>
@@ -193,7 +193,7 @@ const PostNewPage: FC<Props> = ({ user }) => {
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={fetchThumbnail} onClick={() => void fetchThumbnail()}
disabled={!(url) || thumbnailLoading}> disabled={!(url) || thumbnailLoading}>
</Button> </Button>