diff --git a/backend/app/controllers/post_imports_controller.rb b/backend/app/controllers/post_imports_controller.rb index 174450f..daa6786 100644 --- a/backend/app/controllers/post_imports_controller.rb +++ b/backend/app/controllers/post_imports_controller.rb @@ -79,7 +79,7 @@ class PostImportsController < ApplicationController raise ArgumentError, '取込件数が多すぎます.' if rows.length > PostImportSourceParser::MAX_ROWS rows.map do |row| - value = ActionController::Parameters.new(row).permit(:source_row, :sourceRow, :url, + value = ActionController::Parameters.new(row).permit(:source_row, :sourceRow, :url, :metadata_url, :metadataUrl, attributes: {}, provenance: {}, tag_sources: {}, fetch_warnings: [], validation_warnings: []) normalised = value.to_h.deep_transform_keys { _1.to_s.underscore } @@ -100,6 +100,12 @@ class PostImportsController < ApplicationController (normalised['tag_sources'].keys - ['automatic', 'mapped', 'fixed', 'manual']).present?) raise ArgumentError, 'タグ由来の形式が不正です.' end + if normalised['tag_sources'].present? && + (!normalised['tag_sources'].values.all?(String) || + normalised['tag_sources'].values.any? { _1.bytesize > PostImportSourceParser::MAX_CELL_BYTES } || + normalised['tag_sources'].values.sum(&:bytesize) > PostImportSourceParser::MAX_CELL_BYTES) + raise ArgumentError, 'タグ由来が大きすぎます.' + end normalised.symbolize_keys end diff --git a/backend/app/services/post_import_previewer.rb b/backend/app/services/post_import_previewer.rb index fe649cc..48d52a4 100644 --- a/backend/app/services/post_import_previewer.rb +++ b/backend/app/services/post_import_previewer.rb @@ -22,6 +22,8 @@ class PostImportPreviewer tag_sources = row[:tag_sources]&.stringify_keys || initial_tag_sources(attributes, provenance) tag_sources['manual'] = attributes['tags'].to_s if provenance['tags'] == 'manual' url = row[:url].presence || values[@url_column].to_s.strip + url_changed = row[:metadata_url].present? && row[:metadata_url] != url + clear_automatic_values!(attributes, provenance, tag_sources) if url_changed provenance['url'] ||= row[:url].present? ? 'manual' : 'mapped' attributes['url'] = url fetch_warnings = Array(row[:fetch_warnings]).dup @@ -49,11 +51,12 @@ class PostImportPreviewer @existing == 'error' ? errors[:url] = ['既存投稿です.'] : validation_warnings << '既存投稿のためスキップします.' end validate_basic_data(attributes, errors) - validate_preview_tags(merged_tags(tag_sources), errors, validation_warnings) + validate_preview_tags(merged_tags(tag_sources, provenance['tags']), errors, validation_warnings) validate_parents(attributes['parent_post_ids'], errors) attributes.delete('url') - attributes['tags'] = merged_tags(tag_sources) + attributes['tags'] = merged_tags(tag_sources, provenance['tags']) { source_row: row[:source_row], url: normal_url || url, attributes:, provenance:, tag_sources:, + metadata_url: url, fetch_warnings:, validation_warnings:, warnings: fetch_warnings + validation_warnings, errors:, status: errors.present? ? 'error' : ((fetch_warnings + validation_warnings).present? ? 'warning' : 'ready'), existing: normal_url.present? && Post.exists?(url: normal_url) } @@ -97,10 +100,19 @@ class PostImportPreviewer 'fixed' => origin == 'fixed' ? attributes['tags'].to_s : '', 'manual' => '' } end - def merged_tags sources + def merged_tags sources, origin = nil + return sources['manual'].to_s if origin == 'manual' + %w[automatic mapped fixed manual].flat_map { sources[_1].to_s.split }.uniq.join(' ') end + def clear_automatic_values! attributes, provenance, tag_sources + %w[title thumbnail_base original_created_from original_created_before duration].each do |field| + attributes[field] = '' if provenance[field] == 'automatic' + end + tag_sources['automatic'] = '' + end + def fetch_metadata url return [{ }, ['URL が空です.']] if url.blank? diff --git a/backend/app/services/post_import_runner.rb b/backend/app/services/post_import_runner.rb index 2627e8a..2641b6b 100644 --- a/backend/app/services/post_import_runner.rb +++ b/backend/app/services/post_import_runner.rb @@ -22,6 +22,7 @@ class PostImportRunner raise ArgumentError, '取込項目が不正です.' unless (attributes.keys - ATTRIBUTE_KEYS).empty? raise ArgumentError, '取込項目が大きすぎます.' if attributes.values.any? { _1.to_s.bytesize > PostImportSourceParser::MAX_CELL_BYTES } raise ArgumentError, 'URL が長すぎます.' if row['url'].to_s.bytesize > PostImportSourceParser::MAX_CELL_BYTES + validate_tag_sources!(row['tag_sources']) preview = PostImportPreviewer.new(parsed: { columns: [], rows: [] }, url_column: 0, mappings: {}, existing: @existing).preview_rows( rows: [{ source_row: row['source_row'], url: row['url'], @@ -46,4 +47,14 @@ class PostImportRunner Rails.logger.error("post_import_runner_failure #{ { error: e.class.name, message: e.message }.to_json }") { source_row: row['source_row'], status: 'failed', errors: { base: ['登録中にエラーが発生しました.'] } } end + + def validate_tag_sources! sources + return if sources.blank? + + sources = sources.to_h + raise ArgumentError unless (sources.keys - %w[automatic mapped fixed manual]).empty? + raise ArgumentError unless sources.values.all?(String) + raise ArgumentError if sources.values.any? { _1.bytesize > PostImportSourceParser::MAX_CELL_BYTES } + raise ArgumentError if sources.values.sum(&:bytesize) > PostImportSourceParser::MAX_CELL_BYTES + end end diff --git a/backend/app/services/post_metadata_fetcher.rb b/backend/app/services/post_metadata_fetcher.rb index 9b37b37..09389ac 100644 --- a/backend/app/services/post_metadata_fetcher.rb +++ b/backend/app/services/post_metadata_fetcher.rb @@ -37,7 +37,8 @@ class PostMetadataFetcher when /\A\d{4}-\d{2}-\d{2}T\d{2}#{ zone }\z/ then from + 1.hour when /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}#{ zone }\z/ then from + 1.minute when /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}#{ zone }\z/ then from + 1.second - when /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+#{ zone }\z/ then from + 0.001 + when /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.(\d+)#{ zone }\z/ + from + (10**(-Regexp.last_match(1).length)) else return nil end [from, before] diff --git a/frontend/src/pages/posts/PostImportPage.tsx b/frontend/src/pages/posts/PostImportPage.tsx index c25a5d1..4b5ad89 100644 --- a/frontend/src/pages/posts/PostImportPage.tsx +++ b/frontend/src/pages/posts/PostImportPage.tsx @@ -31,9 +31,11 @@ type ImportRow = { tagSources?: Record<'automatic' | 'mapped' | 'fixed' | 'manual', string> status: 'ready' | 'warning' | 'error' existing: boolean + metadataUrl?: string importStatus?: 'pending' | 'created' | 'skipped' | 'failed' } type ParsedImport = { columns: Column[], rows: { sourceRow: number, values: string[] }[] } type Phase = 'source' | 'mapping' | 'preview' | 'result' +type ImportResultStatus = 'created' | 'skipped' | 'failed' const detectFormat = (source: string): 'csv' | 'tsv' => @@ -54,7 +56,7 @@ const PostImportPage: FC = ({ user }) => { const [parsed, setParsed] = useState (null) const [columns, setColumns] = useState ([]) const [rows, setRows] = useState ([]) - const [results, setResults] = useState<{ sourceRow: number, status: string, post?: { id: number }, errors?: Record }[]> ([]) + const [results, setResults] = useState<{ sourceRow: number, status: ImportResultStatus, post?: { id: number }, errors?: Record }[]> ([]) const [mappings, setMappings] = useState> ({ }) const [existing, setExisting] = useState<'skip' | 'error'> ('skip') const [loading, setLoading] = useState (false) @@ -181,7 +183,9 @@ const PostImportPage: FC = ({ user }) => { try { const result = await apiPost<{ created: number, skipped: number, failed: number, - rows: { sourceRow: number, status: string, post?: { id: number }, errors?: Record }[] }> ('/posts/import', { rows: targets, existing }) + rows: { sourceRow: number, status: ImportResultStatus, post?: { id: number }, errors?: Record }[] }> ('/posts/import', { rows: targets, existing }) + if (result.rows.some (row => !(['created', 'skipped', 'failed'] as string[]).includes (row.status))) + throw new Error ('不正な登録結果です。') toast ({ title: `登録完了: ${result.created} 件`, description: `スキップ ${result.skipped} 件、失敗 ${result.failed} 件` }) setPhase ('result') @@ -295,15 +299,17 @@ const PostImportPage: FC = ({ user }) => { const RowCard = ({ row, index, update, updateURL }: { row: ImportRow, index: number, update: (index: number, field: string, value: string) => void, updateURL: (index: number, value: string) => void }) => (

行 {row.sourceRow}・{row.status}

+ {row.importStatus === 'created' ?

登録済み。この行は編集・再登録できません。

: <> updateURL (index, value)}/> update (index, 'title', value)}/> update (index, 'thumbnailBase', value)}/> - {row.attributes.thumbnailBase && サムネール { ev.currentTarget.hidden = true }}/>} + update (index, 'originalCreatedFrom', value)}/> update (index, 'originalCreatedBefore', value)}/> update (index, 'duration', value)}/> update (index, 'tags', value)}/> update (index, 'parentPostIds', value)}/> + }
) @@ -324,4 +330,14 @@ const EditableValue = ({ label, value, origin, onCommit }: { label: string, valu } +const ThumbnailPreview = ({ url }: { url: string }) => { + const [failed, setFailed] = useState (false) + useEffect (() => setFailed (false), [url]) + if (!url) + return

サムネール未指定

+ if (failed) + return

サムネールを表示できません

+ return サムネール setFailed (true)}/> +} + export default PostImportPage