このコミットが含まれているのは:
2026-07-15 20:51:24 +09:00
コミット 025f49cbcb
11個のファイルの変更157行の追加33行の削除
+5 -2
ファイルの表示
@@ -1,3 +1,4 @@
require 'time'
require 'timeout' require 'timeout'
class PostImportPreviewer class PostImportPreviewer
@@ -86,7 +87,8 @@ class PostImportPreviewer
if row[:metadata_url].present? && row[:metadata_url] != url_for_metadata if row[:metadata_url].present? && row[:metadata_url] != url_for_metadata
clear_automatic_values!(attributes, provenance, tag_sources) clear_automatic_values!(attributes, provenance, tag_sources)
clear_fetch_warnings!(field_warnings) field_warnings = { }
base_warnings = [ ]
end end
if validation_errors.blank? && normal_url.present? && existing_post if validation_errors.blank? && normal_url.present? && existing_post
@@ -248,7 +250,8 @@ class PostImportPreviewer
def sanitise_metadata_time value def sanitise_metadata_time value
return nil unless value.is_a?(String) return nil unless value.is_a?(String)
Time.zone.parse(value)&.iso8601 time = Time.iso8601(value)
time.nsec.zero? ? time.iso8601 : time.iso8601(9)
rescue ArgumentError, TypeError rescue ArgumentError, TypeError
nil nil
end end
+27 -9
ファイルの表示
@@ -78,8 +78,12 @@ class PostImportRowNormaliser
private_class_method :normalise_source_row! private_class_method :normalise_source_row!
def self.normalise_url! value def self.normalise_url! value
raise ArgumentError, 'URL の形式が不正です.' unless value.is_a?(String) unless value.is_a?(String)
raise ArgumentError, 'URL が長すぎます.' if value.bytesize > PostImportUrlListParser::MAX_URL_BYTES raise ArgumentError, 'URL の形式が不正です.'
end
if value.bytesize > PostImportUrlListParser::MAX_URL_BYTES
raise ArgumentError, 'URL が長すぎます.'
end
end end
private_class_method :normalise_url! private_class_method :normalise_url!
@@ -112,20 +116,32 @@ class PostImportRowNormaliser
private_class_method :normalise_attributes! private_class_method :normalise_attributes!
def self.normalise_provenance! provenance def self.normalise_provenance! provenance
raise ArgumentError, 'provenance の形式が不正です.' unless provenance.is_a?(Hash) unless provenance.is_a?(Hash)
raise ArgumentError, 'provenance の形式が不正です.'
end
allowed = ATTRIBUTE_FIELDS + ['url'] allowed = ATTRIBUTE_FIELDS + ['url']
raise ArgumentError, '値の由来が不正です.' unless (provenance.keys - allowed).empty? unless (provenance.keys - allowed).empty?
raise ArgumentError, '値の由来が不正です.' unless provenance.values.all? { ORIGINS.include?(_1) } raise ArgumentError, '値の由来が不正です.'
end
unless provenance.values.all? { ORIGINS.include?(_1) }
raise ArgumentError, '値の由来が不正です.'
end
end end
private_class_method :normalise_provenance! private_class_method :normalise_provenance!
def self.normalise_tag_sources! tag_sources def self.normalise_tag_sources! tag_sources
return if tag_sources.nil? return if tag_sources.nil?
raise ArgumentError, 'タグ由来の形式が不正です.' unless tag_sources.is_a?(Hash) unless tag_sources.is_a?(Hash)
raise ArgumentError, 'タグ由来の形式が不正です.' unless (tag_sources.keys - ORIGINS).empty? raise ArgumentError, 'タグ由来の形式が不正です.'
raise ArgumentError, 'タグ由来の形式が不正です.' unless tag_sources.values.all? { _1.is_a?(String) } end
unless (tag_sources.keys - ORIGINS).empty?
raise ArgumentError, 'タグ由来の形式が不正です.'
end
unless tag_sources.values.all? { _1.is_a?(String) }
raise ArgumentError, 'タグ由来の形式が不正です.'
end
if tag_sources.values.any? { _1.bytesize > PostImportUrlListParser::MAX_URL_BYTES } if tag_sources.values.any? { _1.bytesize > PostImportUrlListParser::MAX_URL_BYTES }
raise ArgumentError, 'タグ由来が大きすぎます.' raise ArgumentError, 'タグ由来が大きすぎます.'
end end
@@ -143,7 +159,9 @@ class PostImportRowNormaliser
raise ArgumentError, '警告の形式が不正です.' raise ArgumentError, '警告の形式が不正です.'
end end
field_warnings&.each do |key, values| field_warnings&.each do |key, values|
raise ArgumentError, '警告の形式が不正です.' unless ATTRIBUTE_FIELDS.include?(key) || key == 'url' unless ATTRIBUTE_FIELDS.include?(key) || key == 'url'
raise ArgumentError, '警告の形式が不正です.'
end
unless values.is_a?(Array) && values.all? { _1.is_a?(String) } unless values.is_a?(Array) && values.all? { _1.is_a?(String) }
raise ArgumentError, '警告の形式が不正です.' raise ArgumentError, '警告の形式が不正です.'
end end
+24 -6
ファイルの表示
@@ -26,7 +26,8 @@ class PostImportRunner
attributes = row.fetch('attributes', { }).transform_keys { _1.to_s.underscore } attributes = row.fetch('attributes', { }).transform_keys { _1.to_s.underscore }
return { source_row: row['source_row'], return { source_row: row['source_row'],
status: 'failed', status: 'failed',
errors: preview[:validation_errors] } if preview[:validation_errors].present? errors: preview[:validation_errors],
recoverable: true } if preview[:validation_errors].present?
if preview[:skip_reason] == 'existing' if preview[:skip_reason] == 'existing'
return { source_row: row['source_row'], return { source_row: row['source_row'],
status: 'skipped', status: 'skipped',
@@ -45,8 +46,13 @@ class PostImportRunner
existing_post_id: existing_post.id } existing_post_id: existing_post.id }
end end
{ source_row: row['source_row'], status: 'failed', errors: e.record.errors.to_hash } { source_row: row['source_row'],
rescue ActiveRecord::RecordNotUnique status: 'failed',
errors: e.record.errors.to_hash,
recoverable: true }
rescue ActiveRecord::RecordNotUnique => e
raise unless url_record_not_unique?(e)
existing_post = existing_post_for_race(row) existing_post = existing_post_for_race(row)
raise unless existing_post raise unless existing_post
@@ -56,15 +62,23 @@ class PostImportRunner
rescue Tag::NicoTagNormalisationError rescue Tag::NicoTagNormalisationError
{ source_row: row['source_row'], { source_row: row['source_row'],
status: 'failed', status: 'failed',
errors: { tags: ['ニコニコ・タグは直接指定できません.'] } } errors: { tags: ['ニコニコ・タグは直接指定できません.'] },
recoverable: true }
rescue Tag::DeprecatedTagNormalisationError rescue Tag::DeprecatedTagNormalisationError
{ source_row: row['source_row'], { source_row: row['source_row'],
status: 'failed', status: 'failed',
errors: { tags: ['廃止済みタグは付与できません.'] } } errors: { tags: ['廃止済みタグは付与できません.'] },
recoverable: true }
rescue PostCreator::VideoMsParseError
{ source_row: row['source_row'],
status: 'failed',
errors: { duration: ['動画時間の記法が不正です.'] },
recoverable: true }
rescue ArgumentError rescue ArgumentError
{ source_row: row['source_row'], { source_row: row['source_row'],
status: 'failed', status: 'failed',
errors: { base: ['入力値が不正です.'] } } errors: { base: ['入力値が不正です.'] },
recoverable: true }
rescue StandardError => e rescue StandardError => e
Rails.logger.error("post_import_runner_failure #{ { error: e.class.name, Rails.logger.error("post_import_runner_failure #{ { error: e.class.name,
message: e.message }.to_json }") message: e.message }.to_json }")
@@ -83,4 +97,8 @@ class PostImportRunner
Post.find_by(url: normal_url) Post.find_by(url: normal_url)
end end
def url_record_not_unique? error
error.message.include?('index_posts_on_url')
end
end end
+12 -3
ファイルの表示
@@ -1,3 +1,5 @@
require 'time'
class PostMetadataFetcher class PostMetadataFetcher
TIMESTAMP_PATTERN = TIMESTAMP_PATTERN =
Regexp.new( Regexp.new(
@@ -26,8 +28,8 @@ class PostMetadataFetcher
{ title: metadata[:title], { title: metadata[:title],
thumbnail_base: thumbnail_base:
Preview::KnownSiteExtractor.thumbnail_url(uri) || metadata[:image_url], Preview::KnownSiteExtractor.thumbnail_url(uri) || metadata[:image_url],
original_created_from: created_range&.first&.iso8601, original_created_from: serialise_time(created_range&.first),
original_created_before: created_range&.last&.iso8601, original_created_before: serialise_time(created_range&.last),
duration: duration&.to_f&.then { _1.positive? ? (_1 * 1_000).round : nil }, duration: duration&.to_f&.then { _1.positive? ? (_1 * 1_000).round : nil },
tags: platform_tags.join(' ') } tags: platform_tags.join(' ') }
end end
@@ -114,9 +116,16 @@ class PostMetadataFetcher
Rational(value.to_i, 10**value.length) Rational(value.to_i, 10**value.length)
end end
def self.serialise_time value
return nil if value.nil?
value.nsec.zero? ? value.iso8601 : value.iso8601(9)
end
private_class_method :platform_tags, private_class_method :platform_tags,
:original_created_range, :original_created_range,
:parse_timestamp_range, :parse_timestamp_range,
:parse_offset, :parse_offset,
:fractional_seconds :fractional_seconds,
:serialise_time
end end
+2 -2
ファイルの表示
@@ -73,7 +73,7 @@ const PostImportRowDialog: FC<Props> = (
const [resetRequested, setResetRequested] = useState (false) const [resetRequested, setResetRequested] = useState (false)
useEffect (() => { useEffect (() => {
if (open && row) if (open && row != null)
{ {
setDraft (buildDraft (row)) setDraft (buildDraft (row))
setResetRequested (false) setResetRequested (false)
@@ -90,7 +90,7 @@ const PostImportRowDialog: FC<Props> = (
value: Draft[Key], value: Draft[Key],
) => { ) => {
setDraft (current => setDraft (current =>
current ? { ...current, [key]: value } : current) current != null ? { ...current, [key]: value } : current)
} }
const save = async () => { const save = async () => {
+4 -2
ファイルの表示
@@ -14,6 +14,9 @@ const buildResetSnapshot = (row: PostImportRow) => ({
tagSources: { tagSources: {
automatic: row.tagSources?.automatic ?? '', automatic: row.tagSources?.automatic ?? '',
manual: row.tagSources?.manual ?? '' }, manual: row.tagSources?.manual ?? '' },
fieldWarnings: Object.fromEntries (
Object.entries (row.fieldWarnings).map (([key, values]) => [key, [...values]])),
baseWarnings: [...row.baseWarnings],
metadataUrl: row.metadataUrl }) metadataUrl: row.metadataUrl })
@@ -73,8 +76,7 @@ export const mergeValidatedImportRows = (
if ( if (
previous.importStatus === 'created' previous.importStatus === 'created'
|| previous.importStatus === 'skipped' || previous.importStatus === 'skipped'
|| previous.importStatus === 'failed' || previous.importStatus === 'failed')
)
return previous return previous
const row = validatedMap.get (previous.sourceRow) const row = validatedMap.get (previous.sourceRow)
+11
ファイルの表示
@@ -21,6 +21,7 @@ const ATTRIBUTE_KEYS = [
'parentPostIds'] as const 'parentPostIds'] as const
const PROVENANCE_KEYS = [...ATTRIBUTE_KEYS, 'url'] as const const PROVENANCE_KEYS = [...ATTRIBUTE_KEYS, 'url'] as const
const TAG_SOURCE_KEYS = ['automatic', 'manual'] as const const TAG_SOURCE_KEYS = ['automatic', 'manual'] as const
const WARNING_KEYS = [...ATTRIBUTE_KEYS, 'url'] as const
const isPlainObject = (value: unknown): value is Record<string, unknown> => const isPlainObject = (value: unknown): value is Record<string, unknown> =>
@@ -162,6 +163,14 @@ const sanitiseResetSnapshot = (value: unknown): PostImportResetSnapshot | null =
return null return null
if (!(Object.values (value.tagSources).every (_1 => typeof _1 === 'string'))) if (!(Object.values (value.tagSources).every (_1 => typeof _1 === 'string')))
return null return null
const fieldWarnings = ensureStringListRecord (value.fieldWarnings)
if (fieldWarnings == null)
return null
if (!(hasOnlyKeys (fieldWarnings, WARNING_KEYS)))
return null
if (!(Array.isArray (value.baseWarnings))
|| !(value.baseWarnings.every (_1 => typeof _1 === 'string')))
return null
if (value.metadataUrl != null && typeof value.metadataUrl !== 'string') if (value.metadataUrl != null && typeof value.metadataUrl !== 'string')
return null return null
@@ -170,6 +179,8 @@ const sanitiseResetSnapshot = (value: unknown): PostImportResetSnapshot | null =
attributes: value.attributes as Record<string, string | number>, attributes: value.attributes as Record<string, string | number>,
provenance: value.provenance as Record<string, PostImportOrigin>, provenance: value.provenance as Record<string, PostImportOrigin>,
tagSources: value.tagSources as Record<PostImportOrigin, string>, tagSources: value.tagSources as Record<PostImportOrigin, string>,
fieldWarnings,
baseWarnings: value.baseWarnings,
metadataUrl: value.metadataUrl as string | undefined } metadataUrl: value.metadataUrl as string | undefined }
} }
+4 -1
ファイルの表示
@@ -14,6 +14,8 @@ export type PostImportResetSnapshot = {
attributes: Record<string, PostImportAttributeValue> attributes: Record<string, PostImportAttributeValue>
provenance: Record<string, PostImportOrigin> provenance: Record<string, PostImportOrigin>
tagSources: Record<PostImportOrigin, string> tagSources: Record<PostImportOrigin, string>
fieldWarnings: Record<string, string[]>
baseWarnings: string[]
metadataUrl?: string } metadataUrl?: string }
export type PostImportRow = { export type PostImportRow = {
@@ -48,7 +50,8 @@ export type PostImportResultRow =
| { | {
sourceRow: number sourceRow: number
status: 'failed' status: 'failed'
errors?: Record<string, string[]> } errors?: Record<string, string[]>
recoverable?: boolean }
export type PostImportSession = { export type PostImportSession = {
version: number version: number
+30 -4
ファイルの表示
@@ -120,10 +120,36 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
provenance: target.provenance, provenance: target.provenance,
tagSources: target.tagSources, tagSources: target.tagSources,
metadataUrl: target.metadataUrl }] }) metadataUrl: target.metadataUrl }] })
setSession (current => const mergedRows = mergeImportResults (nextSession.rows, result.rows)
current const recoverableRows = result.rows.filter (row =>
? { ...current, rows: mergeImportResults (current.rows, result.rows) } row.status === 'failed'
: current) && row.recoverable
&& Object.keys (row.errors ?? { }).length > 0)
const nextRows = mergedRows.map (row => {
const recoverable = recoverableRows.find (_1 => _1.sourceRow === row.sourceRow)
if (recoverable == null)
return row
return {
...row,
importStatus: 'pending',
validationErrors: recoverable.errors ?? { },
importErrors: undefined }
})
const recoverableTarget = nextRows.find (_1 => _1.sourceRow === sourceRow)
const resultSession = {
...nextSession,
rows: nextRows,
repairMode: recoverableTarget == null ? 'all' as const : 'failed' as const }
setSession (resultSession)
if (recoverableTarget != null
&& Object.keys (recoverableTarget.validationErrors).length > 0)
{
const saved = savePostImportSession (sessionId, resultSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (saved)
navigate (`/posts/import/${ sessionId }/review?edit=${ sourceRow }`)
return
}
} }
catch catch
{ {
+36 -2
ファイルの表示
@@ -115,7 +115,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
const updateSessionRows = (nextRows: PostImportRow[]) => const updateSessionRows = (nextRows: PostImportRow[]) =>
setSession (current => setSession (current =>
current ? { ...current, rows: nextRows } : current) current != null ? { ...current, rows: nextRows } : current)
const saveDraft = async ( const saveDraft = async (
{ draft, resetRequested, resetSnapshot }: { { draft, resetRequested, resetSnapshot }: {
@@ -136,6 +136,10 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
attributes: { ...resetSnapshot.attributes }, attributes: { ...resetSnapshot.attributes },
provenance: { ...resetSnapshot.provenance }, provenance: { ...resetSnapshot.provenance },
tagSources: { ...resetSnapshot.tagSources }, tagSources: { ...resetSnapshot.tagSources },
fieldWarnings: Object.fromEntries (
Object.entries (resetSnapshot.fieldWarnings)
.map (([key, values]) => [key, [...values]])),
baseWarnings: [...resetSnapshot.baseWarnings],
metadataUrl: resetSnapshot.metadataUrl } metadataUrl: resetSnapshot.metadataUrl }
: editingRow : editingRow
const urlChanged = draft.url !== baseRow.url const urlChanged = draft.url !== baseRow.url
@@ -223,7 +227,37 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
provenance: row.provenance, provenance: row.provenance,
tagSources: row.tagSources, tagSources: row.tagSources,
metadataUrl: row.metadataUrl })) }) metadataUrl: row.metadataUrl })) })
const nextRows = mergeImportResults (mergedRows, result.rows) const mergedResults = mergeImportResults (mergedRows, result.rows)
const recoverableRows = result.rows.filter (row =>
row.status === 'failed'
&& row.recoverable
&& Object.keys (row.errors ?? { }).length > 0)
const nextRows = mergedResults.map (row => {
const recoverable = recoverableRows.find (_1 => _1.sourceRow === row.sourceRow)
if (recoverable == null)
return row
return {
...row,
importStatus: 'pending',
validationErrors: recoverable.errors ?? { },
importErrors: undefined }
})
const firstRecoverable = nextRows.find (row =>
Object.keys (row.validationErrors).length > 0
&& row.importStatus !== 'created')
if (firstRecoverable != null)
{
const repairSession = { ...session,
rows: nextRows,
repairMode: 'failed' as const }
setSession (repairSession)
setDialogMessageRow (firstRecoverable)
const saved = savePostImportSession (sessionId, repairSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (saved)
setSearchParams ({ edit: String (firstRecoverable.sourceRow) })
return
}
const nextSession = { ...session, rows: nextRows, repairMode: 'all' as const } const nextSession = { ...session, rows: nextRows, repairMode: 'all' as const }
setSession (nextSession) setSession (nextSession)
const saved = savePostImportSession (sessionId, nextSession, message => const saved = savePostImportSession (sessionId, nextSession, message =>
+2 -2
ファイルの表示
@@ -56,9 +56,9 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
const editedRef = useRef (false) const editedRef = useRef (false)
const lineCount = countImportSourceLines (source) const lineCount = countImportSourceLines (source)
const messages = sourceError ? [sourceError] : [] const messages = sourceError != null ? [sourceError] : []
const sourceDescribedBy = [ const sourceDescribedBy = [
sourceError ? SOURCE_ERROR_ID : null, sourceError != null ? SOURCE_ERROR_ID : null,
sourceIssues.length > 0 ? SOURCE_ISSUES_ID : null] sourceIssues.length > 0 ? SOURCE_ISSUES_ID : null]
.filter (_1 => _1 != null) .filter (_1 => _1 != null)
.join (' ') .join (' ')